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
15 changes: 15 additions & 0 deletions docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

## 结论

Expand Down Expand Up @@ -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。
18 changes: 8 additions & 10 deletions extensions/workflows/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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";
}

/**
Expand Down
66 changes: 37 additions & 29 deletions extensions/workflows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ import {
} from "./completion-projection.ts";
import { RunController } from "./controller.ts";
import {
resolveWorkflowLaunchPolicy,
resolveWorkflowLaunchMode,
waitForWorkflowCompletion,
} from "./coordinator.ts";
import {
Expand Down Expand Up @@ -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<typeof WorkflowParams>;

Expand Down Expand Up @@ -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 = {
Expand All @@ -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,
},
Expand Down Expand Up @@ -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)) {
Expand Down
6 changes: 3 additions & 3 deletions extensions/workflows/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
4 changes: 3 additions & 1 deletion skills/workflows/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
56 changes: 32 additions & 24 deletions tests/extensions/workflows/coordinator.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down
Loading
Loading