pi-taskflow 0.0.16 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +59 -36
- package/README.zh-CN.md +52 -29
- package/examples/dynamic-plan-execute.json +34 -0
- package/examples/iterative-replan.json +30 -0
- package/extensions/agents.ts +13 -37
- package/extensions/cache.ts +5 -1
- package/extensions/index.ts +70 -17
- package/extensions/interpolate.ts +32 -5
- package/extensions/render.ts +2 -2
- package/extensions/runner.ts +38 -2
- package/extensions/runs-view.ts +2 -2
- package/extensions/runtime.ts +225 -20
- package/extensions/schema.ts +57 -2
- package/extensions/store.ts +39 -14
- package/extensions/verify.ts +11 -0
- package/package.json +3 -4
- package/skills/taskflow/SKILL.md +39 -5
- package/skills/taskflow/configuration.md +10 -11
- package/DESIGN.md +0 -338
package/extensions/agents.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface TaskflowSettings {
|
|
|
21
21
|
maxRunAgeDays: number;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
import { DEFAULT_KEPT_RUNS, DEFAULT_RUN_AGE_DAYS } from "./store.ts";
|
|
24
|
+
import { DEFAULT_KEPT_RUNS, DEFAULT_RUN_AGE_DAYS, writeFileAtomic } from "./store.ts";
|
|
25
25
|
|
|
26
26
|
export const DEFAULT_TASKFLOW_SETTINGS: TaskflowSettings = {
|
|
27
27
|
builtInAgents: true,
|
|
@@ -63,12 +63,6 @@ export function shouldSyncBuiltinAgentsToProject(settings: TaskflowSettings = DE
|
|
|
63
63
|
return settings.builtInAgents && settings.syncBuiltinAgentsToProject;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
export interface AgentOverride {
|
|
67
|
-
model?: string;
|
|
68
|
-
thinking?: string;
|
|
69
|
-
tools?: string[];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
66
|
export interface AgentConfig {
|
|
73
67
|
name: string;
|
|
74
68
|
description: string;
|
|
@@ -120,16 +114,18 @@ function loadAgentsFromDir(dir: string, source: "user" | "project" | "built-in")
|
|
|
120
114
|
if (!frontmatter.name || !frontmatter.description) continue;
|
|
121
115
|
|
|
122
116
|
// frontmatter is YAML-parsed: tools may be a comma-separated string ("a, b")
|
|
123
|
-
// OR a YAML sequence ([a, b]). Handle both forms
|
|
117
|
+
// OR a YAML sequence ([a, b]). Handle both forms; reject other types to
|
|
118
|
+
// prevent garbage output from malformed YAML (e.g. boolean, number).
|
|
124
119
|
const rawTools = frontmatter.tools;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
120
|
+
let tools: string[] | undefined;
|
|
121
|
+
if (Array.isArray(rawTools)) {
|
|
122
|
+
tools = rawTools.map((t) => String(t).trim()).filter(Boolean);
|
|
123
|
+
} else if (typeof rawTools === "string") {
|
|
124
|
+
tools = rawTools.split(",").map((t) => t.trim()).filter(Boolean);
|
|
125
|
+
} else if (rawTools !== undefined && rawTools !== null) {
|
|
126
|
+
console.warn(`[taskflow] Agent '${String(frontmatter.name)}': 'tools' must be a string or array, got ${typeof rawTools}. Ignoring.`);
|
|
127
|
+
tools = undefined;
|
|
128
|
+
}
|
|
133
129
|
|
|
134
130
|
agents.push({
|
|
135
131
|
name: String(frontmatter.name),
|
|
@@ -173,7 +169,6 @@ function findNearestProjectAgentsDir(cwd: string): string | null {
|
|
|
173
169
|
export function discoverAgents(
|
|
174
170
|
cwd: string,
|
|
175
171
|
scope: AgentScope,
|
|
176
|
-
overrides?: Record<string, AgentOverride>,
|
|
177
172
|
modelRoles?: Record<string, string>,
|
|
178
173
|
taskflowSettings: TaskflowSettings = DEFAULT_TASKFLOW_SETTINGS,
|
|
179
174
|
): AgentDiscoveryResult {
|
|
@@ -202,23 +197,6 @@ export function discoverAgents(
|
|
|
202
197
|
for (const a of projectAgents) agentMap.set(a.name, a);
|
|
203
198
|
}
|
|
204
199
|
|
|
205
|
-
if (overrides) {
|
|
206
|
-
for (const [name, override] of Object.entries(overrides)) {
|
|
207
|
-
const agent = agentMap.get(name);
|
|
208
|
-
if (agent) {
|
|
209
|
-
// Clone before mutating: agentMap owns the original AgentConfig
|
|
210
|
-
// (loaded from disk in loadAgentsFromDir). Mutating it in place
|
|
211
|
-
// would cause cross-contamination for any caller that retains a
|
|
212
|
-
// reference and invokes discoverAgents again with different overrides.
|
|
213
|
-
const mutated: AgentConfig = { ...agent };
|
|
214
|
-
if (override.model !== undefined) mutated.model = override.model;
|
|
215
|
-
if (override.thinking !== undefined) mutated.thinking = override.thinking;
|
|
216
|
-
if (override.tools !== undefined) mutated.tools = override.tools;
|
|
217
|
-
agentMap.set(name, mutated);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
200
|
// Resolve {{role}} model references (e.g. {{fast}} → openrouter/deepseek/v4-flash)
|
|
223
201
|
// Clone before mutating, consistent with the overrides block above.
|
|
224
202
|
if (modelRoles) {
|
|
@@ -236,7 +214,6 @@ export function discoverAgents(
|
|
|
236
214
|
}
|
|
237
215
|
|
|
238
216
|
export interface SubagentSettings {
|
|
239
|
-
agentOverrides?: Record<string, AgentOverride>;
|
|
240
217
|
globalThinking?: string;
|
|
241
218
|
modelRoles?: Record<string, string>;
|
|
242
219
|
taskflow: TaskflowSettings;
|
|
@@ -261,7 +238,6 @@ export function readSubagentSettings(): SubagentSettings {
|
|
|
261
238
|
if (!fs.existsSync(settingsPath)) return { taskflow: { ...DEFAULT_TASKFLOW_SETTINGS } };
|
|
262
239
|
const raw = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
263
240
|
return {
|
|
264
|
-
agentOverrides: raw.subagents?.agentOverrides,
|
|
265
241
|
globalThinking: raw.subagents?.globalThinking ?? raw.defaultThinkingLevel,
|
|
266
242
|
modelRoles: raw.modelRoles,
|
|
267
243
|
taskflow: normalizeTaskflowSettings(raw.taskflow),
|
|
@@ -311,7 +287,7 @@ export function syncBuiltinAgentsToProject(cwd: string): void {
|
|
|
311
287
|
|
|
312
288
|
try {
|
|
313
289
|
const content = fs.readFileSync(src, "utf-8");
|
|
314
|
-
|
|
290
|
+
writeFileAtomic(dst, content);
|
|
315
291
|
} catch {
|
|
316
292
|
// Best-effort: a locked file must not block the sync.
|
|
317
293
|
}
|
package/extensions/cache.ts
CHANGED
|
@@ -47,9 +47,13 @@ function resolveOne(entry: string, cwd: string): string {
|
|
|
47
47
|
cwd,
|
|
48
48
|
encoding: "utf-8",
|
|
49
49
|
stdio: ["ignore", "pipe", "ignore"],
|
|
50
|
+
timeout: 30_000,
|
|
50
51
|
}).trim();
|
|
51
52
|
return `git:${ref}=${sha}`;
|
|
52
|
-
} catch {
|
|
53
|
+
} catch (e: unknown) {
|
|
54
|
+
if ((e as NodeJS.ErrnoException).code === "ETIMEDOUT") {
|
|
55
|
+
return `git:${ref}=<timeout>`;
|
|
56
|
+
}
|
|
53
57
|
return `git:${ref}=<no-git>`;
|
|
54
58
|
}
|
|
55
59
|
}
|
package/extensions/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-taskflow —
|
|
2
|
+
* pi-taskflow — a declarative, verifiable graph of task nodes for the Pi coding agent.
|
|
3
3
|
*
|
|
4
4
|
* Registers:
|
|
5
5
|
* - tool `taskflow` : run inline / saved flows, save, resume (LLM-callable)
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
DEFAULT_RUN_AGE_DAYS,
|
|
43
43
|
} from "./store.ts";
|
|
44
44
|
import { CacheStore } from "./cache.ts";
|
|
45
|
+
import { safeParse } from "./interpolate.ts";
|
|
45
46
|
|
|
46
47
|
interface TaskflowDetails {
|
|
47
48
|
state?: RunState;
|
|
@@ -195,7 +196,7 @@ async function runFlow(
|
|
|
195
196
|
cleanupConfig.maxKeep = settings.taskflow.maxKeptRuns;
|
|
196
197
|
cleanupConfig.maxAgeDays = settings.taskflow.maxRunAgeDays;
|
|
197
198
|
const scope: AgentScope = def.agentScope ?? "user";
|
|
198
|
-
const { agents } = discoverAgents(ctx.cwd, scope, settings.
|
|
199
|
+
const { agents } = discoverAgents(ctx.cwd, scope, settings.modelRoles, settings.taskflow);
|
|
199
200
|
|
|
200
201
|
// Hint: if any agent still has unresolved {{role}} references, suggest configuring modelRoles
|
|
201
202
|
const unresolvedRoles = agents
|
|
@@ -324,7 +325,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
324
325
|
"Interpolation: {args.X}, {steps.ID.output}, {steps.ID.json}, {item} (map), {previous.output}.",
|
|
325
326
|
].join(" "),
|
|
326
327
|
parameters: TaskflowParams,
|
|
327
|
-
promptSnippet: "
|
|
328
|
+
promptSnippet: "Declare a verifiable graph of subagent tasks (single, parallel, chain, or full DAG) — tracked, resumable, context-isolated. The runtime validates the graph before running. Replaces the subagent tool.",
|
|
328
329
|
promptGuidelines: [
|
|
329
330
|
"BEFORE FIRST USE: invoke skill_load('taskflow') to read the full skill documentation (DSL syntax, phase types, examples, best practices). This tool description is a condensed reference only — the skill is the authoritative guide.\n\nUse taskflow for ALL delegation — single tasks, parallel, chain, or full DAG orchestration. It fully replaces the subagent tool: every delegation is tracked with a runId, resumable across sessions, context-isolated (only final output returns), and saveable as /tf:<name>. Do NOT call the subagent tool directly; use taskflow shorthand (task/tasks/chain) for simple cases instead.",
|
|
330
331
|
"For complex multi-phase work (explore / 审计 / analyze the project, auditing endpoints, reviewing or migrating many files/modules, cross-checked research), use the full DSL with phases. For taskflow map phases, have the upstream phase emit a JSON array and set output:'json'.",
|
|
@@ -416,7 +417,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
416
417
|
if (action === "agents") {
|
|
417
418
|
const scope = params.scope ?? "both";
|
|
418
419
|
const settings2 = readSubagentSettings();
|
|
419
|
-
const { agents } = discoverAgents(ctx.cwd, scope as AgentScope,
|
|
420
|
+
const { agents } = discoverAgents(ctx.cwd, scope as AgentScope, settings2.modelRoles, settings2.taskflow);
|
|
420
421
|
const text = agents.length
|
|
421
422
|
? agents
|
|
422
423
|
.map(
|
|
@@ -441,13 +442,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
441
442
|
const { verifyTaskflow } = await import("./verify.ts");
|
|
442
443
|
// Load definition: inline define takes priority, then saved name
|
|
443
444
|
let def: Taskflow | undefined;
|
|
444
|
-
|
|
445
|
-
|
|
445
|
+
let resolvedDefine: unknown = params.define;
|
|
446
|
+
if (typeof resolvedDefine === "string") {
|
|
447
|
+
const parsed = safeParse(resolvedDefine);
|
|
448
|
+
if (parsed && typeof parsed === "object") resolvedDefine = parsed;
|
|
449
|
+
}
|
|
450
|
+
if (resolvedDefine) {
|
|
451
|
+
const d = resolvedDefine as Record<string, unknown>;
|
|
446
452
|
if (typeof d === "object" && d !== null && Array.isArray(d.phases)) {
|
|
447
453
|
def = d as unknown as Taskflow;
|
|
448
|
-
} else if (isShorthand(
|
|
449
|
-
const r = validateTaskflow(
|
|
450
|
-
if (r.ok) def =
|
|
454
|
+
} else if (isShorthand(resolvedDefine)) {
|
|
455
|
+
const r = validateTaskflow(resolvedDefine);
|
|
456
|
+
if (r.ok) def = resolvedDefine as unknown as Taskflow;
|
|
451
457
|
}
|
|
452
458
|
} else if (params.name) {
|
|
453
459
|
const saved = getFlow(ctx.cwd, params.name);
|
|
@@ -505,9 +511,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
505
511
|
// resolve the definition: inline `define` / shorthand (single|parallel|chain), else saved `name`.
|
|
506
512
|
let def: Taskflow | undefined;
|
|
507
513
|
|
|
514
|
+
// Auto-parse string `define` — LLMs sometimes pass a JSON string
|
|
515
|
+
// instead of a parsed object. safeParse handles markdown fences too.
|
|
516
|
+
let resolvedDefine: unknown = params.define;
|
|
517
|
+
if (typeof resolvedDefine === "string") {
|
|
518
|
+
const parsed = safeParse(resolvedDefine);
|
|
519
|
+
if (parsed && typeof parsed === "object") {
|
|
520
|
+
resolvedDefine = parsed;
|
|
521
|
+
} else {
|
|
522
|
+
return errorResult(
|
|
523
|
+
action,
|
|
524
|
+
`'define' was passed as a string, not a JSON object. Pass it as a proper object, e.g.:\n` +
|
|
525
|
+
`define: {"name":"my-flow","phases":[{"id":"step1","task":"do something"}]}`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
508
530
|
// A shorthand spec can come from `define` (no phases) or top-level params.
|
|
509
531
|
const shorthandSpec: unknown =
|
|
510
|
-
|
|
532
|
+
resolvedDefine ??
|
|
511
533
|
(params.chain
|
|
512
534
|
? { chain: params.chain, name: params.name }
|
|
513
535
|
: params.tasks
|
|
@@ -530,11 +552,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
530
552
|
def = candidate as Taskflow;
|
|
531
553
|
} else if (params.name) {
|
|
532
554
|
const saved = getFlow(ctx.cwd, params.name);
|
|
533
|
-
if (!saved)
|
|
555
|
+
if (!saved) {
|
|
556
|
+
const available = listFlows(ctx.cwd);
|
|
557
|
+
const hint = available.length
|
|
558
|
+
? ` Available flows: ${available.map((f) => f.name).join(", ")}.`
|
|
559
|
+
: " No saved flows found. Use action=save to create one, or pass 'define' for an inline flow.";
|
|
560
|
+
return errorResult(action, `Saved flow '${params.name}' not found.${hint}`);
|
|
561
|
+
}
|
|
534
562
|
def = saved.def;
|
|
535
563
|
}
|
|
536
564
|
if (!def)
|
|
537
|
-
return errorResult(
|
|
565
|
+
return errorResult(
|
|
566
|
+
action,
|
|
567
|
+
`No taskflow definition provided. Use one of:\n` +
|
|
568
|
+
`- define: {"name":"...","phases":[...]} (inline DSL object)\n` +
|
|
569
|
+
`- task: "..." (shorthand single agent)\n` +
|
|
570
|
+
`- tasks: [{"task":"..."},...] (shorthand parallel)\n` +
|
|
571
|
+
`- chain: [{"task":"..."},...] (shorthand sequential)\n` +
|
|
572
|
+
`- name: "saved-flow-name" (run a previously saved flow)`,
|
|
573
|
+
);
|
|
538
574
|
|
|
539
575
|
// save
|
|
540
576
|
if (action === "save") {
|
|
@@ -562,7 +598,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
562
598
|
}
|
|
563
599
|
|
|
564
600
|
// run
|
|
565
|
-
|
|
601
|
+
// Auto-parse string args — LLMs sometimes pass a JSON string.
|
|
602
|
+
let resolvedArgs: Record<string, unknown> | undefined;
|
|
603
|
+
if (typeof params.args === "string") {
|
|
604
|
+
const parsed = safeParse(params.args);
|
|
605
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
606
|
+
resolvedArgs = parsed as Record<string, unknown>;
|
|
607
|
+
}
|
|
608
|
+
} else if (params.args && typeof params.args === "object") {
|
|
609
|
+
resolvedArgs = params.args as Record<string, unknown>;
|
|
610
|
+
}
|
|
611
|
+
const args = resolveArgs(def, resolvedArgs);
|
|
566
612
|
const v = validateTaskflow(def, { args, cwd: ctx.cwd });
|
|
567
613
|
if (!v.ok) return errorResult(action, `Invalid taskflow:\n- ${v.errors.join("\n- ")}`);
|
|
568
614
|
for (const w of v.warnings) {
|
|
@@ -579,7 +625,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
579
625
|
|
|
580
626
|
renderCall(args, theme) {
|
|
581
627
|
const action = args.action ?? "run";
|
|
582
|
-
let label = args.name
|
|
628
|
+
let label = args.name;
|
|
629
|
+
if (!label) {
|
|
630
|
+
let define = args.define;
|
|
631
|
+
if (typeof define === "string") {
|
|
632
|
+
try { define = JSON.parse(define); } catch { /* not JSON */ }
|
|
633
|
+
}
|
|
634
|
+
label = (define as { name?: string } | undefined)?.name;
|
|
635
|
+
}
|
|
583
636
|
let suffix = "";
|
|
584
637
|
const phases = (args.define as Taskflow | undefined)?.phases;
|
|
585
638
|
if (phases) suffix = ` (${phases.length} phases)`;
|
|
@@ -613,7 +666,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
613
666
|
pi.registerCommand("tf", {
|
|
614
667
|
description: "Taskflow: list | run <name> | show <name> | runs | init",
|
|
615
668
|
getArgumentCompletions: (prefix) => {
|
|
616
|
-
const subs = ["list", "run", "show", "runs", "resume", "init"];
|
|
669
|
+
const subs = ["list", "run", "show", "runs", "resume", "init", "save", "verify"];
|
|
617
670
|
const items = subs.map((s) => ({ value: s, label: s }));
|
|
618
671
|
const filtered = items.filter((i) => i.value.startsWith(prefix));
|
|
619
672
|
return filtered.length > 0 ? filtered : null;
|
|
@@ -797,13 +850,13 @@ function parseArgsString(input: string, def: Taskflow): Record<string, unknown>
|
|
|
797
850
|
}
|
|
798
851
|
// key=value pairs
|
|
799
852
|
const out: Record<string, unknown> = {};
|
|
800
|
-
const pairs = trimmed.match(/(\w+)=("[^"]*"|\S+)/g);
|
|
853
|
+
const pairs = trimmed.match(/(\w+)=("(?:[^"\\]|\\.)*"|\S+)/g);
|
|
801
854
|
if (pairs) {
|
|
802
855
|
for (const p of pairs) {
|
|
803
856
|
const idx = p.indexOf("=");
|
|
804
857
|
const k = p.slice(0, idx);
|
|
805
858
|
let v: string = p.slice(idx + 1);
|
|
806
|
-
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
|
859
|
+
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1).replace(/\\"/g, '"');
|
|
807
860
|
out[k] = v;
|
|
808
861
|
}
|
|
809
862
|
return out;
|
|
@@ -66,7 +66,13 @@ function resolvePath(path: string, ctx: InterpolationContext): unknown {
|
|
|
66
66
|
const step = stepId ? ctx.steps[stepId] : undefined;
|
|
67
67
|
if (!step) return undefined;
|
|
68
68
|
const field = parts[2];
|
|
69
|
-
if (field === "output")
|
|
69
|
+
if (field === "output") {
|
|
70
|
+
// Guard: {steps.X.output.trailing} — trailing segments after output are
|
|
71
|
+
// likely author errors (output is a string, not an object). Return
|
|
72
|
+
// undefined so the placeholder is left intact with a missing warning.
|
|
73
|
+
if (parts.length > 3) return undefined;
|
|
74
|
+
return step.output;
|
|
75
|
+
}
|
|
70
76
|
if (field === "json") {
|
|
71
77
|
const json = step.json ?? safeParse(step.output);
|
|
72
78
|
return dig(json, parts.slice(3));
|
|
@@ -82,6 +88,12 @@ function resolvePath(path: string, ctx: InterpolationContext): unknown {
|
|
|
82
88
|
return undefined;
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Traverse an object by a sequence of property keys. Returns `undefined`
|
|
93
|
+
* when any segment is missing or the current value is not an object —
|
|
94
|
+
* never throws, so extra path segments like {steps.X.json.a.b} where the
|
|
95
|
+
* data is shallower resolve gracefully to undefined (M-8).
|
|
96
|
+
*/
|
|
85
97
|
function dig(obj: unknown, parts: string[]): unknown {
|
|
86
98
|
let cur: unknown = obj;
|
|
87
99
|
for (const part of parts) {
|
|
@@ -219,10 +231,25 @@ function tokenize(input: string): Tok[] {
|
|
|
219
231
|
}
|
|
220
232
|
// quoted string
|
|
221
233
|
if (c === '"' || c === "'") {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
234
|
+
// Handle escaped quotes. Note: ALL \X sequences are interpreted as literal X
|
|
235
|
+
// (including \n → n, \t → t). This differs from JSON/JS escaping but is
|
|
236
|
+
// correct for condition strings which only need quote escaping.
|
|
237
|
+
let j = i + 1;
|
|
238
|
+
let val = "";
|
|
239
|
+
while (j < n) {
|
|
240
|
+
if (input[j] === "\\" && j + 1 < n) {
|
|
241
|
+
val += input[j + 1];
|
|
242
|
+
j += 2;
|
|
243
|
+
} else if (input[j] === c) {
|
|
244
|
+
break;
|
|
245
|
+
} else {
|
|
246
|
+
val += input[j];
|
|
247
|
+
j++;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (j >= n) throw new Error("unterminated string");
|
|
251
|
+
toks.push({ t: "str", v: val });
|
|
252
|
+
i = j + 1;
|
|
226
253
|
continue;
|
|
227
254
|
}
|
|
228
255
|
// multi/single char operators
|
package/extensions/render.ts
CHANGED
|
@@ -104,7 +104,7 @@ export function summarizeRun(state: RunState): string {
|
|
|
104
104
|
const done = phases.filter((p) => p.status === "done").length;
|
|
105
105
|
const failed = phases.filter((p) => p.status === "failed").length;
|
|
106
106
|
const running = phases.filter((p) => p.status === "running").length;
|
|
107
|
-
const total = state.
|
|
107
|
+
const total = Object.keys(state.phases).length;
|
|
108
108
|
const bits = [`${done}/${total} done`];
|
|
109
109
|
if (running) bits.push(`${running} running`);
|
|
110
110
|
if (failed) bits.push(`${failed} failed`);
|
|
@@ -254,7 +254,7 @@ function headerLine(state: RunState, theme: Theme): string {
|
|
|
254
254
|
const done = phases.filter((p) => p.status === "done").length;
|
|
255
255
|
const failed = phases.filter((p) => p.status === "failed").length;
|
|
256
256
|
const running = phases.filter((p) => p.status === "running").length;
|
|
257
|
-
const total = state.
|
|
257
|
+
const total = Object.keys(state.phases).length;
|
|
258
258
|
|
|
259
259
|
const head =
|
|
260
260
|
state.status === "completed"
|
package/extensions/runner.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface RunResult {
|
|
|
25
25
|
errorMessage?: string;
|
|
26
26
|
/** Total subagent attempts incl. retries (set by the runtime's retry wrapper). */
|
|
27
27
|
attempts?: number;
|
|
28
|
+
/** Set when the subagent was killed by the idle watchdog (not a user abort). */
|
|
29
|
+
idleTimeout?: boolean;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export interface LiveUpdate {
|
|
@@ -74,6 +76,8 @@ const TRANSIENT_ERROR_RE =
|
|
|
74
76
|
/rate[_\s-]?limit|too\s+many\s+requests|overloaded|\b429\b|\b503\b|\b502\b|\b504\b|service\s+unavailable|temporarily\s+unavailable|timeout|timed?\s+out|econnreset|etimedout|socket\s+hang\s*up/i;
|
|
75
77
|
export function isTransientError(r: RunResult): boolean {
|
|
76
78
|
if (r.stopReason === "aborted") return false;
|
|
79
|
+
// Idle timeout is a deterministic stall — retrying won't help.
|
|
80
|
+
if (r.stopReason === "error" && r.idleTimeout) return false;
|
|
77
81
|
const hay = `${r.errorMessage ?? ""} ${r.stderr ?? ""} ${r.output ?? ""}`;
|
|
78
82
|
return TRANSIENT_ERROR_RE.test(hay);
|
|
79
83
|
}
|
|
@@ -153,6 +157,8 @@ export interface EventAccumulator {
|
|
|
153
157
|
stopReason?: string;
|
|
154
158
|
errorMessage?: string;
|
|
155
159
|
lastActivity: string;
|
|
160
|
+
/** Set when message cap was hit — output gets a truncation notice. */
|
|
161
|
+
truncated?: boolean;
|
|
156
162
|
}
|
|
157
163
|
|
|
158
164
|
export function newAccumulator(model?: string): EventAccumulator {
|
|
@@ -175,7 +181,15 @@ export function foldEventLine(acc: EventAccumulator, line: string): LiveUpdate |
|
|
|
175
181
|
}
|
|
176
182
|
if (event.type !== "message_end" || !event.message) return null;
|
|
177
183
|
const msg = event.message as Message;
|
|
178
|
-
|
|
184
|
+
// Cap prevents OOM from misconfigured loops. 500 messages is generous for
|
|
185
|
+
// normal subagent tasks (50 turns × 10 messages each). Messages beyond the
|
|
186
|
+
// cap are still parsed for usage/model/stopReason extraction.
|
|
187
|
+
const MAX_MESSAGES = 500;
|
|
188
|
+
if (acc.messages.length < MAX_MESSAGES) {
|
|
189
|
+
acc.messages.push(msg);
|
|
190
|
+
} else {
|
|
191
|
+
acc.truncated = true;
|
|
192
|
+
}
|
|
179
193
|
if (msg.role !== "assistant") return null;
|
|
180
194
|
acc.usage.turns++;
|
|
181
195
|
const u = (msg as any).usage;
|
|
@@ -323,6 +337,7 @@ export async function runAgentTask(
|
|
|
323
337
|
|
|
324
338
|
let wasAborted = false;
|
|
325
339
|
let idleTimedOut = false;
|
|
340
|
+
let killedBySignal: string | undefined;
|
|
326
341
|
const exitCode = await new Promise<number>((resolve) => {
|
|
327
342
|
const invocation = getPiInvocation(args);
|
|
328
343
|
const proc = spawn(invocation.command, invocation.args, {
|
|
@@ -371,12 +386,19 @@ export async function runAgentTask(
|
|
|
371
386
|
buffer = lines.pop() || "";
|
|
372
387
|
for (const line of lines) processLine(line);
|
|
373
388
|
});
|
|
389
|
+
// Cap prevents OOM from verbose tool output (e.g., npm install). 64 KB is
|
|
390
|
+
// generous for error diagnosis while preventing memory exhaustion.
|
|
391
|
+
const STDERR_MAX_LEN = 64 * 1024;
|
|
374
392
|
proc.stderr.on("data", (data) => {
|
|
375
393
|
result.stderr += data.toString();
|
|
394
|
+
if (result.stderr.length >= STDERR_MAX_LEN) {
|
|
395
|
+
result.stderr = result.stderr.slice(0, STDERR_MAX_LEN) + "\n[...stderr truncated at 64KB]";
|
|
396
|
+
}
|
|
376
397
|
});
|
|
377
|
-
proc.on("close", (code) => {
|
|
398
|
+
proc.on("close", (code, signal) => {
|
|
378
399
|
clearTimers();
|
|
379
400
|
if (buffer.trim()) processLine(buffer);
|
|
401
|
+
if (code === null && signal) killedBySignal = signal;
|
|
380
402
|
resolve(code ?? 0);
|
|
381
403
|
});
|
|
382
404
|
proc.on("error", (err) => {
|
|
@@ -411,11 +433,25 @@ export async function runAgentTask(
|
|
|
411
433
|
result.stopReason = acc.stopReason;
|
|
412
434
|
result.errorMessage = acc.errorMessage;
|
|
413
435
|
result.output = getFinalOutput(acc.messages);
|
|
436
|
+
// M-6: surface truncation when the message cap was hit so downstream
|
|
437
|
+
// phases and the user know output was cut short.
|
|
438
|
+
if (acc.truncated) {
|
|
439
|
+
result.output += "\n\n[...output truncated after 500 messages]";
|
|
440
|
+
}
|
|
441
|
+
// Signal kill detection: process exited 0 but was killed by a signal
|
|
442
|
+
// (e.g. OOM killer, cgroup limit). Treat as failure so the runtime's
|
|
443
|
+
// retry/fail handling doesn't silently accept a truncated result.
|
|
444
|
+
if (exitCode === 0 && killedBySignal && !idleTimedOut && !wasAborted) {
|
|
445
|
+
result.exitCode = 1;
|
|
446
|
+
result.stopReason = "error";
|
|
447
|
+
result.errorMessage = `Subagent killed by signal ${killedBySignal}`;
|
|
448
|
+
}
|
|
414
449
|
if (idleTimedOut) {
|
|
415
450
|
// Distinct, actionable signal: the child was killed for being idle, not
|
|
416
451
|
// a user abort. stopReason "error" keeps it in the failed bucket so the
|
|
417
452
|
// runtime's retry/fail handling treats it as a real failure.
|
|
418
453
|
result.stopReason = "error";
|
|
454
|
+
result.idleTimeout = true;
|
|
419
455
|
result.errorMessage = `Subagent stalled: no output for ${Math.round((opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS) / 1000)}s (idle timeout) — killed`;
|
|
420
456
|
} else if (wasAborted) {
|
|
421
457
|
result.stopReason = "aborted";
|
package/extensions/runs-view.ts
CHANGED
|
@@ -29,7 +29,7 @@ function statusBadge(status: RunState["status"], theme: Theme): string {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function timeAgo(ts: number): string {
|
|
32
|
-
const s = Math.floor((Date.now() - ts) / 1000);
|
|
32
|
+
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
33
33
|
if (s < 60) return `${s}s ago`;
|
|
34
34
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
35
35
|
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
@@ -37,7 +37,7 @@ function timeAgo(ts: number): string {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function isResumable(r: RunState): boolean {
|
|
40
|
-
return r.status === "paused" || r.status === "failed"
|
|
40
|
+
return r.status === "paused" || r.status === "failed";
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export class RunHistoryComponent {
|