pi-crew 0.11.26 → 0.11.28
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 +29 -0
- package/dist/index.mjs +279 -274
- package/package.json +1 -1
- package/src/extension/team-tool/chain-executor.ts +14 -1
- package/src/runtime/chain-parser.ts +24 -1
- package/src/runtime/chain-runner.ts +99 -14
- package/src/runtime/dwf-state-store.ts +7 -0
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +41 -1
- package/src/runtime/pi-spawn.ts +9 -1
- package/src/runtime/scheduling/scheduler.ts +12 -1
- package/src/state/contracts.ts +3 -0
- package/src/state/stores/artifact-store.ts +10 -0
- package/src/state/stores/state-store.ts +21 -2
- package/src/ui/theme-adapter.ts +13 -1
package/package.json
CHANGED
|
@@ -235,7 +235,14 @@ export class ChainTeamRunExecutor implements ChainTaskRunner {
|
|
|
235
235
|
// 1. Format chain history into the goal — this is how context reaches the worker.
|
|
236
236
|
// Mirrors the parentContext pattern in child-pi.ts (parent context + task delimiter).
|
|
237
237
|
const historyPrefix = formatChainHistory(context);
|
|
238
|
-
|
|
238
|
+
// v0.11.27 (R14-C1 tail): `--with-context` carries the previous step's FULL
|
|
239
|
+
// output as __chainPrevOutput (runner-capped); render it as its own block so
|
|
240
|
+
// the worker sees raw output, not just the size-capped handoff summary.
|
|
241
|
+
const prevOutput = typeof context.__chainPrevOutput === "string" ? context.__chainPrevOutput.trim() : "";
|
|
242
|
+
const prevOutputBlock = prevOutput ? `# Previous Step Output (--with-context)\n\n${prevOutput}` : "";
|
|
243
|
+
const headerParts = [historyPrefix, prevOutputBlock].filter((part) => part.length > 0);
|
|
244
|
+
const enrichedGoal =
|
|
245
|
+
headerParts.length > 0 ? `${headerParts.join("\n\n---\n")}\n\n---\n# Current Chain Step\n${packet.goal}` : packet.goal;
|
|
239
246
|
|
|
240
247
|
// 2. Resolve team/workflow/model: step config (set by executeStep) → overrides → default.
|
|
241
248
|
// BUG-44 (github #44): a workflow override of "chain" is a dispatcher-only marker
|
|
@@ -250,6 +257,11 @@ export class ChainTeamRunExecutor implements ChainTaskRunner {
|
|
|
250
257
|
// 3. Call handleRun for the heavy lifting. async:false forces each step to
|
|
251
258
|
// complete synchronously (overrides asyncByDefault) so the chain is sequential.
|
|
252
259
|
// NO `chain` key here — so the run.ts guard does not re-enter chain dispatch.
|
|
260
|
+
// v0.11.27 (R14-C2): forward the previously-dead `--timeout N` (s) flag.
|
|
261
|
+
// resolveRunDeadline honors params.timeoutMs (CORE-8 unified deadline:
|
|
262
|
+
// params > config.limits.maxRunMinutes > 1h default) for foreground runs.
|
|
263
|
+
const stepTimeoutMs = typeof context.__chainStepTimeoutMs === "number" ? context.__chainStepTimeoutMs : undefined;
|
|
264
|
+
|
|
253
265
|
const runParams: TeamToolParamsValue = {
|
|
254
266
|
action: "run",
|
|
255
267
|
goal: enrichedGoal,
|
|
@@ -257,6 +269,7 @@ export class ChainTeamRunExecutor implements ChainTaskRunner {
|
|
|
257
269
|
async: false,
|
|
258
270
|
...(stepWorkflow ? { workflow: stepWorkflow } : {}),
|
|
259
271
|
...(stepModel ? { model: stepModel } : {}),
|
|
272
|
+
...(stepTimeoutMs !== undefined && Number.isFinite(stepTimeoutMs) ? { timeoutMs: stepTimeoutMs } : {}),
|
|
260
273
|
};
|
|
261
274
|
const runRes = await this.handleRunRef(runParams, this.ctx);
|
|
262
275
|
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* step1:3 -> step2 --with-context -> step3
|
|
7
7
|
* parallel(a, b, parallel(c, d)) -> e
|
|
8
8
|
*
|
|
9
|
+
* `:N` loop counts are CLAMPED to 1..10 (v0.11.27): N=0/absent would silently
|
|
10
|
+
* skip nothing, N>10 would queue unbounded sequential runs. Clamping (not
|
|
11
|
+
* throwing) is deliberate — a throw here makes chain-runner fall back to the
|
|
12
|
+
* simple splitter, which turns `step:99` into ONE literal step named "step:99"
|
|
13
|
+
* (the exact R14-C1 silent-corruption class this parser exists to prevent).
|
|
14
|
+
*
|
|
9
15
|
* Pattern origin: pi-prompt-template-model chain-parser.ts
|
|
10
16
|
*/
|
|
11
17
|
|
|
@@ -193,6 +199,21 @@ class ChainParser {
|
|
|
193
199
|
return step;
|
|
194
200
|
}
|
|
195
201
|
|
|
202
|
+
// v0.11.27 (probe-caught): a QUOTED string is a valid step too —
|
|
203
|
+
// `"polish output":2` is the natural way to combine an inline goal with
|
|
204
|
+
// DSL modifiers. Previously only bare NAMEs were accepted here, so any
|
|
205
|
+
// quoted-goal chain with `:N`/`--with-context` threw and chain-runner
|
|
206
|
+
// silently fell back to the simple splitter — the constructs died for
|
|
207
|
+
// the MOST COMMON goal syntax. A quoted step keeps its text as `name`
|
|
208
|
+
// (executeStep uses it as the goal, mirroring the simple path's
|
|
209
|
+
// inlineGoal semantics).
|
|
210
|
+
if (this.peek("QUOTED")) {
|
|
211
|
+
const quoted = this.consume("QUOTED");
|
|
212
|
+
const quotedStep: ChainStep = { name: quoted.value };
|
|
213
|
+
this.parseModifiers(quotedStep);
|
|
214
|
+
return quotedStep;
|
|
215
|
+
}
|
|
216
|
+
|
|
196
217
|
// Normal step name
|
|
197
218
|
const name = this.consume("NAME").value;
|
|
198
219
|
const step: ChainStep = { name };
|
|
@@ -207,7 +228,9 @@ class ChainParser {
|
|
|
207
228
|
if (this.peek("COLON")) {
|
|
208
229
|
this.consume("COLON");
|
|
209
230
|
const num = this.consume("NUMBER");
|
|
210
|
-
|
|
231
|
+
// v0.11.27: clamp 1..10 (see header). Clamp-not-throw: a throw silently
|
|
232
|
+
// degrades the whole chain to the simple parser (literal step names).
|
|
233
|
+
step.loopCount = Math.min(10, Math.max(1, Number.parseInt(num.value, 10)));
|
|
211
234
|
} else if (this.peek("FLAG", "with-context")) {
|
|
212
235
|
this.consume("FLAG");
|
|
213
236
|
step.withContext = true;
|
|
@@ -105,6 +105,10 @@ export class ChainRunner {
|
|
|
105
105
|
/** Maximum size per handoff entry to prevent memory issues from large artifacts */
|
|
106
106
|
private static readonly MAX_HANDOFF_ENTRY_SIZE = 5000; // bytes per entry
|
|
107
107
|
|
|
108
|
+
/** v0.11.27: `--with-context` full-output cap (default history carries only
|
|
109
|
+
* 5000-char summaries; this is the explicit full-output channel, bounded). */
|
|
110
|
+
private static readonly MAX_WITH_CONTEXT_OUTPUT_CHARS = 20000; // chars
|
|
111
|
+
|
|
108
112
|
private taskRunner: ChainTaskRunner;
|
|
109
113
|
private handoffManager: HandoffManager;
|
|
110
114
|
|
|
@@ -186,8 +190,16 @@ export class ChainRunner {
|
|
|
186
190
|
// success predicate cannot report a truncated chain as complete.
|
|
187
191
|
let brokeEarly = false;
|
|
188
192
|
|
|
189
|
-
|
|
190
|
-
|
|
193
|
+
// v0.11.27 (R14-C1 tail): expand `:N` loop steps into an execution plan —
|
|
194
|
+
// one entry per ITERATION. Each iteration is its own ChainStepResult
|
|
195
|
+
// (name#k for k>1) so a failing iteration stays visible; the success
|
|
196
|
+
// predicate counts planned EXECUTIONS, so a chain that broke inside a
|
|
197
|
+
// loop cannot pass unexecuted iterations off as done (R14-C5 class).
|
|
198
|
+
const plan = this.buildExecutionPlan(spec);
|
|
199
|
+
const plannedExecutions = plan.length;
|
|
200
|
+
|
|
201
|
+
for (const entry of plan) {
|
|
202
|
+
const { step, stepIndex: i, displayName, iterations } = entry;
|
|
191
203
|
const stepStart = Date.now();
|
|
192
204
|
|
|
193
205
|
try {
|
|
@@ -198,7 +210,7 @@ export class ChainRunner {
|
|
|
198
210
|
const stepContext = this.enrichContextFromHandoffs(accumulatedContext, stepResults);
|
|
199
211
|
|
|
200
212
|
// Execute step
|
|
201
|
-
const result = await this.executeStep(effectiveConfig, stepContext);
|
|
213
|
+
const result = await this.executeStep(effectiveConfig, stepContext, stepResults);
|
|
202
214
|
|
|
203
215
|
// Track tokens
|
|
204
216
|
if (result.usage?.totalTokens) {
|
|
@@ -210,7 +222,7 @@ export class ChainRunner {
|
|
|
210
222
|
|
|
211
223
|
stepResults.push({
|
|
212
224
|
step: i + 1,
|
|
213
|
-
name:
|
|
225
|
+
name: displayName,
|
|
214
226
|
outcome: result.outcome,
|
|
215
227
|
result,
|
|
216
228
|
handoff,
|
|
@@ -243,10 +255,10 @@ export class ChainRunner {
|
|
|
243
255
|
await appendEventAsync(eventsPath, {
|
|
244
256
|
type: "chain.step_completed",
|
|
245
257
|
runId: "chain",
|
|
246
|
-
taskId: `step-${i + 1}`,
|
|
258
|
+
taskId: iterations > 1 ? `step-${i + 1}#${entry.iter}` : `step-${i + 1}`,
|
|
247
259
|
data: {
|
|
248
260
|
step: i + 1,
|
|
249
|
-
name:
|
|
261
|
+
name: displayName,
|
|
250
262
|
outcome: result.outcome,
|
|
251
263
|
duration: Date.now() - stepStart,
|
|
252
264
|
},
|
|
@@ -257,7 +269,7 @@ export class ChainRunner {
|
|
|
257
269
|
|
|
258
270
|
stepResults.push({
|
|
259
271
|
step: i + 1,
|
|
260
|
-
name:
|
|
272
|
+
name: displayName,
|
|
261
273
|
outcome: "failure",
|
|
262
274
|
duration: Date.now() - stepStart,
|
|
263
275
|
error: errMsg,
|
|
@@ -277,7 +289,7 @@ export class ChainRunner {
|
|
|
277
289
|
// for a chain whose remaining steps never ran. An empty spec was vacuously
|
|
278
290
|
// green for the same reason. Success now requires: at least one step, no
|
|
279
291
|
// early break, every planned step executed, and every outcome `success`.
|
|
280
|
-
const allStepsExecuted = stepResults.length ===
|
|
292
|
+
const allStepsExecuted = stepResults.length === plannedExecutions;
|
|
281
293
|
const allOutcomesSuccessful = stepResults.every((s) => s.outcome === "success");
|
|
282
294
|
|
|
283
295
|
return {
|
|
@@ -393,9 +405,14 @@ export class ChainRunner {
|
|
|
393
405
|
// Parse step timeout
|
|
394
406
|
const timeoutStr = this.extractFlag(step, "timeout");
|
|
395
407
|
if (timeoutStr) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
408
|
+
// v0.11.27 (R14-C2): the flag is SECONDS — the old bound compared the
|
|
409
|
+
// seconds value against 86400000 (a 1000-day ms bound) before
|
|
410
|
+
// multiplying, so `--timeout 99999999` was silently accepted. Now
|
|
411
|
+
// 1..86400 s (1 day), and the value is actually CONSUMED (chain-executor
|
|
412
|
+
// forwards it as runParams.timeoutMs).
|
|
413
|
+
const timeoutSec = parseInt(timeoutStr, 10);
|
|
414
|
+
if (!Number.isNaN(timeoutSec) && timeoutSec > 0 && timeoutSec <= 86400) {
|
|
415
|
+
parsed.timeout = timeoutSec * 1000;
|
|
399
416
|
}
|
|
400
417
|
}
|
|
401
418
|
|
|
@@ -412,7 +429,12 @@ export class ChainRunner {
|
|
|
412
429
|
* DSL features: parallel(...), :loopCount, --with-context flag
|
|
413
430
|
*/
|
|
414
431
|
private hasDSLConstructs(chainString: string): boolean {
|
|
415
|
-
|
|
432
|
+
// v0.11.27 (probe-caught): `:\d+` no longer requires a leading \w — a
|
|
433
|
+
// QUOTED goal followed by `:2` (`"polish":2`) must route to the DSL
|
|
434
|
+
// parser too. Broadening is safe: time-like false positives
|
|
435
|
+
// ("deploy at 10:30") still throw inside the DSL parser and fall back to
|
|
436
|
+
// the simple splitter exactly as before.
|
|
437
|
+
return /\bparallel\s*\(/.test(chainString) || /:\d+\b/.test(chainString) || /--with-context/.test(chainString);
|
|
416
438
|
}
|
|
417
439
|
|
|
418
440
|
/**
|
|
@@ -613,7 +635,29 @@ export class ChainRunner {
|
|
|
613
635
|
/**
|
|
614
636
|
* Execute a single step.
|
|
615
637
|
*/
|
|
616
|
-
private async executeStep(
|
|
638
|
+
private async executeStep(
|
|
639
|
+
config: ChainStep,
|
|
640
|
+
context: Record<string, unknown>,
|
|
641
|
+
previousResults: ChainStepResult[],
|
|
642
|
+
): Promise<TaskResult> {
|
|
643
|
+
// v0.11.27 (R14-C1 tail): DSL `args` append to the goal — positional goal
|
|
644
|
+
// arguments (`"research" "topic X"` -> goal "research topic X").
|
|
645
|
+
const dslArgs = Array.isArray(config.context?.args) ? (config.context.args as string[]) : undefined;
|
|
646
|
+
const baseGoal = config.inlineGoal ?? config.name;
|
|
647
|
+
const goal = dslArgs && dslArgs.length > 0 ? [baseGoal, ...dslArgs].join(" ") : baseGoal;
|
|
648
|
+
|
|
649
|
+
// v0.11.27 (R14-C1 tail): DSL `--with-context` passes the PREVIOUS step's
|
|
650
|
+
// full worker output (capped) — distinct from the default __chainHistory,
|
|
651
|
+
// which carries only size-capped handoff summaries.
|
|
652
|
+
const withContext = config.context?.withContext === true;
|
|
653
|
+
let chainPrevOutput: string | undefined;
|
|
654
|
+
if (withContext) {
|
|
655
|
+
const prev = [...previousResults].reverse().find((r) => r.outcome === "success" && r.result?.outputText);
|
|
656
|
+
if (prev?.result?.outputText) {
|
|
657
|
+
chainPrevOutput = prev.result.outputText.slice(0, ChainRunner.MAX_WITH_CONTEXT_OUTPUT_CHARS);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
617
661
|
// Pass step config (team/workflow/model) into the packet context so the
|
|
618
662
|
// concrete ChainTaskRunner can resolve which team/workflow to run. Without
|
|
619
663
|
// this, a step parsed to a @teamName reference would lose its team because
|
|
@@ -622,7 +666,7 @@ export class ChainRunner {
|
|
|
622
666
|
const packet: TaskPacket = {
|
|
623
667
|
taskId: `chain-${Date.now()}-${config.name}`,
|
|
624
668
|
runId: "chain",
|
|
625
|
-
goal
|
|
669
|
+
goal,
|
|
626
670
|
summarizeThreshold: 3000,
|
|
627
671
|
collapseContext: true,
|
|
628
672
|
context: {
|
|
@@ -630,12 +674,53 @@ export class ChainRunner {
|
|
|
630
674
|
__chainStepTeam: config.team,
|
|
631
675
|
__chainStepWorkflow: config.workflow,
|
|
632
676
|
__chainStepModel: config.model,
|
|
677
|
+
...(chainPrevOutput !== undefined ? { __chainPrevOutput: chainPrevOutput } : {}),
|
|
678
|
+
// v0.11.27 (R14-C2): wire the previously-dead `--timeout` flag into
|
|
679
|
+
// the step run's deadline (ms; consumed by chain-executor as
|
|
680
|
+
// runParams.timeoutMs -> CORE-8 resolveRunDeadline).
|
|
681
|
+
...(config.timeout !== undefined ? { __chainStepTimeoutMs: config.timeout } : {}),
|
|
633
682
|
},
|
|
634
683
|
};
|
|
635
684
|
|
|
636
685
|
return this.taskRunner.runTask(packet);
|
|
637
686
|
}
|
|
638
687
|
|
|
688
|
+
/** v0.11.27: iterations planned for a step — `:N` DSL (parser-clamped 1..10). */
|
|
689
|
+
private plannedIterations(step: ChainStep): number {
|
|
690
|
+
const n = step.context?.loopCount;
|
|
691
|
+
return typeof n === "number" && Number.isInteger(n) && n >= 1 ? Math.min(10, n) : 1;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** v0.11.27: expand steps into per-iteration plan entries. */
|
|
695
|
+
private buildExecutionPlan(spec: ChainSpec): Array<{
|
|
696
|
+
step: ChainStep;
|
|
697
|
+
stepIndex: number;
|
|
698
|
+
iter: number;
|
|
699
|
+
iterations: number;
|
|
700
|
+
displayName: string;
|
|
701
|
+
}> {
|
|
702
|
+
const plan: Array<{
|
|
703
|
+
step: ChainStep;
|
|
704
|
+
stepIndex: number;
|
|
705
|
+
iter: number;
|
|
706
|
+
iterations: number;
|
|
707
|
+
displayName: string;
|
|
708
|
+
}> = [];
|
|
709
|
+
spec.steps.forEach((step, stepIndex) => {
|
|
710
|
+
const iterations = this.plannedIterations(step);
|
|
711
|
+
for (let iter = 1; iter <= iterations; iter++) {
|
|
712
|
+
plan.push({
|
|
713
|
+
step,
|
|
714
|
+
stepIndex,
|
|
715
|
+
iter,
|
|
716
|
+
iterations,
|
|
717
|
+
displayName: iter > 1 ? `${step.name}#${iter}` : step.name,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
return plan;
|
|
722
|
+
}
|
|
723
|
+
|
|
639
724
|
/**
|
|
640
725
|
* Create minimal packet for handoff generation.
|
|
641
726
|
*/
|
|
@@ -49,6 +49,13 @@ export interface DwfCheckpointState {
|
|
|
49
49
|
* Uses Record (not Map) for JSON serialization safety. Optional: legacy
|
|
50
50
|
* checkpoints (and fresh states) omit it. */
|
|
51
51
|
completedAgentCalls?: Record<string, AgentResult>;
|
|
52
|
+
/** R14-C3 (wave 7, 2026-10-20): dispatch-time run context fingerprint. On
|
|
53
|
+
* save the runner stamps {modelOverride, team}; on resume a mismatch with
|
|
54
|
+
* the CURRENT dispatch context emits dwf.resume_context_mismatch — replayed
|
|
55
|
+
* agent results were produced under a different model/team and the operator
|
|
56
|
+
* should know (fail-open with visibility; the completed-call cache is still
|
|
57
|
+
* intentional work-skip). Absent on legacy checkpoints → check skipped. */
|
|
58
|
+
dispatchContext?: { modelOverride?: string; team?: string };
|
|
52
59
|
updatedAt: string;
|
|
53
60
|
}
|
|
54
61
|
|
|
@@ -210,6 +210,36 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
|
|
|
210
210
|
const dwfStore = new DwfStore(manifest.stateRoot);
|
|
211
211
|
const resumedState = dwfStore.load();
|
|
212
212
|
if (resumedState) {
|
|
213
|
+
// R14-C3 (wave 7): fingerprint check — a resume that changes the dispatch
|
|
214
|
+
// context (modelOverride/team) still replays the completed-call cache, but
|
|
215
|
+
// that replay now carries an operator-visible mismatch warning instead of
|
|
216
|
+
// silently passing off old-model results as current-model work.
|
|
217
|
+
const savedCtx2 = resumedState.dispatchContext;
|
|
218
|
+
const norm = (v: string | undefined): string | undefined => (v && v.length > 0 ? v : undefined);
|
|
219
|
+
if (savedCtx2 && norm(savedCtx2.modelOverride) !== norm(input.modelOverride)) {
|
|
220
|
+
await appendEventAsync(eventsPath, {
|
|
221
|
+
type: "dwf.resume_context_mismatch",
|
|
222
|
+
runId: manifest.runId,
|
|
223
|
+
data: {
|
|
224
|
+
axis: "modelOverride",
|
|
225
|
+
checkpoint: savedCtx2.modelOverride ?? null,
|
|
226
|
+
current: input.modelOverride ?? null,
|
|
227
|
+
},
|
|
228
|
+
message: `resume modelOverride differs from checkpoint (was ${savedCtx2.modelOverride ?? "default"}, now ${input.modelOverride ?? "default"}) — replayed agent results were produced under the checkpoint's model`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (savedCtx2 && norm(savedCtx2.team) !== norm(input.team?.name)) {
|
|
232
|
+
await appendEventAsync(eventsPath, {
|
|
233
|
+
type: "dwf.resume_context_mismatch",
|
|
234
|
+
runId: manifest.runId,
|
|
235
|
+
data: {
|
|
236
|
+
axis: "team",
|
|
237
|
+
checkpoint: savedCtx2.team ?? null,
|
|
238
|
+
current: input.team?.name ?? null,
|
|
239
|
+
},
|
|
240
|
+
message: `resume team differs from checkpoint (was ${savedCtx2.team ?? "default"}, now ${input.team?.name ?? "default"}) — replayed agent results were produced under the checkpoint's team`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
213
243
|
await appendEventAsync(eventsPath, {
|
|
214
244
|
type: "dwf.resumed",
|
|
215
245
|
runId: manifest.runId,
|
|
@@ -228,6 +258,14 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
|
|
|
228
258
|
const timeoutController = new AbortController();
|
|
229
259
|
const combinedSignal = AbortSignal.any([signal, timeoutController.signal]);
|
|
230
260
|
|
|
261
|
+
// R14-C3 (wave 7): dispatch-time fingerprint stamped into every checkpoint
|
|
262
|
+
// save; the resume path above compares it against the CURRENT dispatch
|
|
263
|
+
// context and emits dwf.resume_context_mismatch on drift.
|
|
264
|
+
const dispatchContext: { modelOverride?: string; team?: string } = {
|
|
265
|
+
...(input.modelOverride ? { modelOverride: input.modelOverride } : {}),
|
|
266
|
+
...(input.team?.name ? { team: input.team.name } : {}),
|
|
267
|
+
};
|
|
268
|
+
|
|
231
269
|
const ctx = makeWorkflowCtx(manifest, {
|
|
232
270
|
concurrency: input.concurrency ?? workflow.maxConcurrency ?? 4,
|
|
233
271
|
signal: combinedSignal,
|
|
@@ -241,9 +279,11 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
|
|
|
241
279
|
resumedState,
|
|
242
280
|
// round-18 P2-3: checkpoint after each ctx.agent() call so a crash between calls
|
|
243
281
|
// leaves durable state. onCheckpoint captures the closure values at call time.
|
|
282
|
+
// R14-C3 (wave 7): dispatchContext (declared above the ctx) is stamped into
|
|
283
|
+
// every save so a later resume can compare against the current dispatch.
|
|
244
284
|
onCheckpoint: (state) => {
|
|
245
285
|
try {
|
|
246
|
-
dwfStore.save(state);
|
|
286
|
+
dwfStore.save({ ...state, dispatchContext });
|
|
247
287
|
} catch (error) {
|
|
248
288
|
logInternalError("dynamic-workflow-runner.checkpoint-save", error, `runId=${manifest.runId}`);
|
|
249
289
|
}
|
package/src/runtime/pi-spawn.ts
CHANGED
|
@@ -289,7 +289,15 @@ function validateExplicitBin(explicit: string): string | undefined {
|
|
|
289
289
|
}
|
|
290
290
|
} catch (e) {
|
|
291
291
|
if (e instanceof Error && e.message.includes("allowed prefixes")) throw e;
|
|
292
|
-
|
|
292
|
+
// R2-L-A2 (wave 7): the drop is now stated explicitly in the message — the
|
|
293
|
+
// operator sees that their explicit pin was IGNORED and which fallback ran,
|
|
294
|
+
// not just the raw realpath error.
|
|
295
|
+
logInternalError(
|
|
296
|
+
"pi-spawn",
|
|
297
|
+
e,
|
|
298
|
+
`validateExplicitBin: unexpected realpathSync error — explicit PI_TEAMS_PI_BIN='${resolved}' IGNORED, falling back to default pi resolution`,
|
|
299
|
+
"error",
|
|
300
|
+
);
|
|
293
301
|
return undefined;
|
|
294
302
|
}
|
|
295
303
|
return resolved;
|
|
@@ -92,7 +92,12 @@ export class CrewScheduler {
|
|
|
92
92
|
// update() (never add()), so a normal fire's stale nextRun cannot reach here.
|
|
93
93
|
this.reportMissedCronOccurrences(job);
|
|
94
94
|
if (job.enabled) this.arm(job);
|
|
95
|
-
|
|
95
|
+
// R14-C4 (wave 7, 2026-10-20): arm() can mutate the stored entry BEFORE the
|
|
96
|
+
// added event fires (e.g. a past `once` job flips to enabled:false,
|
|
97
|
+
// lastStatus:"error" via update()). Emitting the ORIGINAL stale object
|
|
98
|
+
// confused event-order consumers (disk converged; the event stream didn't).
|
|
99
|
+
// Emit the post-arm truth from the map instead.
|
|
100
|
+
this.emit?.({ type: "added", job: this.jobs.get(job.id) ?? job });
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
remove(id: string): boolean {
|
|
@@ -189,6 +194,12 @@ export class CrewScheduler {
|
|
|
189
194
|
private arm(job: ScheduledJob): void {
|
|
190
195
|
if (this.timers.has(job.id)) return;
|
|
191
196
|
if (job.scheduleType === "interval" && job.intervalMs) {
|
|
197
|
+
// R2-L-B10 (wave 7, documented design choice): interval jobs re-arm from
|
|
198
|
+
// FIRE time via the chained hop, but the fire() → update() → disarm/arm
|
|
199
|
+
// lifecycle only re-arms after the run COMPLETES — the effective period is
|
|
200
|
+
// intervalMs + run duration, NOT schedule-aligned wall-clock spacing.
|
|
201
|
+
// Deliberate (no overlap between occurrences of the same job); recorded
|
|
202
|
+
// here so the semantics are discoverable at the arm site.
|
|
192
203
|
// 2026-09-22 spawn-storm root cause: this branch used
|
|
193
204
|
// setInterval(fire, intervalMs). Node timers are 32-bit — a LEGAL long
|
|
194
205
|
// interval (e.g. 90d = 7,776,000,000ms > 2^31-1) overflows and Node
|
package/src/state/contracts.ts
CHANGED
|
@@ -234,6 +234,9 @@ export const TEAM_EVENT_TYPES = [
|
|
|
234
234
|
"crew.task.retry_attempt",
|
|
235
235
|
// Dynamic workflow resume
|
|
236
236
|
"dwf.resumed",
|
|
237
|
+
// R14-C3 (wave 7): resume dispatch-context drift (modelOverride/team) —
|
|
238
|
+
// replayed agent results were produced under a different context.
|
|
239
|
+
"dwf.resume_context_mismatch",
|
|
237
240
|
// Foreground interrupt
|
|
238
241
|
"foreground.interrupt_requested",
|
|
239
242
|
// Goal loop (P0/P1) additional outcomes
|
|
@@ -206,6 +206,16 @@ export function pruneExpiredArtifacts(descriptors: ArtifactDescriptor[], options
|
|
|
206
206
|
if (!lstat.isDirectory()) throw new Error(`artifacts root is not a directory`);
|
|
207
207
|
realAnchor = fs.realpathSync(options.artifactsRoot);
|
|
208
208
|
} catch (error) {
|
|
209
|
+
// W7-C2 (2026-10-20): a MISSING anchor directory means the run's whole
|
|
210
|
+
// artifacts tree is already gone (age-based cleanup, an earlier prune, or
|
|
211
|
+
// operator removal) — there is nothing under it left to delete, so this is
|
|
212
|
+
// success-shaped silence, not a failure. Fail-closed behavior is unchanged
|
|
213
|
+
// (return 0, nothing deleted). Session start walks EVERY retained manifest,
|
|
214
|
+
// so warning here printed one line per stale run per boot (battery
|
|
215
|
+
// 2026-10-19 observation #4). Other anchor failures (symlink anchor,
|
|
216
|
+
// non-directory, permissions) stay visible warns.
|
|
217
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
218
|
+
if (code === "ENOENT") return 0;
|
|
209
219
|
logInternalError(
|
|
210
220
|
"artifact-store.prune-anchor",
|
|
211
221
|
error instanceof Error ? error : new Error(String(error)),
|
|
@@ -1243,7 +1243,18 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
1243
1243
|
let manifest: TeamRunManifest | undefined;
|
|
1244
1244
|
let tasks: TeamTaskState[] | undefined;
|
|
1245
1245
|
while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
|
|
1246
|
-
|
|
1246
|
+
let freshStat: fs.Stats;
|
|
1247
|
+
try {
|
|
1248
|
+
freshStat = fs.statSync(manifestPath);
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
// R2-L-A1 (wave 7, 2026-10-20): the run dir can vanish between the
|
|
1251
|
+
// caller's listing and this load (crash-recovery sweeps, artifact
|
|
1252
|
+
// cleanup, operator rm). A thrown ENOENT aborted the WHOLE best-effort
|
|
1253
|
+
// sweep for the session; "cannot be loaded" is undefined — the loop's
|
|
1254
|
+
// own missing-file contract.
|
|
1255
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
1256
|
+
throw error;
|
|
1257
|
+
}
|
|
1247
1258
|
// PERF (2026-08-24, Task 12 realized): after saveRunTasksCoalesced the
|
|
1248
1259
|
// cache keeps the manifest half of the entry while zeroing only the
|
|
1249
1260
|
// tasks stamps, so this slow path runs solely to refresh tasks. When the
|
|
@@ -1421,7 +1432,15 @@ export async function loadRunManifestByIdAsync(
|
|
|
1421
1432
|
let tasks: TeamTaskState[] | undefined;
|
|
1422
1433
|
let attempts = 0;
|
|
1423
1434
|
while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
|
|
1424
|
-
|
|
1435
|
+
let freshStat: fs.Stats;
|
|
1436
|
+
try {
|
|
1437
|
+
freshStat = await fs.promises.stat(manifestPath);
|
|
1438
|
+
} catch (error) {
|
|
1439
|
+
// R2-L-A1 (wave 7): async twin — ENOENT mid-sweep returns undefined
|
|
1440
|
+
// instead of aborting the caller's best-effort sweep.
|
|
1441
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
1442
|
+
throw error;
|
|
1443
|
+
}
|
|
1425
1444
|
// PERF (2026-08-24, Task 12 realized): async twin of the sync reuse —
|
|
1426
1445
|
// after saveRunTasksCoalesced only the tasks stamps are zeroed, so when
|
|
1427
1446
|
// the retained manifest stamps match the fresh stat (same verification
|
package/src/ui/theme-adapter.ts
CHANGED
|
@@ -195,8 +195,20 @@ function startThemeSourceSubscription(theme: object, subscription: ThemeSourceSu
|
|
|
195
195
|
subscription.unsubscribeSource = removeListener;
|
|
196
196
|
return;
|
|
197
197
|
}
|
|
198
|
+
// R2-L-B9 (wave 7, 2026-10-20): the poll closure previously held a STRONG
|
|
199
|
+
// ref to the theme — an abandoned subscription (consumer never calls the
|
|
200
|
+
// returned unsubscribe) kept the theme + subscription + callbacks alive
|
|
201
|
+
// forever with a permanent 1s poll. Hold only a WeakRef: once the theme is
|
|
202
|
+
// collected, the next tick self-terminates the interval (≤1s later), after
|
|
203
|
+
// which the closure chain is collectable.
|
|
204
|
+
const weakTheme = new WeakRef<object>(theme);
|
|
198
205
|
subscription.pollTimer = setInterval(() => {
|
|
199
|
-
const
|
|
206
|
+
const live = weakTheme.deref();
|
|
207
|
+
if (!live) {
|
|
208
|
+
clearInterval(subscription.pollTimer);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const nextSignature = themeSignature(live);
|
|
200
212
|
if (nextSignature === subscription.lastSignature) return;
|
|
201
213
|
subscription.lastSignature = nextSignature;
|
|
202
214
|
emit();
|