mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
fix(cutter)!: report undeclared output truncation and document generation as unknown (#10726)
* fix(cutter)!: report undeclared output truncation and document generation as unknown cutter_command filled a missing output_truncated with false and a missing document_generation with the caller's expected_generation, so a reply that did not declare them read as "nothing omitted" and "document unchanged". Both are now null when the plugin did not declare them, with an Evidence limitation for each. A command that never started still reports output_truncated false, because there was no output to omit. The packaged plugin now declares output_truncated on every command reply and the current generation on its not-started replies. BREAKING CHANGE: cutter_command's result fields output_truncated and document_generation are now nullable. A caller that read document_generation as a number must handle null by listing Cutter sessions again before choosing an expected_generation. A caller that read output_truncated as a boolean must treat null as "the output may be incomplete". * test(cutter): cover the unknown-truncation limitation
This commit is contained in:
1 parent
0c38229dd3
commit
52d6725d63
8 files changed
+66
-19
No files matched your search
@@ -556,6 +556,8 @@ class _RequestDispatcher(QObject):
|
||||
"ok": False,
|
||||
"error": "active-document-generation-changed",
|
||||
"execution_state": "not_started",
|
||||
"document_generation": self.generation,
|
||||
"output_truncated": False,
|
||||
})
|
||||
self._record_command_completion(request_id, "not_started")
|
||||
request["_event"].set()
|
||||
@@ -566,6 +568,8 @@ class _RequestDispatcher(QObject):
|
||||
"ok": False,
|
||||
"error": "invalid-command",
|
||||
"execution_state": "not_started",
|
||||
"document_generation": self.generation,
|
||||
"output_truncated": False,
|
||||
})
|
||||
self._record_command_completion(request_id, "not_started")
|
||||
request["_event"].set()
|
||||
@@ -587,6 +591,7 @@ class _RequestDispatcher(QObject):
|
||||
"cutter_version": self.cutter_version,
|
||||
"identity_status": "partial",
|
||||
"execution_state": "complete",
|
||||
"output_truncated": False,
|
||||
})
|
||||
self._record_command_completion(request_id, "complete")
|
||||
else:
|
||||
@@ -598,6 +603,7 @@ class _RequestDispatcher(QObject):
|
||||
"cutter_version": self.cutter_version,
|
||||
"identity_status": "partial",
|
||||
"execution_state": "unknown",
|
||||
"output_truncated": False,
|
||||
"message": "The command raised an exception; it may have produced partial effects. Do not retry automatically.",
|
||||
})
|
||||
self._record_command_completion(request_id, "failed")
|
||||
|
||||
@@ -312,6 +312,11 @@ At most 8 connection threads run. A further accept is closed, and a failure to s
|
||||
A command reply that does not fit keeps its completion state and document
|
||||
generation, and reports an oversized path as unknown. Cutter response frames use this same ceiling and
|
||||
also exclude the terminating LF. A command response the client discards because it exceeds that ceiling stays an unknown execution and sets `output_truncated`, so Evidence records the omitted output. Hopper response reads are unchanged.
|
||||
`cutter_command` reports `output_truncated` and `document_generation` as `null`
|
||||
when the plugin did not declare them, for example when no reply was read, the
|
||||
reply was malformed, or an older plugin omitted them. A null generation means
|
||||
the document may have changed; list sessions again before choosing an
|
||||
`expected_generation`. Evidence carries a limitation for each unknown field.
|
||||
Each bridge request waits 25000 ms. The Cutter hello frame still must arrive
|
||||
within 1 second. The authenticated command frame is read with one second per
|
||||
mebibyte of `REA_BRIDGE_MAX_REQUEST_BYTES`, and that read never waits longer
|
||||
|
||||
@@ -28,10 +28,12 @@ export const cutterCommandResultSchema = z.strictObject({
|
||||
execution_state: z.enum(["not_started", "complete", "failed", "unknown"]),
|
||||
error: z.string().nullable(),
|
||||
message: z.string().nullable(),
|
||||
output_truncated: z.boolean(),
|
||||
/** Null when the bridge did not declare whether output was omitted. */
|
||||
output_truncated: z.boolean().nullable(),
|
||||
cutter_version: z.string().nullable(),
|
||||
current_file: z.string().nullable(),
|
||||
document_generation: z.int().nonnegative(),
|
||||
/** Null when the bridge did not report the generation after the command. */
|
||||
document_generation: z.int().nonnegative().nullable(),
|
||||
identity_status: z.literal("partial"),
|
||||
});
|
||||
|
||||
|
||||
@@ -249,13 +249,15 @@ export interface CutterBridgeExecution {
|
||||
readonly requestId: string;
|
||||
readonly output: unknown;
|
||||
readonly currentFile: string | null;
|
||||
readonly documentGeneration: number;
|
||||
/** Generation the bridge reported after the command; null when it did not. */
|
||||
readonly documentGeneration: number | null;
|
||||
readonly identityStatus: "partial";
|
||||
readonly cutterVersion: string | null;
|
||||
readonly executionState: CutterBridgeExecutionState;
|
||||
readonly error: string | null;
|
||||
readonly message: string | null;
|
||||
readonly outputTruncated: boolean;
|
||||
/** Whether output was omitted; null when the bridge did not declare it. */
|
||||
readonly outputTruncated: boolean | null;
|
||||
readonly completionToken?: string;
|
||||
}
|
||||
|
||||
@@ -547,15 +549,17 @@ export class CutterBridgeClient {
|
||||
requestId,
|
||||
output: null,
|
||||
currentFile: descriptor.current_file,
|
||||
documentGeneration: input.expectedGeneration,
|
||||
// No reply was read, so the generation after the command is unknown.
|
||||
documentGeneration: null,
|
||||
identityStatus: "partial",
|
||||
cutterVersion: descriptor.cutter_version,
|
||||
executionState: "unknown",
|
||||
error: "transport-response-missing",
|
||||
message: `${cause.message}; the command may have completed; do not retry automatically`,
|
||||
// The carried limit is the framed response ceiling. The command was
|
||||
// sent, and the discarded response is omitted output.
|
||||
outputTruncated: cause.limit !== undefined,
|
||||
// The carried limit is the framed response ceiling: the command was
|
||||
// sent and its discarded response is omitted output. Any other
|
||||
// missing reply leaves truncation unknown.
|
||||
outputTruncated: cause.limit === undefined ? null : true,
|
||||
...(completionToken === undefined ? {} : { completionToken }),
|
||||
};
|
||||
}
|
||||
@@ -565,14 +569,14 @@ export class CutterBridgeClient {
|
||||
requestId,
|
||||
output: null,
|
||||
currentFile: descriptor.current_file,
|
||||
documentGeneration: input.expectedGeneration,
|
||||
documentGeneration: null,
|
||||
identityStatus: "partial",
|
||||
cutterVersion: descriptor.cutter_version,
|
||||
executionState: "unknown",
|
||||
error: "malformed-bridge-response",
|
||||
message:
|
||||
"Cutter may have completed the command, but its response was invalid; do not retry automatically",
|
||||
outputTruncated: false,
|
||||
outputTruncated: null,
|
||||
...(completionToken === undefined ? {} : { completionToken }),
|
||||
};
|
||||
const response = parsed.data;
|
||||
@@ -623,14 +627,18 @@ export class CutterBridgeClient {
|
||||
response.current_file === undefined
|
||||
? descriptor.current_file
|
||||
: response.current_file,
|
||||
documentGeneration:
|
||||
response.document_generation ?? input.expectedGeneration,
|
||||
// An undeclared generation or truncation stays unknown: the command
|
||||
// may have changed the document, and an omitted flag is not "false".
|
||||
documentGeneration: response.document_generation ?? null,
|
||||
identityStatus: "partial",
|
||||
cutterVersion: response.cutter_version ?? descriptor.cutter_version,
|
||||
executionState,
|
||||
error: response.ok ? null : (response.error ?? "cutter-command-failed"),
|
||||
message: response.message ?? null,
|
||||
outputTruncated: response.output_truncated ?? false,
|
||||
// A command that never started produced no output to omit.
|
||||
outputTruncated:
|
||||
response.output_truncated ??
|
||||
(executionState === "not_started" ? false : null),
|
||||
...(completionToken === undefined ? {} : { completionToken }),
|
||||
};
|
||||
} catch (cause) {
|
||||
|
||||
@@ -200,10 +200,19 @@ export class CutterBridgeService {
|
||||
"Command completion or its output could not be confirmed; do not retry automatically because the command may have produced partial or persistent effects.",
|
||||
]
|
||||
: []),
|
||||
...(output.outputTruncated
|
||||
...(output.outputTruncated === true
|
||||
? [
|
||||
"The command completed or may have completed, but output exceeded the bridge transport limit and was omitted.",
|
||||
]
|
||||
: output.outputTruncated === null
|
||||
? [
|
||||
"The bridge did not declare whether output was omitted, so the output may be incomplete.",
|
||||
]
|
||||
: []),
|
||||
...(output.documentGeneration === null
|
||||
? [
|
||||
"The bridge did not report the document generation after this command; list Cutter sessions again before selecting an expected_generation.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -47,8 +47,16 @@ const assertUncertainEvidence = async (
|
||||
execution_state: "unknown",
|
||||
error: "transport-response-missing",
|
||||
message: expect.stringContaining("do not retry automatically"),
|
||||
document_generation: 3,
|
||||
// No reply was read, so the generation after the command is unknown.
|
||||
document_generation: null,
|
||||
output_truncated: null,
|
||||
});
|
||||
expect(uncertainEvidence.limitations).toContainEqual(
|
||||
expect.stringMatching(/did not report the document generation/u),
|
||||
);
|
||||
expect(uncertainEvidence.limitations).toContainEqual(
|
||||
expect.stringMatching(/did not declare whether output was omitted/u),
|
||||
);
|
||||
expect(uncertainEvidence.limitations).toContainEqual(
|
||||
expect.stringMatching(
|
||||
/completion.*could not be confirmed.*not retry.*effects/iu,
|
||||
|
||||
@@ -140,7 +140,7 @@ const verifyCutterCancellationAfterDispatch = async (): Promise<void> => {
|
||||
executionState: "unknown",
|
||||
error: "transport-response-missing",
|
||||
message: expect.stringContaining("do not retry automatically"),
|
||||
documentGeneration: 0,
|
||||
documentGeneration: null,
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
@@ -487,7 +487,7 @@ describe("CutterBridgeClient: scenario group 1", () => {
|
||||
executionState: "unknown",
|
||||
error: "transport-response-missing",
|
||||
message: expect.stringContaining("do not retry automatically"),
|
||||
documentGeneration: 0,
|
||||
documentGeneration: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -809,12 +809,13 @@ describe("CutterBridgeClient: scenario group 4", () => {
|
||||
command: "pd 4",
|
||||
json: true,
|
||||
});
|
||||
// An undeclared generation and truncation stay unknown, not inferred.
|
||||
await expect(run()).resolves.toMatchObject({
|
||||
executionState: "complete",
|
||||
output: null,
|
||||
currentFile: "/tmp/input",
|
||||
documentGeneration: 8,
|
||||
outputTruncated: false,
|
||||
documentGeneration: null,
|
||||
outputTruncated: null,
|
||||
});
|
||||
response = {
|
||||
ok: false,
|
||||
|
||||
@@ -176,6 +176,8 @@ class ServeDispatcher:
|
||||
"ok": False,
|
||||
"error": "active-document-generation-changed",
|
||||
"execution_state": "not_started",
|
||||
"document_generation": self.generation,
|
||||
"output_truncated": False,
|
||||
})
|
||||
self.completed_commands[request["request_id"]] = "not_started"
|
||||
elif request.get("kind") == "command" and request.get("command") == "fixture-unknown":
|
||||
@@ -194,6 +196,7 @@ class ServeDispatcher:
|
||||
"cutter_version": self.cutter_version,
|
||||
"identity_status": "partial",
|
||||
"execution_state": "complete",
|
||||
"output_truncated": False,
|
||||
})
|
||||
self.completed_commands[request["request_id"]] = "complete"
|
||||
self.fail_next_ack = True
|
||||
@@ -206,6 +209,7 @@ class ServeDispatcher:
|
||||
"cutter_version": self.cutter_version,
|
||||
"identity_status": "partial",
|
||||
"execution_state": "complete",
|
||||
"output_truncated": False,
|
||||
})
|
||||
self.completed_commands[request["request_id"]] = "complete"
|
||||
self.malformed_next_ack = True
|
||||
@@ -222,6 +226,7 @@ class ServeDispatcher:
|
||||
"cutter_version": self.cutter_version,
|
||||
"identity_status": "partial",
|
||||
"execution_state": "complete",
|
||||
"output_truncated": False,
|
||||
})
|
||||
if request.get("kind") == "command":
|
||||
self.completed_commands[request["request_id"]] = "complete"
|
||||
@@ -376,10 +381,13 @@ class DocumentIdentityTest(unittest.TestCase):
|
||||
stale = handle(kind="command", request_id=str(uuid.uuid4()), expected_generation=0, command="iI")
|
||||
self.assertEqual(stale["error"], "active-document-generation-changed")
|
||||
self.assertEqual(stale["execution_state"], "not_started")
|
||||
self.assertEqual(stale["document_generation"], 1)
|
||||
self.assertIs(stale["output_truncated"], False)
|
||||
self.assertEqual(commands, [])
|
||||
current = handle(kind="command", request_id=str(uuid.uuid4()), expected_generation=1, command="iI")
|
||||
self.assertEqual(current["output"], "observed")
|
||||
self.assertEqual(current["document_generation"], 1)
|
||||
self.assertIs(current["output_truncated"], False)
|
||||
self.assertEqual(commands, ["iI"])
|
||||
|
||||
def test_absent_or_unreadable_document_config_remains_unknown(self):
|
||||
|
||||
Reference in new issue
Block a user