mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
5aaa420080
* feat(annotate): add strict atomic result output * feat(annotate): exit 2 for strict-gate usage and publication errors Adopt the grep convention for the strict annotate gate's exit codes: 0 = approved, 1 = negative human outcome (annotated/dismissed under --require-approval), 2 = the gate itself was misconfigured or could not start/deliver a decision. Previously all usage/startup/validation failures shared exit 1 with "reviewer did not approve", so callers could not tell a denied review from a broken gate. - parseStrictAnnotateOptions failures (bad flag combos, strict flags outside annotate --gate --json) now exit 2 - --result-file preflight failures (missing parent, pre-existing or dangling-symlink destination) now exit 2 - post-decision publication failures (destination raced into existence, hard links unavailable, stdout write failure) now exit 2: they deliver no decision record at all, so the code's own fail-closed handling presents them as environment errors, never as a reviewer outcome -- and never approval, since only 0 means approved - decision outcomes keep 0/1 exactly as before; signal deaths keep 128+n - document the contract in AGENTS.md and the annotate-gates guide Claude-Session: https://claude.ai/code/session_01YXkgsNucxDwAL4GdR4XYRk * fix(annotate): exit 2 for strict-gate startup failures The six startup-failure sites in the annotate path (missing path, unreachable URL, empty folder, ambiguous name, missing/unsupported file, oversized file) run after flag parsing and exited 1. Under --require-approval / --result-file, 1 is the "reviewer requested changes" signal, so a typo'd path made automation misclassify a configuration error as a legitimate rejection. Route those sites through exitAnnotateStartupFailure(), which picks its code from the already-parsed strict options via the new pure helper annotateStartupFailureExitCode(). Non-strict invocations still exit 1 with byte-identical stderr; strict invocations exit STRICT_GATE_ERROR_EXIT_CODE (2). Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS * fix(annotate): emit the strict decision on stdout before publishing it writeResultFile ran before the decision JSON reached stdout. On a filesystem without hard links (exFAT, FAT32, most SMB/NFS, some container bind mounts) publication fails deterministically, the catch exited 2 with nothing written anywhere — and the reviewer's autosaved draft had already been deleted by the feedback flow, so their completed decision was lost. Emit the stdout record first, then publish the result file. Exit semantics are unchanged: a publication failure still exits 2, but the decision has reached stdout by then. Only a stdout write failure now leaves no record at all. Correct the docs and comments that claimed exit 2 delivers no decision record: it means the result *file* was not published. Also document the two publication caveats: the 0600 mode is a no-op on Windows, and the atomic link/rename is not followed by a parent-directory fsync, so publication is atomic but not crash-durable. Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS --------- Co-authored-by: Michael Ramos <mdramos8@gmail.com>
78 lines
2.5 KiB
TypeScript
78 lines
2.5 KiB
TypeScript
import {
|
|
annotateOutcomeExitCode,
|
|
serializeStrictAnnotateResult,
|
|
STRICT_GATE_ERROR_EXIT_CODE,
|
|
writeAnnotateResultFile,
|
|
type AnnotateOutcome,
|
|
} from "./strict-annotate-result";
|
|
|
|
export interface CompleteAnnotateCommandOptions {
|
|
waitForDecision: () => Promise<AnnotateOutcome>;
|
|
settleAfterDecision: () => Promise<void>;
|
|
stopServer: () => void;
|
|
requireApproval: boolean;
|
|
resultFile?: string;
|
|
writeResultFile?: (
|
|
resultFile: string,
|
|
serialized: string,
|
|
) => Promise<void>;
|
|
writeStdout?: (output: string) => Promise<void>;
|
|
emitLegacyOutcome: (result: AnnotateOutcome) => void;
|
|
exit?: (code: number) => void;
|
|
logError?: (message: string) => void;
|
|
}
|
|
|
|
export function writeStdout(output: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
process.stdout.write(output, (error) => {
|
|
if (error) reject(error);
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function completeAnnotateCommand({
|
|
waitForDecision,
|
|
settleAfterDecision,
|
|
stopServer,
|
|
requireApproval,
|
|
resultFile,
|
|
writeResultFile = writeAnnotateResultFile,
|
|
writeStdout: outputWriter = writeStdout,
|
|
emitLegacyOutcome,
|
|
exit = process.exit,
|
|
logError = (message) => console.error(message),
|
|
}: CompleteAnnotateCommandOptions): Promise<void> {
|
|
const result = await waitForDecision();
|
|
await settleAfterDecision();
|
|
stopServer();
|
|
|
|
if (requireApproval || resultFile) {
|
|
const serialized = serializeStrictAnnotateResult(result);
|
|
try {
|
|
// stdout first: the reviewer's autosaved draft is already gone by the
|
|
// time we get here, so their completed decision must reach at least one
|
|
// channel before a result-file publication failure can abort the run.
|
|
// Result-file publication is best-effort on top of that record.
|
|
await outputWriter(`${serialized}\n`);
|
|
if (resultFile) {
|
|
await writeResultFile(resultFile, serialized);
|
|
}
|
|
} catch (error) {
|
|
// The result file was not published (or stdout itself was unwritable):
|
|
// an environment error, not a reviewer outcome. Exit 2 — fail-closed, but
|
|
// distinct from exit 1's "gate ran and the reviewer did not approve".
|
|
// The stdout decision record has already been emitted unless stdout was
|
|
// the thing that failed.
|
|
logError(error instanceof Error ? error.message : String(error));
|
|
exit(STRICT_GATE_ERROR_EXIT_CODE);
|
|
return;
|
|
}
|
|
exit(annotateOutcomeExitCode(result, requireApproval));
|
|
return;
|
|
}
|
|
|
|
emitLegacyOutcome(result);
|
|
exit(0);
|
|
}
|