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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
Not a workflow you script — a DAG you declare. Fan out · gate · loop · tournament · resume · save as a command — intermediate results stay out of your context.<br/>
Runs on the <a href="https://pi.dev">Pi</a> coding agent and on <a href="https://github.com/openai/codex">OpenAI Codex</a>.</p>

</div>

```bash
# Pi
pi install npm:pi-taskflow
Expand All @@ -31,8 +33,6 @@ codex plugin marketplace add heggria/taskflow
codex plugin add taskflow@taskflow
```

</div>

---

**A `workflow` flows. A `taskflow` is a *graph*.** Other orchestrators let the model *script* the work — imperative code that flows step by step, with the graph hidden inside control flow. `taskflow` does the opposite: you **declare** the work as a graph of discrete, named **task** nodes connected by `dependsOn` edges — and the runtime *verifies that graph before it spends a single token.*
Expand Down
4 changes: 2 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
不是你要去「写脚本」的 workflow——而是你去「声明」的一张 DAG。并发分发(fan out)· 门控(gate)· 恢复(resume)· 保存为命令——中间结果始终远离你的上下文窗口(context window)。<br/>
可运行于 <a href="https://pi.dev">Pi</a> 编码智能体与 <a href="https://github.com/openai/codex">OpenAI Codex</a>。</p>

</div>

```bash
# Pi
pi install npm:pi-taskflow
Expand All @@ -37,8 +39,6 @@ codex plugin marketplace add heggria/taskflow
codex plugin add taskflow@taskflow
```

</div>

---

**`workflow` 是在「流动」,而 `taskflow` 是一张「图」。** 其他编排框架让模型去「写脚本」——命令式的代码逐步流动,而那张图藏在控制流里。`taskflow` 恰恰相反:你把工作**声明**为一张由离散、具名的**任务(task)节点**、通过 `dependsOn` 边连接而成的图——而运行时会在花掉一个 token 之前,*先验证这张图。*
Expand Down
357 changes: 357 additions & 0 deletions docs/internal/brainstorm-2026-07-frontier-council.md

Large diffs are not rendered by default.

18 changes: 10 additions & 8 deletions packages/pi-taskflow/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
import { Type } from "typebox";
import { type AgentScope, discoverAgents, readSubagentSettings, shouldSyncBuiltinAgentsToProject, syncBuiltinAgentsToProject } from "taskflow-core";
import { renderRunResult, summarizeRun } from "./render.ts";
import { piSubagentRunner } from "./runner.ts";
import { piSubagentRunner, runnerModulePath } from "./runner.ts";
import { RunHistoryComponent, type RunHistoryResult } from "./runs-view.ts";
import { ApprovalViewComponent, type ApprovalChoice } from "./approval-view.ts";
import { executeTaskflow, recomputeTaskflow, summarizeReuse, type ApprovalDecision, type ApprovalRequest, type RecomputeReport, type RuntimeDeps, type RuntimeResult } from "taskflow-core";
Expand Down Expand Up @@ -979,13 +979,15 @@ export default function (pi: ExtensionAPI) {
const os = await import("node:os");
const path = await import("node:path");
const tmpFile = path.join(os.tmpdir(), `taskflow-detach-${state.runId}.json`);
// Resolve the host adapter's runner module from THIS package so it
// works under both workspaces (dev -> src/.ts) and npm installs (->
// dist/.js). Core is host-neutral and cannot spawn pi itself, so the
// detached process needs to dynamically import a runTask from here.
const runnerModule = (await import("node:url")).fileURLToPath(
import.meta.resolve("./runner.ts"),
);
// 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,
Expand Down
20 changes: 20 additions & 0 deletions packages/pi-taskflow/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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 { fileURLToPath } from "node:url";
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import {
emptyUsage,
Expand Down Expand Up @@ -413,3 +414,22 @@ export async function runAgentTask(
export const piSubagentRunner: SubagentRunner<AgentConfig> = {
runTask: runAgentTask,
};

/**
* Absolute filesystem path of THIS module — what the host serializes into the
* detached-run context file as `runnerModule` so the detached-runner child can
* dynamically import `piSubagentRunner`.
*
* Why self-reporting instead of `import.meta.resolve("./runner.ts")` from the
* caller: `rewriteRelativeImportExtensions` rewrites STATIC import specifiers
* at compile time but does NOT touch string arguments of
* `import.meta.resolve()`, so a compiled caller would resolve `dist/runner.ts`
* — a file that does not exist (the build emits `dist/runner.js`) — and every
* detached phase would fail with "No subagent runner injected".
* `import.meta.url` is always the executing file's real path (src/runner.ts in
* dev, dist/runner.js in prod), so this is correct under both conditions with
* no extension guessing.
*/
export function runnerModulePath(): string {
return fileURLToPath(import.meta.url);
}
66 changes: 66 additions & 0 deletions packages/pi-taskflow/test/detached-runner-module.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Regression guard for the detached-run runner-module resolution bug
* (found 2026-07-02, second recurrence of the issue #3 class).
*
* The bug: the host serialized `runnerModule` for the detached child via
* `import.meta.resolve("./runner.ts")`. tsc's `rewriteRelativeImportExtensions`
* rewrites STATIC import specifiers (`import ... from "./runner.ts"` →
* `"./runner.js"`) but does NOT touch string arguments of
* `import.meta.resolve()`. The compiled dist/index.js therefore pointed the
* detached child at `dist/runner.ts` — a file that does not exist — the
* dynamic import failed, and EVERY detached phase died with
* "No subagent runner injected" while unit tests (which run from src) passed.
*
* The fix: runner.ts self-reports its own path via `runnerModulePath()`
* (`import.meta.url`), which is correct under BOTH conditions by construction.
*
* These tests pin the bug class three ways:
* 1. The self-reported path exists and exports a working runner (current
* condition — src in dev, dist in prod).
* 2. If a compiled dist/index.js exists, it must not contain ANY
* `import.meta.resolve("<relative>.ts")` — the exact compile-time trap.
* 3. If a compiled dist/runner.js exists, importing it must yield
* `piSubagentRunner.runTask` AND `runnerModulePath()` must point back at
* that same dist file — proving the detached context would be valid in a
* published install, not just in the workspace.
*/
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { runnerModulePath, piSubagentRunner } from "../src/runner.ts";

test("detached runnerModule: self-reported path exists and exports a SubagentRunner", async () => {
const p = runnerModulePath();
assert.ok(existsSync(p), `runnerModulePath() must exist on disk: ${p}`);
// The detached-runner does exactly this dynamic import — replicate it.
const mod = await import(p);
const runner = mod["piSubagentRunner"];
assert.ok(runner && typeof runner.runTask === "function",
"dynamic import of runnerModulePath() must expose piSubagentRunner.runTask");
assert.equal(runner.runTask, piSubagentRunner.runTask, "must be the same runner object");
});

test("detached runnerModule: compiled index.js must not resolve relative .ts specifiers", () => {
const distIndex = fileURLToPath(new URL("../dist/index.js", import.meta.url));
if (!existsSync(distIndex)) return; // dist not built in this checkout — covered in CI's build job
const src = readFileSync(distIndex, "utf-8");
// The trap: import.meta.resolve("./<anything>.ts") survives compilation
// verbatim and points at a nonexistent file in dist.
const bad = src.match(/import\.meta\.resolve\(\s*["']\.\.?\/[^"']*\.ts["']\s*\)/);
assert.equal(bad, null,
`compiled dist/index.js must not contain import.meta.resolve of a relative .ts specifier (found: ${bad?.[0]}) — ` +
`rewriteRelativeImportExtensions does not rewrite it; use runnerModulePath() instead`);
});

test("detached runnerModule: compiled dist/runner.js (if built) is itself a valid runnerModule", async () => {
const distRunner = fileURLToPath(new URL("../dist/runner.js", import.meta.url));
if (!existsSync(distRunner)) return; // dist not built in this checkout — covered in CI's build job
const mod = await import(distRunner);
assert.ok(mod.piSubagentRunner && typeof mod.piSubagentRunner.runTask === "function",
"dist/runner.js must export piSubagentRunner.runTask");
assert.equal(typeof mod.runnerModulePath, "function", "dist/runner.js must export runnerModulePath");
// In the compiled module, self-reporting must point at the dist file itself.
assert.equal(mod.runnerModulePath(), distRunner,
"compiled runnerModulePath() must self-report the dist path — this is what the detached context serializes");
});
18 changes: 18 additions & 0 deletions packages/taskflow-core/src/detached-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ try {
console.error("[detached-runner] No runnerModule in context — phases will fail with 'No subagent runner injected'");
}

// FAIL FAST when a runner was promised but could not be loaded. Limping on
// would fail EVERY phase with the generic "No subagent runner injected"
// 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.
if (ctx.runnerModule && !runTask) {
state.status = "failed";
state.phases["__detach__"] = {
id: "__detach__",
status: "failed",
endedAt: Date.now(),
error: `Runner module failed to load: '${ctx.runnerModule}' (export '${ctx.runnerExport ?? "piSubagentRunner"}'). See detached-runner stderr for the import error.`,
};
saveRun(state, cleanupConfig);
process.exit(1);
}

const result = await executeTaskflow(state, {
cwd: ctx.cwd,
agents,
Expand Down