pi-crew 0.10.3 → 0.10.4
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/AGENTS.md +2 -1
- package/README.md +5 -1
- package/dist/index.mjs +10685 -6882
- package/docs/architecture.md +4 -4
- package/docs/commands-reference.md +3 -0
- package/docs/publishing.md +15 -3
- package/install.mjs +90 -39
- package/package.json +8 -3
- package/scripts/README.md +4 -3
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +1 -0
- package/skills/real-test-pi-crew/SKILL.md +153 -6
- package/src/config/migration-validator.ts +113 -0
- package/src/extension/cross-extension-rpc.ts +3 -7
- package/src/extension/register.ts +13 -0
- package/src/extension/registration/observability.ts +3 -7
- package/src/extension/registration/subagent-tools.ts +3 -7
- package/src/extension/registration/team-tool.ts +3 -7
- package/src/extension/registration/ui.ts +3 -8
- package/src/extension/registration/viewers.ts +3 -10
- package/src/extension/team-manager-command.ts +3 -7
- package/src/extension/team-tool/api/agent-control.ts +17 -10
- package/src/extension/team-tool/api/heartbeat.ts +4 -3
- package/src/extension/team-tool/api/mailbox.ts +33 -20
- package/src/extension/team-tool/api/plan-approval.ts +5 -5
- package/src/extension/team-tool/api/task-claims.ts +8 -7
- package/src/extension/team-tool/cancel.ts +6 -0
- package/src/extension/team-tool/handle-settings.ts +4 -1
- package/src/extension/team-tool/run.ts +3 -7
- package/src/extension/team-tool/status.ts +5 -0
- package/src/extension/team-tool.ts +6 -14
- package/src/hooks/registry.ts +3 -0
- package/src/prompt/scratchpad-lifecycle.ts +3 -3
- package/src/runtime/background-runner.ts +30 -35
- package/src/runtime/broker/crew-broker.ts +112 -441
- package/src/runtime/broker/delegate/delegate-event.ts +37 -0
- package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
- package/src/runtime/broker/protocol/connection-state.ts +103 -0
- package/src/runtime/broker/protocol/events-replay.ts +68 -0
- package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
- package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
- package/src/runtime/broker/protocol/request-parsers.ts +175 -0
- package/src/runtime/broker/protocol/wait-auth.ts +46 -0
- package/src/runtime/child-pi/child-pi.ts +15 -0
- package/src/runtime/finalize-run.ts +15 -7
- package/src/runtime/foreground-control.ts +19 -6
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
- package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
- package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
- package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
- package/src/runtime/model/pi-args.ts +6 -1
- package/src/runtime/plan-replan.ts +3 -0
- package/src/runtime/stale-reconciler.ts +28 -3
- package/src/runtime/supervisor-contact.ts +3 -0
- package/src/runtime/task-runner/child-executor.ts +33 -0
- package/src/runtime/team-runner.ts +3 -3
- package/src/state/stores/ownership-map.ts +5 -4
- package/src/state/stores/plan-store.ts +12 -0
- package/src/state/stores/state-store.ts +5 -0
- package/src/ui/powerbar-publisher.ts +3 -7
- package/src/ui/run-action-dispatcher.ts +7 -10
- package/src/ui/settings-overlay.ts +4 -1
|
@@ -149,6 +149,11 @@ export function writeForegroundInterruptRequest(
|
|
|
149
149
|
/* no pid file — fall through to sleep */
|
|
150
150
|
}
|
|
151
151
|
// Brief sleep to avoid CPU spinning
|
|
152
|
+
// WI-7.3 (M7 spec §5): kept sync. Making this async would force
|
|
153
|
+
// requestForegroundControl() + its caller chain async (10+
|
|
154
|
+
// callers in message-tool/lifecycle paths). 10ms spin per
|
|
155
|
+
// iteration is bounded by the 5s timeout above (max ~500 iters
|
|
156
|
+
// = 5s total worst-case block, matching the lock-timeout).
|
|
152
157
|
sleepSync(10);
|
|
153
158
|
}
|
|
154
159
|
}
|
|
@@ -180,12 +185,20 @@ export function writeForegroundInterruptRequest(
|
|
|
180
185
|
};
|
|
181
186
|
fs.mkdirSync(path.dirname(controlPath), { recursive: true });
|
|
182
187
|
atomicWriteFile(controlPath, `${JSON.stringify({ requests: [...requests, request] }, null, 2)}\n`);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
188
|
+
// Read-your-writes (CI 2026-09-11, phase6 integration): for a fresh
|
|
189
|
+
// manifest this may be the FIRST event — buffered append leaves
|
|
190
|
+
// events.jsonl nonexistent at the sync read right after (ENOENT).
|
|
191
|
+
// Sync appendEvent (base behavior, b6eba80f pattern).
|
|
192
|
+
try {
|
|
193
|
+
appendEvent(manifest.eventsPath, {
|
|
194
|
+
type: "foreground.interrupt_requested",
|
|
195
|
+
runId: manifest.runId,
|
|
196
|
+
message: reason,
|
|
197
|
+
data: { requestId: request.id, controlPath },
|
|
198
|
+
});
|
|
199
|
+
} catch (e) {
|
|
200
|
+
logInternalError("foreground_control.append", e, "type=foreground.interrupt_requested");
|
|
201
|
+
}
|
|
189
202
|
return request;
|
|
190
203
|
} finally {
|
|
191
204
|
releaseLock();
|
|
@@ -687,6 +687,10 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
|
|
|
687
687
|
// Idempotency: same phase title → no event, no state change.
|
|
688
688
|
if (title === phaseState.currentPhase) return;
|
|
689
689
|
// Close out the previous open phase BEFORE the new one opens.
|
|
690
|
+
// REVIEW FIX (2026-09-10): reverted M2a buffered conversion — phase
|
|
691
|
+
// transitions are low-frequency AND read back synchronously (tests,
|
|
692
|
+
// checkpoint resume; dwf-setresult rounds 12/14/18 assert the events
|
|
693
|
+
// file immediately after the run).
|
|
690
694
|
if (phaseState.currentPhase !== undefined) {
|
|
691
695
|
appendEvent(manifest.eventsPath, {
|
|
692
696
|
type: "dwf.phase_completed",
|
|
@@ -710,6 +714,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
|
|
|
710
714
|
);
|
|
711
715
|
}
|
|
712
716
|
}
|
|
717
|
+
// REVIEW FIX (2026-09-10): reverted M2a buffered conversion — see phase_completed.
|
|
713
718
|
appendEvent(manifest.eventsPath, {
|
|
714
719
|
type: "dwf.phase_started",
|
|
715
720
|
runId: manifest.runId,
|
|
@@ -724,6 +729,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
|
|
|
724
729
|
if (wfState.logs.length < 1000) {
|
|
725
730
|
wfState.logs.push(text);
|
|
726
731
|
}
|
|
732
|
+
// REVIEW FIX (2026-09-10): reverted M2a buffered conversion — see phase().
|
|
727
733
|
appendEvent(manifest.eventsPath, {
|
|
728
734
|
type: "dwf.log",
|
|
729
735
|
runId: manifest.runId,
|
|
@@ -154,6 +154,9 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
|
|
|
154
154
|
// code execution. Default-deny project workflows unless the user explicitly opts in via
|
|
155
155
|
// PI_CREW_TRUST_PROJECT_DWF=1. Builtin and user workflows proceed without restriction.
|
|
156
156
|
if (workflow.source === "project" && getCrewEnv("PI_CREW_TRUST_PROJECT_DWF") !== "1") {
|
|
157
|
+
// REVIEW FIX (2026-09-10): DWF lifecycle events reverted from M2a
|
|
158
|
+
// buffered conversion — runner sites are read back synchronously
|
|
159
|
+
// (dwf-setresult tests, resume flow, status display after run end).
|
|
157
160
|
appendEvent(eventsPath, {
|
|
158
161
|
type: "dwf.trust_denied",
|
|
159
162
|
runId: manifest.runId,
|
|
@@ -20,7 +20,7 @@ import { createRequire } from "node:module";
|
|
|
20
20
|
import type { AgentConfig } from "../../agents/agent-config.ts";
|
|
21
21
|
import { loadConfig } from "../../config/config.ts";
|
|
22
22
|
import { effectiveRunConfig } from "../../extension/team-tool/config-patch.ts";
|
|
23
|
-
import {
|
|
23
|
+
import { appendEventBuffered } from "../../state/event-log/event-log.ts";
|
|
24
24
|
import { registerActiveRun, unregisterActiveRun } from "../../state/stores/active-run-registry.ts";
|
|
25
25
|
import { collectRunMetrics } from "../../state/stores/run-metrics.ts";
|
|
26
26
|
import { createRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
|
|
@@ -121,7 +121,7 @@ export const realGoalEvaluator = async (
|
|
|
121
121
|
const drift = compareSnapshot(snapshot.snapshot, current);
|
|
122
122
|
if (drift.length > 0) {
|
|
123
123
|
verificationCompromised = drift;
|
|
124
|
-
|
|
124
|
+
appendEventBuffered(turnManifest.eventsPath, {
|
|
125
125
|
type: "goal.verification_compromised",
|
|
126
126
|
runId: turnRunId,
|
|
127
127
|
data: {
|
|
@@ -129,7 +129,7 @@ export const realGoalEvaluator = async (
|
|
|
129
129
|
driftedFiles: drift,
|
|
130
130
|
phase: "T_snap",
|
|
131
131
|
},
|
|
132
|
-
});
|
|
132
|
+
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.verification_compromised phase=T_snap"));
|
|
133
133
|
}
|
|
134
134
|
} catch (error) {
|
|
135
135
|
logInternalError("goal-loop.integritySnap", error, `goalId=${goal.goalId} phase=T_snap`);
|
|
@@ -201,7 +201,7 @@ export const realGoalEvaluator = async (
|
|
|
201
201
|
const postDrift = compareSnapshot(snapshot.snapshot, post);
|
|
202
202
|
if (postDrift.length > 0) {
|
|
203
203
|
verificationCompromised = postDrift;
|
|
204
|
-
|
|
204
|
+
appendEventBuffered(turnManifest.eventsPath, {
|
|
205
205
|
type: "goal.verification_compromised",
|
|
206
206
|
runId: turnRunId,
|
|
207
207
|
data: {
|
|
@@ -209,7 +209,9 @@ export const realGoalEvaluator = async (
|
|
|
209
209
|
driftedFiles: postDrift,
|
|
210
210
|
phase: "T_verify_done",
|
|
211
211
|
},
|
|
212
|
-
})
|
|
212
|
+
}).catch((e) =>
|
|
213
|
+
logInternalError("goal-loop.buffered", e, "type=goal.verification_compromised phase=T_verify_done"),
|
|
214
|
+
);
|
|
213
215
|
}
|
|
214
216
|
} catch (error) {
|
|
215
217
|
logInternalError("goal-loop.integritySnap", error, `goalId=${goal.goalId} phase=T_verify_done`);
|
|
@@ -521,7 +523,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
521
523
|
const workflow = resolveGoalTurnWorkflow(goal);
|
|
522
524
|
const agents = input.deps.discoverAgents(goal.cwd);
|
|
523
525
|
|
|
524
|
-
|
|
526
|
+
appendEventBuffered(eventsPath, {
|
|
525
527
|
type: "goal.loop_start",
|
|
526
528
|
runId: manifest.runId,
|
|
527
529
|
data: {
|
|
@@ -529,7 +531,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
529
531
|
objective: goal.objective,
|
|
530
532
|
maxTurns: goal.maxTurns,
|
|
531
533
|
},
|
|
532
|
-
});
|
|
534
|
+
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.loop_start"));
|
|
533
535
|
|
|
534
536
|
// P1g (RFC v0.5 §P1g, cold-review #2 BLOCKING fix): acquire the workspace lock for the
|
|
535
537
|
// goal's lifetime. This serializes concurrent goals targeting the same cwd (workspaceMode:
|
|
@@ -545,14 +547,14 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
545
547
|
} catch (error) {
|
|
546
548
|
logInternalError("goal-loop.workspaceLock", error, `goalId=${goal.goalId} cwd=${goal.cwd}`);
|
|
547
549
|
goal = safeSetStatus(store, goal.goalId, "blocked", goal, eventsPath);
|
|
548
|
-
|
|
550
|
+
appendEventBuffered(eventsPath, {
|
|
549
551
|
type: "goal.workspace_lock_failed",
|
|
550
552
|
runId: manifest.runId,
|
|
551
553
|
data: {
|
|
552
554
|
goalId: goal.goalId,
|
|
553
555
|
error: error instanceof Error ? error.message : String(error),
|
|
554
556
|
},
|
|
555
|
-
});
|
|
557
|
+
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.workspace_lock_failed"));
|
|
556
558
|
return { manifest, tasks: [], goalState: goal };
|
|
557
559
|
}
|
|
558
560
|
|
|
@@ -570,7 +572,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
570
572
|
if (goal.budgetUnlimited !== true && goal.budgetTotal !== undefined && goal.budgetTotal > 0 && goal.budgetAbort !== undefined) {
|
|
571
573
|
if (goal.budgetUsed >= goal.budgetAbort * goal.budgetTotal) {
|
|
572
574
|
goal = safeSetStatus(store, goal.goalId, "budget_exceeded", goal, eventsPath);
|
|
573
|
-
|
|
575
|
+
appendEventBuffered(eventsPath, {
|
|
574
576
|
type: "goal.budget_warning",
|
|
575
577
|
runId: manifest.runId,
|
|
576
578
|
data: {
|
|
@@ -579,11 +581,11 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
579
581
|
budgetTotal: goal.budgetTotal,
|
|
580
582
|
threshold: "abort",
|
|
581
583
|
},
|
|
582
|
-
});
|
|
584
|
+
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.budget_warning threshold=abort"));
|
|
583
585
|
break;
|
|
584
586
|
}
|
|
585
587
|
if (goal.budgetUsed >= (goal.budgetWarning ?? 0.8) * goal.budgetTotal) {
|
|
586
|
-
|
|
588
|
+
appendEventBuffered(eventsPath, {
|
|
587
589
|
type: "goal.budget_warning",
|
|
588
590
|
runId: manifest.runId,
|
|
589
591
|
data: {
|
|
@@ -592,12 +594,12 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
592
594
|
budgetTotal: goal.budgetTotal,
|
|
593
595
|
threshold: "warning",
|
|
594
596
|
},
|
|
595
|
-
});
|
|
597
|
+
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.budget_warning threshold=warning"));
|
|
596
598
|
}
|
|
597
599
|
}
|
|
598
600
|
|
|
599
601
|
const turnIndex = goal.turnsUsed + 1;
|
|
600
|
-
|
|
602
|
+
appendEventBuffered(eventsPath, {
|
|
601
603
|
type: "goal.turn_start",
|
|
602
604
|
runId: manifest.runId,
|
|
603
605
|
data: {
|
|
@@ -605,7 +607,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
605
607
|
turn: turnIndex,
|
|
606
608
|
maxTurns: goal.maxTurns,
|
|
607
609
|
},
|
|
608
|
-
});
|
|
610
|
+
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.turn_start"));
|
|
609
611
|
|
|
610
612
|
// ── TURN: fresh manifest per turn (G2) + executeTeamRun ──────────────────
|
|
611
613
|
const turnGoalText = composeGoalPrompt(goal);
|
|
@@ -630,7 +632,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
630
632
|
// Fix round-6: re-check state AFTER patching (user may have paused/stopped in the inter-turn gap).
|
|
631
633
|
// Without this, a pause that lands between store.patch and executeTeamRun lets one extra turn run.
|
|
632
634
|
if (goal.state !== "running") {
|
|
633
|
-
|
|
635
|
+
appendEventBuffered(eventsPath, {
|
|
634
636
|
type: "goal.loop_end",
|
|
635
637
|
runId: manifest.runId,
|
|
636
638
|
data: {
|
|
@@ -638,7 +640,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
638
640
|
state: goal.state,
|
|
639
641
|
reason: "state changed before turn spawn",
|
|
640
642
|
},
|
|
641
|
-
});
|
|
643
|
+
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.loop_end reason=state_changed"));
|
|
642
644
|
break;
|
|
643
645
|
}
|
|
644
646
|
registerActiveRun(created.manifest);
|
|
@@ -688,7 +690,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
688
690
|
const turnStatus = turnResult.manifest.status;
|
|
689
691
|
if (turnStatus === "blocked" || turnStatus === "failed") {
|
|
690
692
|
goal = safeSetStatus(store, goal.goalId, "blocked", goal, eventsPath);
|
|
691
|
-
|
|
693
|
+
appendEventBuffered(eventsPath, {
|
|
692
694
|
type: "goal.turn_terminal_status",
|
|
693
695
|
runId: manifest.runId,
|
|
694
696
|
data: {
|
|
@@ -697,7 +699,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
697
699
|
turnRunId: created.manifest.runId,
|
|
698
700
|
turnStatus,
|
|
699
701
|
},
|
|
700
|
-
});
|
|
702
|
+
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.turn_terminal_status"));
|
|
701
703
|
break;
|
|
702
704
|
}
|
|
703
705
|
|
|
@@ -735,7 +737,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
735
737
|
eventsPath,
|
|
736
738
|
) ?? goal;
|
|
737
739
|
|
|
738
|
-
|
|
740
|
+
appendEventBuffered(eventsPath, {
|
|
739
741
|
type: "goal.turn_evaluated",
|
|
740
742
|
runId: manifest.runId,
|
|
741
743
|
data: {
|
|
@@ -744,9 +746,9 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
744
746
|
achieved: verdict.achieved,
|
|
745
747
|
reason: verdict.reason,
|
|
746
748
|
},
|
|
747
|
-
});
|
|
749
|
+
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.turn_evaluated"));
|
|
748
750
|
if (!verdict.achieved && goal.nextTurnFeedback) {
|
|
749
|
-
|
|
751
|
+
appendEventBuffered(eventsPath, {
|
|
750
752
|
type: "goal.feedback_steered",
|
|
751
753
|
runId: manifest.runId,
|
|
752
754
|
data: {
|
|
@@ -754,7 +756,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
754
756
|
turn: turnIndex,
|
|
755
757
|
feedback: goal.nextTurnFeedback,
|
|
756
758
|
},
|
|
757
|
-
});
|
|
759
|
+
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.feedback_steered"));
|
|
758
760
|
}
|
|
759
761
|
|
|
760
762
|
// ── STOP CONDITIONS (round-7: re-read disk before applying — external cancel/pause wins) ─
|
|
@@ -781,7 +783,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
781
783
|
const stuck = store.compareAndSetStatus(goal.goalId, "running", "stuck", eventsPath);
|
|
782
784
|
if (stuck) {
|
|
783
785
|
goal = stuck;
|
|
784
|
-
|
|
786
|
+
appendEventBuffered(eventsPath, {
|
|
785
787
|
type: "goal.stuck",
|
|
786
788
|
runId: manifest.runId,
|
|
787
789
|
data: {
|
|
@@ -789,7 +791,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
789
791
|
turn: goal.turnsUsed,
|
|
790
792
|
lastReasons: goal.verdicts.slice(-3).map((v) => v.reason.slice(0, 200)),
|
|
791
793
|
},
|
|
792
|
-
});
|
|
794
|
+
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.stuck"));
|
|
793
795
|
break;
|
|
794
796
|
}
|
|
795
797
|
}
|
|
@@ -811,7 +813,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
811
813
|
} catch {
|
|
812
814
|
/* best-effort */
|
|
813
815
|
}
|
|
814
|
-
|
|
816
|
+
appendEventBuffered(eventsPath, {
|
|
815
817
|
type: "goal.loop_end",
|
|
816
818
|
runId: manifest.runId,
|
|
817
819
|
data: {
|
|
@@ -820,7 +822,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
820
822
|
turnsUsed: goal.turnsUsed,
|
|
821
823
|
budgetUsed: goal.budgetUsed,
|
|
822
824
|
},
|
|
823
|
-
});
|
|
825
|
+
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.loop_end reason=normal"));
|
|
824
826
|
}
|
|
825
827
|
|
|
826
828
|
return { manifest, tasks: [], goalState: goal };
|
|
@@ -81,6 +81,9 @@ export class GoalStore {
|
|
|
81
81
|
mkdirSync(dirname(path), { recursive: true });
|
|
82
82
|
atomicWriteJson(path, next);
|
|
83
83
|
if (eventsPath) {
|
|
84
|
+
// REVIEW FIX (2026-09-10): reverted M2b buffered conversion —
|
|
85
|
+
// goal.state_changed is read back synchronously by CAS tests and
|
|
86
|
+
// goal status display; low-frequency transition event.
|
|
84
87
|
appendEvent(eventsPath, {
|
|
85
88
|
type: "goal.state_changed",
|
|
86
89
|
runId: state.goalId,
|
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { NotificationDescriptor } from "../../extension/notification-router.ts";
|
|
4
4
|
import type { MetricRegistry } from "../../observability/metric-registry.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { appendEventBuffered } from "../../state/event-log/event-log.ts";
|
|
6
6
|
import { loadRunManifestById } from "../../state/stores/state-store.ts";
|
|
7
7
|
import type { TeamRunManifest } from "../../state/types.ts";
|
|
8
8
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
@@ -175,7 +175,7 @@ export class HeartbeatWatcher {
|
|
|
175
175
|
this.lastLevel.set(key, level);
|
|
176
176
|
if (level === "dead" && previous !== "dead") {
|
|
177
177
|
this.opts.registry.counter("crew.heartbeat.dead_total", "Dead heartbeat detections").inc({ runId: run.runId });
|
|
178
|
-
|
|
178
|
+
appendEventBuffered(loaded.manifest.eventsPath, {
|
|
179
179
|
type: "crew.task.heartbeat_dead",
|
|
180
180
|
runId: run.runId,
|
|
181
181
|
taskId: task.id,
|
|
@@ -183,7 +183,7 @@ export class HeartbeatWatcher {
|
|
|
183
183
|
data: {
|
|
184
184
|
elapsedMs: Number.isFinite(elapsed) ? elapsed : undefined,
|
|
185
185
|
},
|
|
186
|
-
});
|
|
186
|
+
}).catch((e) => logInternalError("heartbeat_watcher.buffered", e, "type=crew.task.heartbeat_dead"));
|
|
187
187
|
// W9 fix — prefix title with short run label (first 8 chars of runId)
|
|
188
188
|
// so ambient notifications are scannable when multiple runs are
|
|
189
189
|
// in flight. Full runId remains in the notification object.
|
|
@@ -281,7 +281,12 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
|
|
|
281
281
|
|
|
282
282
|
// D5 (spec v0.7 §6): default loadout = FULL session (như main session).
|
|
283
283
|
// --no-extensions/--no-skills/--tools CHỈ xuất hiện khi agent .md khai explicit.
|
|
284
|
-
|
|
284
|
+
// "message" MUST stay in CONTROL_TOOLS: it is registered by prompt-runtime
|
|
285
|
+
// behind the PI_CREW_MSG_ENABLED dormant-env gate (child-pi-spawn.ts sets it
|
|
286
|
+
// unconditionally), but pi's --tools allowlist filters the tool SURFACE — a
|
|
287
|
+
// control tool missing here is invisible to every frontmatter-pinned worker
|
|
288
|
+
// (battery 2026-09-10: message dead for all builtin roles since f843e14a).
|
|
289
|
+
const CONTROL_TOOLS = ["ask", "delegate", "message"] as const;
|
|
285
290
|
if (input.agent.disableTools === true) {
|
|
286
291
|
args.push("--no-tools"); // capability-locked agents giữ hành vi (goal-judge)
|
|
287
292
|
} else {
|
|
@@ -111,6 +111,8 @@ export function sweepDroppedPlanItems(initialManifest: TeamRunManifest, initialT
|
|
|
111
111
|
: t,
|
|
112
112
|
);
|
|
113
113
|
cancelledTaskIds.push(task.id);
|
|
114
|
+
// REVIEW FIX (2026-09-10): reverted M2b buffered conversion —
|
|
115
|
+
// sweep tests read plan.item.dropped events synchronously.
|
|
114
116
|
appendEvent(fresh.manifest.eventsPath, {
|
|
115
117
|
type: "plan.item.dropped",
|
|
116
118
|
runId,
|
|
@@ -127,6 +129,7 @@ export function sweepDroppedPlanItems(initialManifest: TeamRunManifest, initialT
|
|
|
127
129
|
if (!appendSteeringAdvisory(fresh.manifest, task.id)) continue;
|
|
128
130
|
tasks = tasks.map((t) => (t.id === task.id ? { ...t, replanDroppedAt: new Date().toISOString() } : t));
|
|
129
131
|
advisedTaskIds.push(task.id);
|
|
132
|
+
// REVIEW FIX (2026-09-10): reverted M2b buffered conversion — see above.
|
|
130
133
|
appendEvent(fresh.manifest.eventsPath, {
|
|
131
134
|
type: "plan.item.dropped",
|
|
132
135
|
runId,
|
|
@@ -270,10 +270,35 @@ function isTaskHeartbeatStale(task: TeamTaskState, now: number): boolean {
|
|
|
270
270
|
// Compute elapsed from both sources and use the fresher (minimum) one,
|
|
271
271
|
// mirroring heartbeat-watcher logic: if either source has recent activity,
|
|
272
272
|
// the task is not stale even if the other is stale.
|
|
273
|
-
const heartbeatAge = Number.isFinite(heartbeatAt) ? now - heartbeatAt :
|
|
274
|
-
const activityAge = Number.isFinite(activityAt) ? now - activityAt :
|
|
273
|
+
const heartbeatAge = Number.isFinite(heartbeatAt) ? now - heartbeatAt : Number.MAX_SAFE_INTEGER;
|
|
274
|
+
const activityAge = Number.isFinite(activityAt) ? now - activityAt : Number.MAX_SAFE_INTEGER;
|
|
275
275
|
const elapsed = Math.min(heartbeatAge, activityAge);
|
|
276
|
-
|
|
276
|
+
if (elapsed <= NO_PID_HEARTBEAT_STALE_MS) return false;
|
|
277
|
+
// F1 part 2 (battery 2026-09-10, live evidence run team_20260910171307): a
|
|
278
|
+
// surface worker's recorder only flushes at turn boundaries — one slow LLM
|
|
279
|
+
// turn (observed: 6.7min) freezes lastSeenAt/lastActivityAt while the worker
|
|
280
|
+
// process is alive and mid-turn. The heartbeat-watcher already has a
|
|
281
|
+
// PID-liveness gate for exactly this ("alive → downgrade dead→stale"); the
|
|
282
|
+
// reconciler MUST apply the same gate before repairing, or it kills healthy
|
|
283
|
+
// runs at the 5-minute mark mid-turn. A truly dead pid still repairs as before.
|
|
284
|
+
const taskPid = task.heartbeat?.pid ?? task.checkpoint?.childPid;
|
|
285
|
+
const pidAlive = taskPid ? checkProcessLiveness(taskPid).alive : false;
|
|
286
|
+
if (taskPid && pidAlive) return false;
|
|
287
|
+
if (process.env.PI_CREW_DEBUG_STALE === "1") {
|
|
288
|
+
// F1 forensic (battery 2026-09-10): sidecar log of every STALE verdict so
|
|
289
|
+
// live repros can show exactly what the reconciler saw (pid present? alive?
|
|
290
|
+
// elapsed?) — the reconciler may run in ANY host process, hence a fixed
|
|
291
|
+
// sidecar path instead of console.
|
|
292
|
+
try {
|
|
293
|
+
fs.appendFileSync(
|
|
294
|
+
"/tmp/pi-crew-f1-debug.log",
|
|
295
|
+
`${JSON.stringify({ ts: new Date().toISOString(), taskId: task.id, status: task.status, taskPid, pidAlive, heartbeatAge, activityAge, hbLastSeen: task.heartbeat?.lastSeenAt })}\n`,
|
|
296
|
+
);
|
|
297
|
+
} catch {
|
|
298
|
+
/* best-effort forensic log */
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return true;
|
|
277
302
|
}
|
|
278
303
|
|
|
279
304
|
/**
|
|
@@ -22,6 +22,9 @@ export function recordSupervisorContact(manifest: TeamRunManifest, payload: Omit
|
|
|
22
22
|
timestamp: new Date().toISOString(),
|
|
23
23
|
};
|
|
24
24
|
try {
|
|
25
|
+
// REVIEW FIX (2026-09-10): reverted M2b buffered conversion —
|
|
26
|
+
// supervisor.contact events are read back synchronously (tests +
|
|
27
|
+
// contact-history display); low-frequency by design.
|
|
25
28
|
appendEvent(manifest.eventsPath, {
|
|
26
29
|
type: "supervisor.contact",
|
|
27
30
|
runId: manifest.runId,
|
|
@@ -723,6 +723,39 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
723
723
|
logInternalError("task-runner.on-json-event", err as Error, `taskId=${task.id}`);
|
|
724
724
|
}
|
|
725
725
|
},
|
|
726
|
+
onSurfaceActivity: (event) => {
|
|
727
|
+
// F1 fix (battery 2026-09-10): surface workers have no stdout pipe, so
|
|
728
|
+
// onJsonEvent/onStdoutLine never fire and task liveness state starves.
|
|
729
|
+
// Reuses the SAME throttled persisters as the stdout path:
|
|
730
|
+
// persistHeartbeat (1s) touches lastSeenAt + persists task state;
|
|
731
|
+
// persistChildProgress (coalesced) appends task.progress to the RUN
|
|
732
|
+
// event log - a DIFFERENT file than the per-agent log being tailed, so
|
|
733
|
+
// no watch->append loop. NEVER call appendCrewAgentEventBuffered here:
|
|
734
|
+
// the worker already wrote this event to the tailed file itself.
|
|
735
|
+
try {
|
|
736
|
+
const pidFromStart = event?.type === "worker.started" && typeof event.pid === "number" ? event.pid : undefined;
|
|
737
|
+
if (pidFromStart !== undefined) {
|
|
738
|
+
// Surface pid was previously never persisted into task state - the
|
|
739
|
+
// heartbeat-watcher PID-liveness gate (alive -> downgrade dead->stale)
|
|
740
|
+
// had nothing to check. Pin it once from the worker.started self-report.
|
|
741
|
+
task = {
|
|
742
|
+
...task,
|
|
743
|
+
heartbeat: touchWorkerHeartbeat(task.heartbeat ?? createWorkerHeartbeat(task.id), {
|
|
744
|
+
pid: pidFromStart,
|
|
745
|
+
}),
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
task = {
|
|
749
|
+
...task,
|
|
750
|
+
agentProgress: applyAgentProgressEvent(task.agentProgress ?? emptyCrewAgentProgress(), event, task.startedAt),
|
|
751
|
+
};
|
|
752
|
+
tasks = updateTask(tasks, task);
|
|
753
|
+
persistHeartbeat();
|
|
754
|
+
persistChildProgress(event);
|
|
755
|
+
} catch (err) {
|
|
756
|
+
logInternalError("task-runner.on-surface-activity", err as Error, `taskId=${task.id}`);
|
|
757
|
+
}
|
|
758
|
+
},
|
|
726
759
|
});
|
|
727
760
|
} finally {
|
|
728
761
|
// R10-5 (Wave 2B item 4): task-boundary flush — land every buffered
|
|
@@ -8,7 +8,7 @@ import { appendHookEvent, executeHook } from "../hooks/registry.ts";
|
|
|
8
8
|
import type { MetricRegistry } from "../observability/metric-registry.ts";
|
|
9
9
|
import { atomicWriteFile } from "../state/atomic-write.ts";
|
|
10
10
|
import { canTransitionRunStatus } from "../state/contracts.ts";
|
|
11
|
-
import { appendEvent, appendEventAsync, flushEventLogBuffer } from "../state/event-log/event-log.ts";
|
|
11
|
+
import { appendEvent, appendEventAsync, appendEventBuffered, flushEventLogBuffer } from "../state/event-log/event-log.ts";
|
|
12
12
|
import { hashArtifactContent as hashContent, writeArtifact } from "../state/stores/artifact-store.ts";
|
|
13
13
|
import { loadRunManifestById, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
|
|
14
14
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
@@ -426,7 +426,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
426
426
|
);
|
|
427
427
|
}
|
|
428
428
|
}
|
|
429
|
-
|
|
429
|
+
appendEventBuffered(manifest.eventsPath, {
|
|
430
430
|
type: "run.goal_achievement",
|
|
431
431
|
runId: manifest.runId,
|
|
432
432
|
message: gaApplied.manifest.goalAchievementNote ?? "",
|
|
@@ -436,7 +436,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
436
436
|
reason: gaAssessment.reason,
|
|
437
437
|
signals: gaAssessment.signals,
|
|
438
438
|
},
|
|
439
|
-
});
|
|
439
|
+
}).catch((e) => logInternalError("team-runner.buffered", e, "type=run.goal_achievement"));
|
|
440
440
|
if (gaApplied.downgraded)
|
|
441
441
|
logInternalError(
|
|
442
442
|
"team-runner.goalAchievement.falseGreen",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import * as fs from "node:fs";
|
|
27
27
|
import * as path from "node:path";
|
|
28
28
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
29
|
-
import { atomicWriteJson
|
|
29
|
+
import { atomicWriteJson } from "../atomic-write.ts";
|
|
30
30
|
import { withRunLockSync } from "../coordination/locks.ts";
|
|
31
31
|
import type { TeamRunManifest } from "../types.ts";
|
|
32
32
|
|
|
@@ -100,9 +100,10 @@ export function readOwnershipMap(manifest: TeamRunManifest): OwnershipMapFile {
|
|
|
100
100
|
export function upsertOwnershipEntry(manifest: TeamRunManifest, entry: OwnershipEntry): void {
|
|
101
101
|
try {
|
|
102
102
|
withRunLockSync(manifest, () => {
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
103
|
+
// REVIEW FIX (2026-09-10): reverted WI-2.2's coalesced conversion (and
|
|
104
|
+
// its flush-before-reload guard). The map is small, written at spawn /
|
|
105
|
+
// steer frequency (not a hot loop), and round-trip readers expect
|
|
106
|
+
// read-your-writes immediately after upsert returns.
|
|
106
107
|
const fresh = readOwnershipMap(manifest);
|
|
107
108
|
const prev = fresh.entries[entry.taskId] ?? {};
|
|
108
109
|
const merged: OwnershipEntry = {
|
|
@@ -74,6 +74,13 @@ export function getCurrentPlanRecord(manifest: TeamRunManifest): PlanRecord | un
|
|
|
74
74
|
function writePlanFile(manifest: TeamRunManifest, revisions: PlanRecord[]): void {
|
|
75
75
|
const file: PlanFile = { version: 1, revisions };
|
|
76
76
|
fs.mkdirSync(path.dirname(planFilePath(manifest)), { recursive: true });
|
|
77
|
+
// REVIEW FIX (2026-09-10): reverted WI-2.2's coalesced conversion.
|
|
78
|
+
// `loadPlanRecords` is the PUBLIC read API and every caller expects
|
|
79
|
+
// read-your-writes (getCurrentPlanRecord, plan-replan, handleStatus); a
|
|
80
|
+
// 50ms coalesce window made the mutators' own reload-inside-lock read
|
|
81
|
+
// stale state (lost-update) and broke plan-store.test.ts 11/15. Plan
|
|
82
|
+
// writes are low-frequency (revision append / linkage / approval) — the
|
|
83
|
+
// coalesce win is negligible. Same exclusion rule as status.ts A2/A4.
|
|
77
84
|
atomicWriteJson(planFilePath(manifest), file);
|
|
78
85
|
}
|
|
79
86
|
|
|
@@ -141,6 +148,10 @@ export function appendPlanRevision(manifest: TeamRunManifest, record: PlanRecord
|
|
|
141
148
|
revisions.push(record);
|
|
142
149
|
writePlanFile(manifest, revisions);
|
|
143
150
|
const dropped = record.items.filter((i) => i.status === "dropped").length;
|
|
151
|
+
// REVIEW FIX (2026-09-10): reverted M2b buffered conversion — plan lifecycle
|
|
152
|
+
// events are low-frequency and consumers (plan-store tests, status display)
|
|
153
|
+
// expect read-your-writes; also removes the policy-A catch (sync errors
|
|
154
|
+
// propagate to the producer inside the lock, as at base).
|
|
144
155
|
appendEvent(manifest.eventsPath, {
|
|
145
156
|
type: record.version === 1 ? "plan.created" : "plan.revised",
|
|
146
157
|
runId: manifest.runId,
|
|
@@ -199,6 +210,7 @@ export function setPlanApproval(
|
|
|
199
210
|
// ADR-4 §9: events for approval MUTATIONS. `pending` emits none — the
|
|
200
211
|
// request surface (ensurePlanApprovalRequested) appends its own
|
|
201
212
|
// plan.approval_required event.
|
|
213
|
+
// REVIEW FIX (2026-09-10): reverted M2b buffered conversion — see above.
|
|
202
214
|
if (approval.status !== "pending") {
|
|
203
215
|
appendEvent(manifest.eventsPath, {
|
|
204
216
|
type: approval.status === "approved" ? "plan.approved" : "plan.rejected",
|
|
@@ -502,6 +502,11 @@ export function saveRunManifest(manifest: TeamRunManifest): void {
|
|
|
502
502
|
// which is always safe.
|
|
503
503
|
invalidateRunCache(manifest.stateRoot);
|
|
504
504
|
const manifestPath = path.join(manifest.stateRoot, "manifest.json");
|
|
505
|
+
// REVIEW FIX (2026-09-10): reverted WI-2.2's coalesced conversion —
|
|
506
|
+
// saveRunManifest is a SYNCHRONOUS persist by name/contract (tests assert
|
|
507
|
+
// it, broker loadRunManifestById is a cross-process reader, and the
|
|
508
|
+
// statSync-based cache repopulation below needs the real post-write
|
|
509
|
+
// mtime/size). The 50ms coalesce window broke all three.
|
|
505
510
|
atomicWriteJson(manifestPath, manifest);
|
|
506
511
|
// FIX: Re-populate cache with actual mtime/size so loadRunManifestById
|
|
507
512
|
// doesn't miss the cache on next read. Without this, every load until
|
|
@@ -41,13 +41,9 @@ function hasPowerbarConsumer(events: EventBus): boolean {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
} catch (error) {
|
|
48
|
-
logInternalError("powerbar.statusFallback", error);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
44
|
+
// setStatusFallback removed (WI-5.1 / docs/decisions/2026-09-10): 0 callers,
|
|
45
|
+
// function was deliberately-unused; crew-widget owns the "pi-crew" status
|
|
46
|
+
// bar surface (see test/unit/ui/powerbar-publisher.test.ts:227).
|
|
51
47
|
|
|
52
48
|
function safeEmit(events: EventBus, event: string, data: unknown): void {
|
|
53
49
|
try {
|
|
@@ -3,25 +3,22 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import type { handleTeamTool as HandleTeamToolFn } from "../extension/team-tool.ts";
|
|
4
4
|
import type { MetricRegistry } from "../observability/metric-registry.ts";
|
|
5
5
|
|
|
6
|
-
let _cachedHandleTeamTool: typeof HandleTeamToolFn | undefined;
|
|
7
6
|
async function handleTeamTool(
|
|
8
7
|
params: Parameters<typeof HandleTeamToolFn>[0],
|
|
9
8
|
ctx: Parameters<typeof HandleTeamToolFn>[1],
|
|
10
9
|
): Promise<Awaited<ReturnType<typeof HandleTeamToolFn>>> {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
_cachedHandleTeamTool = mod.handleTeamTool;
|
|
15
|
-
}
|
|
16
|
-
return _cachedHandleTeamTool(params, ctx);
|
|
10
|
+
// LAZY: avoid pulling team-tool.ts (and its entire runtime chain) into module load.
|
|
11
|
+
const mod = await import("../extension/team-tool.ts");
|
|
12
|
+
return mod.handleTeamTool(params, ctx);
|
|
17
13
|
}
|
|
18
14
|
|
|
19
15
|
import { isToolError, textFromToolResult } from "../extension/tool-result.ts";
|
|
20
16
|
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
21
17
|
import { exportDiagnostic } from "../runtime/diagnostic-export.ts";
|
|
22
18
|
import type { MailboxDirection, MailboxMessage } from "../state/coordination/mailbox.ts";
|
|
23
|
-
import {
|
|
19
|
+
import { appendEventBuffered } from "../state/event-log/event-log.ts";
|
|
24
20
|
import { loadRunManifestById, saveRunTasks } from "../state/stores/state-store.ts";
|
|
21
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
25
22
|
|
|
26
23
|
export interface RunActionResult {
|
|
27
24
|
ok: boolean;
|
|
@@ -141,12 +138,12 @@ export async function dispatchKillStaleWorkers(ctx: ExtensionContext, runId: str
|
|
|
141
138
|
};
|
|
142
139
|
});
|
|
143
140
|
saveRunTasks(loaded.manifest, tasks);
|
|
144
|
-
|
|
141
|
+
appendEventBuffered(loaded.manifest.eventsPath, {
|
|
145
142
|
type: "worker.kill_stale",
|
|
146
143
|
runId,
|
|
147
144
|
message: `Marked ${count} stale worker heartbeat(s) dead.`,
|
|
148
145
|
data: { count },
|
|
149
|
-
});
|
|
146
|
+
}).catch((e) => logInternalError("run_action_dispatcher.buffered", e, "type=worker.kill_stale"));
|
|
150
147
|
return {
|
|
151
148
|
ok: true,
|
|
152
149
|
message: `Marked ${count} stale worker heartbeat(s) dead.`,
|
|
@@ -345,7 +345,10 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
|
|
|
345
345
|
"ui.dashboardPlacement": "center",
|
|
346
346
|
"ui.dashboardWidth": 72,
|
|
347
347
|
"ui.autoOpenDashboard": false,
|
|
348
|
-
|
|
348
|
+
// G17 sync (2026-09-10 review): canonical DEFAULT_UI.widgetPlacement =
|
|
349
|
+
// "bottom" (defaults.ts / install.mjs / project-init) — was drifted
|
|
350
|
+
// "aboveEditor" in this duplicated EFFECTIVE_DEFAULTS map.
|
|
351
|
+
"ui.widgetPlacement": "bottom",
|
|
349
352
|
"autonomous.enabled": true,
|
|
350
353
|
"autonomous.injectPolicy": true,
|
|
351
354
|
"autonomous.preferAsyncForLongTasks": false,
|