diff --git a/packages/core/src/dag/core/types.ts b/packages/core/src/dag/core/types.ts index 56576743d6..f391dd6555 100644 --- a/packages/core/src/dag/core/types.ts +++ b/packages/core/src/dag/core/types.ts @@ -96,11 +96,11 @@ export class InvalidTransitionError extends DagCoreError { } export class TerminalViolationError extends DagCoreError { - constructor(entityId: string, terminalStatus: string, attemptedStatus: string) { + constructor(entityId: string, terminalStatus: string, attemptedStatus: string, reason?: string) { super( ErrorCode.TERMINAL_VIOLATION, - `Cannot transition from terminal state: ${entityId} (${terminalStatus} -> ${attemptedStatus})`, - { entityId, terminalStatus, attemptedStatus }, + `Cannot transition from terminal state: ${entityId} (${terminalStatus} -> ${attemptedStatus})${reason ? `: ${reason}` : ""}`, + { entityId, terminalStatus, attemptedStatus, reason }, ) this.name = "TerminalViolationError" } diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 0280982d50..9674b3ae49 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -70,7 +70,7 @@ type PromptInput = { history?: RunPrompt[] onSubmit: (input: RunPrompt) => boolean | Promise onCycle: () => void - onInterrupt: () => boolean + onInterrupt: (mergedDoublePress?: boolean) => boolean onEditorOpen: (input: { value: string }) => Promise onInputClear: () => void onExitRequest?: () => boolean @@ -1000,8 +1000,8 @@ export function createPromptState(input: PromptInput): PromptState { name: "session.interrupt", title: "Interrupt session", category: "Session", - run() { - if (input.onInterrupt()) return + run(ctx: { event: KeyEvent }) { + if (input.onInterrupt(ctx.event.meta)) return return false }, }, diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 0d9da6f297..71f03deefd 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -963,13 +963,15 @@ export class RunFooter implements FooterApi { // Two-press interrupt: first press shows a hint ("esc again to interrupt"), // second press within 5 seconds fires onInterrupt. The timer resets the - // counter if the user doesn't follow through. - private handleInterrupt = (): boolean => { + // counter if the user doesn't follow through. `mergedDoublePress` covers + // terminals that deliver a fast ESC double-press as a single meta-modified + // escape event. + private handleInterrupt = (mergedDoublePress = false): boolean => { if (this.isClosed || this.state().phase !== "running") { return false } - const next = this.state().interrupt + 1 + const next = this.state().interrupt + (mergedDoublePress ? 2 : 1) this.patch({ interrupt: next }) if (next < 2) { diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index 07120eb99c..23c3a162a2 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -95,7 +95,7 @@ type RunFooterViewProps = { onQuestionReply: (input: QuestionReply) => void | Promise onQuestionReject: (input: QuestionReject) => void | Promise onCycle: () => void - onInterrupt: () => boolean + onInterrupt: (mergedDoublePress?: boolean) => boolean onBackground?: () => void onEditorOpen: (input: { value: string }) => Promise onInputClear: () => void diff --git a/packages/opencode/src/dag/dag.ts b/packages/opencode/src/dag/dag.ts index d5e954a2af..ef936b9ea4 100644 --- a/packages/opencode/src/dag/dag.ts +++ b/packages/opencode/src/dag/dag.ts @@ -700,22 +700,52 @@ export const layer = Layer.effect( .map((n) => cfgById.get(n.id)) .filter((n): n is NodeConfig => n !== undefined) const configuredNodes = config?.nodes ?? [] - const hasReportingLeafCheckpoint = nodes.some( + // Leaf qualification runs on the RUNTIME topology, not the static config: + // a dependent that was skipped (condition_false / orphan_cascade) never + // executed, so the graph effectively ended at the checkpoint and the + // naturally-completed workflow may still be reopened by additive extend. + // Dependents that completed or failed continued the graph past the + // checkpoint and block the exception. Row statuses are compared as + // plain strings (loop.ts convention) — enum casts on read-model rows + // trip the lint ratchet. + const executedDependents = (nodeID: string) => + nodes.filter( + (candidate) => + candidate.dependsOn.includes(nodeID) + && (candidate.status === "completed" || candidate.status === "failed"), + ) + const checkpointCandidates = nodes.filter( (node) => - node.status === NodeStatus.COMPLETED + node.status === "completed" && node.wakeEligible - && configuredNodes.some((candidate) => candidate.id === node.id) - && !configuredNodes.some((candidate) => candidate.depends_on.includes(node.id)), + && configuredNodes.some((candidate) => candidate.id === node.id), ) + const hasReportingLeafCheckpoint = checkpointCandidates.some((node) => executedDependents(node.id).length === 0) + const addsNewNode = newNodes.some((node) => !nodes.some((existing) => existing.id === node.id)) + const earlyCompleted = nodes.some((node) => node.errorReason === "agent_complete") const reopenCompleted = - wf.status === WorkflowStatus.COMPLETED - && newNodes.some((node) => !nodes.some((existing) => existing.id === node.id)) + wf.status === "completed" + && addsNewNode && hasReportingLeafCheckpoint - && !nodes.some((node) => node.errorReason === "agent_complete") + && !earlyCompleted + function reopenDenial(workflowStatus: string): string | undefined { + if (workflowStatus === "archived") return "archived workflows are immutable — start a new workflow instead" + if (workflowStatus !== "completed") return "only a naturally completed workflow can be reopened — failed and cancelled workflows are immutable; start a new workflow reusing their completed outputs as static input" + if (!addsNewNode) return "the fragment adds no new node ids — an additive reopen requires at least one new node" + if (earlyCompleted) return "the workflow was completed early via control(complete); early completion stays terminal" + if (checkpointCandidates.length === 0) return "no wake-eligible reporting checkpoint completed the graph — only a naturally completed reporting-leaf checkpoint may be reopened" + const blockers = [...new Set(checkpointCandidates.flatMap((node) => executedDependents(node.id).map((dependent) => dependent.id)))] + return `reporting checkpoint(s) ${checkpointCandidates.map((node) => `"${node.id}"`).join(", ")} are followed by executed dependent(s) ${blockers.map((id) => `"${id}"`).join(", ")} — the graph continued past the checkpoint` + } // A terminal atomic wake may ask the parent to add the next bounded wave. // Keep the exception private to naturally completed additive extension; // an early control(complete) leaves an agent_complete marker and remains // terminal, as do public replan and non-additive terminal mutations. + const wfTerminal = + wf.status === "completed" || wf.status === "failed" || wf.status === "cancelled" || wf.status === "archived" + if (wfTerminal && !reopenCompleted) { + return yield* Effect.fail(new TerminalViolationError(dagID, wf.status, "extend", reopenDenial(wf.status))) + } // Internal call to _replan — shares the caller's lock holding period, // does NOT re-acquire the per-workflow lock or go through Service.of. return yield* _replan(lock, dagID, { nodes: [...preserved, ...newNodes] }, reopenCompleted) diff --git a/packages/opencode/src/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index b4c4ba02a8..121285c1e0 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -279,7 +279,10 @@ export const WorkflowTool = Tool.define< Effect.mapError((error) => new Error(`Invalid workflow spec ${specFile.path}: ${String(error)}`)), Effect.orDie, ) - const r = yield* dag.extend(params.workflow_id, spec.nodes as NodeConfig[]).pipe(Effect.orDie) + const r = yield* withTerminalRecovery( + dag.extend(params.workflow_id, spec.nodes as NodeConfig[]), + "Terminal workflows are immutable except for the additive-extend reopen, which requires the workflow to have completed naturally at a wake-eligible reporting checkpoint (fragment adds new node ids; no early control(complete); no executed node beyond the checkpoint — condition-skipped dependents are fine). When the reopen does not apply, recover by starting a NEW workflow spec that reuses this workflow's completed outputs as static input.", + ).pipe(Effect.orDie) return { title: `Workflow extended: ${r.add.length} nodes added`, output: `\nAdded: ${r.add.join(", ")}\n`, @@ -313,19 +316,13 @@ export const WorkflowTool = Tool.define< Effect.mapError((error) => new Error(`Invalid workflow spec ${specFile.path}: ${String(error)}`)), Effect.orDie, ) - const r = yield* dag.replan(wfId, { nodes: spec.fragment.nodes as NodeConfig[] }).pipe( - // The graph raced to terminal while the fragment was being - // composed (the pause-first protocol was skipped). Surface - // the recovery options instead of a bare iron-law rejection. - Effect.catchIf( - (err): err is TerminalViolationError => err instanceof TerminalViolationError, - (err) => - Effect.die(new Error( - `${err.message}. The workflow reached a terminal status before the replan arrived — terminal workflows are immutable. Recover by writing a new start spec with the updated node definitions and passing its spec_path, or extend if a reporting leaf checkpoint naturally completed the graph. Next time issue control(pause) BEFORE composing the spec file.`, - )), - ), - Effect.orDie, - ) + // The graph raced to terminal while the fragment was being + // composed (the pause-first protocol was skipped). Surface + // the recovery options instead of a bare iron-law rejection. + const r = yield* withTerminalRecovery( + dag.replan(wfId, { nodes: spec.fragment.nodes as NodeConfig[] }), + "The workflow reached a terminal status before the replan arrived — terminal workflows are immutable. Recover by writing a new start spec with the updated node definitions and passing its spec_path, or extend if a reporting leaf checkpoint naturally completed the graph. Next time issue control(pause) BEFORE composing the spec file.", + ).pipe(Effect.orDie) const ignored = r.ignore.length > 0 ? `\nIgnored (terminal, immutable — add replacements under new ids to retry): ${r.ignore.join(", ")}` : "" return { title: `Workflow replanned: +${r.add.length} -${r.cancel.length} ↻${r.restart.length}`, @@ -430,6 +427,17 @@ function workflowSpecParseError(filepath: string, error: unknown) { return new Error(`Invalid workflow YAML ${filepath}: ${error instanceof Error ? error.message : String(error)}`) } +/** Terminal-workflow rejections surface as defects carrying recovery + * guidance, not bare iron-law errors. Shared by the replan and extend paths. */ +function withTerminalRecovery(effect: Effect.Effect, guidance: string) { + return effect.pipe( + Effect.catchIf( + (err): err is TerminalViolationError => err instanceof TerminalViolationError, + (err) => Effect.die(new Error(`${err.message}. ${guidance}`)), + ), + ) +} + function findNodesWithoutModel(input: { nodes: ReadonlyArray> defaults?: Schema.Schema.Type["node_defaults"] diff --git a/packages/opencode/test/dag/dag-wake-integration.test.ts b/packages/opencode/test/dag/dag-wake-integration.test.ts index 3ee133fe1b..dccc095701 100644 --- a/packages/opencode/test/dag/dag-wake-integration.test.ts +++ b/packages/opencode/test/dag/dag-wake-integration.test.ts @@ -84,6 +84,15 @@ function promptText(input: SessionPrompt.PromptInput) { .join("\n") } +function waitForCompletion(store: DagStore.Interface, dagID: string, message: string) { + return pollWithTimeout( + store.getWorkflow(dagID).pipe( + Effect.map((workflow) => workflow?.status === "completed" ? true : undefined), + ), + message, + ) +} + function wakeLayer(input: { readonly childPrompts: Queue.Queue readonly parentPrompts: Queue.Queue @@ -531,6 +540,74 @@ describe("DagLoop atomic wake integration", () => { ), ) + integration.live("reopens a completed workflow whose checkpoint dependents were condition-skipped", () => + runWakeTest(({ dag, store, childPrompts, parentPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create({ + projectID: "project-1", + sessionID: "ses_parent", + title: "Skipped-dependent checkpoint continuation", + config: { + name: "skipped-dependent-checkpoint-continuation", + nodes: [ + node("checkpoint"), + { + ...node("downstream", ["checkpoint"]), + condition: 'checkpoint.output == "GO"', + }, + ], + }, + }) + + const checkpoint = yield* takeWithin(childPrompts, "checkpoint did not start") + yield* Deferred.succeed(checkpoint.release, "REVISE") + yield* waitForCompletion(store, dagID, "checkpoint workflow did not complete") + expect((yield* store.getNode(dagID, "downstream"))?.status).toBe("skipped") + expect((yield* store.getNode(dagID, "downstream"))?.errorReason).toBe("condition_false") + + const parent = yield* takeWithin(parentPrompts, "terminal checkpoint did not wake the parent") + const result = yield* dag.extend(dagID, [node("repair", ["checkpoint"])]) + expect(result.add).toEqual(["repair"]) + + const repair = yield* takeWithin(childPrompts, "additive repair node did not start") + expect(repair.title).toBe("repair") + expect((yield* store.getWorkflow(dagID))?.status).toBe("running") + expect((yield* store.getNode(dagID, "checkpoint"))?.status).toBe("completed") + yield* Deferred.succeed(parent.release, "success") + yield* Deferred.succeed(repair.release, "fixed") + yield* waitForCompletion(store, dagID, "extended workflow did not complete") + }), + ), + ) + + integration.live("keeps a completed workflow terminal when the graph ran past the checkpoint", () => + runWakeTest(({ dag, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create({ + projectID: "project-1", + sessionID: "ses_parent", + title: "Post-checkpoint completion", + config: { + name: "post-checkpoint-completion", + nodes: [node("checkpoint"), { ...node("downstream", ["checkpoint"]), report_to_parent: false }], + }, + }) + + const checkpoint = yield* takeWithin(childPrompts, "checkpoint did not start") + yield* Deferred.succeed(checkpoint.release, "CHECK") + const downstream = yield* takeWithin(childPrompts, "downstream did not start") + yield* Deferred.succeed(downstream.release, "done") + yield* waitForCompletion(store, dagID, "workflow did not complete") + + const error = yield* dag.extend(dagID, [node("repair", ["checkpoint"])]).pipe( + Effect.catch((cause: Error) => Effect.succeed(cause)), + ) + if (!(error instanceof TerminalViolationError)) throw new Error("extend unexpectedly succeeded past a terminal checkpoint") + expect(error.message).toContain("continued past the checkpoint") + }), + ), + ) + integration.live("keeps an early-completed workflow terminal", () => runWakeTest(({ dag, store, childPrompts }) => Effect.gen(function* () { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 80adc99a5f..b205a4877a 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -306,6 +306,10 @@ export function Prompt(props: PromptProps) { // Initialize agent/model/variant from last user message when session changes let syncedSessionID: string | undefined + let interruptTimer: Timer | undefined + onCleanup(() => { + if (interruptTimer) clearTimeout(interruptTimer) + }) createEffect(() => { const sessionID = props.sessionID const msg = lastUserMessage() @@ -391,7 +395,7 @@ export function Prompt(props: PromptProps) { category: "Session", hidden: true, enabled: status().type !== "idle", - run: () => { + run: (ctx: CommandContext) => { if (auto()?.visible) return if (!input.focused) return // TODO: this should be its own command @@ -401,13 +405,20 @@ export function Prompt(props: PromptProps) { } if (!props.sessionID) return - setStore("interrupt", store.interrupt + 1) + // Terminals can deliver a fast ESC double-press as a single + // meta-modified escape event; count it as the confirmed second + // press instead of losing it. + setStore("interrupt", store.interrupt + (ctx.event.meta ? 2 : 1)) - setTimeout(() => { + if (interruptTimer) clearTimeout(interruptTimer) + interruptTimer = setTimeout(() => { + interruptTimer = undefined setStore("interrupt", 0) }, 5000) if (store.interrupt >= 2) { + if (interruptTimer) clearTimeout(interruptTimer) + interruptTimer = undefined void sdk.client.session.abort({ sessionID: props.sessionID, }) diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index c5384fd95d..af7eada8f8 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -107,7 +107,7 @@ export const Definitions = { session_delete: keybind("ctrl+d", "Delete session"), session_share: keybind("none", "Share current session"), session_unshare: keybind("none", "Unshare current session"), - session_interrupt: keybind("escape", "Interrupt current session"), + session_interrupt: keybind(["escape", "alt+escape"], "Interrupt current session"), session_background: keybind("ctrl+b", "Background synchronous subagents"), session_compact: keybind("c", "Compact the session"), session_toggle_timestamps: keybind("none", "Toggle message timestamps"), diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx index b0eed30812..683e5bcae7 100644 --- a/packages/tui/test/keymap.test.tsx +++ b/packages/tui/test/keymap.test.tsx @@ -63,6 +63,51 @@ test("legacy page key aliases compile as page keys", async () => { } }) +test("session interrupt binds a meta-modified escape fallback for merged double-ESC presses", async () => { + const captured: { strokes: Array> } = { strokes: [] } + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const config = createResolvedKeymapConfig() + const offKeymap = registerOpencodeKeymap(keymap, renderer, config) + const offLayer = keymap.registerLayer({ + commands: [{ name: "session.interrupt", run() {} }], + bindings: config.keybinds.gather("prompt.palette", ["session.interrupt"]), + }) + captured.strokes = + keymap + .getCommandBindings({ + visibility: "registered", + commands: ["session.interrupt"], + }) + .get("session.interrupt") + ?.map((binding) => + binding.sequence.map((part) => ({ + name: part.stroke.name, + ...(part.stroke.meta ? { meta: true } : {}), + })), + ) ?? [] + onCleanup(() => { + offLayer() + offKeymap() + }) + + return ( + + + + ) + } + + const app = await testRender(() => ) + try { + expect(captured.strokes).toEqual([[{ name: "escape" }], [{ name: "escape", meta: true }]]) + } finally { + app.renderer.destroy() + } +}) + test("mode-less bindings stay active when opencode mode changes", async () => { const counts: Record> = {}