diff --git a/.a5c/processes/issue-880-run-halt-command.inputs.json b/.a5c/processes/issue-880-run-halt-command.inputs.json new file mode 100644 index 0000000000..cb151b03cd --- /dev/null +++ b/.a5c/processes/issue-880-run-halt-command.inputs.json @@ -0,0 +1,99 @@ +{ + "issueNumber": 880, + "baseBranch": "staging", + "implementationBranch": "feat/issue-880-run-halt", + "title": "Add 'babysitter run:halt' command for sealing divergent runs", + "labels": [ + "enhancement", + "sdk", + "effort:medium", + "ready-for-dev", + "feature", + "priority:medium", + "risk:medium" + ], + "summary": "Implement an operator-facing `babysitter run:halt ` command that appends a synthetic terminal event through journal primitives, records an audit reason, rebuilds state, and exposes safe human/JSON output for divergent run recovery.", + "relatedIssues": [ + 879, + 574, + 573, + 191, + 181 + ], + "targetFiles": [ + "packages/sdk/src/cli/main/program.ts", + "packages/sdk/src/cli/main/dispatchRunSession.ts", + "packages/sdk/src/cli/main/runCommands.ts", + "packages/sdk/src/cli/main/argPositionals.ts", + "packages/sdk/src/cli/main/argFlagParsers.ts", + "packages/sdk/src/cli/main/types.ts", + "packages/sdk/src/cli/main/usage.ts", + "packages/sdk/src/cli/main/runCreate.ts", + "packages/sdk/src/cli/main/runState.ts", + "packages/sdk/src/cli/main/runInspection.ts", + "packages/sdk/src/cli/completionProof.ts", + "packages/sdk/src/storage/journal.ts", + "packages/sdk/src/runtime/replay/stateCache.ts", + "packages/sdk/src/runtime/runLifecycleState.ts" + ], + "testTargets": [ + "packages/sdk/src/cli/__tests__/cliRuns.test.ts", + "packages/sdk/src/runtime/__tests__/orchestrateIteration.integration.test.ts", + "packages/sdk/src/runtime/__tests__/effectIndex.test.ts" + ], + "docsTargets": [ + "docs/reference/babysitter_cli_surface_spec.md", + "docs/user-guide/reference/cli-reference.md", + "docs/agent-reference/runtime-and-layout.md", + "packages/sdk/src/prompts/templates/recovery.md", + "packages/sdk/src/prompts/templates/breakpoint-handling.md" + ], + "qualityCommands": [ + "npm run build:sdk", + "npm run test:sdk -- --run packages/sdk/src/cli/__tests__/cliRuns.test.ts", + "npm run test:sdk", + "npm run verify:metadata" + ], + "acceptanceCriteria": [ + "`babysitter run:halt ` is registered, parsed, dispatched, and documented.", + "The command requires a non-empty audit reason and supports JSON output and dry-run behavior.", + "The command validates selected final status and appends exactly one terminal event via `appendEvent` rather than hand-writing journal files.", + "The terminal event records `manual_seal_reason`, command source, requested final status, prior state, and pending-effect summary.", + "Completed seals preserve existing completionProof behavior; failed or halted seals do not expose completionProof.", + "Successful seals rebuild state cache with a manual-seal rebuild reason.", + "Already terminal runs are rejected by default without mutation.", + "Tests cover dry-run, JSON output, reason recording, supported final statuses, already-terminal rejection, state rebuild, and status/events visibility.", + "Docs explain when to use `run:halt` versus `ctx.halt`, `run:recover-process-error`, `run:repair-journal`, and `run:rebuild-state`." + ], + "nonGoals": [ + "Do not implement a broad journal surgery tool.", + "Do not bypass `appendEvent` or `rebuildStateCache`.", + "Do not change normal `ctx.halt(...)` process-authored behavior except where status consumers need to display manual-seal events.", + "Do not add new storage dependencies or database/API infrastructure.", + "Do not add an override for resealing already-terminal runs unless the contract phase receives maintainer approval." + ], + "defaultContractRecommendation": { + "requireReason": true, + "defaultFinalStatus": "halted", + "explicitFinalStatuses": [ + "halted", + "completed", + "failed" + ], + "eventMapping": { + "halted": "RUN_HALTED", + "completed": "RUN_COMPLETED", + "failed": "RUN_FAILED" + }, + "stateRebuildReason": "manual_seal", + "auditFields": [ + "manual_seal_reason", + "sealedBy", + "requestedFinalStatus", + "priorLifecycleState", + "priorLifecycleEvent", + "pendingEffectsSummary" + ] + }, + "maxImplementationAttempts": 3 +} diff --git a/.a5c/processes/issue-880-run-halt-command.mjs b/.a5c/processes/issue-880-run-halt-command.mjs new file mode 100644 index 0000000000..5a7ec0ee94 --- /dev/null +++ b/.a5c/processes/issue-880-run-halt-command.mjs @@ -0,0 +1,680 @@ +/** + * @process repo/issue-880-run-halt-command + * @description Implement issue #880: operator-facing babysitter run:halt for auditable manual sealing of divergent runs. + * @inputs { issueNumber: number, baseBranch: string, implementationBranch: string, targetFiles: string[], testTargets: string[], docsTargets: string[], qualityCommands: string[], maxImplementationAttempts: number } + * @outputs { success: boolean, phases: string[], changedFiles: string[], runtimeCallPaths: string[], contract: object, verification: object, review: object } + * + * Reuse-audit findings (REVIEW BEFORE PROCEEDING): + * - No `.a5c/reuse-audit.json` exists in this checkout. + * - No migrations, API routes, environment variables, or new SDK dependencies are relevant to this SDK CLI recovery command. + * - Existing adjacent CLI recovery commands live in packages/sdk/src/cli/main/runCreate.ts: `run:rebuild-state`, `run:repair-journal`, and `run:recover-process-error`. + * - Existing command registration and parsing surfaces to extend: + * - packages/sdk/src/cli/main/program.ts command allowlist does not include `run:halt`. + * - packages/sdk/src/cli/main/dispatchRunSession.ts dispatches run commands but has no halt handler. + * - packages/sdk/src/cli/main/runCommands.ts exports adjacent run recovery handlers. + * - packages/sdk/src/cli/main/argPositionals.ts maps run recovery commands to `` but has no halt mapping. + * - packages/sdk/src/cli/main/argFlagParsers.ts already has `--reason` for task cancel as `cancelReason`; `run:halt` needs its own reason/final-status parsing without reusing task-cancel semantics accidentally. + * - packages/sdk/src/cli/main/types.ts needs parsed fields for the new command flags. + * - packages/sdk/src/cli/main/usage.ts needs help output. + * - Existing journal/state primitives to reuse: + * - packages/sdk/src/storage/journal.ts `appendEvent` allocates seq/ULID/checksum and stamps harness/session metadata through a per-run append queue. + * - packages/sdk/src/runtime/replay/stateCache.ts `rebuildStateCache` rebuilds derived state and metadata after mutation. + * - packages/sdk/src/cli/completionProof.ts resolves completed-run proof from metadata or derived run id. + * - Existing state consumers already understand first-class `RUN_HALTED`: + * - packages/sdk/src/runtime/runLifecycleState.ts and packages/sdk/src/cli/main/runState.ts derive `halted`. + * - packages/sdk/src/cli/main/runInspection.ts reports halted state without completionProof. + * - Existing tests to extend: + * - packages/sdk/src/cli/__tests__/cliRuns.test.ts covers status, rebuild-state, repair-journal, recover-process-error, halted state, and completionProof behavior. + * - Existing docs/prompt surfaces to update: + * - docs/reference/babysitter_cli_surface_spec.md + * - docs/user-guide/reference/cli-reference.md + * - docs/agent-reference/runtime-and-layout.md + * - packages/sdk/src/prompts/templates/recovery.md + * - packages/sdk/src/prompts/templates/breakpoint-handling.md + * + * Process-library research: + * - Active process library: /home/runner/.a5c/process-library/babysitter-repo/library + * - Relevant library/process patterns inspected: + * - methodologies/superpowers/systematic-debugging + * - methodologies/superpowers/test-driven-development + * - methodologies/superpowers/verification-before-completion + * - methodologies/v-model + * - processes/shared/tdd-triplet.js + * - processes/shared/completeness-gate.js + * - tdd-quality-convergence.js + * - Relevant repo process patterns inspected: + * - .a5c/processes/issue-573-process-runtime-error-recovery.mjs + * - .a5c/processes/issue-574-typed-ctx-halt.mjs + * + * This process intentionally uses agent tasks rather than shell tasks to respect + * this repository's direct-user Babysitter process-authoring override. + * + * @process methodologies/superpowers/systematic-debugging + * @process methodologies/superpowers/test-driven-development + * @process methodologies/superpowers/verification-before-completion + * @process methodologies/v-model + * @process tdd-quality-convergence + * @process repo/issue-573-process-runtime-error-recovery + * @process repo/issue-574-typed-ctx-halt + */ + +import { defineTask } from '@a5c-ai/babysitter-sdk'; + +const DEFAULT_MAX_ATTEMPTS = 3; + +function valueOf(result) { + return result?.value ?? result ?? null; +} + +export async function process(inputs, ctx) { + const issueNumber = inputs?.issueNumber ?? 880; + + const issueContext = await ctx.task(readIssueContextTask, { + ...inputs, + issueNumber, + }, { + key: 'issue-880.read-issue-context', + }); + + const reuseAudit = await ctx.task(reuseAuditTask, { + inputs, + issueContext: valueOf(issueContext), + }, { + key: 'issue-880.reuse-audit', + }); + + const contract = await ctx.task(designManualSealContractTask, { + inputs, + issueContext: valueOf(issueContext), + reuseAudit: valueOf(reuseAudit), + }, { + key: 'issue-880.manual-seal-contract', + }); + + const contractValue = valueOf(contract); + if (contractValue?.requiresMaintainerDecision === true) { + await ctx.breakpoint({ + title: 'Issue #880 Manual Seal Contract Decision', + question: contractValue.question ?? 'Confirm the operator seal terminal-event contract before implementation continues.', + options: [ + 'Proceed with recommended conservative contract', + 'Pause for maintainer guidance', + ], + expert: 'maintainer', + tags: ['issue-880', 'sdk', 'run-halt', 'manual-seal', 'api-contract'], + context: { + issueNumber, + contract: contractValue, + }, + }); + } + + const regressionTests = await ctx.task(authorRegressionTestsTask, { + inputs, + issueContext: valueOf(issueContext), + reuseAudit: valueOf(reuseAudit), + contract: contractValue, + }, { + key: 'issue-880.author-regression-tests', + }); + + let implementation = null; + let verification = null; + let review = null; + const attempts = []; + const maxAttempts = inputs?.maxImplementationAttempts ?? DEFAULT_MAX_ATTEMPTS; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + implementation = await ctx.task(implementRunHaltTask, { + inputs, + issueContext: valueOf(issueContext), + reuseAudit: valueOf(reuseAudit), + contract: contractValue, + regressionTests: valueOf(regressionTests), + previousVerification: valueOf(verification), + previousReview: valueOf(review), + attempt, + }, { + key: `issue-880.implementation.${attempt}`, + }); + + verification = await ctx.task(runVerificationGateTask, { + inputs, + issueContext: valueOf(issueContext), + contract: contractValue, + regressionTests: valueOf(regressionTests), + implementation: valueOf(implementation), + attempt, + }, { + key: `issue-880.verification.${attempt}`, + }); + + review = await ctx.task(reviewRunHaltTask, { + inputs, + issueContext: valueOf(issueContext), + reuseAudit: valueOf(reuseAudit), + contract: contractValue, + regressionTests: valueOf(regressionTests), + implementation: valueOf(implementation), + verification: valueOf(verification), + attempt, + }, { + key: `issue-880.review.${attempt}`, + }); + + attempts.push({ + attempt, + implementation: valueOf(implementation), + verification: valueOf(verification), + review: valueOf(review), + }); + + if ( + valueOf(verification)?.passed === true && + valueOf(verification)?.journalInvariantVerified === true && + valueOf(verification)?.auditTrailVerified === true && + valueOf(review)?.approved === true + ) { + break; + } + } + + if ( + valueOf(verification)?.passed !== true || + valueOf(verification)?.journalInvariantVerified !== true || + valueOf(verification)?.auditTrailVerified !== true || + valueOf(review)?.approved !== true + ) { + await ctx.breakpoint({ + title: 'Issue #880 Final Gate Did Not Pass', + question: 'The run:halt implementation did not satisfy verification and review within the configured attempts. Review failures before continuing?', + options: [ + 'Retry with reviewer feedback', + 'Pause for maintainer guidance', + ], + expert: 'maintainer', + tags: ['issue-880', 'run-halt', 'quality-gate'], + context: { + issueNumber, + verification: valueOf(verification), + review: valueOf(review), + attempts: attempts.length, + }, + }); + } + + const documentation = await ctx.task(documentManualSealWorkflowTask, { + inputs, + issueContext: valueOf(issueContext), + reuseAudit: valueOf(reuseAudit), + contract: contractValue, + implementation: valueOf(implementation), + verification: valueOf(verification), + review: valueOf(review), + }, { + key: 'issue-880.documentation', + }); + + const finalAcceptance = await ctx.task(finalAcceptanceTask, { + inputs, + issueContext: valueOf(issueContext), + reuseAudit: valueOf(reuseAudit), + contract: contractValue, + regressionTests: valueOf(regressionTests), + implementation: valueOf(implementation), + verification: valueOf(verification), + review: valueOf(review), + documentation: valueOf(documentation), + attempts, + }, { + key: 'issue-880.final-acceptance', + }); + + const finalValue = valueOf(finalAcceptance); + return { + success: finalValue?.passed === true, + phases: [ + 'issue-context', + 'reuse-audit-and-runtime-map', + 'manual-seal-contract', + 'red-regression-tests', + 'implementation-loop', + 'verification-gate', + 'code-review', + 'documentation-gate', + 'final-acceptance', + ], + changedFiles: finalValue?.changedFiles ?? valueOf(implementation)?.changedFiles ?? [], + runtimeCallPaths: valueOf(reuseAudit)?.runtimeCallPaths ?? [], + issueContext: valueOf(issueContext), + reuseAudit: valueOf(reuseAudit), + contract: contractValue, + regressionTests: valueOf(regressionTests), + implementation: valueOf(implementation), + verification: valueOf(verification), + review: valueOf(review), + documentation: valueOf(documentation), + attempts, + finalAcceptance: finalValue, + }; +} + +export const readIssueContextTask = defineTask('issue-880.read-issue-context', (args, taskCtx) => ({ + kind: 'agent', + title: 'Read issue #880 and related recovery context', + labels: ['issue-880', 'sdk', 'run-halt', 'issue-context'], + agent: { + name: 'sdk-recovery-researcher', + prompt: { + role: 'senior Babysitter SDK maintainer', + task: 'Read the GitHub issue thread and produce the authoritative implementation brief for issue #880.', + instructions: [ + 'Do not edit files in this task.', + `Run gh issue view ${args.issueNumber ?? 880} --json title,body,labels,comments,state,stateReason,url.`, + `If ${args.issueNumber ?? 880} resolves as a PR rather than an issue, also run gh pr view ${args.issueNumber ?? 880} --json files,title,body,comments.`, + `Read related issues and PRs listed in the inputs when available: ${JSON.stringify(args.relatedIssues ?? [879, 574, 573, 191, 181], null, 2)}.`, + 'Treat the issue body, comments, and labels as the source of truth. Preserve the distinction between operator-authored `run:halt` and process-authored `ctx.halt(...)`.', + 'Return JSON with: title, state, url, labels, rawIssueBody, comments, acceptanceCriteria, nonGoals, terminalSemanticsOptions, relatedIssueNotes, implementationRisks, and priority.', + ], + }, + outputSchema: { + type: 'object', + required: ['title', 'labels', 'acceptanceCriteria', 'nonGoals', 'implementationRisks'], + }, + }, + io: { + inputJsonPath: `tasks/${taskCtx.effectId}/input.json`, + outputJsonPath: `tasks/${taskCtx.effectId}/output.json`, + }, +})); + +export const reuseAuditTask = defineTask('issue-880.reuse-audit', (args, taskCtx) => ({ + kind: 'agent', + title: 'Map existing CLI recovery and journal sealing surfaces', + labels: ['issue-880', 'sdk', 'reuse-audit', 'runtime-trace'], + agent: { + name: 'sdk-runtime-architect', + prompt: { + role: 'senior TypeScript SDK maintainer', + task: 'Run Phase 0 reuse audit and trace the live command path for a new run:halt recovery command.', + instructions: [ + 'Do not edit files in this task.', + 'Render "Reuse-audit findings (REVIEW BEFORE PROCEEDING)" before implementation planning details.', + 'Confirm whether `.a5c/reuse-audit.json` exists and apply it if present.', + 'Confirm no migrations, API routes, environment variables, or dependency additions are needed.', + 'Search for run:halt, manual_seal_reason, run:recover-process-error, run:rebuild-state, run:repair-journal, RUN_HALTED, RUN_COMPLETED, RUN_FAILED, completionProof, appendEvent, rebuildStateCache, final-status, and --reason.', + 'Trace the command path from command allowlist through flag parsing, positional parsing, dispatch, handler export, run metadata loading, journal append, state cache rebuild, status/events visibility, and help output.', + 'Trace completed/halted/failed terminal status derivation through runLifecycleState, runState, runInspection, and replay/effectIndex to avoid conflicting terminal semantics.', + 'Identify tests and helpers in packages/sdk/src/cli/__tests__/cliRuns.test.ts that can be extended for run:halt.', + 'Identify docs and prompt templates that should explain when to use run:halt versus run:recover-process-error, run:repair-journal, run:rebuild-state, and ctx.halt.', + 'Return JSON with: reuseAuditFindings, runtimeCallPaths, liveExecutionFiles, commandRegistrationFiles, parserFiles, handlerFiles, stateFiles, testFiles, docsFiles, dependencyFindings, existingGaps, and risks.', + '', + 'ISSUE CONTEXT JSON:', + JSON.stringify(args.issueContext ?? {}, null, 2), + ], + }, + outputSchema: { + type: 'object', + required: ['reuseAuditFindings', 'runtimeCallPaths', 'liveExecutionFiles', 'testFiles', 'docsFiles', 'risks'], + }, + }, + io: { + inputJsonPath: `tasks/${taskCtx.effectId}/input.json`, + outputJsonPath: `tasks/${taskCtx.effectId}/output.json`, + }, +})); + +export const designManualSealContractTask = defineTask('issue-880.manual-seal-contract', (args, taskCtx) => ({ + kind: 'agent', + title: 'Design operator manual-seal CLI contract', + labels: ['issue-880', 'sdk', 'api-design', 'manual-seal'], + agent: { + name: 'sdk-cli-architect', + prompt: { + role: 'senior CLI and runtime API designer', + task: 'Define the smallest safe contract for `babysitter run:halt`.', + instructions: [ + 'Do not edit files in this task.', + 'Use the issue context and reuse audit as the acceptance spec.', + 'Define the public command grammar: `babysitter run:halt [--reason ] [--final-status completed|failed|halted] [--json] [--dry-run]` if the reuse audit supports `halted`, or explicitly justify omitting `halted` despite existing RUN_HALTED support.', + 'Resolve the issue/comment tension explicitly: the issue suggests completed|failed, while triage recommends default manual halt semantics unless completed/failed is explicit.', + 'Recommended baseline unless disproven: require a non-empty `--reason`; default `--final-status halted`; allow explicit completed or failed only when the operator chooses it.', + 'Map final status to exactly one terminal event: RUN_HALTED for halted, RUN_COMPLETED for completed, RUN_FAILED for failed.', + 'For RUN_COMPLETED, specify completionProof behavior using existing metadata/resolveCompletionProof semantics; do not invent a second proof source.', + 'Specify event data fields, including manual_seal_reason, sealedBy: "run:halt", requestedFinalStatus, pendingEffectsSummary, priorLifecycleState, priorLifecycleEvent, and any completionProof only for completed seals.', + 'Specify validation: missing runDir, missing/blank reason, invalid final status, already terminal run rejection by default, malformed/corrupt journal failure, and dry-run no mutation.', + 'Specify state rebuild: append terminal event through appendEvent, then rebuildStateCache with reason "manual_seal".', + 'Specify output: human and JSON must include final status, appended event seq/filename/checksum, pending counts, prior state, state rebuild metadata, and completionProof only when completed.', + 'If an override for already-terminal runs is needed, recommend a follow-up instead of adding it unless issue context requires it.', + 'Return JSON with: commandContract, terminalEventContract, validationRules, outputContract, implementationOrder, testPlan, docsPlan, nonGoals, riskMitigations, requiresMaintainerDecision, and question.', + '', + 'ISSUE CONTEXT JSON:', + JSON.stringify(args.issueContext ?? {}, null, 2), + '', + 'REUSE AUDIT JSON:', + JSON.stringify(args.reuseAudit ?? {}, null, 2), + ], + }, + outputSchema: { + type: 'object', + required: ['commandContract', 'terminalEventContract', 'validationRules', 'testPlan', 'docsPlan', 'nonGoals'], + }, + }, + io: { + inputJsonPath: `tasks/${taskCtx.effectId}/input.json`, + outputJsonPath: `tasks/${taskCtx.effectId}/output.json`, + }, +})); + +export const authorRegressionTestsTask = defineTask('issue-880.author-regression-tests', (args, taskCtx) => ({ + kind: 'agent', + title: 'Author failing run:halt regression tests', + labels: ['issue-880', 'sdk', 'tests', 'tdd', 'phase:red'], + agent: { + name: 'sdk-cli-test-author', + responderType: 'agent', + adapter: 'codex', + fallbackType: 'internal', + timeout: 900000, + maxTurns: 10, + prompt: { + role: 'senior TypeScript SDK test engineer', + task: 'Add focused failing tests for issue #880 before production code changes.', + instructions: [ + 'Edit test files only in this task.', + 'Do not modify CLI implementation, runtime source, docs, prompt templates, generated metadata, or unrelated dirty files.', + 'Use existing helpers and patterns from packages/sdk/src/cli/__tests__/cliRuns.test.ts before creating new scaffolding.', + 'Cover command registration/help: run:halt is accepted and usage describes ``, `--reason`, `--final-status`, `--json`, and `--dry-run` according to the contract.', + 'Cover missing/blank `--reason` failing without journal/state mutation.', + 'Cover dry-run JSON reporting prior state, selected final status, pending effect counts, and planned terminal event without mutating journal or state cache.', + 'Cover default/manual seal behavior from the contract, including event type, manual_seal_reason, sealedBy, pending summary, status output, and state cache rebuild metadata.', + 'Cover explicit `--final-status failed` appending RUN_FAILED and explicit `--final-status completed` appending RUN_COMPLETED with completionProof output.', + 'Cover already-terminal run rejection without mutation.', + 'Cover `run:status --json` and `run:events --json` visibility of the audit fields after sealing.', + 'Run the smallest targeted test command needed to prove the new tests fail for the missing command, and preserve the failure evidence.', + 'Return JSON with: changedFiles, testsAdded, redVerified, redCommands, redOutputSummary, coverageMatrix, and ambiguities.', + '', + 'ISSUE CONTEXT JSON:', + JSON.stringify(args.issueContext ?? {}, null, 2), + '', + 'CONTRACT JSON:', + JSON.stringify(args.contract ?? {}, null, 2), + '', + `TEST TARGETS: ${JSON.stringify(args.inputs?.testTargets ?? [], null, 2)}`, + ], + }, + outputSchema: { + type: 'object', + required: ['changedFiles', 'testsAdded', 'redVerified', 'coverageMatrix'], + }, + }, + io: { + inputJsonPath: `tasks/${taskCtx.effectId}/input.json`, + outputJsonPath: `tasks/${taskCtx.effectId}/output.json`, + }, +})); + +export const implementRunHaltTask = defineTask('issue-880.implement-run-halt', (args, taskCtx) => ({ + kind: 'agent', + title: 'Implement run:halt command and consumers', + labels: ['issue-880', 'sdk', 'implementation', 'phase:green'], + agent: { + name: 'sdk-cli-implementer', + responderType: 'agent', + adapter: 'codex', + fallbackType: 'internal', + timeout: 900000, + maxTurns: 12, + prompt: { + role: 'senior Babysitter SDK engineer', + task: `Implement issue #880 attempt ${args.attempt}.`, + instructions: [ + 'Edit the repository directly. Keep changes scoped to files on the live CLI, journal/state, tests, docs, and prompt paths identified by the reuse audit.', + 'Do not modify unrelated process files, generated marketplace files, local Codex config, or unrelated dirty worktree files.', + 'Make the red tests pass with the smallest coherent change.', + 'Wire `run:halt` through command allowlist, usage, flag parsing/types, positional parsing, command exports, and dispatch.', + 'Implement the handler beside adjacent run recovery handlers unless the reuse audit identifies a better existing home.', + 'Use `appendEvent` for the terminal event. Do not hand-write journal files.', + 'Use `rebuildStateCache` after a successful append with reason "manual_seal".', + 'Use existing run metadata and completionProof helpers for completed seals. Do not change normal completion proof behavior.', + 'Validate non-empty reason, valid final status, non-terminal current state, and dry-run no mutation.', + 'Include the audit fields defined by the contract in the event data and output.', + 'Preserve process-level `ctx.halt(...)` semantics. The operator command must not blur with process-authored halts except where the contract intentionally maps default manual sealing to RUN_HALTED.', + 'Update docs and prompt templates only where required by the contract.', + 'Run focused checks while implementing and report exact command outcomes.', + 'Return JSON with: changedFiles, summary, commandContractImplemented, eventPayloadImplemented, stateRebuildBehavior, testsUpdated, docsUpdated, verificationCommandsRun, risks, and blockers.', + '', + 'ISSUE CONTEXT JSON:', + JSON.stringify(args.issueContext ?? {}, null, 2), + '', + 'REUSE AUDIT JSON:', + JSON.stringify(args.reuseAudit ?? {}, null, 2), + '', + 'CONTRACT JSON:', + JSON.stringify(args.contract ?? {}, null, 2), + '', + 'REGRESSION TESTS JSON:', + JSON.stringify(args.regressionTests ?? {}, null, 2), + '', + 'PREVIOUS VERIFICATION JSON:', + JSON.stringify(args.previousVerification ?? null, null, 2), + '', + 'PREVIOUS REVIEW JSON:', + JSON.stringify(args.previousReview ?? null, null, 2), + ], + }, + outputSchema: { + type: 'object', + required: ['changedFiles', 'summary', 'commandContractImplemented', 'eventPayloadImplemented'], + }, + }, + io: { + inputJsonPath: `tasks/${taskCtx.effectId}/input.json`, + outputJsonPath: `tasks/${taskCtx.effectId}/output.json`, + }, +})); + +export const runVerificationGateTask = defineTask('issue-880.verification-gate', (args, taskCtx) => ({ + kind: 'agent', + title: 'Verify run:halt quality gates', + labels: ['issue-880', 'sdk', 'verification', 'quality-gate'], + agent: { + name: 'sdk-cli-verifier', + responderType: 'agent', + adapter: 'codex', + fallbackType: 'internal', + timeout: 900000, + maxTurns: 8, + prompt: { + role: 'senior SDK verification engineer', + task: `Run and interpret the verification gate for issue #880 attempt ${args.attempt}.`, + instructions: [ + 'Do not edit files unless a command creates normal ignored test/build artifacts.', + 'Run the listed quality commands from the repository root.', + 'Confirm red-phase evidence exists and that the same tests now pass.', + 'Confirm `run:halt` uses appendEvent rather than hand-writing journal files.', + 'Confirm one and only one terminal event is appended for each successful seal.', + 'Confirm the event contains manual_seal_reason, sealedBy, selected final status, prior lifecycle state, pending summary, and completionProof only for completed seals.', + 'Confirm dry-run, missing reason, invalid final status, and already-terminal rejection do not mutate journal or state cache.', + 'Confirm successful seals rebuild the state cache with manual_seal metadata and `run:status --json` reflects the selected terminal state.', + 'Confirm completed seals preserve completionProof behavior and failed/halted seals do not emit a completionProof.', + 'Inspect git status and verify the diff is scoped to issue #880.', + 'Return JSON with: passed, commands, commandResults, scopedDiff, changedFiles, failures, journalInvariantVerified, auditTrailVerified, completionProofInvariantVerified, stateRebuildVerified, docsVerified, and nextActions.', + '', + 'ISSUE CONTEXT JSON:', + JSON.stringify(args.issueContext ?? {}, null, 2), + '', + 'CONTRACT JSON:', + JSON.stringify(args.contract ?? {}, null, 2), + '', + 'REGRESSION TESTS JSON:', + JSON.stringify(args.regressionTests ?? {}, null, 2), + '', + 'IMPLEMENTATION JSON:', + JSON.stringify(args.implementation ?? {}, null, 2), + '', + `QUALITY COMMANDS: ${JSON.stringify(args.inputs?.qualityCommands ?? [], null, 2)}`, + ], + }, + outputSchema: { + type: 'object', + required: ['passed', 'commands', 'journalInvariantVerified', 'auditTrailVerified', 'completionProofInvariantVerified', 'stateRebuildVerified'], + }, + }, + io: { + inputJsonPath: `tasks/${taskCtx.effectId}/input.json`, + outputJsonPath: `tasks/${taskCtx.effectId}/output.json`, + }, +})); + +export const reviewRunHaltTask = defineTask('issue-880.review-run-halt', (args, taskCtx) => ({ + kind: 'agent', + title: 'Review run:halt implementation', + labels: ['issue-880', 'sdk', 'code-review', 'manual-seal'], + agent: { + name: 'sdk-recovery-reviewer', + responderType: 'agent', + adapter: 'codex', + fallbackType: 'internal', + timeout: 900000, + maxTurns: 8, + prompt: { + role: 'senior SDK code reviewer', + task: 'Review issue #880 changes against the issue spec and manual seal contract.', + instructions: [ + 'Do not edit files in this task.', + 'Lead with blocking findings, each with file and line references.', + 'Verify `run:halt` is operator-authored recovery and remains distinct from process-authored `ctx.halt(...)`.', + 'Verify command parsing cannot accidentally reuse task-cancel `--reason` state or leak flags into other commands.', + 'Verify the handler refuses unsafe/malformed inputs before mutation.', + 'Verify appendEvent and rebuildStateCache preserve journal/state invariants.', + 'Verify event audit fields are stable, documented, and visible through run:events/status paths.', + 'Verify completionProof behavior is only present for completed seals and uses existing proof helpers.', + 'Verify tests cover dry-run, JSON output, reason recording, all supported final statuses, already-terminal rejection, state rebuild, and visibility.', + 'Verify docs explain command selection versus run:recover-process-error, run:repair-journal, run:rebuild-state, and ctx.halt.', + 'Return JSON with: approved, blockingIssues, nonBlockingSuggestions, missingTests, specCoverage, riskAssessment, and finalSummary.', + '', + 'ISSUE CONTEXT JSON:', + JSON.stringify(args.issueContext ?? {}, null, 2), + '', + 'REUSE AUDIT JSON:', + JSON.stringify(args.reuseAudit ?? {}, null, 2), + '', + 'CONTRACT JSON:', + JSON.stringify(args.contract ?? {}, null, 2), + '', + 'REGRESSION TESTS JSON:', + JSON.stringify(args.regressionTests ?? {}, null, 2), + '', + 'IMPLEMENTATION JSON:', + JSON.stringify(args.implementation ?? {}, null, 2), + '', + 'VERIFICATION JSON:', + JSON.stringify(args.verification ?? {}, null, 2), + ], + }, + outputSchema: { + type: 'object', + required: ['approved', 'blockingIssues', 'specCoverage', 'finalSummary'], + }, + }, + io: { + inputJsonPath: `tasks/${taskCtx.effectId}/input.json`, + outputJsonPath: `tasks/${taskCtx.effectId}/output.json`, + }, +})); + +export const documentManualSealWorkflowTask = defineTask('issue-880.documentation', (args, taskCtx) => ({ + kind: 'agent', + title: 'Document manual seal recovery workflow', + labels: ['issue-880', 'sdk', 'docs', 'operator-recovery'], + agent: { + name: 'sdk-docs-maintainer', + responderType: 'agent', + adapter: 'codex', + fallbackType: 'internal', + timeout: 600000, + maxTurns: 6, + prompt: { + role: 'senior SDK documentation maintainer', + task: 'Update recovery documentation for the implemented run:halt command.', + instructions: [ + 'Edit only the documentation and prompt-template files required by the contract and implementation.', + 'Document the exact command grammar, final-status semantics, audit fields, JSON output, and dry-run behavior.', + 'Add a decision table: ctx.halt for process-authored early exit; run:recover-process-error for typed process exceptions; run:repair-journal for malformed/colliding journal entries; run:rebuild-state for cache drift; run:halt for operator-authored manual sealing of divergent runs.', + 'Warn that completed manual seals are explicit operator assertions and must include manual_seal_reason.', + 'Avoid implying that run:halt repairs root causes or resolves pending effects. It seals operational reality with an audit trail.', + 'Run focused docs/grep checks as practical and report the results.', + 'Return JSON with: changedFiles, docsUpdated, commandExamples, decisionTableUpdated, promptTemplatesUpdated, checksRun, and risks.', + '', + 'CONTRACT JSON:', + JSON.stringify(args.contract ?? {}, null, 2), + '', + 'IMPLEMENTATION JSON:', + JSON.stringify(args.implementation ?? {}, null, 2), + '', + `DOC TARGETS: ${JSON.stringify(args.inputs?.docsTargets ?? [], null, 2)}`, + ], + }, + outputSchema: { + type: 'object', + required: ['changedFiles', 'docsUpdated', 'decisionTableUpdated'], + }, + }, + io: { + inputJsonPath: `tasks/${taskCtx.effectId}/input.json`, + outputJsonPath: `tasks/${taskCtx.effectId}/output.json`, + }, +})); + +export const finalAcceptanceTask = defineTask('issue-880.final-acceptance', (args, taskCtx) => ({ + kind: 'agent', + title: 'Finalize issue #880 acceptance evidence', + labels: ['issue-880', 'sdk', 'final-acceptance'], + agent: { + name: 'sdk-release-verifier', + prompt: { + role: 'senior release verifier', + task: 'Produce final acceptance evidence for issue #880.', + instructions: [ + 'Do not edit files in this task.', + 'Compare the issue context, contract, implementation, verification, review, and documentation results directly.', + 'Confirm every acceptance criterion is addressed or explicitly listed as a deferred follow-up.', + 'Confirm the final diff is scoped to issue #880 and excludes unrelated dirty worktree files.', + 'Confirm no implementation code weakens journal invariants, completionProof semantics, or existing recovery commands.', + 'Return JSON with: passed, changedFiles, acceptanceMatrix, deferredFollowUps, qualityEvidence, docsEvidence, risks, and finalSummary.', + '', + 'ISSUE CONTEXT JSON:', + JSON.stringify(args.issueContext ?? {}, null, 2), + '', + 'CONTRACT JSON:', + JSON.stringify(args.contract ?? {}, null, 2), + '', + 'REGRESSION TESTS JSON:', + JSON.stringify(args.regressionTests ?? {}, null, 2), + '', + 'IMPLEMENTATION JSON:', + JSON.stringify(args.implementation ?? {}, null, 2), + '', + 'VERIFICATION JSON:', + JSON.stringify(args.verification ?? {}, null, 2), + '', + 'REVIEW JSON:', + JSON.stringify(args.review ?? {}, null, 2), + '', + 'DOCUMENTATION JSON:', + JSON.stringify(args.documentation ?? {}, null, 2), + '', + `ATTEMPTS JSON: ${JSON.stringify(args.attempts ?? [], null, 2)}`, + ], + }, + outputSchema: { + type: 'object', + required: ['passed', 'changedFiles', 'acceptanceMatrix', 'finalSummary'], + }, + }, + io: { + inputJsonPath: `tasks/${taskCtx.effectId}/input.json`, + outputJsonPath: `tasks/${taskCtx.effectId}/output.json`, + }, +})); diff --git a/docs/agent-reference/runtime-and-layout.md b/docs/agent-reference/runtime-and-layout.md index e72037472c..8a17908140 100644 --- a/docs/agent-reference/runtime-and-layout.md +++ b/docs/agent-reference/runtime-and-layout.md @@ -61,10 +61,11 @@ The event stream is append-only and centers on: - `EFFECT_REQUESTED` - `EFFECT_RESOLVED` - `RUN_COMPLETED` +- `RUN_HALTED` - `RUN_FAILED` - `PROCESS_RUNTIME_ERROR` -The state cache is derived data. If it drifts from the journal, repair with `run:rebuild-state`. If process code threw and the journal contains `PROCESS_RUNTIME_ERROR`, use `run:recover-process-error` to clear that typed marker and optionally patch the offending task result. If journal entries are malformed or partially written, use `run:repair-journal` carefully after inspecting the affected run. +The state cache is derived data. If it drifts from the journal, repair with `run:rebuild-state`. If process code threw and the journal contains `PROCESS_RUNTIME_ERROR`, use `run:recover-process-error` to clear that typed marker and optionally patch the offending task result. If journal entries are malformed or partially written, use `run:repair-journal` carefully after inspecting the affected run. If external verification proves a divergent run should be sealed rather than repaired, use `run:halt --reason `; it appends one audited terminal event through the journal API and rebuilds state with reason `manual_seal`. ## Effects diff --git a/docs/reference/babysitter_cli_surface_spec.md b/docs/reference/babysitter_cli_surface_spec.md index cabb5a2f59..068d31603c 100644 --- a/docs/reference/babysitter_cli_surface_spec.md +++ b/docs/reference/babysitter_cli_surface_spec.md @@ -22,6 +22,7 @@ Behavior - `run:events` streams journal entries with `--limit`, `--reverse`, `--filter-type`, and `--json`. Missing run directory or unreadable event files emit a single error line and exit `1`. - `run:rebuild-state` (surface for `rebuildStateCache`) locks the run, replays the journal, writes `state/state.json`, and prints/returns the rebuild reason, event counts, and resulting `stateVersion`. - `run:recover-process-error ` targets the latest `PROCESS_RUNTIME_ERROR`. It supports `--dry-run`, `--json`, and `--patch-effect :=`, rewrites only the typed process-error marker out of the journal, optionally patches the offending task result, rebuilds state, and fails without mutation for malformed input or missing markers. Patch paths without leading `value` or `result` apply to the returned task value; explicit wrapper paths remain supported. + - `run:halt --reason [--final-status halted|completed|failed]` is an operator-authored manual seal for divergent runs whose operational reality is known outside the SDK. It requires a non-empty audit reason, defaults to `halted`/`RUN_HALTED`, rejects already-terminal runs, appends exactly one terminal event through `appendEvent`, records `manual_seal_reason`, `sealedBy:"run:halt"`, requested final status, prior lifecycle state/event, and pending-effect summary, then rebuilds state with reason `manual_seal`. `--dry-run` reports the planned event without mutation. Completed seals expose `completionProof`; halted and failed seals return `completionProof: null`. 3. **Orchestration control loops** - `run:continue` was removed; callers should loop `run:iterate`, execute pending effects externally, and commit via `task:post`. diff --git a/docs/user-guide/reference/cli-reference.md b/docs/user-guide/reference/cli-reference.md index f0aaad4a4a..614cd82670 100644 --- a/docs/user-guide/reference/cli-reference.md +++ b/docs/user-guide/reference/cli-reference.md @@ -23,6 +23,7 @@ Complete reference documentation for the core Babysitter command-line interface. - [run:iterate](#runiterate) - [run:rebuild-state](#runrebuild-state) - [run:recover-process-error](#runrecover-process-error) + - [run:halt](#runhalt) - [Task Commands](#task-commands) - [task:list](#tasklist) - [task:show](#taskshow) @@ -601,6 +602,45 @@ babysitter run:recover-process-error run-20260125-143012 --patch-effect 'ef-live --- +### run:halt + +Seals a divergent run with an auditable operator-authored terminal event. + +#### Synopsis + +```bash +babysitter run:halt --reason \ + [--final-status halted|completed|failed] \ + [--dry-run] \ + [--json] +``` + +#### Description + +Use this only when the SDK journal/state no longer matches known operational reality and narrower recovery commands do not apply. The command requires a non-empty `--reason`, rejects already-terminal runs, appends exactly one terminal event through the journal API, records `manual_seal_reason`, `sealedBy`, requested final status, prior lifecycle state/event, and pending-effect summary, then rebuilds state with reason `manual_seal`. + +Default `--final-status` is `halted`, which writes `RUN_HALTED`. Explicit `completed` writes `RUN_COMPLETED` and exposes the existing completion proof. Explicit `failed` writes `RUN_FAILED`. Failed and halted seals return `completionProof: null`. + +#### Recovery Command Selection + +| Situation | Command | +|-----------|---------| +| Process intentionally exits early | `ctx.halt(...)` in process code | +| Process code threw and recorded `PROCESS_RUNTIME_ERROR` | `run:recover-process-error` | +| Journal has malformed or duplicate entries | `run:repair-journal` | +| State cache is missing or stale but journal is valid | `run:rebuild-state` | +| Operator must seal a divergent run after external verification | `run:halt` | + +#### Examples + +```bash +babysitter run:halt run-20260125-143012 --reason "external artifacts already landed" --dry-run --json +babysitter run:halt run-20260125-143012 --reason "accepted manual recovery" +babysitter run:halt run-20260125-143012 --reason "operator marked failed" --final-status failed --json +``` + +--- + ## Task Commands ### task:list diff --git a/packages/sdk/src/cli/__tests__/cliRuns.test.ts b/packages/sdk/src/cli/__tests__/cliRuns.test.ts index 823dde3c22..43a93385fb 100644 --- a/packages/sdk/src/cli/__tests__/cliRuns.test.ts +++ b/packages/sdk/src/cli/__tests__/cliRuns.test.ts @@ -1203,6 +1203,213 @@ describe("run lifecycle inspection commands", () => { }); }); + describe("run:halt", () => { + it("requires a non-empty audit reason without mutating the journal", async () => { + const runDir = await createRunWithPendingEffects(); + const before = await loadJournal(runDir); + + const exitCode = await cli.run(["run:halt", runDir, "--reason", " ", "--json"]); + + expect(exitCode).toBe(1); + expect(hasLineContaining(errorSpy, "[run:halt] --reason is required")).toBe(true); + expect((await loadJournal(runDir)).map((event) => event.type)).toEqual(before.map((event) => event.type)); + await expectStateCacheMissing(runDir); + }); + + it("rejects unsupported final statuses without mutating the journal", async () => { + const runDir = await createRunWithPendingEffects(); + const before = await loadJournal(runDir); + + const exitCode = await cli.run([ + "run:halt", + runDir, + "--reason", + "operator recovery", + "--final-status", + "cancelled", + "--json", + ]); + + expect(exitCode).toBe(1); + expect(hasLineContaining(errorSpy, "--final-status must be one of: halted, completed, failed")).toBe(true); + expect((await loadJournal(runDir)).map((event) => event.type)).toEqual(before.map((event) => event.type)); + }); + + it("supports dry-run JSON without appending or rebuilding state", async () => { + const runDir = await createRunWithPendingEffects(); + const before = await loadJournal(runDir); + + const exitCode = await cli.run(["run:halt", runDir, "--reason", "operator recovery", "--dry-run", "--json"]); + + expect(exitCode).toBe(0); + const payload = readLastJson(logSpy); + expect(payload).toMatchObject({ + dryRun: true, + sealed: false, + runDir, + finalStatus: "halted", + plannedTerminalEvent: { type: "RUN_HALTED" }, + priorLifecycleState: "waiting", + pendingEffectsSummary: { + totalPending: 2, + countsByKind: { breakpoint: 1, node: 1 }, + autoRunnableCount: 1, + }, + }); + expect((await loadJournal(runDir)).map((event) => event.type)).toEqual(before.map((event) => event.type)); + await expectStateCacheMissing(runDir); + }); + + it("defaults to an auditable RUN_HALTED manual seal and rebuilds state", async () => { + const runDir = await createRunWithPendingEffects(); + + const exitCode = await cli.run(["run:halt", runDir, "--reason", "accepted external recovery", "--json"]); + + expect(exitCode).toBe(0); + const payload = readLastJson(logSpy); + expect(payload).toMatchObject({ + sealed: true, + runDir, + finalStatus: "halted", + terminalEvent: { type: "RUN_HALTED", seq: 4 }, + pendingEffectsSummary: { totalPending: 2 }, + metadata: { + stateRebuilt: true, + stateRebuildReason: "manual_seal", + pendingEffectsByKind: { breakpoint: 1, node: 1 }, + }, + completionProof: null, + }); + + const journal = await loadJournal(runDir); + expect(journal.filter((event) => event.type === "RUN_HALTED")).toHaveLength(1); + expect(journal.at(-1)).toMatchObject({ + type: "RUN_HALTED", + data: { + manual_seal_reason: "accepted external recovery", + sealedBy: "run:halt", + requestedFinalStatus: "halted", + priorLifecycleState: "waiting", + pendingEffectsSummary: { + totalPending: 2, + countsByKind: { breakpoint: 1, node: 1 }, + autoRunnableCount: 1, + }, + }, + }); + + logSpy.mockClear(); + const statusExit = await cli.run(["run:status", runDir, "--json"]); + expect(statusExit).toBe(0); + const status = readLastJson(logSpy); + expect(status.state).toBe("halted"); + expect(status.completionProof).toBeNull(); + expect(status.lastEvent.data.manual_seal_reason).toBe("accepted external recovery"); + }); + + it("supports explicit failed seals without completion proofs", async () => { + const runDir = await createRunWithPendingEffects(); + + const exitCode = await cli.run([ + "run:halt", + runDir, + "--reason", + "operator marked failed", + "--final-status", + "failed", + "--json", + ]); + + expect(exitCode).toBe(0); + const payload = readLastJson(logSpy); + expect(payload).toMatchObject({ + sealed: true, + finalStatus: "failed", + terminalEvent: { type: "RUN_FAILED" }, + completionProof: null, + }); + const journal = await loadJournal(runDir); + expect(journal.at(-1)).toMatchObject({ + type: "RUN_FAILED", + data: { + manual_seal_reason: "operator marked failed", + requestedFinalStatus: "failed", + sealedBy: "run:halt", + }, + }); + }); + + it("supports explicit completed seals with existing completion proof semantics", async () => { + const runId = "run-halt-completed"; + const runDir = await createRunSkeleton(runId); + await appendRequestedEffect(runDir, "ef-node", "node", "build"); + + const exitCode = await cli.run([ + "run:halt", + runDir, + "--reason", + "external artifacts already landed", + "--final-status", + "completed", + "--json", + ]); + + expect(exitCode).toBe(0); + const payload = readLastJson(logSpy); + expect(payload).toMatchObject({ + sealed: true, + finalStatus: "completed", + terminalEvent: { type: "RUN_COMPLETED" }, + completionProof: deriveCompletionProof(runId), + }); + const journal = await loadJournal(runDir); + expect(journal.at(-1)).toMatchObject({ + type: "RUN_COMPLETED", + data: { + manual_seal_reason: "external artifacts already landed", + requestedFinalStatus: "completed", + sealedBy: "run:halt", + completionProof: deriveCompletionProof(runId), + }, + }); + }); + + it("rejects already terminal runs without appending another terminal event", async () => { + const runDir = await createRunSkeleton("run-halt-terminal"); + await appendEvent({ + runDir, + eventType: "RUN_COMPLETED", + event: { outputRef: "state/output.json" }, + }); + const before = await loadJournal(runDir); + + const exitCode = await cli.run(["run:halt", runDir, "--reason", "too late", "--json"]); + + expect(exitCode).toBe(1); + expect(hasLineContaining(errorSpy, "run is already terminal")).toBe(true); + expect((await loadJournal(runDir)).map((event) => event.type)).toEqual(before.map((event) => event.type)); + }); + + it("exposes manual seal audit fields through run:events JSON", async () => { + const runDir = await createRunWithPendingEffects(); + await cli.run(["run:halt", runDir, "--reason", "inspectable audit", "--json"]); + logSpy.mockClear(); + + const exitCode = await cli.run(["run:events", runDir, "--filter-type", "RUN_HALTED", "--json"]); + + expect(exitCode).toBe(0); + const payload = readLastJson(logSpy); + expect(payload.events).toHaveLength(1); + expect(payload.events[0]).toMatchObject({ + type: "RUN_HALTED", + data: { + manual_seal_reason: "inspectable audit", + sealedBy: "run:halt", + requestedFinalStatus: "halted", + }, + }); + }); + }); describe("run:repair-journal", () => { it("skips 0-byte corrupt journal files and reports droppedCorrupt count", async () => { diff --git a/packages/sdk/src/cli/main/argFlagParsers.ts b/packages/sdk/src/cli/main/argFlagParsers.ts index 6f6a144289..25556874b3 100644 --- a/packages/sdk/src/cli/main/argFlagParsers.ts +++ b/packages/sdk/src/cli/main/argFlagParsers.ts @@ -4,6 +4,7 @@ */ import type { ParsedArgs } from "./types"; +import type { RunHaltFinalStatus } from "./types"; import { resolveRunsDir } from "../../config"; type FlagParser = (parsed: ParsedArgs, args: string[], index: number) => number; @@ -43,6 +44,14 @@ function parseIntegerFlag(raw: string): number { return parseInt(raw, 10); } +function parseRunHaltFinalStatus(raw: string): RunHaltFinalStatus { + const normalized = raw.toLowerCase(); + if (normalized !== "halted" && normalized !== "completed" && normalized !== "failed") { + throw new Error("--final-status must be one of: halted, completed, failed"); + } + return normalized; +} + function readOptionalSessionId(args: string[], index: number): { sessionId?: string; index: number } { const next = args[index + 1]; if (next && !next.startsWith("-")) { @@ -332,7 +341,16 @@ export const FLAG_PARSERS: Record = { return index + 1; }, "--reason": (parsed, args, index) => { - parsed.cancelReason = expectFlagValue(args, index + 1, "--reason"); + const reason = expectFlagValue(args, index + 1, "--reason"); + if (parsed.command === "run:halt") { + parsed.runHaltReason = reason; + } else { + parsed.cancelReason = reason; + } + return index + 1; + }, + "--final-status": (parsed, args, index) => { + parsed.runHaltFinalStatus = parseRunHaltFinalStatus(expectFlagValue(args, index + 1, "--final-status")); return index + 1; }, "--action": (parsed, args, index) => { diff --git a/packages/sdk/src/cli/main/argPositionals.ts b/packages/sdk/src/cli/main/argPositionals.ts index 1f462e5dff..86d815c12e 100644 --- a/packages/sdk/src/cli/main/argPositionals.ts +++ b/packages/sdk/src/cli/main/argPositionals.ts @@ -19,6 +19,7 @@ export function applyPositionalArgs(parsed: ParsedArgs, positionals: string[]) { case "run:rebuild-state": case "run:repair-journal": case "run:recover-process-error": + case "run:halt": [parsed.runDirArg] = positionals; return; case "configure": diff --git a/packages/sdk/src/cli/main/dispatchRunSession.ts b/packages/sdk/src/cli/main/dispatchRunSession.ts index 949ae7dddc..3371a18255 100644 --- a/packages/sdk/src/cli/main/dispatchRunSession.ts +++ b/packages/sdk/src/cli/main/dispatchRunSession.ts @@ -41,6 +41,7 @@ import { handleRunAssignProcess, handleRunRebuildState, handleRunRecoverProcessError, + handleRunHalt, handleRunRepairJournal, handleRunStatus, } from "./runCommands"; @@ -71,6 +72,8 @@ async function executeRunTaskCommand(parsed: ParsedArgs): Promise { @@ -314,6 +316,144 @@ export async function handleRunRebuildState(parsed: ParsedArgs): Promise return 0; } +export async function handleRunHalt(parsed: ParsedArgs): Promise { + if (!parsed.runDirArg) { + console.error(USAGE); + return 1; + } + const runDir = resolveRunDir(parsed.runsDir, parsed.runDirArg); + const finalStatus = parsed.runHaltFinalStatus ?? "halted"; + logVerbose("run:halt", parsed, { runDir, dryRun: parsed.dryRun, json: parsed.json, finalStatus }); + + const reason = parsed.runHaltReason?.trim() ?? ""; + if (!reason) { + console.error("[run:halt] --reason is required"); + return 1; + } + + const metadata = await readRunMetadataSafe(runDir, "run:halt"); + if (!metadata) return 1; + + let journal: JournalEvent[]; + let pendingRecords; + try { + journal = await loadJournal(runDir); + pendingRecords = (await buildEffectIndex({ runDir, events: journal })).listPendingEffects(); + } catch (error) { + console.error(`[run:halt] unable to read run at ${runDir}: ${error instanceof Error ? error.message : String(error)}`); + return 1; + } + + const pendingByKind = countPendingByKind(pendingRecords); + const pendingEffectsSummary = { + totalPending: pendingRecords.length, + countsByKind: pendingByKind, + autoRunnableCount: pendingRecords.filter((record) => record.kind === "node").length, + }; + const priorLifecycleEvent = findLastLifecycleEvent(journal); + const priorLifecycleState = deriveRunState(priorLifecycleEvent?.type, pendingRecords.length); + if (priorLifecycleState === "completed" || priorLifecycleState === "halted" || priorLifecycleState === "failed") { + console.error(`[run:halt] run is already terminal (state=${priorLifecycleState})`); + return 1; + } + + const terminalType = terminalEventTypeForRunHalt(finalStatus); + const completionProof = finalStatus === "completed" ? resolveCompletionProof(metadata) : null; + const eventData: JsonRecord = { + reason: "manual_seal", + payload: { + manual_seal_reason: reason, + requestedFinalStatus: finalStatus, + }, + manual_seal_reason: reason, + sealedBy: "run:halt", + requestedFinalStatus: finalStatus, + priorLifecycleState, + priorLifecycleEvent: priorLifecycleEvent ? summarizeLifecycleEvent(priorLifecycleEvent) : null, + pendingEffectsSummary, + ...(completionProof ? { completionProof } : {}), + }; + + const summaryBase = { + dryRun: parsed.dryRun, + sealed: !parsed.dryRun, + runDir, + finalStatus, + priorLifecycleState, + priorLifecycleEvent: priorLifecycleEvent ? summarizeLifecycleEvent(priorLifecycleEvent) : null, + pendingEffectsSummary, + completionProof, + }; + + if (parsed.dryRun) { + const dryRunSummary = { + ...summaryBase, + sealed: false, + plannedTerminalEvent: { + type: terminalType, + data: eventData, + }, + }; + if (parsed.json) { + console.log(JSON.stringify(dryRunSummary, null, 2)); + } else { + console.log(`[run:halt] dry-run runDir=${runDir} finalStatus=${finalStatus} event=${terminalType} pending[total]=${pendingEffectsSummary.totalPending}`); + } + return 0; + } + + const appendResult = await appendEvent({ + runDir, + eventType: terminalType, + event: eventData, + }); + const snapshot = await rebuildStateCache(runDir, { reason: "manual_seal" }); + const formatted = formatIterationMetadata({ + pendingEffectsByKind: snapshot.pendingEffectsByKind, + stateVersion: snapshot.stateVersion, + journalHead: snapshot.journalHead ?? null, + stateRebuilt: true, + stateRebuildReason: snapshot.rebuildReason ?? undefined, + }); + const summary = { + ...summaryBase, + terminalEvent: { + type: terminalType, + seq: appendResult.seq, + ulid: appendResult.ulid, + filename: appendResult.filename, + checksum: appendResult.checksum, + recordedAt: appendResult.recordedAt, + data: eventData, + }, + metadata: formatted.jsonMetadata ?? null, + }; + if (parsed.json) { + console.log(JSON.stringify(summary, null, 2)); + } else { + const proofSuffix = completionProof ? ` completionProof=${completionProof}` : ""; + const metadataSuffix = formatted.textParts.length ? ` ${formatted.textParts.join(" ")}` : ""; + console.log(`[run:halt] sealed runDir=${runDir} finalStatus=${finalStatus} event=${terminalType}#${String(appendResult.seq).padStart(6, "0")} pending[total]=${pendingEffectsSummary.totalPending}${metadataSuffix}${proofSuffix}`); + } + return 0; +} + +function terminalEventTypeForRunHalt(finalStatus: RunHaltFinalStatus): string { + if (finalStatus === "completed") return "RUN_COMPLETED"; + if (finalStatus === "failed") return "RUN_FAILED"; + return "RUN_HALTED"; +} + +function summarizeLifecycleEvent(event: JournalEvent): JsonRecord { + return { + type: event.type, + seq: event.seq, + ulid: event.ulid, + recordedAt: event.recordedAt, + data: event.data, + }; +} + export async function handleRunRepairJournal(parsed: ParsedArgs): Promise { if (!parsed.runDirArg) { console.error(USAGE); diff --git a/packages/sdk/src/cli/main/types.ts b/packages/sdk/src/cli/main/types.ts index f03ada2824..f59b837ff3 100644 --- a/packages/sdk/src/cli/main/types.ts +++ b/packages/sdk/src/cli/main/types.ts @@ -1,4 +1,5 @@ export type HelpSurface = "agent" | "human"; +export type RunHaltFinalStatus = "halted" | "completed" | "failed"; export interface ParsedArgs { command?: string; @@ -104,6 +105,8 @@ export interface ParsedArgs { breakpointTags?: string; breakpointExpert?: string; cancelReason?: string; + runHaltReason?: string; + runHaltFinalStatus?: RunHaltFinalStatus; patchEffect?: string; verbosity?: string; tuiFlag?: boolean; diff --git a/packages/sdk/src/cli/main/usage.ts b/packages/sdk/src/cli/main/usage.ts index db036ddf46..cbf19aac5e 100644 --- a/packages/sdk/src/cli/main/usage.ts +++ b/packages/sdk/src/cli/main/usage.ts @@ -9,6 +9,7 @@ function coreAgentUsage(commandName: string): string { ${commandName} run:rebuild-state [--json] [--dry-run] ${commandName} run:repair-journal [--json] [--dry-run] ${commandName} run:recover-process-error [--patch-effect :=] [--json] [--dry-run] + ${commandName} run:halt --reason [--final-status halted|completed|failed] [--json] [--dry-run] ${commandName} run:iterate [--json] [--verbose] [--iteration ] ${commandName} task:post --status [--json] [--dry-run] [--value ] [--value-inline ] [--error ] [--stdout-ref ] [--stderr-ref ] [--stdout-file ] [--stderr-file ] [--started-at ] [--finished-at ] [--metadata ] [--invocation-key ] ${commandName} task:list [--pending] [--kind ] [--json] diff --git a/packages/sdk/src/prompts/templates/breakpoint-handling.md b/packages/sdk/src/prompts/templates/breakpoint-handling.md index eaab2c165f..1e6ff04329 100644 --- a/packages/sdk/src/prompts/templates/breakpoint-handling.md +++ b/packages/sdk/src/prompts/templates/breakpoint-handling.md @@ -33,7 +33,10 @@ so the user's intent is unambiguous. If bad posted data later causes `PROCESS_RUNTIME_ERROR`, inspect it with `run:status`/`run:events` and recover with `run:recover-process-error`; reserve manual journal repair for malformed or partially written journal - entries that targeted recovery cannot represent. + entries that targeted recovery cannot represent. If a rejected breakpoint + should deliberately stop a divergent run after external verification, use + `run:halt --reason ...` as an operator manual seal rather than hand-writing a + terminal journal event. - Only use `--status error` if the {{interactiveToolName}} itself throws an error. **Breakpoint posting examples:** diff --git a/packages/sdk/src/prompts/templates/recovery.md b/packages/sdk/src/prompts/templates/recovery.md index 4c7271184d..bce3cf1a73 100644 --- a/packages/sdk/src/prompts/templates/recovery.md +++ b/packages/sdk/src/prompts/templates/recovery.md @@ -16,6 +16,20 @@ Patch the offending task result only when the fix is clear. Running recovery without a patch is allowed, but the next `run:iterate` should honestly rethrow if the bad result remains. +Use `run:halt` only when operational reality is known and the run must be +sealed manually rather than repaired: + +```bash +$CLI run:halt --reason '' --dry-run --json +$CLI run:halt --reason '' [--final-status halted|completed|failed] --json +``` + +Prefer `ctx.halt(...)` for process-authored early exits, +`run:recover-process-error` for typed process exceptions, `run:repair-journal` +for malformed journal files, and `run:rebuild-state` for cache drift. A +completed manual seal is an explicit operator assertion, not proof that normal +process execution finished. + When recovery requires it, repair the actual process/run artifacts instead of only describing the problem. If the process authored a bad loop, impossible shell command, or permanently failing effect pattern, edit the process file