From 4043373e81375225d54982f6af25e38eefb964ea Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 18 Jul 2026 11:53:35 +0800 Subject: [PATCH 1/8] feat(mcp): add durable background run lifecycle --- .claude-plugin/marketplace.json | 4 +- .grok-plugin/marketplace.json | 2 +- CHANGELOG.md | 6 + README.md | 2 +- README.zh-CN.md | 2 +- docs/claude-mcp.md | 14 +- docs/codex-mcp.md | 20 +- docs/grok-mcp.md | 17 +- docs/opencode-mcp.md | 11 +- .../plugin/skills/taskflow/SKILL.md | 8 +- packages/claude-taskflow/src/mcp/server.ts | 14 +- .../claude-taskflow/test/mcp-server.test.ts | 4 +- .../plugin/skills/taskflow/SKILL.md | 8 +- packages/codex-taskflow/src/mcp/server.ts | 14 +- .../codex-taskflow/test/mcp-server.test.ts | 4 +- .../plugin/skills/taskflow/SKILL.md | 8 +- packages/grok-taskflow/src/mcp/server.ts | 14 +- .../grok-taskflow/test/mcp-server.test.ts | 2 + .../plugin/skills/taskflow/SKILL.md | 8 +- packages/opencode-taskflow/src/mcp/server.ts | 14 +- .../opencode-taskflow/test/mcp-server.test.ts | 4 +- .../pi-taskflow/test/skills-build.test.ts | 6 +- .../taskflow-core/src/detached-control.ts | 89 ++++++++ packages/taskflow-core/src/detached-runner.ts | 124 +++++++++-- packages/taskflow-core/src/index.ts | 1 + packages/taskflow-core/src/store.ts | 9 + .../taskflow-mcp-core/src/mcp/background.ts | 204 ++++++++++++++++++ packages/taskflow-mcp-core/src/mcp/server.ts | 96 ++++++++- .../test/background-runs.test.ts | 88 ++++++++ .../test/fixtures/background-runner.mjs | 53 +++++ skills-src/taskflow/core.md | 5 + skills-src/taskflow/entry.claude.md | 3 +- skills-src/taskflow/entry.codex.md | 3 +- skills-src/taskflow/entry.grok.md | 3 +- skills-src/taskflow/entry.opencode.md | 3 +- 35 files changed, 786 insertions(+), 81 deletions(-) create mode 100644 packages/taskflow-core/src/detached-control.ts create mode 100644 packages/taskflow-mcp-core/src/mcp/background.ts create mode 100644 packages/taskflow-mcp-core/test/background-runs.test.ts create mode 100644 packages/taskflow-mcp-core/test/fixtures/background-runner.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 88136946..cc2c728c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,14 +10,14 @@ "plugins": [ { "name": "taskflow", - "description": "Multi-phase subagent orchestration for Codex: 15 taskflow_* MCP tools (run/resume/version/list/show/verify/compile/peek/trace/replay/why_stale/recompute/reconcile_workspace/save/search) plus a routing skill. compile returns SVG + text outline. The server runs via npx (codex-taskflow).", + "description": "Multi-phase subagent orchestration for Codex: 16 taskflow_* MCP tools (run/runs/resume/version/list/show/verify/compile/peek/trace/replay/why_stale/recompute/reconcile_workspace/save/search) plus a routing skill. Background runs support status/wait/cancel; compile returns SVG + text outline. The server runs via npx (codex-taskflow).", "source": "./packages/codex-taskflow/plugin", "category": "developer-tools", "homepage": "https://github.com/heggria/taskflow" }, { "name": "claude-taskflow", - "description": "Multi-phase subagent orchestration for Claude Code: 15 taskflow_* MCP tools (run/resume/version/list/show/verify/compile/peek/trace/replay/why_stale/recompute/reconcile_workspace/save/search) plus a routing skill. compile returns SVG + text outline. The server runs via npx (claude-taskflow).", + "description": "Multi-phase subagent orchestration for Claude Code: 16 taskflow_* MCP tools (run/runs/resume/version/list/show/verify/compile/peek/trace/replay/why_stale/recompute/reconcile_workspace/save/search) plus a routing skill. Background runs support status/wait/cancel; compile returns SVG + text outline. The server runs via npx (claude-taskflow).", "source": "./packages/claude-taskflow/plugin", "category": "developer-tools", "homepage": "https://github.com/heggria/taskflow" diff --git a/.grok-plugin/marketplace.json b/.grok-plugin/marketplace.json index 8acef904..a968aeaa 100644 --- a/.grok-plugin/marketplace.json +++ b/.grok-plugin/marketplace.json @@ -8,7 +8,7 @@ "plugins": [ { "name": "taskflow", - "description": "Multi-phase subagent orchestration for Grok Build: 15 taskflow_* MCP tools (run/resume/version/list/show/verify/compile/peek/trace/replay/why_stale/recompute/reconcile_workspace/save/search) plus a routing skill. compile returns SVG + text outline. The server runs via npx (grok-taskflow).", + "description": "Multi-phase subagent orchestration for Grok Build: 16 taskflow_* MCP tools (run/runs/resume/version/list/show/verify/compile/peek/trace/replay/why_stale/recompute/reconcile_workspace/save/search) plus a routing skill. Background runs support status/wait/cancel; compile returns SVG + text outline. The server runs via npx (grok-taskflow).", "source": { "type": "local", "path": "./packages/grok-taskflow/plugin" diff --git a/CHANGELOG.md b/CHANGELOG.md index 83089e49..d47559f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to taskflow are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. +## [0.2.3] — Unreleased + +### Added + +- **Durable MCP background runs.** `taskflow_run` now accepts `mode: "background"` on Codex, Claude Code, OpenCode, and Grok Build, returning a durable `runId` immediately instead of tying a long DAG to one MCP request timeout. The new `taskflow_runs` tool lists background runs and supports `status`, bounded/repeatable `wait`, and explicit `cancel`. Detached runs persist their final output and trace, preserve incremental-cache and library-reuse behavior, detect orphaned processes, and use a file-backed cancellation control plane that survives MCP request and server boundaries. + ## [0.2.2] — 2026-07-14 ### ⚠️ Breaking — migration required diff --git a/README.md b/README.md index ca25a095..68f0b93a 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ Save it as `.pi/taskflows/audit-api.json`, then run: /tf:audit-api dir=src/api ``` -On Codex, Claude Code, OpenCode, and Grok Build, run the same saved definition by name through `taskflow_run`. +On Codex, Claude Code, OpenCode, and Grok Build, run the same saved definition by name through `taskflow_run`. For long DAGs, use `mode: "background"`, then manage the durable run with `taskflow_runs` (`status` / `wait` / `cancel`). [Follow the full quickstart →](https://heggria.github.io/taskflow/en/docs/getting-started) diff --git a/README.zh-CN.md b/README.zh-CN.md index a5869652..76372c51 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -131,7 +131,7 @@ pi install npm:pi-taskflow /tf:audit-api dir=src/api ``` -在 Codex、Claude Code、OpenCode 和 Grok Build 上,通过 `taskflow_run` 按名称运行同一份保存定义。 +在 Codex、Claude Code、OpenCode 和 Grok Build 上,通过 `taskflow_run` 按名称运行同一份保存定义。长任务可使用 `mode: "background"`,再用 `taskflow_runs` 执行 `status` / `wait` / `cancel`,无需担心单次 MCP 调用超时。 [查看完整快速开始 →](https://heggria.github.io/taskflow/zh-cn/docs/getting-started) diff --git a/docs/claude-mcp.md b/docs/claude-mcp.md index ec153b36..0fb76ac6 100644 --- a/docs/claude-mcp.md +++ b/docs/claude-mcp.md @@ -74,12 +74,11 @@ such as npm tokens, database URLs, and other-provider API keys are not inherited ## Long-running flows and the tool-call timeout -`taskflow_run` returns only after the **whole DAG finishes** — intermediate -phase outputs stay in the runtime, so from Claude Code's side it's a single tool -call that can run for many minutes. If a flow is genuinely huge, consider -splitting it into a few smaller `taskflow_run` calls so each returns promptly, -or run it in the background from a plain shell (`claude -p … &`) and inspect the -run afterward with `taskflow_peek`. +Foreground `taskflow_run` returns only after the **whole DAG finishes**. For a +long flow, pass `mode: "background"`: it returns a durable `runId` immediately +and continues independently of that MCP request. Use `taskflow_runs` with +`action: "status"`, `"wait"`, or `"cancel"`; `wait` is bounded by `timeoutMs` +and can be called repeatedly until the persisted final output is ready. ## Alternative: register the MCP server manually @@ -113,7 +112,8 @@ subagent a flow spawns is itself a `claude -p` process — no pi process needed. | Tool | What it does | |------|--------------| -| `taskflow_run` | Run a saved flow (`name`) or an inline `define` (full DAG or shorthand `{task}`/`{tasks}`/`{chain}`). Returns only the final phase output + a `runId`. | +| `taskflow_run` | Run a saved or inline flow. Foreground returns the final output; `mode: "background"` returns a durable `runId` immediately. | +| `taskflow_runs` | List background runs or `status` / `wait` / `cancel` one by `runId`. | | `taskflow_list` | List saved flows discoverable from the cwd, now with library metadata (`purpose`, `generality`, `reuseCount`) when available. | | `taskflow_show` | Show a saved flow as `{definition, library}` — the `library` object holds the sidecar metadata (`purpose`, `tags`, `generality`, `reuseCount`, `phaseSignature`, …). | | `taskflow_save` | Save a flow to the library with optional `purpose`, `tags`, and `notes`. Writes the flow JSON plus a sidecar `.meta.json`. | diff --git a/docs/codex-mcp.md b/docs/codex-mcp.md index 43a57997..615ae11a 100644 --- a/docs/codex-mcp.md +++ b/docs/codex-mcp.md @@ -51,11 +51,14 @@ Unknown values fail closed before the subprocess starts. ## Long-running flows and the tool-call timeout -`taskflow_run` returns only after the **whole DAG finishes** — intermediate -phase outputs stay in the runtime, so from Codex's side it's a single tool call -that can run for many minutes. Codex applies a per-server MCP **tool-call -timeout**; if the call outlives it, Codex abandons the result client-side even -though the run keeps executing server-side. +Foreground `taskflow_run` returns only after the **whole DAG finishes**. +For a long flow, pass `mode: "background"`: the call returns a durable `runId` +immediately, and the run continues independently of that MCP request. Use +`taskflow_runs` with `action: "status"`, `"wait"`, or `"cancel"`; `wait` may +be called repeatedly with a bounded `timeoutMs` and returns the persisted final +output when complete. + +Codex still applies a per-server MCP **tool-call timeout** to foreground calls. To stop large flows from being cut off, the plugin's `.mcp.json` ships a 30-minute default: @@ -80,8 +83,8 @@ default): tool_timeout_sec = 3600 ``` -If a flow is genuinely huge, also consider splitting it into a few smaller -`taskflow_run` calls so each returns well inside the window. +Use foreground mode for short flows and background mode for anything that may +outlive the window. Each spawned Codex subagent is isolated from the parent Codex customization: the runner uses `--ephemeral --ignore-user-config --ignore-rules`, clears @@ -123,7 +126,8 @@ subagent a flow spawns is itself a `codex exec` process — no pi process needed | Tool | What it does | |------|--------------| -| `taskflow_run` | Run a saved flow (`name`) or an inline `define` (full DAG or shorthand `{task}`/`{tasks}`/`{chain}`). Returns only the final phase output + a `runId`. | +| `taskflow_run` | Run a saved or inline flow. Foreground returns the final output; `mode: "background"` returns a durable `runId` immediately. | +| `taskflow_runs` | List background runs or `status` / `wait` / `cancel` one by `runId`. | | `taskflow_list` | List saved flows discoverable from the cwd, now with library metadata (`purpose`, `generality`, `reuseCount`) when available. | | `taskflow_show` | Show a saved flow as `{definition, library}` — the `library` object holds the sidecar metadata (`purpose`, `tags`, `generality`, `reuseCount`, `phaseSignature`, …). | | `taskflow_save` | Save a flow to the library with optional `purpose`, `tags`, and `notes`. Writes the flow JSON plus a sidecar `.meta.json`. | diff --git a/docs/grok-mcp.md b/docs/grok-mcp.md index fdb493d1..6db48a95 100644 --- a/docs/grok-mcp.md +++ b/docs/grok-mcp.md @@ -151,13 +151,13 @@ exact hard token/USD ceiling for an in-flight model call. ## Long-running flows and the tool-call timeout -`taskflow_run` returns only after the **whole DAG finishes** — intermediate -phase outputs stay in the runtime, so from Grok's side it's a single tool call -that can run for many minutes. The plugin's `.mcp.json` ships -`tool_timeout_sec: 1800` (30 minutes). For huge flows, split into a few smaller -`taskflow_run` calls. MCP does not expose Pi's detached-run mode. If the client -sends `notifications/cancelled` (including after a tool timeout), the server -aborts the active DAG and subagent instead of leaving hidden background work. +Foreground `taskflow_run` returns only after the **whole DAG finishes**. The +plugin's `.mcp.json` ships `tool_timeout_sec: 1800` (30 minutes). For a long +flow, pass `mode: "background"`: it returns a durable `runId` immediately and +continues independently of that MCP request. Use `taskflow_runs` with `action: +"status"`, `"wait"`, or `"cancel"`; bounded waits can be repeated until the +persisted final output is ready. Cancelling a foreground MCP request still +aborts that foreground DAG; background runs are cancelled explicitly by id. ## Alternative: register the MCP server manually @@ -186,7 +186,8 @@ subagent a flow spawns is itself a `grok -p` process — no pi process needed. | Tool | What it does | |------|--------------| -| `taskflow_run` | Run a saved flow (`name`) or an inline `define` (full DAG or shorthand `{task}`/`{tasks}`/`{chain}`). Returns only the final phase output + a `runId`. | +| `taskflow_run` | Run a saved or inline flow. Foreground returns the final output; `mode: "background"` returns a durable `runId` immediately. | +| `taskflow_runs` | List background runs or `status` / `wait` / `cancel` one by `runId`. | | `taskflow_list` | List saved flows discoverable from the cwd, with library metadata when available. | | `taskflow_show` | Show a saved flow as `{definition, library}`. | | `taskflow_save` | Save a flow to the library with optional `purpose`, `tags`, and `notes`. | diff --git a/docs/opencode-mcp.md b/docs/opencode-mcp.md index 6a3b31ee..a31afeb1 100644 --- a/docs/opencode-mcp.md +++ b/docs/opencode-mcp.md @@ -105,11 +105,20 @@ Effective Taskflow thinking is passed as OpenCode's provider/model-specific `--variant`; `off` maps to `none` and `ultra` maps to `max`. Other levels are best-effort because available variants differ by provider and model. +## Long-running flows + +Foreground `taskflow_run` waits for the whole DAG. For a long flow, pass +`mode: "background"`: it returns a durable `runId` immediately and continues +independently of that MCP request. Use `taskflow_runs` with `action: "status"`, +`"wait"`, or `"cancel"`; bounded waits can be repeated until the persisted +final output is ready. + ## Tools exposed | Tool | What it does | |------|--------------| -| `taskflow_run` | Run a saved flow (`name`) or an inline `define` (full DAG or shorthand `{task}`/`{tasks}`/`{chain}`). Returns only the final phase output + a `runId`. | +| `taskflow_run` | Run a saved or inline flow. Foreground returns the final output; `mode: "background"` returns a durable `runId` immediately. | +| `taskflow_runs` | List background runs or `status` / `wait` / `cancel` one by `runId`. | | `taskflow_list` | List saved flows discoverable from the cwd, now with library metadata (`purpose`, `generality`, `reuseCount`) when available. | | `taskflow_show` | Show a saved flow as `{definition, library}` — the `library` object holds the sidecar metadata (`purpose`, `tags`, `generality`, `reuseCount`, `phaseSignature`, …). | | `taskflow_save` | Save a flow to the library with optional `purpose`, `tags`, and `notes`. Writes the flow JSON plus a sidecar `.meta.json`. | diff --git a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md index f9f527fb..bce82a40 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md @@ -14,7 +14,8 @@ runs as an isolated `claude -p` session. | Tool | What it does | |------|--------------| -| `taskflow_run` | Run a saved flow (`name`) or an inline `define` (full DAG, or shorthand `{task}` / `{tasks}` / `{chain}`). Optional `args`, `incremental`. Returns only the final phase output + a `runId`. | +| `taskflow_run` | Run a saved or inline flow. Optional `args`, `incremental`; `mode: "background"` returns a durable `runId` immediately. | +| `taskflow_runs` | List background runs or `status` / `wait` / `cancel` one by `runId`. | | `taskflow_resume` | Fork a failed/paused run into a new immutable child run, optionally overriding one phase's task/model/timeouts. | | `taskflow_version` | Report the executing package version, build commit, schema version, build time, and host identity. | | `taskflow_list` | List saved flows discoverable from the current working directory. | @@ -667,6 +668,11 @@ and output sizes, then peek the suspicious phase (`json: true` for parsed output, `item: n` for one fan-out section). Output is hard-truncated (default 4000 chars, max 32000) so a peek never floods your context. +For a flow that may outlive one MCP tool call, set `mode: "background"` on +`taskflow_run`. It returns immediately; use `taskflow_runs` with `action: +"status"`, `"wait"`, or `"cancel"` and the returned `runId`. A bounded `wait` +can be called repeatedly, and completion returns the persisted final output. + Use `taskflow_trace` to inspect the append-only event log for a finished run, then `taskflow_replay` to re-judge it under alternate thresholds/budget **offline (zero tokens)** — e.g. "would a 0.9 gate threshold have blocked this run?" diff --git a/packages/claude-taskflow/src/mcp/server.ts b/packages/claude-taskflow/src/mcp/server.ts index f8680194..ec2abbfe 100644 --- a/packages/claude-taskflow/src/mcp/server.ts +++ b/packages/claude-taskflow/src/mcp/server.ts @@ -15,17 +15,25 @@ import { import type { RpcHandler } from "taskflow-mcp-core/jsonrpc"; import { claudeSubagentRunner } from "taskflow-hosts"; +const HOST_OPTIONS = { + host: "claude", + detachedRunner: { + module: import.meta.resolve("taskflow-hosts/claude"), + exportName: "claudeSubagentRunner", + }, +} as const; + /** Per-call tool handlers with claude subagent execution bound in. */ export function makeToolHandlers(cwd: string): Record) => Promise> { - return coreMakeToolHandlers(cwd, claudeSubagentRunner, { host: "claude" }); + return coreMakeToolHandlers(cwd, claudeSubagentRunner, HOST_OPTIONS); } /** Full MCP method dispatch table (protocol + tools), claude-bound. */ export function makeMcpHandlers(cwd: string): Record { - return coreMakeMcpHandlers(cwd, claudeSubagentRunner, { host: "claude" }); + return coreMakeMcpHandlers(cwd, claudeSubagentRunner, HOST_OPTIONS); } /** Start the stdio MCP server. Resolves when the client disconnects. */ export function startMcpServer(cwd: string = process.cwd()): Promise { - return coreStartMcpServer(claudeSubagentRunner, cwd, { host: "claude" }); + return coreStartMcpServer(claudeSubagentRunner, cwd, HOST_OPTIONS); } diff --git a/packages/claude-taskflow/test/mcp-server.test.ts b/packages/claude-taskflow/test/mcp-server.test.ts index f64d9555..becf2bfa 100644 --- a/packages/claude-taskflow/test/mcp-server.test.ts +++ b/packages/claude-taskflow/test/mcp-server.test.ts @@ -53,7 +53,7 @@ test("claude mcp: tools/list exposes the same taskflow tools as codex", async () const names = res.result.tools.map((t: any) => t.name); assert.deepEqual( names.sort(), - ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], ); for (const t of res.result.tools) { assert.equal(typeof t.description, "string"); @@ -83,6 +83,6 @@ test("claude mcp: makeToolHandlers exposes the tools", () => { const tools = makeToolHandlers(process.cwd()); assert.deepEqual( Object.keys(tools).sort(), - ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], ); }); diff --git a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md index f9b6ac9f..3926829e 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md @@ -13,7 +13,8 @@ the Codex form (`taskflow_verify`). | Tool | What it does | |------|--------------| -| `taskflow_run` | Run a saved flow (`name`) or an inline `define` (full DAG, or shorthand `{task}` / `{tasks}` / `{chain}`). Optional `args`, `incremental`. Returns only the final phase output + a `runId`. | +| `taskflow_run` | Run a saved or inline flow. Optional `args`, `incremental`; `mode: "background"` returns a durable `runId` immediately. | +| `taskflow_runs` | List background runs or `status` / `wait` / `cancel` one by `runId`. | | `taskflow_resume` | Fork a failed/paused run into a new immutable child run, optionally overriding one phase's task/model/timeouts. | | `taskflow_version` | Report the executing package version, build commit, schema version, build time, and host identity. | | `taskflow_list` | List saved flows discoverable from the current working directory. | @@ -662,6 +663,11 @@ and output sizes, then peek the suspicious phase (`json: true` for parsed output, `item: n` for one fan-out section). Output is hard-truncated (default 4000 chars, max 32000) so a peek never floods your context. +For a flow that may outlive one MCP tool call, set `mode: "background"` on +`taskflow_run`. It returns immediately; use `taskflow_runs` with `action: +"status"`, `"wait"`, or `"cancel"` and the returned `runId`. A bounded `wait` +can be called repeatedly, and completion returns the persisted final output. + Use `taskflow_trace` to inspect the append-only event log for a finished run, then `taskflow_replay` to re-judge it under alternate thresholds/budget **offline (zero tokens)** — e.g. "would a 0.9 gate threshold have blocked this run?" diff --git a/packages/codex-taskflow/src/mcp/server.ts b/packages/codex-taskflow/src/mcp/server.ts index d5b3c996..ed58dc0f 100644 --- a/packages/codex-taskflow/src/mcp/server.ts +++ b/packages/codex-taskflow/src/mcp/server.ts @@ -15,17 +15,25 @@ import { import type { RpcHandler } from "taskflow-mcp-core/jsonrpc"; import { codexSubagentRunner } from "taskflow-hosts"; +const HOST_OPTIONS = { + host: "codex", + detachedRunner: { + module: import.meta.resolve("taskflow-hosts/codex"), + exportName: "codexSubagentRunner", + }, +} as const; + /** Per-call tool handlers with codex subagent execution bound in. */ export function makeToolHandlers(cwd: string): Record) => Promise> { - return coreMakeToolHandlers(cwd, codexSubagentRunner, { host: "codex" }); + return coreMakeToolHandlers(cwd, codexSubagentRunner, HOST_OPTIONS); } /** Full MCP method dispatch table (protocol + tools), codex-bound. */ export function makeMcpHandlers(cwd: string): Record { - return coreMakeMcpHandlers(cwd, codexSubagentRunner, { host: "codex" }); + return coreMakeMcpHandlers(cwd, codexSubagentRunner, HOST_OPTIONS); } /** Start the stdio MCP server. Resolves when the client disconnects. */ export function startMcpServer(cwd: string = process.cwd()): Promise { - return coreStartMcpServer(codexSubagentRunner, cwd, { host: "codex" }); + return coreStartMcpServer(codexSubagentRunner, cwd, HOST_OPTIONS); } diff --git a/packages/codex-taskflow/test/mcp-server.test.ts b/packages/codex-taskflow/test/mcp-server.test.ts index 4317d696..afb114c1 100644 --- a/packages/codex-taskflow/test/mcp-server.test.ts +++ b/packages/codex-taskflow/test/mcp-server.test.ts @@ -59,7 +59,7 @@ test("mcp: tools/list exposes the taskflow tools with schemas", async () => { const names = res.result.tools.map((t: any) => t.name); assert.deepEqual( names.sort(), - ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], ); for (const t of res.result.tools) { assert.equal(typeof t.description, "string"); @@ -245,7 +245,7 @@ test("mcp: makeToolHandlers exposes the tools", () => { const tools = makeToolHandlers(process.cwd()); assert.deepEqual( Object.keys(tools).sort(), - ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], ); }); diff --git a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md index db82ff14..575f2d96 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md @@ -23,7 +23,8 @@ kernel enforcement is unavailable. | Tool | What it does | |------|--------------| -| `taskflow_run` | Run a saved flow (`name`) or an inline `define` (full DAG, or shorthand `{task}` / `{tasks}` / `{chain}`). Optional `args`, `incremental`. Returns only the final phase output + a `runId`. | +| `taskflow_run` | Run a saved or inline flow. Optional `args`, `incremental`; `mode: "background"` returns a durable `runId` immediately. | +| `taskflow_runs` | List background runs or `status` / `wait` / `cancel` one by `runId`. | | `taskflow_resume` | Fork a failed/paused run into a new immutable child run, optionally overriding one phase's task/model/timeouts. | | `taskflow_version` | Report the executing package version, build commit, schema version, build time, and host identity. | | `taskflow_list` | List saved flows discoverable from the current working directory. | @@ -672,6 +673,11 @@ and output sizes, then peek the suspicious phase (`json: true` for parsed output, `item: n` for one fan-out section). Output is hard-truncated (default 4000 chars, max 32000) so a peek never floods your context. +For a flow that may outlive one MCP tool call, set `mode: "background"` on +`taskflow_run`. It returns immediately; use `taskflow_runs` with `action: +"status"`, `"wait"`, or `"cancel"` and the returned `runId`. A bounded `wait` +can be called repeatedly, and completion returns the persisted final output. + Use `taskflow_trace` to inspect the append-only event log for a finished run, then `taskflow_replay` to re-judge it under alternate thresholds/budget **offline (zero tokens)** — e.g. "would a 0.9 gate threshold have blocked this run?" diff --git a/packages/grok-taskflow/src/mcp/server.ts b/packages/grok-taskflow/src/mcp/server.ts index 0a99ae5a..fdb28449 100644 --- a/packages/grok-taskflow/src/mcp/server.ts +++ b/packages/grok-taskflow/src/mcp/server.ts @@ -15,19 +15,27 @@ import { import type { RpcContext, RpcHandler } from "taskflow-mcp-core/jsonrpc"; import { grokSubagentRunner } from "taskflow-hosts"; +const HOST_OPTIONS = { + host: "grok", + detachedRunner: { + module: import.meta.resolve("taskflow-hosts/grok"), + exportName: "grokSubagentRunner", + }, +} as const; + /** Per-call tool handlers with grok subagent execution bound in. */ export function makeToolHandlers( cwd: string, ): Record, context?: RpcContext) => Promise> { - return coreMakeToolHandlers(cwd, grokSubagentRunner, { host: "grok" }); + return coreMakeToolHandlers(cwd, grokSubagentRunner, HOST_OPTIONS); } /** Full MCP method dispatch table (protocol + tools), grok-bound. */ export function makeMcpHandlers(cwd: string): Record { - return coreMakeMcpHandlers(cwd, grokSubagentRunner, { host: "grok" }); + return coreMakeMcpHandlers(cwd, grokSubagentRunner, HOST_OPTIONS); } /** Start the stdio MCP server. Resolves when the client disconnects. */ export function startMcpServer(cwd: string = process.cwd()): Promise { - return coreStartMcpServer(grokSubagentRunner, cwd, { host: "grok" }); + return coreStartMcpServer(grokSubagentRunner, cwd, HOST_OPTIONS); } diff --git a/packages/grok-taskflow/test/mcp-server.test.ts b/packages/grok-taskflow/test/mcp-server.test.ts index c550dc82..35474a88 100644 --- a/packages/grok-taskflow/test/mcp-server.test.ts +++ b/packages/grok-taskflow/test/mcp-server.test.ts @@ -60,6 +60,7 @@ test("grok mcp: tools/list exposes the same taskflow tools as other hosts", asyn "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", + "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", @@ -104,6 +105,7 @@ test("grok mcp: makeToolHandlers exposes the tools", () => { "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", + "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md index 104a91d8..1c1d1e0a 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md @@ -14,7 +14,8 @@ the OpenCode form (`taskflow_verify`). Each phase's subagent runs as an isolated | Tool | What it does | |------|--------------| -| `taskflow_run` | Run a saved flow (`name`) or an inline `define` (full DAG, or shorthand `{task}` / `{tasks}` / `{chain}`). Optional `args`, `incremental`. Returns only the final phase output + a `runId`. | +| `taskflow_run` | Run a saved or inline flow. Optional `args`, `incremental`; `mode: "background"` returns a durable `runId` immediately. | +| `taskflow_runs` | List background runs or `status` / `wait` / `cancel` one by `runId`. | | `taskflow_resume` | Fork a failed/paused run into a new immutable child run, optionally overriding one phase's task/model/timeouts. | | `taskflow_version` | Report the executing package version, build commit, schema version, build time, and host identity. | | `taskflow_list` | List saved flows discoverable from the current working directory. | @@ -663,6 +664,11 @@ and output sizes, then peek the suspicious phase (`json: true` for parsed output, `item: n` for one fan-out section). Output is hard-truncated (default 4000 chars, max 32000) so a peek never floods your context. +For a flow that may outlive one MCP tool call, set `mode: "background"` on +`taskflow_run`. It returns immediately; use `taskflow_runs` with `action: +"status"`, `"wait"`, or `"cancel"` and the returned `runId`. A bounded `wait` +can be called repeatedly, and completion returns the persisted final output. + Use `taskflow_trace` to inspect the append-only event log for a finished run, then `taskflow_replay` to re-judge it under alternate thresholds/budget **offline (zero tokens)** — e.g. "would a 0.9 gate threshold have blocked this run?" diff --git a/packages/opencode-taskflow/src/mcp/server.ts b/packages/opencode-taskflow/src/mcp/server.ts index 498b9383..a9df8070 100644 --- a/packages/opencode-taskflow/src/mcp/server.ts +++ b/packages/opencode-taskflow/src/mcp/server.ts @@ -16,17 +16,25 @@ import { import type { RpcHandler } from "taskflow-mcp-core/jsonrpc"; import { opencodeSubagentRunner } from "taskflow-hosts"; +const HOST_OPTIONS = { + host: "opencode", + detachedRunner: { + module: import.meta.resolve("taskflow-hosts/opencode"), + exportName: "opencodeSubagentRunner", + }, +} as const; + /** Per-call tool handlers with opencode subagent execution bound in. */ export function makeToolHandlers(cwd: string): Record) => Promise> { - return coreMakeToolHandlers(cwd, opencodeSubagentRunner, { host: "opencode" }); + return coreMakeToolHandlers(cwd, opencodeSubagentRunner, HOST_OPTIONS); } /** Full MCP method dispatch table (protocol + tools), opencode-bound. */ export function makeMcpHandlers(cwd: string): Record { - return coreMakeMcpHandlers(cwd, opencodeSubagentRunner, { host: "opencode" }); + return coreMakeMcpHandlers(cwd, opencodeSubagentRunner, HOST_OPTIONS); } /** Start the stdio MCP server. Resolves when the client disconnects. */ export function startMcpServer(cwd: string = process.cwd()): Promise { - return coreStartMcpServer(opencodeSubagentRunner, cwd, { host: "opencode" }); + return coreStartMcpServer(opencodeSubagentRunner, cwd, HOST_OPTIONS); } diff --git a/packages/opencode-taskflow/test/mcp-server.test.ts b/packages/opencode-taskflow/test/mcp-server.test.ts index 7675b375..2b76c645 100644 --- a/packages/opencode-taskflow/test/mcp-server.test.ts +++ b/packages/opencode-taskflow/test/mcp-server.test.ts @@ -53,7 +53,7 @@ test("opencode mcp: tools/list exposes the taskflow tools", async () => { const names = res.result.tools.map((t: any) => t.name); assert.deepEqual( names.sort(), - ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], ); for (const t of res.result.tools) { assert.equal(typeof t.description, "string"); @@ -83,6 +83,6 @@ test("opencode mcp: makeToolHandlers exposes the tools", () => { const tools = makeToolHandlers(process.cwd()); assert.deepEqual( Object.keys(tools).sort(), - ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], ); }); diff --git a/packages/pi-taskflow/test/skills-build.test.ts b/packages/pi-taskflow/test/skills-build.test.ts index 84bd72bc..9494aafb 100644 --- a/packages/pi-taskflow/test/skills-build.test.ts +++ b/packages/pi-taskflow/test/skills-build.test.ts @@ -27,12 +27,12 @@ test("skills: generated skill files are in sync with skills-src (build-skills -- } }); -test("release discovery metadata advertises the complete 0.2.2 surface", async () => { +test("release discovery metadata advertises the complete MCP surface", async () => { const { readFileSync } = await import("node:fs"); for (const file of [".claude-plugin/marketplace.json", ".grok-plugin/marketplace.json"]) { const text = readFileSync(path.join(root, file), "utf8"); - assert.match(text, /15 taskflow_\* MCP tools/); - assert.match(text, /run\/resume\/version\/list/); + assert.match(text, /16 taskflow_\* MCP tools/); + assert.match(text, /run\/runs\/resume\/version\/list/); } const piSource = readFileSync(path.join(root, "packages", "pi-taskflow", "src", "index.ts"), "utf8"); assert.match(piSource, /Use action=resume/); diff --git a/packages/taskflow-core/src/detached-control.ts b/packages/taskflow-core/src/detached-control.ts new file mode 100644 index 00000000..89e5c8c6 --- /dev/null +++ b/packages/taskflow-core/src/detached-control.ts @@ -0,0 +1,89 @@ +/** + * File-backed control plane for detached Taskflow runs. + * + * A detached runner cannot rely on an IPC channel surviving its parent MCP + * process. Cancellation is therefore requested through a small durable marker + * under the project runs directory. The child polls that marker and aborts the + * normal RuntimeDeps signal, preserving the runtime's existing paused semantics + * and process-tree cleanup. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { runsDir, validateRunId, writeFileAtomic } from "./store.ts"; + +export interface DetachedCancelRequest { + requestedAt: number; + reason?: string; +} + +const CONTROL_DIR = ".control"; +const DEFAULT_POLL_MS = 250; + +/** Resolve the cancel marker for a run without accepting caller-selected paths. */ +export function detachedCancelRequestPath(cwd: string, runId: string): string { + if (!validateRunId(runId)) throw new Error("Invalid runId for detached control"); + return path.join(runsDir(cwd), CONTROL_DIR, `${runId}.cancel.json`); +} + +/** Persist an idempotent cancellation request for a detached runner. */ +export function requestDetachedCancel(cwd: string, runId: string, reason?: string): DetachedCancelRequest { + const request: DetachedCancelRequest = { + requestedAt: Date.now(), + ...(typeof reason === "string" && reason.trim() + ? { reason: reason.trim().slice(0, 500) } + : {}), + }; + writeFileAtomic(detachedCancelRequestPath(cwd, runId), `${JSON.stringify(request)}\n`); + return request; +} + +/** Read a cancellation request. Corrupt markers still mean "cancel" fail-safe. */ +export function readDetachedCancelRequest(cwd: string, runId: string): DetachedCancelRequest | null { + const filePath = detachedCancelRequestPath(cwd, runId); + if (!fs.existsSync(filePath)) return null; + try { + const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf8")); + if (typeof parsed === "object" && parsed !== null) { + const record = parsed as Record; + return { + requestedAt: typeof record.requestedAt === "number" ? record.requestedAt : Date.now(), + ...(typeof record.reason === "string" ? { reason: record.reason } : {}), + }; + } + } catch { + // The marker exists, so fail safe and cancel even if its optional metadata + // was truncated or externally corrupted. + } + return { requestedAt: Date.now(), reason: "Cancellation marker was unreadable" }; +} + +export function clearDetachedCancelRequest(cwd: string, runId: string): void { + try { + fs.unlinkSync(detachedCancelRequestPath(cwd, runId)); + } catch { + /* missing/already consumed */ + } +} + +/** + * Poll a detached cancellation marker and bridge it into an AbortController. + * Returns a cleanup callback. The timer is unref'd so it never keeps a + * completed detached runner alive by itself. + */ +export function watchDetachedCancel( + cwd: string, + runId: string, + controller: AbortController, + pollMs = DEFAULT_POLL_MS, +): () => void { + const check = () => { + if (controller.signal.aborted) return; + const request = readDetachedCancelRequest(cwd, runId); + if (request) controller.abort(request); + }; + check(); + const timer = setInterval(check, Math.max(50, pollMs)); + timer.unref(); + return () => clearInterval(timer); +} diff --git a/packages/taskflow-core/src/detached-runner.ts b/packages/taskflow-core/src/detached-runner.ts index e1058b86..de0df8bd 100644 --- a/packages/taskflow-core/src/detached-runner.ts +++ b/packages/taskflow-core/src/detached-runner.ts @@ -9,20 +9,35 @@ * This file is NOT imported by index.ts — it is spawned via `child_process.spawn`. */ -import { readFileSync, rmdirSync, unlinkSync } from "node:fs"; -import { basename, dirname } from "node:path"; +import { existsSync, readFileSync, rmdirSync, unlinkSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; import { type AgentScope, discoverAgents, readSubagentSettings } from "./agents.ts"; import { executeTaskflow } from "./runtime.ts"; import { cwdBridgeModeFromEnv } from "./cwd-bridge.ts"; -import { getFlow, loadRun, saveRun, DEFAULT_KEPT_RUNS, DEFAULT_RUN_AGE_DAYS } from "./store.ts"; +import { + bumpReuseInSidecar, + getFlow, + loadRun, + runsDir, + saveRun, + traceFilePath, + DEFAULT_KEPT_RUNS, + DEFAULT_RUN_AGE_DAYS, +} from "./store.ts"; +import { + clearDetachedCancelRequest, + watchDetachedCancel, +} from "./detached-control.ts"; +import { FileTraceSink } from "./trace.ts"; +import type { RuntimeDeps } from "./runtime.ts"; +import type { SubagentRunner } from "./host/runner-types.ts"; interface DetachContext { runId: string; defName: string; args: Record; cwd: string; - /** Bare specifier of the host adapter's runner module, e.g. - * "pi-taskflow/dist/runner.js". The detached process can't import the host + /** Resolved URL/path of the host adapter's runner module. The detached process can't import the host * adapter statically (core is host-neutral), so the host tells it where to * find a `runTask`. Resolved via dynamic import at run time. */ runnerModule?: string; @@ -33,6 +48,34 @@ interface DetachContext { * host adapter; core never interprets or expands its authority. */ runnerFactoryExport?: string; runnerConfig?: unknown; + /** Wait for the parent to persist pid/launch metadata before execution. */ + waitForStart?: boolean; + /** Match foreground taskflow_run incremental cache defaults. */ + incremental?: boolean; + /** Saved flow selected through search; bump reuse only after success. */ + reusedSavedName?: string; +} + +const START_GATE_TIMEOUT_MS = 5_000; + +async function waitForStartGate(contextDir: string): Promise { + const startPath = join(contextDir, "start"); + const deadline = Date.now() + START_GATE_TIMEOUT_MS; + while (Date.now() < deadline) { + if (existsSync(startPath)) { + try { unlinkSync(startPath); } catch { /* consumed concurrently */ } + return true; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return false; +} + +function cleanupContextDir(contextPath: string): void { + const parent = dirname(contextPath); + if (basename(parent).startsWith("taskflow-detach-")) { + try { rmdirSync(parent); } catch { /* not empty / already gone */ } + } } const contextPath = process.argv[2]; @@ -51,18 +94,33 @@ try { // when reading/parsing fails, so a corrupt detached launch cannot leave a // private authorization snapshot behind indefinitely. try { unlinkSync(contextPath); } catch { /* best-effort one-shot file cleanup */ } - const parent = dirname(contextPath); - // Remove only an empty Taskflow temp directory. Recursive deletion here would - // make this public spawn entry capable of deleting unrelated argv-selected - // files merely because their parent happened to share the expected prefix. - if (basename(parent).startsWith("taskflow-detach-")) { - try { rmdirSync(parent); } catch { /* not empty / already gone */ } - } } -if (!ctx) process.exit(1); +if (!ctx) { + cleanupContextDir(contextPath); + process.exit(1); +} const cleanupConfig = { maxKeep: DEFAULT_KEPT_RUNS, maxAgeDays: DEFAULT_RUN_AGE_DAYS }; +if (ctx.waitForStart && !(await waitForStartGate(dirname(contextPath)))) { + try { + const state = loadRun(ctx.cwd, ctx.runId); + if (state?.status === "running") { + state.status = "failed"; + state.phases["__detach__"] = { + id: "__detach__", + status: "failed", + endedAt: Date.now(), + error: `Detached launch gate was not released within ${START_GATE_TIMEOUT_MS}ms.`, + }; + saveRun(state, cleanupConfig); + } + } catch { /* best-effort terminalization */ } + cleanupContextDir(contextPath); + process.exit(1); +} +cleanupContextDir(contextPath); + try { const state = loadRun(ctx.cwd, ctx.runId); if (!state) { @@ -81,7 +139,7 @@ try { // CANNOT spawn pi/codex itself, so without this every phase would fail with // "No subagent runner injected". Resolve the runner via a dynamic import of // the module path the host serialized into the context file. - let runTask: import("./host/runner-types.ts").SubagentRunner["runTask"] | undefined; + let injectedRunner: SubagentRunner | undefined; if (ctx.runnerModule) { try { const runnerMod = await import(ctx.runnerModule); @@ -91,7 +149,7 @@ try { ? exported(ctx.runnerConfig) : exported; if (runner && typeof runner.runTask === "function") { - runTask = runner.runTask; + injectedRunner = runner as SubagentRunner; } else { console.error(`[detached-runner] '${exportName}' on '${ctx.runnerModule}' is not a SubagentRunner (missing runTask)`); } @@ -108,7 +166,7 @@ try { // bug, a missing export). Exiting non-zero here routes the real stderr // message into the host's early-exit crash guard, which persists it on the // run's synthetic __detach__ phase where it is pollable and debuggable. - if (ctx.runnerModule && !runTask) { + if (ctx.runnerModule && !injectedRunner) { state.status = "failed"; state.phases["__detach__"] = { id: "__detach__", @@ -120,19 +178,42 @@ try { process.exit(1); } - const result = await executeTaskflow(state, { + const abortController = new AbortController(); + const stopCancelWatch = watchDetachedCancel(ctx.cwd, ctx.runId, abortController); + const abortForSignal = () => abortController.abort({ reason: "detached-process-signal" }); + process.once("SIGTERM", abortForSignal); + process.once("SIGINT", abortForSignal); + process.once("SIGHUP", abortForSignal); + const deps: RuntimeDeps = { cwd: ctx.cwd, cwdBridgeMode: cwdBridgeModeFromEnv(), agents, globalThinking: settings.globalThinking, persist: (s) => saveRun(s, cleanupConfig), - runTask, + runTask: injectedRunner?.runTask, + usageAccounting: injectedRunner?.usageAccounting, + signal: abortController.signal, + trace: new FileTraceSink(traceFilePath(runsDir(ctx.cwd), state.flowName, state.runId)), // No requestApproval — approval phases auto-reject in detached/CI mode // (safety: approval gates are never bypassed; the run records the rejection). loadFlow: (name: string) => getFlow(ctx.cwd, name)?.def, - }); - - saveRun(result.state, cleanupConfig); + }; + if (ctx.incremental === true) deps.cacheScopeDefault = "cross-run"; + try { + const result = await executeTaskflow(state, deps); + result.state.finalOutput = result.finalOutput; + result.state.outputSourcePhaseId = result.outputSourcePhaseId; + saveRun(result.state, cleanupConfig); + if (result.ok && ctx.reusedSavedName) { + try { bumpReuseInSidecar(ctx.cwd, ctx.reusedSavedName); } catch { /* best-effort */ } + } + } finally { + stopCancelWatch(); + clearDetachedCancelRequest(ctx.cwd, ctx.runId); + process.removeListener("SIGTERM", abortForSignal); + process.removeListener("SIGINT", abortForSignal); + process.removeListener("SIGHUP", abortForSignal); + } } catch (e) { // Top-level catch: persist failure so the host can poll the terminal state. const message = e instanceof Error ? e.message : String(e); @@ -146,5 +227,6 @@ try { } catch { // Best-effort — if we can't even load the state, there's nothing to persist. } + clearDetachedCancelRequest(ctx.cwd, ctx.runId); process.exit(1); } diff --git a/packages/taskflow-core/src/index.ts b/packages/taskflow-core/src/index.ts index b672eb69..c47b659b 100644 --- a/packages/taskflow-core/src/index.ts +++ b/packages/taskflow-core/src/index.ts @@ -25,6 +25,7 @@ export * from "./rates.ts"; export * from "./stale.ts"; export * from "./workspace.ts"; export * from "./cwd-bridge.ts"; +export * from "./detached-control.ts"; // 0.2.1 exposes only the deliberately supported resolve-only compatibility // controls. The broader Workspace Capability scaffold remains internal until // its native backend/API contract is complete; publishing it from the root diff --git a/packages/taskflow-core/src/store.ts b/packages/taskflow-core/src/store.ts index 16f96308..1fa5a8ed 100644 --- a/packages/taskflow-core/src/store.ts +++ b/packages/taskflow-core/src/store.ts @@ -193,6 +193,12 @@ export interface RunState { pid?: number; /** True for runs spawned via `detach: true` (background execution). */ detached?: boolean; + /** Wall-clock launch time for detached lifecycle/orphan diagnostics. */ + detachedStartedAt?: number; + /** Final output persisted by a detached runner for later wait/status calls. */ + finalOutput?: string; + /** Phase whose output supplied `finalOutput`, when attributable. */ + outputSourcePhaseId?: string; /** Content fingerprint of the desugared flow definition (overstory hash * algorithm). Folded into every phase's cache key so a structural change * to the flow always invalidates cross-run cache hits — and an identical @@ -680,6 +686,9 @@ function cleanupTerminalRuns( // Also remove the per-run Shared Context Tree directory (C6). Orphaned // ctx dirs would otherwise accumulate under runs/ctx/ over many runs. try { fs.rmSync(path.join(runsRoot, "ctx", e.runId), { recursive: true, force: true }); } catch { /* ignore */ } + // Remove a detached control marker left by a process killed before it + // could consume/clear cancellation itself. + try { fs.unlinkSync(path.join(runsRoot, ".control", `${e.runId}.cancel.json`)); } catch { /* ignore */ } // Also remove the per-run isolated-workspace dir tree (cwd:"dedicated"). // `dedicated` workspaces are persistent by design; reclaim them once the // run is pruned. The dir name uses the same sanitization as workspace.ts. diff --git a/packages/taskflow-mcp-core/src/mcp/background.ts b/packages/taskflow-mcp-core/src/mcp/background.ts new file mode 100644 index 00000000..ffd8af6e --- /dev/null +++ b/packages/taskflow-mcp-core/src/mcp/background.ts @@ -0,0 +1,204 @@ +/** Host-neutral detached launch + lifecycle helpers for MCP adapters. */ + +import { spawn } from "node:child_process"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + clearDetachedCancelRequest, + isProcessAlive, + loadRun, + readDetachedCancelRequest, + requestDetachedCancel, + saveRun, + type DetachedCancelRequest, + type RunState, +} from "taskflow-core"; + +export interface DetachedRunnerBinding { + /** Resolved file URL/path of a module exporting the host SubagentRunner. */ + module: string; + exportName: string; +} + +export interface BackgroundLaunchOptions { + state: RunState; + runner: DetachedRunnerBinding; + incremental?: boolean; + reusedSavedName?: string; +} + +const STARTING_GRACE_MS = 5_000; +const WAIT_POLL_MS = 100; + +function markDetachedFailure(state: RunState, message: string): RunState { + state.status = "failed"; + state.phases["__detach__"] = { + id: "__detach__", + status: "failed", + endedAt: Date.now(), + error: message.slice(0, 2_000), + }; + saveRun(state); + return state; +} + +function markDetachedExit(cwd: string, runId: string, pid: number, message: string): void { + try { + const state = loadRun(cwd, runId); + if (state?.status === "running" && state.pid === pid) markDetachedFailure(state, message); + } catch { + /* best-effort crash guard */ + } +} + +function detachedNodeArgs(runnerScript: string): string[] { + if (!runnerScript.endsWith(".ts")) return []; + return ["--conditions=development", "--experimental-strip-types"]; +} + +/** + * Spawn the shared core detached runner and release it only after pid metadata + * is durably persisted, preventing a fast child from being overwritten by a + * stale parent-side `running` save. + */ +export function launchMcpBackgroundRun(options: BackgroundLaunchOptions): { pid: number } { + const { state } = options; + // A run id should be unique, but clearing first makes relaunch/recovery safe + // if an operator deliberately reuses a stored state in tests or tooling. + clearDetachedCancelRequest(state.cwd, state.runId); + state.detached = true; + state.detachedStartedAt = Date.now(); + saveRun(state); + + const tempDir = mkdtempSync(join(tmpdir(), "taskflow-detach-")); + chmodSync(tempDir, 0o700); + const contextPath = join(tempDir, "context.json"); + const startPath = join(tempDir, "start"); + writeFileSync(contextPath, JSON.stringify({ + runId: state.runId, + defName: state.flowName, + args: state.args, + cwd: state.cwd, + runnerModule: options.runner.module, + runnerExport: options.runner.exportName, + waitForStart: true, + incremental: options.incremental === true, + reusedSavedName: options.reusedSavedName, + }), { encoding: "utf8", flag: "wx", mode: 0o600 }); + + try { + const runnerScript = fileURLToPath(import.meta.resolve("taskflow-core/detached-runner")); + const child = spawn( + process.execPath, + [...detachedNodeArgs(runnerScript), runnerScript, contextPath], + { cwd: state.cwd, detached: true, stdio: "ignore" }, + ); + const pid = child.pid; + if (typeof pid !== "number") throw new Error("Detached runner did not report a pid"); + + child.once("error", (error) => { + markDetachedExit(state.cwd, state.runId, pid, `Failed to spawn detached runner: ${error.message}`); + }); + child.once("exit", (code, signal) => { + if (code === 0) return; + markDetachedExit( + state.cwd, + state.runId, + pid, + `Detached runner exited before completing (code ${code ?? "null"}, signal ${signal ?? "none"}).`, + ); + }); + + state.pid = pid; + saveRun(state); + writeFileSync(startPath, "start\n", { encoding: "utf8", flag: "wx", mode: 0o600 }); + child.unref(); + return { pid }; + } catch (error) { + try { rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort */ } + const message = error instanceof Error ? error.message : String(error); + markDetachedFailure(state, `Failed to launch detached runner: ${message}`); + throw error; + } +} + +/** Refresh a detached run and terminalize an orphaned process. */ +export function refreshDetachedRun(cwd: string, runId: string): RunState | null { + const state = loadRun(cwd, runId); + if (!state || state.status !== "running" || !state.detached) return state; + + const cancel = readDetachedCancelRequest(cwd, runId); + if (typeof state.pid === "number") { + if (isProcessAlive(state.pid)) return state; + const fresh = loadRun(cwd, runId); + if (!fresh || fresh.status !== "running" || fresh.pid !== state.pid) return fresh; + if (cancel) { + fresh.status = "paused"; + fresh.phases["__detach__"] = { + id: "__detach__", + status: "skipped", + endedAt: Date.now(), + warnings: ["Cancellation requested; detached process exited before persisting its normal paused state."], + }; + saveRun(fresh); + clearDetachedCancelRequest(cwd, runId); + return fresh; + } + return markDetachedFailure(fresh, "Detached runner process exited before persisting a terminal state."); + } + + if (Date.now() - (state.detachedStartedAt ?? state.createdAt) <= STARTING_GRACE_MS) return state; + return markDetachedFailure(state, "Detached runner never persisted a process id."); +} + +export function cancelMcpBackgroundRun(cwd: string, runId: string, reason?: string): DetachedCancelRequest { + return requestDetachedCancel(cwd, runId, reason); +} + +export async function waitForMcpBackgroundRun( + cwd: string, + runId: string, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + const deadline = Date.now() + Math.max(0, timeoutMs); + let state = refreshDetachedRun(cwd, runId); + while (state?.status === "running" && Date.now() < deadline && !signal?.aborted) { + await new Promise((resolve) => setTimeout(resolve, Math.min(WAIT_POLL_MS, Math.max(1, deadline - Date.now())))); + state = refreshDetachedRun(cwd, runId); + } + return state; +} + +function phaseProgress(state: RunState): { done: number; total: number } { + const total = state.def.phases.length; + const done = Object.values(state.phases).filter((phase) => phase.status !== "running").length; + return { done: Math.min(done, total), total }; +} + +function statusGlyph(status: RunState["status"]): string { + switch (status) { + case "completed": return "✓"; + case "running": return "↻"; + case "blocked": return "■"; + case "paused": return "Ⅱ"; + case "failed": return "✗"; + } +} + +/** Compact, plaintext MCP presentation shared by list/status/wait/cancel. */ +export function formatBackgroundRun(state: RunState, includeOutput: boolean): string { + const progress = phaseProgress(state); + const pid = typeof state.pid === "number" ? ` · pid ${state.pid}` : ""; + const first = `${statusGlyph(state.status)} ${state.status} · ${state.flowName} · ${progress.done}/${progress.total} phases${pid} · run ${state.runId}`; + if (!includeOutput || state.status === "running") { + const cancel = readDetachedCancelRequest(state.cwd, state.runId); + return cancel ? `${first} · cancellation requested` : first; + } + const source = state.outputSourcePhaseId ? `--- ${state.outputSourcePhaseId} ---\n` : ""; + if (state.finalOutput) return `${first}\n\n${source}${state.finalOutput}`; + const failure = Object.values(state.phases).find((phase) => phase.status === "failed" && phase.error)?.error; + return failure ? `${first}\n\n${failure}` : first; +} diff --git a/packages/taskflow-mcp-core/src/mcp/server.ts b/packages/taskflow-mcp-core/src/mcp/server.ts index 06bd4e5c..8cdf7f6b 100644 --- a/packages/taskflow-mcp-core/src/mcp/server.ts +++ b/packages/taskflow-mcp-core/src/mcp/server.ts @@ -16,6 +16,7 @@ * * Tools exposed: * - taskflow_run : run an inline or saved flow, return the final output + * - taskflow_runs : list/status/wait/cancel background runs * - taskflow_list : list saved flows discoverable in this cwd * - taskflow_show : show a saved flow's definition * - taskflow_verify : statically verify a flow (no execution) @@ -29,6 +30,14 @@ import { RpcError, RPC, serveStdio, type RpcContext, type RpcHandler } from "./jsonrpc.ts"; import { renderFlowSvg, renderFlowOutline, svgToBase64 } from "./svg.ts"; +import { + cancelMcpBackgroundRun, + formatBackgroundRun, + launchMcpBackgroundRun, + refreshDetachedRun, + waitForMcpBackgroundRun, + type DetachedRunnerBinding, +} from "./background.ts"; import { readFileSync, realpathSync } from "node:fs"; import { basename, dirname, join, resolve, relative, isAbsolute } from "node:path"; import { tmpdir } from "node:os"; @@ -38,6 +47,7 @@ import { executeTaskflow, getFlowDiagnosed, listFlows, + listRuns, newRunId, peekRun, saveRun, @@ -415,7 +425,26 @@ const TOOLS: McpTool[] = [ args: { type: "object", description: "Invocation arguments interpolated as {args.X}." }, incremental: { type: "boolean", description: "Default every phase to cross-run cache reuse." }, reusedFromSearch: { type: "boolean", description: "Set true when this run was chosen because of a prior taskflow_search → bumps the flow's reuseCount (the reuse flywheel). Default false; direct run-by-name does not bump." }, + mode: { type: "string", enum: ["foreground", "background"], description: "Execution mode. foreground (default) waits for the full DAG; background returns a runId immediately and continues even if the MCP request ends." }, + }, + }, + }, + { + name: "taskflow_runs", + title: "Manage background taskflow runs", + description: + "List recent background runs, inspect one run, wait for completion without losing it when the MCP request ends, or request cancellation. Use after taskflow_run with mode:'background'.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + action: { type: "string", enum: ["list", "status", "wait", "cancel"], description: "Lifecycle action." }, + runId: { type: "string", description: "Required for status, wait, and cancel." }, + timeoutMs: { type: "integer", description: "For wait: return after this many ms if still running (default 30000, max 300000)." }, + limit: { type: "integer", description: "For list: maximum recent background runs (default 10, max 50)." }, + reason: { type: "string", description: "For cancel: optional audit reason." }, }, + required: ["action"], }, }, { @@ -691,6 +720,8 @@ function resolveFlow(cwd: string, params: { name?: string; define?: unknown; def * `"taskflow"` when omitted so existing callers are unaffected. */ export interface McpHostOptions { host?: string; + /** Resolvable host-runner module used by detached/background execution. */ + detachedRunner?: DetachedRunnerBinding; } /** Stamp a freshly-created RunState with build/host identity (0.2.0 dogfood @@ -790,6 +821,30 @@ export function makeToolHandlers( cwdBridgeMode: cwdBridgeModeFromEnv(), }; const state = mkRunState(def, resolvedArgs, cwd, host); + if (args.mode === "background") { + if (!opts?.detachedRunner) { + return textContent( + "Background mode is unavailable because this MCP host did not provide a detached runner binding.", + true, + ); + } + try { + const { pid } = launchMcpBackgroundRun({ + state, + runner: opts.detachedRunner, + incremental: args.incremental === true, + reusedSavedName: args.reusedFromSearch === true ? reusedSavedName : undefined, + }); + return textContent( + `↻ taskflow started in background\n\n${state.flowName} · pid ${pid} · run ${state.runId}\nUse taskflow_runs with action status, wait, or cancel.`, + ); + } catch (error) { + return textContent( + `Failed to start background taskflow: ${error instanceof Error ? error.message : String(error)}`, + true, + ); + } + } // Deterministic-replay trace (best-effort, fail-open). deps.trace = new FileTraceSink(traceFilePath(runsDir(cwd), state.flowName, state.runId)); if (args.incremental === true) (deps as RuntimeDeps & { cacheScopeDefault?: string }).cacheScopeDefault = "cross-run"; @@ -807,11 +862,16 @@ export function makeToolHandlers( }; let terminalPersistError: string | undefined; - const res = await executeTaskflow(state, deps).finally(() => { + let res: Awaited>; + try { + res = await executeTaskflow(state, deps); + state.finalOutput = res.finalOutput; + state.outputSourcePhaseId = res.outputSourcePhaseId; + } finally { // Terminal persist must survive a throwing runtime ("never lose work") — // and persistence itself must never sink a completed run. terminalPersistError = persistTerminalRun(state, cleanupConfig); - }); + } if (terminalPersistError !== undefined) { return textContent( `Taskflow execution finished, but terminal run persistence failed; no durable run ID can be promised: ${terminalPersistError}`, @@ -836,6 +896,38 @@ export function makeToolHandlers( return textContent(`${header}\n\n${sourceLabel}${res.finalOutput}${usageLine}`, !res.ok); }, + taskflow_runs: async (args, context) => { + const action = String(args.action ?? ""); + if (action === "list") { + const limit = Math.max(1, Math.min(50, typeof args.limit === "number" ? Math.floor(args.limit) : 10)); + const runs = listRuns(cwd, 1_000) + .filter((run) => run.detached) + .slice(0, limit) + .map((run) => refreshDetachedRun(cwd, run.runId) ?? run); + if (runs.length === 0) return textContent("No background taskflow runs found from this directory."); + return textContent(`Background taskflow runs:\n${runs.map((run) => formatBackgroundRun(run, false)).join("\n")}`); + } + + const runId = typeof args.runId === "string" ? args.runId.trim() : ""; + if (!runId) return textContent(`taskflow_runs action '${action}' requires \`runId\`.`, true); + let state = refreshDetachedRun(cwd, runId); + if (!state) return textContent(`Run \"${runId}\" was not found.`, true); + if (!state.detached) return textContent(`Run \"${runId}\" is not a background run.`, true); + + if (action === "status") return textContent(formatBackgroundRun(state, true)); + if (action === "wait") { + const timeoutMs = Math.max(0, Math.min(300_000, typeof args.timeoutMs === "number" ? Math.floor(args.timeoutMs) : 30_000)); + state = await waitForMcpBackgroundRun(cwd, runId, timeoutMs, context?.signal) ?? state; + return textContent(formatBackgroundRun(state, true), state.status !== "running" && state.status !== "completed"); + } + if (action === "cancel") { + if (state.status !== "running") return textContent(`Run is already ${state.status}.\n${formatBackgroundRun(state, true)}`); + cancelMcpBackgroundRun(cwd, runId, typeof args.reason === "string" ? args.reason : undefined); + return textContent(`Cancellation requested.\n${formatBackgroundRun(state, false)}`); + } + return textContent(`Unknown taskflow_runs action: ${action}`, true); + }, + taskflow_resume: async (args, context) => { // 0.2.0 dogfood issue 5: resume forks a NEW run; the original is never // mutated or overwritten. Optional overrides re-run exactly one phase. diff --git a/packages/taskflow-mcp-core/test/background-runs.test.ts b/packages/taskflow-mcp-core/test/background-runs.test.ts new file mode 100644 index 00000000..7338d571 --- /dev/null +++ b/packages/taskflow-mcp-core/test/background-runs.test.ts @@ -0,0 +1,88 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { pathToFileURL } from "node:url"; +import { loadRun, type SubagentRunner } from "taskflow-core"; +import { makeToolHandlers } from "taskflow-mcp-core/server"; + +interface TextResult { + content: Array<{ type: string; text: string }>; + isError?: boolean; +} + +const unusedForegroundRunner: SubagentRunner = { + runTask: async () => { + throw new Error("foreground runner should not be called"); + }, +}; + +function fixtureModule(): string { + return pathToFileURL(path.join(import.meta.dirname, "fixtures", "background-runner.mjs")).href; +} + +function runIdFrom(result: TextResult): string { + const match = /\brun ([A-Za-z0-9._-]+)/.exec(result.content[0]?.text ?? ""); + assert.ok(match, `expected run id in:\n${result.content[0]?.text}`); + return match[1]!; +} + +function inlineAgentFlow(name: string) { + return { + name, + phases: [{ id: "work", type: "agent", agent: "executor", task: "work", final: true }], + }; +} + +test("mcp background: run returns immediately and wait returns durable final output", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-background-")); + try { + const tools = makeToolHandlers(cwd, unusedForegroundRunner, { + host: "test", + detachedRunner: { module: fixtureModule(), exportName: "instantRunner" }, + }); + const started = await tools.taskflow_run({ define: inlineAgentFlow("background-complete"), mode: "background" }) as TextResult; + assert.equal(started.isError, false); + assert.match(started.content[0]!.text, /started in background/); + const runId = runIdFrom(started); + + const waited = await tools.taskflow_runs({ action: "wait", runId, timeoutMs: 5_000 }) as TextResult; + assert.equal(waited.isError, false, waited.content[0]?.text); + assert.match(waited.content[0]!.text, /✓ completed/); + assert.match(waited.content[0]!.text, /detached output/); + + const stored = loadRun(cwd, runId); + assert.equal(stored?.status, "completed"); + assert.equal(stored?.finalOutput, "detached output"); + assert.equal(stored?.outputSourcePhaseId, "work"); + + const listed = await tools.taskflow_runs({ action: "list" }) as TextResult; + assert.match(listed.content[0]!.text, new RegExp(runId)); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("mcp background: cancel survives request boundaries and pauses the run", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-cancel-")); + try { + const tools = makeToolHandlers(cwd, unusedForegroundRunner, { + host: "test", + detachedRunner: { module: fixtureModule(), exportName: "cancellableRunner" }, + }); + const started = await tools.taskflow_run({ define: inlineAgentFlow("background-cancel"), mode: "background" }) as TextResult; + const runId = runIdFrom(started); + + const cancelled = await tools.taskflow_runs({ action: "cancel", runId, reason: "test cancellation" }) as TextResult; + assert.equal(cancelled.isError, false); + assert.match(cancelled.content[0]!.text, /Cancellation requested/); + + const waited = await tools.taskflow_runs({ action: "wait", runId, timeoutMs: 5_000 }) as TextResult; + assert.equal(waited.isError, true); + assert.match(waited.content[0]!.text, /Ⅱ paused/); + assert.equal(loadRun(cwd, runId)?.status, "paused"); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-mcp-core/test/fixtures/background-runner.mjs b/packages/taskflow-mcp-core/test/fixtures/background-runner.mjs new file mode 100644 index 00000000..f7bfcc47 --- /dev/null +++ b/packages/taskflow-mcp-core/test/fixtures/background-runner.mjs @@ -0,0 +1,53 @@ +const usage = { + input: 3, + output: 2, + cacheRead: 0, + cacheWrite: 0, + cost: 0.001, + contextTokens: 5, + turns: 1, +}; + +export const instantRunner = { + usageAccounting: "full", + runTask: async (_cwd, _agents, agent, task) => ({ + agent, + task, + exitCode: 0, + output: "detached output", + stderr: "", + usage, + stopReason: "end", + }), +}; + +export const cancellableRunner = { + usageAccounting: "full", + runTask: async (_cwd, _agents, agent, task, options) => + await new Promise((resolve) => { + const finish = () => resolve({ + agent, + task, + exitCode: 1, + output: "", + stderr: "cancelled", + error: "Cancelled", + usage: { ...usage, input: 0, output: 0, contextTokens: 0 }, + stopReason: "aborted", + }); + if (options?.signal?.aborted) return finish(); + const timer = setTimeout(() => resolve({ + agent, + task, + exitCode: 0, + output: "too late", + stderr: "", + usage, + stopReason: "end", + }), 10_000); + options?.signal?.addEventListener("abort", () => { + clearTimeout(timer); + finish(); + }, { once: true }); + }), +}; diff --git a/skills-src/taskflow/core.md b/skills-src/taskflow/core.md index 3bde3e66..7da86292 100644 --- a/skills-src/taskflow/core.md +++ b/skills-src/taskflow/core.md @@ -745,6 +745,11 @@ and output sizes, then peek the suspicious phase (`json: true` for parsed output, `item: n` for one fan-out section). Output is hard-truncated (default 4000 chars, max 32000) so a peek never floods your context. +For a flow that may outlive one MCP tool call, set `mode: "background"` on +`taskflow_run`. It returns immediately; use `taskflow_runs` with `action: +"status"`, `"wait"`, or `"cancel"` and the returned `runId`. A bounded `wait` +can be called repeatedly, and completion returns the persisted final output. + Use `taskflow_trace` to inspect the append-only event log for a finished run, then `taskflow_replay` to re-judge it under alternate thresholds/budget **offline (zero tokens)** — e.g. "would a 0.9 gate threshold have blocked this run?" diff --git a/skills-src/taskflow/entry.claude.md b/skills-src/taskflow/entry.claude.md index f3f05b52..bc037926 100644 --- a/skills-src/taskflow/entry.claude.md +++ b/skills-src/taskflow/entry.claude.md @@ -12,7 +12,8 @@ runs as an isolated `claude -p` session. | Tool | What it does | |------|--------------| -| `taskflow_run` | Run a saved flow (`name`) or an inline `define` (full DAG, or shorthand `{task}` / `{tasks}` / `{chain}`). Optional `args`, `incremental`. Returns only the final phase output + a `runId`. | +| `taskflow_run` | Run a saved or inline flow. Optional `args`, `incremental`; `mode: "background"` returns a durable `runId` immediately. | +| `taskflow_runs` | List background runs or `status` / `wait` / `cancel` one by `runId`. | | `taskflow_resume` | Fork a failed/paused run into a new immutable child run, optionally overriding one phase's task/model/timeouts. | | `taskflow_version` | Report the executing package version, build commit, schema version, build time, and host identity. | | `taskflow_list` | List saved flows discoverable from the current working directory. | diff --git a/skills-src/taskflow/entry.codex.md b/skills-src/taskflow/entry.codex.md index 0f2ca833..70c9c8b8 100644 --- a/skills-src/taskflow/entry.codex.md +++ b/skills-src/taskflow/entry.codex.md @@ -11,7 +11,8 @@ the Codex form (`taskflow_verify`). | Tool | What it does | |------|--------------| -| `taskflow_run` | Run a saved flow (`name`) or an inline `define` (full DAG, or shorthand `{task}` / `{tasks}` / `{chain}`). Optional `args`, `incremental`. Returns only the final phase output + a `runId`. | +| `taskflow_run` | Run a saved or inline flow. Optional `args`, `incremental`; `mode: "background"` returns a durable `runId` immediately. | +| `taskflow_runs` | List background runs or `status` / `wait` / `cancel` one by `runId`. | | `taskflow_resume` | Fork a failed/paused run into a new immutable child run, optionally overriding one phase's task/model/timeouts. | | `taskflow_version` | Report the executing package version, build commit, schema version, build time, and host identity. | | `taskflow_list` | List saved flows discoverable from the current working directory. | diff --git a/skills-src/taskflow/entry.grok.md b/skills-src/taskflow/entry.grok.md index 2b3183aa..5bd46677 100644 --- a/skills-src/taskflow/entry.grok.md +++ b/skills-src/taskflow/entry.grok.md @@ -21,7 +21,8 @@ kernel enforcement is unavailable. | Tool | What it does | |------|--------------| -| `taskflow_run` | Run a saved flow (`name`) or an inline `define` (full DAG, or shorthand `{task}` / `{tasks}` / `{chain}`). Optional `args`, `incremental`. Returns only the final phase output + a `runId`. | +| `taskflow_run` | Run a saved or inline flow. Optional `args`, `incremental`; `mode: "background"` returns a durable `runId` immediately. | +| `taskflow_runs` | List background runs or `status` / `wait` / `cancel` one by `runId`. | | `taskflow_resume` | Fork a failed/paused run into a new immutable child run, optionally overriding one phase's task/model/timeouts. | | `taskflow_version` | Report the executing package version, build commit, schema version, build time, and host identity. | | `taskflow_list` | List saved flows discoverable from the current working directory. | diff --git a/skills-src/taskflow/entry.opencode.md b/skills-src/taskflow/entry.opencode.md index ba72cf09..f6b0cadb 100644 --- a/skills-src/taskflow/entry.opencode.md +++ b/skills-src/taskflow/entry.opencode.md @@ -12,7 +12,8 @@ the OpenCode form (`taskflow_verify`). Each phase's subagent runs as an isolated | Tool | What it does | |------|--------------| -| `taskflow_run` | Run a saved flow (`name`) or an inline `define` (full DAG, or shorthand `{task}` / `{tasks}` / `{chain}`). Optional `args`, `incremental`. Returns only the final phase output + a `runId`. | +| `taskflow_run` | Run a saved or inline flow. Optional `args`, `incremental`; `mode: "background"` returns a durable `runId` immediately. | +| `taskflow_runs` | List background runs or `status` / `wait` / `cancel` one by `runId`. | | `taskflow_resume` | Fork a failed/paused run into a new immutable child run, optionally overriding one phase's task/model/timeouts. | | `taskflow_version` | Report the executing package version, build commit, schema version, build time, and host identity. | | `taskflow_list` | List saved flows discoverable from the current working directory. | From 6091884eac625ebe86667359cf5ec5ea42f7c678 Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 18 Jul 2026 12:09:43 +0800 Subject: [PATCH 2/8] feat(mcp): surface background run contention Add active-run filters and warnings, and preserve the WIP design value as current daemon and workspace capability boundaries. --- CHANGELOG.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- docs/claude-mcp.md | 3 + docs/codex-mcp.md | 4 +- docs/grok-mcp.md | 3 + docs/internal/rfc-local-daemon.md | 101 +++++++++++++ docs/internal/rfc-workspace-capabilities.md | 141 ++++++++++++++++++ docs/opencode-mcp.md | 4 +- .../plugin/skills/taskflow/SKILL.md | 3 + .../plugin/skills/taskflow/SKILL.md | 3 + .../plugin/skills/taskflow/SKILL.md | 3 + .../plugin/skills/taskflow/SKILL.md | 3 + .../taskflow-mcp-core/src/mcp/background.ts | 36 +++++ packages/taskflow-mcp-core/src/mcp/server.ts | 29 +++- .../test/background-runs.test.ts | 49 +++++- skills-src/taskflow/core.md | 3 + 17 files changed, 376 insertions(+), 15 deletions(-) create mode 100644 docs/internal/rfc-local-daemon.md create mode 100644 docs/internal/rfc-workspace-capabilities.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d47559f5..975b0981 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to taskflow are documented here. This project follows [Keep ### Added -- **Durable MCP background runs.** `taskflow_run` now accepts `mode: "background"` on Codex, Claude Code, OpenCode, and Grok Build, returning a durable `runId` immediately instead of tying a long DAG to one MCP request timeout. The new `taskflow_runs` tool lists background runs and supports `status`, bounded/repeatable `wait`, and explicit `cancel`. Detached runs persist their final output and trace, preserve incremental-cache and library-reuse behavior, detect orphaned processes, and use a file-backed cancellation control plane that survives MCP request and server boundaries. +- **Durable MCP background runs.** `taskflow_run` now accepts `mode: "background"` on Codex, Claude Code, OpenCode, and Grok Build, returning a durable `runId` immediately instead of tying a long DAG to one MCP request timeout. The new `taskflow_runs` tool lists background runs and supports `status`, bounded/repeatable `wait`, and explicit `cancel`; lists report the total active count and can filter `running` versus `terminal` runs. Detached runs persist their final output and trace, preserve incremental-cache and library-reuse behavior, detect orphaned processes, and use a file-backed cancellation control plane that survives MCP request and server boundaries. Starting a sixth concurrent background run emits an explicit resource-contention warning because Taskflow intentionally has no hidden global cross-host scheduler. ## [0.2.2] — 2026-07-14 diff --git a/README.md b/README.md index 68f0b93a..3d311232 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ Save it as `.pi/taskflows/audit-api.json`, then run: /tf:audit-api dir=src/api ``` -On Codex, Claude Code, OpenCode, and Grok Build, run the same saved definition by name through `taskflow_run`. For long DAGs, use `mode: "background"`, then manage the durable run with `taskflow_runs` (`status` / `wait` / `cancel`). +On Codex, Claude Code, OpenCode, and Grok Build, run the same saved definition by name through `taskflow_run`. For long DAGs, use `mode: "background"`, then manage the durable run with `taskflow_runs` (`list` / `status` / `wait` / `cancel`); list output reports active concurrency and can filter `running` or `terminal` runs. [Follow the full quickstart →](https://heggria.github.io/taskflow/en/docs/getting-started) diff --git a/README.zh-CN.md b/README.zh-CN.md index 76372c51..203a817f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -131,7 +131,7 @@ pi install npm:pi-taskflow /tf:audit-api dir=src/api ``` -在 Codex、Claude Code、OpenCode 和 Grok Build 上,通过 `taskflow_run` 按名称运行同一份保存定义。长任务可使用 `mode: "background"`,再用 `taskflow_runs` 执行 `status` / `wait` / `cancel`,无需担心单次 MCP 调用超时。 +在 Codex、Claude Code、OpenCode 和 Grok Build 上,通过 `taskflow_run` 按名称运行同一份保存定义。长任务可使用 `mode: "background"`,再用 `taskflow_runs` 执行 `list` / `status` / `wait` / `cancel`,无需担心单次 MCP 调用超时;列表会显示当前并发数,并可筛选 `running` 或 `terminal` 运行。 [查看完整快速开始 →](https://heggria.github.io/taskflow/zh-cn/docs/getting-started) diff --git a/docs/claude-mcp.md b/docs/claude-mcp.md index 0fb76ac6..79a543a9 100644 --- a/docs/claude-mcp.md +++ b/docs/claude-mcp.md @@ -79,6 +79,9 @@ long flow, pass `mode: "background"`: it returns a durable `runId` immediately and continues independently of that MCP request. Use `taskflow_runs` with `action: "status"`, `"wait"`, or `"cancel"`; `wait` is bounded by `timeoutMs` and can be called repeatedly until the persisted final output is ready. +`action: "list"` reports total active concurrency and accepts +`status: "running" | "terminal"`; starting a sixth active background run warns +that no global cross-host concurrency/budget coordinator exists. ## Alternative: register the MCP server manually diff --git a/docs/codex-mcp.md b/docs/codex-mcp.md index 615ae11a..b625e568 100644 --- a/docs/codex-mcp.md +++ b/docs/codex-mcp.md @@ -56,7 +56,9 @@ For a long flow, pass `mode: "background"`: the call returns a durable `runId` immediately, and the run continues independently of that MCP request. Use `taskflow_runs` with `action: "status"`, `"wait"`, or `"cancel"`; `wait` may be called repeatedly with a bounded `timeoutMs` and returns the persisted final -output when complete. +output when complete. `action: "list"` reports total active concurrency and +accepts `status: "running" | "terminal"`; starting a sixth active background +run warns that no global cross-host concurrency/budget coordinator exists. Codex still applies a per-server MCP **tool-call timeout** to foreground calls. diff --git a/docs/grok-mcp.md b/docs/grok-mcp.md index 6db48a95..f3142328 100644 --- a/docs/grok-mcp.md +++ b/docs/grok-mcp.md @@ -158,6 +158,9 @@ continues independently of that MCP request. Use `taskflow_runs` with `action: "status"`, `"wait"`, or `"cancel"`; bounded waits can be repeated until the persisted final output is ready. Cancelling a foreground MCP request still aborts that foreground DAG; background runs are cancelled explicitly by id. +`action: "list"` reports total active concurrency and accepts +`status: "running" | "terminal"`; starting a sixth active background run warns +that no global cross-host concurrency/budget coordinator exists. ## Alternative: register the MCP server manually diff --git a/docs/internal/rfc-local-daemon.md b/docs/internal/rfc-local-daemon.md new file mode 100644 index 00000000..ab52eeac --- /dev/null +++ b/docs/internal/rfc-local-daemon.md @@ -0,0 +1,101 @@ +# RFC: optional local daemon (`taskflowd`) + +> Status: **Deferred after the 0.2.3 background lifecycle** +> Updated: **2026-07-18** +> Related: [`rfc-background-run.md`](./rfc-background-run.md) + +## Decision + +Taskflow remains process-less by default. The project store is authoritative; +hosts start ordinary stdio MCP servers and long runs execute in isolated, +one-shot detached processes. + +0.2.3 closes the main usability gap that originally motivated a daemon: + +- `taskflow_run` with `mode: "background"` returns a durable `runId` + immediately; +- `taskflow_runs` lists and filters the project roster, reports active + concurrency, waits in bounded/repeatable calls, and requests cancellation; +- final output, traces, process metadata, and cancellation intent live on disk, + so they survive MCP request and server boundaries; +- orphaned detached processes are reconciled into a terminal run state. + +This gives users a controllable long-run lifecycle without introducing a +resident service, socket, installer, upgrade protocol, or second source of +truth. + +## Current boundary + +| Capability | 0.2.3 mechanism | +|---|---| +| Long DAG outlives one tool call | Detached runner process | +| Cross-session status | Project-backed run store | +| Wait without losing the run | Bounded `taskflow_runs wait` | +| Cross-request cancellation | Durable control marker | +| Multi-host discovery | Shared project store | +| Resource-contention awareness | Active count plus warning above five runs | +| Global admission/budget queue | **Not implemented** | +| Push/live event subscription | **Not implemented** | + +The active-run warning is deliberately advisory. A hidden scheduler would +change execution semantics and introduce policy questions that cannot be +answered safely by a patch release. + +## When a daemon becomes justified + +Reopen this RFC only when measured usage repeatedly shows at least one of: + +1. MCP cold-start/process churn materially dominates short runs; +2. users need one cross-host concurrency or budget admission policy rather than + per-run ceilings and explicit warnings; +3. a live UI needs event subscription instead of bounded polling; +4. multiple host sessions must atomically claim queued work. + +The existence of detached runs alone is no longer sufficient justification. + +## Required design if reopened + +```text +Pi / Codex / Claude / OpenCode / Grok + │ + thin host adapter + │ optional local transport + ▼ + taskflowd + │ + taskflow-core + store +``` + +The following rules are non-negotiable: + +- **Default off.** The stdio/in-process path always remains usable. +- **Disk is authority.** Daemon memory may cache or schedule, never become the + only copy of run state. +- **Per-project namespace.** Worktrees do not silently share a global queue. +- **Version handshake.** Client and daemon reject incompatible protocol/schema + versions before dispatch. +- **Authenticated local transport.** Prefer a Unix-domain socket; any loopback + TCP fallback requires an explicit token and threat model. +- **Graceful degradation.** A daemon outage must not corrupt or hide persisted + runs. Whether new work falls back or fails closed must be an explicit policy. +- **One admission authority.** If the daemon claims global concurrency or + budgets, every participating host must dispatch through it; mixed hidden + bypasses would make the claim false. + +## Non-goals + +- cloud or multi-machine orchestration; +- making a daemon mandatory for any supported host; +- replacing detached one-shot execution; +- storing authoritative state only in memory; +- opening a network listener by default. + +## Minimal future protocol + +- `health` / `version` — compatibility and schema handshake; +- `run` / `resume` / `cancel` — durable lifecycle commands; +- `list` / `status` / `wait` / `subscribe` — observation surfaces; +- `admission` — explicit concurrency/budget policy and queue position. + +Until the trigger evidence exists, the 0.2.3 process-less lifecycle is the +smaller and more reliable product. diff --git a/docs/internal/rfc-workspace-capabilities.md b/docs/internal/rfc-workspace-capabilities.md new file mode 100644 index 00000000..aa39aa11 --- /dev/null +++ b/docs/internal/rfc-workspace-capabilities.md @@ -0,0 +1,141 @@ +# RFC: Workspace Capabilities + +> Status: **Design boundary active; control-plane scaffold partially shipped** +> Updated: **2026-07-18** +> Motivation: reusable dynamic working directories without false isolation, +> cache, or resume claims. + +## Executive decision + +Taskflow must not model dynamic workspaces as arbitrary string interpolation. +A path selected by an argument is useful, but resolving that path and isolating +a subprocess to it are different guarantees. + +The durable model has four planes: + +```text +Authority Host policy → principal → authorized root grant +Resource requirement → bound workspace → scoped path/capability +Execution resolved resources → leases → sandbox/executor/file boundary +Durability write intent → observation/checkpoint → run/cache/trace state +``` + +## Guarantee levels + +| Mode | What Taskflow can claim | +|---|---| +| `resolve-only` | The selected canonical cwd stays within an authorized invocation root. This does **not** constrain every file the host subprocess may read or write. | +| `sandboxed` | Resolver containment plus an enforced subprocess/filesystem policy for the exact scoped grants. | + +These labels are not interchangeable. A permission prompt, tool allowlist, or +successful `realpath` check is not evidence of an operating-system filesystem +boundary. + +## Current 0.2.3 truth + +The current release line ships a deliberately narrow compatibility bridge: + +- an author-written phase may use the exact placeholder + `cwd: "{args.package}"` when the argument is a typed `relative-path`; +- the bridge is default-off and requires + `TASKFLOW_CWD_BRIDGE_MODE=resolve-only`; +- absolute paths, concatenated placeholders, step-derived paths, and generated + sub-flow cwd/context authority are rejected; +- canonical containment is checked against the invocation root, including + symlink resolution; +- potential writers in one invocation are serialized before durable lease + acquisition; +- write intent, mutation permits, generations, and explicit reconciliation + prevent a failed writer from being silently treated as clean; +- retries and output-only reuse are restricted where filesystem side effects + cannot be restored safely; +- the checked-in native sandbox allowlist is empty, so no host is advertised as + providing the canonical `sandboxed` workspace model. + +The existing `packages/taskflow-core/src/resources/` modules are a host-neutral +control-plane scaffold. They are not a public claim that the full execution +backend, race-free file broker, or cross-host sandbox matrix is complete. + +## Non-negotiable invariants + +1. Flow JSON cannot grant itself a physical root or forge trusted provenance. +2. Generated/model-authored flows receive only explicitly attenuated authority. +3. Resolution, subprocess isolation, and Taskflow-owned file I/O use distinct, + truthful capability claims. +4. Writable aliases and overlapping paths share one canonical lease/version + domain. +5. Mutation intent is persisted before a side effect can begin. +6. Crash or uncertain cancellation leaves the workspace `dirty-unknown`; it is + never silently upgraded to clean. +7. Reconciliation accepts the observed current state only after explicit human + acknowledgement; it does not restore files. +8. A cache hit may skip model execution, but cannot skip authorization, version + checks, leases, or state restoration required by the declared effects. +9. Nested flows can attenuate authority but never expand it. +10. Unsupported sandbox policies fail closed instead of degrading under a + stronger label. + +## Why `cwd.fromArg` is not the public model + +A field-specific shortcut cannot answer: + +- who authorized the selected root; +- whether the value is a path or arbitrary text; +- what subtree and access mode reach the subprocess; +- whether scripts and Taskflow-owned file reads obey the same boundary; +- how nested flows attenuate authority; +- how aliases and overlapping writes are coordinated; +- how crash, cache, resume, and relocation affect correctness. + +Typed relative cwd remains a compatibility bridge. The canonical future model +uses host-authorized named roots, flow-declared logical requirements, scoped +path references, and runtime-minted handles. + +## Delivery path + +| Milestone | Exit condition | +|---|---| +| Current bridge | Resolve-only label, typed relative argument, containment, leases/journal/permits, explicit reconcile | +| Host feasibility | Versioned probes for exact host binary, OS/build, architecture, subprocess descendants, path-swap resistance, secrets, and cleanup | +| Single-root sandbox | One host passes a real Agent + Script + Taskflow file-I/O boundary with fail-closed policy negotiation | +| Multi-root capabilities | Named authorized roots, attenuation, atomic lease acquisition, and complete conformance matrix | +| Restorable state | Cache/resume can materialize and verify filesystem post-state rather than reusing output alone | +| Canonical 0.3 model | Public scoped capabilities/PathRefs replace raw physical-path authority | + +No milestone is called secure based only on lexical or `realpath` containment. + +## Code map + +```text +packages/taskflow-core/src/ +├── cwd-bridge.ts typed relative cwd compatibility boundary +└── resources/ + ├── authority.ts invocation authority + ├── registry.ts authorized roots/domains + ├── resolve.ts scoped resolution + ├── leases.ts persistent coordination + ├── journal.ts write-intent durability + ├── permits.ts attempt-bound mutation permission + ├── persistence.ts generations and recovery state + ├── sandbox.ts policy negotiation/contracts + ├── backend.ts future execution boundary + └── baseline.ts exact host evidence loader +``` + +## Rejected alternatives + +| Alternative | Reason | +|---|---| +| Arbitrary cwd interpolation | Path and authority injection; non-portable identity | +| Realpath-only security claim | Does not constrain subprocess ambient access or close time-of-check/time-of-use races | +| Agent-runner-only sandbox | Script and Taskflow-owned file operations bypass it | +| Flow-supplied physical roots | Model-callable data would grant itself authority | +| Workspace-name-only locks | Aliases, overlaps, and cross-process writers bypass them | +| Prompt/tool-policy read-only | Not a filesystem enforcement boundary | + +## Decision for 0.2.3 + +Keep the resolve-only bridge narrow, explicit, and accurately labeled. Preserve +the control-plane work, but do not expose a stronger public workspace API until +at least one exact host target passes the complete execution and filesystem +conformance boundary. diff --git a/docs/opencode-mcp.md b/docs/opencode-mcp.md index a31afeb1..9a00a8d8 100644 --- a/docs/opencode-mcp.md +++ b/docs/opencode-mcp.md @@ -111,7 +111,9 @@ Foreground `taskflow_run` waits for the whole DAG. For a long flow, pass `mode: "background"`: it returns a durable `runId` immediately and continues independently of that MCP request. Use `taskflow_runs` with `action: "status"`, `"wait"`, or `"cancel"`; bounded waits can be repeated until the persisted -final output is ready. +final output is ready. `action: "list"` reports total active concurrency and +accepts `status: "running" | "terminal"`; starting a sixth active background +run warns that no global cross-host concurrency/budget coordinator exists. ## Tools exposed diff --git a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md index bce82a40..f03c476d 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md @@ -672,6 +672,9 @@ For a flow that may outlive one MCP tool call, set `mode: "background"` on `taskflow_run`. It returns immediately; use `taskflow_runs` with `action: "status"`, `"wait"`, or `"cancel"` and the returned `runId`. A bounded `wait` can be called repeatedly, and completion returns the persisted final output. +Use `action: "list"` with optional `status: "running" | "terminal"` to see +active concurrency. Starting a sixth active run warns that Taskflow has no +hidden global cross-host concurrency or budget coordinator. Use `taskflow_trace` to inspect the append-only event log for a finished run, then `taskflow_replay` to re-judge it under alternate thresholds/budget **offline diff --git a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md index 3926829e..a882d2f4 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md @@ -667,6 +667,9 @@ For a flow that may outlive one MCP tool call, set `mode: "background"` on `taskflow_run`. It returns immediately; use `taskflow_runs` with `action: "status"`, `"wait"`, or `"cancel"` and the returned `runId`. A bounded `wait` can be called repeatedly, and completion returns the persisted final output. +Use `action: "list"` with optional `status: "running" | "terminal"` to see +active concurrency. Starting a sixth active run warns that Taskflow has no +hidden global cross-host concurrency or budget coordinator. Use `taskflow_trace` to inspect the append-only event log for a finished run, then `taskflow_replay` to re-judge it under alternate thresholds/budget **offline diff --git a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md index 575f2d96..a9cbf772 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md @@ -677,6 +677,9 @@ For a flow that may outlive one MCP tool call, set `mode: "background"` on `taskflow_run`. It returns immediately; use `taskflow_runs` with `action: "status"`, `"wait"`, or `"cancel"` and the returned `runId`. A bounded `wait` can be called repeatedly, and completion returns the persisted final output. +Use `action: "list"` with optional `status: "running" | "terminal"` to see +active concurrency. Starting a sixth active run warns that Taskflow has no +hidden global cross-host concurrency or budget coordinator. Use `taskflow_trace` to inspect the append-only event log for a finished run, then `taskflow_replay` to re-judge it under alternate thresholds/budget **offline diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md index 1c1d1e0a..bd84ff1b 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md @@ -668,6 +668,9 @@ For a flow that may outlive one MCP tool call, set `mode: "background"` on `taskflow_run`. It returns immediately; use `taskflow_runs` with `action: "status"`, `"wait"`, or `"cancel"` and the returned `runId`. A bounded `wait` can be called repeatedly, and completion returns the persisted final output. +Use `action: "list"` with optional `status: "running" | "terminal"` to see +active concurrency. Starting a sixth active run warns that Taskflow has no +hidden global cross-host concurrency or budget coordinator. Use `taskflow_trace` to inspect the append-only event log for a finished run, then `taskflow_replay` to re-judge it under alternate thresholds/budget **offline diff --git a/packages/taskflow-mcp-core/src/mcp/background.ts b/packages/taskflow-mcp-core/src/mcp/background.ts index ffd8af6e..57f2798e 100644 --- a/packages/taskflow-mcp-core/src/mcp/background.ts +++ b/packages/taskflow-mcp-core/src/mcp/background.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url"; import { clearDetachedCancelRequest, isProcessAlive, + listRuns, loadRun, readDetachedCancelRequest, requestDetachedCancel, @@ -31,6 +32,17 @@ export interface BackgroundLaunchOptions { const STARTING_GRACE_MS = 5_000; const WAIT_POLL_MS = 100; +const BACKGROUND_SCAN_LIMIT = 1_000; + +export const BACKGROUND_RUN_WARNING_THRESHOLD = 5; + +export type BackgroundRunFilter = "all" | "running" | "terminal"; + +export interface BackgroundRunList { + runs: RunState[]; + activeCount: number; + totalCount: number; +} function markDetachedFailure(state: RunState, message: string): RunState { state.status = "failed"; @@ -172,6 +184,30 @@ export async function waitForMcpBackgroundRun( return state; } +/** + * Read the project-backed background roster, refreshing orphaned processes + * before filtering. The active count is always computed from the full roster, + * so a filtered/limited list still reports total contention. + */ +export function listMcpBackgroundRuns( + cwd: string, + limit: number, + filter: BackgroundRunFilter = "all", +): BackgroundRunList { + const all = listRuns(cwd, BACKGROUND_SCAN_LIMIT) + .filter((run) => run.detached) + .map((run) => refreshDetachedRun(cwd, run.runId) ?? run); + const activeCount = all.filter((run) => run.status === "running").length; + const filtered = filter === "all" + ? all + : all.filter((run) => filter === "running" ? run.status === "running" : run.status !== "running"); + return { + runs: filtered.slice(0, Math.max(0, limit)), + activeCount, + totalCount: all.length, + }; +} + function phaseProgress(state: RunState): { done: number; total: number } { const total = state.def.phases.length; const done = Object.values(state.phases).filter((phase) => phase.status !== "running").length; diff --git a/packages/taskflow-mcp-core/src/mcp/server.ts b/packages/taskflow-mcp-core/src/mcp/server.ts index 8cdf7f6b..a3da375c 100644 --- a/packages/taskflow-mcp-core/src/mcp/server.ts +++ b/packages/taskflow-mcp-core/src/mcp/server.ts @@ -31,11 +31,14 @@ import { RpcError, RPC, serveStdio, type RpcContext, type RpcHandler } from "./jsonrpc.ts"; import { renderFlowSvg, renderFlowOutline, svgToBase64 } from "./svg.ts"; import { + BACKGROUND_RUN_WARNING_THRESHOLD, cancelMcpBackgroundRun, formatBackgroundRun, launchMcpBackgroundRun, + listMcpBackgroundRuns, refreshDetachedRun, waitForMcpBackgroundRun, + type BackgroundRunFilter, type DetachedRunnerBinding, } from "./background.ts"; import { readFileSync, realpathSync } from "node:fs"; @@ -47,7 +50,6 @@ import { executeTaskflow, getFlowDiagnosed, listFlows, - listRuns, newRunId, peekRun, saveRun, @@ -442,6 +444,7 @@ const TOOLS: McpTool[] = [ runId: { type: "string", description: "Required for status, wait, and cancel." }, timeoutMs: { type: "integer", description: "For wait: return after this many ms if still running (default 30000, max 300000)." }, limit: { type: "integer", description: "For list: maximum recent background runs (default 10, max 50)." }, + status: { type: "string", enum: ["all", "running", "terminal"], description: "For list: show all runs (default), only active runs, or only terminal runs." }, reason: { type: "string", description: "For cancel: optional audit reason." }, }, required: ["action"], @@ -835,8 +838,12 @@ export function makeToolHandlers( incremental: args.incremental === true, reusedSavedName: args.reusedFromSearch === true ? reusedSavedName : undefined, }); + const { activeCount } = listMcpBackgroundRuns(cwd, 0); + const contentionWarning = activeCount > BACKGROUND_RUN_WARNING_THRESHOLD + ? `\n\nWarning: ${activeCount} background runs are active in this project. Taskflow does not provide a global cross-host concurrency or budget coordinator; use taskflow_runs list/cancel if this is unintentional.` + : ""; return textContent( - `↻ taskflow started in background\n\n${state.flowName} · pid ${pid} · run ${state.runId}\nUse taskflow_runs with action status, wait, or cancel.`, + `↻ taskflow started in background\n\n${state.flowName} · pid ${pid} · run ${state.runId}\nUse taskflow_runs with action status, wait, or cancel.${contentionWarning}`, ); } catch (error) { return textContent( @@ -900,12 +907,18 @@ export function makeToolHandlers( const action = String(args.action ?? ""); if (action === "list") { const limit = Math.max(1, Math.min(50, typeof args.limit === "number" ? Math.floor(args.limit) : 10)); - const runs = listRuns(cwd, 1_000) - .filter((run) => run.detached) - .slice(0, limit) - .map((run) => refreshDetachedRun(cwd, run.runId) ?? run); - if (runs.length === 0) return textContent("No background taskflow runs found from this directory."); - return textContent(`Background taskflow runs:\n${runs.map((run) => formatBackgroundRun(run, false)).join("\n")}`); + const filter: BackgroundRunFilter = args.status === "running" || args.status === "terminal" + ? args.status + : "all"; + const roster = listMcpBackgroundRuns(cwd, limit, filter); + if (roster.runs.length === 0) { + const scope = filter === "all" ? "" : `${filter} `; + return textContent(`No ${scope}background taskflow runs found from this directory. ${roster.activeCount} active total.`); + } + const scope = filter === "all" ? "" : ` · ${filter}`; + return textContent( + `Background taskflow runs — ${roster.activeCount} active · ${roster.totalCount} total${scope}:\n${roster.runs.map((run) => formatBackgroundRun(run, false)).join("\n")}`, + ); } const runId = typeof args.runId === "string" ? args.runId.trim() : ""; diff --git a/packages/taskflow-mcp-core/test/background-runs.test.ts b/packages/taskflow-mcp-core/test/background-runs.test.ts index 7338d571..5d3f622f 100644 --- a/packages/taskflow-mcp-core/test/background-runs.test.ts +++ b/packages/taskflow-mcp-core/test/background-runs.test.ts @@ -4,7 +4,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { pathToFileURL } from "node:url"; -import { loadRun, type SubagentRunner } from "taskflow-core"; +import { loadRun, newRunId, saveRun, type RunState, type SubagentRunner, type Taskflow } from "taskflow-core"; import { makeToolHandlers } from "taskflow-mcp-core/server"; interface TextResult { @@ -28,13 +28,31 @@ function runIdFrom(result: TextResult): string { return match[1]!; } -function inlineAgentFlow(name: string) { +function inlineAgentFlow(name: string): Taskflow { return { name, phases: [{ id: "work", type: "agent", agent: "executor", task: "work", final: true }], }; } +function runningBackgroundState(cwd: string, name: string): RunState { + const now = Date.now(); + return { + runId: newRunId(name), + flowName: name, + def: inlineAgentFlow(name), + args: {}, + status: "running", + phases: {}, + createdAt: now, + updatedAt: now, + cwd, + detached: true, + detachedStartedAt: now, + pid: process.pid, + }; +} + test("mcp background: run returns immediately and wait returns durable final output", async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-background-")); try { @@ -86,3 +104,30 @@ test("mcp background: cancel survives request boundaries and pauses the run", as fs.rmSync(cwd, { recursive: true, force: true }); } }); + +test("mcp background: roster filters active runs and warns about uncoordinated contention", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-roster-")); + try { + for (let index = 0; index < 5; index++) { + saveRun(runningBackgroundState(cwd, `already-running-${index}`)); + } + + const tools = makeToolHandlers(cwd, unusedForegroundRunner, { + host: "test", + detachedRunner: { module: fixtureModule(), exportName: "cancellableRunner" }, + }); + const started = await tools.taskflow_run({ define: inlineAgentFlow("contention-warning"), mode: "background" }) as TextResult; + assert.equal(started.isError, false); + assert.match(started.content[0]!.text, /Warning: 6 background runs are active/); + const runId = runIdFrom(started); + + const active = await tools.taskflow_runs({ action: "list", status: "running", limit: 3 }) as TextResult; + assert.match(active.content[0]!.text, /6 active · 6 total · running/); + assert.doesNotMatch(active.content[0]!.text, /completed/); + + await tools.taskflow_runs({ action: "cancel", runId, reason: "test cleanup" }); + await tools.taskflow_runs({ action: "wait", runId, timeoutMs: 5_000 }); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/skills-src/taskflow/core.md b/skills-src/taskflow/core.md index 7da86292..3ed55acf 100644 --- a/skills-src/taskflow/core.md +++ b/skills-src/taskflow/core.md @@ -749,6 +749,9 @@ For a flow that may outlive one MCP tool call, set `mode: "background"` on `taskflow_run`. It returns immediately; use `taskflow_runs` with `action: "status"`, `"wait"`, or `"cancel"` and the returned `runId`. A bounded `wait` can be called repeatedly, and completion returns the persisted final output. +Use `action: "list"` with optional `status: "running" | "terminal"` to see +active concurrency. Starting a sixth active run warns that Taskflow has no +hidden global cross-host concurrency or budget coordinator. Use `taskflow_trace` to inspect the append-only event log for a finished run, then `taskflow_replay` to re-judge it under alternate thresholds/budget **offline From f1edc22016ab39b39618a88a3f0196d8f70f34ce Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 18 Jul 2026 15:30:50 +0800 Subject: [PATCH 3/8] fix(runtime): close 0.2.3 release blockers Persist terminal outcomes atomically, harden detached-run lifecycle and cancellation, align foreground/background execution semantics, and bound retained run history.\n\nRefresh package and plugin metadata, public documentation, and regression coverage for the 0.2.3 release candidate. --- CHANGELOG.md | 10 +- README.md | 4 +- README.zh-CN.md | 4 +- RELEASE.md | 6 +- docs/claude-mcp.md | 2 +- docs/codex-mcp.md | 4 +- docs/grok-mcp.md | 4 +- docs/i18n/README.ar.md | 4 +- docs/i18n/README.bn.md | 4 +- docs/i18n/README.es.md | 4 +- docs/i18n/README.hi.md | 4 +- docs/i18n/README.pt.md | 4 +- docs/i18n/README.ru.md | 4 +- package.json | 2 +- packages/claude-taskflow/package.json | 2 +- .../plugin/.claude-plugin/plugin.json | 2 +- packages/claude-taskflow/plugin/.mcp.json | 2 +- .../claude-taskflow/test/mcp-server.test.ts | 2 +- packages/codex-taskflow/package.json | 2 +- .../plugin/.codex-plugin/plugin.json | 2 +- packages/codex-taskflow/plugin/.mcp.json | 2 +- .../test/e2e-mcp-comprehensive.mts | 4 +- .../codex-taskflow/test/mcp-server.test.ts | 2 +- packages/grok-taskflow/package.json | 2 +- .../plugin/.grok-plugin/plugin.json | 2 +- packages/grok-taskflow/plugin/.mcp.json | 2 +- .../grok-taskflow/test/mcp-server.test.ts | 2 +- packages/opencode-taskflow/package.json | 2 +- .../opencode-taskflow/plugin/opencode.json | 2 +- .../opencode-taskflow/test/mcp-server.test.ts | 2 +- packages/pi-taskflow/package.json | 2 +- packages/pi-taskflow/src/index.ts | 60 ++++- .../pi-taskflow/test/runner-injection.test.ts | 11 +- packages/taskflow-core/package.json | 2 +- packages/taskflow-core/src/agents.ts | 4 +- .../taskflow-core/src/detached-control.ts | 176 ++++++++++++++- packages/taskflow-core/src/detached-runner.ts | 65 +++++- packages/taskflow-core/src/exec/driver.ts | 2 + packages/taskflow-core/src/runner-core.ts | 13 ++ packages/taskflow-core/src/runtime.ts | 60 +++-- packages/taskflow-core/src/store.ts | 95 ++++++-- .../test/detached-control.test.ts | 115 ++++++++++ .../taskflow-core/test/run-retention.test.ts | 53 +++++ .../test/runtime-terminal-persist.test.ts | 90 ++++++++ packages/taskflow-dsl/package.json | 2 +- packages/taskflow-hosts/package.json | 2 +- packages/taskflow-mcp-core/package.json | 2 +- .../taskflow-mcp-core/src/mcp/background.ts | 175 +++++++++++---- packages/taskflow-mcp-core/src/mcp/server.ts | 51 ++++- .../test/background-runs.test.ts | 206 +++++++++++++++++- .../test/fixtures/background-runner.mjs | 36 +++ website/app/[lang]/page.tsx | 2 +- .../en/blog/orchestrate-codex-subagents.mdx | 6 +- .../en/compiler-runtime/background-runs.mdx | 37 ++-- .../en/compiler-runtime/typescript-dsl.mdx | 2 +- .../docs/en/guides/agents-and-model-roles.mdx | 4 +- .../content/docs/en/guides/claude-code.mdx | 14 +- website/content/docs/en/guides/codex.mdx | 10 +- website/content/docs/en/guides/grok-build.mdx | 10 +- website/content/docs/en/guides/opencode.mdx | 14 +- .../content/docs/en/reference/commands.mdx | 3 + .../blog/orchestrate-codex-subagents.mdx | 6 +- .../compiler-runtime/background-runs.mdx | 26 +-- .../zh-cn/compiler-runtime/typescript-dsl.mdx | 2 +- .../zh-cn/guides/agents-and-model-roles.mdx | 4 +- .../content/docs/zh-cn/guides/claude-code.mdx | 12 +- website/content/docs/zh-cn/guides/codex.mdx | 10 +- .../content/docs/zh-cn/guides/grok-build.mdx | 8 +- .../content/docs/zh-cn/guides/opencode.mdx | 12 +- .../content/docs/zh-cn/reference/commands.mdx | 3 + 70 files changed, 1237 insertions(+), 264 deletions(-) create mode 100644 packages/taskflow-core/test/detached-control.test.ts create mode 100644 packages/taskflow-core/test/run-retention.test.ts create mode 100644 packages/taskflow-core/test/runtime-terminal-persist.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 975b0981..6e5f3e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,20 @@ All notable changes to taskflow are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. -## [0.2.3] — Unreleased +## [0.2.3] — 2026-07-18 ### Added - **Durable MCP background runs.** `taskflow_run` now accepts `mode: "background"` on Codex, Claude Code, OpenCode, and Grok Build, returning a durable `runId` immediately instead of tying a long DAG to one MCP request timeout. The new `taskflow_runs` tool lists background runs and supports `status`, bounded/repeatable `wait`, and explicit `cancel`; lists report the total active count and can filter `running` versus `terminal` runs. Detached runs persist their final output and trace, preserve incremental-cache and library-reuse behavior, detect orphaned processes, and use a file-backed cancellation control plane that survives MCP request and server boundaries. Starting a sixth concurrent background run emits an explicit resource-contention warning because Taskflow intentionally has no hidden global cross-host scheduler. +### Fixed + +- **Atomic terminal results.** The runtime now persists `finalOutput` and `outputSourcePhaseId` in the same terminal-state write, so a crash or immediate poll can never observe `completed` without its result. +- **Detached-run ownership and cleanup.** Cancellation and process-heartbeat records now live in a user-private control directory keyed by canonical invocation root, preventing project symlinks and sibling worktrees from redirecting or cross-cancelling runs. Current workers carry a versioned instance identity; stale or killed workers are terminalized and their registered Host CLI process groups are reaped, while ambiguous legacy runs fail closed for cancellation. +- **Foreground/background parity.** MCP and Pi detached launches snapshot the same agent scope, model roles, global thinking, runner profile, incremental settings, and retention policy as the foreground invocation. Launch failures preserve their real cause, and post-spawn roster diagnostics can no longer misreport a successfully started run as a launch failure. +- **Bounded run history and accurate rosters.** Retention now applies to every inactive state (`completed`, `failed`, `paused`, and `blocked`) while never pruning active runs. MCP background lists compute counts from the complete project roster without the former 1000-run cap or per-row reload pattern. +- **Release surface synchronization.** Package/plugin versions, installation pins, built-dist MCP expectations, the 16-tool roster, background-run documentation, and English/Chinese host guides are synchronized for 0.2.3. + ## [0.2.2] — 2026-07-14 ### ⚠️ Breaking — migration required diff --git a/README.md b/README.md index 3d311232..1b583622 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,7 @@ claude plugin install claude-taskflow@taskflow ```bash opencode mcp add taskflow -- \ - npx -y -p opencode-taskflow@0.2.2 opencode-taskflow-mcp + npx -y -p opencode-taskflow@0.2.3 opencode-taskflow-mcp ``` [OpenCode guide →](https://heggria.github.io/taskflow/en/docs/guides/opencode) @@ -324,7 +324,7 @@ opencode mcp add taskflow -- \ ```bash grok mcp add taskflow -- \ - npx -y -p grok-taskflow@0.2.2 grok-taskflow-mcp + npx -y -p grok-taskflow@0.2.3 grok-taskflow-mcp ``` Grok Build support is new in 0.2. Its CLI stream does not report token/cost usage, so budget-declaring flows are rejected rather than silently running without enforcement. diff --git a/README.zh-CN.md b/README.zh-CN.md index 203a817f..b0181f6a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -310,7 +310,7 @@ claude plugin install claude-taskflow@taskflow ```bash opencode mcp add taskflow -- \ - npx -y -p opencode-taskflow@0.2.2 opencode-taskflow-mcp + npx -y -p opencode-taskflow@0.2.3 opencode-taskflow-mcp ``` [OpenCode 指南 →](https://heggria.github.io/taskflow/zh-cn/docs/guides/opencode) @@ -319,7 +319,7 @@ opencode mcp add taskflow -- \ ```bash grok mcp add taskflow -- \ - npx -y -p grok-taskflow@0.2.2 grok-taskflow-mcp + npx -y -p grok-taskflow@0.2.3 grok-taskflow-mcp ``` Grok Build 支持在 0.2 首次加入。其 CLI stream 不返回 token/cost 用量,因此声明了预算的 flow 会被拒绝,而不是在无法执行预算约束时静默运行。 diff --git a/RELEASE.md b/RELEASE.md index e570067c..e4597527 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -81,8 +81,8 @@ the matching annotated tag: ```sh git switch main git pull --ff-only origin main -git tag -a v0.2.2 -m "Release v0.2.2" -git push origin v0.2.2 +git tag -a v0.2.3 -m "Release v0.2.3" +git push origin v0.2.3 ``` `.github/workflows/publish.yml` then performs the complete release transaction: @@ -140,6 +140,6 @@ claude plugin install claude-taskflow@taskflow opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp # Grok Build (published MCP package) -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.2 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.3 grok-taskflow-mcp # or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` diff --git a/docs/claude-mcp.md b/docs/claude-mcp.md index 79a543a9..ab732a73 100644 --- a/docs/claude-mcp.md +++ b/docs/claude-mcp.md @@ -35,7 +35,7 @@ Verify: ```sh claude plugin list # → claude-taskflow@taskflow installed, enabled -claude mcp list # → taskflow … (npx -y -p claude-taskflow@0.2.2 claude-taskflow-mcp) +claude mcp list # → taskflow … (npx -y -p claude-taskflow@0.2.3 claude-taskflow-mcp) ``` The bundled skill tells Claude Code *when* to reach for the tools (multi-phase diff --git a/docs/codex-mcp.md b/docs/codex-mcp.md index b625e568..38c4b245 100644 --- a/docs/codex-mcp.md +++ b/docs/codex-mcp.md @@ -31,7 +31,7 @@ globally, and the plugin version binds the exact code that runs. Verify: ```sh codex plugin list # → taskflow@taskflow installed, enabled -codex mcp list # → taskflow … enabled (npx -y -p codex-taskflow@0.2.2 codex-taskflow-mcp) +codex mcp list # → taskflow … enabled (npx -y -p codex-taskflow@0.2.3 codex-taskflow-mcp) ``` The bundled skill tells Codex *when* to reach for the tools (multi-phase or @@ -70,7 +70,7 @@ To stop large flows from being cut off, the plugin's `.mcp.json` ships a "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "codex-taskflow@0.2.2", "codex-taskflow-mcp"], + "args": ["-y", "-p", "codex-taskflow@0.2.3", "codex-taskflow-mcp"], "tool_timeout_sec": 1800 } } diff --git a/docs/grok-mcp.md b/docs/grok-mcp.md index f3142328..9b29832e 100644 --- a/docs/grok-mcp.md +++ b/docs/grok-mcp.md @@ -27,7 +27,7 @@ Official Grok docs used for this integration: ## Install (recommended): register the published MCP server ```sh -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.2 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.3 grok-taskflow-mcp ``` A public Grok plugin marketplace/source is not published yet. Do not substitute @@ -172,7 +172,7 @@ grok mcp add taskflow -- grok-taskflow-mcp Or with npx (no global install): ```sh -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.2 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.3 grok-taskflow-mcp ``` Verify: diff --git a/docs/i18n/README.ar.md b/docs/i18n/README.ar.md index eb85bdc5..c0dee2ea 100644 --- a/docs/i18n/README.ar.md +++ b/docs/i18n/README.ar.md @@ -2,7 +2,7 @@ > ⚠️ **هذه الترجمة قديمة.** يُرجى مراجعة [README بالإنجليزية](../../README.md) للحصول على أحدث المعلومات. -taskflow رسم بياني تصريحي وقابل للتحقق للمهام على Pi وCodex وClaude Code وOpenCode وGrok Build. خط 0.2.2 يتطلب Node.js ≥ 22.19.0 ويضم 12 نوعًا من المراحل وأكثر من 1500 اختبار. طبقة MCP لا تعتمد على MCP SDK؛ يستخدم core ‏`typebox` كـ peer ويعتمد DSL على TypeScript. +taskflow رسم بياني تصريحي وقابل للتحقق للمهام على Pi وCodex وClaude Code وOpenCode وGrok Build. خط 0.2.3 يتطلب Node.js ≥ 22.19.0 ويضم 12 نوعًا من المراحل وأكثر من 1500 اختبار. طبقة MCP لا تعتمد على MCP SDK؛ يستخدم core ‏`typebox` كـ peer ويعتمد DSL على TypeScript. > **لماذا "taskflow" وليس "workflow"؟** الـ *workflow* (بنمط code-mode) هو برنامج أمري *يتدفق*، ورسمه البياني مخبأ داخل تدفق التحكم. أما الـ *taskflow* فينقل الخطة إلى رسم بياني تصريحي من عقد مهام منفصلة — يمكن التحقق منه ساكنًا وعرضه واستئنافه وحفظه كأمر. نحن نستبدل القدرة التعبيرية بالقابلية للتحقق، عن قصد. @@ -22,7 +22,7 @@ claude plugin install claude-taskflow@taskflow opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp # Grok Build (from monorepo checkout pre-publish, or published source) -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.2 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.3 grok-taskflow-mcp # or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` diff --git a/docs/i18n/README.bn.md b/docs/i18n/README.bn.md index f51ddc15..a31688ce 100644 --- a/docs/i18n/README.bn.md +++ b/docs/i18n/README.bn.md @@ -2,7 +2,7 @@ > ⚠️ **এই অনুবাদটি পুরোনো।** অনুগ্রহ করে সর্বশেষ তথ্যের জন্য [ইংরেজি README](../../README.md) দেখুন। -taskflow Pi, Codex, Claude Code, OpenCode ও Grok Build-এর জন্য একটি ডিক্লারেটিভ, যাচাইযোগ্য টাস্ক গ্রাফ। 0.2.2 লাইনে Node.js ≥ 22.19.0, 12টি phase kind এবং 1500+ পরীক্ষা রয়েছে। MCP layer-এর MCP SDK dependency নেই; core-এর peer `typebox`, আর DSL TypeScript-এর উপর নির্ভরশীল। +taskflow Pi, Codex, Claude Code, OpenCode ও Grok Build-এর জন্য একটি ডিক্লারেটিভ, যাচাইযোগ্য টাস্ক গ্রাফ। 0.2.3 লাইনে Node.js ≥ 22.19.0, 12টি phase kind এবং 1500+ পরীক্ষা রয়েছে। MCP layer-এর MCP SDK dependency নেই; core-এর peer `typebox`, আর DSL TypeScript-এর উপর নির্ভরশীল। > **কেন "taskflow", "workflow" নয় কেন?** একটি *workflow* (code-mode) হল একটি ইম্পারেটিভ স্ক্রিপ্ট যা *প্রবাহিত হয়*, যার গ্রাফ কন্ট্রোল ফ্লোর মধ্যে লুকানো। একটি *taskflow* পরিকল্পনাকে পৃথক টাস্ক নোডের একটি ডিক্লারেটিভ গ্রাফে সরিয়ে নেয় — যা স্থিরভাবে যাচাই, দৃশ্যমান, পুনরারম্ভ এবং একটি কমান্ড হিসেবে সংরক্ষণ করা যায়। আমরা ইচ্ছাকৃতভাবে এক্সপ্রেসিভনেসকে যাচাইযোগ্যতার বিনিময়ে দিই। @@ -22,7 +22,7 @@ claude plugin install claude-taskflow@taskflow opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp # Grok Build (from monorepo checkout pre-publish, or published source) -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.2 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.3 grok-taskflow-mcp # or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` diff --git a/docs/i18n/README.es.md b/docs/i18n/README.es.md index bc8f92f7..4e0fe897 100644 --- a/docs/i18n/README.es.md +++ b/docs/i18n/README.es.md @@ -2,7 +2,7 @@ > ⚠️ **Esta traducción está desactualizada.** Consulta el [README en inglés](../../README.md) para obtener la información más reciente. -taskflow es un *grafo de tareas* declarativo y verificable para agentes de codificación — funciona en Pi, Codex, Claude Code, OpenCode y Grok Build. Línea 0.2.2: Node.js ≥ 22.19.0, 12 tipos de fase y más de 1500 pruebas. La capa MCP no depende de un SDK MCP; core usa `typebox` como peer y el DSL depende de TypeScript. +taskflow es un *grafo de tareas* declarativo y verificable para agentes de codificación — funciona en Pi, Codex, Claude Code, OpenCode y Grok Build. Línea 0.2.3: Node.js ≥ 22.19.0, 12 tipos de fase y más de 1500 pruebas. La capa MCP no depende de un SDK MCP; core usa `typebox` como peer y el DSL depende de TypeScript. > **¿Por qué "taskflow" y no "workflow"?** Un *workflow* (estilo code-mode) es un script imperativo que *fluye*, con el grafo oculto en el control de flujo. Un *taskflow* mueve el plan a un grafo declarativo de nodos de tarea discretos — que se puede verificar estáticamente, visualizar, reanudar y guardar como un comando. Cambiamos expresividad por verificabilidad, a propósito. @@ -22,7 +22,7 @@ claude plugin install claude-taskflow@taskflow opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp # Grok Build (from monorepo checkout pre-publish, or published source) -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.2 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.3 grok-taskflow-mcp # or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` diff --git a/docs/i18n/README.hi.md b/docs/i18n/README.hi.md index 9cfdc140..f18e74c0 100644 --- a/docs/i18n/README.hi.md +++ b/docs/i18n/README.hi.md @@ -2,7 +2,7 @@ > ⚠️ **यह अनुवाद पुराना है।** कृपया नवीनतम जानकारी के लिए [अंग्रेज़ी README](../../README.md) देखें। -taskflow Pi, Codex, Claude Code, OpenCode और Grok Build के लिए एक घोषणात्मक, सत्यापन-योग्य टास्क ग्राफ है। 0.2.2 लाइन में Node.js ≥ 22.19.0, 12 phase kinds और 1500+ परीक्षण हैं। MCP layer किसी MCP SDK पर निर्भर नहीं है; core का peer `typebox` है और DSL TypeScript पर निर्भर है। +taskflow Pi, Codex, Claude Code, OpenCode और Grok Build के लिए एक घोषणात्मक, सत्यापन-योग्य टास्क ग्राफ है। 0.2.3 लाइन में Node.js ≥ 22.19.0, 12 phase kinds और 1500+ परीक्षण हैं। MCP layer किसी MCP SDK पर निर्भर नहीं है; core का peer `typebox` है और DSL TypeScript पर निर्भर है। > **"taskflow" क्यों, "workflow" क्यों नहीं?** एक *workflow* (code-mode) एक आज्ञात्मक स्क्रिप्ट है जो *बहता* है, जिसका ग्राफ कंट्रोल फ़्लो में छिपा होता है। एक *taskflow* योजना को अलग-अलग टास्क नोड्स के एक घोषणात्मक ग्राफ में ले जाता है — जिसे स्थिर रूप से सत्यापित, दृश्य, पुनः शुरू और एक कमांड के रूप में सहेजा जा सकता है। हम जान-बूझकर अभिव्यक्ति को सत्यापन-योग्यता से बदलते हैं। @@ -22,7 +22,7 @@ claude plugin install claude-taskflow@taskflow opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp # Grok Build (from monorepo checkout pre-publish, or published source) -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.2 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.3 grok-taskflow-mcp # or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` diff --git a/docs/i18n/README.pt.md b/docs/i18n/README.pt.md index c2854fc4..aa7cd600 100644 --- a/docs/i18n/README.pt.md +++ b/docs/i18n/README.pt.md @@ -2,7 +2,7 @@ > ⚠️ **Esta tradução está desatualizada.** Consulte o [README em inglês](../../README.md) para obter as informações mais recentes. -taskflow é um *grafo de tarefas* declarativo e verificável para Pi, Codex, Claude Code, OpenCode e Grok Build. Linha 0.2.2: Node.js ≥ 22.19.0, 12 tipos de fase e mais de 1500 testes. A camada MCP não depende de um SDK MCP; core usa `typebox` como peer e o DSL depende de TypeScript. +taskflow é um *grafo de tarefas* declarativo e verificável para Pi, Codex, Claude Code, OpenCode e Grok Build. Linha 0.2.3: Node.js ≥ 22.19.0, 12 tipos de fase e mais de 1500 testes. A camada MCP não depende de um SDK MCP; core usa `typebox` como peer e o DSL depende de TypeScript. > **Por que "taskflow" e não "workflow"?** Um *workflow* (estilo code-mode) é um script imperativo que *flui*, com o grafo escondido no controle de fluxo. Um *taskflow* move o plano para um grafo declarativo de nós de tarefa discretos — que pode ser verificado estaticamente, visualizado, retomado e salvo como um comando. Trocamos expressividade por verificabilidade, de propósito. @@ -22,7 +22,7 @@ claude plugin install claude-taskflow@taskflow opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp # Grok Build (from monorepo checkout pre-publish, or published source) -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.2 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.3 grok-taskflow-mcp # or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` diff --git a/docs/i18n/README.ru.md b/docs/i18n/README.ru.md index 328075ae..102cf666 100644 --- a/docs/i18n/README.ru.md +++ b/docs/i18n/README.ru.md @@ -2,7 +2,7 @@ > ⚠️ **Этот перевод устарел.** Пожалуйста, обратитесь к [английскому README](../../README.md) за актуальной информацией. -taskflow — декларативный и проверяемый граф задач для Pi, Codex, Claude Code, OpenCode и Grok Build. Линия 0.2.2: Node.js ≥ 22.19.0, 12 типов фаз и более 1500 тестов. Слой MCP не зависит от MCP SDK; core использует peer `typebox`, а DSL зависит от TypeScript. +taskflow — декларативный и проверяемый граф задач для Pi, Codex, Claude Code, OpenCode и Grok Build. Линия 0.2.3: Node.js ≥ 22.19.0, 12 типов фаз и более 1500 тестов. Слой MCP не зависит от MCP SDK; core использует peer `typebox`, а DSL зависит от TypeScript. > **Почему "taskflow", а не "workflow"?** *Workflow* (в стиле code-mode) — это императивный скрипт, который *течёт*, а его граф спрятан в потоке управления. *Taskflow* переносит план в декларативный граф из дискретных узлов-задач — его можно статически проверить, визуализировать, возобновить и сохранить как команду. Мы осознанно меняем выразительность на проверяемость. @@ -22,7 +22,7 @@ claude plugin install claude-taskflow@taskflow opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp # Grok Build (from monorepo checkout pre-publish, or published source) -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.2 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.3 grok-taskflow-mcp # or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` diff --git a/package.json b/package.json index 73927817..98acd642 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-taskflow-monorepo", - "version": "0.2.2", + "version": "0.2.3", "private": true, "description": "Monorepo for taskflow-core, taskflow-mcp-core, taskflow-hosts, taskflow-dsl, pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, grok-taskflow, and the documentation website.", "type": "module", diff --git a/packages/claude-taskflow/package.json b/packages/claude-taskflow/package.json index 21b621f1..0d47a430 100644 --- a/packages/claude-taskflow/package.json +++ b/packages/claude-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "claude-taskflow", - "version": "0.2.2", + "version": "0.2.3", "description": "Run taskflow on Claude Code: a Claude subagent runner plus an MCP server (and a plug-and-play Claude Code plugin) that exposes the taskflow_* tools to Claude Code users.", "keywords": [ "claude", diff --git a/packages/claude-taskflow/plugin/.claude-plugin/plugin.json b/packages/claude-taskflow/plugin/.claude-plugin/plugin.json index 313fcd2c..da2c6f89 100644 --- a/packages/claude-taskflow/plugin/.claude-plugin/plugin.json +++ b/packages/claude-taskflow/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "taskflow", - "version": "0.2.2", + "version": "0.2.3", "description": "Declarative, verifiable DAG orchestration for Claude Code subagents — fan-out, gates, loops, tournaments, approvals, resumable runs, and saveable commands, with intermediate transcripts kept out of your context.", "author": { "name": "heggria", diff --git a/packages/claude-taskflow/plugin/.mcp.json b/packages/claude-taskflow/plugin/.mcp.json index cab81b22..54242dc0 100644 --- a/packages/claude-taskflow/plugin/.mcp.json +++ b/packages/claude-taskflow/plugin/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "claude-taskflow@0.2.2", "claude-taskflow-mcp"] + "args": ["-y", "-p", "claude-taskflow@0.2.3", "claude-taskflow-mcp"] } } } diff --git a/packages/claude-taskflow/test/mcp-server.test.ts b/packages/claude-taskflow/test/mcp-server.test.ts index becf2bfa..35c9e80c 100644 --- a/packages/claude-taskflow/test/mcp-server.test.ts +++ b/packages/claude-taskflow/test/mcp-server.test.ts @@ -45,7 +45,7 @@ test("claude mcp: initialize returns the protocol version + serverInfo", async ( assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-claude"); - assert.equal(res.result.serverInfo.version, "0.2.2"); + assert.equal(res.result.serverInfo.version, "0.2.3"); }); test("claude mcp: tools/list exposes the same taskflow tools as codex", async () => { diff --git a/packages/codex-taskflow/package.json b/packages/codex-taskflow/package.json index 8b9caca7..9a874304 100644 --- a/packages/codex-taskflow/package.json +++ b/packages/codex-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "codex-taskflow", - "version": "0.2.2", + "version": "0.2.3", "description": "Run taskflow on OpenAI Codex: a Codex subagent runner plus an MCP server (and a plug-and-play Codex plugin) that exposes the taskflow_* tools to Codex users.", "keywords": [ "codex", diff --git a/packages/codex-taskflow/plugin/.codex-plugin/plugin.json b/packages/codex-taskflow/plugin/.codex-plugin/plugin.json index 2db1e5e2..f5c67413 100644 --- a/packages/codex-taskflow/plugin/.codex-plugin/plugin.json +++ b/packages/codex-taskflow/plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "taskflow", - "version": "0.2.2", + "version": "0.2.3", "description": "Declarative, verifiable DAG orchestration for Codex subagents — fan-out, gates, loops, tournaments, approvals, resumable runs, and saveable commands, with intermediate transcripts kept out of your context.", "author": { "name": "heggria", diff --git a/packages/codex-taskflow/plugin/.mcp.json b/packages/codex-taskflow/plugin/.mcp.json index 42f0afeb..0ea070f6 100644 --- a/packages/codex-taskflow/plugin/.mcp.json +++ b/packages/codex-taskflow/plugin/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "codex-taskflow@0.2.2", "codex-taskflow-mcp"], + "args": ["-y", "-p", "codex-taskflow@0.2.3", "codex-taskflow-mcp"], "tool_timeout_sec": 1800 } } diff --git a/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts b/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts index 49635573..08e7b811 100644 --- a/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts +++ b/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts @@ -79,7 +79,7 @@ send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: " const init = await waitFor(1, "initialize"); assert.equal(init.result.protocolVersion, "2025-06-18"); assert.equal(init.result.serverInfo.name, "taskflow-codex"); -assert.equal(init.result.serverInfo.version, "0.2.2"); +assert.equal(init.result.serverInfo.version, "0.2.3"); ok(`initialize → ${JSON.stringify(init.result.serverInfo)}`); // notification must NOT produce a response @@ -88,7 +88,7 @@ send({ jsonrpc: "2.0", method: "notifications/initialized" }); send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); const list = await waitFor(2, "tools/list"); const toolNames = list.result.tools.map((t: any) => t.name).sort(); -assert.deepEqual(toolNames, ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"]); +assert.deepEqual(toolNames, ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"]); for (const t of list.result.tools) { assert.equal(t.inputSchema.type, "object", `${t.name} has object schema`); assert.equal(typeof t.description, "string"); diff --git a/packages/codex-taskflow/test/mcp-server.test.ts b/packages/codex-taskflow/test/mcp-server.test.ts index afb114c1..0c46717a 100644 --- a/packages/codex-taskflow/test/mcp-server.test.ts +++ b/packages/codex-taskflow/test/mcp-server.test.ts @@ -51,7 +51,7 @@ test("mcp: initialize returns the protocol version + serverInfo codex expects", assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-codex"); - assert.equal(res.result.serverInfo.version, "0.2.2"); + assert.equal(res.result.serverInfo.version, "0.2.3"); }); test("mcp: tools/list exposes the taskflow tools with schemas", async () => { diff --git a/packages/grok-taskflow/package.json b/packages/grok-taskflow/package.json index ff305603..52774ed2 100644 --- a/packages/grok-taskflow/package.json +++ b/packages/grok-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "grok-taskflow", - "version": "0.2.2", + "version": "0.2.3", "description": "Run taskflow on Grok Build: a Grok subagent runner plus an MCP server (and a plug-and-play Grok plugin) that exposes the taskflow_* tools to Grok Build users.", "keywords": [ "grok", diff --git a/packages/grok-taskflow/plugin/.grok-plugin/plugin.json b/packages/grok-taskflow/plugin/.grok-plugin/plugin.json index 3ad4c785..2ee2d74f 100644 --- a/packages/grok-taskflow/plugin/.grok-plugin/plugin.json +++ b/packages/grok-taskflow/plugin/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "taskflow", - "version": "0.2.2", + "version": "0.2.3", "description": "Declarative, verifiable DAG orchestration for Grok Build subagents — fan-out, gates, loops, tournaments, approvals, resumable runs, and saveable commands, with intermediate transcripts kept out of your context.", "author": { "name": "heggria", diff --git a/packages/grok-taskflow/plugin/.mcp.json b/packages/grok-taskflow/plugin/.mcp.json index d163a027..d922ce0b 100644 --- a/packages/grok-taskflow/plugin/.mcp.json +++ b/packages/grok-taskflow/plugin/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "grok-taskflow@0.2.2", "grok-taskflow-mcp"], + "args": ["-y", "-p", "grok-taskflow@0.2.3", "grok-taskflow-mcp"], "tool_timeout_sec": 1800 } } diff --git a/packages/grok-taskflow/test/mcp-server.test.ts b/packages/grok-taskflow/test/mcp-server.test.ts index 35474a88..edb79d22 100644 --- a/packages/grok-taskflow/test/mcp-server.test.ts +++ b/packages/grok-taskflow/test/mcp-server.test.ts @@ -45,7 +45,7 @@ test("grok mcp: initialize returns the protocol version + serverInfo", async () assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-grok"); - assert.equal(res.result.serverInfo.version, "0.2.2"); + assert.equal(res.result.serverInfo.version, "0.2.3"); }); test("grok mcp: tools/list exposes the same taskflow tools as other hosts", async () => { diff --git a/packages/opencode-taskflow/package.json b/packages/opencode-taskflow/package.json index 670a016c..f5ac95bd 100644 --- a/packages/opencode-taskflow/package.json +++ b/packages/opencode-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "opencode-taskflow", - "version": "0.2.2", + "version": "0.2.3", "description": "Run taskflow on OpenCode: an OpenCode subagent runner plus an MCP server (and an opencode.json config scaffold) that exposes the taskflow_* tools to OpenCode users.", "keywords": [ "opencode", diff --git a/packages/opencode-taskflow/plugin/opencode.json b/packages/opencode-taskflow/plugin/opencode.json index 936181b2..f8aa29ce 100644 --- a/packages/opencode-taskflow/plugin/opencode.json +++ b/packages/opencode-taskflow/plugin/opencode.json @@ -3,7 +3,7 @@ "mcp": { "taskflow": { "type": "local", - "command": ["npx", "-y", "-p", "opencode-taskflow@0.2.2", "opencode-taskflow-mcp"], + "command": ["npx", "-y", "-p", "opencode-taskflow@0.2.3", "opencode-taskflow-mcp"], "enabled": true } }, diff --git a/packages/opencode-taskflow/test/mcp-server.test.ts b/packages/opencode-taskflow/test/mcp-server.test.ts index 2b76c645..67eaec47 100644 --- a/packages/opencode-taskflow/test/mcp-server.test.ts +++ b/packages/opencode-taskflow/test/mcp-server.test.ts @@ -45,7 +45,7 @@ test("opencode mcp: initialize returns the protocol version + serverInfo", async assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-opencode"); - assert.equal(res.result.serverInfo.version, "0.2.2"); + assert.equal(res.result.serverInfo.version, "0.2.3"); }); test("opencode mcp: tools/list exposes the taskflow tools", async () => { diff --git a/packages/pi-taskflow/package.json b/packages/pi-taskflow/package.json index 7509d0a9..c4e53a2f 100644 --- a/packages/pi-taskflow/package.json +++ b/packages/pi-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "pi-taskflow", - "version": "0.2.2", + "version": "0.2.3", "description": "A declarative, verifiable graph of task nodes for the Pi coding agent — statically verified before it runs, with dynamic fan-out, gates, isolated subagent context, resumable runs, and saveable commands.", "keywords": [ "pi-package", diff --git a/packages/pi-taskflow/src/index.ts b/packages/pi-taskflow/src/index.ts index 6fbb3110..a343b85c 100644 --- a/packages/pi-taskflow/src/index.ts +++ b/packages/pi-taskflow/src/index.ts @@ -97,6 +97,9 @@ import { reconcileResolveOnlyWorkspace, WORKSPACE_RECONCILE_ACKNOWLEDGEMENT, workspaceReconcileAllowedFromEnv, + clearDetachedProcessRegistry, + DETACHED_CONTROL_VERSION, + terminateDetachedProcessTrees, } from "taskflow-core"; interface TaskflowDetails { @@ -1363,8 +1366,21 @@ export default function (pi: ExtensionAPI) { if (params.detach) { const state = makeRunState(def, args, ctx.cwd); state.detached = true; - saveRun(state); - + state.detachedStartedAt = Date.now(); + state.detachedControlVersion = DETACHED_CONTROL_VERSION; + state.detachedInstanceId = (await import("node:crypto")).randomUUID(); + const detachedSettings = readSubagentSettings(); + state.detachedRetention = { + maxKeep: detachedSettings.taskflow.maxKeptRuns, + maxAgeDays: detachedSettings.taskflow.maxRunAgeDays, + }; + const detachedScope: AgentScope = def.agentScope ?? "user"; + const detachedAgents = discoverAgents( + ctx.cwd, + detachedScope, + detachedSettings.modelRoles, + detachedSettings.taskflow, + ).agents; // Serialize context for the detached runner script. const { chmodSync, mkdtempSync, rmSync, writeFileSync } = await import("node:fs"); const { spawn } = await import("node:child_process"); @@ -1373,6 +1389,7 @@ export default function (pi: ExtensionAPI) { const tmpDir = mkdtempSync(path.join(os.tmpdir(), "taskflow-detach-")); chmodSync(tmpDir, 0o700); const tmpFile = path.join(tmpDir, "context.json"); + const startFile = path.join(tmpDir, "start"); // The runner module path is SELF-REPORTED by runner.ts (import.meta.url): // src/runner.ts in dev, dist/runner.js in the compiled package. Do NOT // switch this to resolving the relative "./runner" specifier with a .ts @@ -1389,7 +1406,19 @@ export default function (pi: ExtensionAPI) { cwd: ctx.cwd, runnerModule, runnerFactoryExport: "createPiSubagentRunner", - runnerConfig: readSubagentSettings().taskflow.piChild, + runnerConfig: detachedSettings.taskflow.piChild, + waitForStart: true, + incremental: params.incremental === true, + reusedSavedName: + params.reusedFromSearch === true && typeof params.name === "string" && params.name.trim() + ? params.name.trim() + : undefined, + agents: detachedAgents, + globalThinking: detachedSettings.globalThinking, + agentScope: detachedScope, + maxKeptRuns: detachedSettings.taskflow.maxKeptRuns, + maxRunAgeDays: detachedSettings.taskflow.maxRunAgeDays, + detachedInstanceId: state.detachedInstanceId, }), { encoding: "utf-8", flag: "wx", mode: 0o600 }); // detached-runner lives in taskflow-core (spawn-only entry). Resolve it @@ -1403,10 +1432,14 @@ export default function (pi: ExtensionAPI) { const runnerScript = (await import("node:url")).fileURLToPath( import.meta.resolve("taskflow-core/detached-runner"), ); + // Persist only after all pre-spawn preparation succeeds. The start + // gate still prevents the child from racing the later pid update. + saveRun(state, state.detachedRetention); // Capture stderr so a crashed child is debuggable instead of invisible. const child = spawn(process.execPath, [runnerScript, tmpFile], { detached: true, stdio: ["ignore", "ignore", "pipe"], + env: { ...process.env, TASKFLOW_DETACHED_RUNNER: "1" }, }); let childErr = ""; child.stderr?.on("data", (chunk: Buffer) => { childErr += chunk.toString(); }); @@ -1415,10 +1448,15 @@ export default function (pi: ExtensionAPI) { // Guarded by pid + status so we never clobber a genuine terminal state // the runner may have persisted between spawn and this callback. const markFailedOnEarlyExit = (exitCode: number | null) => { + try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* child consumed it */ } if (exitCode === 0) return; // clean exit — runner persists its own state try { const cur = loadRun(ctx.cwd, state.runId); if (cur && cur.status === "running" && cur.pid === child.pid) { + if (cur.detachedInstanceId) { + terminateDetachedProcessTrees(cur.cwd, cur.runId, cur.detachedInstanceId); + clearDetachedProcessRegistry(cur.cwd, cur.runId, cur.detachedInstanceId); + } cur.status = "failed"; // Record the crash reason in a synthetic phase so it is persisted, // pollable, and debuggable (RunState has no run-level error field). @@ -1430,7 +1468,10 @@ export default function (pi: ExtensionAPI) { ? `Detached runner exited with code ${exitCode}: ${childErr.trim().slice(0, 2000)}` : `Detached runner exited with code ${exitCode} before completing.`, }; - saveRun(cur, { maxKeep: 50, maxAgeDays: 14 }); + saveRun(cur, { + maxKeep: detachedSettings.taskflow.maxKeptRuns, + maxAgeDays: detachedSettings.taskflow.maxRunAgeDays, + }); } } catch { /* best-effort: never let a handler throw */ } }; @@ -1447,14 +1488,21 @@ export default function (pi: ExtensionAPI) { endedAt: Date.now(), error: `Failed to spawn detached runner: ${err.message}`, }; - saveRun(cur, { maxKeep: 50, maxAgeDays: 14 }); + saveRun(cur, { + maxKeep: detachedSettings.taskflow.maxKeptRuns, + maxAgeDays: detachedSettings.taskflow.maxRunAgeDays, + }); } } catch { /* best-effort */ } }); child.unref(); state.pid = child.pid ?? undefined; - saveRun(state); + saveRun(state, { + maxKeep: detachedSettings.taskflow.maxKeptRuns, + maxAgeDays: detachedSettings.taskflow.maxRunAgeDays, + }); + writeFileSync(startFile, "start\n", { encoding: "utf-8", flag: "wx", mode: 0o600 }); return { content: [{ type: "text", text: `Taskflow '${def.name}' started in background (pid: ${child.pid}). Run id: ${state.runId}` }], diff --git a/packages/pi-taskflow/test/runner-injection.test.ts b/packages/pi-taskflow/test/runner-injection.test.ts index 025efe70..2e87ded1 100644 --- a/packages/pi-taskflow/test/runner-injection.test.ts +++ b/packages/pi-taskflow/test/runner-injection.test.ts @@ -82,9 +82,18 @@ test("regression: the detached context file carries a runnerModule (runner injec // Accept either the shorthand (`runnerModule,`) or key (`runnerModule:`) form. assert.ok(/\brunnerModule\b[,:]/.test(SRC), "detached context must carry runnerModule"); assert.match(SRC, /runnerFactoryExport:\s*"createPiSubagentRunner"/, "detached context must name the host-authorized Pi runner factory"); - assert.match(SRC, /runnerConfig:\s*readSubagentSettings\(\)\.taskflow\.piChild/, "detached context must carry the normalized Pi child profile snapshot"); + assert.match( + SRC, + /runnerConfig:\s*(?:readSubagentSettings\(\)|detachedSettings)\.taskflow\.piChild/, + "detached context must carry the normalized Pi child profile snapshot", + ); }); test("regression: createPiSubagentRunner is imported into index.ts (the injected runner factory)", () => { assert.match(SRC, /import\s*\{[^}]*\bcreatePiSubagentRunner\b[^}]*\}\s*from\s*"\.\/runner\.ts"/, "index.ts must import createPiSubagentRunner from ./runner.ts"); }); + +test("regression: detached context preserves invocation-level cache and reuse semantics", () => { + assert.match(SRC, /incremental:\s*params\.incremental\s*===\s*true/, "detached context must carry the incremental invocation override"); + assert.match(SRC, /reusedSavedName:\s*[\s\S]{0,240}params\.reusedFromSearch\s*===\s*true/, "detached context must carry the reuse attribution only for an authorized search selection"); +}); diff --git a/packages/taskflow-core/package.json b/packages/taskflow-core/package.json index bda843b1..eac4cb46 100644 --- a/packages/taskflow-core/package.json +++ b/packages/taskflow-core/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-core", - "version": "0.2.2", + "version": "0.2.3", "description": "Host-neutral engine for declarative, verifiable task-DAG orchestration — the runtime, DSL, cache, and verification shared by pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, and grok-taskflow.", "keywords": [ "taskflow", diff --git a/packages/taskflow-core/src/agents.ts b/packages/taskflow-core/src/agents.ts index 5d2a291e..108a20dd 100644 --- a/packages/taskflow-core/src/agents.ts +++ b/packages/taskflow-core/src/agents.ts @@ -32,9 +32,9 @@ export interface TaskflowSettings { builtInAgents: boolean; /** Whether package-local built-ins are copied into the current project's .pi/agents/. */ syncBuiltinAgentsToProject: boolean; - /** Maximum completed/failed runs to keep. 0 disables cleanup. */ + /** Maximum inactive runs to keep. Running runs are never pruned. 0 disables cleanup. */ maxKeptRuns: number; - /** Maximum age (days) for completed/failed runs. 0 disables age cleanup. */ + /** Maximum age (days) for inactive runs. Running runs are never pruned. 0 disables age cleanup. */ maxRunAgeDays: number; /** Library (reusable-flow asset layer) settings. RFC: docs/rfc-library-reuse.md */ library: LibrarySettings; diff --git a/packages/taskflow-core/src/detached-control.ts b/packages/taskflow-core/src/detached-control.ts index 89e5c8c6..41859534 100644 --- a/packages/taskflow-core/src/detached-control.ts +++ b/packages/taskflow-core/src/detached-control.ts @@ -3,31 +3,61 @@ * * A detached runner cannot rely on an IPC channel surviving its parent MCP * process. Cancellation is therefore requested through a small durable marker - * under the project runs directory. The child polls that marker and aborts the - * normal RuntimeDeps signal, preserving the runtime's existing paused semantics - * and process-tree cleanup. + * in a user-private control directory keyed by the canonical invocation root. + * The child polls that marker and aborts the normal RuntimeDeps signal, + * preserving the runtime's existing paused semantics and process-tree cleanup. */ import * as fs from "node:fs"; import * as path from "node:path"; -import { runsDir, validateRunId, writeFileAtomic } from "./store.ts"; +import { spawnSync } from "node:child_process"; +import { detachedControlDir, validateRunId, writeFileAtomic } from "./store.ts"; export interface DetachedCancelRequest { requestedAt: number; reason?: string; } -const CONTROL_DIR = ".control"; const DEFAULT_POLL_MS = 250; +const CONTROL_VERSION = 1; + +export const DETACHED_CONTROL_VERSION = CONTROL_VERSION; +export const DETACHED_CONTROL_CWD_ENV = "TASKFLOW_DETACHED_CONTROL_CWD"; +export const DETACHED_CONTROL_RUN_ID_ENV = "TASKFLOW_DETACHED_CONTROL_RUN_ID"; +export const DETACHED_CONTROL_INSTANCE_ENV = "TASKFLOW_DETACHED_CONTROL_INSTANCE"; + +export interface DetachedProcessRegistry { + version: number; + instanceId: string; + ownerPid: number; + heartbeatAt: number; + pids: number[]; +} + +function ensureControlDir(cwd: string): string { + const dir = detachedControlDir(cwd); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const stat = fs.lstatSync(dir); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error("Detached control root must be a private physical directory"); + } + try { fs.chmodSync(dir, 0o700); } catch { /* Windows / restrictive filesystem */ } + return dir; +} + +function controlPath(cwd: string, runId: string, suffix: string): string { + if (!validateRunId(runId)) throw new Error("Invalid runId for detached control"); + return path.join(detachedControlDir(cwd), `${runId}.${suffix}.json`); +} /** Resolve the cancel marker for a run without accepting caller-selected paths. */ export function detachedCancelRequestPath(cwd: string, runId: string): string { - if (!validateRunId(runId)) throw new Error("Invalid runId for detached control"); - return path.join(runsDir(cwd), CONTROL_DIR, `${runId}.cancel.json`); + return controlPath(cwd, runId, "cancel"); } /** Persist an idempotent cancellation request for a detached runner. */ export function requestDetachedCancel(cwd: string, runId: string, reason?: string): DetachedCancelRequest { + ensureControlDir(cwd); const request: DetachedCancelRequest = { requestedAt: Date.now(), ...(typeof reason === "string" && reason.trim() @@ -64,6 +94,132 @@ export function clearDetachedCancelRequest(cwd: string, runId: string): void { } catch { /* missing/already consumed */ } + try { fs.rmdirSync(detachedControlDir(cwd)); } catch { /* other markers / missing */ } +} + +export function detachedProcessRegistryPath(cwd: string, runId: string): string { + return controlPath(cwd, runId, "processes"); +} + +export function readDetachedProcessRegistry(cwd: string, runId: string): DetachedProcessRegistry | null { + const filePath = detachedProcessRegistryPath(cwd, runId); + try { + const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf8")); + if (typeof parsed !== "object" || parsed === null) return null; + const value = parsed as Record; + if ( + value.version !== CONTROL_VERSION || + typeof value.instanceId !== "string" || + !Number.isSafeInteger(value.ownerPid) || + typeof value.heartbeatAt !== "number" || + !Array.isArray(value.pids) + ) return null; + return { + version: CONTROL_VERSION, + instanceId: value.instanceId, + ownerPid: value.ownerPid as number, + heartbeatAt: value.heartbeatAt, + pids: value.pids.filter((pid): pid is number => Number.isSafeInteger(pid) && Number(pid) > 0), + }; + } catch { + return null; + } +} + +function writeDetachedProcessRegistry(cwd: string, runId: string, registry: DetachedProcessRegistry): void { + ensureControlDir(cwd); + writeFileAtomic(detachedProcessRegistryPath(cwd, runId), `${JSON.stringify(registry)}\n`); +} + +export function heartbeatDetachedProcessRegistry( + cwd: string, + runId: string, + instanceId: string, + ownerPid = process.pid, +): void { + const existing = readDetachedProcessRegistry(cwd, runId); + const pids = existing?.instanceId === instanceId ? existing.pids : []; + writeDetachedProcessRegistry(cwd, runId, { + version: CONTROL_VERSION, + instanceId, + ownerPid, + heartbeatAt: Date.now(), + pids, + }); +} + +function processRegistryContextFromEnv(): { cwd: string; runId: string; instanceId: string } | null { + const cwd = process.env[DETACHED_CONTROL_CWD_ENV]; + const runId = process.env[DETACHED_CONTROL_RUN_ID_ENV]; + const instanceId = process.env[DETACHED_CONTROL_INSTANCE_ENV]; + if (!cwd || !runId || !instanceId || !validateRunId(runId)) return null; + return { cwd, runId, instanceId }; +} + +/** Best-effort hook used by runner-core whenever a Host CLI process group starts. */ +export function registerDetachedProcessTreeFromEnv(pid: number): void { + const context = processRegistryContextFromEnv(); + if (!context || !Number.isSafeInteger(pid) || pid <= 0) return; + try { + const existing = readDetachedProcessRegistry(context.cwd, context.runId); + const pids = existing?.instanceId === context.instanceId ? existing.pids : []; + writeDetachedProcessRegistry(context.cwd, context.runId, { + version: CONTROL_VERSION, + instanceId: context.instanceId, + ownerPid: process.pid, + heartbeatAt: Date.now(), + pids: [...new Set([...pids, pid])], + }); + } catch { /* lifecycle diagnostics must never replace phase execution */ } +} + +/** Best-effort hook used by runner-core after a Host CLI process group is reaped. */ +export function unregisterDetachedProcessTreeFromEnv(pid: number): void { + const context = processRegistryContextFromEnv(); + if (!context) return; + try { + const existing = readDetachedProcessRegistry(context.cwd, context.runId); + if (!existing || existing.instanceId !== context.instanceId) return; + writeDetachedProcessRegistry(context.cwd, context.runId, { + ...existing, + heartbeatAt: Date.now(), + pids: existing.pids.filter((candidate) => candidate !== pid), + }); + } catch { /* lifecycle diagnostics must never replace phase execution */ } +} + +function killRegisteredProcessTree(pid: number): void { + if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) return; + if (process.platform === "win32") { + try { + spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { + shell: false, + stdio: "ignore", + windowsHide: true, + }); + } catch { /* already gone / taskkill unavailable */ } + return; + } + try { process.kill(-pid, "SIGKILL"); } catch { + try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ } + } +} + +/** Reap every Host CLI process group owned by exactly this worker instance. */ +export function terminateDetachedProcessTrees(cwd: string, runId: string, instanceId: string): number[] { + const registry = readDetachedProcessRegistry(cwd, runId); + if (!registry || registry.instanceId !== instanceId) return []; + for (const pid of registry.pids) killRegisteredProcessTree(pid); + return registry.pids; +} + +export function clearDetachedProcessRegistry(cwd: string, runId: string, instanceId?: string): void { + try { + const existing = readDetachedProcessRegistry(cwd, runId); + if (instanceId && existing && existing.instanceId !== instanceId) return; + fs.unlinkSync(detachedProcessRegistryPath(cwd, runId)); + } catch { /* missing/already cleared */ } + try { fs.rmdirSync(detachedControlDir(cwd)); } catch { /* other markers / missing */ } } /** @@ -76,11 +232,15 @@ export function watchDetachedCancel( runId: string, controller: AbortController, pollMs = DEFAULT_POLL_MS, + onRequest?: (request: DetachedCancelRequest) => void, ): () => void { const check = () => { if (controller.signal.aborted) return; const request = readDetachedCancelRequest(cwd, runId); - if (request) controller.abort(request); + if (request) { + onRequest?.(request); + controller.abort(request); + } }; check(); const timer = setInterval(check, Math.max(50, pollMs)); diff --git a/packages/taskflow-core/src/detached-runner.ts b/packages/taskflow-core/src/detached-runner.ts index de0df8bd..566e0072 100644 --- a/packages/taskflow-core/src/detached-runner.ts +++ b/packages/taskflow-core/src/detached-runner.ts @@ -11,7 +11,7 @@ import { existsSync, readFileSync, rmdirSync, unlinkSync } from "node:fs"; import { basename, dirname, join } from "node:path"; -import { type AgentScope, discoverAgents, readSubagentSettings } from "./agents.ts"; +import { type AgentConfig, type AgentScope, discoverAgents, readSubagentSettings } from "./agents.ts"; import { executeTaskflow } from "./runtime.ts"; import { cwdBridgeModeFromEnv } from "./cwd-bridge.ts"; import { @@ -26,6 +26,12 @@ import { } from "./store.ts"; import { clearDetachedCancelRequest, + clearDetachedProcessRegistry, + DETACHED_CONTROL_CWD_ENV, + DETACHED_CONTROL_INSTANCE_ENV, + DETACHED_CONTROL_RUN_ID_ENV, + heartbeatDetachedProcessRegistry, + terminateDetachedProcessTrees, watchDetachedCancel, } from "./detached-control.ts"; import { FileTraceSink } from "./trace.ts"; @@ -54,6 +60,14 @@ interface DetachContext { incremental?: boolean; /** Saved flow selected through search; bump reuse only after success. */ reusedSavedName?: string; + /** Parent-resolved execution context. Freezing this at dispatch keeps mode + * background semantically identical to the foreground invocation. */ + agents?: AgentConfig[]; + globalThinking?: string; + agentScope?: AgentScope; + maxKeptRuns?: number; + maxRunAgeDays?: number; + detachedInstanceId?: string; } const START_GATE_TIMEOUT_MS = 5_000; @@ -128,18 +142,34 @@ try { process.exit(1); } - // Re-discover agents using the same settings as the host session. + process.env.TASKFLOW_DETACHED_RUNNER = "1"; + let heartbeatTimer: NodeJS.Timeout | undefined; + if (ctx.detachedInstanceId) { + process.env[DETACHED_CONTROL_CWD_ENV] = ctx.cwd; + process.env[DETACHED_CONTROL_RUN_ID_ENV] = ctx.runId; + process.env[DETACHED_CONTROL_INSTANCE_ENV] = ctx.detachedInstanceId; + heartbeatDetachedProcessRegistry(ctx.cwd, ctx.runId, ctx.detachedInstanceId); + heartbeatTimer = setInterval(() => { + try { heartbeatDetachedProcessRegistry(ctx.cwd, ctx.runId, ctx.detachedInstanceId!); } catch { /* best-effort lease */ } + }, 1_000); + heartbeatTimer.unref(); + } + + // Prefer the execution context frozen by the parent. Legacy callers without + // a snapshot retain the old discovery fallback. const settings = readSubagentSettings(); - cleanupConfig.maxKeep = settings.taskflow.maxKeptRuns; - cleanupConfig.maxAgeDays = settings.taskflow.maxRunAgeDays; - const scope: AgentScope = state.def.agentScope ?? "user"; - const { agents } = discoverAgents(ctx.cwd, scope, settings.modelRoles, settings.taskflow); + cleanupConfig.maxKeep = ctx.maxKeptRuns ?? state.detachedRetention?.maxKeep ?? settings.taskflow.maxKeptRuns; + cleanupConfig.maxAgeDays = ctx.maxRunAgeDays ?? state.detachedRetention?.maxAgeDays ?? settings.taskflow.maxRunAgeDays; + const legacyDefaultScope: AgentScope = ctx.runnerFactoryExport ? "user" : "both"; + const scope: AgentScope = ctx.agentScope ?? state.def.agentScope ?? legacyDefaultScope; + const agents = ctx.agents ?? discoverAgents(ctx.cwd, scope, settings.modelRoles, settings.taskflow).agents; // The host adapter injects its subagent runner. Core is host-neutral and // CANNOT spawn pi/codex itself, so without this every phase would fail with // "No subagent runner injected". Resolve the runner via a dynamic import of // the module path the host serialized into the context file. let injectedRunner: SubagentRunner | undefined; + let runnerLoadError: string | undefined; if (ctx.runnerModule) { try { const runnerMod = await import(ctx.runnerModule); @@ -154,7 +184,8 @@ try { console.error(`[detached-runner] '${exportName}' on '${ctx.runnerModule}' is not a SubagentRunner (missing runTask)`); } } catch (e) { - console.error(`[detached-runner] Failed to load runner module '${ctx.runnerModule}': ${e instanceof Error ? e.message : String(e)}`); + runnerLoadError = e instanceof Error ? e.message : String(e); + console.error(`[detached-runner] Failed to load runner module '${ctx.runnerModule}': ${runnerLoadError}`); } } else { console.error("[detached-runner] No runnerModule in context — phases will fail with 'No subagent runner injected'"); @@ -172,14 +203,18 @@ try { id: "__detach__", status: "failed", endedAt: Date.now(), - error: `Runner module failed to load: '${ctx.runnerModule}' (export '${ctx.runnerFactoryExport ?? ctx.runnerExport ?? "piSubagentRunner"}'). See detached-runner stderr for the import error.`, + error: `Runner module failed to load: '${ctx.runnerModule}' (export '${ctx.runnerFactoryExport ?? ctx.runnerExport ?? "piSubagentRunner"}'): ${runnerLoadError ?? "export is not a SubagentRunner"}`, }; saveRun(state, cleanupConfig); + if (heartbeatTimer) clearInterval(heartbeatTimer); + if (ctx.detachedInstanceId) clearDetachedProcessRegistry(ctx.cwd, ctx.runId, ctx.detachedInstanceId); process.exit(1); } const abortController = new AbortController(); - const stopCancelWatch = watchDetachedCancel(ctx.cwd, ctx.runId, abortController); + const stopCancelWatch = watchDetachedCancel(ctx.cwd, ctx.runId, abortController, undefined, (request) => { + state.detachedCancel = request; + }); const abortForSignal = () => abortController.abort({ reason: "detached-process-signal" }); process.once("SIGTERM", abortForSignal); process.once("SIGINT", abortForSignal); @@ -188,7 +223,7 @@ try { cwd: ctx.cwd, cwdBridgeMode: cwdBridgeModeFromEnv(), agents, - globalThinking: settings.globalThinking, + globalThinking: ctx.globalThinking ?? settings.globalThinking, persist: (s) => saveRun(s, cleanupConfig), runTask: injectedRunner?.runTask, usageAccounting: injectedRunner?.usageAccounting, @@ -209,7 +244,9 @@ try { } } finally { stopCancelWatch(); + if (heartbeatTimer) clearInterval(heartbeatTimer); clearDetachedCancelRequest(ctx.cwd, ctx.runId); + if (ctx.detachedInstanceId) clearDetachedProcessRegistry(ctx.cwd, ctx.runId, ctx.detachedInstanceId); process.removeListener("SIGTERM", abortForSignal); process.removeListener("SIGINT", abortForSignal); process.removeListener("SIGHUP", abortForSignal); @@ -222,11 +259,19 @@ try { const state = loadRun(ctx.cwd, ctx.runId); if (state && state.status === "running") { state.status = "failed"; + state.phases["__detach__"] = { + id: "__detach__", + status: "failed", + endedAt: Date.now(), + error: message.slice(0, 2_000), + }; saveRun(state, cleanupConfig); } } catch { // Best-effort — if we can't even load the state, there's nothing to persist. } + if (ctx.detachedInstanceId) terminateDetachedProcessTrees(ctx.cwd, ctx.runId, ctx.detachedInstanceId); clearDetachedCancelRequest(ctx.cwd, ctx.runId); + if (ctx.detachedInstanceId) clearDetachedProcessRegistry(ctx.cwd, ctx.runId, ctx.detachedInstanceId); process.exit(1); } diff --git a/packages/taskflow-core/src/exec/driver.ts b/packages/taskflow-core/src/exec/driver.ts index b5e66058..9168f402 100644 --- a/packages/taskflow-core/src/exec/driver.ts +++ b/packages/taskflow-core/src/exec/driver.ts @@ -467,6 +467,8 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr budget: budgetBlocked, budgetReason, }); + state.finalOutput = finalOutput; + state.outputSourcePhaseId = outputSourcePhaseId; const totalUsage = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage())); try { diff --git a/packages/taskflow-core/src/runner-core.ts b/packages/taskflow-core/src/runner-core.ts index f9ccb494..f706f1d4 100644 --- a/packages/taskflow-core/src/runner-core.ts +++ b/packages/taskflow-core/src/runner-core.ts @@ -17,6 +17,10 @@ import { StringDecoder } from "node:string_decoder"; import { emptyUsage, type UsageStats } from "./usage.ts"; import type { AgentConfig } from "./agents.ts"; import type { CoreMessage, LiveUpdate, RunResult } from "./host/runner-types.ts"; +import { + registerDetachedProcessTreeFromEnv, + unregisterDetachedProcessTreeFromEnv, +} from "./detached-control.ts"; // Re-export the host-neutral execution contract types so importers of the // runner surface get them from one place. @@ -294,12 +298,14 @@ const activeChildren = new Set(); * cannot leave either kind of child mutating the workspace. */ export function registerProcessTree(pid: number): void { activeChildren.add(pid); + registerDetachedProcessTreeFromEnv(pid); } /** Remove a process group after its stdio has closed and all descendants have * been synchronously reaped. */ export function unregisterProcessTree(pid: number): void { activeChildren.delete(pid); + unregisterDetachedProcessTreeFromEnv(pid); } /** Signal the whole process tree rooted at a spawned subagent. POSIX children @@ -372,6 +378,13 @@ for (const signal of EXTERNAL_SIGNALS) { if (handlingExternalSignal) return; handlingExternalSignal = true; killAllChildren(); + // Detached workers own a higher-level AbortController that persists the + // run as paused. Let its later signal listener finish that state transition + // instead of immediately restoring the OS default and killing the process. + if (process.env.TASKFLOW_DETACHED_RUNNER === "1") { + handlingExternalSignal = false; + return; + } // Existing listeners have already been snapshotted for this EventEmitter // dispatch. Removing them here only ensures the re-delivered signal takes // the OS default path instead of recursively entering user handlers. diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 0713593a..c81a5a04 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -4124,31 +4124,24 @@ export async function recomputeTaskflow( export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promise { deps = snapshotFlowLoader(deps); const def: Taskflow = state.def; + const failBeforeExecution = (finalOutput: string): RuntimeResult => { + state.status = "failed"; + state.finalOutput = finalOutput; + state.outputSourcePhaseId = undefined; + safeEmit(deps, state); + return { state, finalOutput, ok: false, totalUsage: emptyUsage() }; + }; // Normalize defaults at the engine boundary too. Adapters already do this, // but direct Core callers, resume, and detached execution must behave the same. state.args = resolveArgs(def, state.args); const invocationErrors = validateInvocationArgs(def, state.args); if (invocationErrors.length > 0) { - state.status = "failed"; - safeEmit(deps, state); - return { - state, - finalOutput: `Taskflow '${def.name}' invocation is invalid: ${invocationErrors.join("; ")}`, - ok: false, - totalUsage: emptyUsage(), - }; + return failBeforeExecution(`Taskflow '${def.name}' invocation is invalid: ${invocationErrors.join("; ")}`); } if (deps._dynamic === true) { const dynamicValidation = validateTaskflow(def, { dynamic: true, cwd: deps.cwd, args: state.args }); if (!dynamicValidation.ok) { - state.status = "failed"; - safeEmit(deps, state); - return { - state, - finalOutput: `Dynamic taskflow '${def.name}' is invalid: ${dynamicValidation.errors.join("; ")}`, - ok: false, - totalUsage: emptyUsage(), - }; + return failBeforeExecution(`Dynamic taskflow '${def.name}' is invalid: ${dynamicValidation.errors.join("; ")}`); } } // A cwd bridge carries compatibility read-write authority. Until workspace @@ -4179,15 +4172,9 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi (launchRoot !== undefined && !sameDirectoryIdentity(launchRoot, invocationRoot)) || (recordedRoot !== undefined && !sameDirectoryIdentity(recordedRoot, invocationRoot)) ) { - state.status = "failed"; - safeEmit(deps, state); - return { - state, - finalOutput: - `Taskflow '${def.name}' cwd-bridge invocation root does not match the run's persisted root; start a new run instead of rebinding on resume`, - ok: false, - totalUsage: emptyUsage(), - }; + return failBeforeExecution( + `Taskflow '${def.name}' cwd-bridge invocation root does not match the run's persisted root; start a new run instead of rebinding on resume`, + ); } state.cwdRootBinding ??= invocationRoot; // Freeze the invocation root to the canonical identity we just bound. In @@ -4213,14 +4200,9 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi }), }; } catch (error) { - state.status = "failed"; - safeEmit(deps, state); - return { - state, - finalOutput: `Taskflow '${def.name}' workspace capability initialization failed: ${error instanceof Error ? error.message : String(error)}`, - ok: false, - totalUsage: emptyUsage(), - }; + return failBeforeExecution( + `Taskflow '${def.name}' workspace capability initialization failed: ${error instanceof Error ? error.message : String(error)}`, + ); } } const runnerUsageAccounting = (deps.runTask as (RunTaskFn & { usageAccounting?: "available" | "tokens-only" | "unavailable" }) | undefined) @@ -4284,9 +4266,12 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi } } state.status = "failed"; + const finalOutput = `Taskflow '${def.name}' crashed: ${message}`; + state.finalOutput = finalOutput; + state.outputSourcePhaseId = undefined; safeEmit(deps, state); const totalUsage = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage())); - return { state, finalOutput: `Taskflow '${def.name}' crashed: ${message}`, ok: false, totalUsage }; + return { state, finalOutput, ok: false, totalUsage }; } } @@ -4546,7 +4531,6 @@ async function runTaskflowLayers(state: RunState, deps: RuntimeDeps): Promise p.usage ?? emptyUsage())); return { diff --git a/packages/taskflow-core/src/store.ts b/packages/taskflow-core/src/store.ts index 1fa5a8ed..7ed67f0f 100644 --- a/packages/taskflow-core/src/store.ts +++ b/packages/taskflow-core/src/store.ts @@ -22,7 +22,7 @@ import { parseJsonc } from "./jsonc.ts"; import { getAgentDir } from "./paths.ts"; import { parseStrict } from "./interpolate.ts"; import type { Taskflow } from "./schema.ts"; -import type { DirectoryIdentity } from "./cwd-bridge.ts"; +import { directoryIdentity, type DirectoryIdentity } from "./cwd-bridge.ts"; import type { UsageStats } from "./usage.ts"; import type { DeclaredDeps } from "./flowir/meta.ts"; import type { ScorerResult } from "./scorers.ts"; @@ -195,6 +195,18 @@ export interface RunState { detached?: boolean; /** Wall-clock launch time for detached lifecycle/orphan diagnostics. */ detachedStartedAt?: number; + /** Version of the durable detached-control protocol understood by the + * worker. Missing means a legacy detached run that cannot safely accept + * cross-request lifecycle commands. */ + detachedControlVersion?: number; + /** Unforgeable identity for one detached worker instance. Unlike a PID, this + * value cannot be silently reused by an unrelated OS process. */ + detachedInstanceId?: string; + /** Retention policy frozen at detached dispatch so crash/orphan writes do + * not silently fall back to another session's defaults. */ + detachedRetention?: { maxKeep: number; maxAgeDays: number }; + /** Durable audit record for the cancellation request that paused this run. */ + detachedCancel?: { requestedAt: number; reason?: string }; /** Final output persisted by a detached runner for later wait/status calls. */ finalOutput?: string; /** Phase whose output supplied `finalOutput`, when attributable. */ @@ -250,6 +262,9 @@ export interface RunState { export interface RunIndexEntry { runId: string; flowName: string; + /** Invocation cwd used to bind user-private detached lifecycle records. + * Absent on historical indexes; callers must retain a safe fallback. */ + cwd?: string; status: RunState["status"]; createdAt: number; updatedAt: number; @@ -356,14 +371,7 @@ function lockPathForRun(runsRoot: string, flowName: string, runId: string): stri * Legitimate runIds are produced by newRunId() and contain only [A-Za-z0-9._-]. */ export function validateRunId(runId: string): boolean { - return ( - typeof runId === "string" && - runId.length > 0 && - !runId.includes("/") && - !runId.includes("\\") && - !runId.includes("\0") && - !runId.includes("..") - ); + return typeof runId === "string" && runId.length <= 160 && /^[A-Za-z0-9_-](?:[A-Za-z0-9._-]*[A-Za-z0-9_-])?$/.test(runId) && !runId.includes(".."); } // --------------------------------------------------------------------------- @@ -460,6 +468,7 @@ export function extractIndexEntry(state: RunState, relPath: string): RunIndexEnt return { runId: state.runId, flowName: state.flowName, + cwd: state.cwd, status: state.status, createdAt: state.createdAt, updatedAt: state.updatedAt, @@ -593,11 +602,12 @@ function rebuildIndex(runsRoot: string): RunIndexEntry[] { // --------------------------------------------------------------------------- /** - * Remove excess and expired terminal (completed/failed) runs. + * Remove excess and expired inactive runs. * * Called opportunistically at the end of saveRun. Throttled to at most once - * per CLEANUP_INTERVAL_MS. Active runs (running/paused/blocked) are never - * touched. + * per CLEANUP_INTERVAL_MS. Only actually executing (`running`) runs are never + * touched. Paused and blocked runs remain resumable/inspectable inside the + * configured retention window, but no longer bypass it forever. * * The index read-modify-write is performed under the index lock so it cannot * race a concurrent updateIndexEntry and clobber a freshly-added entry (M1). @@ -625,7 +635,7 @@ function cleanupTerminalRuns( const active: RunIndexEntry[] = []; for (const e of entries) { - if (e.status === "completed" || e.status === "failed") { + if (e.status !== "running") { terminal.push(e); } else { active.push(e); @@ -648,8 +658,8 @@ function cleanupTerminalRuns( for (let i = 0; i < cleanTerminal.length; i++) { const e = cleanTerminal[i]!; - const expiredByAge = now - e.updatedAt > maxAgeMs; - const excessByCount = i >= maxKeep; + const expiredByAge = maxAgeDays > 0 && now - e.updatedAt > maxAgeMs; + const excessByCount = maxKeep > 0 && i >= maxKeep; if (expiredByAge || excessByCount) { toRemove.push(e); } @@ -686,9 +696,12 @@ function cleanupTerminalRuns( // Also remove the per-run Shared Context Tree directory (C6). Orphaned // ctx dirs would otherwise accumulate under runs/ctx/ over many runs. try { fs.rmSync(path.join(runsRoot, "ctx", e.runId), { recursive: true, force: true }); } catch { /* ignore */ } - // Remove a detached control marker left by a process killed before it - // could consume/clear cancellation itself. - try { fs.unlinkSync(path.join(runsRoot, ".control", `${e.runId}.cancel.json`)); } catch { /* ignore */ } + // Remove user-private detached control records left by a process killed + // before it could consume/clear cancellation or process ownership state. + const controlDir = detachedControlDir(e.cwd ?? path.dirname(path.dirname(path.dirname(runsRoot)))); + try { fs.unlinkSync(path.join(controlDir, `${e.runId}.cancel.json`)); } catch { /* ignore */ } + try { fs.unlinkSync(path.join(controlDir, `${e.runId}.processes.json`)); } catch { /* ignore */ } + try { fs.rmdirSync(controlDir); } catch { /* other runs / missing */ } // Also remove the per-run isolated-workspace dir tree (cwd:"dedicated"). // `dedicated` workspaces are persistent by design; reclaim them once the // run is pruned. The dir name uses the same sanitization as workspace.ts. @@ -971,6 +984,23 @@ export function runsDir(cwd: string): string { return path.join(projDir, "runs"); } +/** + * User-private control directory for one invocation root. + * + * Cancellation and process-registry markers must not live below a repository: + * a checked-out `.pi` tree can contain symlinks controlled by project content. + * Bind the control plane to the canonical directory identity and keep only its + * digest in the user agent directory, so sibling worktrees remain isolated. + */ +export function detachedControlDir(cwd: string): string { + const identity = directoryIdentity(cwd); + const source = identity + ? `${identity.canonicalPath}\0${identity.device}\0${identity.inode}` + : path.resolve(cwd); + const key = crypto.createHash("sha256").update(source).digest("hex"); + return path.join(getAgentDir(), "taskflow-control", key); +} + /** Root dir for the cross-run memoization cache (sibling of `runs`). */ export function cacheDir(cwd: string): string { const projDir = findProjectFlowsDir(cwd, true)!; @@ -1199,15 +1229,34 @@ export function hashInput(...parts: string[]): string { * Uses signal 0 (no signal sent) — succeeds if the process exists and we have * permission to signal it, throws ESRCH if it doesn't exist. */ -export function isProcessAlive(pid: number): boolean { +export type ProcessLiveness = "alive" | "dead" | "unknown"; + +export function probeProcess( + pid: number, + signalZero: (pid: number, signal: 0) => unknown = (candidate, signal) => process.kill(candidate, signal), +): ProcessLiveness { + // Node/libuv rejects values outside the signed 32-bit PID range before an + // OS probe occurs; those are definitively not live process identifiers. + if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0x7fffffff) return "dead"; try { - process.kill(pid, 0); - return true; - } catch { - return false; + signalZero(pid, 0); + return "alive"; + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code ?? "") + : ""; + if (code === "ESRCH") return "dead"; + // EPERM proves that a process exists but its identity is not observable. + // Unknown platform errors must likewise never terminalize a live run. + return "unknown"; } } +/** Back-compatible boolean probe. Unknown is conservatively treated as alive. */ +export function isProcessAlive(pid: number): boolean { + return probeProcess(pid) !== "dead"; +} + /** * Write a file atomically: write to a unique temp file in the same directory, * then rename over the target (rename is atomic on the same filesystem). Prevents diff --git a/packages/taskflow-core/test/detached-control.test.ts b/packages/taskflow-core/test/detached-control.test.ts new file mode 100644 index 00000000..04e75bc8 --- /dev/null +++ b/packages/taskflow-core/test/detached-control.test.ts @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test } from "node:test"; +import { + clearDetachedProcessRegistry, + detachedCancelRequestPath, + detachedControlDir, + DETACHED_CONTROL_CWD_ENV, + DETACHED_CONTROL_INSTANCE_ENV, + DETACHED_CONTROL_RUN_ID_ENV, + probeProcess, + registerDetachedProcessTreeFromEnv, + requestDetachedCancel, + terminateDetachedProcessTrees, +} from "../src/index.ts"; + +function withAgentDir(agentDir: string): () => void { + const previous = process.env.TASKFLOW_AGENT_DIR; + process.env.TASKFLOW_AGENT_DIR = agentDir; + return () => { + if (previous === undefined) delete process.env.TASKFLOW_AGENT_DIR; + else process.env.TASKFLOW_AGENT_DIR = previous; + }; +} + +test("detached control: repository .control symlinks cannot redirect markers", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-control-private-")); + const cwd = path.join(root, "project"); + const outside = path.join(root, "outside"); + fs.mkdirSync(path.join(cwd, ".pi", "taskflows", "runs"), { recursive: true }); + fs.mkdirSync(outside); + fs.symlinkSync(outside, path.join(cwd, ".pi", "taskflows", "runs", ".control"), "dir"); + const restore = withAgentDir(path.join(root, "agent")); + try { + requestDetachedCancel(cwd, "safe-run", "test"); + assert.equal(fs.readdirSync(outside).length, 0, "project-controlled symlink target stays untouched"); + assert.equal(fs.existsSync(detachedCancelRequestPath(cwd, "safe-run")), true); + assert.equal(detachedCancelRequestPath(cwd, "safe-run").startsWith(detachedControlDir(cwd)), true); + } finally { + restore(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("detached control: a symlinked private control leaf fails closed", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-control-symlink-")); + const cwd = path.join(root, "project"); + const outside = path.join(root, "outside"); + fs.mkdirSync(cwd); + fs.mkdirSync(outside); + const restore = withAgentDir(path.join(root, "agent")); + try { + const controlDir = detachedControlDir(cwd); + fs.mkdirSync(path.dirname(controlDir), { recursive: true }); + fs.symlinkSync(outside, controlDir, "dir"); + assert.throws(() => requestDetachedCancel(cwd, "safe-run"), /private physical directory|EEXIST/); + assert.deepEqual(fs.readdirSync(outside), []); + } finally { + restore(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("detached control: registered Host CLI process groups are reaped by instance", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-control-reap-")); + const cwd = path.join(root, "project"); + fs.mkdirSync(cwd); + const restore = withAgentDir(path.join(root, "agent")); + const runId = "reap-run"; + const instanceId = "instance-a"; + const previousContext = { + cwd: process.env[DETACHED_CONTROL_CWD_ENV], + runId: process.env[DETACHED_CONTROL_RUN_ID_ENV], + instance: process.env[DETACHED_CONTROL_INSTANCE_ENV], + }; + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + detached: process.platform !== "win32", + stdio: "ignore", + }); + assert.ok(child.pid); + try { + process.env[DETACHED_CONTROL_CWD_ENV] = cwd; + process.env[DETACHED_CONTROL_RUN_ID_ENV] = runId; + process.env[DETACHED_CONTROL_INSTANCE_ENV] = instanceId; + registerDetachedProcessTreeFromEnv(child.pid!); + assert.deepEqual(terminateDetachedProcessTrees(cwd, runId, "wrong-instance"), []); + assert.deepEqual(terminateDetachedProcessTrees(cwd, runId, instanceId), [child.pid]); + + const deadline = Date.now() + 5_000; + while (probeProcess(child.pid!) !== "dead" && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + assert.equal(probeProcess(child.pid!), "dead"); + } finally { + try { child.kill("SIGKILL"); } catch { /* already reaped */ } + clearDetachedProcessRegistry(cwd, runId, instanceId); + if (previousContext.cwd === undefined) delete process.env[DETACHED_CONTROL_CWD_ENV]; + else process.env[DETACHED_CONTROL_CWD_ENV] = previousContext.cwd; + if (previousContext.runId === undefined) delete process.env[DETACHED_CONTROL_RUN_ID_ENV]; + else process.env[DETACHED_CONTROL_RUN_ID_ENV] = previousContext.runId; + if (previousContext.instance === undefined) delete process.env[DETACHED_CONTROL_INSTANCE_ENV]; + else process.env[DETACHED_CONTROL_INSTANCE_ENV] = previousContext.instance; + restore(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("probeProcess: EPERM is unknown while ESRCH is dead", () => { + const error = (code: string) => Object.assign(new Error(code), { code }); + assert.equal(probeProcess(123, () => { throw error("EPERM"); }), "unknown"); + assert.equal(probeProcess(123, () => { throw error("ESRCH"); }), "dead"); +}); diff --git a/packages/taskflow-core/test/run-retention.test.ts b/packages/taskflow-core/test/run-retention.test.ts new file mode 100644 index 00000000..01d68ab3 --- /dev/null +++ b/packages/taskflow-core/test/run-retention.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test } from "node:test"; +import type { RunState, Taskflow } from "../src/index.ts"; + +const flow: Taskflow = { + name: "retention", + phases: [{ id: "done", type: "script", run: "true", final: true }], +}; + +function state(cwd: string, runId: string, status: RunState["status"]): RunState { + const now = Date.now(); + return { + runId, + flowName: flow.name, + def: flow, + args: {}, + status, + phases: {}, + createdAt: now, + updatedAt: now, + cwd, + }; +} + +test("retention: paused and blocked history is bounded while running work is preserved", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-retention-")); + fs.mkdirSync(path.join(cwd, ".pi")); + try { + // Query-bearing imports create isolated module instances so the internal + // cleanup throttle can be deterministically exercised without a sleep. + const seed = await import(`../src/store.ts?retention-seed=${Date.now()}`) as typeof import("../src/store.ts"); + for (const [runId, status] of [ + ["paused-a", "paused"], + ["paused-b", "paused"], + ["blocked-a", "blocked"], + ["blocked-b", "blocked"], + ] as const) { + seed.saveRun(state(cwd, runId, status), { maxKeep: 0, maxAgeDays: 0 }); + } + + const cleanup = await import(`../src/store.ts?retention-cleanup=${Date.now()}`) as typeof import("../src/store.ts"); + cleanup.saveRun(state(cwd, "active", "running"), { maxKeep: 1, maxAgeDays: 0 }); + + const runs = cleanup.listRuns(cwd, 20); + assert.equal(runs.filter((run) => run.status !== "running").length, 1); + assert.ok(runs.some((run) => run.runId === "active" && run.status === "running")); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-core/test/runtime-terminal-persist.test.ts b/packages/taskflow-core/test/runtime-terminal-persist.test.ts new file mode 100644 index 00000000..db4bdae8 --- /dev/null +++ b/packages/taskflow-core/test/runtime-terminal-persist.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { executeTaskflow, type RunState, type RuntimeDeps, type Taskflow } from "../src/index.ts"; + +async function assertAtomicTerminalPersist(eventKernel: boolean): Promise { + const def: Taskflow = { + name: "terminal-atomic", + phases: [{ id: "work", type: "agent", agent: "executor", task: "work", final: true }], + }; + const now = Date.now(); + const state: RunState = { + runId: "terminal-atomic-run", + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: now, + updatedAt: now, + cwd: process.cwd(), + }; + const terminalSnapshots: RunState[] = []; + const deps: RuntimeDeps = { + cwd: process.cwd(), + eventKernel, + agents: [{ + name: "executor", + description: "test", + systemPrompt: "test", + source: "built-in", + filePath: "test", + }], + runTask: async () => ({ + agent: "executor", + task: "work", + exitCode: 0, + output: "durable result", + stderr: "", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 1 }, + stopReason: "end", + }), + persist: (snapshot) => { + if (snapshot.status !== "running") terminalSnapshots.push(structuredClone(snapshot)); + }, + }; + + const result = await executeTaskflow(state, deps); + assert.equal(result.finalOutput, "durable result"); + assert.equal(terminalSnapshots.length, 1); + assert.equal(terminalSnapshots[0]!.status, "completed"); + assert.equal(terminalSnapshots[0]!.finalOutput, "durable result"); + assert.equal(terminalSnapshots[0]!.outputSourcePhaseId, "work"); +} + +for (const eventKernel of [false, true]) { + test( + `runtime: first terminal persist already includes final output (${eventKernel ? "event kernel" : "imperative"})`, + () => assertAtomicTerminalPersist(eventKernel), + ); +} + +test("runtime: pre-execution failure persists its terminal diagnostic atomically", async () => { + const def: Taskflow = { + name: "terminal-invalid", + args: { target: { type: "string", required: true } }, + phases: [{ id: "work", type: "script", run: "true", final: true }], + }; + const now = Date.now(); + const state: RunState = { + runId: "terminal-invalid-run", + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: now, + updatedAt: now, + cwd: process.cwd(), + }; + let terminal: RunState | undefined; + const result = await executeTaskflow(state, { + cwd: process.cwd(), + agents: [], + persist: (snapshot) => { terminal = structuredClone(snapshot); }, + }); + assert.equal(result.ok, false); + assert.match(result.finalOutput, /invocation is invalid/); + assert.equal(terminal?.status, "failed"); + assert.equal(terminal?.finalOutput, result.finalOutput); +}); diff --git a/packages/taskflow-dsl/package.json b/packages/taskflow-dsl/package.json index 618e1e64..e09dd089 100644 --- a/packages/taskflow-dsl/package.json +++ b/packages/taskflow-dsl/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-dsl", - "version": "0.2.2", + "version": "0.2.3", "description": "Compile-time TypeScript DSL frontend for taskflow: erase .tf.ts runes to Taskflow JSON, then FlowIR via taskflow-core.", "keywords": [ "taskflow", diff --git a/packages/taskflow-hosts/package.json b/packages/taskflow-hosts/package.json index 1536fc42..bb48ff5e 100644 --- a/packages/taskflow-hosts/package.json +++ b/packages/taskflow-hosts/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-hosts", - "version": "0.2.2", + "version": "0.2.3", "description": "Shared host-runner collection for taskflow — the codex, claude, opencode, and grok SubagentRunner implementations + their argv builders and event-stream parsers. The per-host MCP servers, plugin scaffolds, and bins live in codex-taskflow / claude-taskflow / opencode-taskflow / grok-taskflow; this package holds just the runners so a new host can be added in one place.", "homepage": "https://github.com/heggria/taskflow#readme", "author": "heggria ", diff --git a/packages/taskflow-mcp-core/package.json b/packages/taskflow-mcp-core/package.json index ae9f27d4..d1a3ed23 100644 --- a/packages/taskflow-mcp-core/package.json +++ b/packages/taskflow-mcp-core/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-mcp-core", - "version": "0.2.2", + "version": "0.2.3", "description": "Host-neutral MCP server for taskflow: a dependency-free stdio JSON-RPC server exposing the taskflow_* tools, plus the DAG SVG/outline renderer. Shared by the codex/claude/opencode/grok adapters — depends only on taskflow-core.", "keywords": [ "taskflow", diff --git a/packages/taskflow-mcp-core/src/mcp/background.ts b/packages/taskflow-mcp-core/src/mcp/background.ts index 57f2798e..916c74f0 100644 --- a/packages/taskflow-mcp-core/src/mcp/background.ts +++ b/packages/taskflow-mcp-core/src/mcp/background.ts @@ -1,18 +1,28 @@ /** Host-neutral detached launch + lifecycle helpers for MCP adapters. */ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { clearDetachedCancelRequest, + clearDetachedProcessRegistry, + directoryIdentity, + DETACHED_CONTROL_VERSION, isProcessAlive, + killProcessTree, listRuns, loadRun, + probeProcess, readDetachedCancelRequest, + readDetachedProcessRegistry, requestDetachedCancel, saveRun, + terminateDetachedProcessTrees, + type AgentConfig, + type AgentScope, type DetachedCancelRequest, type RunState, } from "taskflow-core"; @@ -28,11 +38,17 @@ export interface BackgroundLaunchOptions { runner: DetachedRunnerBinding; incremental?: boolean; reusedSavedName?: string; + agents: AgentConfig[]; + globalThinking?: string; + agentScope: AgentScope; + maxKeptRuns: number; + maxRunAgeDays: number; } const STARTING_GRACE_MS = 5_000; const WAIT_POLL_MS = 100; -const BACKGROUND_SCAN_LIMIT = 1_000; +const HEARTBEAT_STALE_MS = 30_000; +const MAX_PRESENTED_OUTPUT_CHARS = 50_000; export const BACKGROUND_RUN_WARNING_THRESHOLD = 5; @@ -46,20 +62,27 @@ export interface BackgroundRunList { function markDetachedFailure(state: RunState, message: string): RunState { state.status = "failed"; + if (!state.phases || typeof state.phases !== "object") state.phases = {}; state.phases["__detach__"] = { id: "__detach__", status: "failed", endedAt: Date.now(), error: message.slice(0, 2_000), }; - saveRun(state); + saveRun(state, state.detachedRetention); return state; } function markDetachedExit(cwd: string, runId: string, pid: number, message: string): void { try { const state = loadRun(cwd, runId); - if (state?.status === "running" && state.pid === pid) markDetachedFailure(state, message); + if (state?.status === "running" && state.pid === pid) { + if (state.detachedInstanceId) { + terminateDetachedProcessTrees(state.cwd, state.runId, state.detachedInstanceId); + clearDetachedProcessRegistry(state.cwd, state.runId, state.detachedInstanceId); + } + markDetachedFailure(state, message); + } } catch { /* best-effort crash guard */ } @@ -82,38 +105,58 @@ export function launchMcpBackgroundRun(options: BackgroundLaunchOptions): { pid: clearDetachedCancelRequest(state.cwd, state.runId); state.detached = true; state.detachedStartedAt = Date.now(); - saveRun(state); - - const tempDir = mkdtempSync(join(tmpdir(), "taskflow-detach-")); - chmodSync(tempDir, 0o700); - const contextPath = join(tempDir, "context.json"); - const startPath = join(tempDir, "start"); - writeFileSync(contextPath, JSON.stringify({ - runId: state.runId, - defName: state.flowName, - args: state.args, - cwd: state.cwd, - runnerModule: options.runner.module, - runnerExport: options.runner.exportName, - waitForStart: true, - incremental: options.incremental === true, - reusedSavedName: options.reusedSavedName, - }), { encoding: "utf8", flag: "wx", mode: 0o600 }); + state.detachedControlVersion = DETACHED_CONTROL_VERSION; + state.detachedInstanceId = randomUUID(); + state.detachedRetention = { + maxKeep: options.maxKeptRuns, + maxAgeDays: options.maxRunAgeDays, + }; + let tempDir: string | undefined; + let child: ChildProcess | undefined; try { + saveRun(state, { maxKeep: options.maxKeptRuns, maxAgeDays: options.maxRunAgeDays }); + tempDir = mkdtempSync(join(tmpdir(), "taskflow-detach-")); + chmodSync(tempDir, 0o700); + const contextPath = join(tempDir, "context.json"); + const startPath = join(tempDir, "start"); + writeFileSync(contextPath, JSON.stringify({ + runId: state.runId, + defName: state.flowName, + args: state.args, + cwd: state.cwd, + runnerModule: options.runner.module, + runnerExport: options.runner.exportName, + waitForStart: true, + incremental: options.incremental === true, + reusedSavedName: options.reusedSavedName, + agents: options.agents, + globalThinking: options.globalThinking, + agentScope: options.agentScope, + maxKeptRuns: options.maxKeptRuns, + maxRunAgeDays: options.maxRunAgeDays, + detachedInstanceId: state.detachedInstanceId, + }), { encoding: "utf8", flag: "wx", mode: 0o600 }); const runnerScript = fileURLToPath(import.meta.resolve("taskflow-core/detached-runner")); - const child = spawn( + child = spawn( process.execPath, [...detachedNodeArgs(runnerScript), runnerScript, contextPath], - { cwd: state.cwd, detached: true, stdio: "ignore" }, + { + cwd: state.cwd, + detached: true, + stdio: "ignore", + env: { ...process.env, TASKFLOW_DETACHED_RUNNER: "1" }, + }, ); const pid = child.pid; if (typeof pid !== "number") throw new Error("Detached runner did not report a pid"); child.once("error", (error) => { markDetachedExit(state.cwd, state.runId, pid, `Failed to spawn detached runner: ${error.message}`); + if (tempDir) try { rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort */ } }); child.once("exit", (code, signal) => { + if (tempDir) try { rmSync(tempDir, { recursive: true, force: true }); } catch { /* child consumed it */ } if (code === 0) return; markDetachedExit( state.cwd, @@ -124,30 +167,71 @@ export function launchMcpBackgroundRun(options: BackgroundLaunchOptions): { pid: }); state.pid = pid; - saveRun(state); + saveRun(state, { maxKeep: options.maxKeptRuns, maxAgeDays: options.maxRunAgeDays }); writeFileSync(startPath, "start\n", { encoding: "utf8", flag: "wx", mode: 0o600 }); child.unref(); return { pid }; } catch (error) { - try { rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort */ } + if (child?.pid) killProcessTree(child.pid, "SIGKILL", child); + if (tempDir) try { rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort */ } const message = error instanceof Error ? error.message : String(error); - markDetachedFailure(state, `Failed to launch detached runner: ${message}`); + try { markDetachedFailure(state, `Failed to launch detached runner: ${message}`); } catch { /* preserve launch cause */ } throw error; } } -/** Refresh a detached run and terminalize an orphaned process. */ -export function refreshDetachedRun(cwd: string, runId: string): RunState | null { - const state = loadRun(cwd, runId); - if (!state || state.status !== "running" || !state.detached) return state; +function sameInvocationRoot(state: RunState, cwd: string): boolean { + const current = directoryIdentity(cwd); + const owner = state.invocationRootSnapshot ?? directoryIdentity(state.cwd); + return Boolean( + current && owner && + current.canonicalPath === owner.canonicalPath && + current.device === owner.device && + current.inode === owner.inode, + ); +} + +function isStructurallyUsableRun(state: RunState): boolean { + return Boolean( + state && typeof state === "object" && + typeof state.runId === "string" && typeof state.cwd === "string" && + state.def && Array.isArray(state.def.phases) && + state.phases && typeof state.phases === "object" && + ["running", "completed", "failed", "paused", "blocked"].includes(state.status), + ); +} + +function refreshDetachedState(cwd: string, state: RunState): RunState | null { + if (!isStructurallyUsableRun(state) || !sameInvocationRoot(state, cwd)) return null; + if (state.status !== "running" || !state.detached) return state; - const cancel = readDetachedCancelRequest(cwd, runId); + const cancel = readDetachedCancelRequest(state.cwd, state.runId); if (typeof state.pid === "number") { - if (isProcessAlive(state.pid)) return state; - const fresh = loadRun(cwd, runId); + const liveness = probeProcess(state.pid); + if (state.detachedControlVersion === DETACHED_CONTROL_VERSION && state.detachedInstanceId) { + const registry = readDetachedProcessRegistry(state.cwd, state.runId); + const heartbeatFresh = Boolean( + registry && registry.instanceId === state.detachedInstanceId && + registry.ownerPid === state.pid && Date.now() - registry.heartbeatAt <= HEARTBEAT_STALE_MS, + ); + if (liveness !== "dead" && heartbeatFresh) return state; + if ( + liveness !== "dead" && !registry && + Date.now() - (state.detachedStartedAt ?? state.createdAt) <= STARTING_GRACE_MS + ) return state; + // For current-protocol workers the private heartbeat is authoritative. + // EPERM from signal 0 must not keep a run phantom-running forever once + // the startup grace has elapsed without a registry heartbeat. + terminateDetachedProcessTrees(state.cwd, state.runId, state.detachedInstanceId); + clearDetachedProcessRegistry(state.cwd, state.runId, state.detachedInstanceId); + } else if (isProcessAlive(state.pid)) { + return state; + } + const fresh = loadRun(cwd, state.runId); if (!fresh || fresh.status !== "running" || fresh.pid !== state.pid) return fresh; if (cancel) { fresh.status = "paused"; + fresh.detachedCancel = cancel; fresh.phases["__detach__"] = { id: "__detach__", status: "skipped", @@ -155,7 +239,7 @@ export function refreshDetachedRun(cwd: string, runId: string): RunState | null warnings: ["Cancellation requested; detached process exited before persisting its normal paused state."], }; saveRun(fresh); - clearDetachedCancelRequest(cwd, runId); + clearDetachedCancelRequest(fresh.cwd, fresh.runId); return fresh; } return markDetachedFailure(fresh, "Detached runner process exited before persisting a terminal state."); @@ -165,6 +249,12 @@ export function refreshDetachedRun(cwd: string, runId: string): RunState | null return markDetachedFailure(state, "Detached runner never persisted a process id."); } +/** Refresh a detached run and terminalize an orphaned process. */ +export function refreshDetachedRun(cwd: string, runId: string): RunState | null { + const state = loadRun(cwd, runId); + return state ? refreshDetachedState(cwd, state) : null; +} + export function cancelMcpBackgroundRun(cwd: string, runId: string, reason?: string): DetachedCancelRequest { return requestDetachedCancel(cwd, runId, reason); } @@ -194,9 +284,10 @@ export function listMcpBackgroundRuns( limit: number, filter: BackgroundRunFilter = "all", ): BackgroundRunList { - const all = listRuns(cwd, BACKGROUND_SCAN_LIMIT) - .filter((run) => run.detached) - .map((run) => refreshDetachedRun(cwd, run.runId) ?? run); + const all = listRuns(cwd, Number.MAX_SAFE_INTEGER) + .filter((run) => isStructurallyUsableRun(run) && run.detached && sameInvocationRoot(run, cwd)) + .map((run) => refreshDetachedState(cwd, run)) + .filter((run): run is RunState => run !== null); const activeCount = all.filter((run) => run.status === "running").length; const filtered = filter === "all" ? all @@ -228,13 +319,19 @@ function statusGlyph(status: RunState["status"]): string { export function formatBackgroundRun(state: RunState, includeOutput: boolean): string { const progress = phaseProgress(state); const pid = typeof state.pid === "number" ? ` · pid ${state.pid}` : ""; - const first = `${statusGlyph(state.status)} ${state.status} · ${state.flowName} · ${progress.done}/${progress.total} phases${pid} · run ${state.runId}`; + const host = state.host ? ` · ${state.host}` : ""; + const first = `${statusGlyph(state.status)} ${state.status} · ${state.flowName} · ${progress.done}/${progress.total} phases${pid}${host} · run ${state.runId} · cwd ${state.cwd}`; if (!includeOutput || state.status === "running") { const cancel = readDetachedCancelRequest(state.cwd, state.runId); return cancel ? `${first} · cancellation requested` : first; } const source = state.outputSourcePhaseId ? `--- ${state.outputSourcePhaseId} ---\n` : ""; - if (state.finalOutput) return `${first}\n\n${source}${state.finalOutput}`; + if (state.finalOutput !== undefined) { + const truncated = state.finalOutput.length > MAX_PRESENTED_OUTPUT_CHARS + ? `${state.finalOutput.slice(0, MAX_PRESENTED_OUTPUT_CHARS)}\n\n… output truncated; use taskflow_peek for targeted inspection.` + : state.finalOutput; + return `${first}\n\n${source}${truncated}`; + } const failure = Object.values(state.phases).find((phase) => phase.status === "failed" && phase.error)?.error; return failure ? `${first}\n\n${failure}` : first; } diff --git a/packages/taskflow-mcp-core/src/mcp/server.ts b/packages/taskflow-mcp-core/src/mcp/server.ts index a3da375c..6403e22b 100644 --- a/packages/taskflow-mcp-core/src/mcp/server.ts +++ b/packages/taskflow-mcp-core/src/mcp/server.ts @@ -83,6 +83,8 @@ import { type ReplayReport, type ReplayOverrides, reconcileResolveOnlyWorkspace, + clearDetachedCancelRequest, + DETACHED_CONTROL_VERSION, WORKSPACE_RECONCILE_ACKNOWLEDGEMENT, workspaceReconcileAllowedFromEnv, } from "taskflow-core"; @@ -814,14 +816,20 @@ export function makeToolHandlers( // the same lookup the pi adapter does; without it every phase fails with // "Model metadata for {{fast}} not found". const settings = readSubagentSettings(); - const { agents } = discoverAgents(cwd, "both", settings.modelRoles, settings.taskflow); + const agentScope = def.agentScope ?? "both"; + const { agents } = discoverAgents(cwd, agentScope, settings.modelRoles, settings.taskflow); const deps: RuntimeDeps = { cwd, agents, + globalThinking: settings.globalThinking, runTask: runner.runTask, signal: context?.signal, usageAccounting, cwdBridgeMode: cwdBridgeModeFromEnv(), + loadFlow: (name: string) => { + const loaded = getFlowDiagnosed(cwd, name); + return loaded.ok ? loaded.value.def : undefined; + }, }; const state = mkRunState(def, resolvedArgs, cwd, host); if (args.mode === "background") { @@ -831,26 +839,38 @@ export function makeToolHandlers( true, ); } + let pid: number; try { - const { pid } = launchMcpBackgroundRun({ + ({ pid } = launchMcpBackgroundRun({ state, runner: opts.detachedRunner, incremental: args.incremental === true, reusedSavedName: args.reusedFromSearch === true ? reusedSavedName : undefined, - }); - const { activeCount } = listMcpBackgroundRuns(cwd, 0); - const contentionWarning = activeCount > BACKGROUND_RUN_WARNING_THRESHOLD - ? `\n\nWarning: ${activeCount} background runs are active in this project. Taskflow does not provide a global cross-host concurrency or budget coordinator; use taskflow_runs list/cancel if this is unintentional.` - : ""; - return textContent( - `↻ taskflow started in background\n\n${state.flowName} · pid ${pid} · run ${state.runId}\nUse taskflow_runs with action status, wait, or cancel.${contentionWarning}`, - ); + agents, + globalThinking: settings.globalThinking, + agentScope, + maxKeptRuns: settings.taskflow.maxKeptRuns, + maxRunAgeDays: settings.taskflow.maxRunAgeDays, + })); } catch (error) { return textContent( `Failed to start background taskflow: ${error instanceof Error ? error.message : String(error)}`, true, ); } + + let contentionWarning = ""; + try { + const { activeCount } = listMcpBackgroundRuns(cwd, 0); + contentionWarning = activeCount > BACKGROUND_RUN_WARNING_THRESHOLD + ? `\n\nWarning: ${activeCount} background runs are active in this project. Taskflow does not provide a global cross-host concurrency or budget coordinator; use taskflow_runs list/cancel if this is unintentional.` + : ""; + } catch (error) { + contentionWarning = `\n\nWarning: the run started successfully, but background contention could not be inspected: ${error instanceof Error ? error.message : String(error)}`; + } + return textContent( + `↻ taskflow started in background\n\n${state.flowName} · pid ${pid} · run ${state.runId}\nUse taskflow_runs with action status, wait, or cancel.${contentionWarning}`, + ); } // Deterministic-replay trace (best-effort, fail-open). deps.trace = new FileTraceSink(traceFilePath(runsDir(cwd), state.flowName, state.runId)); @@ -935,7 +955,18 @@ export function makeToolHandlers( } if (action === "cancel") { if (state.status !== "running") return textContent(`Run is already ${state.status}.\n${formatBackgroundRun(state, true)}`); + if (state.detachedControlVersion !== DETACHED_CONTROL_VERSION) { + return textContent( + `Run \"${runId}\" was created by a legacy detached worker that does not support durable cross-request cancellation. Wait for it to finish or stop that worker through its original host.`, + true, + ); + } cancelMcpBackgroundRun(cwd, runId, typeof args.reason === "string" ? args.reason : undefined); + const afterRequest = refreshDetachedRun(cwd, runId); + if (afterRequest && afterRequest.status !== "running") { + clearDetachedCancelRequest(afterRequest.cwd, afterRequest.runId); + return textContent(`Run became ${afterRequest.status} before cancellation was delivered.\n${formatBackgroundRun(afterRequest, true)}`); + } return textContent(`Cancellation requested.\n${formatBackgroundRun(state, false)}`); } return textContent(`Unknown taskflow_runs action: ${action}`, true); diff --git a/packages/taskflow-mcp-core/test/background-runs.test.ts b/packages/taskflow-mcp-core/test/background-runs.test.ts index 5d3f622f..4533489a 100644 --- a/packages/taskflow-mcp-core/test/background-runs.test.ts +++ b/packages/taskflow-mcp-core/test/background-runs.test.ts @@ -4,7 +4,16 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { pathToFileURL } from "node:url"; -import { loadRun, newRunId, saveRun, type RunState, type SubagentRunner, type Taskflow } from "taskflow-core"; +import { + DETACHED_CONTROL_VERSION, + loadRun, + newRunId, + probeProcess, + saveRun, + type RunState, + type SubagentRunner, + type Taskflow, +} from "taskflow-core"; import { makeToolHandlers } from "taskflow-mcp-core/server"; interface TextResult { @@ -28,6 +37,15 @@ function runIdFrom(result: TextResult): string { return match[1]!; } +function usePrivateAgentDir(cwd: string): () => void { + const previous = process.env.TASKFLOW_AGENT_DIR; + process.env.TASKFLOW_AGENT_DIR = path.join(cwd, ".agent"); + return () => { + if (previous === undefined) delete process.env.TASKFLOW_AGENT_DIR; + else process.env.TASKFLOW_AGENT_DIR = previous; + }; +} + function inlineAgentFlow(name: string): Taskflow { return { name, @@ -55,6 +73,7 @@ function runningBackgroundState(cwd: string, name: string): RunState { test("mcp background: run returns immediately and wait returns durable final output", async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-background-")); + const restoreAgentDir = usePrivateAgentDir(cwd); try { const tools = makeToolHandlers(cwd, unusedForegroundRunner, { host: "test", @@ -78,12 +97,14 @@ test("mcp background: run returns immediately and wait returns durable final out const listed = await tools.taskflow_runs({ action: "list" }) as TextResult; assert.match(listed.content[0]!.text, new RegExp(runId)); } finally { + restoreAgentDir(); fs.rmSync(cwd, { recursive: true, force: true }); } }); test("mcp background: cancel survives request boundaries and pauses the run", async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-cancel-")); + const restoreAgentDir = usePrivateAgentDir(cwd); try { const tools = makeToolHandlers(cwd, unusedForegroundRunner, { host: "test", @@ -101,12 +122,14 @@ test("mcp background: cancel survives request boundaries and pauses the run", as assert.match(waited.content[0]!.text, /Ⅱ paused/); assert.equal(loadRun(cwd, runId)?.status, "paused"); } finally { + restoreAgentDir(); fs.rmSync(cwd, { recursive: true, force: true }); } }); test("mcp background: roster filters active runs and warns about uncoordinated contention", async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-roster-")); + const restoreAgentDir = usePrivateAgentDir(cwd); try { for (let index = 0; index < 5; index++) { saveRun(runningBackgroundState(cwd, `already-running-${index}`)); @@ -128,6 +151,187 @@ test("mcp background: roster filters active runs and warns about uncoordinated c await tools.taskflow_runs({ action: "cancel", runId, reason: "test cleanup" }); await tools.taskflow_runs({ action: "wait", runId, timeoutMs: 5_000 }); } finally { + restoreAgentDir(); + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("mcp background: malformed historical state cannot turn a successful launch into start failed", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-corrupt-roster-")); + const restoreAgentDir = usePrivateAgentDir(cwd); + try { + const malformed = runningBackgroundState(cwd, "malformed-old-run"); + malformed.def = {} as Taskflow; + saveRun(malformed); + + const tools = makeToolHandlers(cwd, unusedForegroundRunner, { + host: "test", + detachedRunner: { module: fixtureModule(), exportName: "instantRunner" }, + }); + const started = await tools.taskflow_run({ define: inlineAgentFlow("survives-roster"), mode: "background" }) as TextResult; + assert.equal(started.isError, false, started.content[0]?.text); + assert.match(started.content[0]!.text, /started in background/); + const runId = runIdFrom(started); + const waited = await tools.taskflow_runs({ action: "wait", runId, timeoutMs: 5_000 }) as TextResult; + assert.match(waited.content[0]!.text, /✓ completed/); + } finally { + restoreAgentDir(); + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("mcp background: legacy detached workers fail closed for cancel", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-legacy-cancel-")); + const restoreAgentDir = usePrivateAgentDir(cwd); + try { + const legacy = runningBackgroundState(cwd, "legacy-running"); + saveRun(legacy); + const tools = makeToolHandlers(cwd, unusedForegroundRunner); + const cancelled = await tools.taskflow_runs({ action: "cancel", runId: legacy.runId }) as TextResult; + assert.equal(cancelled.isError, true); + assert.match(cancelled.content[0]!.text, /legacy detached worker/); + } finally { + restoreAgentDir(); + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("mcp background: current worker without a heartbeat leaves running after startup grace", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-missing-heartbeat-")); + const restoreAgentDir = usePrivateAgentDir(cwd); + try { + const state = runningBackgroundState(cwd, "missing-heartbeat"); + state.detachedControlVersion = DETACHED_CONTROL_VERSION; + state.detachedInstanceId = "missing-heartbeat-instance"; + state.detachedStartedAt = Date.now() - 10_000; + state.pid = 2_147_483_647; + saveRun(state); + + const tools = makeToolHandlers(cwd, unusedForegroundRunner); + const status = await tools.taskflow_runs({ action: "status", runId: state.runId }) as TextResult; + assert.equal(status.isError, false); + assert.match(status.content[0]!.text, /failed/); + assert.equal(loadRun(cwd, state.runId)?.status, "failed"); + } finally { + restoreAgentDir(); + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("mcp background: sibling worktrees sharing an ancestor .pi remain isolated", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-worktrees-")); + const cwdA = path.join(root, "worktree-a"); + const cwdB = path.join(root, "worktree-b"); + fs.mkdirSync(path.join(root, ".pi")); + fs.mkdirSync(cwdA); + fs.mkdirSync(cwdB); + const restoreAgentDir = usePrivateAgentDir(root); + try { + const ownedByA = runningBackgroundState(cwdA, "owned-by-a"); + saveRun(ownedByA); + const toolsA = makeToolHandlers(cwdA, unusedForegroundRunner); + const toolsB = makeToolHandlers(cwdB, unusedForegroundRunner); + const listA = await toolsA.taskflow_runs({ action: "list" }) as TextResult; + assert.match(listA.content[0]!.text, new RegExp(ownedByA.runId)); + const listB = await toolsB.taskflow_runs({ action: "list" }) as TextResult; + assert.doesNotMatch(listB.content[0]!.text, new RegExp(ownedByA.runId)); + const statusB = await toolsB.taskflow_runs({ action: "status", runId: ownedByA.runId }) as TextResult; + assert.equal(statusB.isError, true); + assert.match(statusB.content[0]!.text, /not found/); + } finally { + restoreAgentDir(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("mcp background: foreground and background share agent scope and thinking", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-parity-")); + const restoreAgentDir = usePrivateAgentDir(cwd); + try { + fs.mkdirSync(path.join(cwd, ".pi", "agents"), { recursive: true }); + fs.writeFileSync( + path.join(cwd, ".pi", "agents", "project-only.md"), + "---\nname: project-only\ndescription: parity fixture\n---\nPROJECT AGENT MARKER\n", + ); + fs.mkdirSync(path.join(cwd, ".agent"), { recursive: true }); + fs.writeFileSync( + path.join(cwd, ".agent", "settings.json"), + JSON.stringify({ subagents: { globalThinking: "high" }, taskflow: { builtInAgents: false } }), + ); + const foregroundRunner: SubagentRunner = { + usageAccounting: "available", + runTask: async (_cwd, agents, agent, _task, _options, globalThinking) => { + const agentList = agents as Array<{ name: string; systemPrompt: string }>; + return { + agent, + task: "snapshot", + exitCode: 0, + output: `${agentList.find((candidate) => candidate.name === agent)?.systemPrompt ?? "missing-agent"}|${globalThinking ?? "no-thinking"}`, + stderr: "", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 1 }, + stopReason: "end", + }; + }, + }; + const flow: Taskflow = { + name: "mode-parity", + agentScope: "project", + phases: [{ id: "work", type: "agent", agent: "project-only", task: "snapshot", final: true }], + }; + const tools = makeToolHandlers(cwd, foregroundRunner, { + host: "test", + detachedRunner: { module: fixtureModule(), exportName: "snapshotRunner" }, + }); + const foreground = await tools.taskflow_run({ define: flow }) as TextResult; + assert.match(foreground.content[0]!.text, /PROJECT AGENT MARKER\|high/); + const started = await tools.taskflow_run({ define: flow, mode: "background" }) as TextResult; + const waited = await tools.taskflow_runs({ action: "wait", runId: runIdFrom(started), timeoutMs: 5_000 }) as TextResult; + assert.match(waited.content[0]!.text, /PROJECT AGENT MARKER\|high/); + } finally { + restoreAgentDir(); + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("mcp background: hard-killed worker reaps its registered Host CLI tree", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-hard-kill-")); + const restoreAgentDir = usePrivateAgentDir(cwd); + const heartbeat = path.join(cwd, "host-heartbeat"); + try { + const tools = makeToolHandlers(cwd, unusedForegroundRunner, { + host: "test", + detachedRunner: { module: fixtureModule(), exportName: "orphaningRunner" }, + }); + const flow: Taskflow = { + name: "hard-kill", + phases: [{ id: "work", type: "agent", agent: "executor", task: heartbeat, final: true }], + }; + const started = await tools.taskflow_run({ define: flow, mode: "background" }) as TextResult; + const runId = runIdFrom(started); + const deadline = Date.now() + 5_000; + while ((!fs.existsSync(`${heartbeat}.pid`) || !fs.existsSync(heartbeat)) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + assert.equal(fs.existsSync(`${heartbeat}.pid`), true, "fixture Host CLI started"); + assert.equal(fs.existsSync(heartbeat), true, "fixture Host CLI mutated before worker death"); + const hostPid = Number(fs.readFileSync(`${heartbeat}.pid`, "utf8")); + const workerPid = loadRun(cwd, runId)?.pid; + assert.ok(workerPid); + process.kill(workerPid, "SIGKILL"); + + while (loadRun(cwd, runId)?.status === "running" && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + assert.equal(loadRun(cwd, runId)?.status, "failed"); + while (probeProcess(hostPid) !== "dead" && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + assert.equal(probeProcess(hostPid), "dead", "registered Host CLI process tree was reaped"); + const sizeAfterReap = fs.statSync(heartbeat).size; + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal(fs.statSync(heartbeat).size, sizeAfterReap, "workspace mutation stopped after terminal failure"); + } finally { + restoreAgentDir(); fs.rmSync(cwd, { recursive: true, force: true }); } }); diff --git a/packages/taskflow-mcp-core/test/fixtures/background-runner.mjs b/packages/taskflow-mcp-core/test/fixtures/background-runner.mjs index f7bfcc47..b2adf7a8 100644 --- a/packages/taskflow-mcp-core/test/fixtures/background-runner.mjs +++ b/packages/taskflow-mcp-core/test/fixtures/background-runner.mjs @@ -1,3 +1,7 @@ +import { spawn } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { registerProcessTree } from "taskflow-core"; + const usage = { input: 3, output: 2, @@ -21,6 +25,19 @@ export const instantRunner = { }), }; +export const snapshotRunner = { + usageAccounting: "full", + runTask: async (_cwd, agents, agent, _task, _options, globalThinking) => ({ + agent, + task: "snapshot", + exitCode: 0, + output: `${agents.find((candidate) => candidate.name === agent)?.systemPrompt ?? "missing-agent"}|${globalThinking ?? "no-thinking"}`, + stderr: "", + usage, + stopReason: "end", + }), +}; + export const cancellableRunner = { usageAccounting: "full", runTask: async (_cwd, _agents, agent, task, options) => @@ -51,3 +68,22 @@ export const cancellableRunner = { }, { once: true }); }), }; + +export const orphaningRunner = { + usageAccounting: "full", + runTask: async (_cwd, _agents, agent, task) => + await new Promise(() => { + const child = spawn(process.execPath, [ + "-e", + "const fs=require('node:fs');const p=process.argv[1];setInterval(()=>fs.appendFileSync(p,'x'),25)", + task, + ], { + detached: process.platform !== "win32", + stdio: "ignore", + }); + if (!child.pid) throw new Error("fixture child has no pid"); + registerProcessTree(child.pid); + writeFileSync(`${task}.pid`, String(child.pid)); + void agent; + }), +}; diff --git a/website/app/[lang]/page.tsx b/website/app/[lang]/page.tsx index 441fa565..443fd571 100644 --- a/website/app/[lang]/page.tsx +++ b/website/app/[lang]/page.tsx @@ -341,7 +341,7 @@ export default async function HomePage({ "@context": "https://schema.org", "@type": "SoftwareApplication", name: "taskflow", - softwareVersion: "0.2.2", + softwareVersion: "0.2.3", description: t.hero.sub, applicationCategory: "DeveloperApplication", operatingSystem: "Any", diff --git a/website/content/docs/en/blog/orchestrate-codex-subagents.mdx b/website/content/docs/en/blog/orchestrate-codex-subagents.mdx index 79ae5859..4d31bf02 100644 --- a/website/content/docs/en/blog/orchestrate-codex-subagents.mdx +++ b/website/content/docs/en/blog/orchestrate-codex-subagents.mdx @@ -116,9 +116,9 @@ The 50 per-file transcripts never enter your Codex context. Only the merged repo | **Quality gate** | eyeball it | `gate` phase halts on `BLOCK` | | **Context** | every transcript returns | only the final report returns | -## Inspect or repeat across sessions +## Inspect or resume across sessions -Codex MCP does not expose a `taskflow_resume` tool. Keep the reported run id to inspect the stored run: +Keep the reported run id to inspect the stored run: Call `taskflow_peek` with structured arguments: @@ -126,7 +126,7 @@ Call `taskflow_peek` with structured arguments: { "runId": "" } ``` -To execute again, call `taskflow_run` again. Cross-run reuse is opt-in with `cache.scope: "cross-run"`; without it, a new run executes the phases again. Exact paused-run resume by run id is currently Pi-only (`/tf resume`). See [incremental recompute](/en/docs/compiler-runtime/incremental-recompute). +To continue a `failed` or `paused` run, call `taskflow_resume` with its `runId`. Resume forks immutable history into a new child run and may override one phase. To start an unrelated fresh execution, call `taskflow_run` again; cross-run reuse remains opt-in with `cache.scope: "cross-run"`. See [incremental recompute](/en/docs/compiler-runtime/incremental-recompute). ## When to stay ad-hoc diff --git a/website/content/docs/en/compiler-runtime/background-runs.mdx b/website/content/docs/en/compiler-runtime/background-runs.mdx index b9b5c0b6..8c2995e6 100644 --- a/website/content/docs/en/compiler-runtime/background-runs.mdx +++ b/website/content/docs/en/compiler-runtime/background-runs.mdx @@ -49,11 +49,16 @@ That `runId` is your handle. The `pid` is the OS process id of the detached chil ### On MCP hosts -Codex, Claude Code, OpenCode, and Grok Build do **not** expose `detach` on -`taskflow_run`; detached execution is currently Pi-only. MCP runs are -synchronous. When an MCP client cancels a request, taskflow propagates that -cancellation to the DAG and active subagent instead of continuing as an -untracked background run. +Codex, Claude Code, OpenCode, and Grok Build use `mode: "background"` on +`taskflow_run`: + +```json +{ "name": "audit", "args": { "dir": "src" }, "mode": "background" } +``` + +The call returns a durable `runId` immediately. Use `taskflow_runs` with +`action: "list"`, `"status"`, `"wait"`, or `"cancel"` to manage it. Foreground +remains the default when `mode` is omitted. ## What happens under the hood @@ -68,14 +73,14 @@ untracked background run. **Return immediately.** The host persists the initial run state (with `detached: true` and the child's `pid`), then returns the `runId` to the caller. The parent conversation is free. - **The child runs the DAG.** The detached runner reads the context file, re-discovers agents, dynamically imports the host's runner module, and calls `executeTaskflow`. Each phase runs exactly as it would inline — subagent calls, caching, retry, persistence. + **The child runs the DAG.** The detached runner reads the context file, uses the parent invocation's frozen agent/settings snapshot, dynamically imports the host's runner module, and calls `executeTaskflow`. Each phase runs exactly as it would inline — subagent calls, caching, retry, persistence. **Persist terminal state.** When the flow completes (or fails), the child persists the final `RunState` to disk and exits. The parent can poll the store at any time to see the result. -The child process is genuinely independent. It has its own event loop, its own stderr, and its own lifecycle. Killing the parent does not kill the child — that is by design. The child was spawned with `detached: true` specifically so it survives the parent session. +The child process is genuinely independent and normally survives its parent session. A private heartbeat and instance-specific process registry let a later status check distinguish a live worker from PID reuse and reap any registered Host CLI process groups if the worker is killed. ## Polling and monitoring @@ -109,7 +114,9 @@ If a detached run fails (a transient error exhausted its retries, a gate blocked /tf resume ``` -Resume runs inline by default. If you want the resumed run to also be detached, pass `--detach` again. +Pi resumes inline by default and may pass `--detach` again. MCP uses +`taskflow_resume`, which currently runs in the foreground and forks immutable +history into a new child run. ## Approval phases in detached mode @@ -151,21 +158,21 @@ The detached runner dynamically imports the host's runner module (e.g., `piSubag { "id": "__detach__", "status": "failed", - "error": "Runner module failed to load: 'pi-taskflow/dist/runner.js' (export 'piSubagentRunner'). See detached-runner stderr for the import error." + "error": "Runner module failed to load: 'pi-taskflow/dist/runner.js' (export 'piSubagentRunner'): Cannot find module ..." } ``` -This fail-fast behavior prevents the runner from limping on and failing every phase with the generic "No subagent runner injected" error. The real cause (a bad module path, a missing export) is preserved in the `__detach__` phase and in the child's stderr. +This fail-fast behavior prevents the runner from limping on and failing every phase with the generic "No subagent runner injected" error. The real import/export cause is preserved directly in the `__detach__` phase. ### Early exit guard -The parent process captures the child's stderr and registers an `exit` handler. If the child exits with a non-zero code before reaching a terminal state, the parent marks the run failed and writes a `__detach__` phase with the captured stderr: +The parent registers an `exit` handler. If the child exits with a non-zero code before reaching a terminal state, it marks the run failed and writes a `__detach__` phase. Pi also captures bounded child stderr; MCP records the exit code/signal and relies on the worker's own persisted diagnostic when available: ```json { "id": "__detach__", "status": "failed", - "error": "Detached runner exited with code 1: [stderr content]" + "error": "Detached runner exited before completing (code 1, signal none)." } ``` @@ -245,7 +252,7 @@ You're in the middle of a conversation, and you realize you need to run a slow f **No live progress.** The parent conversation does not receive live updates. You poll the run state instead. If you need streaming progress, run the flow inline. -**Independent lifecycle.** The child process survives the parent. If you kill your terminal, the flow keeps running. To stop a detached run, kill the child process by its `pid` (e.g., `kill `). There is no built-in cancel command. +**Independent lifecycle.** The child process survives a normal parent exit. On MCP hosts, stop it with `taskflow_runs { action: "cancel", runId }`; cancellation is durable across MCP server restarts. Pi's detached command does not yet expose that management tool, so Pi operators still use the recorded PID when manual termination is required. **Runner module required.** The child process must be able to dynamically import the host's runner module. If the module path is wrong or the module is missing, the run fails fast with a `__detach__` phase. This is rarely an issue in practice — the host serializes the correct path automatically. @@ -280,9 +287,7 @@ When a detached run fails, the run state tells you why. Check the `__detach__` p /tf peek __detach__ ``` -If the `__detach__` phase exists, it contains the crash reason — a runner module import error, a spawn failure, or the child's stderr. If the `__detach__` phase does not exist, the failure was a normal flow failure (a gate blocked, a phase exhausted its retries, a budget exceeded). Check the other phases to find the root cause. - -You can also inspect the child's stderr directly if you have access to the parent's logs. The parent captures stderr and includes it in the `__detach__` phase on early exit. +If the `__detach__` phase exists, it contains the crash reason — a runner module import error, a spawn failure, or early-exit metadata (plus bounded stderr on Pi). If it does not exist, the failure was a normal flow failure. Check the other phases to find the root cause. ## Next diff --git a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx index e7c761e6..1b6e4e26 100644 --- a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx @@ -8,7 +8,7 @@ description: Compile-time .tf.ts authoring — erase runes to Taskflow JSON, the S4 adds a **compile-time** TypeScript frontend. You author `*.tf.ts` with **runes** (`agent`, `map`, `race`, …). A CLI erases them to ordinary Taskflow JSON. Hosts still run **JSON** via `taskflow_run` / `/tf run` — there is **no** interpret path and **no** host auto-build of `.tf.ts`. - **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests are `0.2.2` on this release line; npm availability updates after the `v0.2.2` publish job — prefer a workspace install / local path until then. + **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests are `0.2.3` on this release line; npm availability updates after the `v0.2.3` publish job — prefer a workspace install / local path until then. ## Workflow diff --git a/website/content/docs/en/guides/agents-and-model-roles.mdx b/website/content/docs/en/guides/agents-and-model-roles.mdx index de503a93..a559ef31 100644 --- a/website/content/docs/en/guides/agents-and-model-roles.mdx +++ b/website/content/docs/en/guides/agents-and-model-roles.mdx @@ -201,8 +201,8 @@ The "Configure taskflow preferences" option in `/tf init` adjusts four settings: |---|---|---| | `builtInAgents` | `true` | Enable or disable the 18 built-in agents. Disable this if you only want your own agents. | | `syncBuiltinAgentsToProject` | `false` | Copy built-in agents into your project's `.pi/agents/` on session start, so native Pi `@agent` syntax can discover them too. | -| `maxKeptRuns` | `100` | Maximum completed/failed runs to keep. Set to `0` to disable count-based cleanup. | -| `maxRunAgeDays` | `30` | Maximum age (days) for completed/failed runs. Set to `0` to disable age-based cleanup. | +| `maxKeptRuns` | `100` | Maximum inactive runs (`completed`, `failed`, `paused`, or `blocked`) to keep. Running work is never pruned. Set to `0` to disable count-based cleanup. | +| `maxRunAgeDays` | `30` | Maximum age (days) for inactive runs. Running work is never pruned. Set to `0` to disable age-based cleanup. | Cleanup runs automatically, throttled to once per minute. It only removes terminal (completed or failed) runs — active runs are never touched. diff --git a/website/content/docs/en/guides/claude-code.mdx b/website/content/docs/en/guides/claude-code.mdx index d27ca859..bb9385f5 100644 --- a/website/content/docs/en/guides/claude-code.mdx +++ b/website/content/docs/en/guides/claude-code.mdx @@ -155,13 +155,9 @@ Taskflow requires Claude Code **2.1.169 or newer** for `--safe-mode`. Older CLIs ## Long-running flows -Here's the one thing to know about running taskflow on Claude Code: **`taskflow_run` is synchronous.** The tool call doesn't return until the entire DAG finishes — every phase, every subagent. For a three-phase review that's fine; for a fan-out over fifty files with a tournament at the end, it can take a while. - -If a flow is genuinely huge, consider splitting it into a few smaller `taskflow_run` calls so each returns promptly, or run it in the background from a plain shell: - -```bash title="Run a flow in the background" -claude -p "run the nightly-audit taskflow" & -``` +`taskflow_run` is synchronous by default. For a long flow, set +`mode: "background"`; the MCP call returns a durable `runId` immediately. +Use `taskflow_runs` to list, inspect, wait for, or cancel that worker. Then inspect the run afterward with `taskflow_peek`: @@ -189,6 +185,9 @@ Here's the full MCP surface the plugin exposes: | Tool | Purpose | |---|---| | `taskflow_run` | Run a saved flow (by `name`) or an inline definition (by `define`). Returns the final output + a `runId`. | +| `taskflow_runs` | List background runs, or `status` / `wait` / `cancel` one by `runId`. | +| `taskflow_resume` | Fork a failed or paused run into a new immutable child run, with optional one-phase overrides. | +| `taskflow_version` | Report package version, build commit, schema version, build time, and host. | | `taskflow_list` | List saved flows discoverable from the cwd, with library metadata when available. | | `taskflow_show` | Show a saved flow's definition plus its library sidecar metadata. | | `taskflow_save` | Save a flow to the library with optional `purpose`, `tags`, and `notes`. | @@ -200,6 +199,7 @@ Here's the full MCP surface the plugin exposes: | `taskflow_replay` | Replay recorded decisions offline with no model calls. | | `taskflow_why_stale` | Explain dependency staleness at zero tokens. | | `taskflow_recompute` | Report the stale frontier (MCP is dry-run only). | +| `taskflow_reconcile_workspace` | Explicitly accept an inspected/repaired resolve-only workspace after a dirty-unknown write. | You rarely need to memorize these. The bundled skill tells Claude Code which tool fits the request — "verify this flow", "show me the diagram", "run it" all route correctly on their own. diff --git a/website/content/docs/en/guides/codex.mdx b/website/content/docs/en/guides/codex.mdx index eb8d2489..d86b7d87 100644 --- a/website/content/docs/en/guides/codex.mdx +++ b/website/content/docs/en/guides/codex.mdx @@ -134,7 +134,7 @@ Or render the DAG as a diagram to eyeball its shape: ## Long-running flows -Here's the one thing to know about running taskflow on Codex: **`taskflow_run` is synchronous.** The tool call doesn't return until the entire DAG finishes — every phase, every subagent. For a three-phase review that's fine; for a fan-out over fifty files with a tournament at the end, it can take a while. +`taskflow_run` is synchronous by default, which is ideal when you want the final result in the current turn. For a long DAG, set `mode: "background"`; Codex receives a durable `runId` immediately and the worker continues independently. The plugin ships a 30-minute default tool-call timeout to accommodate this. If your flows run longer, override it in your Codex config: @@ -144,10 +144,10 @@ tool_timeout_sec = 3600 ``` - Don't set this timeout lower than your longest expected run. Codex sends MCP cancellation when it gives up; taskflow propagates that cancellation to the DAG and active subagent. MCP does not turn the request into a detached background run. + This timeout applies to foreground calls. For longer work, prefer background mode instead of relying on an oversized request timeout. -For genuinely long work, consider splitting the flow: run the heavy phases in a saved flow, and use a downstream `approval` or `gate` phase to pause for review. (Resuming a paused run by `runId` is a Pi-only capability — `/tf resume`. On Codex there is no `taskflow_resume` MCP tool, so a paused run is continued by re-running the flow; phases with `cache.scope: "cross-run"` are reused automatically.) +Start background work with `taskflow_run { name: "...", mode: "background" }`, then use `taskflow_runs` to list, inspect, wait for, or cancel it. A failed or paused run can be continued with `taskflow_resume`, which forks immutable history into a new child run. Codex reports token usage but not cost. A Codex flow may use `budget.maxTokens`; any `budget.maxUSD` is rejected before execution. The budget is an observed-usage stop-loss, not a zero-overshoot maximum: ordinary calls are admitted serially, while active `race` branches may overshoot together. @@ -181,6 +181,9 @@ Here's the full MCP surface the plugin exposes: | Tool | Purpose | |---|---| | `taskflow_run` | Run a saved flow (by `name`) or an inline definition (by `define`). Returns the final output. | +| `taskflow_runs` | List background runs, or `status` / `wait` / `cancel` one by `runId`. | +| `taskflow_resume` | Fork a failed or paused run into a new immutable child run, with optional one-phase overrides. | +| `taskflow_version` | Report package version, build commit, schema version, build time, and host. | | `taskflow_list` | List saved flows in the current project. | | `taskflow_show` | Print a saved flow's JSON definition. | | `taskflow_verify` | Statically verify a flow (cycles, refs, configs) at zero cost. | @@ -190,6 +193,7 @@ Here's the full MCP surface the plugin exposes: | `taskflow_replay` | Replay recorded decisions offline with no model calls. | | `taskflow_why_stale` | Explain dependency staleness at zero tokens. | | `taskflow_recompute` | Report the stale frontier (MCP is dry-run only). | +| `taskflow_reconcile_workspace` | Explicitly accept an inspected/repaired resolve-only workspace after a dirty-unknown write. | | `taskflow_save` | Save a reusable flow and optional library metadata. | | `taskflow_search` | Search and rank reusable flows. | diff --git a/website/content/docs/en/guides/grok-build.mdx b/website/content/docs/en/guides/grok-build.mdx index cdd9c15d..283bd1f6 100644 --- a/website/content/docs/en/guides/grok-build.mdx +++ b/website/content/docs/en/guides/grok-build.mdx @@ -12,7 +12,7 @@ This page walks through install, verify, first run, permissions, and long-runnin ### Published MCP package (recommended) ```bash title="Register taskflow MCP" -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.2 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.3 grok-taskflow-mcp ``` Requires **Node.js ≥ 22.19.0**. The MCP protocol code has no MCP SDK dependency. A public Grok plugin marketplace/source is not published yet; do not substitute an imaginary source string. @@ -142,7 +142,7 @@ Grok children inherit only platform/proxy/CA plus Grok/xAI/Taskflow-Grok variabl ## Long-running flows -**`taskflow_run` is synchronous** — the tool call returns only when the whole DAG finishes. The plugin ships `tool_timeout_sec: 1800`; MCP does not expose Pi's detached mode. Split huge graphs. If the client cancels a request, the server aborts the active DAG and subagent instead of leaving hidden background work. +`taskflow_run` is synchronous by default. For a long graph, set `mode: "background"`; the call returns a durable `runId` immediately. Use `taskflow_runs` to list, inspect, wait for, or cancel it. The 30-minute tool timeout applies only to foreground calls. ## Approvals in MCP mode @@ -153,6 +153,9 @@ MCP-driven runs are non-interactive, so an `approval` phase **auto-rejects** (fa | Tool | Purpose | |---|---| | `taskflow_run` | Run a saved or inline flow. Returns final output + `runId`. | +| `taskflow_runs` | List background runs, or `status` / `wait` / `cancel` one by `runId`. | +| `taskflow_resume` | Fork a failed or paused run into a new immutable child run. | +| `taskflow_version` | Report package/build/schema/host identity. | | `taskflow_list` / `taskflow_show` | Discover and inspect saved flows. | | `taskflow_save` / `taskflow_search` | Library write + search. | | `taskflow_verify` / `taskflow_compile` | Static check and DAG render (zero tokens). | @@ -160,13 +163,14 @@ MCP-driven runs are non-interactive, so an `approval` phase **auto-rejects** (fa | `taskflow_trace` | Append-only event log timeline (subagent I/O + decisions). | | `taskflow_replay` | Offline what-if re-judge of thresholds/budget (zero tokens). | | `taskflow_why_stale` / `taskflow_recompute` | Staleness analysis (recompute is dry-run only over MCP). | +| `taskflow_reconcile_workspace` | Explicitly accept an inspected/repaired resolve-only workspace after a dirty-unknown write. | ## Alternative: register the MCP server manually ```bash title="Manual MCP registration" pnpm add -g grok-taskflow grok mcp add taskflow -- grok-taskflow-mcp -# or: grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.2 grok-taskflow-mcp +# or: grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.3 grok-taskflow-mcp ``` ## Remove diff --git a/website/content/docs/en/guides/opencode.mdx b/website/content/docs/en/guides/opencode.mdx index a112c9d8..c5b8526d 100644 --- a/website/content/docs/en/guides/opencode.mdx +++ b/website/content/docs/en/guides/opencode.mdx @@ -173,13 +173,9 @@ A dropped id falls back to OpenCode's configured default model. ## Long-running flows -Here's the one thing to know about running taskflow on OpenCode: **`taskflow_run` is synchronous.** The tool call doesn't return until the entire DAG finishes — every phase, every subagent. For a three-phase review that's fine; for a fan-out over fifty files with a tournament at the end, it can take a while. - -If a flow is genuinely huge, consider splitting it into a few smaller `taskflow_run` calls so each returns promptly, or run it in the background from a plain shell: - -```bash title="Run a flow in the background" -opencode run "run the nightly-audit taskflow" & -``` +`taskflow_run` is synchronous by default. For a long flow, set +`mode: "background"`; the MCP call returns a durable `runId` immediately. +Use `taskflow_runs` to list, inspect, wait for, or cancel that worker. Then inspect the run afterward with `taskflow_peek`: @@ -207,6 +203,9 @@ Here's the full MCP surface the server exposes: | Tool | Purpose | |---|---| | `taskflow_run` | Run a saved flow (by `name`) or an inline definition (by `define`). Returns the final output + a `runId`. | +| `taskflow_runs` | List background runs, or `status` / `wait` / `cancel` one by `runId`. | +| `taskflow_resume` | Fork a failed or paused run into a new immutable child run, with optional one-phase overrides. | +| `taskflow_version` | Report package version, build commit, schema version, build time, and host. | | `taskflow_list` | List saved flows discoverable from the cwd, with library metadata when available. | | `taskflow_show` | Show a saved flow's definition plus its library sidecar metadata. | | `taskflow_save` | Save a flow to the library with optional `purpose`, `tags`, and `notes`. | @@ -218,6 +217,7 @@ Here's the full MCP surface the server exposes: | `taskflow_replay` | Replay recorded decisions offline with no model calls. | | `taskflow_why_stale` | Explain dependency staleness at zero tokens. | | `taskflow_recompute` | Report the stale frontier (MCP is dry-run only). | +| `taskflow_reconcile_workspace` | Explicitly accept an inspected/repaired resolve-only workspace after a dirty-unknown write. | You rarely need to memorize these. The bundled skill tells OpenCode which tool fits the request — "verify this flow", "show me the diagram", "run it" all route correctly on their own. diff --git a/website/content/docs/en/reference/commands.mdx b/website/content/docs/en/reference/commands.mdx index 3d692f36..625cdd0d 100644 --- a/website/content/docs/en/reference/commands.mdx +++ b/website/content/docs/en/reference/commands.mdx @@ -83,6 +83,9 @@ On MCP hosts, taskflow exposes these tools (the model calls them; you usually do | Tool | Description | |---|---| | `taskflow_run` | Run an inline or saved flow; returns the final output and a `runId`. | +| `taskflow_runs` | List background runs, or `status` / bounded `wait` / `cancel` one by `runId`. | +| `taskflow_resume` | Fork a failed or paused run into a new immutable child run, with optional one-phase overrides. | +| `taskflow_version` | Report package version, build commit, schema version, build time, and host. | | `taskflow_list` | List saved flows discoverable in this directory. | | `taskflow_show` | Print a saved flow's definition. | | `taskflow_verify` | Statically verify a flow (no execution). | diff --git a/website/content/docs/zh-cn/blog/orchestrate-codex-subagents.mdx b/website/content/docs/zh-cn/blog/orchestrate-codex-subagents.mdx index 07c1d4f0..04eb3e06 100644 --- a/website/content/docs/zh-cn/blog/orchestrate-codex-subagents.mdx +++ b/website/content/docs/zh-cn/blog/orchestrate-codex-subagents.mdx @@ -116,9 +116,9 @@ codex plugin add taskflow@taskflow | **质量门禁** | 自己过目 | `gate` 阶段遇 `BLOCK` 叫停 | | **上下文** | 每份逐字稿都返回 | 只有最终报告返回 | -## 跨会话检查或重跑 +## 跨会话检查或续跑 -Codex MCP 不提供 `taskflow_resume` 工具。保留运行返回的 run id,可以检查已存储运行: +保留运行返回的 run id,可以检查已存储运行: 用结构化参数调用 `taskflow_peek`: @@ -126,7 +126,7 @@ Codex MCP 不提供 `taskflow_resume` 工具。保留运行返回的 run id, { "runId": "" } ``` -要再次执行,请重新调用 `taskflow_run`。跨运行复用必须显式设置 `cache.scope: "cross-run"`;没有该设置时,新运行会重新执行阶段。按 run id 精确续跑暂停任务目前仅 Pi 支持(`/tf resume`)。详见[增量重算](/zh-cn/docs/compiler-runtime/incremental-recompute)。 +要继续 `failed` 或 `paused` 运行,请用其 `runId` 调用 `taskflow_resume`。续跑会保留不可变历史并创建新的子运行,也可覆盖一个阶段。全新执行仍调用 `taskflow_run`;跨运行复用继续要求显式设置 `cache.scope: "cross-run"`。详见[增量重算](/zh-cn/docs/compiler-runtime/incremental-recompute)。 ## 什么时候保持临时 diff --git a/website/content/docs/zh-cn/compiler-runtime/background-runs.mdx b/website/content/docs/zh-cn/compiler-runtime/background-runs.mdx index a01db094..24c62380 100644 --- a/website/content/docs/zh-cn/compiler-runtime/background-runs.mdx +++ b/website/content/docs/zh-cn/compiler-runtime/background-runs.mdx @@ -5,7 +5,7 @@ description: 启动长时间运行的流程并立即返回提示符。通过 run 普通的 `taskflow` 调用会阻塞你的会话,直到所有阶段完成。一个三阶段的代码审计如果耗时八分钟,你的提示符就冻结八分钟。 -后台运行解决了这个问题。在 **Pi 的 `taskflow` 工具**上设置 `detach: true`,工具在一秒内返回一个 `runId`。一个子进程接管工作,在每个阶段完成后持久化进度,并在结束时写入最终状态——或者在崩溃时写入失败状态。你可以随时从磁盘轮询运行状态来获取进度更新,也可以在中途失败后恢复它。Codex、Claude Code、OpenCode 和 Grok Build 的 MCP `taskflow_run` 目前不支持 detach;MCP 取消会中止 DAG,不会转为后台运行。 +后台运行解决了这个问题。Pi 的 `taskflow` 工具使用 `detach: true`;Codex、Claude Code、OpenCode 和 Grok Build 的 MCP `taskflow_run` 使用 `mode: "background"`。两者都会立即返回 `runId`,由子进程接管工作并持久化进度与最终状态。MCP 宿主还可通过 `taskflow_runs` 执行 `list`、`status`、`wait` 和持久化 `cancel`。 本指南详细说明具体的 spawn 机制、如何检查后台运行的状态、审批(approval)阶段会发生什么,以及无头运行的限制。 @@ -37,19 +37,19 @@ Taskflow 'code-audit' started in background (pid: 48231). Run id: 20260706T14302 **宿主在磁盘上创建 `RunState`。** 运行以 `status: "running"`、`detached: true` 开始,流程定义和参数被持久化。这与所有运行使用的原子写入路径相同——分离进程会把它读回来。 - **上下文序列化到临时文件。** `/tmp/taskflow-detach-.json` 的 JSON 文件携带 `runId`、`defName`、`args`、`cwd` 以及宿主适配器运行模块的绝对路径(`runnerModule`)。这个文件是子进程的唯一输入。 + **上下文序列化到临时文件。** 私有 `/tmp/taskflow-detach-*/context.json` 文件携带 `runId`、`defName`、`args`、`cwd`、父调用冻结的配置快照,以及宿主适配器运行模块的绝对路径。这个文件是子进程的唯一输入,读取后即删除。 - **生成子进程。** 宿主 fork `node `,使用 `detached: true` 和 `stdio: ["ignore", "ignore", "pipe"]`。stdout 被忽略;stderr 被管道回父进程以捕获崩溃诊断信息。子进程立即 `unref()`——它不会保持父进程的事件循环存活。 + **生成子进程。** 宿主 fork `node `,使用 `detached: true` 并立即 `unref()`。Pi 管道接收有限 stderr;MCP 使用 `stdio: "ignore"`,真实导入错误由 worker 直接持久化到 `__detach__`。 **父进程返回 runId。** 子进程的 PID 写入 `RunState.pid`,Pi `taskflow` 工具响应同时包含 PID 和 runId。父会话可以继续其他工作。 - **子进程启动并运行流程。** 分离运行器读取上下文文件,从磁盘重新发现 agent 配置,动态导入宿主适配器的运行模块,然后调用 `executeTaskflow`。每个阶段完成后通过同一个 `saveRun` 路径持久化进度。 + **子进程启动并运行流程。** 分离运行器读取上下文文件,使用父调用冻结的 agent 与 settings 快照,动态导入宿主适配器的运行模块,然后调用 `executeTaskflow`。每个阶段完成后通过同一个 `saveRun` 路径持久化进度。 - **写入终态。** 成功时,子进程持久化 `status: "completed"` 和所有阶段输出。失败时,顶层 `catch` 写入 `status: "failed"`。崩溃时,父进程的崩溃保护写入合成的 `__detach__` 阶段和 stderr 信息。 + **写入终态。** 成功时,子进程在同一次终态写入中持久化 `status: "completed"`、最终输出和来源阶段。失败时,顶层 `catch` 写入 `status: "failed"`;异常退出由父进程或下一次状态轮询补写合成的 `__detach__` 阶段,并回收已登记的 Host CLI 进程组。 @@ -70,7 +70,7 @@ Taskflow 'code-audit' started in background (pid: 48231). Run id: 20260706T14302 | 状态 | 含义 | |---|---| | `running` | 分离进程存活并正在执行阶段。 | -| `completed` | 所有阶段完成。`finalOutput` 在最后一个阶段的输出中。 | +| `completed` | 所有阶段完成。顶层 `finalOutput` 与 `outputSourcePhaseId` 已随终态原子持久化。 | | `failed` | 某个阶段失败(重试已耗尽),或子进程崩溃。 | | `paused` | `loop` 阶段达到迭代上限并暂停以供审查。 | | `blocked` | `gate` 阶段返回 `VERDICT: BLOCK`。 | @@ -90,7 +90,7 @@ Taskflow 'code-audit' started in background (pid: 48231). Run id: 20260706T14302 崩溃保护仅在运行仍为 `"running"` 且 PID 匹配时触发——它永远不会覆盖运行器已持久化的真正终态。这意味着: - **正常退出(code 0):** 运行器已持久化自己的状态。崩溃保护不执行操作。 -- **非零退出:** 崩溃保护检查运行状态。如果仍为 `"running"`,则标记为 `"failed"` 并附上捕获的 stderr。 +- **非零退出:** 崩溃保护检查运行状态。如果仍为 `"running"`,则标记为 `"failed"`;Pi 可附带有限 stderr,MCP 至少记录退出码/信号。 - **Spawn 错误:** `child.on("error")` 处理器用 spawn 错误信息写入相同的合成阶段。 `__detach__` 阶段可轮询、可调试——你可以在 `/tf runs` 和运行详情视图中看到它,就像任何其他阶段一样。 @@ -155,7 +155,7 @@ done ## 上下文文件格式 -`/tmp/taskflow-detach-.json` 的临时文件携带: +私有 `/tmp/taskflow-detach-*/context.json` 临时文件携带: ```json { @@ -187,7 +187,7 @@ done 1. 从 `process.argv[2]` 读取并解析上下文文件。 2. 通过 `runId` 加载预保存的 `RunState`。 -3. 使用 `readSubagentSettings()` 和 `discoverAgents()` 重新发现 agent 配置。 +3. 使用父调用写入的 agent、模型角色、global thinking 与保留策略快照。 4. 动态 `import(runnerModule)` 并提取 `runTask` 函数。 5. **快速失败**——如果指定了运行模块但无法加载,写入合成的 `__detach__` 阶段并以非零退出码退出,而不是带着"未注入运行器"的存根继续跛行。 6. 调用 `executeTaskflow()`,带有在每个阶段完成后保存状态的 `persist` 回调。 @@ -203,9 +203,9 @@ done |---|---| | **审批门控自动拒绝** | 没有交互式审批者可用。安全门控不得被静默绕过。 | | **无实时 TUI 流式传输** | 子进程的 stdout 被忽略(`stdio: "ignore"`)。进度仅通过文件轮询可见。 | -| **宿主无法发送 `AbortSignal`** | 父进程无法在运行中途取消子进程。你可以手动 kill PID。 | +| **无实时 `AbortSignal` 通道** | MCP 通过私有持久化控制记录执行 `taskflow_runs cancel`,子进程轮询后进入正常暂停/清理路径;Pi 暂无同名管理工具,需要时仍可按记录的 PID 手动终止。 | | **`runnerModule` 必须可解析** | 子进程在运行时 `import()` 宿主适配器的运行器。如果模块路径错误,每个阶段都会失败。快速失败保护会提前捕获此问题。 | -| **临时文件必须在子进程读取前保持存在** | `/tmp/taskflow-detach-.json` 的上下文文件在启动时同步读取。在子进程启动前删除它会导致立即退出。 | +| **启动握手有界** | 父进程先持久化 PID/协议元数据,再写入 start gate;子进程若 5 秒内未获放行会失败退出,不会无限等待。 | | **无 `requestApproval` 回调** | 宿主无法将审批处理器注入分离进程。这是自动拒绝背后的机制。 | ## 失败后恢复 @@ -219,7 +219,7 @@ done } ``` -已完成的阶段被跳过(它们的输出已缓存)。只有失败的阶段及其下游依赖项重新执行。恢复本身也可以是分离的——在恢复调用上设置 `detach: true` 再次在后台触发它。 +已完成且不受影响的阶段被复用,失败阶段及其下游重新执行。Pi 调用可再次设置 `detach: true`;MCP 的 `taskflow_resume` 当前前台执行并创建不可变子运行。 ## 跨运行缓存 @@ -257,7 +257,7 @@ done └─ 用户轮询 /tf runs → 看到 "completed" ``` -如果子进程在写入终态之前以非零退出码退出,崩溃保护在 `exit` 事件和用户下一次轮询的 `saveRun` 之间触发,将运行标记为 `"failed"` 并附上捕获的 stderr。 +如果子进程在写入终态之前异常退出,父进程的 `exit` 处理器或下一次 `taskflow_runs` 轮询会把它终态化为 `"failed"`;Pi 可保留有限 stderr,MCP 保留退出信息和 worker 已写入的诊断。 ## 下一步 diff --git a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx index 14cae7d6..f85b8b90 100644 --- a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx @@ -8,7 +8,7 @@ description: 编译期 .tf.ts 写法 —— rune erase 成 Taskflow JSON,再 S4 增加**编译期** TypeScript 前端:用 rune(`agent`、`map`、`race`…)写 `*.tf.ts`,CLI erase 成普通 Taskflow JSON。宿主仍通过 `taskflow_run` / `/tf run` 跑 **JSON**——**没有**解释执行路径,也**没有**宿主对 `.tf.ts` 的自动 build。 - **包状态。** `taskflow-dsl` 在 monorepo 的 `packages/taskflow-dsl`。纯 JSON 作者不需要它。本发布线 manifest 为 `0.2.2`;npm 在 `v0.2.2` 发布任务完成后更新——此前请用 workspace / 本地 path。 + **包状态。** `taskflow-dsl` 在 monorepo 的 `packages/taskflow-dsl`。纯 JSON 作者不需要它。本发布线 manifest 为 `0.2.3`;npm 在 `v0.2.3` 发布任务完成后更新——此前请用 workspace / 本地 path。 ## 工作流 diff --git a/website/content/docs/zh-cn/guides/agents-and-model-roles.mdx b/website/content/docs/zh-cn/guides/agents-and-model-roles.mdx index b61480c0..d4bf62d5 100644 --- a/website/content/docs/zh-cn/guides/agents-and-model-roles.mdx +++ b/website/content/docs/zh-cn/guides/agents-and-model-roles.mdx @@ -201,8 +201,8 @@ Subagent spawns with: model: "anthropic/claude-3.5-sonnet" |---|---|---| | `builtInAgents` | `true` | 启用或禁用 18 个内置 agent。如果你只想要自己的 agent,请禁用。 | | `syncBuiltinAgentsToProject` | `false` | 在会话开始时把内置 agent 复制到项目的 `.pi/agents/`,以便原生 Pi `@agent` 语法也能发现它们。 | -| `maxKeptRuns` | `100` | 保留的已完成/失败运行的最大数量。设为 `0` 禁用按数量清理。 | -| `maxRunAgeDays` | `30` | 已完成/失败运行的最大保留天数。设为 `0` 禁用按时间清理。 | +| `maxKeptRuns` | `100` | 保留的非活动运行(`completed`、`failed`、`paused`、`blocked`)最大数量;运行中的工作永不修剪。设为 `0` 禁用按数量清理。 | +| `maxRunAgeDays` | `30` | 非活动运行的最大保留天数;运行中的工作永不修剪。设为 `0` 禁用按时间清理。 | 清理自动运行,节流为每分钟一次。它只移除终态(已完成或失败)的运行——活跃的运行永远不会被触碰。 diff --git a/website/content/docs/zh-cn/guides/claude-code.mdx b/website/content/docs/zh-cn/guides/claude-code.mdx index aea6bdbf..d73a3c51 100644 --- a/website/content/docs/zh-cn/guides/claude-code.mdx +++ b/website/content/docs/zh-cn/guides/claude-code.mdx @@ -155,13 +155,7 @@ Taskflow 要求 Claude Code **2.1.169 或更高版本**以使用 `--safe-mode` ## 长时运行的 flow -关于在 Claude Code 上运行 taskflow,你需要知道一件事:**`taskflow_run` 是同步的。** 工具调用在**整个 DAG 完成**之前不会返回——每个阶段、每个 subagent。对三阶段审查这没问题;对五十个文件的扇出加末尾的 tournament,可能要花一阵子。 - -如果一个 flow 确实很大,考虑把它拆成几个较小的 `taskflow_run` 调用,让每个都能及时返回,或者从普通 shell 在后台运行: - -```bash title="在后台运行 flow" -claude -p "run the nightly-audit taskflow" & -``` +`taskflow_run` 默认同步。长 flow 可设置 `mode: "background"`;MCP 调用会立即返回持久化 `runId`。使用 `taskflow_runs` 列出、查看、等待或取消该 worker。 然后用 `taskflow_peek` 事后检查运行: @@ -189,6 +183,9 @@ MCP 驱动的运行是非交互的,所以 `approval` 阶段会**自动拒绝** | 工具 | 用途 | |---|---| | `taskflow_run` | 运行保存的 flow(按 `name`)或内联定义(按 `define`)。返回最终输出 + 一个 `runId`。 | +| `taskflow_runs` | 列出后台运行,或按 `runId` 执行 `status` / `wait` / `cancel`。 | +| `taskflow_resume` | 把失败或暂停运行 fork 为新的不可变子运行,可覆盖一个阶段。 | +| `taskflow_version` | 报告包版本、构建 commit、schema 版本、构建时间与 host。 | | `taskflow_list` | 列出从 cwd 可发现的保存 flow,附带库元数据(如有)。 | | `taskflow_show` | 显示保存 flow 的定义及其库 sidecar 元数据。 | | `taskflow_save` | 把 flow 保存到库,可选 `purpose`、`tags`、`notes`。 | @@ -200,6 +197,7 @@ MCP 驱动的运行是非交互的,所以 `approval` 阶段会**自动拒绝** | `taskflow_replay` | 离线重放已记录决策,不调用模型。 | | `taskflow_why_stale` | 零 token 解释依赖为何 stale。 | | `taskflow_recompute` | 报告 stale frontier(MCP 仅 dry-run)。 | +| `taskflow_reconcile_workspace` | 检查/修复 dirty-unknown 的 resolve-only 工作区后显式接受当前状态。 | 你很少需要记住这些。内置 skill 会告诉 Claude Code 哪个工具适合请求——"verify this flow"、"show me the diagram"、"run it" 都能自动正确路由。 diff --git a/website/content/docs/zh-cn/guides/codex.mdx b/website/content/docs/zh-cn/guides/codex.mdx index 176183ce..b6968940 100644 --- a/website/content/docs/zh-cn/guides/codex.mdx +++ b/website/content/docs/zh-cn/guides/codex.mdx @@ -138,7 +138,7 @@ skill 把请求路由到正确的工具。如果一个 flow 已保存在当前 ## 长时间运行的 flow -关于在 Codex 上运行 taskflow,有一件事要知道:**`taskflow_run` 是同步的。** 工具调用直到整个 DAG 完成——每个阶段、每个子代理——才返回。对一次三阶段的审查来说没问题;对一次五十个文件的 fan-out 外加末尾一个 tournament 来说,可能要花一会儿。 +`taskflow_run` 默认同步,适合希望在当前轮次直接拿到最终结果的工作。长 DAG 可设置 `mode: "background"`;Codex 会立即得到持久化 `runId`,worker 独立继续执行。 插件自带一个 30 分钟的默认工具调用超时来适应这点。如果你的 flow 跑得更久,在你的 Codex 配置里覆盖它: @@ -148,10 +148,10 @@ tool_timeout_sec = 3600 ``` - 不要把这个超时设得比你预期的最长运行还短。如果工具调用超时,运行会在后台继续执行——但 Codex 那时已经放弃了结果。 + 该超时只影响前台调用。长任务优先使用后台模式,不要只依赖超大的请求超时。 -对于真正长的工作,考虑拆分 flow:在已保存的 flow 里跑重的阶段,用下游的 `approval` 或 `gate` 阶段来暂停审查。(按 `runId` 续跑一个暂停的运行是 Pi 独有的能力——`/tf resume`。在 Codex 上没有 `taskflow_resume` MCP 工具,因此要继续一个暂停的运行就重新运行该 flow;带 `cache.scope: "cross-run"` 的阶段会被自动复用。) +用 `taskflow_run { name: "...", mode: "background" }` 启动后台工作,再通过 `taskflow_runs` 列出、查看、等待或取消。失败或暂停的运行可用 `taskflow_resume` 续跑;它会保留不可变历史并创建新的子运行。 Codex 上报 token usage,但不上报 cost。Codex flow 可以使用 `budget.maxTokens`;任何 `budget.maxUSD` 都会在执行前被拒绝。预算是基于已上报用量的止损线,不是零超支保证:普通调用串行准入,已活动的 `race` 分支可能一起超支。 @@ -181,6 +181,9 @@ tool_timeout_sec = 3600 | 工具 | 用途 | |---|---| | `taskflow_run` | 运行一个已保存的 flow(按 `name`)或一个内联定义(按 `define`)。返回最终输出。 | +| `taskflow_runs` | 列出后台运行,或按 `runId` 执行 `status` / `wait` / `cancel`。 | +| `taskflow_resume` | 把失败或暂停运行 fork 为新的不可变子运行,可覆盖一个阶段。 | +| `taskflow_version` | 报告包版本、构建 commit、schema 版本、构建时间与 host。 | | `taskflow_list` | 列出当前项目里已保存的 flow。 | | `taskflow_show` | 打印一个已保存 flow 的 JSON 定义。 | | `taskflow_verify` | 静态验证一个 flow(循环、引用、配置),零成本。 | @@ -190,6 +193,7 @@ tool_timeout_sec = 3600 | `taskflow_replay` | 离线重放已记录决策,不调用模型。 | | `taskflow_why_stale` | 零 token 解释依赖为何 stale。 | | `taskflow_recompute` | 报告 stale frontier(MCP 仅 dry-run)。 | +| `taskflow_reconcile_workspace` | 检查/修复 dirty-unknown 的 resolve-only 工作区后显式接受当前状态。 | | `taskflow_save` | 保存可复用 flow 与可选库元数据。 | | `taskflow_search` | 搜索并排序可复用 flow。 | diff --git a/website/content/docs/zh-cn/guides/grok-build.mdx b/website/content/docs/zh-cn/guides/grok-build.mdx index 87131a3a..c72104ba 100644 --- a/website/content/docs/zh-cn/guides/grok-build.mdx +++ b/website/content/docs/zh-cn/guides/grok-build.mdx @@ -12,7 +12,7 @@ description: 将 taskflow 作为 Grok Build 插件安装,通过 MCP 编排多 ### 已发布 MCP 包(推荐) ```bash title="注册 taskflow MCP" -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.2 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.3 grok-taskflow-mcp ``` 要求 **Node.js ≥ 22.19.0**。MCP 协议代码不依赖 MCP SDK。公共 Grok plugin marketplace/source 尚未发布;不要代入一个不存在的 source。 @@ -146,7 +146,7 @@ Grok 0.2.93 的 `streaming-json` 不返回 token / cost usage,所以 Grok MCP ## 长时 flow -**`taskflow_run` 同步返回**——整图跑完才结束。插件默认 `tool_timeout_sec: 1800`;MCP 不暴露 Pi 的 detach 模式。把大图拆开。如果客户端取消请求,服务器会中止正在运行的 DAG 和子 agent,不会留下隐藏的后台工作。 +`taskflow_run` 默认同步。长图可设置 `mode: "background"`;调用会立即返回持久化 `runId`。使用 `taskflow_runs` 列出、查看、等待或取消。30 分钟工具超时只影响前台调用。 ## MCP 下的 approval @@ -157,6 +157,9 @@ MCP 驱动的运行是非交互的,所以 `approval` 阶段会 **auto-reject** | 工具 | 用途 | |---|---| | `taskflow_run` | 运行已保存或内联 flow。返回最终输出 + `runId`。 | +| `taskflow_runs` | 列出后台运行,或按 `runId` 执行 `status` / `wait` / `cancel`。 | +| `taskflow_resume` | 把失败或暂停运行 fork 为新的不可变子运行。 | +| `taskflow_version` | 报告包 / 构建 / schema / host 身份。 | | `taskflow_list` / `taskflow_show` | 发现与查看已保存 flow。 | | `taskflow_save` / `taskflow_search` | 写入库 + 搜索。 | | `taskflow_verify` / `taskflow_compile` | 静态检查与 DAG 渲染(零 token)。 | @@ -164,6 +167,7 @@ MCP 驱动的运行是非交互的,所以 `approval` 阶段会 **auto-reject** | `taskflow_trace` | 只追加事件日志时间线(子 agent I/O + 决策)。 | | `taskflow_replay` | 离线 what-if 阈值 / 预算重判(零 token)。 | | `taskflow_why_stale` / `taskflow_recompute` | 过期分析(MCP 上 recompute 仅 dry-run)。 | +| `taskflow_reconcile_workspace` | 检查/修复 dirty-unknown 的 resolve-only 工作区后显式接受当前状态。 | ## 手动注册 MCP diff --git a/website/content/docs/zh-cn/guides/opencode.mdx b/website/content/docs/zh-cn/guides/opencode.mdx index 8f3db986..5812787a 100644 --- a/website/content/docs/zh-cn/guides/opencode.mdx +++ b/website/content/docs/zh-cn/guides/opencode.mdx @@ -173,13 +173,7 @@ OpenCode 模型 id 是 `provider/model`(如 `anthropic/claude-sonnet-4-5`) ## 长时运行的 flow -关于在 OpenCode 上运行 taskflow,你需要知道一件事:**`taskflow_run` 是同步的。** 工具调用在**整个 DAG 完成**之前不会返回——每个阶段、每个 subagent。对三阶段审查这没问题;对五十个文件的扇出加末尾的 tournament,可能要花一阵子。 - -如果一个 flow 确实很大,考虑把它拆成几个较小的 `taskflow_run` 调用,让每个都能及时返回,或者从普通 shell 在后台运行: - -```bash title="在后台运行 flow" -opencode run "run the nightly-audit taskflow" & -``` +`taskflow_run` 默认同步。长 flow 可设置 `mode: "background"`;MCP 调用会立即返回持久化 `runId`。使用 `taskflow_runs` 列出、查看、等待或取消该 worker。 然后用 `taskflow_peek` 事后检查运行: @@ -207,6 +201,9 @@ MCP 驱动的运行是非交互的,所以 `approval` 阶段会**自动拒绝** | 工具 | 用途 | |---|---| | `taskflow_run` | 运行保存的 flow(按 `name`)或内联定义(按 `define`)。返回最终输出 + 一个 `runId`。 | +| `taskflow_runs` | 列出后台运行,或按 `runId` 执行 `status` / `wait` / `cancel`。 | +| `taskflow_resume` | 把失败或暂停运行 fork 为新的不可变子运行,可覆盖一个阶段。 | +| `taskflow_version` | 报告包版本、构建 commit、schema 版本、构建时间与 host。 | | `taskflow_list` | 列出从 cwd 可发现的保存 flow,附带库元数据(如有)。 | | `taskflow_show` | 显示保存 flow 的定义及其库 sidecar 元数据。 | | `taskflow_save` | 把 flow 保存到库,可选 `purpose`、`tags`、`notes`。 | @@ -218,6 +215,7 @@ MCP 驱动的运行是非交互的,所以 `approval` 阶段会**自动拒绝** | `taskflow_replay` | 离线重放已记录决策,不调用模型。 | | `taskflow_why_stale` | 零 token 解释依赖为何 stale。 | | `taskflow_recompute` | 报告 stale frontier(MCP 仅 dry-run)。 | +| `taskflow_reconcile_workspace` | 检查/修复 dirty-unknown 的 resolve-only 工作区后显式接受当前状态。 | 你很少需要记住这些。内置 skill 会告诉 OpenCode 哪个工具适合请求——"verify this flow"、"show me the diagram"、"run it" 都能自动正确路由。 diff --git a/website/content/docs/zh-cn/reference/commands.mdx b/website/content/docs/zh-cn/reference/commands.mdx index d9e99a71..f75a1096 100644 --- a/website/content/docs/zh-cn/reference/commands.mdx +++ b/website/content/docs/zh-cn/reference/commands.mdx @@ -83,6 +83,9 @@ taskflow 以两种方式暴露相同的操作。在 **Pi** 上,你输入像 `/ | 工具 | 说明 | |---|---| | `taskflow_run` | 运行内联或已保存的 flow;返回最终输出和一个 `runId`。 | +| `taskflow_runs` | 列出后台运行,或按 `runId` 执行 `status` / 有界 `wait` / `cancel`。 | +| `taskflow_resume` | 把失败或暂停运行 fork 为新的不可变子运行,可覆盖一个阶段。 | +| `taskflow_version` | 报告包版本、构建 commit、schema 版本、构建时间与 host。 | | `taskflow_list` | 列出在此目录下可发现的已保存 flow。 | | `taskflow_show` | 打印一个已保存 flow 的定义。 | | `taskflow_verify` | 静态验证一个 flow(不执行)。 | From 2b3a5c1f264bca990b0000e037d65b82033c9c39 Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 18 Jul 2026 16:05:08 +0800 Subject: [PATCH 4/8] fix(runtime): harden detached lifecycle edge cases Preserve dot-leading run IDs, authenticate detached worker ownership, and kill stale owners before publishing terminal state. Make Pi launch rollback transactional, harden malformed status rendering, honor retention settings consistently, and add regression plus live-E2E coverage. --- CHANGELOG.md | 5 +- packages/pi-taskflow/src/index.ts | 263 ++++++++++-------- .../pi-taskflow/test/e2e-terminal-reap.mts | 13 +- .../pi-taskflow/test/runner-injection.test.ts | 21 ++ .../taskflow-core/src/detached-control.ts | 12 + packages/taskflow-core/src/detached-runner.ts | 5 + packages/taskflow-core/src/runner-core.ts | 3 +- packages/taskflow-core/src/store.ts | 7 +- .../test/detached-control.test.ts | 12 + .../taskflow-core/test/store-extended.test.ts | 14 +- .../taskflow-mcp-core/src/mcp/background.ts | 41 ++- packages/taskflow-mcp-core/src/mcp/server.ts | 12 +- .../test/background-runs.test.ts | 138 ++++++++- 13 files changed, 404 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e5f3e16..4db72532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,10 @@ All notable changes to taskflow are documented here. This project follows [Keep ### Fixed - **Atomic terminal results.** The runtime now persists `finalOutput` and `outputSourcePhaseId` in the same terminal-state write, so a crash or immediate poll can never observe `completed` without its result. -- **Detached-run ownership and cleanup.** Cancellation and process-heartbeat records now live in a user-private control directory keyed by canonical invocation root, preventing project symlinks and sibling worktrees from redirecting or cross-cancelling runs. Current workers carry a versioned instance identity; stale or killed workers are terminalized and their registered Host CLI process groups are reaped, while ambiguous legacy runs fail closed for cancellation. -- **Foreground/background parity.** MCP and Pi detached launches snapshot the same agent scope, model roles, global thinking, runner profile, incremental settings, and retention policy as the foreground invocation. Launch failures preserve their real cause, and post-spawn roster diagnostics can no longer misreport a successfully started run as a launch failure. +- **Detached-run ownership and cleanup.** Cancellation and process-heartbeat records now live in a user-private control directory keyed by canonical invocation root, preventing project symlinks and sibling worktrees from redirecting or cross-cancelling runs. Current workers carry a versioned instance identity; stale authenticated workers are killed before terminalization and their registered Host CLI process groups are reaped, inherited detached-runner environment variables cannot claim an outer run, and ambiguous legacy runs fail closed for cancellation. +- **Foreground/background parity.** MCP and Pi detached launches snapshot the same agent scope, model roles, global thinking, runner profile, incremental settings, and retention policy as the foreground invocation. Pi now uses fully detached stdio and transactionally reaps the worker plus its private launch context when post-spawn setup fails. Launch failures preserve their real cause, and post-spawn roster diagnostics can no longer misreport a successfully started run as a launch failure. - **Bounded run history and accurate rosters.** Retention now applies to every inactive state (`completed`, `failed`, `paused`, and `blocked`) while never pruning active runs. MCP background lists compute counts from the complete project roster without the former 1000-run cap or per-row reload pattern. +- **Run-id and status compatibility.** Dot-leading flow names now produce persistable run IDs without weakening traversal guards. Background status ignores synthetic launch phases in progress totals and tolerates malformed legacy optional output instead of crashing the MCP request. - **Release surface synchronization.** Package/plugin versions, installation pins, built-dist MCP expectations, the 16-tool roster, background-run documentation, and English/Chinese host guides are synchronized for 0.2.3. ## [0.2.2] — 2026-07-14 diff --git a/packages/pi-taskflow/src/index.ts b/packages/pi-taskflow/src/index.ts index a343b85c..e3cd4315 100644 --- a/packages/pi-taskflow/src/index.ts +++ b/packages/pi-taskflow/src/index.ts @@ -99,6 +99,7 @@ import { workspaceReconcileAllowedFromEnv, clearDetachedProcessRegistry, DETACHED_CONTROL_VERSION, + killProcessTree, terminateDetachedProcessTrees, } from "taskflow-core"; @@ -1386,128 +1387,152 @@ export default function (pi: ExtensionAPI) { const { spawn } = await import("node:child_process"); const os = await import("node:os"); const path = await import("node:path"); - const tmpDir = mkdtempSync(path.join(os.tmpdir(), "taskflow-detach-")); - chmodSync(tmpDir, 0o700); - const tmpFile = path.join(tmpDir, "context.json"); - const startFile = path.join(tmpDir, "start"); - // The runner module path is SELF-REPORTED by runner.ts (import.meta.url): - // src/runner.ts in dev, dist/runner.js in the compiled package. Do NOT - // switch this to resolving the relative "./runner" specifier with a .ts - // suffix at this call site — tsc's `rewriteRelativeImportExtensions` - // only rewrites static import statements, not string args of - // import.meta.resolve, so the compiled build would point at a - // non-existent dist .ts file and every detached phase would fail with - // "No subagent runner injected" (regression: detached-spawn.test.ts). - const runnerModule = runnerModulePath(); - writeFileSync(tmpFile, JSON.stringify({ - runId: state.runId, - defName: def.name, - args, - cwd: ctx.cwd, - runnerModule, - runnerFactoryExport: "createPiSubagentRunner", - runnerConfig: detachedSettings.taskflow.piChild, - waitForStart: true, - incremental: params.incremental === true, - reusedSavedName: - params.reusedFromSearch === true && typeof params.name === "string" && params.name.trim() - ? params.name.trim() - : undefined, - agents: detachedAgents, - globalThinking: detachedSettings.globalThinking, - agentScope: detachedScope, - maxKeptRuns: detachedSettings.taskflow.maxKeptRuns, - maxRunAgeDays: detachedSettings.taskflow.maxRunAgeDays, - detachedInstanceId: state.detachedInstanceId, - }), { encoding: "utf-8", flag: "wx", mode: 0o600 }); - - // detached-runner lives in taskflow-core (spawn-only entry). Resolve it - // from the installed package so it works under workspaces and when - // pi-taskflow is installed from npm. NOTE: the specifier is given WITHOUT - // the `.js` suffix — taskflow-core's `"./*"` export rewrites `` to - // `dist/.js`, so passing `detached-runner.js` here would resolve to - // `dist/detached-runner.js.js` (ENOENT). The runner is precompiled to - // `.js`, so no `--experimental-strip-types` flag is needed (Node refuses - // to strip `.ts` under node_modules, which is exactly why we ship JS). - const runnerScript = (await import("node:url")).fileURLToPath( - import.meta.resolve("taskflow-core/detached-runner"), - ); - // Persist only after all pre-spawn preparation succeeds. The start - // gate still prevents the child from racing the later pid update. - saveRun(state, state.detachedRetention); - // Capture stderr so a crashed child is debuggable instead of invisible. - const child = spawn(process.execPath, [runnerScript, tmpFile], { - detached: true, - stdio: ["ignore", "ignore", "pipe"], - env: { ...process.env, TASKFLOW_DETACHED_RUNNER: "1" }, - }); - let childErr = ""; - child.stderr?.on("data", (chunk: Buffer) => { childErr += chunk.toString(); }); - // Race-safe crash guard: if the child dies before reaching a terminal - // state, mark the run failed so it is never stuck at "running" forever. - // Guarded by pid + status so we never clobber a genuine terminal state - // the runner may have persisted between spawn and this callback. - const markFailedOnEarlyExit = (exitCode: number | null) => { - try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* child consumed it */ } - if (exitCode === 0) return; // clean exit — runner persists its own state - try { - const cur = loadRun(ctx.cwd, state.runId); - if (cur && cur.status === "running" && cur.pid === child.pid) { - if (cur.detachedInstanceId) { - terminateDetachedProcessTrees(cur.cwd, cur.runId, cur.detachedInstanceId); - clearDetachedProcessRegistry(cur.cwd, cur.runId, cur.detachedInstanceId); + let tmpDir: string | undefined; + let spawnedChild: ReturnType | undefined; + try { + tmpDir = mkdtempSync(path.join(os.tmpdir(), "taskflow-detach-")); + const launchTempDir = tmpDir; + chmodSync(launchTempDir, 0o700); + const tmpFile = path.join(launchTempDir, "context.json"); + const startFile = path.join(launchTempDir, "start"); + // The runner module path is SELF-REPORTED by runner.ts (import.meta.url): + // src/runner.ts in dev, dist/runner.js in the compiled package. Do NOT + // switch this to resolving the relative "./runner" specifier with a .ts + // suffix at this call site — tsc's `rewriteRelativeImportExtensions` + // only rewrites static import statements, not string args of + // import.meta.resolve, so the compiled build would point at a + // non-existent dist .ts file and every detached phase would fail with + // "No subagent runner injected" (regression: detached-spawn.test.ts). + const runnerModule = runnerModulePath(); + writeFileSync( + tmpFile, + JSON.stringify({ + runId: state.runId, + defName: def.name, + args, + cwd: ctx.cwd, + runnerModule, + runnerFactoryExport: "createPiSubagentRunner", + runnerConfig: detachedSettings.taskflow.piChild, + waitForStart: true, + incremental: params.incremental === true, + reusedSavedName: + params.reusedFromSearch === true && typeof params.name === "string" && params.name.trim() + ? params.name.trim() + : undefined, + agents: detachedAgents, + globalThinking: detachedSettings.globalThinking, + agentScope: detachedScope, + maxKeptRuns: detachedSettings.taskflow.maxKeptRuns, + maxRunAgeDays: detachedSettings.taskflow.maxRunAgeDays, + detachedInstanceId: state.detachedInstanceId, + }), + { encoding: "utf-8", flag: "wx", mode: 0o600 }, + ); + + // detached-runner lives in taskflow-core (spawn-only entry). Resolve it + // from the installed package so it works under workspaces and when + // pi-taskflow is installed from npm. NOTE: the specifier is given WITHOUT + // the `.js` suffix — taskflow-core's `"./*"` export rewrites `` to + // `dist/.js`, so passing `detached-runner.js` here would resolve to + // `dist/detached-runner.js.js` (ENOENT). The runner is precompiled to + // `.js`, so no `--experimental-strip-types` flag is needed (Node refuses + // to strip `.ts` under node_modules, which is exactly why we ship JS). + const runnerScript = (await import("node:url")).fileURLToPath( + import.meta.resolve("taskflow-core/detached-runner"), + ); + // Persist only after all pre-spawn preparation succeeds. The start + // gate still prevents the child from racing the later pid update. + saveRun(state, state.detachedRetention); + // A genuinely detached process must not retain a stdio pipe owned by Pi: + // that pipe couples its lifetime back to the host session. The child + // persists import/runtime failures on __detach__, while this parent still + // records spawn/early-exit failures below. + const child = spawn(process.execPath, [runnerScript, tmpFile], { + detached: true, + stdio: "ignore", + env: { ...process.env, TASKFLOW_DETACHED_RUNNER: "1" }, + }); + spawnedChild = child; + // Race-safe crash guard: if the child dies before reaching a terminal + // state, mark the run failed so it is never stuck at "running" forever. + // Guarded by pid + status so we never clobber a genuine terminal state + // the runner may have persisted between spawn and this callback. + const markFailedOnEarlyExit = (exitCode: number | null) => { + try { rmSync(launchTempDir, { recursive: true, force: true }); } catch { /* child consumed it */ } + if (exitCode === 0) return; // clean exit — runner persists its own state + try { + const cur = loadRun(ctx.cwd, state.runId); + if (cur && cur.status === "running" && cur.pid === child.pid) { + if (cur.detachedInstanceId) { + terminateDetachedProcessTrees(cur.cwd, cur.runId, cur.detachedInstanceId); + clearDetachedProcessRegistry(cur.cwd, cur.runId, cur.detachedInstanceId); + } + cur.status = "failed"; + // Record the crash reason in a synthetic phase so it is persisted, + // pollable, and debuggable (RunState has no run-level error field). + cur.phases["__detach__"] = { + id: "__detach__", + status: "failed", + endedAt: Date.now(), + error: `Detached runner exited with code ${exitCode} before completing.`, + }; + saveRun(cur, { + maxKeep: detachedSettings.taskflow.maxKeptRuns, + maxAgeDays: detachedSettings.taskflow.maxRunAgeDays, + }); } - cur.status = "failed"; - // Record the crash reason in a synthetic phase so it is persisted, - // pollable, and debuggable (RunState has no run-level error field). - cur.phases["__detach__"] = { - id: "__detach__", - status: "failed", - endedAt: Date.now(), - error: childErr.trim() - ? `Detached runner exited with code ${exitCode}: ${childErr.trim().slice(0, 2000)}` - : `Detached runner exited with code ${exitCode} before completing.`, - }; - saveRun(cur, { - maxKeep: detachedSettings.taskflow.maxKeptRuns, - maxAgeDays: detachedSettings.taskflow.maxRunAgeDays, - }); - } - } catch { /* best-effort: never let a handler throw */ } - }; - child.on("exit", markFailedOnEarlyExit); - child.on("error", (err) => { - try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ } - try { - const cur = loadRun(ctx.cwd, state.runId); - if (cur && cur.status === "running") { - cur.status = "failed"; - cur.phases["__detach__"] = { - id: "__detach__", - status: "failed", - endedAt: Date.now(), - error: `Failed to spawn detached runner: ${err.message}`, - }; - saveRun(cur, { - maxKeep: detachedSettings.taskflow.maxKeptRuns, - maxAgeDays: detachedSettings.taskflow.maxRunAgeDays, - }); - } - } catch { /* best-effort */ } - }); - child.unref(); + } catch { /* best-effort: never let a handler throw */ } + }; + child.on("exit", markFailedOnEarlyExit); + child.on("error", (err) => { + try { rmSync(launchTempDir, { recursive: true, force: true }); } catch { /* best-effort */ } + try { + const cur = loadRun(ctx.cwd, state.runId); + if (cur && cur.status === "running") { + cur.status = "failed"; + cur.phases["__detach__"] = { + id: "__detach__", + status: "failed", + endedAt: Date.now(), + error: `Failed to spawn detached runner: ${err.message}`, + }; + saveRun(cur, { + maxKeep: detachedSettings.taskflow.maxKeptRuns, + maxAgeDays: detachedSettings.taskflow.maxRunAgeDays, + }); + } + } catch { /* best-effort */ } + }); + child.unref(); - state.pid = child.pid ?? undefined; - saveRun(state, { - maxKeep: detachedSettings.taskflow.maxKeptRuns, - maxAgeDays: detachedSettings.taskflow.maxRunAgeDays, - }); - writeFileSync(startFile, "start\n", { encoding: "utf-8", flag: "wx", mode: 0o600 }); + state.pid = child.pid ?? undefined; + saveRun(state, { + maxKeep: detachedSettings.taskflow.maxKeptRuns, + maxAgeDays: detachedSettings.taskflow.maxRunAgeDays, + }); + writeFileSync(startFile, "start\n", { encoding: "utf-8", flag: "wx", mode: 0o600 }); - return { - content: [{ type: "text", text: `Taskflow '${def.name}' started in background (pid: ${child.pid}). Run id: ${state.runId}` }], - details: { action, state, message: state.runId } satisfies TaskflowDetails, - }; + return { + content: [{ type: "text", text: `Taskflow '${def.name}' started in background (pid: ${child.pid}). Run id: ${state.runId}` }], + details: { action, state, message: state.runId } satisfies TaskflowDetails, + }; + } catch (error) { + if (spawnedChild?.pid) killProcessTree(spawnedChild.pid, "SIGKILL", spawnedChild); + if (tmpDir) { + try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ } + } + const message = error instanceof Error ? error.message : String(error); + state.status = "failed"; + state.phases["__detach__"] = { + id: "__detach__", + status: "failed", + endedAt: Date.now(), + error: `Failed to launch detached runner: ${message}`.slice(0, 2_000), + }; + try { saveRun(state, state.detachedRetention); } catch { /* preserve launch cause */ } + return errorResult(action, `Failed to start detached taskflow: ${message}`); + } } const result = await runFlow(def, args, ctx, signal, onUpdate as any, undefined, params.incremental as boolean | undefined); diff --git a/packages/pi-taskflow/test/e2e-terminal-reap.mts b/packages/pi-taskflow/test/e2e-terminal-reap.mts index 6b937aff..9e7e2620 100644 --- a/packages/pi-taskflow/test/e2e-terminal-reap.mts +++ b/packages/pi-taskflow/test/e2e-terminal-reap.mts @@ -4,7 +4,8 @@ * Runs two real model turns with an explicitly allowlisted extension that keeps * a referenced interval alive. Each Pi CLI therefore emits its genuine NDJSON * lifecycle but cannot exit on its own; Taskflow must validate the terminal - * candidate, reap it, and proceed to phase two. + * candidate, reap it, and proceed to phase two. Set PI_TASKFLOW_E2E_MODEL to + * select an authenticated model instead of Pi's current default. */ import assert from "node:assert/strict"; import fs from "node:fs"; @@ -21,6 +22,7 @@ import { createPiSubagentRunner } from "../src/runner.ts"; const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-real-pi-terminal-")); const extension = path.join(cwd, "leaky-extension.mjs"); +const e2eModel = process.env.PI_TASKFLOW_E2E_MODEL?.trim(); fs.writeFileSync(extension, `export default function leakyExtension() { setInterval(() => {}, 1000); }\n`); try { @@ -43,7 +45,14 @@ try { cwd, }; const agents: AgentConfig[] = [ - { name: "executor", description: "live smoke", systemPrompt: "Follow the requested output format exactly.", source: "user", filePath: "" }, + { + name: "executor", + description: "live smoke", + systemPrompt: "Follow the requested output format exactly.", + source: "user", + filePath: "", + ...(e2eModel ? { model: e2eModel } : {}), + }, ]; const runner = createPiSubagentRunner({ resourceProfile: "allowlist", diff --git a/packages/pi-taskflow/test/runner-injection.test.ts b/packages/pi-taskflow/test/runner-injection.test.ts index 2e87ded1..2be3bdfd 100644 --- a/packages/pi-taskflow/test/runner-injection.test.ts +++ b/packages/pi-taskflow/test/runner-injection.test.ts @@ -97,3 +97,24 @@ test("regression: detached context preserves invocation-level cache and reuse se assert.match(SRC, /incremental:\s*params\.incremental\s*===\s*true/, "detached context must carry the incremental invocation override"); assert.match(SRC, /reusedSavedName:\s*[\s\S]{0,240}params\.reusedFromSearch\s*===\s*true/, "detached context must carry the reuse attribution only for an authorized search selection"); }); + +test("regression: Pi background runner has no host-owned stdio pipe", () => { + assert.match( + SRC, + /spawn\(process\.execPath,[\s\S]{0,400}detached:\s*true,[\s\S]{0,120}stdio:\s*"ignore"/, + "a durable detached runner must not retain a Pi-owned stdout/stderr pipe", + ); +}); + +test("regression: Pi background launch rolls back post-spawn failures", () => { + assert.match( + SRC, + /catch \(error\) \{[\s\S]{0,200}killProcessTree\(spawnedChild\.pid,[\s\S]{0,240}rmSync\(tmpDir/, + "post-spawn failures must reap the detached worker and remove its private launch context", + ); + assert.match( + SRC, + /error: `Failed to launch detached runner:[\s\S]{0,300}saveRun\(state, state\.detachedRetention\)/, + "launch rollback must persist the original failure on the durable run", + ); +}); diff --git a/packages/taskflow-core/src/detached-control.ts b/packages/taskflow-core/src/detached-control.ts index 41859534..6363156b 100644 --- a/packages/taskflow-core/src/detached-control.ts +++ b/packages/taskflow-core/src/detached-control.ts @@ -25,6 +25,8 @@ export const DETACHED_CONTROL_VERSION = CONTROL_VERSION; export const DETACHED_CONTROL_CWD_ENV = "TASKFLOW_DETACHED_CONTROL_CWD"; export const DETACHED_CONTROL_RUN_ID_ENV = "TASKFLOW_DETACHED_CONTROL_RUN_ID"; export const DETACHED_CONTROL_INSTANCE_ENV = "TASKFLOW_DETACHED_CONTROL_INSTANCE"; +export const DETACHED_CONTROL_OWNER_PID_ENV = "TASKFLOW_DETACHED_CONTROL_OWNER_PID"; +export const DETACHED_CONTROL_SIGNAL_READY_ENV = "TASKFLOW_DETACHED_CONTROL_SIGNAL_READY"; export interface DetachedProcessRegistry { version: number; @@ -152,10 +154,20 @@ function processRegistryContextFromEnv(): { cwd: string; runId: string; instance const cwd = process.env[DETACHED_CONTROL_CWD_ENV]; const runId = process.env[DETACHED_CONTROL_RUN_ID_ENV]; const instanceId = process.env[DETACHED_CONTROL_INSTANCE_ENV]; + // Host CLIs inherit the detached worker's environment. Only the process that + // owns this exact PID token may mutate the outer worker's registry; nested + // Taskflow instances must not overwrite its heartbeat/owner identity. + if (process.env[DETACHED_CONTROL_OWNER_PID_ENV] !== String(process.pid)) return null; if (!cwd || !runId || !instanceId || !validateRunId(runId)) return null; return { cwd, runId, instanceId }; } +/** True only after this exact detached owner installed its persistence-aware signal bridge. */ +export function isDetachedSignalOwnerReady(): boolean { + return process.env[DETACHED_CONTROL_OWNER_PID_ENV] === String(process.pid) && + process.env[DETACHED_CONTROL_SIGNAL_READY_ENV] === "1"; +} + /** Best-effort hook used by runner-core whenever a Host CLI process group starts. */ export function registerDetachedProcessTreeFromEnv(pid: number): void { const context = processRegistryContextFromEnv(); diff --git a/packages/taskflow-core/src/detached-runner.ts b/packages/taskflow-core/src/detached-runner.ts index 566e0072..255b7dc6 100644 --- a/packages/taskflow-core/src/detached-runner.ts +++ b/packages/taskflow-core/src/detached-runner.ts @@ -29,7 +29,9 @@ import { clearDetachedProcessRegistry, DETACHED_CONTROL_CWD_ENV, DETACHED_CONTROL_INSTANCE_ENV, + DETACHED_CONTROL_OWNER_PID_ENV, DETACHED_CONTROL_RUN_ID_ENV, + DETACHED_CONTROL_SIGNAL_READY_ENV, heartbeatDetachedProcessRegistry, terminateDetachedProcessTrees, watchDetachedCancel, @@ -148,6 +150,7 @@ try { process.env[DETACHED_CONTROL_CWD_ENV] = ctx.cwd; process.env[DETACHED_CONTROL_RUN_ID_ENV] = ctx.runId; process.env[DETACHED_CONTROL_INSTANCE_ENV] = ctx.detachedInstanceId; + process.env[DETACHED_CONTROL_OWNER_PID_ENV] = String(process.pid); heartbeatDetachedProcessRegistry(ctx.cwd, ctx.runId, ctx.detachedInstanceId); heartbeatTimer = setInterval(() => { try { heartbeatDetachedProcessRegistry(ctx.cwd, ctx.runId, ctx.detachedInstanceId!); } catch { /* best-effort lease */ } @@ -219,6 +222,7 @@ try { process.once("SIGTERM", abortForSignal); process.once("SIGINT", abortForSignal); process.once("SIGHUP", abortForSignal); + process.env[DETACHED_CONTROL_SIGNAL_READY_ENV] = "1"; const deps: RuntimeDeps = { cwd: ctx.cwd, cwdBridgeMode: cwdBridgeModeFromEnv(), @@ -247,6 +251,7 @@ try { if (heartbeatTimer) clearInterval(heartbeatTimer); clearDetachedCancelRequest(ctx.cwd, ctx.runId); if (ctx.detachedInstanceId) clearDetachedProcessRegistry(ctx.cwd, ctx.runId, ctx.detachedInstanceId); + delete process.env[DETACHED_CONTROL_SIGNAL_READY_ENV]; process.removeListener("SIGTERM", abortForSignal); process.removeListener("SIGINT", abortForSignal); process.removeListener("SIGHUP", abortForSignal); diff --git a/packages/taskflow-core/src/runner-core.ts b/packages/taskflow-core/src/runner-core.ts index f706f1d4..df05dcec 100644 --- a/packages/taskflow-core/src/runner-core.ts +++ b/packages/taskflow-core/src/runner-core.ts @@ -18,6 +18,7 @@ import { emptyUsage, type UsageStats } from "./usage.ts"; import type { AgentConfig } from "./agents.ts"; import type { CoreMessage, LiveUpdate, RunResult } from "./host/runner-types.ts"; import { + isDetachedSignalOwnerReady, registerDetachedProcessTreeFromEnv, unregisterDetachedProcessTreeFromEnv, } from "./detached-control.ts"; @@ -381,7 +382,7 @@ for (const signal of EXTERNAL_SIGNALS) { // Detached workers own a higher-level AbortController that persists the // run as paused. Let its later signal listener finish that state transition // instead of immediately restoring the OS default and killing the process. - if (process.env.TASKFLOW_DETACHED_RUNNER === "1") { + if (process.env.TASKFLOW_DETACHED_RUNNER === "1" && isDetachedSignalOwnerReady()) { handlingExternalSignal = false; return; } diff --git a/packages/taskflow-core/src/store.ts b/packages/taskflow-core/src/store.ts index 7ed67f0f..d2facabc 100644 --- a/packages/taskflow-core/src/store.ts +++ b/packages/taskflow-core/src/store.ts @@ -371,7 +371,12 @@ function lockPathForRun(runsRoot: string, flowName: string, runId: string): stri * Legitimate runIds are produced by newRunId() and contain only [A-Za-z0-9._-]. */ export function validateRunId(runId: string): boolean { - return typeof runId === "string" && runId.length <= 160 && /^[A-Za-z0-9_-](?:[A-Za-z0-9._-]*[A-Za-z0-9_-])?$/.test(runId) && !runId.includes(".."); + // A single leading/trailing dot is safe once the id is used as + // `${runId}.json`, and `newRunId()` has historically produced leading-dot + // ids for valid flow names such as `.ci`. Reject separators and dot-dot + // traversal, but retain compatibility with those already-persisted runs. + return typeof runId === "string" && runId.length > 0 && runId.length <= 160 && + /^[A-Za-z0-9._-]+$/.test(runId) && !runId.includes(".."); } // --------------------------------------------------------------------------- diff --git a/packages/taskflow-core/test/detached-control.test.ts b/packages/taskflow-core/test/detached-control.test.ts index 04e75bc8..10d656cb 100644 --- a/packages/taskflow-core/test/detached-control.test.ts +++ b/packages/taskflow-core/test/detached-control.test.ts @@ -10,6 +10,7 @@ import { detachedControlDir, DETACHED_CONTROL_CWD_ENV, DETACHED_CONTROL_INSTANCE_ENV, + DETACHED_CONTROL_OWNER_PID_ENV, DETACHED_CONTROL_RUN_ID_ENV, probeProcess, registerDetachedProcessTreeFromEnv, @@ -75,6 +76,7 @@ test("detached control: registered Host CLI process groups are reaped by instanc cwd: process.env[DETACHED_CONTROL_CWD_ENV], runId: process.env[DETACHED_CONTROL_RUN_ID_ENV], instance: process.env[DETACHED_CONTROL_INSTANCE_ENV], + ownerPid: process.env[DETACHED_CONTROL_OWNER_PID_ENV], }; const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: process.platform !== "win32", @@ -85,6 +87,14 @@ test("detached control: registered Host CLI process groups are reaped by instanc process.env[DETACHED_CONTROL_CWD_ENV] = cwd; process.env[DETACHED_CONTROL_RUN_ID_ENV] = runId; process.env[DETACHED_CONTROL_INSTANCE_ENV] = instanceId; + process.env[DETACHED_CONTROL_OWNER_PID_ENV] = String(process.pid + 1); + registerDetachedProcessTreeFromEnv(child.pid!); + assert.deepEqual( + terminateDetachedProcessTrees(cwd, runId, instanceId), + [], + "an inherited detached context cannot register from a non-owner process", + ); + process.env[DETACHED_CONTROL_OWNER_PID_ENV] = String(process.pid); registerDetachedProcessTreeFromEnv(child.pid!); assert.deepEqual(terminateDetachedProcessTrees(cwd, runId, "wrong-instance"), []); assert.deepEqual(terminateDetachedProcessTrees(cwd, runId, instanceId), [child.pid]); @@ -103,6 +113,8 @@ test("detached control: registered Host CLI process groups are reaped by instanc else process.env[DETACHED_CONTROL_RUN_ID_ENV] = previousContext.runId; if (previousContext.instance === undefined) delete process.env[DETACHED_CONTROL_INSTANCE_ENV]; else process.env[DETACHED_CONTROL_INSTANCE_ENV] = previousContext.instance; + if (previousContext.ownerPid === undefined) delete process.env[DETACHED_CONTROL_OWNER_PID_ENV]; + else process.env[DETACHED_CONTROL_OWNER_PID_ENV] = previousContext.ownerPid; restore(); fs.rmSync(root, { recursive: true, force: true }); } diff --git a/packages/taskflow-core/test/store-extended.test.ts b/packages/taskflow-core/test/store-extended.test.ts index fc8580a7..b238473d 100644 --- a/packages/taskflow-core/test/store-extended.test.ts +++ b/packages/taskflow-core/test/store-extended.test.ts @@ -7,7 +7,7 @@ import { test } from "node:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { hashInput, newRunId, writeFileAtomic } from "../src/store.ts"; +import { hashInput, newRunId, validateRunId, writeFileAtomic } from "../src/store.ts"; // ════════════════════════════════════════════════════════════════════ // HASH INPUT @@ -73,6 +73,18 @@ test("newRunId: long flow name is truncated", () => { assert.ok(prefix.length <= 24, `prefix length ${prefix.length} should be <= 24`); }); +test("newRunId: dot-leading flow names remain valid and loadable", () => { + const id = newRunId(".ci"); + assert.ok(id.startsWith(".ci-")); + assert.equal(validateRunId(id), true); +}); + +test("validateRunId: rejects traversal and path separators", () => { + for (const id of ["../escape", "a..b", "a/b", "a\\b", "", "x".repeat(161)]) { + assert.equal(validateRunId(id), false, `expected ${JSON.stringify(id)} to be rejected`); + } +}); + // ════════════════════════════════════════════════════════════════════ // WRITE FILE ATOMIC // ════════════════════════════════════════════════════════════════════ diff --git a/packages/taskflow-mcp-core/src/mcp/background.ts b/packages/taskflow-mcp-core/src/mcp/background.ts index 916c74f0..b674f1b1 100644 --- a/packages/taskflow-mcp-core/src/mcp/background.ts +++ b/packages/taskflow-mcp-core/src/mcp/background.ts @@ -196,7 +196,10 @@ function isStructurallyUsableRun(state: RunState): boolean { state && typeof state === "object" && typeof state.runId === "string" && typeof state.cwd === "string" && state.def && Array.isArray(state.def.phases) && - state.phases && typeof state.phases === "object" && + state.def.phases.every((phase) => phase && typeof phase === "object" && typeof phase.id === "string") && + state.phases && typeof state.phases === "object" && !Array.isArray(state.phases) && + Object.values(state.phases).every((phase) => + phase && typeof phase === "object" && typeof phase.status === "string") && ["running", "completed", "failed", "paused", "blocked"].includes(state.status), ); } @@ -210,20 +213,33 @@ function refreshDetachedState(cwd: string, state: RunState): RunState | null { const liveness = probeProcess(state.pid); if (state.detachedControlVersion === DETACHED_CONTROL_VERSION && state.detachedInstanceId) { const registry = readDetachedProcessRegistry(state.cwd, state.runId); + const registryMatches = Boolean( + registry && registry.instanceId === state.detachedInstanceId && registry.ownerPid === state.pid, + ); const heartbeatFresh = Boolean( - registry && registry.instanceId === state.detachedInstanceId && - registry.ownerPid === state.pid && Date.now() - registry.heartbeatAt <= HEARTBEAT_STALE_MS, + registryMatches && registry && Date.now() - registry.heartbeatAt <= HEARTBEAT_STALE_MS, ); if (liveness !== "dead" && heartbeatFresh) return state; if ( liveness !== "dead" && !registry && Date.now() - (state.detachedStartedAt ?? state.createdAt) <= STARTING_GRACE_MS ) return state; - // For current-protocol workers the private heartbeat is authoritative. - // EPERM from signal 0 must not keep a run phantom-running forever once - // the startup grace has elapsed without a registry heartbeat. - terminateDetachedProcessTrees(state.cwd, state.runId, state.detachedInstanceId); - clearDetachedProcessRegistry(state.cwd, state.runId, state.detachedInstanceId); + if (liveness === "alive" && registryMatches) { + // A live owner that stopped renewing its authenticated lease is stuck. + // Kill the owner before publishing a terminal state; otherwise it could + // resume later and mutate the workspace after status reported failure. + killProcessTree(state.pid, "SIGKILL"); + terminateDetachedProcessTrees(state.cwd, state.runId, state.detachedInstanceId); + clearDetachedProcessRegistry(state.cwd, state.runId, state.detachedInstanceId); + } else if (liveness === "dead") { + terminateDetachedProcessTrees(state.cwd, state.runId, state.detachedInstanceId); + clearDetachedProcessRegistry(state.cwd, state.runId, state.detachedInstanceId); + } else { + // A live/EPERM pid without a matching instance lease cannot safely be + // signalled or terminalized: it may be an unrelated reused PID. Keep the + // state conservative until cancellation succeeds or liveness is proven. + return state; + } } else if (isProcessAlive(state.pid)) { return state; } @@ -301,8 +317,11 @@ export function listMcpBackgroundRuns( function phaseProgress(state: RunState): { done: number; total: number } { const total = state.def.phases.length; - const done = Object.values(state.phases).filter((phase) => phase.status !== "running").length; - return { done: Math.min(done, total), total }; + const done = state.def.phases.filter((phase) => { + const stored = state.phases[phase.id]; + return stored !== undefined && stored.status !== "running"; + }).length; + return { done, total }; } function statusGlyph(status: RunState["status"]): string { @@ -326,7 +345,7 @@ export function formatBackgroundRun(state: RunState, includeOutput: boolean): st return cancel ? `${first} · cancellation requested` : first; } const source = state.outputSourcePhaseId ? `--- ${state.outputSourcePhaseId} ---\n` : ""; - if (state.finalOutput !== undefined) { + if (typeof state.finalOutput === "string") { const truncated = state.finalOutput.length > MAX_PRESENTED_OUTPUT_CHARS ? `${state.finalOutput.slice(0, MAX_PRESENTED_OUTPUT_CHARS)}\n\n… output truncated; use taskflow_peek for targeted inspection.` : state.finalOutput; diff --git a/packages/taskflow-mcp-core/src/mcp/server.ts b/packages/taskflow-mcp-core/src/mcp/server.ts index 6403e22b..e827d7bc 100644 --- a/packages/taskflow-mcp-core/src/mcp/server.ts +++ b/packages/taskflow-mcp-core/src/mcp/server.ts @@ -53,8 +53,6 @@ import { newRunId, peekRun, saveRun, - DEFAULT_KEPT_RUNS, - DEFAULT_RUN_AGE_DAYS, readDefineFile, describeLoadFailure, compileTaskflow, @@ -878,7 +876,10 @@ export function makeToolHandlers( // Persist run state (throttled + final) so taskflow_peek / resume can read // intermediate phase outputs after the run — same contract as the pi adapter. - const cleanupConfig = { maxKeep: DEFAULT_KEPT_RUNS, maxAgeDays: DEFAULT_RUN_AGE_DAYS }; + const cleanupConfig = { + maxKeep: settings.taskflow.maxKeptRuns, + maxAgeDays: settings.taskflow.maxRunAgeDays, + }; let lastPersist = 0; deps.persist = (s) => { const now = Date.now(); @@ -1012,7 +1013,10 @@ export function makeToolHandlers( cwdBridgeMode: cwdBridgeModeFromEnv(), trace: new FileTraceSink(traceFilePath(runsDir(cwd), child.flowName, child.runId)), }; - const cleanupConfig = { maxKeep: DEFAULT_KEPT_RUNS, maxAgeDays: DEFAULT_RUN_AGE_DAYS }; + const cleanupConfig = { + maxKeep: settings.taskflow.maxKeptRuns, + maxAgeDays: settings.taskflow.maxRunAgeDays, + }; let lastPersist = 0; deps.persist = (s) => { if (Date.now() - lastPersist >= 1000) { lastPersist = Date.now(); saveRun(s, cleanupConfig); } }; let terminalPersistError: string | undefined; diff --git a/packages/taskflow-mcp-core/test/background-runs.test.ts b/packages/taskflow-mcp-core/test/background-runs.test.ts index 4533489a..5d08101c 100644 --- a/packages/taskflow-mcp-core/test/background-runs.test.ts +++ b/packages/taskflow-mcp-core/test/background-runs.test.ts @@ -1,11 +1,13 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { pathToFileURL } from "node:url"; import { DETACHED_CONTROL_VERSION, + detachedProcessRegistryPath, loadRun, newRunId, probeProcess, @@ -102,6 +104,27 @@ test("mcp background: run returns immediately and wait returns durable final out } }); +test("mcp background: dot-leading flow names keep a durable run id", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-background-dot-")); + const restoreAgentDir = usePrivateAgentDir(cwd); + try { + const tools = makeToolHandlers(cwd, unusedForegroundRunner, { + host: "test", + detachedRunner: { module: fixtureModule(), exportName: "instantRunner" }, + }); + const started = await tools.taskflow_run({ define: inlineAgentFlow(".ci"), mode: "background" }) as TextResult; + assert.equal(started.isError, false, started.content[0]?.text); + const runId = runIdFrom(started); + assert.ok(runId.startsWith(".ci-")); + const waited = await tools.taskflow_runs({ action: "wait", runId, timeoutMs: 5_000 }) as TextResult; + assert.equal(waited.isError, false, waited.content[0]?.text); + assert.equal(loadRun(cwd, runId)?.status, "completed"); + } finally { + restoreAgentDir(); + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + test("mcp background: cancel survives request boundaries and pauses the run", async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-cancel-")); const restoreAgentDir = usePrivateAgentDir(cwd); @@ -180,6 +203,50 @@ test("mcp background: malformed historical state cannot turn a successful launch } }); +test("mcp background: malformed optional output cannot crash status formatting", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-malformed-output-")); + const restoreAgentDir = usePrivateAgentDir(cwd); + try { + const malformed = runningBackgroundState(cwd, "malformed-output"); + malformed.status = "failed"; + malformed.finalOutput = null as unknown as string; + malformed.phases["__detach__"] = { + id: "__detach__", + status: "failed", + error: "detached launch failed", + endedAt: Date.now(), + }; + saveRun(malformed); + + const tools = makeToolHandlers(cwd, unusedForegroundRunner); + const status = await tools.taskflow_runs({ action: "status", runId: malformed.runId }) as TextResult; + assert.equal(status.isError, false); + assert.match(status.content[0]!.text, /0\/1 phases/); + assert.match(status.content[0]!.text, /detached launch failed/); + } finally { + restoreAgentDir(); + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("mcp background: malformed phase definitions are rejected without crashing status", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-malformed-phase-")); + const restoreAgentDir = usePrivateAgentDir(cwd); + try { + const malformed = runningBackgroundState(cwd, "malformed-phase"); + malformed.def.phases = [null as unknown as Taskflow["phases"][number]]; + saveRun(malformed); + + const tools = makeToolHandlers(cwd, unusedForegroundRunner); + const status = await tools.taskflow_runs({ action: "status", runId: malformed.runId }) as TextResult; + assert.equal(status.isError, true); + assert.match(status.content[0]!.text, /was not found/); + } finally { + restoreAgentDir(); + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + test("mcp background: legacy detached workers fail closed for cancel", async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-legacy-cancel-")); const restoreAgentDir = usePrivateAgentDir(cwd); @@ -196,7 +263,7 @@ test("mcp background: legacy detached workers fail closed for cancel", async () } }); -test("mcp background: current worker without a heartbeat leaves running after startup grace", async () => { +test("mcp background: dead current-protocol worker without a heartbeat becomes failed", async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-missing-heartbeat-")); const restoreAgentDir = usePrivateAgentDir(cwd); try { @@ -218,6 +285,75 @@ test("mcp background: current worker without a heartbeat leaves running after st } }); +test("mcp background: a live pid without an authenticated lease is not falsely terminalized", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-unverified-live-")); + const restoreAgentDir = usePrivateAgentDir(cwd); + const worker = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + detached: true, + stdio: "ignore", + }); + assert.ok(worker.pid); + try { + const state = runningBackgroundState(cwd, "unverified-live"); + state.detachedControlVersion = DETACHED_CONTROL_VERSION; + state.detachedInstanceId = "unverified-live-instance"; + state.detachedStartedAt = Date.now() - 10_000; + state.pid = worker.pid; + saveRun(state); + + const tools = makeToolHandlers(cwd, unusedForegroundRunner); + const status = await tools.taskflow_runs({ action: "status", runId: state.runId }) as TextResult; + assert.match(status.content[0]!.text, /running/); + assert.equal(loadRun(cwd, state.runId)?.status, "running"); + assert.notEqual(probeProcess(worker.pid!), "dead"); + } finally { + try { process.kill(process.platform === "win32" ? worker.pid! : -worker.pid!, "SIGKILL"); } catch { /* already gone */ } + restoreAgentDir(); + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("mcp background: a stale authenticated lease kills its owner before terminalizing", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-stale-lease-")); + const restoreAgentDir = usePrivateAgentDir(cwd); + const worker = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + detached: true, + stdio: "ignore", + }); + assert.ok(worker.pid); + try { + const state = runningBackgroundState(cwd, "stale-lease"); + state.detachedControlVersion = DETACHED_CONTROL_VERSION; + state.detachedInstanceId = "stale-lease-instance"; + state.detachedStartedAt = Date.now() - 60_000; + state.pid = worker.pid; + saveRun(state); + const registryPath = detachedProcessRegistryPath(cwd, state.runId); + fs.mkdirSync(path.dirname(registryPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(registryPath, JSON.stringify({ + version: DETACHED_CONTROL_VERSION, + instanceId: state.detachedInstanceId, + ownerPid: worker.pid, + heartbeatAt: Date.now() - 60_000, + pids: [], + })); + + const tools = makeToolHandlers(cwd, unusedForegroundRunner); + const status = await tools.taskflow_runs({ action: "status", runId: state.runId }) as TextResult; + assert.match(status.content[0]!.text, /failed/); + assert.equal(loadRun(cwd, state.runId)?.status, "failed"); + const deadline = Date.now() + 5_000; + while (probeProcess(worker.pid!) !== "dead" && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + assert.equal(probeProcess(worker.pid!), "dead", "owner must stop before failure becomes durable"); + } finally { + try { process.kill(process.platform === "win32" ? worker.pid! : -worker.pid!, "SIGKILL"); } catch { /* already gone */ } + restoreAgentDir(); + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + test("mcp background: sibling worktrees sharing an ancestor .pi remain isolated", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-worktrees-")); const cwdA = path.join(root, "worktree-a"); From fac5637e89795854b9ce82821c9d99d52574b641 Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 18 Jul 2026 16:42:54 +0800 Subject: [PATCH 5/8] fix(runtime): harden retention and detached inputs --- CHANGELOG.md | 4 +- .../pi-taskflow/test/detached-spawn.test.ts | 78 ++++-- packages/taskflow-core/src/detached-runner.ts | 46 +++- packages/taskflow-core/src/store.ts | 232 ++++++++++++------ .../taskflow-core/test/run-retention.test.ts | 101 ++++++++ .../en/compiler-runtime/background-runs.mdx | 6 +- .../compiler-runtime/background-runs.mdx | 6 +- 7 files changed, 362 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db72532..97e85e21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,9 @@ All notable changes to taskflow are documented here. This project follows [Keep ### Fixed - **Atomic terminal results.** The runtime now persists `finalOutput` and `outputSourcePhaseId` in the same terminal-state write, so a crash or immediate poll can never observe `completed` without its result. -- **Detached-run ownership and cleanup.** Cancellation and process-heartbeat records now live in a user-private control directory keyed by canonical invocation root, preventing project symlinks and sibling worktrees from redirecting or cross-cancelling runs. Current workers carry a versioned instance identity; stale authenticated workers are killed before terminalization and their registered Host CLI process groups are reaped, inherited detached-runner environment variables cannot claim an outer run, and ambiguous legacy runs fail closed for cancellation. +- **Detached-run ownership and cleanup.** Cancellation and process-heartbeat records now live in a user-private control directory keyed by canonical invocation root, preventing project symlinks and sibling worktrees from redirecting or cross-cancelling runs. Current workers carry a versioned instance identity; stale authenticated workers are killed before terminalization and their registered Host CLI process groups are reaped, inherited detached-runner environment variables cannot claim an outer run, the spawn-only worker consumes only user-owned private temp contexts, and ambiguous legacy runs fail closed for cancellation. - **Foreground/background parity.** MCP and Pi detached launches snapshot the same agent scope, model roles, global thinking, runner profile, incremental settings, and retention policy as the foreground invocation. Pi now uses fully detached stdio and transactionally reaps the worker plus its private launch context when post-spawn setup fails. Launch failures preserve their real cause, and post-spawn roster diagnostics can no longer misreport a successfully started run as a launch failure. -- **Bounded run history and accurate rosters.** Retention now applies to every inactive state (`completed`, `failed`, `paused`, and `blocked`) while never pruning active runs. MCP background lists compute counts from the complete project roster without the former 1000-run cap or per-row reload pattern. +- **Bounded run history and accurate rosters.** Retention now applies to every inactive state (`completed`, `failed`, `paused`, and `blocked`) while never pruning active runs. Cleanup validates every indexed path and rechecks the selected snapshot under the run lock, preventing traversal and resume/delete races. MCP background lists compute counts from the complete project roster without the former 1000-run cap or per-row reload pattern. - **Run-id and status compatibility.** Dot-leading flow names now produce persistable run IDs without weakening traversal guards. Background status ignores synthetic launch phases in progress totals and tolerates malformed legacy optional output instead of crashing the MCP request. - **Release surface synchronization.** Package/plugin versions, installation pins, built-dist MCP expectations, the 16-tool roster, background-run documentation, and English/Chinese host guides are synchronized for 0.2.3. diff --git a/packages/pi-taskflow/test/detached-spawn.test.ts b/packages/pi-taskflow/test/detached-spawn.test.ts index c549b4c5..4d607801 100644 --- a/packages/pi-taskflow/test/detached-spawn.test.ts +++ b/packages/pi-taskflow/test/detached-spawn.test.ts @@ -53,34 +53,39 @@ test("detached-runner: the resolved module loads (spawn → exits, does not ENOE // (run not found), but the key assertion is that it does NOT die at import // time with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING / Cannot find module. const resolved = fileURLToPath(import.meta.resolve(DETACHED_RUNNER_SPECIFIER)); - const tmpCtx = join(tmpdir(), `issue3-ctx-${process.pid}-${Date.now()}.json`); + const privateDir = mkdtempSync(join(tmpdir(), "taskflow-detach-load-")); + const tmpCtx = join(privateDir, "context.json"); writeFileSync(tmpCtx, JSON.stringify({ runId: "bogus", defName: "nope", args: {}, cwd: tmpdir() })); - await new Promise((resolve) => { - const child = spawn(process.execPath, [resolved, tmpCtx], { - stdio: ["ignore", "pipe", "pipe"], - }); - let stderr = ""; - child.stderr.on("data", (c: Buffer) => { stderr += c.toString(); }); - child.on("exit", (code) => { - // It is expected to exit non-zero (bogus run). The regression would be a - // module-load failure: ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING or - // "Cannot find module …detached-runner.js.js". - assert.doesNotMatch( - stderr, - /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/, - "must not hit the node_modules type-strip guardrail (loads compiled JS)", - ); - assert.doesNotMatch( - stderr, - /Cannot find module[\s\S]*\.js\.js/, - "must not hit the double-suffix ENOENT (issue #3)", - ); - assert.notEqual(code, null, "child must have exited (not hang)"); - resolve(); + try { + await new Promise((resolve) => { + const child = spawn(process.execPath, [resolved, tmpCtx], { + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (c: Buffer) => { stderr += c.toString(); }); + child.on("exit", (code) => { + // It is expected to exit non-zero (bogus run). The regression would be a + // module-load failure: ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING or + // "Cannot find module …detached-runner.js.js". + assert.doesNotMatch( + stderr, + /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/, + "must not hit the node_modules type-strip guardrail (loads compiled JS)", + ); + assert.doesNotMatch( + stderr, + /Cannot find module[\s\S]*\.js\.js/, + "must not hit the double-suffix ENOENT (issue #3)", + ); + assert.notEqual(code, null, "child must have exited (not hang)"); + resolve(); + }); + child.on("error", () => resolve()); }); - child.on("error", () => resolve()); - }); + } finally { + rmSync(privateDir, { recursive: true, force: true }); + } }); test("detached-runner: malformed context still removes its private authorization directory", async () => { @@ -102,6 +107,25 @@ test("detached-runner: malformed context still removes its private authorization } }); +test("detached-runner: refuses an arbitrary context path without deleting it", async () => { + const resolved = fileURLToPath(new URL("../../taskflow-core/src/detached-runner.ts", import.meta.url)); + const untrustedDir = mkdtempSync(join(tmpdir(), "taskflow-untrusted-context-")); + const contextPath = join(untrustedDir, "context.json"); + writeFileSync(contextPath, JSON.stringify({ runId: "bogus", cwd: tmpdir() })); + try { + await new Promise((resolve) => { + const child = spawn(process.execPath, ["--conditions=development", "--experimental-strip-types", resolved, contextPath], { + stdio: ["ignore", "ignore", "ignore"], + }); + child.once("close", () => resolve()); + child.once("error", () => resolve()); + }); + assert.equal(existsSync(contextPath), true, "untrusted command-line paths must never be consumed"); + } finally { + rmSync(untrustedDir, { recursive: true, force: true }); + } +}); + // --------------------------------------------------------------------------- // Bug 2: a child that dies early must not leave the run stuck at "running". // @@ -217,6 +241,7 @@ test("detached: crash guard does NOT clobber a genuine terminal state", () => { test("detached-runner: injects the host runner so phases do not hit 'No subagent runner injected'", async () => { const resolved = fileURLToPath(import.meta.resolve(DETACHED_RUNNER_SPECIFIER)); const cwd = mkdtempSync(join(tmpdir(), "issue3-runner-")); + const contextDir = mkdtempSync(join(tmpdir(), "taskflow-detach-runner-")); try { const def: Taskflow = { name: "runner-inject", @@ -228,7 +253,7 @@ test("detached-runner: injects the host runner so phases do not hit 'No subagent status: "running", phases: {}, createdAt: Date.now(), updatedAt: Date.now(), cwd, }); - const ctxFile = join(tmpdir(), `issue3-runner-ctx-${process.pid}-${Date.now()}.json`); + const ctxFile = join(contextDir, "context.json"); // Mirror the host: resolve the runner module from pi-taskflow itself so it // works under both dev (.ts) and prod (.js) conditions. const runnerModule = fileURLToPath(import.meta.resolve("../src/runner.ts")); @@ -277,5 +302,6 @@ test("detached-runner: injects the host runner so phases do not hit 'No subagent ); } finally { rmSync(cwd, { recursive: true, force: true }); + rmSync(contextDir, { recursive: true, force: true }); } }); diff --git a/packages/taskflow-core/src/detached-runner.ts b/packages/taskflow-core/src/detached-runner.ts index 255b7dc6..2e651ecf 100644 --- a/packages/taskflow-core/src/detached-runner.ts +++ b/packages/taskflow-core/src/detached-runner.ts @@ -9,8 +9,9 @@ * This file is NOT imported by index.ts — it is spawned via `child_process.spawn`. */ -import { existsSync, readFileSync, rmdirSync, unlinkSync } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { existsSync, lstatSync, readFileSync, realpathSync, rmdirSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; import { type AgentConfig, type AgentScope, discoverAgents, readSubagentSettings } from "./agents.ts"; import { executeTaskflow } from "./runtime.ts"; import { cwdBridgeModeFromEnv } from "./cwd-bridge.ts"; @@ -74,6 +75,39 @@ interface DetachContext { const START_GATE_TIMEOUT_MS = 5_000; +/** + * The spawn-only entry consumes and deletes its context file. Refuse arbitrary + * command-line paths so a direct or malformed invocation cannot turn that + * one-shot cleanup into an unlink primitive outside Taskflow's private temp + * directory. + */ +function isPrivateDetachContextPath(candidate: string): boolean { + if (!isAbsolute(candidate) || basename(candidate) !== "context.json") return false; + const parent = dirname(candidate); + if (!basename(parent).startsWith("taskflow-detach-")) return false; + + try { + const tempRoot = realpathSync(tmpdir()); + const realParent = realpathSync(parent); + const rel = relative(tempRoot, realParent); + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + return false; + } + + const parentStat = lstatSync(parent); + const fileStat = lstatSync(candidate); + if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) return false; + if (!fileStat.isFile() || fileStat.isSymbolicLink()) return false; + if (typeof process.getuid === "function") { + const uid = process.getuid(); + if (parentStat.uid !== uid || fileStat.uid !== uid) return false; + } + return true; + } catch { + return false; + } +} + async function waitForStartGate(contextDir: string): Promise { const startPath = join(contextDir, "start"); const deadline = Date.now() + START_GATE_TIMEOUT_MS; @@ -99,6 +133,10 @@ if (!contextPath) { console.error("[detached-runner] Missing context file path argument"); process.exit(1); } +if (!isPrivateDetachContextPath(contextPath)) { + console.error("[detached-runner] Refusing context outside a private taskflow-detach temp directory"); + process.exit(1); +} let ctx: DetachContext | undefined; try { @@ -199,7 +237,9 @@ try { // stub — burying the real cause (a bad module path, a compile-time specifier // bug, a missing export). Exiting non-zero here routes the real stderr // message into the host's early-exit crash guard, which persists it on the - // run's synthetic __detach__ phase where it is pollable and debuggable. + // run's synthetic __detach__ phase where it is pollable and debuggable. The + // non-zero exit then lets the parent guard observe that terminal record + // without overwriting it; detached stdio itself is intentionally ignored. if (ctx.runnerModule && !injectedRunner) { state.status = "failed"; state.phases["__detach__"] = { diff --git a/packages/taskflow-core/src/store.ts b/packages/taskflow-core/src/store.ts index d2facabc..31f43bef 100644 --- a/packages/taskflow-core/src/store.ts +++ b/packages/taskflow-core/src/store.ts @@ -291,6 +291,8 @@ const LOCK_STALE_MS = 30_000; const LOCK_POLL_MS = 50; /** Default acquisition timeout before throwing. */ const LOCK_TIMEOUT_MS = 10_000; +/** Retention is opportunistic: never stall a foreground save behind cleanup. */ +const CLEANUP_LOCK_TIMEOUT_MS = 250; // --------------------------------------------------------------------------- // Cleanup throttle @@ -379,6 +381,33 @@ export function validateRunId(runId: string): boolean { /^[A-Za-z0-9._-]+$/.test(runId) && !runId.includes(".."); } +/** + * Validate an index path before it is joined to the runs root. + * + * Index files live in a project-controlled directory and may be stale or + * manually edited. Only the two layouts Taskflow itself has ever emitted are + * accepted: `/.json` and the legacy `.json`. Keeping + * this check independent from the host OS also makes an index written on one + * platform safe to consume on another (generated index paths always use `/`). + */ +function isSafeRunIndexRelPath(relPath: string, runId: string): boolean { + if (!validateRunId(runId) || relPath.length === 0 || relPath.length > 420) return false; + if (path.isAbsolute(relPath) || relPath.includes("\\")) return false; + + const parts = relPath.split("/"); + if (parts.some((part) => part.length === 0 || part === "." || part === "..")) return false; + if (parts.length === 1) return parts[0] === `${runId}.json`; + if (parts.length !== 2) return false; + + const [flowDir, fileName] = parts; + return flowDir!.length <= 255 && safeFlowDirName(flowDir!) === flowDir && fileName === `${runId}.json`; +} + +/** Join an already-validated, platform-neutral index path to the runs root. */ +function runIndexFilePath(runsRoot: string, relPath: string): string { + return path.join(runsRoot, ...relPath.split("/")); +} + // --------------------------------------------------------------------------- // File-lock primitives — zero-dependency, using O_CREAT|O_EXCL (atomic) // --------------------------------------------------------------------------- @@ -452,8 +481,8 @@ function releaseLock(lockPath: string): void { /** * Execute `fn` while holding a file lock. Guarantees release even on throw. */ -export function withLock(lockPath: string, fn: () => T): T { - acquireLock(lockPath); +export function withLock(lockPath: string, fn: () => T, timeoutMs: number = LOCK_TIMEOUT_MS): T { + acquireLock(lockPath, timeoutMs); try { return fn(); } finally { @@ -490,9 +519,11 @@ function readIndex(runsRoot: string): RunIndexEntry[] { const raw = fs.readFileSync(indexPath(runsRoot), "utf-8"); const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; - // Validate each entry minimally. + // Treat the project-controlled index as untrusted input. In particular, + // never let a crafted relPath escape runsRoot during load or retention. return (parsed as RunIndexEntry[]).filter( - (e) => e && typeof e.runId === "string" && typeof e.relPath === "string", + (e) => e && typeof e.runId === "string" && typeof e.relPath === "string" && + isSafeRunIndexRelPath(e.relPath, e.runId), ); } catch { return []; @@ -557,13 +588,10 @@ function rebuildIndex(runsRoot: string): RunIndexEntry[] { } catch { continue; } for (const file of files) { - try { - const raw = fs.readFileSync(path.join(dirPath, file), "utf-8"); - const state = JSON.parse(raw) as RunState; - if (state && typeof state.runId === "string") { - entries.set(state.runId, extractIndexEntry(state, `${dirName}/${file}`)); - } - } catch { /* skip corrupt */ } + const loaded = tryReadRunFile(runsRoot, path.join(dirPath, file)); + if (loaded.ok && validateRunId(loaded.value.runId) && file === `${loaded.value.runId}.json`) { + entries.set(loaded.value.runId, extractIndexEntry(loaded.value, `${dirName}/${file}`)); + } } } @@ -579,13 +607,11 @@ function rebuildIndex(runsRoot: string): RunIndexEntry[] { for (const file of flatFiles) { if (entries.has(file.replace(/\.json$/, ""))) continue; // prefer subdir entry - try { - const raw = fs.readFileSync(path.join(runsRoot, file), "utf-8"); - const state = JSON.parse(raw) as RunState; - if (state && typeof state.runId === "string" && !entries.has(state.runId)) { - entries.set(state.runId, extractIndexEntry(state, file)); - } - } catch { /* skip corrupt */ } + const loaded = tryReadRunFile(runsRoot, path.join(runsRoot, file)); + if (loaded.ok && validateRunId(loaded.value.runId) && file === `${loaded.value.runId}.json` && + !entries.has(loaded.value.runId)) { + entries.set(loaded.value.runId, extractIndexEntry(loaded.value, file)); + } } const scanned = Array.from(entries.values()); @@ -618,16 +644,16 @@ function rebuildIndex(runsRoot: string): RunIndexEntry[] { * race a concurrent updateIndexEntry and clobber a freshly-added entry (M1). * We re-read the index *inside* the lock (rather than trusting a snapshot read * before locking) so the rewrite reflects the latest committed state. File and - * directory unlinks happen after the lock is released to keep the critical - * section short; deleting a file that is no longer in the index is harmless. + * directory unlinks happen after the index lock is released, but each candidate + * is revalidated while holding its own run lock. This prevents cleanup from + * unlinking a run that was resumed or otherwise saved after selection. */ function cleanupTerminalRuns( runsRoot: string, maxKeep: number = DEFAULT_MAX_KEPT_TERMINAL, maxAgeDays: number = DEFAULT_MAX_AGE_DAYS, ): void { - const cleanupStarted = Date.now(); - const now = cleanupStarted; + const now = Date.now(); if (now - lastCleanupAt < CLEANUP_INTERVAL_MS) return; lastCleanupAt = now; @@ -674,55 +700,113 @@ function cleanupTerminalRuns( // Commit the pruned index while holding the lock so a concurrent // updateIndexEntry cannot interleave and lose entries. - const remaining = cleanTerminal.filter((e) => !toRemove.includes(e)); + const removalSet = new Set(toRemove); + const remaining = cleanTerminal.filter((e) => !removalSet.has(e)); writeIndex(runsRoot, [...active, ...remaining]); }); if (toRemove.length === 0) return; - console.warn( - `[taskflow] Cleaning up ${toRemove.length} old run(s) ` + - `(max ${maxKeep} runs, ${maxAgeDays} day age limit). ` + - `Configure 'taskflow.maxKeptRuns' / 'taskflow.maxRunAgeDays' in settings.json (0 = keep all).`, - ); - - // Delete run files + lock files (outside the index lock). + // Delete artifacts outside the index lock, but under the exact per-run lock + // used by saveRun. Only the entries whose on-disk snapshots still match the + // selected index entries are safe to remove. + const removed: RunIndexEntry[] = []; for (const e of toRemove) { - const filePath = path.join(runsRoot, e.relPath); - // Race guard: skip files modified after cleanup started (Finding 2). - try { if (fs.statSync(filePath).mtimeMs > cleanupStarted) continue; } catch { continue; } - try { fs.unlinkSync(filePath); } catch { /* already gone */ } - // Also remove any orphaned lock file. - try { fs.unlinkSync(filePath + ".lock"); } catch { /* ignore */ } - // Also remove the deterministic-replay trace (sibling .trace.jsonl + - // its append lock) so pruned runs don't accumulate trace files. - try { fs.unlinkSync(filePath.replace(/\.json$/, ".trace.jsonl")); } catch { /* ignore */ } - try { fs.unlinkSync(filePath.replace(/\.json$/, ".trace.jsonl.lock")); } catch { /* ignore */ } - // Also remove the per-run Shared Context Tree directory (C6). Orphaned - // ctx dirs would otherwise accumulate under runs/ctx/ over many runs. - try { fs.rmSync(path.join(runsRoot, "ctx", e.runId), { recursive: true, force: true }); } catch { /* ignore */ } - // Remove user-private detached control records left by a process killed - // before it could consume/clear cancellation or process ownership state. - const controlDir = detachedControlDir(e.cwd ?? path.dirname(path.dirname(path.dirname(runsRoot)))); - try { fs.unlinkSync(path.join(controlDir, `${e.runId}.cancel.json`)); } catch { /* ignore */ } - try { fs.unlinkSync(path.join(controlDir, `${e.runId}.processes.json`)); } catch { /* ignore */ } - try { fs.rmdirSync(controlDir); } catch { /* other runs / missing */ } - // Also remove the per-run isolated-workspace dir tree (cwd:"dedicated"). - // `dedicated` workspaces are persistent by design; reclaim them once the - // run is pruned. The dir name uses the same sanitization as workspace.ts. - try { - const wsSeg = e.runId.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_").slice(0, 100) || "phase"; - fs.rmSync(path.join(runsRoot, "ws", wsSeg), { recursive: true, force: true }); - } catch { /* ignore */ } + if (cleanupRunArtifactsIfSnapshotMatches(runsRoot, e)) removed.push(e); + } + + if (removed.length > 0) { + console.warn( + `[taskflow] Cleaning up ${removed.length} old run(s) ` + + `(max ${maxKeep} runs, ${maxAgeDays} day age limit). ` + + `Configure 'taskflow.maxKeptRuns' / 'taskflow.maxRunAgeDays' in settings.json (0 = keep all).`, + ); } // Remove empty flow subdirectories. - for (const e of toRemove) { - const dirPath = path.dirname(path.join(runsRoot, e.relPath)); + for (const e of removed) { + const dirPath = path.dirname(runIndexFilePath(runsRoot, e.relPath)); try { fs.rmdirSync(dirPath); } catch { /* ENOTEMPTY or ENOENT — ignore */ } } } +/** + * Remove one retention candidate if it is still the exact state selected from + * the index. Returning false is deliberately fail-open: the run remains on + * disk, and a changed valid snapshot is restored to the index. + */ +function cleanupRunArtifactsIfSnapshotMatches(runsRoot: string, entry: RunIndexEntry): boolean { + if (!isSafeRunIndexRelPath(entry.relPath, entry.runId)) return false; + const filePath = runIndexFilePath(runsRoot, entry.relPath); + const restore = (state: RunState): void => { + try { updateIndexEntry(runsRoot, extractIndexEntry(state, entry.relPath)); } catch { /* best effort */ } + }; + + try { + return withLock(`${filePath}.lock`, () => { + // saveRun holds this same run lock until its index upsert commits. If a + // save won the race after cleanup pruned the old entry, that fresh entry + // is now visible and owns the file even when Date.now() reused the same + // millisecond/status values. Never remove a re-indexed candidate. + const wasReindexed = withLock(indexLockPath(runsRoot), () => + readIndex(runsRoot).some((current) => current.runId === entry.runId), + CLEANUP_LOCK_TIMEOUT_MS, + ); + if (wasReindexed) return false; + + let state: RunState; + try { + const stat = fs.lstatSync(filePath); + if (!stat.isFile() || stat.isSymbolicLink()) return false; + state = JSON.parse(fs.readFileSync(filePath, "utf-8")) as RunState; + } catch { + return false; + } + + if (state.runId !== entry.runId) return false; + if (state.status === "running" || state.status !== entry.status || state.updatedAt !== entry.updatedAt) { + restore(state); + return false; + } + + try { + fs.unlinkSync(filePath); + } catch { + restore(state); + return false; + } + + // Remove deterministic-replay trace while respecting its append lock. + const tracePath = filePath.replace(/\.json$/, ".trace.jsonl"); + try { + withLock( + `${tracePath}.lock`, + () => { try { fs.unlinkSync(tracePath); } catch { /* missing */ } }, + CLEANUP_LOCK_TIMEOUT_MS, + ); + } catch { /* best effort */ } + // Remove per-run Shared Context Tree and isolated-workspace artifacts. + try { fs.rmSync(path.join(runsRoot, "ctx", entry.runId), { recursive: true, force: true }); } catch { /* ignore */ } + try { + const wsSeg = entry.runId.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_").slice(0, 100) || "phase"; + fs.rmSync(path.join(runsRoot, "ws", wsSeg), { recursive: true, force: true }); + } catch { /* ignore */ } + + // Remove user-private detached control records left by an interrupted worker. + const fallbackCwd = path.dirname(path.dirname(path.dirname(runsRoot))); + const controlCwd = typeof entry.cwd === "string" ? entry.cwd : fallbackCwd; + const controlDir = detachedControlDir(controlCwd); + try { fs.unlinkSync(path.join(controlDir, `${entry.runId}.cancel.json`)); } catch { /* ignore */ } + try { fs.unlinkSync(path.join(controlDir, `${entry.runId}.processes.json`)); } catch { /* ignore */ } + try { fs.rmdirSync(controlDir); } catch { /* other runs / missing */ } + return true; + }, CLEANUP_LOCK_TIMEOUT_MS); + } catch { + // Retention is opportunistic and must never make saveRun fail. + return false; + } +} + // --------------------------------------------------------------------------- // Original helpers (unchanged) // --------------------------------------------------------------------------- @@ -1088,7 +1172,8 @@ export function loadRunDiagnosed(cwd: string, runId: string): LoadResult, { ok: false }> | null = null; const probe = (filePath: string): RunState | undefined => { const r = tryReadRunFile(root, filePath); - if (r.ok) return r.value; + // A filename/index record must never alias a different run's state. + if (r.ok) return r.value.runId === runId ? r.value : undefined; if (r.reason === "unparseable" && !corrupt) corrupt = r; return undefined; }; @@ -1193,31 +1278,30 @@ export function listRuns(cwd: string, limit = 20): RunState[] { for (const file of flatFiles) { const runIdFromName = file.replace(/\.json$/, ""); if (indexRunIds.has(runIdFromName)) continue; - try { - const raw = fs.readFileSync(path.join(root, file), "utf-8"); - const state = JSON.parse(raw) as RunState; - if (state && typeof state.runId === "string" && !indexRunIds.has(state.runId)) { - entries.push(extractIndexEntry(state, file)); - indexRunIds.add(state.runId); - } - } catch { /* skip corrupt */ } + const loaded = tryReadRunFile(root, path.join(root, file)); + if (loaded.ok && validateRunId(loaded.value.runId) && file === `${loaded.value.runId}.json` && + !indexRunIds.has(loaded.value.runId)) { + entries.push(extractIndexEntry(loaded.value, file)); + indexRunIds.add(loaded.value.runId); + } } - // Sort by updatedAt desc, slice to limit. + // Sort by updatedAt desc. Invalid/unreadable files do not consume the limit: + // otherwise one corrupt or replaced high-ranked entry could hide healthy + // history that follows it. // Filter out entries with non-numeric/NaN updatedAt BEFORE sorting to // prevent NaN from corrupting V8's sort order (which can displace valid // entries when a limit is applied). const valid = entries.filter((e) => typeof e.updatedAt === "number" && !Number.isNaN(e.updatedAt)); valid.sort((a, b) => b.updatedAt - a.updatedAt); - const sliced = valid.slice(0, limit); + const targetLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : valid.length; // Read full RunState for each entry. const runs: RunState[] = []; - for (const e of sliced) { - try { - const raw = fs.readFileSync(path.join(root, e.relPath), "utf-8"); - runs.push(JSON.parse(raw) as RunState); - } catch { /* file may have been deleted since index was built — skip */ } + for (const e of valid) { + if (runs.length >= targetLimit) break; + const loaded = tryReadRunFile(root, runIndexFilePath(root, e.relPath)); + if (loaded.ok && loaded.value.runId === e.runId) runs.push(loaded.value); } // F-010: filter out records with non-numeric/NaN updatedAt. diff --git a/packages/taskflow-core/test/run-retention.test.ts b/packages/taskflow-core/test/run-retention.test.ts index 01d68ab3..a5b860e1 100644 --- a/packages/taskflow-core/test/run-retention.test.ts +++ b/packages/taskflow-core/test/run-retention.test.ts @@ -51,3 +51,104 @@ test("retention: paused and blocked history is bounded while running work is pre fs.rmSync(cwd, { recursive: true, force: true }); } }); + +test("retention: a crafted index path cannot delete outside the runs root", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-retention-path-")); + fs.mkdirSync(path.join(cwd, ".pi")); + try { + const seed = await import(`../src/store.ts?retention-path-seed=${Date.now()}`) as typeof import("../src/store.ts"); + const root = seed.runsDir(cwd); + fs.mkdirSync(root, { recursive: true }); + + const sentinel = path.join(cwd, ".pi", "sentinel"); + fs.writeFileSync(sentinel, "do not delete"); + fs.utimesSync(sentinel, new Date(1_000), new Date(1_000)); + const malicious = { + ...seed.extractIndexEntry(state(cwd, "escape", "failed"), "../../sentinel"), + updatedAt: 1, + }; + fs.writeFileSync(path.join(root, "index.json"), JSON.stringify([malicious])); + + const cleanup = await import(`../src/store.ts?retention-path-cleanup=${Date.now()}`) as typeof import("../src/store.ts"); + cleanup.saveRun(state(cwd, "trigger", "running"), { maxKeep: 1, maxAgeDays: 1 }); + + assert.equal(fs.readFileSync(sentinel, "utf-8"), "do not delete"); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("retention: cleanup rechecks a candidate under its run lock", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-retention-race-")); + fs.mkdirSync(path.join(cwd, ".pi")); + try { + const seed = await import(`../src/store.ts?retention-race-seed=${Date.now()}`) as typeof import("../src/store.ts"); + seed.saveRun(state(cwd, "resumed", "failed"), { maxKeep: 0, maxAgeDays: 0 }); + const root = seed.runsDir(cwd); + const indexFile = path.join(root, "index.json"); + const entries = JSON.parse(fs.readFileSync(indexFile, "utf-8")) as Array<{ + runId: string; + status: RunState["status"]; + updatedAt: number; + relPath: string; + }>; + const candidate = entries.find((entry) => entry.runId === "resumed"); + assert.ok(candidate); + + const runFile = path.join(root, ...candidate.relPath.split("/")); + const resumed = JSON.parse(fs.readFileSync(runFile, "utf-8")) as RunState; + resumed.status = "running"; + resumed.updatedAt = Date.now() + 10_000; + fs.writeFileSync(runFile, JSON.stringify(resumed)); + // Make the old mtime-only guard select this file, proving that snapshot + // identity rather than timestamp heuristics protects the resumed run. + fs.utimesSync(runFile, new Date(1_000), new Date(1_000)); + candidate.status = "failed"; + candidate.updatedAt = 1; + fs.writeFileSync(indexFile, JSON.stringify(entries)); + + const cleanup = await import(`../src/store.ts?retention-race-cleanup=${Date.now()}`) as typeof import("../src/store.ts"); + cleanup.saveRun(state(cwd, "trigger", "running"), { maxKeep: 1, maxAgeDays: 1 }); + + assert.equal(cleanup.loadRun(cwd, "resumed")?.status, "running"); + assert.ok(cleanup.listRuns(cwd, 20).some((run) => run.runId === "resumed" && run.status === "running")); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("run index: list does not follow a run-file symlink outside the runs root", async (t) => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-index-symlink-")); + fs.mkdirSync(path.join(cwd, ".pi")); + try { + const store = await import(`../src/store.ts?retention-symlink=${Date.now()}`) as typeof import("../src/store.ts"); + const root = store.runsDir(cwd); + const flowDir = path.join(root, "retention"); + fs.mkdirSync(flowDir, { recursive: true }); + + const externalState = state(cwd, "symlinked", "completed"); + externalState.updatedAt += 10_000; + const validState = state(cwd, "valid", "completed"); + const externalFile = path.join(cwd, "outside-run.json"); + fs.writeFileSync(externalFile, JSON.stringify(externalState)); + fs.writeFileSync(path.join(flowDir, "valid.json"), JSON.stringify(validState)); + try { + fs.symlinkSync(externalFile, path.join(flowDir, "symlinked.json")); + } catch (error) { + t.skip(`symlink unavailable: ${String(error)}`); + return; + } + fs.writeFileSync( + path.join(root, "index.json"), + JSON.stringify([ + store.extractIndexEntry(externalState, "retention/symlinked.json"), + store.extractIndexEntry(validState, "retention/valid.json"), + ]), + ); + + assert.deepEqual(store.listRuns(cwd, 1).map((run) => run.runId), ["valid"]); + assert.equal(store.loadRun(cwd, "symlinked"), null); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/website/content/docs/en/compiler-runtime/background-runs.mdx b/website/content/docs/en/compiler-runtime/background-runs.mdx index 8c2995e6..1b8b2b68 100644 --- a/website/content/docs/en/compiler-runtime/background-runs.mdx +++ b/website/content/docs/en/compiler-runtime/background-runs.mdx @@ -148,7 +148,7 @@ Background runs can fail in ways that inline runs cannot: the child process can ### Top-level crash guard -The detached runner wraps its entire execution in a top-level `try/catch`. If an unhandled exception reaches the top level, the catch block persists `status: "failed"` to the run state before exiting. The run is never left in `status: "running"` after the child process dies. +The detached runner wraps its entire execution in a top-level `try/catch`. If an unhandled exception reaches the top level, the catch block persists `status: "failed"` to the run state before exiting. If the process dies before that catch can run, the parent guard or the next status refresh terminalizes the stale `running` record. ### Runner module failure @@ -166,7 +166,7 @@ This fail-fast behavior prevents the runner from limping on and failing every ph ### Early exit guard -The parent registers an `exit` handler. If the child exits with a non-zero code before reaching a terminal state, it marks the run failed and writes a `__detach__` phase. Pi also captures bounded child stderr; MCP records the exit code/signal and relies on the worker's own persisted diagnostic when available: +The parent registers an `exit` handler. If the child exits with a non-zero code before reaching a terminal state, it marks the run failed and writes a `__detach__` phase. Pi and MCP both detach standard I/O completely; the worker persists import/runtime diagnostics itself, while the guard records generic exit-code/signal metadata when no richer diagnostic was saved: ```json { @@ -287,7 +287,7 @@ When a detached run fails, the run state tells you why. Check the `__detach__` p /tf peek __detach__ ``` -If the `__detach__` phase exists, it contains the crash reason — a runner module import error, a spawn failure, or early-exit metadata (plus bounded stderr on Pi). If it does not exist, the failure was a normal flow failure. Check the other phases to find the root cause. +If the `__detach__` phase exists, it contains the crash reason — a runner module import error, a spawn failure, or early-exit metadata. If it does not exist, the failure was a normal flow failure. Check the other phases to find the root cause. ## Next diff --git a/website/content/docs/zh-cn/compiler-runtime/background-runs.mdx b/website/content/docs/zh-cn/compiler-runtime/background-runs.mdx index 24c62380..e0363330 100644 --- a/website/content/docs/zh-cn/compiler-runtime/background-runs.mdx +++ b/website/content/docs/zh-cn/compiler-runtime/background-runs.mdx @@ -40,7 +40,7 @@ Taskflow 'code-audit' started in background (pid: 48231). Run id: 20260706T14302 **上下文序列化到临时文件。** 私有 `/tmp/taskflow-detach-*/context.json` 文件携带 `runId`、`defName`、`args`、`cwd`、父调用冻结的配置快照,以及宿主适配器运行模块的绝对路径。这个文件是子进程的唯一输入,读取后即删除。 - **生成子进程。** 宿主 fork `node `,使用 `detached: true` 并立即 `unref()`。Pi 管道接收有限 stderr;MCP 使用 `stdio: "ignore"`,真实导入错误由 worker 直接持久化到 `__detach__`。 + **生成子进程。** 宿主 fork `node `,使用 `detached: true`、`stdio: "ignore"` 并立即 `unref()`。Pi 与 MCP 都完全分离标准输入输出;真实导入或运行错误由 worker 直接持久化到 `__detach__`。 **父进程返回 runId。** 子进程的 PID 写入 `RunState.pid`,Pi `taskflow` 工具响应同时包含 PID 和 runId。父会话可以继续其他工作。 @@ -90,7 +90,7 @@ Taskflow 'code-audit' started in background (pid: 48231). Run id: 20260706T14302 崩溃保护仅在运行仍为 `"running"` 且 PID 匹配时触发——它永远不会覆盖运行器已持久化的真正终态。这意味着: - **正常退出(code 0):** 运行器已持久化自己的状态。崩溃保护不执行操作。 -- **非零退出:** 崩溃保护检查运行状态。如果仍为 `"running"`,则标记为 `"failed"`;Pi 可附带有限 stderr,MCP 至少记录退出码/信号。 +- **非零退出:** 崩溃保护检查运行状态。如果仍为 `"running"`,则标记为 `"failed"`;worker 优先保留具体诊断,崩溃保护至少记录退出码/信号。 - **Spawn 错误:** `child.on("error")` 处理器用 spawn 错误信息写入相同的合成阶段。 `__detach__` 阶段可轮询、可调试——你可以在 `/tf runs` 和运行详情视图中看到它,就像任何其他阶段一样。 @@ -257,7 +257,7 @@ done └─ 用户轮询 /tf runs → 看到 "completed" ``` -如果子进程在写入终态之前异常退出,父进程的 `exit` 处理器或下一次 `taskflow_runs` 轮询会把它终态化为 `"failed"`;Pi 可保留有限 stderr,MCP 保留退出信息和 worker 已写入的诊断。 +如果子进程在写入终态之前异常退出,父进程的 `exit` 处理器或下一次 `taskflow_runs` 轮询会把它终态化为 `"failed"`;Pi 与 MCP 都保留退出信息和 worker 已写入的诊断。 ## 下一步 From 99c81cab8db18d28e594ed8e6dd5f8814db5f2fc Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 18 Jul 2026 17:23:56 +0800 Subject: [PATCH 6/8] fix(store): harden retention and lock ownership --- CHANGELOG.md | 3 +- packages/claude-taskflow/src/mcp/server.ts | 6 +- packages/codex-taskflow/src/mcp/server.ts | 6 +- packages/opencode-taskflow/src/mcp/server.ts | 6 +- .../pi-taskflow/test/e2e-terminal-reap.mts | 7 +- packages/taskflow-core/src/store.ts | 361 ++++++++++++++---- .../taskflow-core/test/run-retention.test.ts | 147 +++++++ packages/taskflow-core/test/store.test.ts | 76 +++- 8 files changed, 522 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e85e21..e58a4d41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ All notable changes to taskflow are documented here. This project follows [Keep - **Atomic terminal results.** The runtime now persists `finalOutput` and `outputSourcePhaseId` in the same terminal-state write, so a crash or immediate poll can never observe `completed` without its result. - **Detached-run ownership and cleanup.** Cancellation and process-heartbeat records now live in a user-private control directory keyed by canonical invocation root, preventing project symlinks and sibling worktrees from redirecting or cross-cancelling runs. Current workers carry a versioned instance identity; stale authenticated workers are killed before terminalization and their registered Host CLI process groups are reaped, inherited detached-runner environment variables cannot claim an outer run, the spawn-only worker consumes only user-owned private temp contexts, and ambiguous legacy runs fail closed for cancellation. - **Foreground/background parity.** MCP and Pi detached launches snapshot the same agent scope, model roles, global thinking, runner profile, incremental settings, and retention policy as the foreground invocation. Pi now uses fully detached stdio and transactionally reaps the worker plus its private launch context when post-spawn setup fails. Launch failures preserve their real cause, and post-spawn roster diagnostics can no longer misreport a successfully started run as a launch failure. -- **Bounded run history and accurate rosters.** Retention now applies to every inactive state (`completed`, `failed`, `paused`, and `blocked`) while never pruning active runs. Cleanup validates every indexed path and rechecks the selected snapshot under the run lock, preventing traversal and resume/delete races. MCP background lists compute counts from the complete project roster without the former 1000-run cap or per-row reload pattern. +- **Bounded run history and accurate rosters.** Retention now applies to every inactive state (`completed`, `failed`, `paused`, and `blocked`) while never pruning active runs. Cleanup is throttled independently per project, uses short fail-open lock waits, validates physical directories as well as indexed paths, and rechecks the selected snapshot under the run lock, preventing cross-project starvation, symlink traversal, and resume/delete races. Detached control records are removed only when their persisted cwd belongs to the run store being retained. MCP background lists compute counts from the complete project roster without the former 1000-run cap or per-row reload pattern. +- **File-lock ownership.** Expired locks are stolen only when their recorded owner process is definitively dead, stale-lock stealers serialize on a generation claim, and release verifies both inode and a random owner token. A slow live writer can no longer overlap a replacement writer or unlink its successor's lock. - **Run-id and status compatibility.** Dot-leading flow names now produce persistable run IDs without weakening traversal guards. Background status ignores synthetic launch phases in progress totals and tolerates malformed legacy optional output instead of crashing the MCP request. - **Release surface synchronization.** Package/plugin versions, installation pins, built-dist MCP expectations, the 16-tool roster, background-run documentation, and English/Chinese host guides are synchronized for 0.2.3. diff --git a/packages/claude-taskflow/src/mcp/server.ts b/packages/claude-taskflow/src/mcp/server.ts index ec2abbfe..50dce2b9 100644 --- a/packages/claude-taskflow/src/mcp/server.ts +++ b/packages/claude-taskflow/src/mcp/server.ts @@ -12,7 +12,7 @@ import { makeToolHandlers as coreMakeToolHandlers, startMcpServer as coreStartMcpServer, } from "taskflow-mcp-core/server"; -import type { RpcHandler } from "taskflow-mcp-core/jsonrpc"; +import type { RpcContext, RpcHandler } from "taskflow-mcp-core/jsonrpc"; import { claudeSubagentRunner } from "taskflow-hosts"; const HOST_OPTIONS = { @@ -24,7 +24,9 @@ const HOST_OPTIONS = { } as const; /** Per-call tool handlers with claude subagent execution bound in. */ -export function makeToolHandlers(cwd: string): Record) => Promise> { +export function makeToolHandlers( + cwd: string, +): Record, context?: RpcContext) => Promise> { return coreMakeToolHandlers(cwd, claudeSubagentRunner, HOST_OPTIONS); } diff --git a/packages/codex-taskflow/src/mcp/server.ts b/packages/codex-taskflow/src/mcp/server.ts index ed58dc0f..1b8eacf8 100644 --- a/packages/codex-taskflow/src/mcp/server.ts +++ b/packages/codex-taskflow/src/mcp/server.ts @@ -12,7 +12,7 @@ import { makeToolHandlers as coreMakeToolHandlers, startMcpServer as coreStartMcpServer, } from "taskflow-mcp-core/server"; -import type { RpcHandler } from "taskflow-mcp-core/jsonrpc"; +import type { RpcContext, RpcHandler } from "taskflow-mcp-core/jsonrpc"; import { codexSubagentRunner } from "taskflow-hosts"; const HOST_OPTIONS = { @@ -24,7 +24,9 @@ const HOST_OPTIONS = { } as const; /** Per-call tool handlers with codex subagent execution bound in. */ -export function makeToolHandlers(cwd: string): Record) => Promise> { +export function makeToolHandlers( + cwd: string, +): Record, context?: RpcContext) => Promise> { return coreMakeToolHandlers(cwd, codexSubagentRunner, HOST_OPTIONS); } diff --git a/packages/opencode-taskflow/src/mcp/server.ts b/packages/opencode-taskflow/src/mcp/server.ts index a9df8070..59ee0fd8 100644 --- a/packages/opencode-taskflow/src/mcp/server.ts +++ b/packages/opencode-taskflow/src/mcp/server.ts @@ -13,7 +13,7 @@ import { makeToolHandlers as coreMakeToolHandlers, startMcpServer as coreStartMcpServer, } from "taskflow-mcp-core/server"; -import type { RpcHandler } from "taskflow-mcp-core/jsonrpc"; +import type { RpcContext, RpcHandler } from "taskflow-mcp-core/jsonrpc"; import { opencodeSubagentRunner } from "taskflow-hosts"; const HOST_OPTIONS = { @@ -25,7 +25,9 @@ const HOST_OPTIONS = { } as const; /** Per-call tool handlers with opencode subagent execution bound in. */ -export function makeToolHandlers(cwd: string): Record) => Promise> { +export function makeToolHandlers( + cwd: string, +): Record, context?: RpcContext) => Promise> { return coreMakeToolHandlers(cwd, opencodeSubagentRunner, HOST_OPTIONS); } diff --git a/packages/pi-taskflow/test/e2e-terminal-reap.mts b/packages/pi-taskflow/test/e2e-terminal-reap.mts index 9e7e2620..a81abef6 100644 --- a/packages/pi-taskflow/test/e2e-terminal-reap.mts +++ b/packages/pi-taskflow/test/e2e-terminal-reap.mts @@ -70,7 +70,12 @@ try { }, }); - assert.equal(result.ok, true, result.finalOutput); + assert.equal(result.ok, true, JSON.stringify({ + finalOutput: result.finalOutput, + status: result.state.status, + phases: result.state.phases, + calls, + }, null, 2)); assert.equal(calls.length, 2); assert.match(calls[0].output, /PHASE_ONE_DONE/); assert.match(calls[1].output, /PHASE_TWO_DONE/); diff --git a/packages/taskflow-core/src/store.ts b/packages/taskflow-core/src/store.ts index 31f43bef..a43342d5 100644 --- a/packages/taskflow-core/src/store.ts +++ b/packages/taskflow-core/src/store.ts @@ -300,6 +300,9 @@ const CLEANUP_LOCK_TIMEOUT_MS = 250; /** Minimum ms between opportunistic cleanup runs (called inside saveRun). */ const CLEANUP_INTERVAL_MS = 60_000; +/** Bound the project-keyed throttle so a long-lived multi-project host cannot + * retain one map entry for every directory it has ever visited. */ +const CLEANUP_THROTTLE_MAX_ROOTS = 256; /** Retain at most this many terminal runs by default. */ const DEFAULT_MAX_KEPT_TERMINAL = 100; /** Remove terminal runs older than this (days). */ @@ -309,12 +312,25 @@ const DEFAULT_MAX_AGE_DAYS = 30; export const DEFAULT_KEPT_RUNS = DEFAULT_MAX_KEPT_TERMINAL; export const DEFAULT_RUN_AGE_DAYS = DEFAULT_MAX_AGE_DAYS; -/** Last cleanup timestamp — module-level so it persists across calls. */ -let lastCleanupAt = 0; +/** Per-runs-root cleanup timestamps. A process-global scalar lets one busy + * project suppress retention in every other project served by the same host. */ +const lastCleanupAtByRoot = new Map(); /** Shared buffer for Atomics.wait in acquireLock busy-wait (Finding 6). */ const LOCK_WAIT_BUF = new Int32Array(new SharedArrayBuffer(4)); +interface FileLockHandle { + device: number; + inode: number; + token: string; +} + +interface LockOwnerRecord { + pid: number; + ts: number; + token?: string; +} + // --------------------------------------------------------------------------- // Internal helpers — path construction & sanitisation // --------------------------------------------------------------------------- @@ -408,24 +424,180 @@ function runIndexFilePath(runsRoot: string, relPath: string): string { return path.join(runsRoot, ...relPath.split("/")); } +/** Canonical, bounded-LRU throttle for opportunistic per-project cleanup. */ +function shouldRunCleanup(runsRoot: string, now: number): boolean { + let key: string; + try { key = fs.realpathSync(runsRoot); } catch { key = path.resolve(runsRoot); } + const previous = lastCleanupAtByRoot.get(key); + if (previous !== undefined && now - previous < CLEANUP_INTERVAL_MS) return false; + + // Refresh insertion order for simple LRU eviction. + lastCleanupAtByRoot.delete(key); + lastCleanupAtByRoot.set(key, now); + while (lastCleanupAtByRoot.size > CLEANUP_THROTTLE_MAX_ROOTS) { + const oldest = lastCleanupAtByRoot.keys().next().value as string | undefined; + if (oldest === undefined) break; + lastCleanupAtByRoot.delete(oldest); + } + return true; +} + +interface PhysicalDirectorySnapshot { + realPath: string; + device: number; + inode: number; +} + +/** + * Resolve a physical directory only when it is a non-symlink descendant of + * runsRoot. Retention performs destructive operations, so lexical containment + * alone is insufficient: a checked-in `runs/` symlink could otherwise + * redirect the run lock and unlink to an arbitrary directory. + */ +function physicalDirectoryInsideRunsRoot( + runsRoot: string, + candidate: string, +): PhysicalDirectorySnapshot | null { + try { + const rootReal = fs.realpathSync(runsRoot); + const stat = fs.lstatSync(candidate); + if (!stat.isDirectory() || stat.isSymbolicLink()) return null; + const realPath = fs.realpathSync(candidate); + const rel = path.relative(rootReal, realPath); + if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null; + return { realPath, device: stat.dev, inode: stat.ino }; + } catch { + return null; + } +} + +function physicalDirectoryStillMatches( + runsRoot: string, + candidate: string, + snapshot: PhysicalDirectorySnapshot, +): boolean { + const current = physicalDirectoryInsideRunsRoot(runsRoot, candidate); + return Boolean( + current && current.realPath === snapshot.realPath && + current.device === snapshot.device && current.inode === snapshot.inode, + ); +} + +/** Remove a Taskflow-owned artifact tree without following a project symlink. */ +function removeArtifactDirectoryInsideRunsRoot(runsRoot: string, target: string): void { + const parent = path.dirname(target); + const parentSnapshot = physicalDirectoryInsideRunsRoot(runsRoot, parent); + if (!parentSnapshot) return; + try { + const stat = fs.lstatSync(target); + if (!stat.isDirectory() || stat.isSymbolicLink()) return; + const targetSnapshot = physicalDirectoryInsideRunsRoot(runsRoot, target); + if (!targetSnapshot || !physicalDirectoryStillMatches(runsRoot, parent, parentSnapshot)) return; + if (!physicalDirectoryStillMatches(runsRoot, target, targetSnapshot)) return; + fs.rmSync(target, { recursive: true, force: true }); + } catch { /* missing / concurrently changed */ } +} + +/** Accept a persisted cwd for control-record cleanup only when it resolves to + * the same project run store currently being retained. */ +function controlCwdForRunsRoot(runsRoot: string, candidate: unknown): string { + const fallback = path.dirname(path.dirname(path.dirname(runsRoot))); + if (typeof candidate !== "string" || candidate.length === 0) return fallback; + try { + if (fs.realpathSync(runsDir(candidate)) === fs.realpathSync(runsRoot)) return candidate; + } catch { /* malformed, missing, or from another project */ } + return fallback; +} + // --------------------------------------------------------------------------- // File-lock primitives — zero-dependency, using O_CREAT|O_EXCL (atomic) // --------------------------------------------------------------------------- +function readLockOwner(lockPath: string): LockOwnerRecord | null { + try { + const stat = fs.lstatSync(lockPath); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4_096) return null; + const raw = fs.readFileSync(lockPath, "utf-8"); + if (Buffer.byteLength(raw, "utf-8") > 4_096) return null; + const parsed = JSON.parse(raw) as Record; + if (!Number.isSafeInteger(parsed.pid) || typeof parsed.ts !== "number") return null; + return { + pid: parsed.pid as number, + ts: parsed.ts, + ...(typeof parsed.token === "string" ? { token: parsed.token } : {}), + }; + } catch { + return null; + } +} + +function sameLockOwner(left: LockOwnerRecord | null, right: LockOwnerRecord): boolean { + return Boolean( + left && left.pid === right.pid && left.ts === right.ts && left.token === right.token, + ); +} + +/** + * Serialize stale-lock stealers for one observed lock generation. Without this + * claim, contender B can replace a dead lock and contender C — acting on its + * earlier observation — can then rename B's fresh live lock. + */ +function tryStealDeadLock(lockPath: string, observed: fs.Stats, owner: LockOwnerRecord): boolean { + if (probeProcess(owner.pid) !== "dead") return false; + const generation = crypto.createHash("sha256") + .update(`${observed.dev}\0${observed.ino}\0${owner.pid}\0${owner.ts}\0${owner.token ?? ""}`) + .digest("hex") + .slice(0, 16); + const claimPath = `${lockPath}.steal.${generation}`; + let claimFd: number; + try { + claimFd = fs.openSync(claimPath, "wx"); + } catch { + return false; + } + + const claimToken = crypto.randomBytes(16).toString("hex"); + let claimHandle: FileLockHandle | undefined; + try { + fs.writeFileSync(claimFd, JSON.stringify({ pid: process.pid, ts: Date.now(), token: claimToken })); + const claimStat = fs.fstatSync(claimFd); + claimHandle = { device: claimStat.dev, inode: claimStat.ino, token: claimToken }; + fs.closeSync(claimFd); + claimFd = -1; + + const current = fs.lstatSync(lockPath); + if (current.dev !== observed.dev || current.ino !== observed.ino) return false; + if (!sameLockOwner(readLockOwner(lockPath), owner)) return false; + + const grave = `${lockPath}.stale.${process.pid}.${crypto.randomBytes(4).toString("hex")}`; + fs.renameSync(lockPath, grave); + try { fs.unlinkSync(grave); } catch { /* best-effort grave cleanup */ } + return true; + } catch { + return false; + } finally { + if (claimFd >= 0) { + try { fs.closeSync(claimFd); } catch { /* ignore */ } + } + if (claimHandle) releaseLock(claimPath, claimHandle); + else { + // Initialization did not finish; only this exclusive creator can own it. + try { fs.unlinkSync(claimPath); } catch { /* ignore */ } + } + } +} + /** * Acquire a file lock by atomically creating a lock file. * * Uses O_CREAT|O_EXCL (`wx` flag) which is atomic on POSIX and NTFS. - * Stale locks (> LOCK_STALE_MS) are stolen via an atomic rename rather than a - * naive unlink-then-create: a plain `unlinkSync` + `openSync('wx')` has a - * TOCTOU window where two processes both unlink the same stale lock and both - * then create a fresh one, yielding two simultaneous holders (risk-reviewer - * v0.0.9 audit, L1). `rename` is atomic and removes the *specific* inode the - * caller observed: only one racing process can win the rename of that exact - * stale file, so at most one process proceeds to re-create the lock. + * Stale locks (> LOCK_STALE_MS) are stolen only after their recorded owner PID + * is definitively dead, then moved via an atomic rename. Age alone cannot prove + * abandonment: stealing from a slow but live holder creates two simultaneous + * critical sections, and the old holder can later unlink the new holder's lock. * Throws on timeout. */ -function acquireLock(lockPath: string, timeoutMs: number = LOCK_TIMEOUT_MS): void { +function acquireLock(lockPath: string, timeoutMs: number = LOCK_TIMEOUT_MS): FileLockHandle { const start = Date.now(); // Ensure parent directory exists (lock file lives inside the flow subdir). const dir = path.dirname(lockPath); @@ -434,27 +606,31 @@ function acquireLock(lockPath: string, timeoutMs: number = LOCK_TIMEOUT_MS): voi while (true) { try { const fd = fs.openSync(lockPath, "wx"); - fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() })); - fs.closeSync(fd); - return; // lock acquired + const token = crypto.randomBytes(16).toString("hex"); + try { + fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now(), token })); + const stat = fs.fstatSync(fd); + return { device: stat.dev, inode: stat.ino, token }; + } catch (error) { + // Creation succeeded but initialization did not. Remove only the inode + // this process created so a partial lock cannot become unstealable. + try { + const held = fs.fstatSync(fd); + const current = fs.lstatSync(lockPath); + if (current.dev === held.dev && current.ino === held.ino) fs.unlinkSync(lockPath); + } catch { /* best-effort rollback */ } + throw error; + } finally { + fs.closeSync(fd); + } } catch (e: unknown) { if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; // Lock file exists — check if stale. try { - const stat = fs.statSync(lockPath); + const stat = fs.lstatSync(lockPath); if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) { - // Stale lock — steal it via atomic rename so only one racing - // stealer can win (L1). The "graveyard" name is unique per - // process+attempt; the winner unlinks it, losers see ENOENT - // on their own rename and simply retry the acquire loop. - const grave = `${lockPath}.stale.${process.pid}.${crypto.randomBytes(4).toString("hex")}`; - try { - fs.renameSync(lockPath, grave); - // We won the steal — discard the graveyard copy and retry - // the loop, where openSync('wx') will create a fresh lock. - try { fs.unlinkSync(grave); } catch { /* ignore */ } - } catch { /* lost the steal race (ENOENT) — just retry */ } - continue; + const owner = readLockOwner(lockPath); + if (owner && tryStealDeadLock(lockPath, stat, owner)) continue; } } catch { // ENOENT: another process released it between openSync and statSync — retry. @@ -471,22 +647,28 @@ function acquireLock(lockPath: string, timeoutMs: number = LOCK_TIMEOUT_MS): voi } /** - * Release a file lock by deleting the lock file. Ignores ENOENT (already - * released by another process or stolen due to staleness). + * Release a file lock only when the path still carries this holder's inode and + * random ownership token. A dead-owner steal must never let an old finally + * block delete the successor's lock. */ -function releaseLock(lockPath: string): void { - try { fs.unlinkSync(lockPath); } catch { /* ENOENT or other — ignore */ } +function releaseLock(lockPath: string, handle: FileLockHandle): void { + try { + const stat = fs.lstatSync(lockPath); + if (!stat.isFile() || stat.dev !== handle.device || stat.ino !== handle.inode) return; + if (readLockOwner(lockPath)?.token !== handle.token) return; + fs.unlinkSync(lockPath); + } catch { /* ENOENT, replaced, or corrupt — never unlink another owner's lock */ } } /** * Execute `fn` while holding a file lock. Guarantees release even on throw. */ export function withLock(lockPath: string, fn: () => T, timeoutMs: number = LOCK_TIMEOUT_MS): T { - acquireLock(lockPath, timeoutMs); + const handle = acquireLock(lockPath, timeoutMs); try { return fn(); } finally { - releaseLock(lockPath); + releaseLock(lockPath, handle); } } @@ -654,56 +836,60 @@ function cleanupTerminalRuns( maxAgeDays: number = DEFAULT_MAX_AGE_DAYS, ): void { const now = Date.now(); - if (now - lastCleanupAt < CLEANUP_INTERVAL_MS) return; - lastCleanupAt = now; + if (!shouldRunCleanup(runsRoot, now)) return; const maxAgeMs = maxAgeDays * 86_400_000; let toRemove: RunIndexEntry[] = []; - withLock(indexLockPath(runsRoot), () => { - const entries = readIndex(runsRoot); - const terminal: RunIndexEntry[] = []; - const active: RunIndexEntry[] = []; - - for (const e of entries) { - if (e.status !== "running") { - terminal.push(e); - } else { - active.push(e); + try { + withLock(indexLockPath(runsRoot), () => { + const entries = readIndex(runsRoot); + const terminal: RunIndexEntry[] = []; + const active: RunIndexEntry[] = []; + + for (const e of entries) { + if (e.status !== "running") { + terminal.push(e); + } else { + active.push(e); + } } - } - // Sort terminal by updatedAt desc (newest first). - // Filter out entries with corrupt updatedAt (non-numeric/NaN) BEFORE sorting - // to prevent NaN from corrupting sort order. Corrupt entries cannot be - // reliably aged, so they are always moved to toRemove. - const cleanTerminal: RunIndexEntry[] = []; - for (const e of terminal) { - if (typeof e.updatedAt === "number" && !Number.isNaN(e.updatedAt)) { - cleanTerminal.push(e); - } else { - toRemove.push(e); + // Sort terminal by updatedAt desc (newest first). + // Filter out entries with corrupt updatedAt (non-numeric/NaN) BEFORE sorting + // to prevent NaN from corrupting sort order. Corrupt entries cannot be + // reliably aged, so they are always moved to toRemove. + const cleanTerminal: RunIndexEntry[] = []; + for (const e of terminal) { + if (typeof e.updatedAt === "number" && !Number.isNaN(e.updatedAt)) { + cleanTerminal.push(e); + } else { + toRemove.push(e); + } } - } - cleanTerminal.sort((a, b) => b.updatedAt - a.updatedAt); - - for (let i = 0; i < cleanTerminal.length; i++) { - const e = cleanTerminal[i]!; - const expiredByAge = maxAgeDays > 0 && now - e.updatedAt > maxAgeMs; - const excessByCount = maxKeep > 0 && i >= maxKeep; - if (expiredByAge || excessByCount) { - toRemove.push(e); + cleanTerminal.sort((a, b) => b.updatedAt - a.updatedAt); + + for (let i = 0; i < cleanTerminal.length; i++) { + const e = cleanTerminal[i]!; + const expiredByAge = maxAgeDays > 0 && now - e.updatedAt > maxAgeMs; + const excessByCount = maxKeep > 0 && i >= maxKeep; + if (expiredByAge || excessByCount) { + toRemove.push(e); + } } - } - if (toRemove.length === 0) return; + if (toRemove.length === 0) return; - // Commit the pruned index while holding the lock so a concurrent - // updateIndexEntry cannot interleave and lose entries. - const removalSet = new Set(toRemove); - const remaining = cleanTerminal.filter((e) => !removalSet.has(e)); - writeIndex(runsRoot, [...active, ...remaining]); - }); + // Commit the pruned index while holding the lock so a concurrent + // updateIndexEntry cannot interleave and lose entries. + const removalSet = new Set(toRemove); + const remaining = cleanTerminal.filter((e) => !removalSet.has(e)); + writeIndex(runsRoot, [...active, ...remaining]); + }, CLEANUP_LOCK_TIMEOUT_MS); + } catch { + // Retention is opportunistic. A busy index must not stall or fail saveRun. + return; + } if (toRemove.length === 0) return; @@ -738,12 +924,16 @@ function cleanupTerminalRuns( function cleanupRunArtifactsIfSnapshotMatches(runsRoot: string, entry: RunIndexEntry): boolean { if (!isSafeRunIndexRelPath(entry.relPath, entry.runId)) return false; const filePath = runIndexFilePath(runsRoot, entry.relPath); + const fileDir = path.dirname(filePath); + const directorySnapshot = physicalDirectoryInsideRunsRoot(runsRoot, fileDir); + if (!directorySnapshot) return false; const restore = (state: RunState): void => { try { updateIndexEntry(runsRoot, extractIndexEntry(state, entry.relPath)); } catch { /* best effort */ } }; try { return withLock(`${filePath}.lock`, () => { + if (!physicalDirectoryStillMatches(runsRoot, fileDir, directorySnapshot)) return false; // saveRun holds this same run lock until its index upsert commits. If a // save won the race after cleanup pruned the old entry, that fresh entry // is now visible and owns the file even when Date.now() reused the same @@ -755,10 +945,14 @@ function cleanupRunArtifactsIfSnapshotMatches(runsRoot: string, entry: RunIndexE if (wasReindexed) return false; let state: RunState; + let fileSnapshot: { device: number; inode: number }; try { const stat = fs.lstatSync(filePath); if (!stat.isFile() || stat.isSymbolicLink()) return false; - state = JSON.parse(fs.readFileSync(filePath, "utf-8")) as RunState; + fileSnapshot = { device: stat.dev, inode: stat.ino }; + const loaded = tryReadRunFile(runsRoot, filePath); + if (!loaded.ok) return false; + state = loaded.value; } catch { return false; } @@ -770,6 +964,16 @@ function cleanupRunArtifactsIfSnapshotMatches(runsRoot: string, entry: RunIndexE } try { + if (!physicalDirectoryStillMatches(runsRoot, fileDir, directorySnapshot)) { + restore(state); + return false; + } + const currentFile = fs.lstatSync(filePath); + if (!currentFile.isFile() || currentFile.isSymbolicLink() || + currentFile.dev !== fileSnapshot.device || currentFile.ino !== fileSnapshot.inode) { + restore(state); + return false; + } fs.unlinkSync(filePath); } catch { restore(state); @@ -786,15 +990,12 @@ function cleanupRunArtifactsIfSnapshotMatches(runsRoot: string, entry: RunIndexE ); } catch { /* best effort */ } // Remove per-run Shared Context Tree and isolated-workspace artifacts. - try { fs.rmSync(path.join(runsRoot, "ctx", entry.runId), { recursive: true, force: true }); } catch { /* ignore */ } - try { - const wsSeg = entry.runId.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_").slice(0, 100) || "phase"; - fs.rmSync(path.join(runsRoot, "ws", wsSeg), { recursive: true, force: true }); - } catch { /* ignore */ } + removeArtifactDirectoryInsideRunsRoot(runsRoot, path.join(runsRoot, "ctx", entry.runId)); + const wsSeg = entry.runId.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_").slice(0, 100) || "phase"; + removeArtifactDirectoryInsideRunsRoot(runsRoot, path.join(runsRoot, "ws", wsSeg)); // Remove user-private detached control records left by an interrupted worker. - const fallbackCwd = path.dirname(path.dirname(path.dirname(runsRoot))); - const controlCwd = typeof entry.cwd === "string" ? entry.cwd : fallbackCwd; + const controlCwd = controlCwdForRunsRoot(runsRoot, state.cwd); const controlDir = detachedControlDir(controlCwd); try { fs.unlinkSync(path.join(controlDir, `${entry.runId}.cancel.json`)); } catch { /* ignore */ } try { fs.unlinkSync(path.join(controlDir, `${entry.runId}.processes.json`)); } catch { /* ignore */ } diff --git a/packages/taskflow-core/test/run-retention.test.ts b/packages/taskflow-core/test/run-retention.test.ts index a5b860e1..8935ace4 100644 --- a/packages/taskflow-core/test/run-retention.test.ts +++ b/packages/taskflow-core/test/run-retention.test.ts @@ -52,6 +52,35 @@ test("retention: paused and blocked history is bounded while running work is pre } }); +test("retention: cleanup throttling is isolated per project", async () => { + const cwdA = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-retention-root-a-")); + const cwdB = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-retention-root-b-")); + fs.mkdirSync(path.join(cwdA, ".pi")); + fs.mkdirSync(path.join(cwdB, ".pi")); + try { + const seed = await import(`../src/store.ts?retention-roots-seed=${Date.now()}`) as typeof import("../src/store.ts"); + for (const cwd of [cwdA, cwdB]) { + seed.saveRun(state(cwd, "old-a", "completed"), { maxKeep: 0, maxAgeDays: 0 }); + seed.saveRun(state(cwd, "old-b", "failed"), { maxKeep: 0, maxAgeDays: 0 }); + } + + const cleanup = await import(`../src/store.ts?retention-roots-cleanup=${Date.now()}`) as typeof import("../src/store.ts"); + cleanup.saveRun(state(cwdA, "active-a", "running"), { maxKeep: 1, maxAgeDays: 0 }); + cleanup.saveRun(state(cwdB, "active-b", "running"), { maxKeep: 1, maxAgeDays: 0 }); + + for (const cwd of [cwdA, cwdB]) { + assert.equal( + cleanup.listRuns(cwd, 20).filter((run) => run.status !== "running").length, + 1, + "one busy project must not suppress another project's retention pass", + ); + } + } finally { + fs.rmSync(cwdA, { recursive: true, force: true }); + fs.rmSync(cwdB, { recursive: true, force: true }); + } +}); + test("retention: a crafted index path cannot delete outside the runs root", async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-retention-path-")); fs.mkdirSync(path.join(cwd, ".pi")); @@ -78,6 +107,124 @@ test("retention: a crafted index path cannot delete outside the runs root", asyn } }); +test("retention: cleanup does not follow a flow-directory symlink outside the runs root", async (t) => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-retention-dir-symlink-")); + fs.mkdirSync(path.join(cwd, ".pi")); + try { + const store = await import(`../src/store.ts?retention-dir-symlink=${Date.now()}`) as typeof import("../src/store.ts"); + const root = store.runsDir(cwd); + const externalDir = path.join(cwd, "external-runs"); + fs.mkdirSync(root, { recursive: true }); + fs.mkdirSync(externalDir); + + const victim = state(cwd, "victim", "failed"); + victim.updatedAt = 1; + const victimPath = path.join(externalDir, "victim.json"); + fs.writeFileSync(victimPath, JSON.stringify(victim)); + try { + fs.symlinkSync(externalDir, path.join(root, "retention")); + } catch (error) { + t.skip(`symlink unavailable: ${String(error)}`); + return; + } + fs.writeFileSync( + path.join(root, "index.json"), + JSON.stringify([store.extractIndexEntry(victim, "retention/victim.json")]), + ); + + const trigger = state(cwd, "trigger", "running"); + trigger.flowName = "retention-trigger"; + trigger.def = { ...flow, name: "retention-trigger" }; + store.saveRun(trigger, { maxKeep: 1, maxAgeDays: 1 }); + + assert.equal(fs.readFileSync(victimPath, "utf-8"), JSON.stringify(victim)); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("retention: cleanup does not follow context artifact symlinks", async (t) => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-retention-artifact-symlink-")); + fs.mkdirSync(path.join(cwd, ".pi")); + try { + const store = await import(`../src/store.ts?retention-artifact-symlink=${Date.now()}`) as typeof import("../src/store.ts"); + store.saveRun(state(cwd, "victim", "failed"), { maxKeep: 0, maxAgeDays: 0 }); + const root = store.runsDir(cwd); + const indexFile = path.join(root, "index.json"); + const entries = JSON.parse(fs.readFileSync(indexFile, "utf-8")) as Array<{ + runId: string; + updatedAt: number; + relPath: string; + }>; + const entry = entries.find((candidate) => candidate.runId === "victim"); + assert.ok(entry); + const runFile = path.join(root, ...entry.relPath.split("/")); + const persisted = JSON.parse(fs.readFileSync(runFile, "utf-8")) as RunState; + persisted.updatedAt = 1; + entry.updatedAt = 1; + fs.writeFileSync(runFile, JSON.stringify(persisted)); + fs.writeFileSync(indexFile, JSON.stringify(entries)); + + const externalCtx = path.join(cwd, "external-ctx"); + const externalRun = path.join(externalCtx, "victim"); + fs.mkdirSync(externalRun, { recursive: true }); + const sentinel = path.join(externalRun, "sentinel.txt"); + fs.writeFileSync(sentinel, "keep"); + try { + fs.symlinkSync(externalCtx, path.join(root, "ctx")); + } catch (error) { + t.skip(`symlink unavailable: ${String(error)}`); + return; + } + + const cleanup = await import(`../src/store.ts?retention-artifact-cleanup=${Date.now()}`) as typeof import("../src/store.ts"); + cleanup.saveRun(state(cwd, "trigger", "running"), { maxKeep: 1, maxAgeDays: 1 }); + + assert.equal(fs.readFileSync(sentinel, "utf-8"), "keep"); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("retention: forged run cwd cannot remove another project's control records", async () => { + const cwdA = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-retention-control-a-")); + const cwdB = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-retention-control-b-")); + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-retention-agent-")); + const previousAgentDir = process.env.TASKFLOW_AGENT_DIR; + process.env.TASKFLOW_AGENT_DIR = agentDir; + fs.mkdirSync(path.join(cwdA, ".pi")); + fs.mkdirSync(path.join(cwdB, ".pi")); + try { + const suffix = Date.now(); + const store = await import(`../src/store.ts?retention-control=${suffix}`) as typeof import("../src/store.ts"); + const control = await import(`../src/detached-control.ts?retention-control=${suffix}`) as typeof import("../src/detached-control.ts"); + const root = store.runsDir(cwdA); + const flowDir = path.join(root, "retention"); + fs.mkdirSync(flowDir, { recursive: true }); + + const forged = state(cwdB, "victim", "failed"); + forged.updatedAt = 1; + fs.writeFileSync(path.join(flowDir, "victim.json"), JSON.stringify(forged)); + fs.writeFileSync( + path.join(root, "index.json"), + JSON.stringify([store.extractIndexEntry(forged, "retention/victim.json")]), + ); + control.requestDetachedCancel(cwdB, "victim", "belongs to project B"); + const marker = control.detachedCancelRequestPath(cwdB, "victim"); + assert.ok(fs.existsSync(marker)); + + store.saveRun(state(cwdA, "trigger", "running"), { maxKeep: 1, maxAgeDays: 1 }); + + assert.ok(fs.existsSync(marker), "project A retention must not mutate project B's control plane"); + } finally { + if (previousAgentDir === undefined) delete process.env.TASKFLOW_AGENT_DIR; + else process.env.TASKFLOW_AGENT_DIR = previousAgentDir; + fs.rmSync(cwdA, { recursive: true, force: true }); + fs.rmSync(cwdB, { recursive: true, force: true }); + fs.rmSync(agentDir, { recursive: true, force: true }); + } +}); + test("retention: cleanup rechecks a candidate under its run lock", async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-retention-race-")); fs.mkdirSync(path.join(cwd, ".pi")); diff --git a/packages/taskflow-core/test/store.test.ts b/packages/taskflow-core/test/store.test.ts index 901e5bb4..808ed7f1 100644 --- a/packages/taskflow-core/test/store.test.ts +++ b/packages/taskflow-core/test/store.test.ts @@ -13,6 +13,7 @@ import { newRunId, saveFlow, saveRun, + withLock, type RunIndexEntry, type RunState, } from "../src/store.ts"; @@ -1355,8 +1356,79 @@ test("L1: a stale lock is stolen cleanly by racing acquirers (no leak, single wi const loaded = loadRun(cwd, "L1"); assert.ok(loaded, "run should be readable after both saves"); assert.ok(!fs.existsSync(lockPath), "lock file must be released, not leaked"); - const stray = fs.readdirSync(flowDir).filter((f) => f.includes(".stale.")); - assert.deepEqual(stray, [], "no graveyard (.stale.) files should remain"); + const stray = fs.readdirSync(flowDir).filter((f) => f.includes(".stale.") || f.includes(".steal.")); + assert.deepEqual(stray, [], "no stale graveyard or steal-claim files should remain"); + } finally { + cleanup(cwd); + } +}); + +test("L1: an old mtime never permits stealing from a live lock owner", async () => { + const cwd = makeTmpCwd(); + try { + const lockPath = path.join(cwd, "live-owner.lock"); + const readyPath = path.join(cwd, "ready"); + const releasePath = path.join(cwd, "release"); + const script = ` + import fs from "node:fs"; + import { withLock } from ${JSON.stringify(path.resolve("packages/taskflow-core/src/store.ts"))}; + const [lockPath, readyPath, releasePath] = process.argv.slice(2); + withLock(lockPath, () => { + fs.writeFileSync(readyPath, "ready"); + const wait = new Int32Array(new SharedArrayBuffer(4)); + const deadline = Date.now() + 5_000; + while (!fs.existsSync(releasePath) && Date.now() < deadline) Atomics.wait(wait, 0, 0, 20); + if (!fs.existsSync(releasePath)) process.exitCode = 4; + }); + `; + const scriptPath = path.join(cwd, "live-owner.mjs"); + fs.writeFileSync(scriptPath, script); + const { spawn } = await import("node:child_process"); + const holder = spawn( + process.execPath, + ["--experimental-strip-types", scriptPath, lockPath, readyPath, releasePath], + { stdio: "ignore" }, + ); + const deadline = Date.now() + 2_000; + while (!fs.existsSync(readyPath) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.ok(fs.existsSync(readyPath), "holder should enter the critical section"); + const old = Date.now() / 1000 - 3_600; + fs.utimesSync(lockPath, old, old); + + assert.throws( + () => withLock(lockPath, () => assert.fail("live lock must not be stolen"), 200), + /Lock timeout/, + ); + fs.writeFileSync(releasePath, "release"); + const code = await new Promise((resolve) => holder.on("close", (value) => resolve(value ?? 0))); + assert.equal(code, 0); + assert.ok(!fs.existsSync(lockPath), "the original owner should release its own lock"); + } finally { + cleanup(cwd); + } +}); + +test("L1: releasing an old handle never unlinks a replacement lock", () => { + const cwd = makeTmpCwd(); + try { + const lockPath = path.join(cwd, "replacement.lock"); + const displacedPath = path.join(cwd, "displaced.lock"); + withLock(lockPath, () => { + fs.renameSync(lockPath, displacedPath); + fs.writeFileSync( + lockPath, + JSON.stringify({ pid: process.pid, ts: Date.now(), token: "successor" }), + { flag: "wx" }, + ); + }); + + assert.equal( + JSON.parse(fs.readFileSync(lockPath, "utf-8")).token, + "successor", + "old finally block must leave the successor's inode untouched", + ); } finally { cleanup(cwd); } From e5ecc53b9e082b040f4b0378d07fed97c85ab425 Mon Sep 17 00:00:00 2001 From: heggria Date: Mon, 13 Jul 2026 15:28:09 +0800 Subject: [PATCH 7/8] docs: add taskflow 0.2 frontier assessment --- README.md | 1 + README.zh-CN.md | 1 + ...askflow-0.2.0-frontier-assessment.zh-CN.md | 883 ++++++++++++++++++ 3 files changed, 885 insertions(+) create mode 100644 docs/taskflow-0.2.0-frontier-assessment.zh-CN.md diff --git a/README.md b/README.md index 1b583622..0bab81bb 100644 --- a/README.md +++ b/README.md @@ -365,6 +365,7 @@ The test suite covers orchestration semantics, persistence and file-lock races, | [Host Guides](https://heggria.github.io/taskflow/en/docs/guides/) | Pi, Codex, Claude Code, OpenCode, and Grok setup | | [Reference](https://heggria.github.io/taskflow/en/docs/reference/) | Commands, shorthand, and exact tool surfaces | | [Showcase](https://heggria.github.io/taskflow/en/docs/showcase/) | Real flows and case studies | +| [0.2.0 Frontier Assessment](./docs/taskflow-0.2.0-frontier-assessment.zh-CN.md) | Independent, evidence-based technical assessment (Chinese) | Also see [`examples/`](./examples), the [changelog](./CHANGELOG.md), and the [release guide](./RELEASE.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index b0181f6a..672b546f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -360,6 +360,7 @@ Grok Build 支持在 0.2 首次加入。其 CLI stream 不返回 token/cost 用 | [宿主指南](https://heggria.github.io/taskflow/zh-cn/docs/guides/) | Pi、Codex、Claude Code、OpenCode、Grok 配置 | | [参考](https://heggria.github.io/taskflow/zh-cn/docs/reference/) | 命令、简写与精确工具接口 | | [Showcase](https://heggria.github.io/taskflow/zh-cn/docs/showcase/) | 真实 flow 与案例研究 | +| [0.2.0 前沿性评估](./docs/taskflow-0.2.0-frontier-assessment.zh-CN.md) | 基于源码、竞品与采用数据的独立技术报告 | 另见 [`examples/`](./examples)、[变更日志](./CHANGELOG.md)和[发版指南](./RELEASE.md)。 diff --git a/docs/taskflow-0.2.0-frontier-assessment.zh-CN.md b/docs/taskflow-0.2.0-frontier-assessment.zh-CN.md new file mode 100644 index 00000000..8f996778 --- /dev/null +++ b/docs/taskflow-0.2.0-frontier-assessment.zh-CN.md @@ -0,0 +1,883 @@ +# taskflow 0.2.0 在开源生态中的前沿性评估 + +> **报告类型:**公开技术评估
+> **评估对象:**[`v0.2.0`](https://github.com/heggria/taskflow/releases/tag/v0.2.0)
+> **固定提交:**[`5d36083`](https://github.com/heggria/taskflow/commit/5d36083d65bb95e4adbdbf99577c4abb572404e3)
+> **评估日期:**2026-07-13
+> **报告语言:**简体中文
+> **结论性质:**基于公开源码、测试、发布流水线、GitHub/npm 公开数据和同类项目资料的独立技术判断;不是正式安全认证,也不代表生产采用证明。 + +--- + +## 摘要 + +如果必须用一句话概括: + +> **taskflow 0.2.0 是一个技术上明显处于前沿、工程质量远超同龄项目,但产品成熟度、生态影响力和生产验证尚未进入 GitHub 第一梯队的年轻系统。** + +它不是普通的“调用多个 agent 的 DAG wrapper”。0.2.0 已经实现了一条相对完整的编译与执行链路:JSON 或 TypeScript DSL 被归一成 Taskflow,再编译为带内容哈希的 FlowIR;运行前可做零模型调用的静态验证;运行中记录结构化决策事件;运行后可执行零 token 的反事实 replay、依赖 provenance 分析和 stale-frontier 增量重算。其核心运行时通过统一的 `SubagentRunner` 边界支持 Pi、Codex、Claude Code、OpenCode 和 Grok Build 五个编码智能体宿主。 + +真正值得称为“前沿”的不是 DAG、事件溯源、内容寻址缓存或 read-set 本身——这些都有成熟前例——而是 taskflow 将**编译器 IR、静态验证、内容寻址、增量计算、决策级 replay 和编码智能体上下文隔离**组合进同一个本地执行系统,并把昂贵、非确定性的 LLM 子智能体调用作为一等执行节点。这种系统组合在当前开源 Agent Framework 中仍然少见。 + +但 0.2.0 也有清晰的成熟度上限:event kernel 默认关闭,多个高级能力会退回 imperative runtime,`race` 和 `expand` 尚不进入 kernel path;五宿主统一的是运行时接口,而不是完整能力语义;持久化属于单机文件级 crash resilience,不是 Temporal 式分布式 durability;项目发布仅 39 天,主要由单一维护者完成,没有公开的长期生产案例、性能基准或真实用户留存数据。 + +本报告给出的综合判断是: + +- **纯技术前沿性:约 8.0 / 10** +- **产品前沿性:约 5.0 / 10** +- **生产与生态成熟度:约 4.0 / 10** +- **综合前沿性:6.8 / 10** + +因此,taskflow 0.2.0 最准确的位置是: + +> **狭义 coding-agent orchestration 赛道中的第一梯队候选;更广泛开源 Agent Framework 中的架构型第二梯队新秀;尚不是整个 GitHub 范围内具有类别定义能力的成熟领导项目。** + +--- + +## 1. 评估问题与口径 + +### 1.1 “前沿性”不等于功能数量 + +本报告把“前沿性”拆成四个彼此独立的概念: + +1. **原创性(novelty)**:是否提出了新的机制,或以少见方式组合既有机制; +2. **技术复杂度(sophistication)**:系统是否真的实现了可推理、可验证的架构,而不是停留在接口包装; +3. **成熟度(maturity)**:是否经受过生产负载、真实故障、兼容性变化和长期维护; +4. **影响力(impact)**:是否获得了社区采用、外部贡献、生态集成和类别认知。 + +这四项不能互相替代: + +- stars 多不代表架构先进; +- 测试多不代表生产成熟; +- 功能少不代表设计落后; +- 机制来自既有思想,也不意味着组合没有创新价值。 + +### 1.2 评分说明 + +本报告中的 0–10 分是序数评价,不是精确测量,也不按简单平均生成总分: + +| 区间 | 含义 | +|---|---| +| 0–2 | 概念或早期原型,关键主张缺少实现证据 | +| 3–4 | 能运行的早期产品,但差异化或工程深度有限 | +| 5–6 | 扎实的细分项目,有真实价值但前沿性或成熟度有限 | +| 7–8 | 明显具有前沿倾向,在部分维度接近或达到同类上层 | +| 9 | 已被生产与生态验证的类别领导者 | +| 10 | 改变行业方法或建立新类别的标志性系统 | + +### 1.3 证据优先级 + +当资料发生冲突时,本报告按以下顺序采信: + +1. `v0.2.0` 固定提交中的真实源码与测试; +2. 发布 CI 与 npm/GitHub 官方 API; +3. 项目 RFC、CHANGELOG 和内部 claim-vs-implementation 文档; +4. 官方竞品仓库和文档; +5. README、宣传文案和二手比较。 + +任何只存在于 roadmap、RFC 或 north-star 文档而没有源码实现的能力,都不计为 0.2.0 已交付能力。 + +### 1.4 关键术语 + +- **fail-closed(故障关闭):**无法确认安全或正确状态时拒绝继续,宁可误拒。例如,gate 的模型输出无法解析时按 BLOCK 处理。 +- **fail-open(故障开放):**无法确认状态时允许流程降级继续,宁可误放。例如,tournament 裁判输出无法解析时回退到第一个变体,避免丢失已经产生的候选结果。 +- **content-addressed(内容寻址):**用内容及其输入的哈希标识计算结果,而不是只按文件名或执行顺序标识。 +- **read-set(读取集):**某个 phase 实际读取的上游结果集合,用于解释依赖并计算受变化影响的范围。 +- **stale frontier(陈旧前沿):**从一个变化点出发,所有必须重新计算的 phase 集合。 + +--- + +## 2. 已核验的 0.2.0 事实 + +### 2.1 发布表面 + +源码机械核验确认: + +- **9 个发布 package**(`packages/` 下),另有独立的 `website` workspace; +- **5 个宿主**:Pi、Codex、Claude Code、OpenCode、Grok Build; +- **12 种 phase type**; +- **12 个 MCP 工具**; +- `taskflow-core` 没有直接 runtime dependency,但声明了 `typebox` peer dependency; +- Node.js 最低要求为 22.19; +- JSON DSL 与 TypeScript `.tf.ts` DSL 都能进入 FlowIR 编译链路。 + +12 种 phase type 可直接在 [`schema.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/schema.ts) 的 `PHASE_TYPES` 中核验: + +```text +agent · parallel · map · gate · reduce · approval +flow · loop · tournament · script · race · expand +``` + +12 个 MCP 工具可在 [`taskflow-mcp-core/src/mcp/server.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-mcp-core/src/mcp/server.ts) 核验: + +```text +taskflow_run · taskflow_list · taskflow_show · taskflow_verify +taskflow_compile · taskflow_peek · taskflow_trace · taskflow_replay +taskflow_why_stale · taskflow_recompute · taskflow_save · taskflow_search +``` + +### 2.2 测试与发布工程 + +`v0.2.0` 源码线包含: + +- **106 个 `.test.ts` 测试文件**; +- docs-only PR [#71](https://github.com/heggria/taskflow/pull/71) 上相同源码线的 Node 22 CI 运行产物报告 **1,599/1,599 tests passed**(该数字来自 CI 日志,不是可由源码静态计数的指标); +- Node 22 和 Node 24 双版本测试; +- 9 包 build; +- packed-consumer smoke; +- 网站 production export; +- Codex MCP network-free E2E; +- CodeQL。 + +发布流水线还验证: + +- Git tag 与 `main` ancestry; +- 9 个 npm package 的版本一致性; +- plugin manifest 与 MCP package pin; +- npm provenance; +- trusted owner; +- 发布后 registry tarball integrity; +- GitHub Actions 使用完整 commit SHA 固定。 + +这一发布严谨度显著高于多数同龄个人项目。 + +### 2.3 GitHub 项目状态 + +截至 2026-07-13,GitHub 官方 API 返回: + +| 指标 | 数值 | +|---|---:| +| 项目创建时间 | 2026-06-04 | +| 项目年龄 | 约 39 天 | +| Stars | 33 | +| Forks | 4 | +| Commits | 186(`v0.2.0` tag)/ 187(报告日 `main`) | +| Releases | 38 | +| Contributor identities | 5(报告日,含 bot) | +| 主维护者贡献 | 179 / 186 commits(tag)· 180 / 187(报告日 `main`) | + +这些数字说明项目具有极高的早期开发速度,但也说明 bus factor(巴士因子,即关键维护者单点依赖风险)接近 1。Contributor identity 按 GitHub API 口径统计;不同大小写但邮箱相同的主维护者身份合并理解。 + +### 2.4 npm 下载数据 + +最近 30 个完整自然日为 2026-06-13 至 2026-07-12: + +| 包 | npm 下载次数 | +|---|---:| +| `pi-taskflow` | 3,934 | +| `taskflow-core` | 2,324 | +| `codex-taskflow` | 1,321 | +| `claude-taskflow` | 499 | +| `taskflow-hosts` | 424 | +| `taskflow-mcp-core` | 215 | +| `opencode-taskflow` | 154 | +| `taskflow-dsl` | 尚无历史数据 | +| `grok-taskflow` | 尚无历史数据 | + +有数据的包简单合计为 8,871 次下载;宿主入口包合计为 5,908 次。 + +这里的“下载”沿用 npm `/downloads/point/` API 的字段名,它不是唯一用户、也不是去重后的网络 fetch。这些数字**不能解释为独立用户数**。npm 下载统计会包含: + +- 内部依赖安装; +- CI; +- 重复安装; +- 升级; +- 同一用户的多次拉取。 + +此外,`taskflow-dsl`、`grok-taskflow` 和 `0.2.0` 本身均在 2026-07-13 首次发布,因此上述 30 天数据主要反映此前版本和包级安装行为,不能作为 0.2.0 采用量。 + +### 2.5 包依赖拓扑与安装范围 + +九个发布包不是每次安装都会全部进入用户环境: + +```text +taskflow-core +├── taskflow-mcp-core +├── taskflow-hosts +│ ├── codex-taskflow ─┐ +│ ├── claude-taskflow ├─ 同时依赖 taskflow-mcp-core +│ ├── opencode-taskflow │ +│ └── grok-taskflow ─┘ +├── taskflow-dsl (+ TypeScript) +└── pi-taskflow(Pi adapter;直接依赖 core,并 peer-depend Pi SDK) +``` + +`taskflow-core` 没有直接 `dependencies`,但声明 `typebox` peer dependency;四个 MCP delivery package 按需组合 `taskflow-core`、`taskflow-hosts` 和 `taskflow-mcp-core`。因此安装某一个宿主入口包不会无条件拉取全部九包。该拆分改善了宿主隔离,但也增加了版本一致性和发布验证负担,这正是 packed-consumer 与九包 registry verification 的必要性。 + +### 2.6 文档与首次使用表面 + +仓库提供英中双语 README、Getting Started、Concepts、Syntax、Compiler & Runtime、五宿主指南、Reference、Showcase、可运行 examples,以及从单一 `skills-src/` 生成的五宿主 skill 文档。文档覆盖面和源码链接质量高于多数同龄项目,README 中的 JSON/TypeScript 示例也经过真实 schema/DSL 校验。 + +代价是学习表面较宽:两种 DSL、12 种 phase、host-specific permission 配置和多个增量/replay 命令会提高第一次深入使用的认知负担;根 README 还会被复制到多个 npm 子包,package-specific landing page 的针对性有限。因此本报告把 DX 评为 7.0,而不是把文档数量直接等同于易用性。 + +--- + +## 3. 系统架构评估 + +### 3.1 编译链路是真实实现,不是品牌包装 + +0.2.0 的核心链路可以概括为: + +```text +JSON / .tf.ts + │ + ▼ +Taskflow JSON + │ + ├── validate / verify + ▼ +canonical FlowIR + ir: + │ + ▼ +imperative runtime 或 opt-in event kernel + │ + ├── persisted RunState + ├── append-only decision trace + ├── provenance / read-set + └── cache entries + │ + ▼ +resume · replay · why-stale · recompute +``` + +关键源码包括: + +- [`flowir/compile.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/flowir/compile.ts) +- [`flowir/canonical-hash.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/flowir/canonical-hash.ts) +- [`verify.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/verify.ts) +- [`replay.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/replay.ts) +- [`stale.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/stale.ts) +- [`cache.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/cache.ts) + +`compileTaskflowToFlowIR()` 会把 Taskflow 编译成规范化 IR;`hashFlowIR()` 对 canonical representation 计算 SHA-256,得到 `ir:<64-hex>`。这允许系统讨论“哪个 phase 的定义发生变化”,而不只是比较原始 JSON 文本。 + +这属于真实的 compiler/runtime 架构,但需要正确理解“compiled”:它指 AST/JSON 到规范 IR 的编译与验证,不是把 workflow AOT 编译成本地机器码。 + +### 3.2 静态验证是核心差异化能力 + +代码优先的 Agent Framework 通常把控制流藏在 Python/TypeScript 的 `if`、`for`、`await` 中。由于图只在程序运行时完整出现,通用静态分析很困难。 + +taskflow 把图作为数据,因此可以在调用模型前检查: + +- 环和缺失依赖; +- unreachable/dead-end phase; +- dangling reference; +- gate exhaustion; +- 输出 contract 与字段引用; +- 部分预算与控制流风险; +- 条件和 final phase 的结构问题。 + +对 coding-agent 工作流而言,“在烧 token 前发现图错误”是实质价值,而不是纯粹 DX 优化。 + +### 3.3 Replay 是决策级反事实分析 + +[`replay.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/replay.ts) 已实现 `replayRun(events, overrides)`,不是类型占位符。 + +它能够基于历史事件重新判断: + +- 如果 gate threshold 改变,verdict 是否会翻转; +- 如果预算更低,何处会触发阻断; +- 哪些 phase 可以复用; +- 哪些变化必须 live rerun; +- 模型或参数变化对决策链的影响。 + +项目还通过 import-lint 测试阻止 replay 代码依赖真正的 runtime/driver,从结构上维持“零模型调用、零执行副作用”的边界。 + +必须强调: + +> taskflow replay 是**对已记录决策的 counterfactual fold**,不是重新生成模型输出,也不是 Temporal 式 workflow code history replay。 + +它比完整重执行窄,但对调试成本、gate threshold 和预算策略很有价值。 + +### 3.4 增量重算把构建系统思想带入 Agent 调用 + +[`cache.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/cache.ts) 支持: + +- `git:` +- `glob:` +- `glob!:` +- `file:` +- `env:` + +等 fingerprint,并将 phase 定义、输入与外部世界状态组合成跨运行 cache key。 + +> **直观类比:**它类似 Make/Nx/Bazel 根据输入变化决定哪些目标需要重建,但这里缓存和失效的是昂贵的 Agent 输出,而不是编译产物。 + +[`stale.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/stale.ts) 基于 declared dependencies 与 observed read-set 计算 stale frontier。设计采用保守失效策略:可能多重算,但尽量不错误复用。 + +这类机制在 Nix、Bazel、Nx、Salsa 和 self-adjusting computation 中都有前例。taskflow 的差异在于缓存对象是: + +> 高成本、非确定性、受 prompt、代码状态、模型和上下文共同影响的子智能体结果。 + +这使增量执行的潜在经济价值很高,但正确性和收益也更难证明。0.2.0 已证明机制存在,尚未证明在真实 workload 中持续达到某个固定节省比例。因此 `$6 → $0.40` 一类数字应视为待验证目标,而不是 0.2.0 的已证实性质。 + +### 3.5 Host-neutral core 设计成立 + +核心边界位于 [`host/runner-types.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/host/runner-types.ts)。`taskflow-core` 通过注入的 `SubagentRunner` 调用宿主,不在主 runtime 中出现 Pi/Codex/Claude/OpenCode/Grok 的行为分支。 + +四个非 Pi runner 集中在: + +- [`taskflow-hosts/src/codex-runner.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-hosts/src/codex-runner.ts) +- [`taskflow-hosts/src/claude-runner.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-hosts/src/claude-runner.ts) +- [`taskflow-hosts/src/opencode-runner.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-hosts/src/opencode-runner.ts) +- [`taskflow-hosts/src/grok-runner.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-hosts/src/grok-runner.ts) + +Pi adapter 独立位于 `pi-taskflow`,避免 host SDK 进入 core。 + +这是优秀的分层,不应被降格为“只是把五个 CLI 包在一起”。但它也带来长期维护成本:每个 host 都有独立的 argv、permission model、event-stream parser 和版本 workaround,宿主 CLI 格式变化会持续要求适配和测试更新。 + +### 3.6 持久化适合单机开发者,不等价于分布式 durable execution + +[`store.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/store.ts) 的文件持久化设计包含: + +- same-directory temp file + atomic rename; +- `O_CREAT|O_EXCL` 文件锁; +- stale lock 的原子抢占; +- run id 和 flow path traversal 防护; +- index 重建; +- phase-level resume。 + +对本地 CLI runtime 来说,这是一套认真且合理的实现。不同 run id 可以并发执行;同一 run state 的持久化由 per-run lock 串行化,共享 index 更新另有 index lock 保护。它解决的是并发文件写入一致性,不提供分布式调度或跨机器协调。 + +但它没有: + +- 数据库 WAL; +- replication; +- multi-node worker; +- sharding; +- exactly-once activity semantics; +- HA; +- 长期 scheduler; +- 集群级 backpressure。 + +因此正确定位是: + +> **local developer runtime with crash-resilient state**,而不是 Temporal-class distributed durable workflow platform。 + +--- + +## 4. 原创性:哪些新,哪些不新 + +### 4.1 明确属于成熟前例的机制 + +| taskflow 机制 | 主要已有思想 | +|---|---| +| Declarative DAG | Airflow、Step Functions、Argo、科学工作流系统 | +| `parallel` / `map` / `reduce` | 数据流与工作流标准原语 | +| `gate` / `approval` | CI/CD quality gate、Temporal Signal、Camunda UserTask | +| `loop` / convergence | 迭代工作流、状态机、固定点计算 | +| `race` | first-success / Promise race / Go select | +| Event log + fold | Event sourcing、CQRS | +| Content-addressed cache | Nix、Bazel、现代构建系统 | +| Stale frontier | Self-adjusting computation、Salsa、Adapton | +| Reflexion | 2023 年 Reflexion 研究路线 | +| Tournament + judge | LLM-as-judge、Arena、自一致性和多样本选择 | +| Context isolation | 进程隔离、最小权限、上下文边界 | +| TypeScript compile-time runes | Svelte 风格编译期 directive | + +因此,以下说法都不够准确: + +- “taskflow 发明了 declarative DAG”; +- “taskflow 发明了 event sourcing”; +- “taskflow 发明了内容寻址缓存”; +- “taskflow 是第一个 resumable workflow”; +- “拥有 12 种 phase 就意味着 12 项原创能力”。 + +### 4.2 有价值的是组合创新 + +0.2.0 的主要原创性来自三个组合: + +#### 组合 A:编译 IR + 静态验证 + 非确定性 Agent 节点 + +传统 workflow IR 更多面向确定性计算、数据管道或服务活动。taskflow 将 token、模型、工具、上下文、预算和 judge 作为 Agent 节点语义纳入可验证图结构。 + +#### 组合 B:事件 trace + 零 token 决策 replay + +事件溯源不是新技术,但把 LLM 输出固定为历史证据、只重新评估 gate/budget/route 等结构决策,是一种适合非确定性 Agent 的务实 replay 语义。 + +#### 组合 C:内容寻址 + observed read-set + Agent 结果增量重算 + +构建系统缓存确定性 artefact;taskflow 尝试缓存昂贵的 Agent 输出,并通过 phase fingerprint 与 provenance 控制复用范围。机制来自既有研究,应用域和统一产品形态具有新意。 + +### 4.3 TypeScript DSL 是现代适配,不是基础突破 + +`taskflow-dsl` 采用编译期 rune 思路,把看起来像函数调用的 `agent()`、`map()`、`gate()` 等结构通过 TypeScript AST 擦除为 Taskflow JSON/FlowIR。 + +它提升了类型提示、模块化和 authoring ergonomics,但模式直接受 Svelte 等 compile-time directive 系统启发。更准确的评价是: + +> 在 Agent workflow authoring 领域有辨识度的现代适配,而不是新的编程语言理论。 + +--- + +## 5. 与主要开源项目的比较 + +### 5.1 采用规模只作背景,不作为质量分数 + +2026-07-13 GitHub API 数据: + +| 项目 | Stars | Forks | 创建时间 | +|---|---:|---:|---| +| `heggria/taskflow` | 33 | 4 | 2026-06-04 | +| `langchain-ai/langgraph` | 37,132 | 6,236 | 2023-08-09 | +| `temporalio/temporal` | 21,602 | 1,725 | 2019-10-16 | +| `crewAIInc/crewAI` | 55,403 | 7,814 | 2023-10-27 | +| `microsoft/autogen` | 59,687 | 8,985 | 2023-08-18 | +| `openai/openai-agents-python` | 27,858 | 4,307 | 2025-03-11 | +| `mastra-ai/mastra` | 26,118 | 2,407 | 2024-08-06 | +| `pydantic/pydantic-ai` | 18,459 | 2,350 | 2024-06-21 | +| `google/adk-python` | 20,579 | 3,687 | 2025-04-01 | + +这些项目年龄、定位、公司背景和发行方式差异巨大。星数只能说明 taskflow 尚处早期影响力阶段,不能单独说明技术质量。 + +### 5.2 比较矩阵 + +> **时效声明:**下表反映 2026-07-13 可验证状态;竞品仓库和商业服务可能已经演进。所有 taskflow 内部源码判断均固定到 `v0.2.0` tag。 + +| 项目 | 对方更强的部分 | taskflow 更强或更独特的部分 | 关键类别差异 | +|---|---|---|---| +| [LangGraph](https://github.com/langchain-ai/langgraph) | 循环状态图、checkpoint、LangSmith、生态、生产采用 | 静态可验证 DAG、host portability、FlowIR、决策 replay、内容寻址增量执行 | LangGraph 是 stateful agent graph;taskflow 是本地 declarative coding-agent DAG | +| [Temporal](https://github.com/temporalio/temporal) | 分布式 durability、event history、HA、长期 workflow、生产证明 | 无服务端本地使用、Agent 专用 phase、零 token 决策 replay | Temporal 是分布式 durable workflow;taskflow 是本地开发者 runtime | +| [Mastra](https://github.com/mastra-ai/mastra) | TypeScript 产品体验、Studio、integrations、observability、生态 | 静态图验证、五 coding-agent hosts、FlowIR/provenance/recompute | Mastra 是通用 TS Agent 平台;taskflow 更专注编排内核 | +| [CrewAI](https://github.com/crewAIInc/crewAI) | 社区、角色协作、易上手、平台化能力 | runtime-owned explicit DAG、结构验证、缓存与隔离语义 | CrewAI 面向角色团队;taskflow 面向可检查任务图 | +| [AutoGen](https://github.com/microsoft/autogen) | Agent messaging、runtime abstraction、企业生态 | 更可审计的 declarative graph、静态验证、coding-host adapters | AutoGen 更偏对话式多智能体;taskflow 更偏执行图 | +| [OpenAI Agents SDK](https://github.com/openai/openai-agents-python) | 官方模型生态、sessions、tracing、realtime、guardrails | vendor-neutral coding-agent DAG、编译和增量语义 | OpenAI SDK 是 Agent 应用 SDK;taskflow 是跨宿主 orchestration runtime | +| [PydanticAI](https://github.com/pydantic/pydantic-ai) | 类型系统、通用 Agent DX、生态和 observability | Agent DAG 静态检查、跨宿主执行、counterfactual replay | PydanticAI 面向 Python 应用;taskflow 面向 coding-agent CLI | +| [Google ADK](https://github.com/google/adk-python) | Cloud/Vertex/A2A 集成、企业场景、一般 Agent runtime | 本地轻量、host-neutral coding workflow、内容寻址重算 | ADK 是平台生态;taskflow 是本地内核 | +| [Nx](https://github.com/nrwl/nx) / [Bazel](https://github.com/bazelbuild/bazel) | 成熟增量计算、remote cache、确定性、规模 | 将类似思想应用到昂贵、非确定性的 Agent 调用 | 构建系统不是 Agent Framework,比较仅限 incrementality | +| [Dagster](https://github.com/dagster-io/dagster) / [Prefect](https://github.com/PrefectHQ/prefect) | Scheduler、UI、worker pool、数据库持久化、生产运维 | 更轻量、更贴近本地 coding-agent 和上下文隔离 | 数据编排平台与本地 Agent runtime 类别不同 | + +### 5.3 不应做的错误比较 + +以下结论都不成立: + +- taskflow 比 Temporal 更 durable; +- taskflow 比 LangGraph 更全面; +- taskflow 比 CrewAI 更成熟; +- taskflow 因为 phase type 更多就技术上全面领先; +- taskflow 因为 stars 少就只是玩具。 + +正确的比较应该限定问题: + +> 在“单机、可声明、可验证、跨 coding-agent host、需要上下文隔离与增量复用”的任务中,谁提供了更深的 orchestration engine? + +在这个限定下,taskflow 的确具有第一梯队技术深度;离开这个限定,它在通用状态管理、分布式执行、integrations、observability 和生态上明显落后于头部平台。 + +--- + +## 6. 工程与安全评估 + +### 6.1 工程严谨度 + +值得肯定的机制包括: + +- 原子文件写入; +- TOCTOU 风险更低的 stale-lock 原子抢占; +- path traversal guard; +- process group 级子进程清理; +- SIGTERM 到 SIGKILL 的升级; +- idle watchdog; +- loop、tournament、dynamic graph 的硬上限; +- non-idempotent phase 自动退出缓存和 transient retry; +- callback fail-open; +- gate model output fail-closed; +- 发布后的 npm provenance 与 tarball integrity 验证。 + +这些特征表明项目对失败模式有真实建模,而不是只覆盖 happy path。 + +### 6.2 安全设计优势 + +公开源码可确认: + +- 非 Pi host runner 使用环境变量 allowlist,而不是完整继承父进程 secret; +- LLM-generated dynamic flow 禁止 `script`; +- 动态 flow 禁止容易产生代码执行或 ReDoS 风险的 scorer; +- dynamic nesting、phase count、map items、tournament variants 有硬限制; +- 不可解析 gate 输出按 BLOCK 处理; +- 不同宿主的 mutating 权限采用 fail-closed opt-in; +- MCP 走 stdio,不暴露 HTTP origin/CSRF 表面。 + +这套 threat model 明确把“LLM 输出是不可信输入”作为设计前提,属于同龄开源项目中较少见的安全意识。 + +### 6.3 仍需修复的一致性缺口 + +为避免公开报告成为利用说明,本节仅描述类别与修复方向: + +1. **Script phase 的环境变量边界应与 host runner 对齐。**当前人类编写的 script subprocess 继承范围比 agent child 更宽。建议引入 script-specific allowlist,默认不透传凭证类变量。 +2. **动态 cwd containment 应统一使用 realpath 校验。**词法路径检查应与存储读取路径的 symlink-aware containment 保持一致。 +3. **应增加 parser 和 gate verdict 的 fuzz/adversarial 测试。**当前已知输入覆盖很好,但自动生成表达式和边界输入值得系统化模糊测试。 +4. **跨宿主 capability degradation 需要测试。**`shareContext`、budget、recompute 等能力不能只依赖文档约定。 + +这些问题会压低安全评分,但没有证据表明 0.2.0 存在无需可信 flow 作者即可利用的严重远程漏洞。 + +--- + +## 7. 决定性短板 + +### 7.1 Event kernel 尚未成为统一主运行时 + +这是 0.2.0 最大的架构扣分项。 + +[`runtime.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/runtime.ts) 明确说明 event kernel 默认关闭;[`exec/kernel-policy.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/exec/kernel-policy.ts) 会让多个高级特性回退到 imperative path。 + +当前大致是: + +```text +Event kernel + ├── 更容易 fold、replay 和比较 + ├── 默认 OFF + ├── race / expand 不支持 + └── 多种高级能力触发 fallback + +Imperative runtime + ├── 0.2 功能覆盖更完整 + └── 继续承担主生产路径 +``` + +因此不能把 0.2.0 描述成“所有执行都已经由统一 event-sourced kernel 驱动”。更准确的说法是: + +> event kernel 已交付并有测试,但尚处于受限、opt-in 的 strangler 路径;完整统一属于后续阶段。 + +### 7.2 五宿主统一的是接口,不是完整能力语义 + +主要差异包括: + +| 能力 | Pi | Codex | Claude | OpenCode | Grok | +|---|---|---|---|---|---| +| 基础 DAG 运行 | ✓ | ✓ | ✓ | ✓ | ✓ | +| Token budget | ✓ | ✓ | ✓ | ✓ | ✗(host 不报告 usage) | +| USD budget | ✓ | ✗ | ✓ | ✓ | ✗ | +| Shared Context Tree 工具注入 | ✓ | 退化 | 退化 | 退化 | 退化 | +| `recompute --apply` | ✓ | dry-run | dry-run | dry-run | dry-run | +| Guided init | ✓ | 手工 | 手工 | 手工 | 手工 | +| 权限配置 | Pi 模型 | Codex sandbox | Claude opt-in | OpenCode opt-in | 自定义 sandbox profile | + +fail-closed 是正确行为,但它说明“one contract across five hosts”需要拆成两个层次: + +- **结构合同:成立;** +- **能力完全等价:不成立。** + +公开文档应该提供明确的 host capability matrix,而不是让用户在错误发生后才发现限制。 + +### 7.3 Crash recovery 粒度仍然偏粗 + +taskflow 的 resume 主要是 phase-level。一个长时间运行的 map 在中途崩溃时,如果没有可用的 per-item cache,可能需要重跑整个 map。它与 Temporal activity checkpoint 或更细粒度的 durable execution 仍有距离。 + +### 7.4 缺少生产性能和正确性证据 + +目前没有公开证明: + +- 大规模图的 scheduler overhead; +- 数百 map item 的内存和进程行为; +- event kernel 与 imperative runtime 的性能差异; +- 真实 repo 中 cache hit rate; +- stale frontier 的 over-invalidation 比例; +- 跨宿主结果一致性; +- 长期 run-state 兼容性; +- 高频 host CLI 版本变化的适配成本。 + +对 39 天项目而言这很正常,但它决定了 0.2.0 不能获得高生产成熟度评分。 + +### 7.5 Bus factor 与生态风险 + +180/187 commits(报告日 `main`;固定 tag 为 179/186)来自主维护者。高速度证明执行力,但也意味着: + +- 架构知识高度集中; +- 五 host parser 的维护负担集中; +- 发布、文档、兼容性和 issue triage 都依赖同一人; +- 外部用户尚未形成共同维护能力。 + +这是当前比代码复杂度更现实的长期风险。 + +--- + +## 8. 评分卡 + +| 维度 | 评分 | 置信度 | 主要依据 | +|---|---:|---|---| +| **技术原创性** | **7.0** | 中高 | 单项机制多有前例;组合形态少见,尤其是 Agent DAG 的 FlowIR + replay + incrementality | +| **架构设计** | **8.5** | 高 | Host-neutral core、IR、明确不变量、失败语义;扣分来自双执行路径和 kernel coverage | +| **工程严谨度** | **8.7** | 高 | 106 测试文件、CI 运行产物报告 1,599 tests、原子存储、packed consumer、供应链验证 | +| **安全设计** | **8.0** | 中高 | 动态 flow hardening、环境过滤、进程树清理、fail-closed;仍有 subprocess env 和 realpath 一致性缺口 | +| **开发者体验** | **7.0** | 中 | JSON/TS DSL、verify、compile、trace、文档优秀;概念较多、配置负担高 | +| **跨宿主可移植性** | **6.5** | 高 | Engine seam 真实;预算、ctx、recompute、init、权限语义不完全一致 | +| **生产成熟度** | **4.5** | 高 | 单机可靠,但无公开长期生产、benchmark、scheduler、OTel、HA | +| **生态成熟度** | **3.5** | 高 | 九包五宿主完整;外部 integrations、maintainers 和 dependents 很少 | +| **采用与影响力** | **2.5** | 高 | 早期 npm/GitHub 信号存在,但项目仅 39 天,不能推断用户数或留存 | +| **综合前沿性** | **6.8** | 中高 | 技术显著前沿,但受产品、生产与生态成熟度封顶 | + +综合分不是简单平均。原因是“前沿性”需要同时考虑创新潜力和现实完成度:taskflow 的技术组合足以高于普通细分项目,但没有成熟证据支撑 8 分以上的整体判断。 + +--- + +## 9. 分层定位 + +### 9.1 放在整个 GitHub 系统软件中 + +**定位:B-,早期技术创新项目。** + +理由: + +- 真实实现,不是概念 demo; +- 架构复杂度和工程严谨度高; +- 但项目年龄、生产证据、生态、bus factor 和分布式能力不足; +- 尚不能与 Temporal、Bazel、SQLite、React 等 category-defining 项目同层比较。 + +### 9.2 放在开源 Agent Framework 中 + +**定位:B,架构型第二梯队新秀。** + +它在以下维度可能位于同类上层: + +- 静态验证; +- 决策级 replay; +- 内容寻址和 stale-frontier; +- coding-agent context isolation; +- 跨 host execution seam。 + +但在以下维度明显落后头部: + +- general-purpose state management; +- integrations; +- Web/Cloud observability; +- managed runtime; +- 社区和采用; +- 生产证明。 + +### 9.3 放在 coding-agent orchestration 中 + +**技术深度:A-,第一梯队候选。** + +限定条件是: + +- 本地 coding-agent 子进程; +- reusable declarative graph; +- context isolation; +- verify-before-spend; +- resume/replay/recompute; +- 多宿主。 + +这个判断仅代表 engine depth,不代表: + +- 市场第一; +- 采用第一; +- UX 第一; +- 分布式 durability 第一; +- 生态第一。 + +“第一梯队候选”比“已经类别领先”更符合当前证据。 + +--- + +## 10. 将项目提升一个层级的路线 + +### 10.1 第一优先级:收敛双执行路径 + +建议的可验证目标: + +1. event kernel 默认开启; +2. `race` 和 `expand` 进入 kernel; +3. score gate、retry、reflexion、expect、cross-run cache、context 等不再触发 fallback; +4. 12 phase type 都有 imperative/kernel differential parity tests; +5. 可以逐步删除或显著缩小 imperative path。 + +这是从“前沿架构原型”变成“可信执行内核”的关键。 + +### 10.2 第二优先级:发布可复现的增量 benchmark + +一个可信 benchmark 至少应包含: + +```text +公开 repo + 固定 commit +固定 flow + 固定模型配置 +首次完整运行的 wall time / token / USD +修改一个文件后的 why-stale 输出 +recompute 的 wall time / token / USD +cache hit rate +stale over-invalidation +错误复用检查 +不同宿主差异 +``` + +应覆盖至少三类 workload: + +- 多文件安全审计; +- 代码迁移; +- 计划—实现—审查闭环。 + +只有这样才能把“增量重算具有潜力”升级为“增量重算已证明持续有效”。 + +### 10.3 第三优先级:建立 host capability contract + +建议: + +- 在 schema/runtime 层增加 capability negotiation; +- 在执行前输出 host capability report; +- 对 unsupported feature 统一 fail-closed,而不是静默退化; +- 为 `shareContext`、budget、recompute、tools、sandbox 建立跨宿主矩阵; +- 增加 advanced-phase 跨宿主行为等价 E2E; +- 使 MCP hosts 支持 apply recompute,或明确将其定义为 Pi-only extension。 + +### 10.4 第四优先级:补齐安全边界一致性 + +- Script subprocess 使用最小环境 allowlist; +- 动态 cwd 使用 symlink-aware realpath containment; +- condition/interpolation/gate parser 引入 fuzz tests; +- host parser 运行 fixture compatibility CI; +- 记录每个 host CLI 的最低和最高已验证版本。 + +### 10.5 第五优先级:改善产品层,而不是扩张更多 phase type + +0.2.0 已经有足够多的 phase。下一阶段更有价值的是: + +- 非 Pi guided init; +- intent-to-flow / compose assistant; +- host capability diagnostics; +- 轻量 trace viewer; +- 可选 OpenTelemetry exporter; +- package-specific npm README; +- 更少概念的渐进式 onboarding。 + +继续增加 phase type 的边际价值可能低于让现有能力更统一、更可观察、更容易成功使用。 + +### 10.6 第六优先级:建立外部证明 + +建议在 90 天和 180 天重新评估: + +- 外部长期贡献者数量; +- 外部依赖项目; +- issue 响应和兼容性记录; +- 不同组织的 case study; +- npm 下载次数的持续趋势,而不是发布峰值; +- 公开 workload benchmark; +- 长期 run-state migration 成功率。 + +--- + +## 11. 不建议采取的方向 + +### 11.1 不要把自己描述成 Temporal 替代品 + +这会把比较带入 taskflow 当前最弱的维度:HA、distributed durability、scheduler 和 enterprise operations。 + +### 11.2 不要把下载量宣传成用户数 + +npm 下载次数不能证明独立用户、留存或生产使用。更适合报告: + +- 包级下载次数; +- GitHub traffic; +- 外部仓库引用; +- case study; +- repeat-run telemetry(若用户明确 opt-in)。 + +### 11.3 不要继续使用整个 monorepo “zero deps”的绝对表述 + +更准确的是: + +> `taskflow-core` 没有直接 runtime dependencies,并使用 `typebox` peer dependency;delivery packages 依赖 taskflow 内部包,Pi adapter 有 host SDK peer dependencies。 + +### 11.4 不要把 replay 描述成完整确定性重执行 + +更准确的是: + +> 对记录事件执行零 token 的 counterfactual decision replay;需要改变模型输出时会标记 live rerun。 + +### 11.5 不要在统一 kernel 前急于实现分布式集群 + +taskflow 的当前价值来自轻量、本地、跨 coding-agent host。直接追逐 Temporal 式 distributed runtime 会显著扩大复杂度,并可能稀释最有差异化的本地开发者场景。 + +--- + +## 12. 最终结论 + +对 taskflow 0.2.0 最准确的判断不是“已经领先整个 GitHub”,也不是“只是另一个 Agent workflow wrapper”。 + +更准确的结论是: + +> **taskflow 已经把 coding-agent 编排从“多调用几次模型”提升到了一套可以编译、验证、记录、反事实回放和增量重算的本地执行系统。这个组合在当前开源 Agent 生态中确实具有前沿性。** + +其技术前沿性主要来自: + +- 真实的 FlowIR 编译与内容寻址; +- 模型调用前的结构验证; +- 决策级零 token replay; +- 基于 provenance 的 stale-frontier; +- 对非确定性 Agent 输出的跨运行复用; +- 干净的 host-neutral execution seam; +- 超出同龄项目平均水平的测试、安全和发布工程。 + +但它仍然是: + +- 一个 39 天的 0.2 项目; +- 主要由单一维护者完成; +- 使用单机文件持久化; +- event kernel 默认关闭; +- 存在双执行路径; +- 五宿主能力并不完全等价; +- 没有公开生产规模和长期收益证明。 + +因此,最终裁定是: + +| 判断 | 结论 | +|---|---| +| 是否是普通玩具 | **不是** | +| 是否只是 CLI 包装 | **不是** | +| 技术方向是否前沿 | **是** | +| 单项机制是否大多原创 | **不是,大多来自成熟前例** | +| 系统组合是否少见 | **是** | +| 是否已是综合 Agent Framework 第一梯队 | **尚未** | +| 是否是狭义 coding-agent orchestration 第一梯队候选 | **是** | +| 是否已是整个 GitHub 的 category-defining 项目 | **不是** | +| 是否有升级为类别定义项目的潜力 | **有,但需要统一 kernel、生产 benchmark、宿主收敛和外部采用证明** | + +**综合评分:6.8 / 10。** + +这不是保守地“各打五十大板”,而是对不同事实同时成立的承认:taskflow 0.2.0 的技术骨架已经足够严肃,足以进入前沿讨论;但一个项目只有在技术想法、统一实现、真实生产、外部生态和长期维护都被证明之后,才有资格被称为整个 GitHub 范围内的类别领导者。taskflow 已完成第一步和部分第二步,尚未完成后三步。 + +--- + +## 附录 A:关键源码证据 + +| 能力 | 固定版本源码 | +|---|---| +| 12 phase type 与 schema | [`packages/taskflow-core/src/schema.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/schema.ts) | +| 主运行时与 event-kernel 开关 | [`packages/taskflow-core/src/runtime.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/runtime.ts) | +| Kernel capability policy | [`packages/taskflow-core/src/exec/kernel-policy.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/exec/kernel-policy.ts) | +| FlowIR 编译 | [`packages/taskflow-core/src/flowir/compile.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/flowir/compile.ts) | +| Canonical hash | [`packages/taskflow-core/src/flowir/canonical-hash.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/flowir/canonical-hash.ts) | +| Replay | [`packages/taskflow-core/src/replay.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/replay.ts) | +| Stale frontier | [`packages/taskflow-core/src/stale.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/stale.ts) | +| Cross-run cache | [`packages/taskflow-core/src/cache.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/cache.ts) | +| Atomic store / locks | [`packages/taskflow-core/src/store.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/store.ts) | +| Shared Context Tree | [`packages/taskflow-core/src/context-store.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/context-store.ts) | +| Host runner contract | [`packages/taskflow-core/src/host/runner-types.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/host/runner-types.ts) | +| MCP tools | [`packages/taskflow-mcp-core/src/mcp/server.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-mcp-core/src/mcp/server.ts) | +| TypeScript DSL | [`packages/taskflow-dsl/src`](https://github.com/heggria/taskflow/tree/v0.2.0/packages/taskflow-dsl/src) | +| Child env filtering | [`packages/taskflow-hosts/src/child-env.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-hosts/src/child-env.ts) | +| Script subprocess | [`packages/taskflow-core/src/runtime/phases/script.ts`](https://github.com/heggria/taskflow/blob/v0.2.0/packages/taskflow-core/src/runtime/phases/script.ts) | +| CI | [`.github/workflows/ci.yml`](https://github.com/heggria/taskflow/blob/v0.2.0/.github/workflows/ci.yml) | +| Publish pipeline | [`.github/workflows/publish.yml`](https://github.com/heggria/taskflow/blob/v0.2.0/.github/workflows/publish.yml) | + +## 附录 B:外部项目链接 + +- LangGraph: +- Temporal: +- CrewAI: +- AutoGen: +- OpenAI Agents SDK: +- Mastra: +- PydanticAI: +- Google ADK: +- Dagster: +- Prefect: +- Dagger: +- Nx: +- Bazel: + +## 附录 C:数据限制 + +1. GitHub stars、forks 和 npm downloads 是采用信号,不是技术质量指标。 +2. npm API 不提供可靠的按版本独立用户统计,因此不能单独估算 0.2.0 用户数。 +3. 本报告没有访问私有生产 telemetry、用户访谈或未公开的企业案例。 +4. 安全部分是源码层设计审查,不是渗透测试、形式化验证或第三方认证。 +5. 竞品功能会快速变化;比较矩阵仅代表 2026-07-13 可验证状态。 +6. 项目后续提交可能已经修复报告中的缺口;固定 tag 链接用于保证评估可复现。 From 1a91d084a4c1ea9967bb84e09868979007acdabb Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 18 Jul 2026 18:03:47 +0800 Subject: [PATCH 8/8] chore(release): absorb verified maintenance updates Roll the safe dependency and workflow upgrades from the open maintenance PRs into the 0.2.3 release branch. Keep the TypeScript 7 compiler-API migration separate, and make the persistence stress test independent of slow durable-fsync hosts without changing the production timeout. --- .github/workflows/ci.yml | 12 +- .github/workflows/deploy-website.yml | 2 +- .github/workflows/publish.yml | 2 +- CHANGELOG.md | 4 + package.json | 8 +- .../test/resource-persistence.test.ts | 2 +- .../test/publish-verification.test.ts | 4 +- pnpm-lock.yaml | 580 ++++++++++++------ website/package.json | 8 +- 9 files changed, 427 insertions(+), 195 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96483156..862aa017 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "22" cache: "pnpm" @@ -49,7 +49,7 @@ jobs: # in package.json, so every contributor and CI run use the same pnpm. - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: ${{ matrix.node }} registry-url: "https://registry.npmjs.org" @@ -72,7 +72,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "22" registry-url: "https://registry.npmjs.org" @@ -124,7 +124,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "22" registry-url: "https://registry.npmjs.org" @@ -146,7 +146,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "22" registry-url: "https://registry.npmjs.org" @@ -172,7 +172,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "22" cache: "pnpm" diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 2a36e6de..bc15178c 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -48,7 +48,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 352ced24..65481581 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -48,7 +48,7 @@ jobs: fi echo "Verified $GITHUB_REF_NAME at $TAG_COMMIT is reachable from origin/main" - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "22" registry-url: "https://registry.npmjs.org" diff --git a/CHANGELOG.md b/CHANGELOG.md index e58a4d41..7cd2202b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to taskflow are documented here. This project follows [Keep - **Durable MCP background runs.** `taskflow_run` now accepts `mode: "background"` on Codex, Claude Code, OpenCode, and Grok Build, returning a durable `runId` immediately instead of tying a long DAG to one MCP request timeout. The new `taskflow_runs` tool lists background runs and supports `status`, bounded/repeatable `wait`, and explicit `cancel`; lists report the total active count and can filter `running` versus `terminal` runs. Detached runs persist their final output and trace, preserve incremental-cache and library-reuse behavior, detect orphaned processes, and use a file-backed cancellation control plane that survives MCP request and server boundaries. Starting a sixth concurrent background run emits an explicit resource-contention warning because Taskflow intentionally has no hidden global cross-host scheduler. +### Changed + +- **Release maintenance rollup.** The 0.2 frontier assessment is now linked from both READMEs; Pi development peers move to 0.80.7, Fumadocs packages move to their mutually compatible 16.11.5/15.2.0 set, Biome moves to 2.5.4, and every workflow uses the verified `actions/setup-node` v7.0.0 tag commit. These changes absorb the independently opened maintenance PRs into the fully tested 0.2.3 release transaction. + ### Fixed - **Atomic terminal results.** The runtime now persists `finalOutput` and `outputSourcePhaseId` in the same terminal-state write, so a crash or immediate poll can never observe `completed` without its result. diff --git a/package.json b/package.json index 98acd642..73df4696 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,10 @@ "test:e2e-pi-terminal-reap": "node --conditions=development --experimental-strip-types packages/pi-taskflow/test/e2e-terminal-reap.mts" }, "devDependencies": { - "@earendil-works/pi-agent-core": "^0.80.3", - "@earendil-works/pi-ai": "^0.80.3", - "@earendil-works/pi-coding-agent": "^0.80.3", - "@earendil-works/pi-tui": "^0.80.3", + "@earendil-works/pi-agent-core": "^0.80.7", + "@earendil-works/pi-ai": "^0.80.7", + "@earendil-works/pi-coding-agent": "^0.80.7", + "@earendil-works/pi-tui": "^0.80.7", "@types/node": "^26", "typebox": "^1.3.6", "typescript": "^7.0.2" diff --git a/packages/taskflow-core/test/resource-persistence.test.ts b/packages/taskflow-core/test/resource-persistence.test.ts index 717ebf4f..ac8482ce 100644 --- a/packages/taskflow-core/test/resource-persistence.test.ts +++ b/packages/taskflow-core/test/resource-persistence.test.ts @@ -24,7 +24,7 @@ test("persistent mutex serializes concurrent contenders without losing updates", const value = Number(fs.readFileSync(counterPath, "utf8")); await new Promise((resolve) => setImmediate(resolve)); fs.writeFileSync(counterPath, String(value + 1)); - }); + }, { timeoutMs: 30_000 }); })); assert.equal(fs.readFileSync(counterPath, "utf8"), "40"); assert.deepEqual(fs.readdirSync(`${lockPath}.queue`), []); diff --git a/packages/taskflow-hosts/test/publish-verification.test.ts b/packages/taskflow-hosts/test/publish-verification.test.ts index 0b0a8183..c0047a75 100644 --- a/packages/taskflow-hosts/test/publish-verification.test.ts +++ b/packages/taskflow-hosts/test/publish-verification.test.ts @@ -86,7 +86,7 @@ test("publish workflow pins actions and isolates npm provenance from release per 2, ); assert.ok(uses.some((use) => use.includes("pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6"))); - assert.ok(uses.some((use) => use.includes("actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6"))); + assert.ok(uses.some((use) => use.includes("actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7"))); assert.doesNotMatch(source, /uses:\s+\S+@v\d+/); const publish = workflowJob(source, "publish"); @@ -133,7 +133,7 @@ test("every repository workflow pins third-party actions to verified full SHAs", const trustedPins = new Map([ ["actions/checkout", "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"], ["pnpm/action-setup", "0ebf47130e4866e96fce0953f49152a61190b271"], - ["actions/setup-node", "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"], + ["actions/setup-node", "820762786026740c76f36085b0efc47a31fe5020"], ["actions/upload-pages-artifact", "fc324d3547104276b827a68afc52ff2a11cc49c9"], ["actions/deploy-pages", "cd2ce8fcbc39b97be8ca5fce6e763baed58fa128"], ["github/codeql-action/init", "99df26d4f13ea111d4ec1a7dddef6063f76b97e9"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db14fd0d..77407d80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,17 +12,17 @@ importers: .: devDependencies: '@earendil-works/pi-agent-core': - specifier: ^0.80.3 - version: 0.80.3(ws@8.21.0)(zod@4.4.3) + specifier: ^0.80.7 + version: 0.80.10(ws@8.21.0)(zod@4.4.3) '@earendil-works/pi-ai': - specifier: ^0.80.3 - version: 0.80.3(ws@8.21.0)(zod@4.4.3) + specifier: ^0.80.7 + version: 0.80.10(ws@8.21.0)(zod@4.4.3) '@earendil-works/pi-coding-agent': - specifier: ^0.80.3 - version: 0.80.3(ws@8.21.0)(zod@4.4.3) + specifier: ^0.80.7 + version: 0.80.10(ws@8.21.0)(zod@4.4.3) '@earendil-works/pi-tui': - specifier: ^0.80.3 - version: 0.80.3 + specifier: ^0.80.7 + version: 0.80.10 '@types/node': specifier: ^26 version: 26.1.1 @@ -136,14 +136,14 @@ importers: website: dependencies: fumadocs-core: - specifier: 16.11.1 - version: 16.11.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + specifier: 16.11.5 + version: 16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) fumadocs-mdx: - specifier: 15.1.0 - version: 15.1.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + specifier: 15.2.0 + version: 15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) fumadocs-ui: - specifier: 16.11.1 - version: 16.11.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2) + specifier: 16.11.5 + version: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2) lucide-react: specifier: ^1.21.0 version: 1.23.0(react@19.2.7) @@ -158,8 +158,8 @@ importers: version: 19.2.7(react@19.2.7) devDependencies: '@biomejs/biome': - specifier: 2.5.3 - version: 2.5.3 + specifier: 2.5.4 + version: 2.5.4 '@tailwindcss/postcss': specifier: 4.3.2 version: 4.3.2 @@ -301,73 +301,91 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.5.3': - resolution: {integrity: sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A==} + '@biomejs/biome@2.5.4': + resolution: {integrity: sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.5.3': - resolution: {integrity: sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g==} + '@biomejs/cli-darwin-arm64@2.5.4': + resolution: {integrity: sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.5.3': - resolution: {integrity: sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ==} + '@biomejs/cli-darwin-x64@2.5.4': + resolution: {integrity: sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.3': - resolution: {integrity: sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg==} + '@biomejs/cli-linux-arm64-musl@2.5.4': + resolution: {integrity: sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-arm64@2.5.3': - resolution: {integrity: sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA==} + '@biomejs/cli-linux-arm64@2.5.4': + resolution: {integrity: sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-x64-musl@2.5.3': - resolution: {integrity: sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ==} + '@biomejs/cli-linux-x64-musl@2.5.4': + resolution: {integrity: sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-linux-x64@2.5.3': - resolution: {integrity: sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA==} + '@biomejs/cli-linux-x64@2.5.4': + resolution: {integrity: sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-win32-arm64@2.5.3': - resolution: {integrity: sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw==} + '@biomejs/cli-win32-arm64@2.5.4': + resolution: {integrity: sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.3': - resolution: {integrity: sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q==} + '@biomejs/cli-win32-x64@2.5.4': + resolution: {integrity: sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] + '@earendil-works/pi-agent-core@0.80.10': + resolution: {integrity: sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg==} + engines: {node: '>=22.19.0'} + '@earendil-works/pi-agent-core@0.80.3': resolution: {integrity: sha512-3qw0/GeRQBU/nlGjDe5Yb7ePKTmoxefx2YxyKMFAviFUMXpFexBG/hS7mBtwFahFvzrrTPPoRT6sFIDjwoDWPQ==} engines: {node: '>=22.19.0'} + '@earendil-works/pi-ai@0.80.10': + resolution: {integrity: sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==} + engines: {node: '>=22.19.0'} + hasBin: true + '@earendil-works/pi-ai@0.80.3': resolution: {integrity: sha512-jPZLMeGL5kkMSEAwAklfXTMHqZvfhsJtCCpKGIr5Duk7mc0n4skjB1dugk7y0z3z8ZHIUCmPAWHdyDqgUz5vdA==} engines: {node: '>=22.19.0'} hasBin: true + '@earendil-works/pi-coding-agent@0.80.10': + resolution: {integrity: sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg==} + engines: {node: '>=22.19.0'} + hasBin: true + '@earendil-works/pi-coding-agent@0.80.3': resolution: {integrity: sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==} engines: {node: '>=22.19.0'} hasBin: true + '@earendil-works/pi-tui@0.80.10': + resolution: {integrity: sha512-c2JO29PbhKPEQ6fgHQKAl0WhwuFqzWfzspMmP+8B5tpDuP+0mvarRbKKg8gq4b+pQx/QX+6aVS4ko7deoyjQjg==} + engines: {node: '>=22.19.0'} + '@earendil-works/pi-tui@0.80.3': resolution: {integrity: sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==} engines: {node: '>=22.19.0'} @@ -556,8 +574,8 @@ packages: '@types/react': optional: true - '@fumadocs/tailwind@0.1.0': - resolution: {integrity: sha512-nF/DCAwOR21HZ4AkjIOv3Iqwyqywzb6pdyeMcoa+aZzirXj5ntvNZbe3jJ0v3ehhtrRfYYeXBezvjn8ZmV+fuQ==} + '@fumadocs/tailwind@0.1.1': + resolution: {integrity: sha512-BnPe52UxSaG8yKlHMKBxXw8h6GpK5qO55ci6+Qd5JnquTvIw6SpfbC1P+qAi82PuPWv1KZAWY8bxRk4+x9ctXw==} peerDependencies: tailwindcss: ^4.0.0 peerDependenciesMeta: @@ -893,11 +911,11 @@ packages: '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} - '@radix-ui/primitive@1.1.4': - resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} + '@radix-ui/primitive@1.1.5': + resolution: {integrity: sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==} - '@radix-ui/react-accordion@1.2.15': - resolution: {integrity: sha512-24Zz/0SYx8F2bSVThBnQrdJs2VbKelyuJordcFRRdA0fRAhrq/wSegGCqaQz34VQoiWqSMGYCYXEhynLSlyQlg==} + '@radix-ui/react-accordion@1.2.16': + resolution: {integrity: sha512-BpZJNmetujnGgUI6OX0jEhEmlA46WPqgub8Rv09Kyquwd0cc1ndMKpiPYCjmBU6KSSRPAMtgLpEoZSG/tdNIWQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -922,8 +940,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collapsible@1.1.15': - resolution: {integrity: sha512-8A1zibu5skAQ+UVbaeNH5hVMibiFCRJzgMuM14LTWGttnTZKQL9jwYnhAbHRuxrtCqPXa4JvvnVUq1pTNgyZYw==} + '@radix-ui/react-collapsible@1.1.16': + resolution: {integrity: sha512-opfXRe6nnzyGmCDPx+l1Aqo/RbqWtQal2FnsBqF9hhePp6j0LsRoBaRxcMOlTv+uYTJVtWYZKg9t9wTe+BA/ZA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -935,8 +953,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collection@1.1.11': - resolution: {integrity: sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g==} + '@radix-ui/react-collection@1.1.12': + resolution: {integrity: sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -957,8 +975,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-context@1.1.4': - resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} + '@radix-ui/react-context@1.2.0': + resolution: {integrity: sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -966,8 +984,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dialog@1.1.18': - resolution: {integrity: sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw==} + '@radix-ui/react-dialog@1.1.19': + resolution: {integrity: sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -988,8 +1006,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dismissable-layer@1.1.14': - resolution: {integrity: sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ==} + '@radix-ui/react-dismissable-layer@1.1.15': + resolution: {integrity: sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1010,8 +1028,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-focus-scope@1.1.11': - resolution: {integrity: sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w==} + '@radix-ui/react-focus-scope@1.1.12': + resolution: {integrity: sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1032,8 +1050,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-navigation-menu@1.2.17': - resolution: {integrity: sha512-fYeYQvbeNn5AQk2RBbpO7koLm2YbS00UYxC/IL2sgLlninEH5UNIv+X3E0KJ1Vy4WIo+dhN9w8GNqSHhbHWCIg==} + '@radix-ui/react-navigation-menu@1.2.18': + resolution: {integrity: sha512-K9HiuxZ6xCwSaHcIuUpxyhy4w5gpwzWjh9dHTSbMN3Ix4qAyVObS9RlU3zMycb0PO3v9Tpk0BXMwWvXOUbVXew==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1045,8 +1063,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popover@1.1.18': - resolution: {integrity: sha512-qdXDes+eHlnMUGlBAAAe5EG7oOQvqsXuq4mq585diMudg80iB+jHbsSeG3+Q4eWNsogNyhqU2p/3i+Y0iEepqg==} + '@radix-ui/react-popover@1.1.19': + resolution: {integrity: sha512-jkrTdQVxnIB8fpn0NyyxW9CTB5aCXZZelVz5z+Xmii6g5WxMqS3fInNslZ63puP39+Puu4jYohUK31y3dT87gQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1058,8 +1076,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popper@1.3.2': - resolution: {integrity: sha512-3QXNeMkdshed1MR3LNoiCirBywRFPkD8ETJa/HlPuLwSajaQixf2ro+isoDNJlGABg9ug41XuZpINZJIle4XWg==} + '@radix-ui/react-popper@1.3.3': + resolution: {integrity: sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1084,8 +1102,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-presence@1.1.6': - resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} + '@radix-ui/react-presence@1.1.7': + resolution: {integrity: sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1110,8 +1128,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-roving-focus@1.1.14': - resolution: {integrity: sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q==} + '@radix-ui/react-roving-focus@1.1.15': + resolution: {integrity: sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1123,8 +1141,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-scroll-area@1.2.13': - resolution: {integrity: sha512-7tncSubo2G0UY1e8rk+72qe3XRzrGnOLtZQ1PL1KoBfRUNX0NrJT5akb+0kfwSCc3gVR4wdHqyhAQBDpDNOwDw==} + '@radix-ui/react-scroll-area@1.2.14': + resolution: {integrity: sha512-bBODCWZK7JTbQLHs0uIP4f73wIWatakK4OS33UzkR1x897wu0PuO658a3f+6P2GEGyDzGYMuHRatMVoAk9WZTw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1145,8 +1163,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-tabs@1.1.16': - resolution: {integrity: sha512-v3Ab2l7z6U7tRB4xA0IyKdq0OsqaO1o9ZjsIEoKKnSZ/l96mZz8aCTX0NCXw+YVHJXr8Km4d+Mn6/Q8YjXa+gw==} + '@radix-ui/react-tabs@1.1.17': + resolution: {integrity: sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1185,6 +1203,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-is-hydrated@0.1.1': + resolution: {integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-layout-effect@1.1.2': resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} peerDependencies: @@ -1569,6 +1596,67 @@ packages: '@ungap/structured-clone@1.3.2': resolution: {integrity: sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==} + '@yuku-analyzer/binding-darwin-arm64@0.6.12': + resolution: {integrity: sha512-9rpIP7IeybjyvWUf6WnU24h1qo+JdxIHr1o3yb06HoE8tM3S/Jh5RrUw9aw5P9BKSIvSPbLyVlItX7PcD3o5bQ==} + cpu: [arm64] + os: [darwin] + + '@yuku-analyzer/binding-darwin-x64@0.6.12': + resolution: {integrity: sha512-ELLhNT4FGnqY8yh0W3cSs9rGMSeUyhib1aYD84RupjlfsrDTrQRoDhWu01Dv6xCfYgASYaj1Abntk91A7njNag==} + cpu: [x64] + os: [darwin] + + '@yuku-analyzer/binding-freebsd-x64@0.6.12': + resolution: {integrity: sha512-s76XocUMlK9liTyipALFb2K64ku35u/wg238A0NW8U5CUDsuIe/8tu5TzdLjJAGxnd0IV+gBneDt9cJJzLeFRQ==} + cpu: [x64] + os: [freebsd] + + '@yuku-analyzer/binding-linux-arm-gnu@0.6.12': + resolution: {integrity: sha512-hm8Tq0umop3RGu6dOMF61q69tYn1bDp1CeYD5ZjuGFQJclp0moVtjzY4z0bzusicKeZ9+k5LRroR0p5HWC2hDw==} + cpu: [arm] + os: [linux] + + '@yuku-analyzer/binding-linux-arm-musl@0.6.12': + resolution: {integrity: sha512-CxtPKLddogHAB3ZHVWaUl+U8jx0pdriTSbQ1K/orlDqU0GDhg8LuIRyUscP7r2/62fGGMzkc119fE71I4Nl1Fg==} + cpu: [arm] + os: [linux] + + '@yuku-analyzer/binding-linux-arm64-gnu@0.6.12': + resolution: {integrity: sha512-EOyLcpAmF5qAVDKmKvV7xt8oBGeWQ92CqFI4s7h7TRlrF6TfGRrh8PwawGn92gFploNLAYj/1Z9Q1gVvwGgG9g==} + cpu: [arm64] + os: [linux] + + '@yuku-analyzer/binding-linux-arm64-musl@0.6.12': + resolution: {integrity: sha512-T3eCYy6bMnVRMQEYAbDcpj08/XM93dBTtnn/DDocJN21RARe+KCzWKeL26J3yd3bOW3WVjVLq09BfdpAGB0buQ==} + cpu: [arm64] + os: [linux] + + '@yuku-analyzer/binding-linux-x64-gnu@0.6.12': + resolution: {integrity: sha512-1Y+noIuvnDugIVsoIr5NduZqX7KuFTzICSkvG8RW3OKK9URVeTOicKK217i44ABZSSZJ7A0E7vzifapx0c9VDw==} + cpu: [x64] + os: [linux] + + '@yuku-analyzer/binding-linux-x64-musl@0.6.12': + resolution: {integrity: sha512-woN/GuG95Fd6bp+ZQfmiFrZnoA2hdu3vfVSc89A8LElnYpzFaJM81sOZp8f3tVOVUJxbt7KAUiCLwSy34MJKqA==} + cpu: [x64] + os: [linux] + + '@yuku-analyzer/binding-win32-arm64@0.6.12': + resolution: {integrity: sha512-8OVFnKbK+lgsL6MqILPLpzlsa00K4KiKsdbHH94hpGcrqaz1jv+k0Y7ujSaoYTWw5Bb7Lr9GJ3L1n1hT2sXoYA==} + cpu: [arm64] + os: [win32] + + '@yuku-analyzer/binding-win32-x64@0.6.12': + resolution: {integrity: sha512-3w8w1Xc5njwgbGTcn3JfDxWuQnFvtSll1D8gBlk4U8CI5v7ibKOMIdABucCXH8WtsRREG0ME5Vn0i422eX3zLQ==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.6.11': + resolution: {integrity: sha512-i1JYFNJaKNCgyJ/nVoR8GK7wvlXF+ShYzFHBauWcvg8IoiXInK7pVziHcgNz/MWLPNr/Mb/CtmXccrJMkKqSHQ==} + + '@yuku-toolchain/types@0.6.8': + resolution: {integrity: sha512-AbUd1775RVkOxJkh8hkldIWoU6kRMTCsZFSZq8Ny53q7GkbaVe5UCfleNZ3RWCoz/ZKE8qwfeB7Cj0xqhLWsKA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1583,9 +1671,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1796,8 +1881,8 @@ packages: react-dom: optional: true - fumadocs-core@16.11.1: - resolution: {integrity: sha512-tKuh1AKoVTb+f7IoAOM2cfz5djd3YhePeqA95q6mf422gEvDTeJms23OJ+icYRWZ6ryNQ5W/ZsgKEe87M5HVYg==} + fumadocs-core@16.11.5: + resolution: {integrity: sha512-YrHjS09+QYYKOSTGyiZbxF/VDs7ciMcjurYBGfmYqtzdj14k7Ho0HX9c6VuvG54YsYHQs5mGWemT1TXm7vDBaA==} peerDependencies: '@mdx-js/mdx': '*' '@mixedbread/sdk': 0.x.x @@ -1855,8 +1940,8 @@ packages: zod: optional: true - fumadocs-mdx@15.1.0: - resolution: {integrity: sha512-2nDusSlYFuNVcyB51jgY3tA3r01ALTwoURrMDNoc7cbJKZ2sac/PW+CDq6SHTArkgRMmFiKYQGfspJdjgTtPTg==} + fumadocs-mdx@15.2.0: + resolution: {integrity: sha512-+yBP8QYw5wA9LF5eVdMhwbP7KT1OF4B/YfC6PZoD2jz0amZi1B+6QHTI6XoRRSTmhWrI4cL5LU1DspW0itk+NA==} hasBin: true peerDependencies: '@fumadocs/satteri': 0.x.x @@ -1892,25 +1977,25 @@ packages: vite: optional: true - fumadocs-ui@16.11.1: - resolution: {integrity: sha512-Dq819PFV4RGhAI9Wd4erSCiRlEDLVOZae+kgE5LeOKFH8mbKX49U8N17ldFOhdkC9EZpxMZdEKul77RDgFHQww==} + fumadocs-ui@16.11.5: + resolution: {integrity: sha512-Eda7x2Hk7E1iIjZ4uES0xxGr25Z72efRM5kP8sbgLSLhWg8TDCyWddvKAkzXIq8bupPOuJkdZa/YVvXbCktIEA==} peerDependencies: - '@takumi-rs/image-response': '*' '@types/mdx': '*' '@types/react': '*' - fumadocs-core: 16.11.1 + fumadocs-core: 16.11.5 next: 16.x.x react: ^19.2.0 react-dom: ^19.2.0 + takumi-js: '*' peerDependenciesMeta: - '@takumi-rs/image-response': - optional: true '@types/mdx': optional: true '@types/react': optional: true next: optional: true + takumi-js: + optional: true gaxios@7.1.5: resolution: {integrity: sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==} @@ -2021,10 +2106,6 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - js-yaml@5.2.1: - resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==} - hasBin: true - json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} @@ -2123,6 +2204,11 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lucide-react@1.25.0: + resolution: {integrity: sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2721,6 +2807,12 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yuku-analyzer@0.6.12: + resolution: {integrity: sha512-0zu/gwv6nKA3wm2GMjM1iczw9rbt77ijEyR5tXpPQ8AZcXIpXlll66BXOtMHgYudLn91bJx0ybhpARoJWm5/dw==} + + yuku-ast@0.6.11: + resolution: {integrity: sha512-ZfXkFYVsDewS45+kv3WiA/qNB73CRfxFDEQwfnRMUAR4AD5zRI7PRqxmI2U3Jz/oG41GneTVW6mxDOQal0lgeA==} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -2958,41 +3050,55 @@ snapshots: '@babel/runtime@7.29.7': {} - '@biomejs/biome@2.5.3': + '@biomejs/biome@2.5.4': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.3 - '@biomejs/cli-darwin-x64': 2.5.3 - '@biomejs/cli-linux-arm64': 2.5.3 - '@biomejs/cli-linux-arm64-musl': 2.5.3 - '@biomejs/cli-linux-x64': 2.5.3 - '@biomejs/cli-linux-x64-musl': 2.5.3 - '@biomejs/cli-win32-arm64': 2.5.3 - '@biomejs/cli-win32-x64': 2.5.3 - - '@biomejs/cli-darwin-arm64@2.5.3': + '@biomejs/cli-darwin-arm64': 2.5.4 + '@biomejs/cli-darwin-x64': 2.5.4 + '@biomejs/cli-linux-arm64': 2.5.4 + '@biomejs/cli-linux-arm64-musl': 2.5.4 + '@biomejs/cli-linux-x64': 2.5.4 + '@biomejs/cli-linux-x64-musl': 2.5.4 + '@biomejs/cli-win32-arm64': 2.5.4 + '@biomejs/cli-win32-x64': 2.5.4 + + '@biomejs/cli-darwin-arm64@2.5.4': optional: true - '@biomejs/cli-darwin-x64@2.5.3': + '@biomejs/cli-darwin-x64@2.5.4': optional: true - '@biomejs/cli-linux-arm64-musl@2.5.3': + '@biomejs/cli-linux-arm64-musl@2.5.4': optional: true - '@biomejs/cli-linux-arm64@2.5.3': + '@biomejs/cli-linux-arm64@2.5.4': optional: true - '@biomejs/cli-linux-x64-musl@2.5.3': + '@biomejs/cli-linux-x64-musl@2.5.4': optional: true - '@biomejs/cli-linux-x64@2.5.3': + '@biomejs/cli-linux-x64@2.5.4': optional: true - '@biomejs/cli-win32-arm64@2.5.3': + '@biomejs/cli-win32-arm64@2.5.4': optional: true - '@biomejs/cli-win32-x64@2.5.3': + '@biomejs/cli-win32-x64@2.5.4': optional: true + '@earendil-works/pi-agent-core@0.80.10(ws@8.21.0)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-ai': 0.80.10(ws@8.21.0)(zod@4.4.3) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-agent-core@0.80.3(ws@8.21.0)(zod@4.4.3)': dependencies: '@earendil-works/pi-ai': 0.80.3(ws@8.21.0)(zod@4.4.3) @@ -3007,6 +3113,27 @@ snapshots: - ws - zod + '@earendil-works/pi-ai@0.80.10(ws@8.21.0)(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0 + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.21.0)(zod@4.4.3) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-ai@0.80.3(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) @@ -3028,6 +3155,36 @@ snapshots: - ws - zod + '@earendil-works/pi-coding-agent@0.80.10(ws@8.21.0)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-agent-core': 0.80.10(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.80.10(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.80.10 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + semver: 7.8.0 + typebox: 1.1.38 + undici: 8.5.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-coding-agent@0.80.3(ws@8.21.0)(zod@4.4.3)': dependencies: '@earendil-works/pi-agent-core': 0.80.3(ws@8.21.0)(zod@4.4.3) @@ -3058,6 +3215,11 @@ snapshots: - ws - zod + '@earendil-works/pi-tui@0.80.10': + dependencies: + get-east-asian-width: 1.6.0 + marked: 18.0.5 + '@earendil-works/pi-tui@0.80.3': dependencies: get-east-asian-width: 1.6.0 @@ -3170,7 +3332,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@fumadocs/tailwind@0.1.0(tailwindcss@4.3.2)': + '@fumadocs/tailwind@0.1.1(tailwindcss@4.3.2)': optionalDependencies: tailwindcss: 4.3.2 @@ -3441,15 +3603,15 @@ snapshots: '@radix-ui/number@1.1.2': {} - '@radix-ui/primitive@1.1.4': {} + '@radix-ui/primitive@1.1.5': {} - '@radix-ui/react-accordion@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-accordion@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collapsible': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collapsible': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -3469,13 +3631,13 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-collapsible@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-collapsible@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -3485,10 +3647,10 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-collection@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-collection@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 @@ -3503,23 +3665,23 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-context@1.1.4(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-context@1.2.0(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-dialog@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) @@ -3537,9 +3699,9 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-dismissable-layer@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dismissable-layer@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -3556,7 +3718,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-focus-scope@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-focus-scope@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -3574,16 +3736,16 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-navigation-menu@1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-navigation-menu@1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) @@ -3596,18 +3758,18 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-popover@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-popover@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) @@ -3619,12 +3781,12 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-popper@1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-popper@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -3647,7 +3809,7 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-presence@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 @@ -3665,31 +3827,33 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-roving-focus@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-roving-focus@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-scroll-area@1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-scroll-area@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/number': 1.1.2 - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -3706,15 +3870,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-tabs@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-tabs@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -3743,6 +3907,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-is-hydrated@0.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 @@ -4052,6 +4222,43 @@ snapshots: '@ungap/structured-clone@1.3.2': {} + '@yuku-analyzer/binding-darwin-arm64@0.6.12': + optional: true + + '@yuku-analyzer/binding-darwin-x64@0.6.12': + optional: true + + '@yuku-analyzer/binding-freebsd-x64@0.6.12': + optional: true + + '@yuku-analyzer/binding-linux-arm-gnu@0.6.12': + optional: true + + '@yuku-analyzer/binding-linux-arm-musl@0.6.12': + optional: true + + '@yuku-analyzer/binding-linux-arm64-gnu@0.6.12': + optional: true + + '@yuku-analyzer/binding-linux-arm64-musl@0.6.12': + optional: true + + '@yuku-analyzer/binding-linux-x64-gnu@0.6.12': + optional: true + + '@yuku-analyzer/binding-linux-x64-musl@0.6.12': + optional: true + + '@yuku-analyzer/binding-win32-arm64@0.6.12': + optional: true + + '@yuku-analyzer/binding-win32-x64@0.6.12': + optional: true + + '@yuku-toolchain/types@0.6.11': {} + + '@yuku-toolchain/types@0.6.8': {} + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -4060,8 +4267,6 @@ snapshots: agent-base@7.1.4: {} - argparse@2.0.1: {} - aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -4267,14 +4472,13 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - fumadocs-core@16.11.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3): + fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 github-slugger: 2.0.0 hast-util-to-estree: 3.1.3 hast-util-to-jsx-runtime: 2.3.6 - js-yaml: 5.2.1 mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 remark: 15.0.1 @@ -4286,6 +4490,7 @@ snapshots: unified: 11.0.5 unist-util-visit: 5.1.0 vfile: 6.0.3 + yaml: 2.9.0 optionalDependencies: '@mdx-js/mdx': 3.1.1 '@types/estree-jsx': 1.0.5 @@ -4300,16 +4505,16 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-mdx@15.1.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + fumadocs-mdx@15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.1 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.11.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) github-slugger: 2.0.0 - js-yaml: 5.2.1 + magic-string: 0.30.21 mdast-util-mdx: 3.0.0 picocolors: 1.1.1 picomatch: 4.0.5 @@ -4319,6 +4524,8 @@ snapshots: unist-util-remove-position: 5.0.0 unist-util-visit: 5.1.0 vfile: 6.0.3 + yaml: 2.9.0 + yuku-analyzer: 0.6.12 zod: 4.4.3 optionalDependencies: '@types/mdast': 4.0.4 @@ -4329,24 +4536,24 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.11.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2): + fumadocs-ui@16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@fumadocs/tailwind': 0.1.0(tailwindcss@4.3.2) - '@radix-ui/react-accordion': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-collapsible': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-dialog': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.2) + '@radix-ui/react-accordion': 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collapsible': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-navigation-menu': 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-popover': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-scroll-area': 1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-navigation-menu': 1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popover': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-scroll-area': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-tabs': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tabs': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) class-variance-authority: 0.7.1 cnfast: 0.0.8 - fumadocs-core: 16.11.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) - lucide-react: 1.23.0(react@19.2.7) + fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(@opentelemetry/api@1.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + lucide-react: 1.25.0(react@19.2.7) motion: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-themes: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 @@ -4559,10 +4766,6 @@ snapshots: jiti@2.7.0: {} - js-yaml@5.2.1: - dependencies: - argparse: 2.0.1 - json-bigint@1.0.0: dependencies: bignumber.js: 9.3.1 @@ -4642,6 +4845,10 @@ snapshots: dependencies: react: 19.2.7 + lucide-react@1.25.0(react@19.2.7): + dependencies: + react: 19.2.7 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5580,6 +5787,27 @@ snapshots: yaml@2.9.0: {} + yuku-analyzer@0.6.12: + dependencies: + '@yuku-toolchain/types': 0.6.11 + yuku-ast: 0.6.11 + optionalDependencies: + '@yuku-analyzer/binding-darwin-arm64': 0.6.12 + '@yuku-analyzer/binding-darwin-x64': 0.6.12 + '@yuku-analyzer/binding-freebsd-x64': 0.6.12 + '@yuku-analyzer/binding-linux-arm-gnu': 0.6.12 + '@yuku-analyzer/binding-linux-arm-musl': 0.6.12 + '@yuku-analyzer/binding-linux-arm64-gnu': 0.6.12 + '@yuku-analyzer/binding-linux-arm64-musl': 0.6.12 + '@yuku-analyzer/binding-linux-x64-gnu': 0.6.12 + '@yuku-analyzer/binding-linux-x64-musl': 0.6.12 + '@yuku-analyzer/binding-win32-arm64': 0.6.12 + '@yuku-analyzer/binding-win32-x64': 0.6.12 + + yuku-ast@0.6.11: + dependencies: + '@yuku-toolchain/types': 0.6.8 + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/website/package.json b/website/package.json index 20bcb75c..39153ca8 100644 --- a/website/package.json +++ b/website/package.json @@ -16,16 +16,16 @@ "prepare-source": "fumadocs-mdx" }, "dependencies": { - "fumadocs-core": "16.11.1", - "fumadocs-mdx": "15.1.0", - "fumadocs-ui": "16.11.1", + "fumadocs-core": "16.11.5", + "fumadocs-mdx": "15.2.0", + "fumadocs-ui": "16.11.5", "lucide-react": "^1.21.0", "next": "16.2.10", "react": "19.2.7", "react-dom": "19.2.7" }, "devDependencies": { - "@biomejs/biome": "2.5.3", + "@biomejs/biome": "2.5.4", "@tailwindcss/postcss": "4.3.2", "@types/mdx": "^2.0.14", "@types/node": "26.1.1",