pi-crew 0.11.21 → 0.11.22
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 +30 -0
- package/dist/index.mjs +537 -526
- package/package.json +2 -1
- package/src/errors.ts +14 -1
- package/src/extension/async-notifier.ts +42 -17
- package/src/extension/cross-extension-rpc.ts +42 -5
- package/src/extension/notification-router.ts +15 -2
- package/src/extension/registration/lifecycle-handlers.ts +36 -13
- package/src/extension/registration/runtime-cleanup.ts +17 -0
- package/src/extension/registration/wire-cross-extension.ts +26 -0
- package/src/extension/rpc-hmac.ts +53 -2
- package/src/extension/team-tool/cancel.ts +34 -21
- package/src/extension/team-tool/dispatch/index.ts +6 -5
- package/src/extension/team-tool/dispatch/status.ts +42 -2
- package/src/extension/team-tool.ts +24 -2
- package/src/prompt/inbox-poll.ts +77 -25
- package/src/prompt/message-tool.ts +62 -4
- package/src/prompt/prompt-runtime.ts +82 -32
- package/src/runtime/broker/crew-broker-client.ts +74 -16
- package/src/runtime/broker/crew-broker.ts +111 -77
- package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
- package/src/runtime/broker/wait-status-cache.ts +14 -5
- package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
- package/src/runtime/child-pi/child-pi.ts +20 -2
- package/src/runtime/child-pi/mock-fixtures.ts +23 -0
- package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
- package/src/runtime/compaction/important-line-classifier.ts +85 -11
- package/src/runtime/compaction/tool-output-pruner.ts +35 -11
- package/src/runtime/crew-agent-records.ts +7 -1
- package/src/runtime/delivery-coordinator.ts +30 -0
- package/src/runtime/dwf-state-store.ts +41 -7
- package/src/runtime/finalize-run.ts +13 -2
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
- package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/live-session/embedded-tier.ts +3 -1
- package/src/runtime/live-session/live-session-runtime.ts +54 -1
- package/src/runtime/model/model-fallback.ts +23 -1
- package/src/runtime/output/pi-json-output.ts +26 -1
- package/src/runtime/policy-engine.ts +18 -0
- package/src/runtime/process/zombie-scanner.ts +78 -14
- package/src/runtime/process-status.ts +6 -1
- package/src/runtime/recovery/crash-recovery.ts +170 -28
- package/src/runtime/rpc/rpc-worker.ts +44 -1
- package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -3
- package/src/runtime/scheduling/scheduler.ts +74 -11
- package/src/runtime/scratchpad/engine.ts +26 -3
- package/src/runtime/settings-store.ts +22 -2
- package/src/runtime/surface/tmux-provider.ts +24 -3
- package/src/runtime/task-health.ts +16 -1
- package/src/runtime/task-output-context.ts +143 -71
- package/src/runtime/task-packet.ts +70 -17
- package/src/runtime/task-runner/child-executor.ts +27 -0
- package/src/runtime/task-runner/output-splitter.ts +79 -16
- package/src/runtime/task-runner/post-execution.ts +75 -15
- package/src/runtime/task-runner/state-helpers.ts +92 -2
- package/src/runtime/usage-tracker.ts +11 -0
- package/src/runtime/verification/green-contract.ts +17 -0
- package/src/runtime/verification/verification-gates.ts +24 -6
- package/src/runtime/workspace-lock.ts +53 -0
- package/src/schema/team-tool-schema.ts +10 -4
- package/src/state/coordination/mailbox.ts +133 -38
- package/src/state/decision-ledger.ts +56 -8
- package/src/state/event-log/event-log-rotation.ts +83 -5
- package/src/state/event-log/event-log.ts +181 -12
- package/src/state/stores/active-run-registry.ts +10 -3
- package/src/state/types.ts +9 -1
- package/src/ui/agents-jobs-browser.ts +22 -8
- package/src/ui/dashboard-panes/agents-pane.ts +5 -2
- package/src/ui/events-state-source.ts +67 -28
- package/src/ui/inline-panel/agent-pane.ts +13 -6
- package/src/ui/inline-panel/agent-transcript.ts +33 -21
- package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
- package/src/ui/inline-panel/index.ts +21 -1
- package/src/ui/live-run-sidebar.ts +14 -3
- package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
- package/src/ui/powerbar-publisher.ts +6 -3
- package/src/ui/rail.ts +7 -1
- package/src/ui/render-scheduler.ts +53 -1
- package/src/ui/run-action-dispatcher.ts +68 -20
- package/src/ui/status-colors.ts +9 -0
- package/src/utils/env-filter.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-crew",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.22",
|
|
4
4
|
"description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
|
|
5
5
|
"author": "baphuongna",
|
|
6
6
|
"license": "MIT",
|
|
@@ -88,6 +88,7 @@
|
|
|
88
88
|
"test": "npm run test:unit && npm run test:integration",
|
|
89
89
|
"test:full": "npm run test:unit && npm run test:integration && npm run test:integration:slow",
|
|
90
90
|
"test:unit": "node scripts/test-runner.mjs --test-concurrency=4 --test-timeout=180000 --test-force-exit 'test/unit/**/*.test.ts'",
|
|
91
|
+
"test:coverage": "node --import tsx/esm --test --experimental-test-coverage --test-concurrency=4 --test-timeout=180000 --test-force-exit 'test/unit/**/*.test.ts'",
|
|
91
92
|
"test:critical": "node scripts/test-runner.mjs --test-concurrency=4 --test-timeout=30000 --test-force-exit test/unit/runtime/broker/crew-broker-handshake.test.ts test/unit/runtime/broker/crew-broker-stale-socket.test.ts test/unit/runtime/broker/crew-broker-feature-flag.test.ts test/unit/runtime/broker/crew-broker-server-gate.test.ts test/unit/runtime/broker/crew-broker-client-fallback.test.ts test/unit/runtime/broker/crew-broker-mailbox-observer.test.ts test/unit/runtime/broker/crew-broker-close-during-reconnect.test.ts test/unit/runtime/broker/crew-broker-steer-dedup.test.ts test/unit/runtime/broker/crew-broker-symlink-steering.test.ts test/unit/ui/keybinding-map.parity.test.ts test/unit/ui/pi-tui-dispatch-probe.test.ts test/unit/utils/session-utils-extract.test.ts test/unit/config/config-schema-sync.test.ts test/unit/runtime/child-pi/child-pi-env-spread.test.ts test/unit/runtime/task-runner/child-executor-heartbeat-pulse.test.ts",
|
|
92
93
|
"test:watch": "node scripts/test-runner.mjs --watch --test-concurrency=4 --test-timeout=30000 'test/unit/**/*.test.ts'",
|
|
93
94
|
"test:integration": "node scripts/test-runner.mjs --test-concurrency=1 --test-timeout=300000 test/integration/*.test.ts",
|
package/src/errors.ts
CHANGED
|
@@ -147,9 +147,22 @@ export const errors = {
|
|
|
147
147
|
);
|
|
148
148
|
},
|
|
149
149
|
|
|
150
|
-
modelExhausted(chain: string[], lastFailure?: string): CrewError {
|
|
150
|
+
modelExhausted(chain: string[], lastFailure?: string, plannedChainLength?: number): CrewError {
|
|
151
151
|
const tried = chain.join(" → ");
|
|
152
152
|
const last = lastFailure ? ` Last failure: ${lastFailure}` : "";
|
|
153
|
+
// J4 (DR-03 round-6): a chain that broke EARLY (non-retryable failure,
|
|
154
|
+
// classifier veto, or spawn-budget stop while queued fallbacks remained)
|
|
155
|
+
// is not exhaustion — the old "All N candidates exhausted" wording
|
|
156
|
+
// overstated what happened and hid that fallbacks were never tried.
|
|
157
|
+
// plannedChainLength (the full candidate chain length) lets the message
|
|
158
|
+
// distinguish the two shapes; omit it for the legacy exhaustion wording.
|
|
159
|
+
if (plannedChainLength !== undefined && chain.length < plannedChainLength) {
|
|
160
|
+
const untried = plannedChainLength - chain.length;
|
|
161
|
+
return new CrewError(
|
|
162
|
+
ErrorCode.ModelExhausted,
|
|
163
|
+
`Model chain failed early (tried ${tried} — ${chain.length} of ${plannedChainLength} candidates; ${untried} queued fallback${untried === 1 ? "" : "s"} not tried).${last}`,
|
|
164
|
+
).withContext("model fallback chain");
|
|
165
|
+
}
|
|
153
166
|
return new CrewError(
|
|
154
167
|
ErrorCode.ModelExhausted,
|
|
155
168
|
`All ${chain.length} model candidates exhausted (tried: ${tried}).${last}`,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { readCrewAgents, saveCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
3
3
|
import { checkProcessLiveness, isActiveRunStatus } from "../runtime/process-status.ts";
|
|
4
|
-
import { withRunLock } from "../state/coordination/locks.ts";
|
|
4
|
+
import { withRunLock, withRunLockSync } from "../state/coordination/locks.ts";
|
|
5
5
|
import { appendEventAsync, readEventsCursor, type TeamEvent } from "../state/event-log/event-log.ts";
|
|
6
6
|
import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../state/stores/state-store.ts";
|
|
7
7
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
@@ -70,24 +70,49 @@ function isTaskActive(task: TeamTaskState): boolean {
|
|
|
70
70
|
return task.status === "running" || task.status === "queued" || task.status === "waiting";
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* R4-7 (DR-02 Wave 2 Cluster C): pure RMW core of the async-died sweep —
|
|
75
|
+
* flip ONLY still-active tasks to failed. Exported so the locked sweep body
|
|
76
|
+
* stays unit-testable without the notifier interval harness (repo seam
|
|
77
|
+
* pattern). Predicate is byte-identical to the previous inline map.
|
|
78
|
+
*/
|
|
79
|
+
export function failStillActiveTasks(tasks: readonly TeamTaskState[], failedAt: string, message: string): TeamTaskState[] {
|
|
80
|
+
return tasks.map((task) =>
|
|
81
|
+
isTaskActive(task)
|
|
82
|
+
? {
|
|
83
|
+
...task,
|
|
84
|
+
status: "failed",
|
|
85
|
+
finishedAt: failedAt,
|
|
86
|
+
error: message,
|
|
87
|
+
}
|
|
88
|
+
: task,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
73
92
|
function markActiveTasksAndAgentsFailed(run: TeamRunManifest, message: string): void {
|
|
74
|
-
const loaded = loadRunManifestById(run.cwd, run.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
75
|
-
const tasks = loaded?.tasks ?? [];
|
|
76
93
|
const failedAt = new Date().toISOString();
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
)
|
|
90
|
-
|
|
94
|
+
// R4-7 (DR-02 Wave 2 Cluster C): the task flip used to be an UNLOCKED
|
|
95
|
+
// load→map→save on tasks.json (":74 no withRunLock - best-effort"). It is
|
|
96
|
+
// now a run-locked RMW with the fresh re-read INSIDE the lock (R13-S1
|
|
97
|
+
// canonical pattern): only tasks that are STILL active in the fresh read
|
|
98
|
+
// are flipped, so a task that completed (or was cancelled) by a concurrent
|
|
99
|
+
// writer is never overwritten with failed. Lock re-entrance: the sole
|
|
100
|
+
// caller (markDeadAsyncRunIfNeeded) already holds the ASYNC run lock for
|
|
101
|
+
// this run in the same async context, so this sync acquisition bypasses
|
|
102
|
+
// via lockCtx — no self-deadlock, no double file acquire; an unlocked
|
|
103
|
+
// future caller gets real mutual exclusion.
|
|
104
|
+
try {
|
|
105
|
+
withRunLockSync(run, () => {
|
|
106
|
+
const fresh = loadRunManifestById(run.cwd, run.runId);
|
|
107
|
+
if (!fresh) return;
|
|
108
|
+
if (!fresh.tasks.some(isTaskActive)) return;
|
|
109
|
+
saveRunTasks(fresh.manifest, failStillActiveTasks(fresh.tasks, failedAt, message));
|
|
110
|
+
});
|
|
111
|
+
} catch (error) {
|
|
112
|
+
// Best-effort sweep: lock contention or a store failure must not break
|
|
113
|
+
// the notifier tick (the run-status flip above already happened) — warn
|
|
114
|
+
// and keep going; whatever remains active is retried by the next pass.
|
|
115
|
+
logInternalError("async-notifier.markActiveTasksFailed", error, `runId=${run.runId}`, "warn");
|
|
91
116
|
}
|
|
92
117
|
const agents = readCrewAgents(run);
|
|
93
118
|
if (agents.some((agent) => agent.status === "running" || agent.status === "queued" || agent.status === "waiting")) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
|
|
3
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
3
4
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
4
|
-
import { withHmacVerification } from "./rpc-hmac.ts";
|
|
5
|
+
import { isHmacEnabled, withHmacVerification } from "./rpc-hmac.ts";
|
|
5
6
|
// Lazy-loaded to avoid pulling team-tool.ts (and its entire runtime chain) into module load.
|
|
6
7
|
import type { handleTeamTool as HandleTeamToolFn } from "./team-tool.ts";
|
|
7
8
|
|
|
@@ -75,6 +76,19 @@ function isAllowedRpcOperation(operation: string): boolean {
|
|
|
75
76
|
return RPC_ALLOWED_OPERATIONS.has(operation);
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
// R7-9 (DR-04 round 7): operations a publisher WITHOUT a valid HMAC signature
|
|
80
|
+
// may issue on the realtime `pi-crew:live-control` channel. Mirrors the
|
|
81
|
+
// RPC_ALLOWED_OPERATIONS semantics (unauthenticated = read-only). The
|
|
82
|
+
// live-control protocol currently defines NO read-only operations — every
|
|
83
|
+
// parseable operation (steer/stop/resume/follow-up) mutates a live agent
|
|
84
|
+
// session — so the set is empty and unauthenticated publishers are rejected
|
|
85
|
+
// outright. Add future read-only live-control ops here when introduced.
|
|
86
|
+
const LIVE_CONTROL_UNAUTHENTICATED_OPERATIONS: ReadonlySet<string> = new Set([]);
|
|
87
|
+
|
|
88
|
+
function isUnauthenticatedLiveControlOperationAllowed(operation: string): boolean {
|
|
89
|
+
return LIVE_CONTROL_UNAUTHENTICATED_OPERATIONS.has(operation);
|
|
90
|
+
}
|
|
91
|
+
|
|
78
92
|
// SECURITY (HIGH #4 fix): In-memory rate limiter for RPC run requests.
|
|
79
93
|
// Prevents any extension from spawning unlimited child processes.
|
|
80
94
|
const RPC_RATE_LIMIT_MAX = 5; // Max 5 RPC run requests...
|
|
@@ -148,9 +162,15 @@ function on(events: EventBusLike, channel: string, handler: (raw: unknown) => un
|
|
|
148
162
|
}
|
|
149
163
|
|
|
150
164
|
// SECURITY TRUST BOUNDARY: RPC channels (pi-crew:rpc:run, pi-crew:rpc:status,
|
|
151
|
-
// pi-crew:rpc:live-control
|
|
152
|
-
//
|
|
153
|
-
//
|
|
165
|
+
// pi-crew:rpc:live-control, and the realtime pi-crew:live-control channel) are
|
|
166
|
+
// accessible to any extension on the shared event bus. Mitigations: rate
|
|
167
|
+
// limiting, explicit intent, operation allowlist, cwd containment validation,
|
|
168
|
+
// and HMAC origin signing (see rpc-hmac.ts). Trust model (R7-9): without a
|
|
169
|
+
// valid HMAC signature every RPC surface is read-only — the request/response
|
|
170
|
+
// channels enforce this via RPC_ALLOWED_OPERATIONS, and the realtime
|
|
171
|
+
// pi-crew:live-control channel rejects any operation outside
|
|
172
|
+
// LIVE_CONTROL_UNAUTHENTICATED_OPERATIONS (currently ALL of them, since
|
|
173
|
+
// steer/stop/resume/follow-up are mutating) with a warn log and no publish.
|
|
154
174
|
|
|
155
175
|
export function registerPiCrewRpc(
|
|
156
176
|
events: EventBusLike | undefined,
|
|
@@ -283,7 +303,24 @@ export function registerPiCrewRpc(
|
|
|
283
303
|
"pi-crew:live-control",
|
|
284
304
|
withHmacVerification((raw) => {
|
|
285
305
|
const request = parseLiveControlRealtimeMessage(raw);
|
|
286
|
-
if (request)
|
|
306
|
+
if (!request) return;
|
|
307
|
+
// R7-9 (DR-04 round 7): gate mutating live-control operations. This
|
|
308
|
+
// handler is only reachable WITHOUT a valid HMAC signature when
|
|
309
|
+
// PI_CREW_RPC_SECRET is unset — when the secret IS set the wrapper
|
|
310
|
+
// rejects missing/invalid signatures outright (throw, no publish).
|
|
311
|
+
// With no secret the publisher is unauthenticated, so only the
|
|
312
|
+
// (currently empty) read-only allowlist may pass; steer/stop/
|
|
313
|
+
// resume/follow-up are rejected with a warn log and no publish.
|
|
314
|
+
if (!isHmacEnabled() && !isUnauthenticatedLiveControlOperationAllowed(request.operation)) {
|
|
315
|
+
logInternalError(
|
|
316
|
+
"rpc.live-control",
|
|
317
|
+
new Error(`rejected unauthenticated live-control operation '${request.operation}'`),
|
|
318
|
+
`runId=${request.runId},taskId=${request.taskId}`,
|
|
319
|
+
"warn",
|
|
320
|
+
);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
publishLiveControlRealtime(request);
|
|
287
324
|
}, "pi-crew:live-control"),
|
|
288
325
|
),
|
|
289
326
|
on(
|
|
@@ -154,11 +154,15 @@ export class NotificationRouter {
|
|
|
154
154
|
if (this.batch.length === 0) return;
|
|
155
155
|
const items = this.batch;
|
|
156
156
|
this.batch = [];
|
|
157
|
+
// R7-4 (2026-10-16): flush() also runs inside the setTimeout callback —
|
|
158
|
+
// a throwing `deliver` used to escape into the timer as an uncaught
|
|
159
|
+
// exception. Wrap every delivery; log via logInternalError and keep the
|
|
160
|
+
// lossy batch semantics (the batch is NOT re-queued on error).
|
|
157
161
|
if (items.length === 1) {
|
|
158
|
-
this.
|
|
162
|
+
this.deliverSafely(items[0]!);
|
|
159
163
|
return;
|
|
160
164
|
}
|
|
161
|
-
this.
|
|
165
|
+
this.deliverSafely({
|
|
162
166
|
id: `batch:${items.map((item) => notificationKey(item)).join(",")}`,
|
|
163
167
|
severity: batchSeverity(items),
|
|
164
168
|
source: "batch",
|
|
@@ -168,6 +172,15 @@ export class NotificationRouter {
|
|
|
168
172
|
});
|
|
169
173
|
}
|
|
170
174
|
|
|
175
|
+
/** R7-4: never let a throwing deliver callback escape flush() (timer context). */
|
|
176
|
+
private deliverSafely(notification: NotificationDescriptor): void {
|
|
177
|
+
try {
|
|
178
|
+
this.deliver(notification);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
logInternalError("notification.flush", error);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
171
184
|
dispose(): void {
|
|
172
185
|
if (this.timer) clearTimeout(this.timer);
|
|
173
186
|
this.timer = undefined;
|
|
@@ -536,8 +536,11 @@ async function runDeferredSessionCleanup(
|
|
|
536
536
|
* Build a CrewScheduler wired to the current session. The scheduler's
|
|
537
537
|
* executor closure invokes handleTeamTool lazily — the heavy team-tool
|
|
538
538
|
* import only fires when a scheduled job actually runs.
|
|
539
|
+
*
|
|
540
|
+
* @internal — exported for the R6-2 persistence-wiring unit tests
|
|
541
|
+
* (test/unit/runtime/scheduling/scheduler-persist-reload.test.ts).
|
|
539
542
|
*/
|
|
540
|
-
function setupCrewScheduler(
|
|
543
|
+
export function setupCrewScheduler(
|
|
541
544
|
pi: ExtensionAPI,
|
|
542
545
|
ctx: RegistrationContext,
|
|
543
546
|
extensionCtx: ExtensionContext,
|
|
@@ -556,6 +559,15 @@ function setupCrewScheduler(
|
|
|
556
559
|
if (ctx.cleanedUp) return;
|
|
557
560
|
notifyScheduleEvent(event);
|
|
558
561
|
pi.events?.emit?.("crew-scheduler", event);
|
|
562
|
+
// R6-2(a) (round-6 audit): `updated` scheduler events persist the job —
|
|
563
|
+
// the persist flow armCron's docstring always claimed existed. fire()'s
|
|
564
|
+
// lastStatus flips and advanceCronNextRun's nextRun writes now land on
|
|
565
|
+
// disk; before this only the executor persisted, and it persisted the
|
|
566
|
+
// pre-completion snapshot (lastStatus:"running") while the terminal flip
|
|
567
|
+
// stayed memory-only — a restart in that window reloaded "running" and
|
|
568
|
+
// fire()'s in-flight guard skipped the job forever (jobs died after
|
|
569
|
+
// restart). persistScheduledJobUpdate is best-effort (internal catch).
|
|
570
|
+
if (event.type === "updated") persistScheduledJobUpdate(extensionCtx.cwd, event.job);
|
|
559
571
|
},
|
|
560
572
|
executor: (job) => {
|
|
561
573
|
let runParams: { action: string; team: string; goal: string };
|
|
@@ -601,17 +613,26 @@ function setupCrewScheduler(
|
|
|
601
613
|
/* best-effort provenance tracking */
|
|
602
614
|
}
|
|
603
615
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
616
|
+
// R6-2(b) + I6 (round-6 audit): update state FIRST, then persist the
|
|
617
|
+
// UPDATED job. The old order persisted the fire-time snapshot (still
|
|
618
|
+
// lastStatus:"running") and then flipped memory only — a restart in
|
|
619
|
+
// that window reloaded "running" and fire()'s in-flight guard skipped
|
|
620
|
+
// the job forever. runCount comes from a FRESH list() read: the
|
|
621
|
+
// closure-captured `job` is the dispatch-time snapshot, so any update
|
|
622
|
+
// that landed between dispatch and completion (run-now, another
|
|
623
|
+
// completion) would otherwise be clobbered with a stale count. The
|
|
624
|
+
// update() call also persists via the emit wiring above; the explicit
|
|
625
|
+
// persist keeps the executor authoritative when the emit guard exited
|
|
626
|
+
// early (cleanedUp).
|
|
627
|
+
const freshJob = crewScheduler?.list().find((j) => j.id === job.id);
|
|
628
|
+
const completedJob = freshJob
|
|
629
|
+
? crewScheduler?.update(job.id, {
|
|
630
|
+
runCount: freshJob.runCount + 1,
|
|
631
|
+
lastRun: new Date().toISOString(),
|
|
632
|
+
lastStatus: "success",
|
|
633
|
+
})
|
|
634
|
+
: undefined;
|
|
635
|
+
if (completedJob) persistScheduledJobUpdate(extensionCtx.cwd, completedJob);
|
|
615
636
|
} catch (err) {
|
|
616
637
|
logInternalError("scheduler.execute", err);
|
|
617
638
|
crewScheduler?.update(job.id, { lastStatus: "error" });
|
|
@@ -651,7 +672,9 @@ function setupCrewScheduler(
|
|
|
651
672
|
// - retains the broker across session switches when the session_id is
|
|
652
673
|
// unchanged; on a session_id change, stops the old broker and binds a new
|
|
653
674
|
// one on the next acquire.
|
|
654
|
-
// - stops the broker during session_shutdown
|
|
675
|
+
// - stops the broker during session_shutdown: cleanupRuntime
|
|
676
|
+
// (registration/runtime-cleanup.ts) calls ctx.brokerController?.stop()
|
|
677
|
+
// first, before the rest of the runtime teardown (R7-11 wiring).
|
|
655
678
|
//
|
|
656
679
|
// The gate is re-evaluated on every `issueForChild` call (cheap env+depth
|
|
657
680
|
// check) so the kill switch (PI_CREW_BROKER=0) takes effect immediately.
|
|
@@ -129,6 +129,23 @@ function buildCleanupRuntime(ctx: RegistrationContext): () => void {
|
|
|
129
129
|
return (): void => {
|
|
130
130
|
if (ctx.cleanedUp) return;
|
|
131
131
|
ctx.cleanedUp = true;
|
|
132
|
+
// R7-11 (DR-04 round 7): stop the inter-pi broker FIRST, before the rest
|
|
133
|
+
// of the runtime teardown — this call is what makes the
|
|
134
|
+
// CrewBrokerLifecycleController docblock's "stops the broker during
|
|
135
|
+
// session_shutdown" promise true (see lifecycle-handlers.ts). The
|
|
136
|
+
// controller's stop() is idempotent and swallows its own errors; clearing
|
|
137
|
+
// the field makes a re-entry a no-op even if the cleanedUp gate were
|
|
138
|
+
// bypassed. Fire-and-forget: cleanupRuntime is synchronous and shutdown
|
|
139
|
+
// must not block on the broker socket teardown.
|
|
140
|
+
try {
|
|
141
|
+
const stopping = ctx.brokerController?.stop();
|
|
142
|
+
ctx.brokerController = undefined;
|
|
143
|
+
stopping?.catch((error) => {
|
|
144
|
+
logInternalError("cleanup-runtime.broker-stop", error);
|
|
145
|
+
});
|
|
146
|
+
} catch (error) {
|
|
147
|
+
logInternalError("cleanup-runtime.broker-stop", error);
|
|
148
|
+
}
|
|
132
149
|
const sid = extractSessionId(ctx.currentCtx);
|
|
133
150
|
if (ctx.preloadTimer) {
|
|
134
151
|
clearTimeout(ctx.preloadTimer);
|
|
@@ -10,9 +10,31 @@
|
|
|
10
10
|
* (EXT-9: no longer on globalThis[Symbol.for(...)]).
|
|
11
11
|
*/
|
|
12
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
13
14
|
import { registerPiCrewRpc } from "../cross-extension-rpc.ts";
|
|
14
15
|
import type { RegistrationContext } from "./registration-types.ts";
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* N-6 (DR-04 round 7): shared generation/ownership guard for the late lazy
|
|
19
|
+
* team-tool import. The dynamic import resolves on a LATER tick — a
|
|
20
|
+
* session_shutdown (or session-switch cleanup) may already have torn the
|
|
21
|
+
* runtime down by then, and installing the crew registry after teardown would
|
|
22
|
+
* resurrect extension state for a disposed registration. Skip the install
|
|
23
|
+
* when the registration generation moved on or the context is marked
|
|
24
|
+
* cleaned-up; session_start rebinds the registry via
|
|
25
|
+
* refreshCrossExtensionWiringForSession anyway.
|
|
26
|
+
*/
|
|
27
|
+
function registryInstallSkipped(ctx: RegistrationContext, installGeneration: number, where: string): boolean {
|
|
28
|
+
if (!ctx.cleanedUp && ctx.sessionGeneration === installGeneration) return false;
|
|
29
|
+
logInternalError(
|
|
30
|
+
"register.wire-cross-extension",
|
|
31
|
+
new Error(`skipped crew registry install (${where}): runtime cleanup already ran before the lazy import resolved`),
|
|
32
|
+
`installGeneration=${installGeneration},currentGeneration=${ctx.sessionGeneration},cleanedUp=${ctx.cleanedUp}`,
|
|
33
|
+
"debug",
|
|
34
|
+
);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
/** Install both wires on the registration context. */
|
|
17
39
|
export function installCrossExtensionWiring(pi: ExtensionAPI, ctx: RegistrationContext): void {
|
|
18
40
|
// Wire the in-process RPC handle.
|
|
@@ -27,8 +49,10 @@ export function installCrossExtensionWiring(pi: ExtensionAPI, ctx: RegistrationC
|
|
|
27
49
|
// Install the crew registry. Lazy import keeps team-tool.ts (which pulls
|
|
28
50
|
// in the entire runtime chain) out of the cold-start module graph. EXT-9:
|
|
29
51
|
// the registry now lives in module-scoped state, not globalThis.
|
|
52
|
+
const installGeneration = ctx.sessionGeneration;
|
|
30
53
|
void import("../team-tool.ts").then(({ installCrewGlobalRegistry }) => {
|
|
31
54
|
// LAZY: defer team-tool until cross-extension wiring fires
|
|
55
|
+
if (registryInstallSkipped(ctx, installGeneration, "install")) return;
|
|
32
56
|
const manifestCacheForRegistry = ctx.getManifestCache(ctx.currentCtx?.cwd ?? process.cwd());
|
|
33
57
|
installCrewGlobalRegistry({
|
|
34
58
|
manifestCache: manifestCacheForRegistry,
|
|
@@ -57,8 +81,10 @@ export function refreshCrossExtensionWiringForSession(pi: ExtensionAPI, ctx: Reg
|
|
|
57
81
|
installCrossExtensionWiring(pi, ctx);
|
|
58
82
|
return;
|
|
59
83
|
}
|
|
84
|
+
const installGeneration = ctx.sessionGeneration;
|
|
60
85
|
void import("../team-tool.ts").then(({ installCrewGlobalRegistry }) => {
|
|
61
86
|
// LAZY: defer team-tool until cross-extension wiring fires
|
|
87
|
+
if (registryInstallSkipped(ctx, installGeneration, "session-refresh")) return;
|
|
62
88
|
installCrewGlobalRegistry({
|
|
63
89
|
manifestCache: ctx.getManifestCache(ctx.currentCtx?.cwd ?? process.cwd()),
|
|
64
90
|
cwdProvider: () => ctx.currentCtx?.cwd ?? process.cwd(),
|
|
@@ -96,6 +96,32 @@ export function createRpcSignature(origin: string, channel: string, body: unknow
|
|
|
96
96
|
|
|
97
97
|
// --- Verification ------------------------------------------------------------
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Bounded insertion-ordered (FIFO) cache of nonces from VERIFIED signatures
|
|
101
|
+
* (R7-10, DR-04 round 7). A captured request replayed inside the signature
|
|
102
|
+
* validity window is rejected with reason "replayed nonce".
|
|
103
|
+
*
|
|
104
|
+
* Nonces are recorded ONLY after the HMAC itself validates, so an attacker
|
|
105
|
+
* cannot flood the cache with junk nonces to evict recorded ones (that would
|
|
106
|
+
* re-enable replay). Entries expire with the signature validity window and
|
|
107
|
+
* are pruned on every verification; size is FIFO-capped to bound memory.
|
|
108
|
+
*/
|
|
109
|
+
const NONCE_CACHE_MAX_ENTRIES = 512;
|
|
110
|
+
const seenNonces = new Map<string, number>();
|
|
111
|
+
|
|
112
|
+
function pruneSeenNonces(now: number): void {
|
|
113
|
+
const expiredBefore = now - SIGNATURE_VALIDITY_MS;
|
|
114
|
+
for (const [nonce, seenAt] of seenNonces) {
|
|
115
|
+
if (seenAt >= expiredBefore) break; // insertion order ⇒ every later entry is newer
|
|
116
|
+
seenNonces.delete(nonce);
|
|
117
|
+
}
|
|
118
|
+
while (seenNonces.size > NONCE_CACHE_MAX_ENTRIES) {
|
|
119
|
+
const oldestNonce = seenNonces.keys().next().value;
|
|
120
|
+
if (oldestNonce === undefined) break;
|
|
121
|
+
seenNonces.delete(oldestNonce);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
99
125
|
/**
|
|
100
126
|
* Verify an HMAC signature.
|
|
101
127
|
*
|
|
@@ -121,7 +147,21 @@ export function verifyRpcSignature(payload: RpcSignedPayload, body: unknown): {
|
|
|
121
147
|
}
|
|
122
148
|
|
|
123
149
|
const expected = computeHmac(secret, payload, body);
|
|
124
|
-
|
|
150
|
+
if (!timingSafeCompare(payload.signature, expected)) {
|
|
151
|
+
return { valid: false, reason: "HMAC signature mismatch" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// R7-10: replay protection — reject a nonce that was already verified within
|
|
155
|
+
// the validity window; record this one only after the HMAC validated.
|
|
156
|
+
pruneSeenNonces(now);
|
|
157
|
+
if (seenNonces.has(payload.nonce)) {
|
|
158
|
+
return {
|
|
159
|
+
valid: false,
|
|
160
|
+
reason: `replayed nonce (nonce already verified within the ${Math.round(SIGNATURE_VALIDITY_MS / 60000)}-minute validity window)`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
seenNonces.set(payload.nonce, now);
|
|
164
|
+
return { valid: true };
|
|
125
165
|
}
|
|
126
166
|
|
|
127
167
|
// --- Middleware --------------------------------------------------------------
|
|
@@ -135,7 +175,7 @@ export function verifyRpcSignature(payload: RpcSignedPayload, body: unknown): {
|
|
|
135
175
|
*/
|
|
136
176
|
export function withHmacVerification<P extends { requestId: string }>(
|
|
137
177
|
handler: (params: P) => unknown | Promise<unknown>,
|
|
138
|
-
|
|
178
|
+
channel: string,
|
|
139
179
|
): (raw: unknown) => unknown | Promise<unknown> {
|
|
140
180
|
return (raw: unknown) => {
|
|
141
181
|
const params = raw as P;
|
|
@@ -155,6 +195,17 @@ export function withHmacVerification<P extends { requestId: string }>(
|
|
|
155
195
|
throw new Error("[pi-crew HMAC] Missing HMAC signature in RPC request.");
|
|
156
196
|
}
|
|
157
197
|
|
|
198
|
+
// N-3 (DR-04 round 7): bind the arrival channel. The MAC covers the
|
|
199
|
+
// CLAIMED channel (payload.channel is mixed into computeHmac), so without
|
|
200
|
+
// this check a signature issued for channel A also validates on channel B
|
|
201
|
+
// — a cross-channel replay. The signature must have been issued for the
|
|
202
|
+
// exact channel this wrapper guards.
|
|
203
|
+
if (sigPayload.channel !== channel) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`[pi-crew HMAC] Signature channel mismatch: signature was issued for '${sigPayload.channel}', request arrived on '${channel}'.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
158
209
|
// Strip HMAC fields from body before verification (HMAC was signed over original params)
|
|
159
210
|
const originalBody = stripHmacFields(params);
|
|
160
211
|
const verification = verifyRpcSignature(sigPayload, originalBody);
|
|
@@ -293,27 +293,11 @@ export async function handleCancel(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
293
293
|
true,
|
|
294
294
|
);
|
|
295
295
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
if (asyncPid !== undefined && asyncPid > 0) {
|
|
302
|
-
try {
|
|
303
|
-
killProcessPid(asyncPid);
|
|
304
|
-
// H1 (2026-08-10): informational event in async context — await the
|
|
305
|
-
// async lock path (non-blocking event loop, ~1ms vs ~14ms sync).
|
|
306
|
-
await appendEventAsync(loaded.manifest.eventsPath, {
|
|
307
|
-
type: "async.kill_requested",
|
|
308
|
-
runId: loaded.manifest.runId,
|
|
309
|
-
message: "Sent SIGTERM to background runner process.",
|
|
310
|
-
data: { pid: asyncPid },
|
|
311
|
-
});
|
|
312
|
-
} catch (error) {
|
|
313
|
-
logInternalError("team-tool.handleCancel.killAsync", error, `runId=${loaded.manifest.runId},pid=${asyncPid}`);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
296
|
+
// N-5 (DR-04 round 7): the live-agent termination + async-runner kill used
|
|
297
|
+
// to run HERE on the stale pre-lock `loaded` snapshot — a run that reached a
|
|
298
|
+
// terminal state during the before_cancel hook gap was still torn down and
|
|
299
|
+
// its background runner SIGTERMed. The signal decision now derives from the
|
|
300
|
+
// FRESH in-lock read below (see the gated block inside the lock).
|
|
317
301
|
return withRunLockSync(loaded.manifest, () => {
|
|
318
302
|
// R13-2 (2026-08-14): fresh re-read INSIDE the lock. `loaded` was captured
|
|
319
303
|
// lock-free BEFORE the before_cancel hook + live-agent termination gap; a
|
|
@@ -349,6 +333,35 @@ export async function handleCancel(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
349
333
|
true,
|
|
350
334
|
);
|
|
351
335
|
}
|
|
336
|
+
// N-5 (DR-04 round 7): signal ONLY when the FRESH in-lock status is still
|
|
337
|
+
// "running". Best-effort semantics are kept, but this is a synchronous
|
|
338
|
+
// run-lock callback (cannot await — H1 2026-08-10 pattern), so the
|
|
339
|
+
// async operations are fire-and-forget with errors logged. killProcessPid
|
|
340
|
+
// still signals synchronously here — while we hold the lock — so the async
|
|
341
|
+
// background runner cannot overwrite the cancelled state we write below.
|
|
342
|
+
if (fresh.manifest.status === "running") {
|
|
343
|
+
void terminateLiveAgentsForRun(fresh.manifest.runId, "cancelled", appendEvent, fresh.manifest.eventsPath).catch((error) =>
|
|
344
|
+
logInternalError("team-tool.handleCancel.terminateLiveAgents", error, `runId=${fresh.manifest.runId}`),
|
|
345
|
+
);
|
|
346
|
+
const asyncPid = fresh.manifest.async?.pid;
|
|
347
|
+
if (asyncPid !== undefined && asyncPid > 0) {
|
|
348
|
+
try {
|
|
349
|
+
killProcessPid(asyncPid);
|
|
350
|
+
// H1 (2026-08-10): informational event in async context — fire-and-forget
|
|
351
|
+
// on the async lock path (non-blocking event loop, ~1ms vs ~14ms sync).
|
|
352
|
+
void appendEventAsync(fresh.manifest.eventsPath, {
|
|
353
|
+
type: "async.kill_requested",
|
|
354
|
+
runId: fresh.manifest.runId,
|
|
355
|
+
message: "Sent SIGTERM to background runner process.",
|
|
356
|
+
data: { pid: asyncPid },
|
|
357
|
+
}).catch((error) =>
|
|
358
|
+
logInternalError("team-tool.handleCancel.killAsyncEvent", error, `runId=${fresh.manifest.runId},pid=${asyncPid}`),
|
|
359
|
+
);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
logInternalError("team-tool.handleCancel.killAsync", error, `runId=${fresh.manifest.runId},pid=${asyncPid}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
352
365
|
const cancellableIds = new Set(abortResult.abortedIds);
|
|
353
366
|
const cancelReason = cancelReasonFromParams(params);
|
|
354
367
|
const cancelIntent = intentFromConfig(params.config);
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* API-5 facade dispatch — domain router registry.
|
|
3
3
|
*
|
|
4
|
-
* `domainForAction(action)` maps each of the
|
|
4
|
+
* `domainForAction(action)` maps each of the 57 action strings to one of 5
|
|
5
5
|
* domains. `handleTeamTool` (in team-tool.ts) calls this to pick the right
|
|
6
6
|
* domain router, preserving the exact same public API and zero caller changes.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* 57→5 mapping (kept in sync by test/unit/schema/team-tool-docs-sync.test.ts —
|
|
9
|
+
* DR-01: this header drifted to 54/9/16 while the schema had 57/10/17):
|
|
10
|
+
* run (10): run, parallel, plan, plans, orchestrate, resume, retry, wait, steer, goal
|
|
10
11
|
* status (17): status, list, get, events, artifacts, breakdown, summary, graph,
|
|
11
12
|
* search, health, worktrees, checkpoint, cache, explain, onboard, recommend, help
|
|
12
13
|
* control (7): cancel, invalidate, respond, cleanup, prune, forget, doctor
|
|
@@ -24,7 +25,7 @@ import { handleStatusDomain } from "./status.ts";
|
|
|
24
25
|
export { handleAutomateDomain, handleControlDomain, handleManageDomain, handleRunDomain, handleStatusDomain };
|
|
25
26
|
|
|
26
27
|
const ACTION_TO_DOMAIN: Record<string, TeamDomain> = {
|
|
27
|
-
// run domain (
|
|
28
|
+
// run domain (10)
|
|
28
29
|
run: "run",
|
|
29
30
|
plans: "run",
|
|
30
31
|
parallel: "run",
|
|
@@ -64,7 +65,7 @@ const ACTION_TO_DOMAIN: Record<string, TeamDomain> = {
|
|
|
64
65
|
forget: "control",
|
|
65
66
|
doctor: "control",
|
|
66
67
|
|
|
67
|
-
// manage domain (
|
|
68
|
+
// manage domain (17)
|
|
68
69
|
create: "manage",
|
|
69
70
|
update: "manage",
|
|
70
71
|
delete: "manage",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Inline cases (graph, search, onboard, explain, cache, checkpoint, recommend,
|
|
9
9
|
* help) are moved verbatim from the former handleTeamTool switch.
|
|
10
10
|
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
11
12
|
import * as path from "node:path";
|
|
12
13
|
import { allAgents, discoverAgents } from "../../../agents/discover-agents.ts";
|
|
13
14
|
import { loadConfig } from "../../../config/config.ts";
|
|
@@ -16,6 +17,7 @@ import { getSkillCacheStats } from "../../../runtime/skill-instructions.ts";
|
|
|
16
17
|
import type { TeamToolParamsValue } from "../../../schema/team-tool-schema.ts";
|
|
17
18
|
import { computeRunCacheKey, getCachedRun, getCacheStats } from "../../../state/stores/run-cache.ts";
|
|
18
19
|
import { listRunGraphs, loadRunGraph } from "../../../state/stores/run-graph.ts";
|
|
20
|
+
import type { TaskCheckpointState } from "../../../state/types.ts";
|
|
19
21
|
import { allTeams, discoverTeams } from "../../../teams/discover-teams.ts";
|
|
20
22
|
import { searchAgents, searchTeams } from "../../../utils/bm25-search.ts";
|
|
21
23
|
import { projectCrewRoot } from "../../../utils/paths.ts";
|
|
@@ -200,14 +202,27 @@ export async function handleStatusDomain(params: TeamToolParamsValue, ctx: TeamC
|
|
|
200
202
|
assertSafePathId("runId", params.runId);
|
|
201
203
|
assertSafePathId("taskId", params.taskId);
|
|
202
204
|
const stateRoot = path.join(projectCrewRoot(ctx.cwd), "state", "runs", params.runId);
|
|
205
|
+
// R7-8 (DR-04 round-7): `task.checkpoint` in tasks.json is the REAL
|
|
206
|
+
// production source — `checkpointTask` (task-runner/state-helpers.ts)
|
|
207
|
+
// writes the checkpoint into the task record and persists it via
|
|
208
|
+
// saveRunTasks. FileCheckpointStore is only a fallback for runs whose
|
|
209
|
+
// task record carries no checkpoint. The output says which source won.
|
|
210
|
+
const taskCheckpoint = readTaskCheckpointFromTasksJson(stateRoot, params.taskId);
|
|
211
|
+
if (taskCheckpoint) {
|
|
212
|
+
return result(
|
|
213
|
+
`Checkpoint (source: task.checkpoint in tasks.json): phase=${taskCheckpoint.phase}, updatedAt=${taskCheckpoint.updatedAt}` +
|
|
214
|
+
(taskCheckpoint.childPid !== undefined ? `, childPid=${taskCheckpoint.childPid}` : ""),
|
|
215
|
+
{ action: "checkpoint", status: "ok", data: { source: "task-record", checkpoint: taskCheckpoint } },
|
|
216
|
+
);
|
|
217
|
+
}
|
|
203
218
|
const store = new FileCheckpointStore(stateRoot);
|
|
204
219
|
const checkpoint = store.load(params.runId, params.taskId);
|
|
205
220
|
if (!checkpoint) {
|
|
206
221
|
return result("No checkpoint found.", { action: "checkpoint", status: "error" }, true);
|
|
207
222
|
}
|
|
208
223
|
return result(
|
|
209
|
-
`Checkpoint: step=${checkpoint.step}, progress=${checkpoint.progress}, savedAt=${new Date(checkpoint.savedAt).toISOString()}`,
|
|
210
|
-
{ action: "checkpoint", status: "ok", data: { checkpoint } },
|
|
224
|
+
`Checkpoint (source: checkpoint store): step=${checkpoint.step}, progress=${checkpoint.progress}, savedAt=${new Date(checkpoint.savedAt).toISOString()}`,
|
|
225
|
+
{ action: "checkpoint", status: "ok", data: { source: "checkpoint-store", checkpoint } },
|
|
211
226
|
);
|
|
212
227
|
}
|
|
213
228
|
default: {
|
|
@@ -221,3 +236,28 @@ export async function handleStatusDomain(params: TeamToolParamsValue, ctx: TeamC
|
|
|
221
236
|
}
|
|
222
237
|
}
|
|
223
238
|
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* R7-8 (DR-04 round-7): read `task.checkpoint` from tasks.json — the source
|
|
242
|
+
* production writers actually use (`checkpointTask` persists the checkpoint
|
|
243
|
+
* into the task record via saveRunTasks). Read-only and tolerant on purpose:
|
|
244
|
+
* a status query must never mutate run state (no quarantine/reconstruction),
|
|
245
|
+
* so a missing/corrupt tasks.json simply falls through to the store fallback.
|
|
246
|
+
* Handles both on-disk shapes: bare `TeamTaskState[]` (v0, current writer) and
|
|
247
|
+
* the defensive `{ schemaVersion, tasks }` envelope (see manifest-io.ts).
|
|
248
|
+
*/
|
|
249
|
+
function readTaskCheckpointFromTasksJson(stateRoot: string, taskId: string): TaskCheckpointState | undefined {
|
|
250
|
+
try {
|
|
251
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(path.join(stateRoot, "tasks.json"), "utf-8"));
|
|
252
|
+
const tasks: unknown = Array.isArray(parsed) ? parsed : (parsed as { tasks?: unknown }).tasks;
|
|
253
|
+
if (!Array.isArray(tasks)) return undefined;
|
|
254
|
+
for (const task of tasks) {
|
|
255
|
+
if (task === null || typeof task !== "object" || (task as { id?: unknown }).id !== taskId) continue;
|
|
256
|
+
const checkpoint = (task as { checkpoint?: unknown }).checkpoint;
|
|
257
|
+
return checkpoint !== null && typeof checkpoint === "object" ? (checkpoint as TaskCheckpointState) : undefined;
|
|
258
|
+
}
|
|
259
|
+
return undefined;
|
|
260
|
+
} catch {
|
|
261
|
+
return undefined; // ENOENT / corrupt tasks.json → caller falls back to the store
|
|
262
|
+
}
|
|
263
|
+
}
|