Skip to content
Open
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
12 changes: 6 additions & 6 deletions docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
>
> 依据:当前 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。
> 后续决定(2026-08-30):Issue #132 / PR #139 先将新调用策略收敛为 `wait` 并保留兼容窗口;在 OpenPI 1.0.0 breaking release 中移除 workflow 调用参数 `background`。本文件保留 Workflow V2 落地时的历史合同与验证证据;当前行为以代码和当前用户文档为准,后续结果见文末 addendum。

## 结论

Expand Down Expand Up @@ -35,14 +35,14 @@ workflow_stop

本轮没有把整个 2200 行 Workflow extension 塞进一个新的大类,而是按不变量拆成三个深 seam:

- `coordinator.ts`:宿主默认值、`wait/background` 兼容解析,以及 wait/terminal 仲裁;
- `coordinator.ts`:宿主默认值、`wait` 解析,以及 wait/terminal 仲裁;
- `result-delivery.ts`:逐 run delivery identity、pending/receipt/retry/restore;
- `shared/result-budget.ts` 与 `shared/text-projection.ts`:Subagent/Workflow 共用的公平预算与 head/tail 投影。

已经落地:

- TUI 默认 detached,print/无可靠投递宿主默认 wait;
- `wait:true` 是唯一正向同步选择,`background` 仅为 deprecated inverse alias
- `wait:true` 是唯一正向同步选择;
- 中断 wait 不取消 run,stop/shutdown 才拥有取消权;
- terminal execution state 与 delivery state 正交持久化;
- send failure 以同一 per-run id 重试,成功 sibling 不重发;
Expand Down Expand Up @@ -668,13 +668,13 @@ Lifecycle、delivery、Schema stability、dynamic capacity、fair projection 和

## 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
Issue #132 / PR #139 将 `wait` 作为唯一推荐的新调用策略,并在兼容窗口接受已发布的 `background` inverse alias。OpenPI 1.0.0 现在移除该调用字段;未知输入(包括旧 `background` 参数)由 `additionalProperties: false` fail closed。除调用 schema 外,历史 artifact 的兼容读取不受影响

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [P2] 这里把本次删除 alias 写进了 #139 的历史合同,而下面 675–677 仍把最终验证归于 #139 exact-head review;那个 review 实际验证的是保留 alias。请保留 #139 原结论和对应凭证,另追加 #305 的变更说明与本次验证边界,避免旧凭证证明相反的新行为。


Coordinator 在单一输入边界完成 legacy 映射,内部仍只产生 `inline | detached` 运行模式。`WorkflowDetails.background` 与 persisted artifact 中的同名字段继续记录实际 detached 状态,不记录调用时使用的是 `wait` 还是兼容 alias,也不改写历史 artifact。
Coordinator 在单一输入边界解析 `wait`,内部仍只产生 `inline | detached` 运行模式。`WorkflowDetails.background` 与 persisted artifact 中的同名字段继续记录实际 detached 状态,不记录调用参数,也不改写历史 artifact。

该后续变更的最终验证以 PR #139 exact-head review 为准,至少包括:

- `wait`、legacy `background`、冲突输入、host delivery 能力和 wait interruption 的专项测试;
- `wait`、未知输入拒绝、host delivery 能力和 wait interruption 的专项测试;
- `bun run check`;
- `bun run test`;
- GitHub CI:Node 22.19.0、Node 24 与 Windows background-terminal suite。
19 changes: 2 additions & 17 deletions extensions/workflows/coordinator.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,13 @@
export interface WorkflowLaunchPolicyInput {
wait?: boolean;
background?: boolean;
}

/**
* Resolve the caller's launch preference to one positive runtime mode.
* `background` remains only as the published inverse compatibility alias.
*/
/** Resolve the caller's launch preference to one positive runtime mode. */
export function resolveWorkflowLaunchMode(
input: WorkflowLaunchPolicyInput,
canDeliverLater: boolean,
) {
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; remove background and provide only wait",
);
}
const wait =
input.wait ??
(input.background !== undefined ? !input.background : !canDeliverLater);
const wait = input.wait ?? !canDeliverLater;
if (!wait && !canDeliverLater) {
throw new Error(
"This host cannot deliver a workflow result later; use wait: true",
Expand Down
12 changes: 2 additions & 10 deletions extensions/workflows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,12 +562,6 @@ const WorkflowParams = Type.Object(
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,
Expand Down Expand Up @@ -1234,7 +1228,7 @@ export default function workflows(
const runDir = path.join(getAgentDir(), "workflows", runId);
const canDeliverLater = ctx.hasUI && ctx.mode === "tui";
const launchMode = resolveWorkflowLaunchMode(
{ wait: params.wait, background: params.background },
{ wait: params.wait },
canDeliverLater,
);
const background = launchMode === "detached";
Expand Down Expand Up @@ -2324,9 +2318,7 @@ export default function workflows(
let text =
theme.fg("toolTitle", theme.bold("workflow ")) +
theme.fg("accent", (meta as WorkflowMeta).name ?? "(script)");
if (args.background !== undefined) {
text += theme.fg("dim", ` (deprecated: use wait: ${!args.background})`);
} else if (args.wait === true) {
if (args.wait === true) {
text += theme.fg("dim", " (wait)");
}
const description = (meta as WorkflowMeta).description;
Expand Down
2 changes: 0 additions & 2 deletions extensions/workflows/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ export const WORKFLOW_PARAMETER_DESCRIPTIONS = {
script:
"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 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.",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tt-a1i/openpi",
"version": "0.4.0",
"version": "1.0.0",
"description": "OpenPI — a Pi-native multi-agent workbench with background execution, isolated subagents, replay-safe workflows, goals, tasks, and observable TUI",
"license": "MIT",
"author": "tt-a1i",
Expand Down
2 changes: 1 addition & 1 deletion skills/workflows/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Each call persists intent, admission, and execution state. Interrupted nontermin

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.
Workflow calls use the positive `wait` policy. Interactive TUI runs default to detached delivery (`wait: false`); print and automation hosts default to inline waiting (`wait: true`). Persisted artifact/details fields named `background` remain actual detached-state facts and are not call parameters.

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
32 changes: 2 additions & 30 deletions tests/extensions/workflows/coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,44 +10,16 @@ test("interactive launch defaults detached while non-delivery hosts wait", () =>
assert.equal(resolveWorkflowLaunchMode({}, false), "inline");
});

test("wait is authoritative and legacy background maps to its inverse", () => {
test("wait selects inline or detached execution", () => {
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(
() => 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,
);
test("unsupported detached delivery fails closed", () => {
assert.throws(
() => 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
10 changes: 5 additions & 5 deletions tests/extensions/workflows/execute.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ test("print hosts wait by default and reject detached delivery", async () => {
{
script:
'export const meta = { name: "print-legacy-inline" };\nreturn { inline: true };',
background: false,
wait: true,
},
undefined,
undefined,
Expand All @@ -436,7 +436,7 @@ test("print hosts wait by default and reject detached delivery", async () => {
const workflowsDir = join(agentDir, "workflows");
const runDirsBefore = readdirSync(workflowsDir).sort();
const messagesBefore = sentMessages.length;
for (const input of [{ wait: false }, { background: true }]) {
for (const input of [{ wait: false }]) {
await assert.rejects(
Promise.resolve().then(() =>
workflow.execute(
Expand Down Expand Up @@ -729,7 +729,7 @@ test("shutdown preserves a failed completion for reload recovery", async () => {
{
script:
'export const meta = { name: "shutdown-reload-delivery" };\nreturn { durable: true };',
background: true,
wait: false,
},
undefined,
undefined,
Expand Down Expand Up @@ -816,7 +816,7 @@ test("cancelled detached delivery preserves aborted status after artifact persis
script:
'export const meta = { name: "cancelled-persistence-failure" };\n' +
'return await agent("wait for cancellation", { agent_type: "reviewer" });',
background: true,
wait: false,
},
undefined,
undefined,
Expand Down Expand Up @@ -1632,7 +1632,7 @@ test("forced settlement persists worktree cleanup that finishes later", async ()
'export const meta = { name: "forced-worktree-cleanup" };\n' +
'await agent("finish in isolation", { agent_type: "reviewer", isolation: "worktree" });\n' +
"return true;",
background: true,
wait: false,
},
undefined,
undefined,
Expand Down
23 changes: 11 additions & 12 deletions tests/extensions/workflows/rendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,18 +99,15 @@ function captureRenderers() {
return { workflow, message };
}

test("workflow launch schema recommends wait while preserving only the published alias", () => {
test("workflow launch schema exposes only wait", () => {
const { workflow } = captureRenderers();
const parameters = workflow.parameters as unknown as {
properties?: Record<string, unknown>;
additionalProperties?: boolean;
};

assert.ok(parameters.properties?.wait);
assert.deepEqual(
(parameters.properties?.background as { deprecated?: unknown })?.deprecated,
true,
);
assert.equal(parameters.properties?.background, undefined);
assert.equal(parameters.additionalProperties, false);

const toolCall = (args: Record<string, unknown>) => ({
Expand All @@ -125,9 +122,10 @@ test("workflow launch schema recommends wait while preserving only the published
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, background: true })),
/Invalid tool arguments|additional/i,
);
assert.throws(
() => validateToolArguments(workflow, toolCall({ script, detached: true })),
Expand Down Expand Up @@ -161,12 +159,12 @@ test("workflow call rendering labels an explicit inline wait", () => {
assert.match(component.render(100).join("\n"), /workflow inline \(wait\)/);
});

test("workflow call rendering gives legacy callers an actionable migration", () => {
test("workflow call rendering leaves detached calls unlabelled", () => {
const { workflow } = captureRenderers();
assert.ok(workflow.renderCall);
const args = {
script: 'export const meta = { name: "legacy" }; return 1;',
background: true,
wait: false,
};

const component = workflow.renderCall(args, theme, {
Expand All @@ -184,9 +182,10 @@ test("workflow call rendering gives legacy callers an actionable migration", ()
isError: false,
});

assert.match(
assert.match(component.render(100).join("\n"), /workflow legacy/);
assert.doesNotMatch(
component.render(100).join("\n"),
/workflow legacy \(deprecated: use wait: false\)/,
/deprecated|background/,
);
});

Expand Down
Loading