diff --git a/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md b/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md index eb5080be..61ed7f67 100644 --- a/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md +++ b/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md @@ -5,6 +5,8 @@ > 状态:已在 `codex/workflow-v2` 实施;最终验证与真实模型 smoke 见文末实施记录。 > > 依据:当前 OpenPI 源码、Issues #71/#74/#75/#90、Claude Code `2.1.241` 运行时合同访谈,以及三份相互独立的 interface 设计评审。 +> +> 后续决定(2026-08-30):Issue #132 / PR #139 将新调用策略收敛为 `wait`,同时为已发布的 `background` alias 保留迁移窗口。本文件保留 Workflow V2 落地时的历史合同与验证证据;当前行为以代码和当前用户文档为准,后续结果见文末 addendum。 ## 结论 @@ -663,3 +665,16 @@ bun run test ### 14.3 当前结论 Lifecycle、delivery、Schema stability、dynamic capacity、fair projection 和 artifact 证据链已经实现并有确定性或真实模型证据。尚未把通用 Execution Fabric 暴露给模型,也没有自动插入 Report Agent;这两项是刻意不做,而非未完成缺口。真正的大规模质量仍应通过后续冻结配置的 2×2 benchmark 决定,不用单次 smoke 冒充跑分提升。 + +## 15. 后续合同变更(2026-08-30) + +Issue #132 / PR #139 将 `wait` 作为唯一推荐的新调用策略。由于 `background` 从 OpenPI v0.2.0 起就是已发布输入,本次继续把它作为 deprecated inverse alias 接受:`background: true` 对应 `wait: false`,`background: false` 对应 `wait: true`;真正删除只在另行公告的 breaking release 进行。除这一已发布兼容字段外,未知输入继续 fail closed。 + +Coordinator 在单一输入边界完成 legacy 映射,内部仍只产生 `inline | detached` 运行模式。`WorkflowDetails.background` 与 persisted artifact 中的同名字段继续记录实际 detached 状态,不记录调用时使用的是 `wait` 还是兼容 alias,也不改写历史 artifact。 + +该后续变更的最终验证以 PR #139 exact-head review 为准,至少包括: + +- `wait`、legacy `background`、冲突输入、host delivery 能力和 wait interruption 的专项测试; +- `bun run check`; +- `bun run test`; +- GitHub CI:Node 22.19.0、Node 24 与 Windows background-terminal suite。 diff --git a/extensions/workflows/coordinator.ts b/extensions/workflows/coordinator.ts index 3be2041c..b6ab4500 100644 --- a/extensions/workflows/coordinator.ts +++ b/extensions/workflows/coordinator.ts @@ -3,23 +3,21 @@ export interface WorkflowLaunchPolicyInput { background?: boolean; } -export interface WorkflowLaunchPolicy { - wait: boolean; - detached: boolean; -} - -/** Resolve legacy/background and host capability without silently changing semantics. */ -export function resolveWorkflowLaunchPolicy( +/** + * Resolve the caller's launch preference to one positive runtime mode. + * `background` remains only as the published inverse compatibility alias. + */ +export function resolveWorkflowLaunchMode( input: WorkflowLaunchPolicyInput, canDeliverLater: boolean, -): WorkflowLaunchPolicy { +) { if ( input.wait !== undefined && input.background !== undefined && input.wait === input.background ) { throw new Error( - "wait and background conflict: background is the deprecated inverse of wait", + "wait and background conflict: background is the deprecated inverse of wait; remove background and provide only wait", ); } const wait = @@ -30,7 +28,7 @@ export function resolveWorkflowLaunchPolicy( "This host cannot deliver a workflow result later; use wait: true", ); } - return { wait, detached: !wait }; + return wait ? "inline" : "detached"; } /** diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index 166b4f6d..370003ff 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -101,7 +101,7 @@ import { } from "./completion-projection.ts"; import { RunController } from "./controller.ts"; import { - resolveWorkflowLaunchPolicy, + resolveWorkflowLaunchMode, waitForWorkflowCompletion, } from "./coordinator.ts"; import { @@ -552,31 +552,35 @@ interface AgentCallOptions { inputs?: unknown; } -const WorkflowParams = Type.Object({ - script: Type.String({ - description: WORKFLOW_PARAMETER_DESCRIPTIONS.script, - }), - args: Type.Optional( - Type.String({ - description: WORKFLOW_PARAMETER_DESCRIPTIONS.args, +const WorkflowParams = Type.Object( + { + script: Type.String({ + description: WORKFLOW_PARAMETER_DESCRIPTIONS.script, }), - ), - background: Type.Optional( - Type.Boolean({ - description: WORKFLOW_PARAMETER_DESCRIPTIONS.background, - }), - ), - wait: Type.Optional( - Type.Boolean({ - description: WORKFLOW_PARAMETER_DESCRIPTIONS.wait, - }), - ), - resume_from_run_id: Type.Optional( - Type.String({ - description: WORKFLOW_PARAMETER_DESCRIPTIONS.resumeFromRunId, - }), - ), -}); + args: Type.Optional( + Type.String({ + description: WORKFLOW_PARAMETER_DESCRIPTIONS.args, + }), + ), + background: Type.Optional( + Type.Boolean({ + deprecated: true, + description: WORKFLOW_PARAMETER_DESCRIPTIONS.background, + }), + ), + wait: Type.Optional( + Type.Boolean({ + description: WORKFLOW_PARAMETER_DESCRIPTIONS.wait, + }), + ), + resume_from_run_id: Type.Optional( + Type.String({ + description: WORKFLOW_PARAMETER_DESCRIPTIONS.resumeFromRunId, + }), + ), + }, + { additionalProperties: false }, +); type WorkflowInput = Static; @@ -1229,11 +1233,11 @@ export default function workflows( const runId = `wf_${randomBytes(6).toString("hex")}`; const runDir = path.join(getAgentDir(), "workflows", runId); const canDeliverLater = ctx.hasUI && ctx.mode === "tui"; - const launchPolicy = resolveWorkflowLaunchPolicy( + const launchMode = resolveWorkflowLaunchMode( { wait: params.wait, background: params.background }, canDeliverLater, ); - const background = launchPolicy.detached; + const background = launchMode === "detached"; const now = Date.now(); const details: WorkflowDetails = { @@ -1248,7 +1252,7 @@ export default function workflows( agents: [], delivery: { id: `workflow:${runId}:terminal`, - state: launchPolicy.wait ? "held-for-inline" : "none", + state: launchMode === "inline" ? "held-for-inline" : "none", attempts: 0, updatedAt: now, }, @@ -2320,7 +2324,11 @@ export default function workflows( let text = theme.fg("toolTitle", theme.bold("workflow ")) + theme.fg("accent", (meta as WorkflowMeta).name ?? "(script)"); - if (args.background) text += theme.fg("dim", " (background)"); + if (args.background !== undefined) { + text += theme.fg("dim", ` (deprecated: use wait: ${!args.background})`); + } else if (args.wait === true) { + text += theme.fg("dim", " (wait)"); + } const description = (meta as WorkflowMeta).description; if (description) text += `\n ${theme.fg("dim", description)}`; for (const phase of meta.phases.slice(0, 8)) { diff --git a/extensions/workflows/prompt.ts b/extensions/workflows/prompt.ts index e6d40555..a56afddc 100644 --- a/extensions/workflows/prompt.ts +++ b/extensions/workflows/prompt.ts @@ -18,15 +18,15 @@ export const WORKFLOW_PARAMETER_DESCRIPTIONS = { "JavaScript workflow script. May start with `export const meta = {...}`, then use phase(), agent(), parallel(), args, and a final `return`.", args: "Optional JSON string exposed to the script as `args` (parsed when valid JSON, otherwise passed through as the raw string).", background: - "Deprecated compatibility alias: true means wait=false; false means wait=true. Do not provide both background and wait.", + "Deprecated compatibility alias for published callers only; new calls must use wait. Replace true with wait=false and false with wait=true. Do not provide both fields. The alias will be removed in the next announced breaking release.", wait: "Wait for the final result in this tool call. Interactive sessions default to false and deliver completion later; print/automation defaults to true. Interrupting the wait does not cancel the workflow.", resumeFromRunId: "Optional prior run id or unique suffix for safe read-only replay. See the workflows Skill for matching rules.", }; -/** Describes stopping a running background workflow, mirroring subagent_cancel/bg_kill. */ +/** Describes stopping a running workflow, mirroring subagent_cancel/bg_kill. */ export const WORKFLOW_STOP_TOOL_DESCRIPTION = - "Cancel a running background workflow by its run id (from the workflow launch result). This aborts its remaining agents and settles the run; partial results and artifacts are preserved. Only background runs need this — a blocking workflow is already cancelled by interrupting the turn."; + "Cancel a running workflow by its run id (from the workflow launch result). This aborts its remaining agents and settles the run; partial results and artifacts are preserved."; /** Model-facing schema description for the workflow run id to stop. */ export const WORKFLOW_STOP_PARAMETER_DESCRIPTIONS = { diff --git a/skills/workflows/REFERENCE.md b/skills/workflows/REFERENCE.md index 3e32db34..96979a88 100644 --- a/skills/workflows/REFERENCE.md +++ b/skills/workflows/REFERENCE.md @@ -40,7 +40,9 @@ Each call persists intent, admission, and execution state. Interrupted nontermin ## Lifecycle and replay -Interactive TUI runs return an accepted run id immediately by default, release the parent turn, and later deliver a terminal completion with a stable delivery id. Delivery is at least once: normal retries do not duplicate a run, but a process loss after Pi accepts the message and before the receipt is persisted can replay the same id. `wait: true` explicitly waits inline; interrupting that wait releases only the waiter and the run continues. Print/automation defaults to waiting because it has no later delivery channel. The deprecated `background` parameter remains an inverse compatibility alias and cannot be combined with `wait`. +Interactive TUI runs return an accepted run id immediately by default, release the parent turn, and later deliver a terminal completion with a stable delivery id. Delivery is at least once: normal retries do not duplicate a run, but a process loss after Pi accepts the message and before the receipt is persisted can replay the same id. `wait: true` explicitly waits inline; interrupting that wait releases only the waiter and the run continues. Print/automation defaults to waiting because it has no later delivery channel. + +New calls must use `wait`. For compatibility with released OpenPI versions, the deprecated `background` alias remains accepted during the current migration window: replace `background: true` with `wait: false`, or `background: false` with `wait: true`, and do not provide both fields. The alias will be removed only in an announced breaking release. Persisted artifact/details fields named `background` remain actual detached-state facts and are not part of that removal. Loading the Workflow capability exposes `workflow`, `workflow_status`, and `workflow_stop` as one stable group; starting or settling a run does not mutate the model tool Schema. `workflow_status` returns a bounded state/coverage summary and artifact path without consuming or repeating the full completion. `workflow_stop` is idempotent and preserves partial artifacts. A failed completion send remains pending with the same per-run delivery identity and is retried when the parent settles or the Session is restored. diff --git a/tests/extensions/workflows/coordinator.test.ts b/tests/extensions/workflows/coordinator.test.ts index f4fd1233..0e446c2c 100644 --- a/tests/extensions/workflows/coordinator.test.ts +++ b/tests/extensions/workflows/coordinator.test.ts @@ -1,45 +1,53 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - resolveWorkflowLaunchPolicy, + resolveWorkflowLaunchMode, waitForWorkflowCompletion, } from "../../../extensions/workflows/coordinator.ts"; test("interactive launch defaults detached while non-delivery hosts wait", () => { - assert.deepEqual(resolveWorkflowLaunchPolicy({}, true), { - wait: false, - detached: true, - }); - assert.deepEqual(resolveWorkflowLaunchPolicy({}, false), { - wait: true, - detached: false, - }); + assert.equal(resolveWorkflowLaunchMode({}, true), "detached"); + assert.equal(resolveWorkflowLaunchMode({}, false), "inline"); }); test("wait is authoritative and legacy background maps to its inverse", () => { - assert.deepEqual(resolveWorkflowLaunchPolicy({ wait: true }, true), { - wait: true, - detached: false, - }); - assert.deepEqual(resolveWorkflowLaunchPolicy({ background: true }, true), { - wait: false, - detached: true, - }); - assert.deepEqual(resolveWorkflowLaunchPolicy({ background: false }, true), { - wait: true, - detached: false, - }); + assert.equal(resolveWorkflowLaunchMode({ wait: true }, true), "inline"); + assert.equal(resolveWorkflowLaunchMode({ wait: false }, true), "detached"); + assert.equal( + resolveWorkflowLaunchMode({ background: true }, true), + "detached", + ); + assert.equal( + resolveWorkflowLaunchMode({ background: false }, true), + "inline", + ); + assert.equal( + resolveWorkflowLaunchMode({ wait: true, background: false }, true), + "inline", + ); + assert.equal( + resolveWorkflowLaunchMode({ wait: false, background: true }, true), + "detached", + ); }); test("conflicting aliases and unsupported detached delivery fail closed", () => { assert.throws( - () => resolveWorkflowLaunchPolicy({ wait: true, background: true }, true), - /conflict/, + () => resolveWorkflowLaunchMode({ wait: true, background: true }, true), + /conflict.*background is the deprecated inverse of wait/i, + ); + assert.throws( + () => resolveWorkflowLaunchMode({ wait: false, background: false }, true), + /conflict.*background is the deprecated inverse of wait/i, ); assert.throws( - () => resolveWorkflowLaunchPolicy({ wait: false }, false), + () => resolveWorkflowLaunchMode({ wait: false }, false), /cannot deliver/, ); + assert.throws( + () => resolveWorkflowLaunchMode({ background: true }, false), + /cannot deliver.*wait: true/i, + ); }); test("wait cancellation does not cancel the underlying completion", async () => { diff --git a/tests/extensions/workflows/execute.e2e.test.ts b/tests/extensions/workflows/execute.e2e.test.ts index 04ecd601..7db782a3 100644 --- a/tests/extensions/workflows/execute.e2e.test.ts +++ b/tests/extensions/workflows/execute.e2e.test.ts @@ -11,6 +11,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, writeFileSync, @@ -363,7 +364,7 @@ test("oversized workflow args fail before child sessions or journals are created for (const rawArgs of args) { const launch = (await workflow.execute( "e2e-oversized-workflow-args", - { script, args: rawArgs, background: true }, + { script, args: rawArgs, wait: false }, undefined, undefined, ctx, @@ -391,6 +392,132 @@ test("oversized workflow args fail before child sessions or journals are created assert.equal(sessionCreations, 0); }); +test("print hosts wait by default and reject detached delivery", async () => { + const printCtx = { + ...ctx, + mode: "print", + hasUI: false, + } as unknown as ExtensionContext; + const inline = (await workflow.execute( + "e2e-print-default", + { + script: + 'export const meta = { name: "print-default" };\nreturn { inline: true };', + }, + undefined, + undefined, + printCtx, + )) as AgentToolResult; + + assert.equal(inline.details.status, "completed"); + assert.equal(inline.details.background, false); + assert.equal(inline.details.delivery?.state, "consumed-inline"); + + const legacyInline = (await workflow.execute( + "e2e-print-legacy-inline", + { + script: + 'export const meta = { name: "print-legacy-inline" };\nreturn { inline: true };', + background: false, + }, + undefined, + undefined, + printCtx, + )) as AgentToolResult; + assert.equal(legacyInline.details.background, false); + assert.equal(legacyInline.details.delivery?.state, "consumed-inline"); + assert.doesNotMatch( + legacyInline.content + .map((entry) => (entry.type === "text" ? entry.text : "")) + .join("\n"), + /deprecated|migration/i, + ); + + const workflowsDir = join(agentDir, "workflows"); + const runDirsBefore = readdirSync(workflowsDir).sort(); + const messagesBefore = sentMessages.length; + for (const input of [{ wait: false }, { background: true }]) { + await assert.rejects( + Promise.resolve().then(() => + workflow.execute( + "e2e-print-detached", + { script: "return { detached: true };", ...input }, + undefined, + undefined, + printCtx, + ), + ), + /cannot deliver.*wait: true/i, + ); + } + assert.deepEqual(readdirSync(workflowsDir).sort(), runDirsBefore); + assert.equal(sentMessages.length, messagesBefore); +}); + +test("interrupting an inline wait leaves the run stoppable and delivers one terminal result", async () => { + sentMessages.length = 0; + modelIdle = true; + let sessionCreated = false; + let releasePrompt = () => {}; + const promptGate = new Promise((resolve) => { + releasePrompt = resolve; + }); + __setWorkflowTestAgentSessionFactory(async () => { + sessionCreated = true; + return { session: fakeAgentSession("interrupted output", promptGate) }; + }); + + try { + const controller = new AbortController(); + let interruptedMessage = ""; + const execution = Promise.resolve( + workflow.execute( + "e2e-interrupted-inline-wait", + { + script: + 'export const meta = { name: "interrupted-inline-wait" };\n' + + 'return await agent("wait for interruption", { agent_type: "reviewer" });', + wait: true, + }, + controller.signal, + undefined, + ctx, + ), + ).then( + () => assert.fail("interrupted inline wait unexpectedly resolved"), + (error: unknown) => { + interruptedMessage = String( + error instanceof Error ? error.message : error, + ); + }, + ); + + await waitFor(() => sessionCreated, "inline workflow before interruption"); + controller.abort(); + await execution; + const runId = interruptedMessage.match(/run (wf_[0-9a-f]+)/)?.[1]; + assert.ok(runId); + assert.match(interruptedMessage, /continues in the background/); + assert.equal(readWorkflowJson(runId).status, "running"); + + await workflowStop.execute("e2e-interrupted-inline-stop", { runId }); + releasePrompt(); + await waitFor( + () => readWorkflowJson(runId).status === "aborted", + "interrupted inline workflow cancellation", + ); + await waitFor( + () => + sentMessages.filter((sent) => sent.message.details?.runId === runId) + .length === 1, + "interrupted inline terminal delivery", + ); + } finally { + releasePrompt(); + __setWorkflowTestAgentSessionFactory(undefined); + } +}); + test("background runs deliver a follow-up that triggers a turn only when idle", async () => { sentMessages.length = 0; @@ -429,7 +556,7 @@ test("background runs deliver a follow-up that triggers a turn only when idle", "e2e-bg-busy", { script: 'export const meta = { name: "bg-busy" };\nreturn 8;', - background: true, + wait: false, }, undefined, undefined, diff --git a/tests/extensions/workflows/prompt.test.ts b/tests/extensions/workflows/prompt.test.ts index 53f26a12..ccf880cb 100644 --- a/tests/extensions/workflows/prompt.test.ts +++ b/tests/extensions/workflows/prompt.test.ts @@ -244,14 +244,8 @@ test("launch result advertises the model-facing lifecycle tools", () => { }); test("lifecycle tool descriptions state their scope and non-blocking nature", () => { - assert.match( - WORKFLOW_STOP_TOOL_DESCRIPTION, - /Cancel a running background workflow/, - ); - assert.match( - WORKFLOW_STOP_TOOL_DESCRIPTION, - /Only background runs need this/, - ); + assert.match(WORKFLOW_STOP_TOOL_DESCRIPTION, /Cancel a running workflow/); + assert.doesNotMatch(WORKFLOW_STOP_TOOL_DESCRIPTION, /interrupting the turn/); assert.match(WORKFLOW_STATUS_TOOL_DESCRIPTION, /without blocking/); assert.match(WORKFLOW_STATUS_TOOL_DESCRIPTION, /Does not wait/); }); diff --git a/tests/extensions/workflows/rendering.test.ts b/tests/extensions/workflows/rendering.test.ts index 5c540fdb..4674722d 100644 --- a/tests/extensions/workflows/rendering.test.ts +++ b/tests/extensions/workflows/rendering.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { validateToolArguments } from "@earendil-works/pi-ai"; import { type AgentToolResult, type ExtensionAPI, @@ -98,6 +99,97 @@ function captureRenderers() { return { workflow, message }; } +test("workflow launch schema recommends wait while preserving only the published alias", () => { + const { workflow } = captureRenderers(); + const parameters = workflow.parameters as unknown as { + properties?: Record; + additionalProperties?: boolean; + }; + + assert.ok(parameters.properties?.wait); + assert.deepEqual( + (parameters.properties?.background as { deprecated?: unknown })?.deprecated, + true, + ); + assert.equal(parameters.additionalProperties, false); + + const toolCall = (args: Record) => ({ + type: "toolCall" as const, + id: "call-schema", + name: "workflow", + arguments: args, + }); + const script = "return 1;"; + + assert.deepEqual( + validateToolArguments(workflow, toolCall({ script, wait: false })), + { script, wait: false }, + ); + assert.deepEqual( + validateToolArguments(workflow, toolCall({ script, background: true })), + { script, background: true }, + ); + assert.throws( + () => validateToolArguments(workflow, toolCall({ script, detached: true })), + /Validation failed.*detached/s, + ); +}); + +test("workflow call rendering labels an explicit inline wait", () => { + const { workflow } = captureRenderers(); + assert.ok(workflow.renderCall); + const args = { + script: 'export const meta = { name: "inline" }; return 1;', + wait: true, + }; + + const component = workflow.renderCall(args, theme, { + args, + toolCallId: "call-inline-wait", + invalidate() {}, + lastComponent: undefined, + state: {}, + cwd: process.cwd(), + executionStarted: true, + argsComplete: true, + isPartial: false, + expanded: false, + showImages: false, + isError: false, + }); + + assert.match(component.render(100).join("\n"), /workflow inline \(wait\)/); +}); + +test("workflow call rendering gives legacy callers an actionable migration", () => { + const { workflow } = captureRenderers(); + assert.ok(workflow.renderCall); + const args = { + script: 'export const meta = { name: "legacy" }; return 1;', + background: true, + }; + + const component = workflow.renderCall(args, theme, { + args, + toolCallId: "call-legacy-background", + invalidate() {}, + lastComponent: undefined, + state: {}, + cwd: process.cwd(), + executionStarted: true, + argsComplete: true, + isPartial: false, + expanded: false, + showImages: false, + isError: false, + }); + + assert.match( + component.render(100).join("\n"), + /workflow legacy \(deprecated: use wait: false\)/, + ); +}); + test("workflow tool errors with malformed details fall back to plain text", (t) => { t.mock.timers.enable({ apis: ["setInterval", "Date"], now: 0 }); const { workflow } = captureRenderers(); diff --git a/tests/extensions/workflows/target-resolution.test.ts b/tests/extensions/workflows/target-resolution.test.ts index 08705982..207b2f7a 100644 --- a/tests/extensions/workflows/target-resolution.test.ts +++ b/tests/extensions/workflows/target-resolution.test.ts @@ -125,7 +125,7 @@ test("an ambiguous short suffix cannot stop or inspect either active run", async { script: 'export const meta = { name: "pending", phases: [] };\nawait new Promise(() => {});', - background: true, + wait: false, }, undefined, undefined,