pi-crew 0.9.5 → 0.9.8
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 +556 -0
- package/README.md +10 -3
- package/docs/HARNESS_BACKLOG.md +51 -3
- package/docs/dynamic-workflows.md +315 -2
- package/docs/fix-plan-disabletools-exit-null.md +219 -0
- package/docs/troubleshooting.md +76 -0
- package/package.json +10 -3
- package/src/config/defaults.ts +8 -4
- package/src/extension/team-tool/doctor.ts +14 -0
- package/src/extension/team-tool/run.ts +2 -0
- package/src/runtime/background-runner.ts +1 -1
- package/src/runtime/capability-inventory.ts +20 -1
- package/src/runtime/child-pi.ts +109 -11
- package/src/runtime/deterministic-ast.ts +161 -0
- package/src/runtime/dwf-state-store.ts +97 -0
- package/src/runtime/dynamic-workflow-context.ts +381 -7
- package/src/runtime/dynamic-workflow-runner.ts +93 -2
- package/src/runtime/pi-args.ts +11 -0
- package/src/runtime/result-extractor.ts +72 -7
- package/src/runtime/task-output-context.ts +25 -9
- package/src/runtime/team-runner.ts +8 -3
- package/src/runtime/zombie-scanner.ts +297 -0
- package/src/schema/team-tool-schema.ts +28 -0
- package/src/skills/discover-skills.ts +61 -8
- package/src/skills/validate.ts +267 -0
- package/src/state/contracts.ts +1 -0
- package/src/state/state-store.ts +3 -0
- package/src/state/types.ts +9 -0
- package/src/ui/dashboard-panes/progress-pane.ts +5 -0
- package/src/ui/dwf-phase-display.ts +151 -0
- package/src/ui/keybinding-map.ts +128 -41
- package/src/ui/run-event-bus.ts +83 -0
- package/src/ui/run-snapshot-cache.ts +4 -0
- package/src/ui/snapshot-types.ts +3 -0
- package/src/workflows/workflow-config.ts +3 -0
- package/src/worktree/worktree-manager.ts +94 -0
- package/types/dwf.d.ts +187 -0
|
@@ -23,7 +23,9 @@ import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
|
23
23
|
import { appendEvent } from "../state/event-log.ts";
|
|
24
24
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
25
25
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
26
|
-
import { makeWorkflowCtx, getWorkflowFinalResult } from "./dynamic-workflow-context.ts";
|
|
26
|
+
import { makeWorkflowCtx, getWorkflowFinalResult, getWorkflowPhaseState } from "./dynamic-workflow-context.ts";
|
|
27
|
+
import { DwfStore } from "./dwf-state-store.ts";
|
|
28
|
+
import { assertDeterministicScript, isDeterminismCheckEnabled } from "./deterministic-ast.ts";
|
|
27
29
|
import { projectCrewRoot, userPiRoot, packageRoot } from "../utils/paths.ts";
|
|
28
30
|
import type { DynamicWorkflowConfig } from "../workflows/workflow-config.ts";
|
|
29
31
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
@@ -36,6 +38,8 @@ export interface RunDynamicWorkflowInput {
|
|
|
36
38
|
signal: AbortSignal;
|
|
37
39
|
concurrency?: number;
|
|
38
40
|
modelOverride?: string;
|
|
41
|
+
/** round-14 P1-2: per-workflow token budget. Overrides workflow.maxTokenBudget. */
|
|
42
|
+
tokenBudget?: number;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export interface RunDynamicWorkflowResult {
|
|
@@ -46,6 +50,27 @@ export interface RunDynamicWorkflowResult {
|
|
|
46
50
|
/** The signature a .dwf.ts default export must satisfy. */
|
|
47
51
|
export type DynamicWorkflowScript = (ctx: import("./dynamic-workflow-context.ts").WorkflowCtx) => Promise<void> | void;
|
|
48
52
|
|
|
53
|
+
/**
|
|
54
|
+
* round-12 P0-4: defensive structured-clone guard at the runner boundary.
|
|
55
|
+
*
|
|
56
|
+
* Today this is mostly future-proofing: a DWF script's `setResult()` path
|
|
57
|
+
* reads an artifact file as a string, and strings are always structured-
|
|
58
|
+
* cloneable. But if a future code path produces a non-cloneable value
|
|
59
|
+
* (e.g. a Worker postMessage payload that wraps a Symbol or function), we
|
|
60
|
+
* want a clear, actionable error here — not a cryptic `DataCloneError`
|
|
61
|
+
* from deep inside the artifact store. The error message also nudges
|
|
62
|
+
* users toward the most common cause: forgetting `await` on ctx.agent()
|
|
63
|
+
* or ctx.review() in their script.
|
|
64
|
+
*/
|
|
65
|
+
function assertStructuredCloneable(value: unknown, name: string): void {
|
|
66
|
+
try {
|
|
67
|
+
structuredClone(value);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
70
|
+
throw new Error(`${name} must be structured-cloneable; did you forget to await ctx.agent() or ctx.review()? ${detail}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
49
74
|
/**
|
|
50
75
|
* Resolve + validate the script path against the allowlist of workflow dirs (§0c C5).
|
|
51
76
|
* Returns the real contained path or throws.
|
|
@@ -79,8 +104,19 @@ function resolveScriptPath(workflow: DynamicWorkflowConfig, cwd: string): string
|
|
|
79
104
|
/**
|
|
80
105
|
* Transpile + load the .dwf.ts default export. Uses jiti (already a dep) for TS→JS.
|
|
81
106
|
* Returns the default export function or throws.
|
|
107
|
+
*
|
|
108
|
+
* Round-13 P0-2: after reading the script source, run `assertDeterministicScript`
|
|
109
|
+
* to reject non-deterministic calls (Date.now()/Math.random()/new Date()) BEFORE
|
|
110
|
+
* jiti executes the module. The check is opt-out via PI_CREW_DWF_SKIP_DETERMINISM_CHECK=1.
|
|
82
111
|
*/
|
|
83
112
|
async function loadWorkflowModule(scriptPath: string): Promise<DynamicWorkflowScript> {
|
|
113
|
+
// Round-13 P0-2: read source first so we can AST-scan before execution.
|
|
114
|
+
// jiti does not surface the transpiled source back to us, so we read the
|
|
115
|
+
// raw .dwf.ts file. This is the same source jiti will execute.
|
|
116
|
+
const scriptSource = readFileSync(scriptPath, "utf-8");
|
|
117
|
+
if (isDeterminismCheckEnabled()) {
|
|
118
|
+
assertDeterministicScript(scriptSource);
|
|
119
|
+
}
|
|
84
120
|
// jiti is the same loader async-runner.ts uses (resolveTypeScriptLoader). We require it
|
|
85
121
|
// lazily so this module stays importable in environments without jiti (type-only consumers).
|
|
86
122
|
// Fix round-4: use createRequire(import.meta.url) so `require` works under the strip-types
|
|
@@ -110,11 +146,37 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
|
|
|
110
146
|
|
|
111
147
|
appendEvent(eventsPath, { type: "dwf.started", runId: manifest.runId, data: { workflow: workflow.name, script: scriptPath } });
|
|
112
148
|
|
|
149
|
+
// round-18 P2-3: resume/checkpoint. Load any existing checkpoint for this run's stateRoot.
|
|
150
|
+
// stateRoot is already <crewRoot>/state/runs/<runId>, so the checkpoint lands at
|
|
151
|
+
// <stateRoot>/dwf-checkpoint.json (no double-nesting). A missing checkpoint (fresh run)
|
|
152
|
+
// yields undefined — makeWorkflowCtx starts with empty defaults (backward compatible).
|
|
153
|
+
const dwfStore = new DwfStore(manifest.stateRoot);
|
|
154
|
+
const resumedState = dwfStore.load();
|
|
155
|
+
if (resumedState) {
|
|
156
|
+
appendEvent(eventsPath, {
|
|
157
|
+
type: "dwf.resumed",
|
|
158
|
+
runId: manifest.runId,
|
|
159
|
+
data: { agentCount: resumedState.agentCount, phases: resumedState.phases, currentPhase: resumedState.currentPhase },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
113
163
|
const ctx = makeWorkflowCtx(manifest, {
|
|
114
164
|
concurrency: input.concurrency ?? workflow.maxConcurrency ?? 4,
|
|
115
165
|
signal,
|
|
116
166
|
team: input.team,
|
|
117
167
|
modelOverride: input.modelOverride,
|
|
168
|
+
tokenBudget: input.tokenBudget ?? workflow.maxTokenBudget,
|
|
169
|
+
args: manifest.args,
|
|
170
|
+
resumedState,
|
|
171
|
+
// round-18 P2-3: checkpoint after each ctx.agent() call so a crash between calls
|
|
172
|
+
// leaves durable state. onCheckpoint captures the closure values at call time.
|
|
173
|
+
onCheckpoint: (state) => {
|
|
174
|
+
try {
|
|
175
|
+
dwfStore.save(state);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
logInternalError("dynamic-workflow-runner.checkpoint-save", error, `runId=${manifest.runId}`);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
118
180
|
});
|
|
119
181
|
|
|
120
182
|
// Freeze the ctx so the script cannot add/override capability methods (§0c C4).
|
|
@@ -151,6 +213,12 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
|
|
|
151
213
|
const final = getWorkflowFinalResult(ctx);
|
|
152
214
|
const finalText = final ? readFinalArtifact(final.artifactPath) : `(dynamic workflow '${workflow.name}' completed without calling ctx.setResult())`;
|
|
153
215
|
|
|
216
|
+
// round-12 P0-4: fail fast on unawaited Promise returns BEFORE we try to
|
|
217
|
+
// write a 2 KB blob that contains a Promise reference. structuredClone on
|
|
218
|
+
// a string always succeeds; if it doesn't, the script returned something
|
|
219
|
+
// uncloneable (most often an unawaited Promise) and we want a clear error.
|
|
220
|
+
assertStructuredCloneable(finalText, "final artifact content (set via ctx.setResult)");
|
|
221
|
+
|
|
154
222
|
// Write a summary artifact mirroring the static-workflow summary.md contract (run.ts reads this).
|
|
155
223
|
const summary = writeArtifact(manifest.artifactsRoot, {
|
|
156
224
|
kind: "result",
|
|
@@ -159,12 +227,35 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
|
|
|
159
227
|
producer: "dynamic-workflow",
|
|
160
228
|
});
|
|
161
229
|
|
|
230
|
+
// round-12 P0-1: safety net — if a script never explicitly closes its
|
|
231
|
+
// final phase before returning, the runner emits a closing event so the
|
|
232
|
+
// last open phase is always terminated before dwf.completed.
|
|
233
|
+
const phaseState = getWorkflowPhaseState(ctx);
|
|
234
|
+
if (phaseState?.currentPhase !== undefined) {
|
|
235
|
+
appendEvent(eventsPath, {
|
|
236
|
+
type: "dwf.phase_completed",
|
|
237
|
+
runId: manifest.runId,
|
|
238
|
+
data: { phase: phaseState.currentPhase },
|
|
239
|
+
});
|
|
240
|
+
phaseState.currentPhase = undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
162
243
|
appendEvent(eventsPath, { type: "dwf.completed", runId: manifest.runId, data: { workflow: workflow.name, summaryArtifact: summary.path } });
|
|
163
244
|
|
|
245
|
+
// round-18 P2-3: the run completed cleanly — delete the checkpoint so a fresh re-run
|
|
246
|
+
// (same runId) starts from scratch rather than resuming stale state.
|
|
247
|
+
dwfStore.delete();
|
|
248
|
+
|
|
249
|
+
// round-12 P0-4: also guard the manifest.summary slice (the value is
|
|
250
|
+
// written into JSON-serialized manifest state — a Promise here would also
|
|
251
|
+
// crash later in the run-event-bus emitter).
|
|
252
|
+
const summaryText = finalText.slice(0, 2000);
|
|
253
|
+
assertStructuredCloneable(summaryText, "manifest.summary (derived from final result)");
|
|
254
|
+
|
|
164
255
|
const updatedManifest: TeamRunManifest = {
|
|
165
256
|
...manifest,
|
|
166
257
|
status: "completed",
|
|
167
|
-
summary:
|
|
258
|
+
summary: summaryText,
|
|
168
259
|
updatedAt: new Date().toISOString(),
|
|
169
260
|
artifacts: [...manifest.artifacts, summary],
|
|
170
261
|
};
|
package/src/runtime/pi-args.ts
CHANGED
|
@@ -243,6 +243,12 @@ export function createSafeTempDir(base: string, prefix: string): string {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerArgsResult {
|
|
246
|
+
// NOTE: do NOT add an argv flag like `--crew-subagent` here. Pi uses a strict
|
|
247
|
+
// option parser and REJECTS unknown flags with a non-zero exit, which would
|
|
248
|
+
// break every ctx.agent() call. The authoritative sub-agent identity signal
|
|
249
|
+
// is the PI_CREW_KIND=subagent ENV var (set below) — the zombie scanner and
|
|
250
|
+
// doctor --zombies read it from /proc/<pid>/environ. The user's main session
|
|
251
|
+
// never sets it, so it can never be matched as a sub-agent.
|
|
246
252
|
const args = ["--mode", "json", "-p"];
|
|
247
253
|
if (input.sessionEnabled === false) args.push("--no-session");
|
|
248
254
|
|
|
@@ -327,6 +333,11 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
|
|
|
327
333
|
return {
|
|
328
334
|
args,
|
|
329
335
|
env: {
|
|
336
|
+
// PI_CREW_KIND is the authoritative machine-readable sub-agent marker. It is always
|
|
337
|
+
// present on a child-pi process and NEVER present on a user's interactive main session.
|
|
338
|
+
// doctor --zombies uses it to safely list orphaned sub-agents without ever matching a
|
|
339
|
+
// main session (the lesson from an accidental `kill` of a live main session).
|
|
340
|
+
PI_CREW_KIND: "subagent",
|
|
330
341
|
PI_CREW_INHERIT_PROJECT_CONTEXT: input.agent.inheritProjectContext ? "1" : "0",
|
|
331
342
|
PI_CREW_INHERIT_SKILLS: input.agent.inheritSkills ? "1" : "0",
|
|
332
343
|
PI_CREW_DEPTH: String(parentDepth + 1),
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Structured Result Extractor — attempts to extract structured data from worker output.
|
|
3
3
|
* Tries multiple extraction strategies before falling back to raw text.
|
|
4
|
+
*
|
|
5
|
+
* Round-13 P0-3: optional `schema` (TypeBox `TSchema`) — when provided, extracted
|
|
6
|
+
* data is validated against the schema via `Value.Check`. On mismatch, the result
|
|
7
|
+
* is `structured:false` with an explanatory `error`. Backward compatible: when
|
|
8
|
+
* schema is undefined, behavior is identical to the previous regex-based extractor.
|
|
4
9
|
*/
|
|
10
|
+
import type { TSchema } from "@sinclair/typebox";
|
|
11
|
+
import { Value } from "@sinclair/typebox/value";
|
|
12
|
+
|
|
5
13
|
export interface ExtractedResult {
|
|
6
14
|
/** Whether structured data was successfully extracted */
|
|
7
15
|
structured: boolean;
|
|
8
|
-
/** Parsed structured data (if structured=true) */
|
|
16
|
+
/** Parsed structured data (if structured=true AND validated against schema if provided) */
|
|
9
17
|
data: unknown;
|
|
10
18
|
/** Raw text output (always available) */
|
|
11
19
|
rawText: string;
|
|
@@ -15,9 +23,13 @@ export interface ExtractedResult {
|
|
|
15
23
|
|
|
16
24
|
/**
|
|
17
25
|
* Extract structured result from raw worker output text.
|
|
18
|
-
* Tries strategies in order: direct JSON, fenced JSON, key-value markers.
|
|
26
|
+
* Tries strategies in order: direct JSON, fenced JSON, key-value markers, scan.
|
|
27
|
+
*
|
|
28
|
+
* @param raw - the raw text output from a worker
|
|
29
|
+
* @param schema - optional TypeBox schema. When provided, the extracted value is
|
|
30
|
+
* validated; mismatch produces `{structured:false, error:...}`.
|
|
19
31
|
*/
|
|
20
|
-
export function extractStructuredResult(raw: string,
|
|
32
|
+
export function extractStructuredResult(raw: string, schema?: TSchema): ExtractedResult {
|
|
21
33
|
const trimmed = raw.trim();
|
|
22
34
|
if (!trimmed) {
|
|
23
35
|
return { structured: false, data: null, rawText: raw };
|
|
@@ -26,19 +38,19 @@ export function extractStructuredResult(raw: string, _schema?: Record<string, un
|
|
|
26
38
|
// Strategy 1: Direct JSON parse (entire output is JSON)
|
|
27
39
|
const directResult = tryDirectJson(trimmed);
|
|
28
40
|
if (directResult !== undefined) {
|
|
29
|
-
return
|
|
41
|
+
return finalize(directResult, raw, schema);
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
// Strategy 2: Extract from ```json ... ``` fence
|
|
33
45
|
const fencedResult = tryFencedJson(trimmed);
|
|
34
46
|
if (fencedResult !== undefined) {
|
|
35
|
-
return
|
|
47
|
+
return finalize(fencedResult, raw, schema);
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
// Strategy 3: Extract from markers like "RESULT:" or "OUTPUT:"
|
|
39
51
|
const markerResult = tryMarkerExtraction(trimmed);
|
|
40
52
|
if (markerResult !== undefined) {
|
|
41
|
-
return
|
|
53
|
+
return finalize(markerResult, raw, schema);
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
// Strategy 4: Scan for the first JSON object/array anywhere in text.
|
|
@@ -46,12 +58,65 @@ export function extractStructuredResult(raw: string, _schema?: Record<string, un
|
|
|
46
58
|
// around the JSON. This catches JSON embedded in sentences, lists, or prose.
|
|
47
59
|
const scannedResult = tryScanJson(trimmed);
|
|
48
60
|
if (scannedResult !== undefined) {
|
|
49
|
-
return
|
|
61
|
+
return finalize(scannedResult, raw, schema);
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
return { structured: false, data: null, rawText: raw };
|
|
53
65
|
}
|
|
54
66
|
|
|
67
|
+
/**
|
|
68
|
+
* After extracting a candidate object, validate it against the optional TypeBox schema.
|
|
69
|
+
* When no schema is given, behavior is the legacy "structured:true" path.
|
|
70
|
+
* When a schema is given and validation fails, return structured:false with a
|
|
71
|
+
* clear error message (caller can surface this in the AgentResult).
|
|
72
|
+
*
|
|
73
|
+
* NOTE: TypeBox 0.34.49's `Value.Check` returns a boolean and does not expose
|
|
74
|
+
* per-error paths in its public API. We use the boolean + a fallback "type mismatch"
|
|
75
|
+
* description. Scripts that need detailed diagnostics can wrap their own validator.
|
|
76
|
+
*/
|
|
77
|
+
function finalize(candidate: unknown, raw: string, schema: TSchema | undefined): ExtractedResult {
|
|
78
|
+
if (!schema) {
|
|
79
|
+
return { structured: true, data: candidate, rawText: raw };
|
|
80
|
+
}
|
|
81
|
+
const ok = Value.Check(schema, candidate);
|
|
82
|
+
if (ok) {
|
|
83
|
+
return { structured: true, data: candidate, rawText: raw };
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
structured: false,
|
|
87
|
+
data: null,
|
|
88
|
+
rawText: raw,
|
|
89
|
+
error: `structured output does not match schema: expected shape ${describeSchemaShape(schema)}, got ${describeValue(candidate)}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function describeValue(value: unknown): string {
|
|
94
|
+
try {
|
|
95
|
+
const json = JSON.stringify(value);
|
|
96
|
+
return json.length > 200 ? `${json.slice(0, 200)}…` : json;
|
|
97
|
+
} catch {
|
|
98
|
+
return typeof value;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function describeSchemaShape(schema: unknown): string {
|
|
103
|
+
if (!schema || typeof schema !== "object") return "any";
|
|
104
|
+
const obj = schema as Record<string, unknown>;
|
|
105
|
+
const kind = obj.kind as string | undefined;
|
|
106
|
+
const type = obj.type as string | undefined;
|
|
107
|
+
if (kind === "object" || type === "object") {
|
|
108
|
+
const properties = obj.properties;
|
|
109
|
+
if (!properties || typeof properties !== "object") return "object";
|
|
110
|
+
return `object<${Object.keys(properties as Record<string, unknown>).join(",")}>`;
|
|
111
|
+
}
|
|
112
|
+
if (kind === "array" || type === "array") return "array";
|
|
113
|
+
if (type === "string") return "string";
|
|
114
|
+
if (type === "number" || type === "integer") return "number";
|
|
115
|
+
if (type === "boolean") return "boolean";
|
|
116
|
+
if (Array.isArray(obj.anyOf) || Array.isArray(obj.oneOf)) return "union";
|
|
117
|
+
return "any";
|
|
118
|
+
}
|
|
119
|
+
|
|
55
120
|
function tryDirectJson(text: string): unknown | undefined {
|
|
56
121
|
if (!text.startsWith("{") && !text.startsWith("[")) return undefined;
|
|
57
122
|
try {
|
|
@@ -30,20 +30,36 @@ function containedExists(filePath: string, baseDir?: string): boolean {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
/**
|
|
34
|
+
* L4 output-handling: single consistent threshold for all artifact reads.
|
|
35
|
+
* Sized from real data (27 result artifacts: max 9226 bytes; 100% < 16KB).
|
|
36
|
+
* 32KB gives 2x headroom over the largest observed real output while still
|
|
37
|
+
* bounding memory. Larger than the old inconsistent per-call-site values
|
|
38
|
+
* (24K/40K/80K) which truncated the same artifact differently depending on
|
|
39
|
+
* which code path read it.
|
|
40
|
+
*/
|
|
41
|
+
const MAX_RESULT_INLINE_BYTES = 32_000;
|
|
42
|
+
|
|
43
|
+
function readIfSmall(filePath: string, baseDir?: string): string | undefined {
|
|
44
|
+
const maxBytes = MAX_RESULT_INLINE_BYTES;
|
|
34
45
|
try {
|
|
35
46
|
const safePath = baseDir ? resolveRealContainedPath(baseDir, filePath) : filePath;
|
|
36
47
|
const stat = fs.statSync(safePath);
|
|
37
48
|
if (stat.size > maxBytes) {
|
|
38
|
-
//
|
|
39
|
-
|
|
49
|
+
// L4: head + tail instead of head-only. Keeps closing markdown
|
|
50
|
+
// structure (code fences, headings) instead of leaving them truncated.
|
|
51
|
+
const head = Math.floor(maxBytes * 0.75);
|
|
52
|
+
const tail = maxBytes - head;
|
|
53
|
+
const headBuf = Buffer.alloc(head);
|
|
54
|
+
const tailBuf = Buffer.alloc(tail);
|
|
40
55
|
const fd = fs.openSync(safePath, "r");
|
|
41
56
|
try {
|
|
42
|
-
fs.readSync(fd,
|
|
57
|
+
fs.readSync(fd, headBuf, 0, head, 0);
|
|
58
|
+
fs.readSync(fd, tailBuf, 0, tail, stat.size - tail);
|
|
43
59
|
} finally {
|
|
44
60
|
fs.closeSync(fd);
|
|
45
61
|
}
|
|
46
|
-
return `${
|
|
62
|
+
return `${headBuf.toString("utf-8")}\n\n...[pi-crew truncated ${stat.size - maxBytes} bytes, head+tail preserved]...\n${tailBuf.toString("utf-8")}`;
|
|
47
63
|
}
|
|
48
64
|
return fs.readFileSync(safePath, "utf-8");
|
|
49
65
|
} catch {
|
|
@@ -99,7 +115,7 @@ export function collectDependencyOutputContext(manifest: TeamRunManifest, tasks:
|
|
|
99
115
|
const byStep = new Map(tasks.map((item) => [item.stepId, item]).filter((entry): entry is [string, TeamTaskState] => Boolean(entry[0])));
|
|
100
116
|
const byId = new Map(tasks.map((item) => [item.id, item]));
|
|
101
117
|
const dependencies = task.dependsOn.map((dep) => byStep.get(dep) ?? byId.get(dep)).filter((item): item is TeamTaskState => Boolean(item)).map((item) => {
|
|
102
|
-
const resultText = item.resultArtifact ? readIfSmall(item.resultArtifact.path,
|
|
118
|
+
const resultText = item.resultArtifact ? readIfSmall(item.resultArtifact.path, manifest.artifactsRoot) : undefined;
|
|
103
119
|
return {
|
|
104
120
|
taskId: item.id,
|
|
105
121
|
role: item.role,
|
|
@@ -113,7 +129,7 @@ export function collectDependencyOutputContext(manifest: TeamRunManifest, tasks:
|
|
|
113
129
|
});
|
|
114
130
|
const sharedReads = (step.reads === false ? [] : step.reads ?? []).map((name) => {
|
|
115
131
|
const filePath = sharedPath(manifest, name);
|
|
116
|
-
return { name, path: filePath, content: readIfSmall(filePath,
|
|
132
|
+
return { name, path: filePath, content: readIfSmall(filePath, path.resolve(manifest.artifactsRoot, "shared")) ?? "" };
|
|
117
133
|
}).filter((item) => item.content.trim().length > 0);
|
|
118
134
|
return { dependencies, sharedReads };
|
|
119
135
|
}
|
|
@@ -139,7 +155,7 @@ export function renderDependencyOutputContext(context: DependencyOutputContext):
|
|
|
139
155
|
export function writeTaskSharedOutput(manifest: TeamRunManifest, step: WorkflowStep, task: TeamTaskState): ArtifactDescriptor | undefined {
|
|
140
156
|
if (step.output === false) return undefined;
|
|
141
157
|
const name = safeSharedName(step.output || `${task.id}.md`);
|
|
142
|
-
const source = task.resultArtifact ? readIfSmall(task.resultArtifact.path,
|
|
158
|
+
const source = task.resultArtifact ? readIfSmall(task.resultArtifact.path, manifest.artifactsRoot) : undefined;
|
|
143
159
|
if (!source) return undefined;
|
|
144
160
|
return writeArtifact(manifest.artifactsRoot, {
|
|
145
161
|
kind: "metadata",
|
|
@@ -160,7 +176,7 @@ export function writeTaskInputsArtifact(manifest: TeamRunManifest, task: TeamTas
|
|
|
160
176
|
|
|
161
177
|
export function aggregateTaskOutputs(tasks: TeamTaskState[], manifest?: TeamRunManifest): string {
|
|
162
178
|
return tasks.map((task, index) => {
|
|
163
|
-
const body = task.resultArtifact ? readIfSmall(task.resultArtifact.path,
|
|
179
|
+
const body = task.resultArtifact ? readIfSmall(task.resultArtifact.path, manifest?.artifactsRoot) : undefined;
|
|
164
180
|
const hasBody = Boolean(body?.trim());
|
|
165
181
|
const expectedMissing = task.resultArtifact && !containedExists(task.resultArtifact.path, manifest?.artifactsRoot);
|
|
166
182
|
const status = task.status === "skipped"
|
|
@@ -63,16 +63,21 @@ builtInRegistry.register(VitePlugin);
|
|
|
63
63
|
* executing. The team-runner has no periodic heartbeat today, so any
|
|
64
64
|
* team run lasting >5min is at risk.
|
|
65
65
|
*/
|
|
66
|
-
function startTeamRunHeartbeat(stateRoot: string, runId: string
|
|
66
|
+
function startTeamRunHeartbeat(stateRoot: string, runId: string): () => void {
|
|
67
67
|
const heartbeatPath = path.join(stateRoot, "heartbeat.json");
|
|
68
68
|
const writeHeartbeat = (): void => {
|
|
69
69
|
try {
|
|
70
|
+
// lastTaskUpdateAt is written fresh on each tick so the heartbeat
|
|
71
|
+
// never carries a stale creation-time timestamp. Previously this
|
|
72
|
+
// captured manifest.updatedAt once at startup, making the value
|
|
73
|
+
// permanently stale throughout the run.
|
|
74
|
+
const now = new Date().toISOString();
|
|
70
75
|
fs.writeFileSync(heartbeatPath, JSON.stringify({
|
|
71
76
|
pid: process.pid,
|
|
72
77
|
at: Date.now(),
|
|
73
78
|
runId,
|
|
74
79
|
kind: "team-runner",
|
|
75
|
-
lastTaskUpdateAt,
|
|
80
|
+
lastTaskUpdateAt: now,
|
|
76
81
|
}), { encoding: "utf-8", mode: 0o600 });
|
|
77
82
|
} catch {
|
|
78
83
|
// best-effort
|
|
@@ -439,7 +444,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
439
444
|
// (NO_PID_HEARTBEAT_STALE_MS). Previously only sub-task runners wrote
|
|
440
445
|
// heartbeats; the team-level run had no heartbeat, so any multi-phase
|
|
441
446
|
// workflow lasting >5min was marked stale and cancelled.
|
|
442
|
-
const stopTeamHeartbeat = startTeamRunHeartbeat(manifest.stateRoot, manifest.runId
|
|
447
|
+
const stopTeamHeartbeat = startTeamRunHeartbeat(manifest.stateRoot, manifest.runId);
|
|
443
448
|
|
|
444
449
|
const cleanupUsage = (): void => {
|
|
445
450
|
for (const task of input.tasks) clearTrackedTaskUsage(task.id);
|