pi-crew 0.9.68 → 0.10.1
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 +124 -0
- package/agents/analyst.md +1 -1
- package/agents/cold-verifier.md +3 -1
- package/agents/critic.md +1 -1
- package/agents/executor.md +1 -1
- package/agents/explorer.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/verifier.md +1 -1
- package/agents/writer.md +1 -1
- package/dist/index.mjs +65838 -60331
- package/docs/README.md +2 -0
- package/docs/actions-reference.md +31 -0
- package/docs/commands-reference.md +17 -6
- package/docs/resource-formats.md +13 -0
- package/package.json +4 -2
- package/schema.json +15 -1
- package/scripts/resource-sampler.mjs +36 -2
- package/skills/requirements-to-task-packet/SKILL.md +26 -0
- package/skills/widget-rendering/SKILL.md +7 -7
- package/src/agents/agent-config.ts +2 -1
- package/src/agents/discover-agents.ts +23 -14
- package/src/config/config-merge.ts +183 -0
- package/src/config/config-validation.ts +681 -0
- package/src/config/config.ts +22 -864
- package/src/config/defaults.ts +35 -1
- package/src/config/drift-detector.ts +1 -1
- package/src/config/env-vars.ts +691 -0
- package/src/config/role-tools.ts +8 -8
- package/src/config/sanitize-project-config.ts +172 -0
- package/src/config/types.ts +30 -0
- package/src/extension/async-notifier.ts +25 -2
- package/src/extension/crew-vibes/config.ts +2 -1
- package/src/extension/plan-orchestrate.ts +132 -0
- package/src/extension/registration/commands/dashboard.ts +158 -0
- package/src/extension/registration/commands/index.ts +35 -0
- package/src/extension/registration/commands/manage.ts +303 -0
- package/src/extension/registration/commands/run.ts +214 -0
- package/src/extension/registration/commands/shared.ts +631 -0
- package/src/extension/registration/commands/status.ts +60 -0
- package/src/extension/registration/commands.ts +13 -1224
- package/src/extension/registration/lifecycle-handlers.ts +126 -15
- package/src/extension/registration/subagent-tools.ts +218 -9
- package/src/extension/rpc-hmac.ts +5 -3
- package/src/extension/team-tool/api/heartbeat.ts +47 -10
- package/src/extension/team-tool/api/plan-approval.ts +9 -0
- package/src/extension/team-tool/api/task-claims.ts +109 -40
- package/src/extension/team-tool/cancel.ts +84 -50
- package/src/extension/team-tool/dispatch/index.ts +1 -0
- package/src/extension/team-tool/dispatch/run.ts +4 -1
- package/src/extension/team-tool/doctor.ts +103 -1
- package/src/extension/team-tool/orchestrate.ts +66 -1
- package/src/extension/team-tool/plans.ts +192 -0
- package/src/extension/team-tool/respond.ts +197 -65
- package/src/extension/team-tool/run-deadline.ts +16 -1
- package/src/extension/team-tool/run-intent.ts +63 -0
- package/src/extension/team-tool/run.ts +48 -18
- package/src/extension/team-tool/status.ts +84 -26
- package/src/extension/team-tool.ts +11 -2
- package/src/hooks/registry.ts +1 -6
- package/src/i18n.ts +9 -0
- package/src/prompt/prompt-runtime.ts +521 -2
- package/src/prompt/worker-events-channel.ts +173 -0
- package/src/runtime/README.md +8 -8
- package/src/runtime/async-runner.ts +7 -3
- package/src/runtime/background-runner.ts +42 -14
- package/src/runtime/broker/broker-issuer.ts +9 -2
- package/src/runtime/broker/crew-broker-tokens.ts +43 -6
- package/src/runtime/broker/crew-broker.ts +838 -10
- package/src/runtime/broker/wait-status-cache.ts +157 -0
- package/src/runtime/budget-enforcement.ts +281 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
- package/src/runtime/child-pi/child-pi-timers.ts +324 -0
- package/src/runtime/child-pi/child-pi.ts +97 -201
- package/src/runtime/child-pi/mock-fixtures.ts +16 -2
- package/src/runtime/crew-agent-records.ts +259 -14
- package/src/runtime/delegate-spawn.ts +148 -0
- package/src/runtime/deterministic-ast.ts +2 -1
- package/src/runtime/dispatch-batch.ts +945 -0
- package/src/runtime/finalize-run.ts +557 -0
- package/src/runtime/goal-workflow/adaptive-plan.ts +87 -13
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -2
- package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
- package/src/runtime/group-join.ts +11 -125
- package/src/runtime/live-session/live-session-runtime.ts +26 -1
- package/src/runtime/merge-gate.ts +7 -1
- package/src/runtime/merge-loop.ts +130 -0
- package/src/runtime/model/model-budget-summary.ts +53 -0
- package/src/runtime/model/model-fallback.ts +32 -2
- package/src/runtime/model/pi-args.ts +10 -0
- package/src/runtime/model/provider-extensions.ts +10 -0
- package/src/runtime/orphan-worker-registry.ts +1 -1
- package/src/runtime/output/output-validator.ts +45 -0
- package/src/runtime/parent-guard.ts +3 -1
- package/src/runtime/peer-dep.ts +2 -1
- package/src/runtime/per-write-validator.ts +0 -5
- package/src/runtime/pi-spawn.ts +61 -15
- package/src/runtime/plan-approval.ts +125 -0
- package/src/runtime/plan-replan.ts +151 -0
- package/src/runtime/recovery/checkpoint.ts +0 -18
- package/src/runtime/recovery/crash-recovery.ts +95 -46
- package/src/runtime/scheduler-context.ts +98 -0
- package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
- package/src/runtime/scheduling/global-worker-cap.ts +2 -1
- package/src/runtime/scheduling/nested-slots.ts +70 -0
- package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
- package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
- package/src/runtime/settings-store.ts +219 -0
- package/src/runtime/spawn-policy.ts +217 -0
- package/src/runtime/stale-reconciler.ts +87 -6
- package/src/runtime/subagent-manager.ts +25 -1
- package/src/runtime/task-output-context.ts +230 -9
- package/src/runtime/task-packet.ts +23 -1
- package/src/runtime/task-runner/child-executor.ts +106 -7
- package/src/runtime/task-runner/post-execution.ts +125 -1
- package/src/runtime/task-runner/pre-execution.ts +39 -1
- package/src/runtime/task-runner/prompt-builder.ts +51 -1
- package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
- package/src/runtime/task-runner/spec-evidence.ts +403 -0
- package/src/runtime/task-runner/state-helpers.ts +26 -24
- package/src/runtime/task-runner.ts +11 -0
- package/src/runtime/team-runner.ts +129 -1671
- package/src/runtime/verification/spec-sandbox.ts +255 -0
- package/src/runtime/verification/verification-gates.ts +3 -2
- package/src/runtime/verification/verification-worktree.ts +2 -1
- package/src/runtime/workflow-phase-advance.ts +100 -0
- package/src/runtime/workspace-tree.ts +9 -0
- package/src/schema/config-schema.ts +65 -25
- package/src/schema/sensitive-config-paths.ts +64 -0
- package/src/schema/team-tool-schema.ts +13 -3
- package/src/state/README.md +4 -10
- package/src/state/atomic-write.ts +20 -3
- package/src/state/contracts.ts +38 -0
- package/src/state/coordination/mailbox.ts +12 -2
- package/src/state/event-log/cursor.ts +223 -0
- package/src/state/event-log/event-log-rotation.ts +12 -4
- package/src/state/event-log/event-log.ts +152 -369
- package/src/state/event-log/sequence-cache.ts +373 -0
- package/src/state/event-log/worker-atomic-writer.ts +2 -1
- package/src/state/stores/active-run-registry.ts +3 -2
- package/src/state/stores/manifest-io.ts +237 -0
- package/src/state/stores/ownership-map.ts +162 -0
- package/src/state/stores/plan-store.ts +241 -0
- package/src/state/stores/run-cache.ts +0 -90
- package/src/state/stores/spec-store.ts +189 -0
- package/src/state/stores/state-store.ts +66 -229
- package/src/state/types.ts +197 -0
- package/src/ui/dashboard-panes/plan-pane.ts +136 -0
- package/src/ui/dashboard-panes/progress-pane.ts +6 -0
- package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
- package/src/ui/heartbeat-aggregator.ts +9 -1
- package/src/ui/keybinding-map.ts +54 -13
- package/src/ui/powerbar-publisher.ts +52 -1
- package/src/ui/run-dashboard.ts +31 -5
- package/src/ui/run-snapshot-cache.ts +57 -30
- package/src/ui/snapshot-types.ts +6 -1
- package/src/ui/widget/widget-renderer.ts +9 -1
- package/src/utils/file-coalescer.ts +0 -4
- package/src/utils/fs-errno.ts +66 -0
- package/src/utils/fs-watch.ts +1 -1
- package/src/utils/internal-error.ts +3 -1
- package/src/utils/paths.ts +11 -3
- package/src/utils/task-name-generator.ts +1 -8
- package/src/workflows/discover-workflows.ts +19 -2
- package/src/workflows/validate-workflow.ts +7 -1
- package/src/workflows/workflow-config.ts +10 -0
- package/src/workflows/workflow-serializer.ts +3 -0
- package/src/worktree/worktree-manager.ts +22 -0
- package/src/agents/agent-search.ts +0 -98
- package/src/benchmark/benchmark-runner.ts +0 -313
- package/src/benchmark/feedback-loop.ts +0 -73
- package/src/config/resilient-parser.ts +0 -117
- package/src/extension/crew-vibes/cat-frames.ts +0 -18
- package/src/extension/result-watcher.ts +0 -139
- package/src/observability/exporters/prometheus-exporter.ts +0 -54
- package/src/observability/metric-retention.ts +0 -64
- package/src/runtime/compaction/compaction-summary.ts +0 -278
- package/src/runtime/errors/crew-errors.ts +0 -162
- package/src/runtime/live-session/intercom-bridge.ts +0 -187
- package/src/runtime/loop-gates.ts +0 -128
- package/src/runtime/metric-parser.ts +0 -36
- package/src/runtime/output/stream-preview.ts +0 -184
- package/src/runtime/output/tool-progress.ts +0 -278
- package/src/runtime/phase-tracker.ts +0 -385
- package/src/runtime/pipeline-runner.ts +0 -523
- package/src/runtime/process/process-lifecycle.ts +0 -491
- package/src/runtime/recovery/retry-runner.ts +0 -330
- package/src/runtime/run-drift.ts +0 -219
- package/src/runtime/task-quality.ts +0 -199
- package/src/runtime/task-runner/run-projection.ts +0 -128
- package/src/runtime/verification/post-checks.ts +0 -142
- package/src/state/coordination/schedule.ts +0 -166
- package/src/state/event-log/jsonl-writer.ts +0 -115
- package/src/state/hook-instinct-bridge.ts +0 -94
- package/src/state/hook-integrations.ts +0 -51
- package/src/state/session-state-map.ts +0 -51
- package/src/state/stores/blob-store.ts +0 -308
- package/src/state/stores/instinct-store.ts +0 -275
- package/src/state/stores/observation-store.ts +0 -176
- package/src/state/tiered-eval.ts +0 -480
- package/src/state/types-eval.ts +0 -58
- package/src/tools/safe-bash-extension.ts +0 -54
- package/src/tools/safe-bash.ts +0 -505
- package/src/ui/agent-management-overlay.ts +0 -160
- package/src/ui/crew-footer.ts +0 -102
- package/src/ui/crew-select-list.ts +0 -114
- package/src/ui/dashboard-panes/capability-pane.ts +0 -77
- package/src/ui/transcript-entries.ts +0 -256
- package/src/utils/conflict-detect.ts +0 -721
- package/src/utils/fingerprint.ts +0 -180
- package/src/utils/gh-protocol.ts +0 -556
- package/src/utils/project-detector.ts +0 -160
- package/src/utils/sse-parser.ts +0 -131
- package/src/workflows/cost-estimator.ts +0 -34
- package/src/workflows/intermediate-store.ts +0 -166
|
@@ -14,11 +14,15 @@
|
|
|
14
14
|
//
|
|
15
15
|
// `__test__parseAdaptivePlan` and `__test__repairAdaptivePlan` are re-exported
|
|
16
16
|
// from team-runner.ts so existing test imports keep working.
|
|
17
|
+
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
17
19
|
import * as fs from "node:fs";
|
|
20
|
+
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
18
21
|
import { appendEventAsync, appendEventFireAndForget } from "../../state/event-log/event-log.ts";
|
|
19
22
|
import { writeArtifact } from "../../state/stores/artifact-store.ts";
|
|
20
|
-
import {
|
|
21
|
-
import
|
|
23
|
+
import { appendPlanRevision, getCurrentPlanRecord } from "../../state/stores/plan-store.ts";
|
|
24
|
+
import { saveRunManifestAsync, saveRunTasksAsync } from "../../state/stores/state-store.ts";
|
|
25
|
+
import type { PlanRecord, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
22
26
|
import type { TeamConfig } from "../../teams/team-config.ts";
|
|
23
27
|
import type { WorkflowConfig, WorkflowStep } from "../../workflows/workflow-config.ts";
|
|
24
28
|
import { refreshTaskGraphQueues } from "../scheduling/task-graph-scheduler.ts";
|
|
@@ -39,7 +43,15 @@ export interface AdaptivePlan {
|
|
|
39
43
|
phases: AdaptivePlanPhase[];
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
|
|
46
|
+
/**
|
|
47
|
+
* T2/R4 (ADR-4 §5): the cap is PER-PHASE, not global. Pre-v2 this was a
|
|
48
|
+
* global flatten cap (MAX_ADAPTIVE_TASKS = 12) applied across the whole plan —
|
|
49
|
+
* a 3-phase plan may now schedule up to 3 x this across its life, bounded and
|
|
50
|
+
* visible per phase in the persisted PlanRecord. Deliberately a module
|
|
51
|
+
* constant, NOT a config key (the `adaptive` config section belongs to
|
|
52
|
+
* T3/WP-5 — review finding F3 of the ADR round).
|
|
53
|
+
*/
|
|
54
|
+
const ADAPTIVE_MAX_TASKS_PER_PHASE = 12;
|
|
43
55
|
|
|
44
56
|
export function slug(value: string): string {
|
|
45
57
|
return (
|
|
@@ -124,7 +136,6 @@ export function parseAdaptivePlan(text: string, allowedRoles: string[]): Adaptiv
|
|
|
124
136
|
if (!phasesRaw) return undefined;
|
|
125
137
|
const allowed = new Set(allowedRoles);
|
|
126
138
|
const phases: AdaptivePlanPhase[] = [];
|
|
127
|
-
let total = 0;
|
|
128
139
|
for (const [phaseIndex, phaseRaw] of phasesRaw.entries()) {
|
|
129
140
|
if (!phaseRaw || typeof phaseRaw !== "object" || Array.isArray(phaseRaw)) return undefined;
|
|
130
141
|
const phaseObj = phaseRaw as { name?: unknown; tasks?: unknown };
|
|
@@ -139,13 +150,12 @@ export function parseAdaptivePlan(text: string, allowedRoles: string[]): Adaptiv
|
|
|
139
150
|
};
|
|
140
151
|
if (typeof taskObj.role !== "string" || !allowed.has(taskObj.role)) return undefined;
|
|
141
152
|
if (typeof taskObj.task !== "string" || !taskObj.task.trim()) return undefined;
|
|
142
|
-
if (
|
|
153
|
+
if (tasks.length >= ADAPTIVE_MAX_TASKS_PER_PHASE) return undefined; // per-phase (ADR-4 §5)
|
|
143
154
|
tasks.push({
|
|
144
155
|
role: taskObj.role,
|
|
145
156
|
title: typeof taskObj.title === "string" ? taskObj.title : undefined,
|
|
146
157
|
task: taskObj.task.trim(),
|
|
147
158
|
});
|
|
148
|
-
total++;
|
|
149
159
|
}
|
|
150
160
|
phases.push({
|
|
151
161
|
name: typeof phaseObj.name === "string" && phaseObj.name.trim() ? phaseObj.name.trim() : `phase-${phaseIndex + 1}`,
|
|
@@ -289,7 +299,6 @@ export function repairAdaptivePlan(text: string, allowedRoles: string[]): { plan
|
|
|
289
299
|
if (!phasesRaw) return { repaired: false, reason: "missing-phases" };
|
|
290
300
|
const allowed = new Set(allowedRoles);
|
|
291
301
|
const phases: AdaptivePlanPhase[] = [];
|
|
292
|
-
let total = 0;
|
|
293
302
|
let repaired = salvageUsed || raw !== closeResult.text;
|
|
294
303
|
for (const [phaseIndex, phaseRaw] of phasesRaw.entries()) {
|
|
295
304
|
if (!phaseRaw || typeof phaseRaw !== "object" || Array.isArray(phaseRaw)) continue;
|
|
@@ -297,7 +306,8 @@ export function repairAdaptivePlan(text: string, allowedRoles: string[]): { plan
|
|
|
297
306
|
if (!Array.isArray(phaseObj.tasks)) continue;
|
|
298
307
|
const tasks: AdaptivePlanTask[] = [];
|
|
299
308
|
for (const taskRaw of phaseObj.tasks) {
|
|
300
|
-
if (
|
|
309
|
+
if (tasks.length >= ADAPTIVE_MAX_TASKS_PER_PHASE) {
|
|
310
|
+
// Per-phase cap (ADR-4 §5): truncate THIS phase, keep later phases.
|
|
301
311
|
repaired = true;
|
|
302
312
|
break;
|
|
303
313
|
}
|
|
@@ -321,14 +331,12 @@ export function repairAdaptivePlan(text: string, allowedRoles: string[]): { plan
|
|
|
321
331
|
title: typeof taskObj.title === "string" ? taskObj.title : undefined,
|
|
322
332
|
task: taskText,
|
|
323
333
|
});
|
|
324
|
-
total++;
|
|
325
334
|
}
|
|
326
335
|
if (tasks.length)
|
|
327
336
|
phases.push({
|
|
328
337
|
name: typeof phaseObj.name === "string" && phaseObj.name.trim() ? phaseObj.name.trim() : `phase-${phaseIndex + 1}`,
|
|
329
338
|
tasks,
|
|
330
339
|
});
|
|
331
|
-
if (total >= MAX_ADAPTIVE_TASKS) break;
|
|
332
340
|
}
|
|
333
341
|
return phases.length
|
|
334
342
|
? {
|
|
@@ -409,6 +417,9 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
|
|
|
409
417
|
};
|
|
410
418
|
}
|
|
411
419
|
const assessTask = completedAssess;
|
|
420
|
+
// Review R5: manifest variant tracked through the repair path — the plan
|
|
421
|
+
// save below must not clobber the repair artifact descriptor.
|
|
422
|
+
let manifestBase = input.manifest;
|
|
412
423
|
const resultPath = completedAssess.resultArtifact.path;
|
|
413
424
|
let text = "";
|
|
414
425
|
try {
|
|
@@ -431,7 +442,7 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
|
|
|
431
442
|
let plan = parseAdaptivePlan(text, allowedRoles);
|
|
432
443
|
if (!plan) {
|
|
433
444
|
const repair =
|
|
434
|
-
|
|
445
|
+
getCrewEnv("PI_CREW_ADAPTIVE_REPAIR") === "0" || getCrewEnv("PI_TEAMS_ADAPTIVE_REPAIR") === "0"
|
|
435
446
|
? { repaired: false, reason: "disabled" }
|
|
436
447
|
: repairAdaptivePlan(text, allowedRoles);
|
|
437
448
|
if (repair.plan) {
|
|
@@ -442,11 +453,12 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
|
|
|
442
453
|
producer: assessTask.id,
|
|
443
454
|
content: `${JSON.stringify({ reason: repair.reason, phases: repair.plan.phases.map((phase) => ({ name: phase.name, count: phase.tasks.length, roles: phase.tasks.map((task) => task.role) })) }, null, 2)}\n`,
|
|
444
455
|
});
|
|
445
|
-
|
|
456
|
+
manifestBase = {
|
|
446
457
|
...input.manifest,
|
|
447
458
|
updatedAt: new Date().toISOString(),
|
|
448
459
|
artifacts: [...input.manifest.artifacts, repairArtifact],
|
|
449
|
-
}
|
|
460
|
+
};
|
|
461
|
+
await saveRunManifestAsync(manifestBase);
|
|
450
462
|
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
451
463
|
type: "adaptive.plan_repaired",
|
|
452
464
|
runId: input.manifest.runId,
|
|
@@ -478,14 +490,30 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
|
|
|
478
490
|
}
|
|
479
491
|
const steps: WorkflowStep[] = [];
|
|
480
492
|
const tasks: TeamTaskState[] = [];
|
|
493
|
+
// T2/R4 (ADR-4 §6 producer 2): item ids are stable across revisions —
|
|
494
|
+
// phase/task POSITION based, independent of role renames.
|
|
495
|
+
const planPhases: PlanRecord["phases"] = [];
|
|
496
|
+
const planItems: PlanRecord["items"] = [];
|
|
481
497
|
let previousStepIds = ["assess"];
|
|
482
498
|
let counter = 0;
|
|
483
499
|
for (const [phaseIndex, phase] of plan.phases.entries()) {
|
|
484
500
|
const currentStepIds: string[] = [];
|
|
501
|
+
const phaseItemIds: string[] = [];
|
|
485
502
|
for (const [taskIndex, planned] of phase.tasks.entries()) {
|
|
486
503
|
counter++;
|
|
504
|
+
const itemId = `adaptive-p${phaseIndex + 1}-t${taskIndex + 1}`;
|
|
487
505
|
const stepId = `adaptive-${phaseIndex + 1}-${taskIndex + 1}-${slug(planned.role)}`;
|
|
488
506
|
const taskId = `adaptive-${String(counter).padStart(2, "0")}-${slug(planned.role)}`;
|
|
507
|
+
phaseItemIds.push(itemId);
|
|
508
|
+
planItems.push({
|
|
509
|
+
id: itemId,
|
|
510
|
+
ref: stepId,
|
|
511
|
+
title: planned.title ?? planned.task.slice(0, 80),
|
|
512
|
+
taskIds: [],
|
|
513
|
+
specIds: [],
|
|
514
|
+
acceptance: [],
|
|
515
|
+
status: "pending",
|
|
516
|
+
});
|
|
489
517
|
steps.push({
|
|
490
518
|
id: stepId,
|
|
491
519
|
role: planned.role,
|
|
@@ -504,6 +532,7 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
|
|
|
504
532
|
dependsOn: previousStepIds,
|
|
505
533
|
cwd: input.manifest.cwd,
|
|
506
534
|
adaptive: { phase: phase.name, task: planned.task },
|
|
535
|
+
planItem: itemId,
|
|
507
536
|
graph: {
|
|
508
537
|
taskId,
|
|
509
538
|
dependencies: previousStepIds,
|
|
@@ -513,6 +542,12 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
|
|
|
513
542
|
});
|
|
514
543
|
currentStepIds.push(stepId);
|
|
515
544
|
}
|
|
545
|
+
planPhases.push({
|
|
546
|
+
id: `adaptive-phase-${phaseIndex + 1}`,
|
|
547
|
+
title: phase.name,
|
|
548
|
+
itemIds: phaseItemIds,
|
|
549
|
+
status: "pending",
|
|
550
|
+
});
|
|
516
551
|
previousStepIds = currentStepIds;
|
|
517
552
|
}
|
|
518
553
|
const dependencyTaskIdByStep = new Map<string, string>([
|
|
@@ -531,6 +566,45 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
|
|
|
531
566
|
: task.graph,
|
|
532
567
|
}));
|
|
533
568
|
const allTasks = refreshTaskGraphQueues([...input.tasks, ...withGraph]);
|
|
569
|
+
// T2/R4 (ADR-4 §2/§6): persist the PlanRecord + manifest pointer (dual-write;
|
|
570
|
+
// crash between the two is benign — getCurrentPlanRecord falls back to the
|
|
571
|
+
// highest version). Producers never set taskIds (single-writer rule §3).
|
|
572
|
+
const planRecord: PlanRecord = {
|
|
573
|
+
id: randomUUID(),
|
|
574
|
+
runId: input.manifest.runId,
|
|
575
|
+
version: 1,
|
|
576
|
+
title: `Adaptive plan (${plan.phases.length} phase(s))`,
|
|
577
|
+
phases: planPhases,
|
|
578
|
+
items: planItems,
|
|
579
|
+
createdAt: new Date().toISOString(),
|
|
580
|
+
authorTaskId: assessTask.id,
|
|
581
|
+
};
|
|
582
|
+
// Review R1 (P1, crash-window): if a record ALREADY exists (the append
|
|
583
|
+
// survived a kill between the plans.json write and the tasks save below),
|
|
584
|
+
// REUSE it instead of re-appending — the fresh randomUUID would throw
|
|
585
|
+
// lineage-break on resume and permanently brick the run. Task/item ids are
|
|
586
|
+
// position-deterministic from the SAME parsed artifact, so the rebuilt
|
|
587
|
+
// linkage matches the surviving record exactly.
|
|
588
|
+
const existingRecord = getCurrentPlanRecord(input.manifest);
|
|
589
|
+
if (!existingRecord) appendPlanRevision(input.manifest, planRecord);
|
|
590
|
+
// Review round-2 N1: on the crash-window reuse path the pointer MUST name
|
|
591
|
+
// the SURVIVING record — the fresh planRecord was never persisted, so its
|
|
592
|
+
// randomUUID would dangle (masked today by the highest-version fallback,
|
|
593
|
+
// but it violates ADR-4 §2's pointer invariant).
|
|
594
|
+
const planPointer = existingRecord
|
|
595
|
+
? { id: existingRecord.id, version: existingRecord.version }
|
|
596
|
+
: { id: planRecord.id, version: planRecord.version };
|
|
597
|
+
await saveRunManifestAsync({
|
|
598
|
+
...manifestBase,
|
|
599
|
+
updatedAt: new Date().toISOString(),
|
|
600
|
+
plan: planPointer,
|
|
601
|
+
});
|
|
602
|
+
// FIX (T2/B2 case e, pre-existing): the injected tasks MUST be persisted at
|
|
603
|
+
// injection time. mergeUnitResult reloads tasks.json as the merge base and
|
|
604
|
+
// mergeTaskUpdatesPreservingTerminal DROPS updates for ids absent from the
|
|
605
|
+
// base — an injection that only lived in memory had its completed adaptive
|
|
606
|
+
// tasks silently erased from disk at the first merge.
|
|
607
|
+
await saveRunTasksAsync(manifestBase, allTasks);
|
|
534
608
|
await appendEventAsync(input.manifest.eventsPath, {
|
|
535
609
|
type: "adaptive.plan_injected",
|
|
536
610
|
runId: input.manifest.runId,
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { readFileSync } from "node:fs";
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { transformSync } from "esbuild";
|
|
23
|
+
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
23
24
|
import { appendEvent } from "../../state/event-log/event-log.ts";
|
|
24
25
|
import { writeArtifact } from "../../state/stores/artifact-store.ts";
|
|
25
26
|
import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
@@ -151,7 +152,7 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
|
|
|
151
152
|
// require, fs, child_process). Cloning a hostile repo and running its workflow = arbitrary
|
|
152
153
|
// code execution. Default-deny project workflows unless the user explicitly opts in via
|
|
153
154
|
// PI_CREW_TRUST_PROJECT_DWF=1. Builtin and user workflows proceed without restriction.
|
|
154
|
-
if (workflow.source === "project" &&
|
|
155
|
+
if (workflow.source === "project" && getCrewEnv("PI_CREW_TRUST_PROJECT_DWF") !== "1") {
|
|
155
156
|
appendEvent(eventsPath, {
|
|
156
157
|
type: "dwf.trust_denied",
|
|
157
158
|
runId: manifest.runId,
|
|
@@ -223,7 +224,7 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
|
|
|
223
224
|
// it does NOT kill the script. Promise.race with a hard timeout at least returns an
|
|
224
225
|
// error so the runner doesn't hang. The spawned child process is leaked, but the
|
|
225
226
|
// dynamic-workflow returns failure promptly. (v1.5: use Worker threads to actually kill.)
|
|
226
|
-
const SCRIPT_TIMEOUT_MS = Number.parseInt(
|
|
227
|
+
const SCRIPT_TIMEOUT_MS = Number.parseInt(getCrewEnv("PI_CREW_DWF_SCRIPT_TIMEOUT_MS") ?? "", 10) || 1_800_000; // 30 min default (was 10 min; raised for distill-dwf + similar long pipelines)
|
|
227
228
|
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
228
229
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
229
230
|
timeoutHandle = setTimeout(() => {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* goal loop owns OUTER state) + §0c C10 (hardening: assertSafePathId + UUID goalId).
|
|
7
7
|
*
|
|
8
8
|
* Stores GoalLoopState as atomic JSON at <crewRoot>/state/goals/<goalId>.json.
|
|
9
|
-
* Modeled on
|
|
9
|
+
* Modeled on the (removed in Phase 1.7) ScheduleStore coordination pattern but with atomicWriteJson +
|
|
10
10
|
* path-traversal defense (assertSafePathId on every public method).
|
|
11
11
|
*
|
|
12
12
|
* Per §0c C2: budget lives here (budgetUsed accumulates collectRunMetrics across turns);
|
|
@@ -3,7 +3,7 @@ import { appendMailboxMessage, findMailboxMessageByRequestId, readDeliveryState
|
|
|
3
3
|
import { appendEvent } from "../state/event-log/event-log.ts";
|
|
4
4
|
import { writeArtifact } from "../state/stores/artifact-store.ts";
|
|
5
5
|
import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
6
|
-
import { aggregateTaskOutputs } from "./task-output-context.ts";
|
|
6
|
+
import { aggregateTaskOutputs, type ResultArtifactReadCache } from "./task-output-context.ts";
|
|
7
7
|
|
|
8
8
|
export type CrewGroupJoinMode = "off" | "group" | "smart";
|
|
9
9
|
|
|
@@ -51,6 +51,11 @@ export function deliverGroupJoin(input: {
|
|
|
51
51
|
batch: TeamTaskState[];
|
|
52
52
|
allTasks: TeamTaskState[];
|
|
53
53
|
partial?: boolean;
|
|
54
|
+
/** R10-1: optional per-run result-artifact read cache. The closeout
|
|
55
|
+
* aggregates the same settled batch for the batch-summary artifact
|
|
56
|
+
* immediately before this call — passing that cache here turns the
|
|
57
|
+
* group-join aggregation into cache hits (byte-identical output). */
|
|
58
|
+
cache?: ResultArtifactReadCache;
|
|
54
59
|
}): CrewGroupJoinDelivery | undefined {
|
|
55
60
|
if (!shouldGroupJoin(input.mode, input.batch)) return undefined;
|
|
56
61
|
const taskIds = input.batch.map((task) => task.id);
|
|
@@ -63,7 +68,7 @@ export function deliverGroupJoin(input: {
|
|
|
63
68
|
const remaining = latest.filter((task) => task.status === "queued" || task.status === "running").map((task) => task.id);
|
|
64
69
|
const partial = input.partial ?? remaining.length > 0;
|
|
65
70
|
const batchId = batchIdFor(input.manifest.runId, taskIds);
|
|
66
|
-
const summary = aggregateTaskOutputs(latest, input.manifest);
|
|
71
|
+
const summary = aggregateTaskOutputs(latest, input.manifest, input.cache);
|
|
67
72
|
const requestId = requestIdFor(input.manifest.runId, batchId, partial);
|
|
68
73
|
const existingMailbox = findMailboxMessageByRequestId(input.manifest, requestId);
|
|
69
74
|
const existingStatus = existingMailbox
|
|
@@ -141,127 +146,8 @@ export function deliverGroupJoin(input: {
|
|
|
141
146
|
return { ...delivery, artifact, messageId: mailbox.id };
|
|
142
147
|
}
|
|
143
148
|
|
|
144
|
-
|
|
145
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Persisted settings vocabulary for join mode (imported by settings-store.ts
|
|
151
|
+
* for `defaultJoinMode` validation and config application).
|
|
152
|
+
*/
|
|
146
153
|
export type JoinMode = "async" | "group" | "smart";
|
|
147
|
-
export type DeliveryCallback = (records: CrewAgentRecord[], partial: boolean) => void;
|
|
148
|
-
|
|
149
|
-
interface AgentGroup {
|
|
150
|
-
groupId: string;
|
|
151
|
-
agentIds: Set<string>;
|
|
152
|
-
completedRecords: Map<string, CrewAgentRecord>;
|
|
153
|
-
timeoutHandle?: ReturnType<typeof setTimeout>;
|
|
154
|
-
delivered: boolean;
|
|
155
|
-
isStraggler: boolean;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const DEFAULT_TIMEOUT = 30_000;
|
|
159
|
-
const STRAGGLER_TIMEOUT = 15_000;
|
|
160
|
-
|
|
161
|
-
export class GroupJoinManager {
|
|
162
|
-
private groups = new Map<string, AgentGroup>();
|
|
163
|
-
private agentToGroup = new Map<string, string>();
|
|
164
|
-
|
|
165
|
-
private deliverCb: DeliveryCallback;
|
|
166
|
-
private groupTimeout: number;
|
|
167
|
-
|
|
168
|
-
constructor(deliverCb: DeliveryCallback, groupTimeout = DEFAULT_TIMEOUT) {
|
|
169
|
-
this.deliverCb = deliverCb;
|
|
170
|
-
this.groupTimeout = groupTimeout;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
registerGroup(groupId: string, agentIds: string[]): void {
|
|
174
|
-
const group: AgentGroup = {
|
|
175
|
-
groupId,
|
|
176
|
-
agentIds: new Set(agentIds),
|
|
177
|
-
completedRecords: new Map(),
|
|
178
|
-
delivered: false,
|
|
179
|
-
isStraggler: false,
|
|
180
|
-
};
|
|
181
|
-
this.groups.set(groupId, group);
|
|
182
|
-
for (const id of agentIds) {
|
|
183
|
-
this.agentToGroup.set(id, groupId);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
onAgentComplete(record: CrewAgentRecord): "delivered" | "held" | "pass" {
|
|
188
|
-
const groupId = this.agentToGroup.get(record.taskId);
|
|
189
|
-
if (!groupId) return "pass";
|
|
190
|
-
|
|
191
|
-
const group = this.groups.get(groupId);
|
|
192
|
-
if (!group || group.delivered) return "pass";
|
|
193
|
-
|
|
194
|
-
group.completedRecords.set(record.taskId, record);
|
|
195
|
-
|
|
196
|
-
if (group.completedRecords.size >= group.agentIds.size) {
|
|
197
|
-
this.deliver(group, false);
|
|
198
|
-
return "delivered";
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (!group.timeoutHandle) {
|
|
202
|
-
const timeout = group.isStraggler ? STRAGGLER_TIMEOUT : this.groupTimeout;
|
|
203
|
-
group.timeoutHandle = setTimeout(() => {
|
|
204
|
-
this.onTimeout(group);
|
|
205
|
-
}, timeout);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
return "held";
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
private onTimeout(group: AgentGroup): void {
|
|
212
|
-
if (group.delivered) return;
|
|
213
|
-
group.timeoutHandle = undefined;
|
|
214
|
-
|
|
215
|
-
const remaining = new Set<string>();
|
|
216
|
-
for (const id of group.agentIds) {
|
|
217
|
-
if (!group.completedRecords.has(id)) remaining.add(id);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
for (const id of group.completedRecords.keys()) {
|
|
221
|
-
this.agentToGroup.delete(id);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
this.deliverCb([...group.completedRecords.values()], true);
|
|
225
|
-
|
|
226
|
-
group.completedRecords.clear();
|
|
227
|
-
group.agentIds = remaining;
|
|
228
|
-
group.isStraggler = true;
|
|
229
|
-
|
|
230
|
-
// Re-arm timer for remaining stragglers so they aren't silently abandoned
|
|
231
|
-
if (remaining.size > 0) {
|
|
232
|
-
group.timeoutHandle = setTimeout(() => this.onTimeout(group), STRAGGLER_TIMEOUT);
|
|
233
|
-
group.timeoutHandle.unref();
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
private deliver(group: AgentGroup, partial: boolean): void {
|
|
238
|
-
if (group.timeoutHandle) {
|
|
239
|
-
clearTimeout(group.timeoutHandle);
|
|
240
|
-
group.timeoutHandle = undefined;
|
|
241
|
-
}
|
|
242
|
-
group.delivered = true;
|
|
243
|
-
this.deliverCb([...group.completedRecords.values()], partial);
|
|
244
|
-
this.cleanupGroup(group.groupId);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
private cleanupGroup(groupId: string): void {
|
|
248
|
-
const group = this.groups.get(groupId);
|
|
249
|
-
if (!group) return;
|
|
250
|
-
for (const id of group.agentIds) {
|
|
251
|
-
this.agentToGroup.delete(id);
|
|
252
|
-
}
|
|
253
|
-
this.groups.delete(groupId);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
isGrouped(agentId: string): boolean {
|
|
257
|
-
return this.agentToGroup.has(agentId);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
dispose(): void {
|
|
261
|
-
for (const group of this.groups.values()) {
|
|
262
|
-
if (group.timeoutHandle) clearTimeout(group.timeoutHandle);
|
|
263
|
-
}
|
|
264
|
-
this.groups.clear();
|
|
265
|
-
this.agentToGroup.clear();
|
|
266
|
-
}
|
|
267
|
-
}
|
|
@@ -5,6 +5,7 @@ import { resolveToolPolicy } from "../../agents/agent-config.ts";
|
|
|
5
5
|
import type { CrewRuntimeConfig } from "../../config/config.ts";
|
|
6
6
|
import { loadConfig } from "../../config/config.ts";
|
|
7
7
|
import { DEFAULT_LIVE_SESSION } from "../../config/defaults.ts";
|
|
8
|
+
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
8
9
|
import { appendEvent, appendEventFireAndForget } from "../../state/event-log/event-log.ts";
|
|
9
10
|
import type { TeamRunManifest, TeamTaskState, UsageState } from "../../state/types.ts";
|
|
10
11
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
@@ -81,6 +82,13 @@ import { collectLiveSessionHealth, formatLiveSessionDiagnostics } from "./live-s
|
|
|
81
82
|
* explorer subagents launched together; 3 of 4 crashed.)
|
|
82
83
|
*/
|
|
83
84
|
let liveSessionModulePromise: Promise<LiveSessionModule> | undefined;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Phase 4 (ADR 2026-08-15-runtime-convergence, decision (a) — freeze):
|
|
88
|
+
* warn-once flag so the experimental-path notice fires at most once per
|
|
89
|
+
* process, not per task.
|
|
90
|
+
*/
|
|
91
|
+
let experimentalWarned = false;
|
|
84
92
|
function loadLiveSessionModule(): Promise<LiveSessionModule> {
|
|
85
93
|
if (!liveSessionModulePromise) {
|
|
86
94
|
liveSessionModulePromise = import("@earendil-works/pi-coding-agent") as unknown as Promise<LiveSessionModule>;
|
|
@@ -549,13 +557,30 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
549
557
|
// any module. Under tsx, concurrent first-imports race module-record
|
|
550
558
|
// instantiation; awaiting the registration-time warmup eliminates the window.
|
|
551
559
|
await awaitRuntimeWarmup();
|
|
560
|
+
|
|
561
|
+
// Phase 4 (ADR 2026-08-15-runtime-convergence, decision (a) — freeze):
|
|
562
|
+
// the live-session path is permanently EXPERIMENTAL and intentionally
|
|
563
|
+
// diverges from the supported child-process path (no worker-cap semaphore,
|
|
564
|
+
// no depth guard, permissive SDK tool surface, single-model-fallback
|
|
565
|
+
// report). Warn once per process so operators see the notice without
|
|
566
|
+
// per-task spam.
|
|
567
|
+
if (!experimentalWarned) {
|
|
568
|
+
experimentalWarned = true;
|
|
569
|
+
logInternalError(
|
|
570
|
+
"live-session.experimental",
|
|
571
|
+
new Error("experimental path"),
|
|
572
|
+
"runtime.mode=live-session is EXPERIMENTAL and frozen (decision (a)); it diverges from child-process semantics (no worker-cap/depth guard, permissive tools) — see docs/decisions/2026-08-15-runtime-convergence.md",
|
|
573
|
+
"warn",
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
552
577
|
const isCurrent = input.isCurrent ?? (() => true);
|
|
553
578
|
let streamOut: StreamingOutputHandle | undefined;
|
|
554
579
|
|
|
555
580
|
// G1: Capture yield result from custom tool callback
|
|
556
581
|
let customToolYieldResult: YieldResult | undefined;
|
|
557
582
|
let customToolYieldResolved = false;
|
|
558
|
-
if (
|
|
583
|
+
if (getCrewEnv("PI_CREW_MOCK_LIVE_SESSION") === "success") {
|
|
559
584
|
const agentId = `${input.manifest.runId}:${input.task.id}`;
|
|
560
585
|
const inherited =
|
|
561
586
|
input.runtimeConfig?.inheritContext === true && input.parentContext ? ` with inherited context: ${input.parentContext}` : "";
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { TEAM_TASK_STATUSES, TEAM_TERMINAL_TASK_STATUSES, type TeamTaskStatus } from "../state/contracts.ts";
|
|
15
15
|
import type { TeamTaskState } from "../state/types.ts";
|
|
16
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
16
17
|
import { refreshTaskGraphQueues } from "./scheduling/task-graph-scheduler.ts";
|
|
17
18
|
|
|
18
19
|
export function isNonTerminalTaskStatus(status: TeamTaskState["status"]): boolean {
|
|
@@ -123,7 +124,12 @@ export function shouldMergeTaskUpdate(current: TeamTaskState, updated: TeamTaskS
|
|
|
123
124
|
// Malformed finishedAt (NaN) is treated as Infinity — invalid state should be
|
|
124
125
|
// replaced rather than persisting corruption. Log warning for visibility.
|
|
125
126
|
if (!Number.isFinite(currentTime)) {
|
|
126
|
-
|
|
127
|
+
logInternalError(
|
|
128
|
+
"merge-gate",
|
|
129
|
+
new Error(`Task ${current.id} has malformed finishedAt: ${current.finishedAt}`),
|
|
130
|
+
undefined,
|
|
131
|
+
"warn",
|
|
132
|
+
);
|
|
127
133
|
}
|
|
128
134
|
if (isMalformedFinishedAtReplacement(currentTime, updatedTime)) {
|
|
129
135
|
return true;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge of settled dispatch units into the run state for the team-run
|
|
3
|
+
* scheduler loop.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from team-runner.ts (2026-08, Phase 2.6 maintainability split —
|
|
6
|
+
* CORE-4 extraction 5). Pure code motion: isRunTerminalPreserved +
|
|
7
|
+
* mergeUnitResult moved verbatim, including the RT-12 / NEW-D1 / R15-2 /
|
|
8
|
+
* CANCEL-1 comments.
|
|
9
|
+
*
|
|
10
|
+
* `isRunTerminalPreserved` lives here (not in finalize-run.ts) because it is
|
|
11
|
+
* shared by mergeUnitResult, finalizeRun (finalize-run.ts imports it from
|
|
12
|
+
* here) and the core loop in team-runner.ts (which imports it from here) —
|
|
13
|
+
* keeping it here avoids a merge-loop ↔ finalize-run import cycle.
|
|
14
|
+
*/
|
|
15
|
+
import { flushPendingAtomicWrites } from "../state/atomic-write.ts";
|
|
16
|
+
import { withRunLock } from "../state/coordination/locks.ts";
|
|
17
|
+
import { loadRunManifestById, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
|
|
18
|
+
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
19
|
+
import { classifyFatalFsError } from "../utils/fs-errno.ts";
|
|
20
|
+
import { cancelNonTerminalTasks } from "./dispatch-batch.ts";
|
|
21
|
+
import { mergeTaskUpdatesPreservingTerminal } from "./merge-gate.ts";
|
|
22
|
+
import type { SchedulerContext, SchedulerDecision } from "./scheduler-context.ts";
|
|
23
|
+
import { mergeArtifacts } from "./team-runner-artifacts.ts";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* CORE-4 extraction 5: wait for one in-flight dispatch unit to settle and
|
|
27
|
+
* merge its result into the run state.
|
|
28
|
+
*
|
|
29
|
+
* Awaits Promise.race on ctx.pendingUnits; the first settled unit is merged
|
|
30
|
+
* into ctx.manifest/tasks under the run lock (flushPendingAtomicWrites +
|
|
31
|
+
* loadRunManifestById + mergeTaskUpdatesPreservingTerminal + save). The settled
|
|
32
|
+
* unit is then deleted from ctx.pendingUnits, and the merge outcome (taskIds
|
|
33
|
+
* + result object) is recorded on ctx.settledMerge for the post-merge inline
|
|
34
|
+
* logic (cancel-during-exec check + batch summary).
|
|
35
|
+
*
|
|
36
|
+
* Returns null to continue to the phase/budget check. A `{ kind: "return" }`
|
|
37
|
+
* decision is reserved for future run-complete/failure detection during merge.
|
|
38
|
+
*
|
|
39
|
+
* Reads ctx.pendingUnits/manifest/tasks. Mutates ctx.pendingUnits (delete),
|
|
40
|
+
* ctx.manifest/tasks, ctx.settledMerge.
|
|
41
|
+
*
|
|
42
|
+
* @param ctx The scheduler context.
|
|
43
|
+
*/
|
|
44
|
+
/** R15-2/R15-1: run statuses that must never be overwritten by an in-memory
|
|
45
|
+
* derived status (mergeUnitResult force-"running", finalizeRun "completed").
|
|
46
|
+
* "blocked" is deliberately EXCLUDED — a blocked run can be unblocked and the
|
|
47
|
+
* finalize chain may legitimately derive blocked from in-memory state. */
|
|
48
|
+
export function isRunTerminalPreserved(status: TeamRunManifest["status"]): boolean {
|
|
49
|
+
return status === "cancelled" || status === "failed" || status === "completed";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function mergeUnitResult(ctx: SchedulerContext): Promise<SchedulerDecision | null> {
|
|
53
|
+
// RT-12: race on pre-created wrapper promises (created once at dispatch
|
|
54
|
+
// time) instead of rebuilding a wrapper-promise array with new async
|
|
55
|
+
// closures every iteration. This reduces allocation from O(C×T) wrapper
|
|
56
|
+
// promises to O(C) total (one per unit, created once at dispatch).
|
|
57
|
+
const settled = await Promise.race([...ctx.pendingUnits.values()].map((u) => u.wrapped));
|
|
58
|
+
const completedUnit = ctx.pendingUnits.get(settled.unitKey)!;
|
|
59
|
+
ctx.pendingUnits.delete(settled.unitKey);
|
|
60
|
+
|
|
61
|
+
// Build the single result to merge. On rejection, synthesize a failed
|
|
62
|
+
// result so the run continues (mirrors the old validResults guard).
|
|
63
|
+
// bug-026 sub-issue B: a rejected unit means runTeamTask itself threw —
|
|
64
|
+
// typically an atomicWriteFile/persistSingleTaskUpdate ENOSPC mid-run.
|
|
65
|
+
// Classify the errno and stamp failureCause on the synthesized failed
|
|
66
|
+
// tasks so the operator sees "failed (disk full)", not a generic write
|
|
67
|
+
// error string.
|
|
68
|
+
const thrownFailureCause = settled.result ? undefined : classifyFatalFsError(settled.error);
|
|
69
|
+
const resultToMerge: { manifest: TeamRunManifest; tasks: TeamTaskState[] } = settled.result ?? {
|
|
70
|
+
manifest: ctx.manifest,
|
|
71
|
+
tasks: cancelNonTerminalTasks(ctx.tasks, "failed", settled.error!.message, (t) => completedUnit.taskIds.includes(t.id)).map((t) =>
|
|
72
|
+
thrownFailureCause && t.status === "failed" ? { ...t, failureCause: thrownFailureCause } : t,
|
|
73
|
+
),
|
|
74
|
+
};
|
|
75
|
+
const validResults = [resultToMerge];
|
|
76
|
+
// Reconstruct manifest from the last worker's snapshot. The .artifacts field
|
|
77
|
+
// is re-merged from both the team-runner's in-memory state and all workers'
|
|
78
|
+
// snapshots, so artifact writes by task-runner (which individually save manifest
|
|
79
|
+
// after writing artifacts) are safely persisted. The in-memory manifest is only
|
|
80
|
+
// used for the next batch iteration's orchestration — actual persistence is safe.
|
|
81
|
+
// Use updateRunStatus to recompute manifest status from merged tasks rather than
|
|
82
|
+
// relying on the last result's manifest (which is arbitrary due to mapConcurrent
|
|
83
|
+
// returning results in arbitrary order).
|
|
84
|
+
// Use the in-memory manifest as base (not the last-completing worker's snapshot).
|
|
85
|
+
// Recompute status from merged tasks so the manifest reflects actual task state,
|
|
86
|
+
// not the arbitrary order in which mapConcurrent returned results.
|
|
87
|
+
// Read committed manifest from disk inside the lock so artifact merge is based
|
|
88
|
+
// on committed state, not in-memory state that may differ from disk.
|
|
89
|
+
const mergeResult = await withRunLock(ctx.manifest, async () => {
|
|
90
|
+
// NEW-D1: flush any pending coalesced atomic writes before reading from
|
|
91
|
+
// disk. Without this, a worker's async manifest save (coalesced by
|
|
92
|
+
// atomic-write) may not be committed yet, causing a lost-update on the
|
|
93
|
+
// merge read. flushPendingAtomicWrites forces all queued writes to disk.
|
|
94
|
+
flushPendingAtomicWrites();
|
|
95
|
+
const disk = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
|
|
96
|
+
const diskManifest = disk?.manifest ?? ctx.manifest;
|
|
97
|
+
const diskArtifacts = diskManifest.artifacts;
|
|
98
|
+
const reconciledArtifacts = mergeArtifacts([...diskArtifacts, ...validResults.map((item) => item.manifest.artifacts)].flat());
|
|
99
|
+
// R15-2: only force "running" when the disk manifest is NON-terminal
|
|
100
|
+
// (queued/planning/running/blocked). If the disk status is already terminal
|
|
101
|
+
// (cancelled/failed/completed — an external cancel or reconciler write
|
|
102
|
+
// landing during the batch), PRESERVE that terminal status: forcing
|
|
103
|
+
// "running" would legally erase it (contracts.ts allows cancelled/failed/
|
|
104
|
+
// completed → running) and the loop would never observe the disk-terminal
|
|
105
|
+
// (CANCEL-1/CANCEL-2 only catch worker-reported cancel or signal abort).
|
|
106
|
+
const mergedBase = { ...diskManifest, artifacts: reconciledArtifacts };
|
|
107
|
+
const resultManifest = isRunTerminalPreserved(diskManifest.status)
|
|
108
|
+
? mergedBase
|
|
109
|
+
: updateRunStatus(mergedBase, "running", "Merged task updates from parallel batch.");
|
|
110
|
+
// CANCEL-1: use the freshly-loaded disk tasks as the merge base instead
|
|
111
|
+
// of the in-memory `tasks` closure variable. The in-memory tasks reflect
|
|
112
|
+
// only team-runner's view; an external cancel (handleCancel, background
|
|
113
|
+
// race with SIGTERM arriving after cancel wrote but before merge ran)
|
|
114
|
+
// writes 'cancelled' to disk.tasks — using disk.tasks as base preserves
|
|
115
|
+
// that cancellation through the merge instead of overwriting it with the
|
|
116
|
+
// stale in-memory view. disk was loaded inside this lock, so it reflects
|
|
117
|
+
// the freshest committed state.
|
|
118
|
+
const resultTasks = mergeTaskUpdatesPreservingTerminal(disk?.tasks ?? ctx.tasks, validResults);
|
|
119
|
+
await saveRunManifestAsync(resultManifest);
|
|
120
|
+
await saveRunTasksAsync(resultManifest, resultTasks);
|
|
121
|
+
return { resultManifest, resultTasks };
|
|
122
|
+
});
|
|
123
|
+
ctx.manifest = mergeResult.resultManifest;
|
|
124
|
+
ctx.tasks = mergeResult.resultTasks;
|
|
125
|
+
ctx.settledMerge = { taskIds: completedUnit.taskIds, result: resultToMerge };
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @internal 1.9(b) test export — exercise mergeUnitResult directly. */
|
|
130
|
+
export const __test__mergeUnitResult = mergeUnitResult;
|