Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/core/src/dag/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/cli/cmd/run/footer.prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ type PromptInput = {
history?: RunPrompt[]
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void
onInterrupt: () => boolean
onInterrupt: (mergedDoublePress?: boolean) => boolean
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onInputClear: () => void
onExitRequest?: () => boolean
Expand Down Expand Up @@ -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
},
},
Expand Down
8 changes: 5 additions & 3 deletions packages/opencode/src/cli/cmd/run/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/run/footer.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ type RunFooterViewProps = {
onQuestionReply: (input: QuestionReply) => void | Promise<void>
onQuestionReject: (input: QuestionReject) => void | Promise<void>
onCycle: () => void
onInterrupt: () => boolean
onInterrupt: (mergedDoublePress?: boolean) => boolean
onBackground?: () => void
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onInputClear: () => void
Expand Down
44 changes: 37 additions & 7 deletions packages/opencode/src/dag/dag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 22 additions & 14 deletions packages/opencode/src/tool/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<workflow id="${params.workflow_id}" action="extend">\nAdded: ${r.add.join(", ")}\n</workflow>`,
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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<A>(effect: Effect.Effect<A, Error>, 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<Schema.Schema.Type<typeof NodeSchema>>
defaults?: Schema.Schema.Type<typeof WorkflowGraphSchema>["node_defaults"]
Expand Down
77 changes: 77 additions & 0 deletions packages/opencode/test/dag/dag-wake-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ function promptText(input: SessionPrompt.PromptInput) {
.join("\n")
}

function waitForCompletion(store: DagStore.Interface, dagID: string, message: string) {
return pollWithTimeout<true, Error, never>(
store.getWorkflow(dagID).pipe(
Effect.map((workflow) => workflow?.status === "completed" ? true : undefined),
),
message,
)
}

function wakeLayer(input: {
readonly childPrompts: Queue.Queue<PromptGate>
readonly parentPrompts: Queue.Queue<ParentPromptGate>
Expand Down Expand Up @@ -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* () {
Expand Down
17 changes: 14 additions & 3 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -391,7 +395,7 @@ export function Prompt(props: PromptProps) {
category: "Session",
hidden: true,
enabled: status().type !== "idle",
run: () => {
run: (ctx: CommandContext<Renderable, KeyEvent>) => {
if (auto()?.visible) return
if (!input.focused) return
// TODO: this should be its own command
Expand All @@ -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,
})
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/src/config/keybind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<leader>c", "Compact the session"),
session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
Expand Down
45 changes: 45 additions & 0 deletions packages/tui/test/keymap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<{ name: string; meta?: boolean }>> } = { 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 (
<OpencodeKeymapProvider keymap={keymap}>
<box />
</OpencodeKeymapProvider>
)
}

const app = await testRender(() => <Harness />)
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<string, Record<string, number>> = {}

Expand Down
Loading