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:
morluto authored and GitHub committed 2026-11-15 13:45:23 +08:00
1 parent 0c38229dd3
commit 52d6725d63
8 files changed
+66 -19

No files matched your search

+6
View File
@@ -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")
+5
View File
@@ -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
+4 -2
View File
@@ -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"),
});
+19 -11
View File
@@ -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) {
+10 -1
View File
@@ -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.",
]
: []),
],
},
+9 -1
View File
@@ -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,
+8
View File
@@ -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):