pi-crew 0.11.1 → 0.11.2
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 +39 -9
- package/README.md +161 -1036
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +744 -91462
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +1 -3
- package/schema.json +29 -0
- package/skills/real-test-pi-crew/SKILL.md +193 -36
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -0
- package/src/config/config.ts +47 -13
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -0
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +23 -0
- package/src/extension/help.ts +21 -10
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +31 -15
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +23 -0
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +35 -0
- package/src/extension/registration/lifecycle-handlers.ts +91 -30
- package/src/extension/registration/lifecycle.ts +75 -10
- package/src/extension/registration/observability.ts +98 -35
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/wire-cross-extension.ts +28 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal.ts +4 -1
- package/src/extension/team-tool/handle-settings.ts +19 -2
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +87 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +17 -11
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +5 -1
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/recovery/crash-recovery.ts +25 -1
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +142 -45
- package/src/runtime/task-runner.ts +21 -1
- package/src/runtime/team-runner.ts +38 -1
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +14 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +140 -15
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +112 -22
- package/src/state/types.ts +4 -0
- package/src/ui/dashboard-panes/agents-pane.ts +11 -2
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/keybinding-map.ts +22 -4
- package/src/ui/live-conversation-overlay.ts +6 -3
- package/src/ui/run-dashboard.ts +98 -5
- package/src/ui/run-snapshot-cache.ts +18 -1
- package/src/ui/spinner.ts +26 -2
- package/src/ui/tool-progress-formatter.ts +2 -1
- package/src/ui/tool-renderers/brief-mode.ts +2 -1
- package/src/ui/tool-renderers/index.ts +3 -3
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/worktree/worktree-manager.ts +206 -26
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- package/docs/runtime-flow.md +0 -148
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shadow-lifecycle.ts — broker-owned lifecycle for delegate grandchild SHADOW
|
|
3
|
+
* task records (RR-012, findings F03/F16; ADR-5 §1/§3 S1#1).
|
|
4
|
+
*
|
|
5
|
+
* The `delegate.request` handler (crew-broker.ts) mints a `gc-*` shadow task
|
|
6
|
+
* (agent "delegate", NO stepId) so the subId identity gets a real depth/role
|
|
7
|
+
* entry. The record is NOT a workflow task: it is managed by the EXTERNAL
|
|
8
|
+
* grandchild spawner (the broker), never by the workflow scheduler.
|
|
9
|
+
* isDelegateShadowTask() is the single discriminator the scheduler uses to
|
|
10
|
+
* keep shadow records out of DAG/batch selection — they carry no stepId, and
|
|
11
|
+
* dispatching one would throw ResourceNotFound in findStep()/findAgent()
|
|
12
|
+
* (dispatch-batch.ts; RR-012 §3 reachability proof in
|
|
13
|
+
* test/unit/runtime/broker/shadow-task-dag-readiness.test.ts).
|
|
14
|
+
*
|
|
15
|
+
* BR-09 (discriminator correctness): the marker is STRUCTURAL — the absence of
|
|
16
|
+
* `stepId` — NOT the agent name. Every scheduler-managed task sets
|
|
17
|
+
* `stepId: step.id` (state-store.ts materializeTasks), so "no stepId" means
|
|
18
|
+
* "not a workflow task". The previous test (`agent === "delegate"`) filtered
|
|
19
|
+
* out any LEGITIMATE workflow task whose resolved agent happens to be named
|
|
20
|
+
* `delegate` — such a task never entered a batch, was never dispatched, and
|
|
21
|
+
* finalize-run then marked the whole run `blocked`. (`delegate` is deliberately
|
|
22
|
+
* NOT in PROTECTED_AGENT_NAMES, so the name itself carries no reserved
|
|
23
|
+
* meaning.) Backward compatible both ways: legacy persisted shadows also lack
|
|
24
|
+
* `stepId` (still excluded) and legacy workflow tasks carry one (still
|
|
25
|
+
* dispatched); no schema change.
|
|
26
|
+
*
|
|
27
|
+
* KNOWN overlap (cold-verify): `reconstructTasksFromEventLog`
|
|
28
|
+
* (manifest-io.ts, ST-4 corruption recovery) also emits tasks WITHOUT `stepId`
|
|
29
|
+
* — those are classified as shadows and excluded from dispatch selection.
|
|
30
|
+
* That is fail-closed, and strictly better than the pre-guard behavior
|
|
31
|
+
* (findStep would throw ResourceNotFound and kill the batch): reconstructed
|
|
32
|
+
* tasks carry agent "unknown" and no step, so they cannot be dispatched
|
|
33
|
+
* anyway. Do NOT reuse this predicate to mean "broker shadow" outside
|
|
34
|
+
* scheduler selection.
|
|
35
|
+
*
|
|
36
|
+
* F16 lifecycle contract (broker-owned; every write under the run lock):
|
|
37
|
+
* queued → saved by the admission write (crew-broker.ts handleDelegateRequest)
|
|
38
|
+
* running → promoteShadowToRunning() from the spawner's onSpawn hook the
|
|
39
|
+
* moment the grandchild process exists (pid !== null). Idempotent:
|
|
40
|
+
* only a record still "queued" is promoted — a terminal flip or a
|
|
41
|
+
* stale-reconciler cancel always wins over a late promote.
|
|
42
|
+
* completed | failed → the unconditional terminal flip when the spawner
|
|
43
|
+
* settles (success, failure, error, timeout — every path).
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { withRunLockSync } from "../../../state/coordination/locks.ts";
|
|
47
|
+
import { loadRunManifestById, saveRunTasks } from "../../../state/stores/state-store.ts";
|
|
48
|
+
import type { TeamTaskState } from "../../../state/types.ts";
|
|
49
|
+
import { logInternalError } from "../../../utils/internal-error.ts";
|
|
50
|
+
|
|
51
|
+
/** Shadow-task discriminator (RR-012 + BR-09): a task record managed by the
|
|
52
|
+
* external delegate-broker spawner rather than the workflow scheduler.
|
|
53
|
+
*
|
|
54
|
+
* The marker is STRUCTURAL: shadow records are persisted with NO `stepId`
|
|
55
|
+
* (the broker's shadow literal in crew-broker.ts sets none), while every
|
|
56
|
+
* scheduler-materialized task sets `stepId: step.id`. A record without a
|
|
57
|
+
* stepId cannot be dispatched anyway — `findStep()` matches on `stepId` and
|
|
58
|
+
* throws ResourceNotFound — so "no stepId" is exactly the set the scheduler
|
|
59
|
+
* must skip. The agent name is NOT part of the predicate (BR-09: a real
|
|
60
|
+
* workflow task may legitimately resolve to an agent named `delegate`). */
|
|
61
|
+
export function isDelegateShadowTask(task: Pick<TeamTaskState, "id" | "agent" | "stepId">): boolean {
|
|
62
|
+
return task.stepId === undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** F16: queued→running promote, called from the spawner's onSpawn hook when
|
|
66
|
+
* the grandchild process exists. Best-effort by design: a failure logs and
|
|
67
|
+
* leaves the record as-is — the unconditional terminal flip on settle still
|
|
68
|
+
* runs, so a promote failure can never strand a record in "queued" past the
|
|
69
|
+
* settle (and depth-3 admission merely stays blocked until then, which is
|
|
70
|
+
* the fail-closed direction). */
|
|
71
|
+
export function promoteShadowToRunning(brokerCwd: string, runId: string, subId: string): void {
|
|
72
|
+
try {
|
|
73
|
+
const fresh = loadRunManifestById(brokerCwd, runId);
|
|
74
|
+
if (!fresh) return;
|
|
75
|
+
withRunLockSync(fresh.manifest, () => {
|
|
76
|
+
const latest = loadRunManifestById(brokerCwd, runId);
|
|
77
|
+
if (!latest) return;
|
|
78
|
+
const shadow = latest.tasks.find((t) => t.id === subId);
|
|
79
|
+
if (shadow?.status !== "queued") return;
|
|
80
|
+
saveRunTasks(
|
|
81
|
+
latest.manifest,
|
|
82
|
+
latest.tasks.map((t) => (t.id === subId ? { ...t, status: "running" as const } : t)),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
} catch (err) {
|
|
86
|
+
logInternalError(
|
|
87
|
+
"crew-broker.delegate.promote-shadow",
|
|
88
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
89
|
+
`runId=${runId} subId=${subId}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* wait-status-cache.ts — R10-3: mtime+size-gated load cache for task.waitStatus.
|
|
3
3
|
*
|
|
4
|
-
* Problem (docs/refactor-plan.review.md §Round 10, R10-3): the broker's
|
|
4
|
+
* Problem (docs/archive/refactor-plan.review.md §Round 10, R10-3): the broker's
|
|
5
5
|
* task.waitStatus poll loop ticks every 200ms per waiter and each tick calls
|
|
6
6
|
* loadRunManifestById() — stat manifest + stat tasks + (on the state-store
|
|
7
7
|
* manifest-cache miss, which every write invalidates via generation bump) a
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* `deps` (values) + `state` (getters/setters over the runChildPi-local flags)
|
|
21
21
|
* so behavior is preserved byte-for-byte.
|
|
22
22
|
*
|
|
23
|
-
* R6-F1 (refactor-plan.review.md §ROUND 6): clearAll() MUST cover all six
|
|
23
|
+
* R6-F1 (docs/archive/refactor-plan.review.md §ROUND 6): clearAll() MUST cover all six
|
|
24
24
|
* constructs — previously cancelHardKill was a local const inside abort() and
|
|
25
25
|
* was NOT cleared by clearChildPiTimeouts(), leaking a 200 ms unref'd timer per
|
|
26
26
|
* cancel on short-lived children.
|
|
@@ -131,6 +131,54 @@ export async function runMockChildPi(
|
|
|
131
131
|
stderr: "[MOCK] rate limit: mock failure",
|
|
132
132
|
};
|
|
133
133
|
|
|
134
|
+
// RR-013 (F04) test seam: reproduce the MuxSurface A1 degrade result WITHOUT a
|
|
135
|
+
// real multiplexer. child-executor's `childResult.surface?.degraded` branch is
|
|
136
|
+
// the ONLY producer of TaskExecutionResult.surfaceLost, and runTeamTask offers
|
|
137
|
+
// no seam to inject `surface.providers` (child-executor's runWorker() call
|
|
138
|
+
// passes no `surface` field), so no other mock mode can reach that branch.
|
|
139
|
+
//
|
|
140
|
+
// SCOPE NOTE: this mode does NOT simulate `classifyOnExit` (the 2s window) — it
|
|
141
|
+
// starts from the exact point where classifyOnExit already returned
|
|
142
|
+
// `degraded`. That is the correct scope: the F04 defect lives at the
|
|
143
|
+
// child-executor → task-runner → finalizer boundary, not in classify (which is
|
|
144
|
+
// covered by degrade.test.ts + child-pi-surface.test.ts).
|
|
145
|
+
//
|
|
146
|
+
// Guarded by the existing PI_CREW_ALLOW_MOCK parent-only check above, so it
|
|
147
|
+
// cannot be activated from a child worker.
|
|
148
|
+
if (mock === "surface-degraded") {
|
|
149
|
+
return {
|
|
150
|
+
exitCode: 0,
|
|
151
|
+
stdout: "",
|
|
152
|
+
stderr: "",
|
|
153
|
+
rawFinalText: "",
|
|
154
|
+
surface: {
|
|
155
|
+
kind: "tmux",
|
|
156
|
+
paneId: "%9",
|
|
157
|
+
scriptPath: "/tmp/pi-crew-mock-launch.sh",
|
|
158
|
+
degraded: {
|
|
159
|
+
cause: "pane-closed",
|
|
160
|
+
exitReason: "pane-closed",
|
|
161
|
+
classifiedAt: new Date().toISOString(),
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// RR-013 (AC-2) test seam: the ONLY result channel carrying text is
|
|
168
|
+
// `rawFinalText` (stdout/transcript are empty). This reproduces the compaction
|
|
169
|
+
// scenario the TaskExecutionResult.rawFinalText docstring describes —
|
|
170
|
+
// parsedOutput.finalText and finalStdout are both empty, so the spec-evidence
|
|
171
|
+
// footer union can only see the text if rawFinalText survives the
|
|
172
|
+
// child-executor → task-runner → finalizer boundary.
|
|
173
|
+
if (mock === "raw-final-text-only") {
|
|
174
|
+
return {
|
|
175
|
+
exitCode: 0,
|
|
176
|
+
stdout: "",
|
|
177
|
+
stderr: "",
|
|
178
|
+
rawFinalText: "RR-013 boundary probe result.\n\nSPEC-EVIDENCE:\nacc-1: ran the boundary check\n",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
134
182
|
// E2E fallback-chain fixture: invocation #1 returns a SILENT retryable
|
|
135
183
|
// failure (exit code 0, no real assistant text, message_end carries a
|
|
136
184
|
// retryable-pattern errorMessage). Invocation #2+ delegates to the
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
atomicWriteJson,
|
|
6
|
+
atomicWriteJsonCoalesced,
|
|
7
|
+
cancelPendingCoalescedWriteForPath,
|
|
8
|
+
flushPendingAtomicWrites,
|
|
9
|
+
peekPendingCoalescedWrite,
|
|
10
|
+
readJsonFile,
|
|
11
|
+
} from "../state/atomic-write.ts";
|
|
5
12
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
6
13
|
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
|
|
14
|
+
import { readLinesSince } from "../utils/incremental-reader.ts";
|
|
7
15
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
8
16
|
import { redactSecretString, redactSecrets } from "../utils/redaction.ts";
|
|
9
17
|
import { assertSafePathId, resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
@@ -237,7 +245,9 @@ function withAgentsLock<T>(manifest: TeamRunManifest, fn: () => T): T {
|
|
|
237
245
|
break;
|
|
238
246
|
} catch (error) {
|
|
239
247
|
const code = (error as NodeJS.ErrnoException).code;
|
|
240
|
-
|
|
248
|
+
// EPERM/EACCES/EBUSY: Windows cross-process contention (another handle
|
|
249
|
+
// holds the lock open) — retry like EEXIST, bounded by the deadline.
|
|
250
|
+
if (code !== "EEXIST" && code !== "EISDIR" && code !== "EPERM" && code !== "EACCES" && code !== "EBUSY") throw error;
|
|
241
251
|
if (code === "EISDIR") {
|
|
242
252
|
try {
|
|
243
253
|
fs.rmSync(filePath, { recursive: true, force: true });
|
|
@@ -282,19 +292,22 @@ function setAsyncAgentReaderCache(
|
|
|
282
292
|
}
|
|
283
293
|
|
|
284
294
|
export function readCrewAgents(manifest: TeamRunManifest): CrewAgentRecord[] {
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
|
|
295
|
+
// F06 / RR-017: read-after-write WITHOUT forcing the pending coalesced write
|
|
296
|
+
// to disk. `upsertCrewAgent` always reads first, so the previous scoped
|
|
297
|
+
// `flushPendingAtomicWrites(agentsPath)` (a scoped flush IS a write when an
|
|
298
|
+
// entry exists — atomic-write.ts:1147) turned every non-terminal upsert into
|
|
299
|
+
// a durable agents.json rewrite, destroying the very coalescing window the
|
|
300
|
+
// caller had just created (measured: 20 progress upserts → 19 agents.json
|
|
301
|
+
// renames). The buffered value is byte-for-byte what a flush would have
|
|
302
|
+
// written, so overlaying it preserves the "intra-process readers see the
|
|
303
|
+
// latest data" contract (and cross-process readers still see the file within
|
|
304
|
+
// one coalesce window) with zero I/O.
|
|
305
|
+
const agentsFilePath = agentsPath(manifest);
|
|
306
|
+
const pending = peekPendingCoalescedWrite<CrewAgentRecord[]>(agentsFilePath);
|
|
292
307
|
try {
|
|
293
|
-
const records =
|
|
294
|
-
|
|
295
|
-
AGENT_READER_TTL_MS,
|
|
296
|
-
() => readJsonFile<CrewAgentRecord[]>(agentsPath(manifest)) ?? [],
|
|
297
|
-
);
|
|
308
|
+
const records =
|
|
309
|
+
pending ??
|
|
310
|
+
readJsonFileCoalesced(agentsFilePath, AGENT_READER_TTL_MS, () => readJsonFile<CrewAgentRecord[]>(agentsFilePath) ?? []);
|
|
298
311
|
// Validate schema and deduplicate by id to handle concurrent write conflicts
|
|
299
312
|
const seen = new Set<string>();
|
|
300
313
|
const deduped = records.filter((r) => {
|
|
@@ -368,6 +381,122 @@ export async function readCrewAgentsAsync(manifest: TeamRunManifest): Promise<Cr
|
|
|
368
381
|
return inFlight;
|
|
369
382
|
}
|
|
370
383
|
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
// F06 / RR-017: dirty tracking for per-task status.json writes.
|
|
386
|
+
//
|
|
387
|
+
// `saveCrewAgents`/`saveCrewAgentsCoalesced` used to loop over EVERY record and
|
|
388
|
+
// write its status.json, with no notion of which records actually changed. The
|
|
389
|
+
// measured cost of updating ONE of four `completed` records was 6 renames / 12
|
|
390
|
+
// fsyncs, and the three unchanged records each paid a full durable rename they
|
|
391
|
+
// had no reason to pay.
|
|
392
|
+
//
|
|
393
|
+
// SCOPE OF THE SKIP — durable (terminal) writes only, deliberately:
|
|
394
|
+
// - a coalesced write is buffered in RAM and collapses per path, so skipping
|
|
395
|
+
// it saves no I/O; and a skip there could not be trusted anyway, since the
|
|
396
|
+
// buffered value may never reach disk (the memo would then claim a write
|
|
397
|
+
// that a crash erased). Coalesced writes therefore stay unconditional.
|
|
398
|
+
// - a DURABLE write is only skipped when the exact same bytes are already
|
|
399
|
+
// durably on disk: the memo is written ONLY by durable writes, and the skip
|
|
400
|
+
// is re-validated against the file actually existing. So a skip can never
|
|
401
|
+
// lose durability — it can only avoid re-writing identical bytes.
|
|
402
|
+
// Durability is otherwise untouched: agents.json keeps full durability on every
|
|
403
|
+
// save, terminal status keeps its deliberate DOUBLE write (H2/F4 — the second
|
|
404
|
+
// write is unconditional and is NOT removed here), and non-terminal status keeps
|
|
405
|
+
// the coalesced best-effort path.
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Last record content (redacted, JSON-serialized) written DURABLY per status
|
|
410
|
+
* path.
|
|
411
|
+
*
|
|
412
|
+
* INVARIANT (single-writer, RM-03 / review MINOR 4): this memo is a
|
|
413
|
+
* PROCESS-GLOBAL Map with no locking, and it is only correct because exactly
|
|
414
|
+
* ONE writer owns a given `statusPath` at a time. Two concurrent writers for
|
|
415
|
+
* the same path would each see a stale memo and could skip a durable write
|
|
416
|
+
* that the other expects to have happened. The single-writer property is
|
|
417
|
+
* provided by the run-lock / task-claim protocol (see
|
|
418
|
+
* src/state/coordination/locks.ts): a task is claimed by one agent, and only
|
|
419
|
+
* that agent calls saveCrewAgents for its statusPath.
|
|
420
|
+
*
|
|
421
|
+
* Do NOT add a second writer for the same statusPath without first replacing
|
|
422
|
+
* this memo with a lock-protected (or content-addressed) structure — and do
|
|
423
|
+
* NOT rely on the memo across processes (it is per-process; cross-process
|
|
424
|
+
* readers see the file, which is why needsDurableStatusWrite re-checks
|
|
425
|
+
* fs.existsSync rather than trusting the memo).
|
|
426
|
+
*
|
|
427
|
+
* Bounded by LAST_WRITTEN_STATUS_MAX_ENTRIES with FIFO eviction, so an evicted
|
|
428
|
+
* entry merely causes one redundant durable write (safe), never a skipped one.
|
|
429
|
+
*/
|
|
430
|
+
const lastWrittenStatus = new Map<string, string>();
|
|
431
|
+
const LAST_WRITTEN_STATUS_MAX_ENTRIES = 2048;
|
|
432
|
+
|
|
433
|
+
/** @internal Test-only: how many records a save actually persisted status for. */
|
|
434
|
+
let lastSaveStatusWriteCount = 0;
|
|
435
|
+
/** @internal Test-only: read the counter from the most recent save call. */
|
|
436
|
+
export function __test__lastAgentStatusWriteCount(): number {
|
|
437
|
+
return lastSaveStatusWriteCount;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** @internal Test-only: drop the durable-write memo. */
|
|
441
|
+
export function __test__clearWrittenStatusMemo(): void {
|
|
442
|
+
lastWrittenStatus.clear();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function statusSnapshotKey(record: CrewAgentRecord): string {
|
|
446
|
+
return JSON.stringify(redactSecrets(record));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* True when `record`'s status.json must be written durably: either its content
|
|
451
|
+
* differs from the last durable snapshot for that path, or the file is gone
|
|
452
|
+
* (someone else deleted it — never trust the memo over the filesystem).
|
|
453
|
+
*/
|
|
454
|
+
function needsDurableStatusWrite(manifest: TeamRunManifest, record: CrewAgentRecord): boolean {
|
|
455
|
+
const statusPath = agentStatusPath(manifest, record.taskId);
|
|
456
|
+
const snapshot = statusSnapshotKey(record);
|
|
457
|
+
if (lastWrittenStatus.get(statusPath) !== snapshot) return true;
|
|
458
|
+
return !fs.existsSync(statusPath);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function rememberWrittenStatus(manifest: TeamRunManifest, record: CrewAgentRecord): void {
|
|
462
|
+
const statusPath = agentStatusPath(manifest, record.taskId);
|
|
463
|
+
if (lastWrittenStatus.has(statusPath)) lastWrittenStatus.delete(statusPath);
|
|
464
|
+
lastWrittenStatus.set(statusPath, statusSnapshotKey(record));
|
|
465
|
+
while (lastWrittenStatus.size > LAST_WRITTEN_STATUS_MAX_ENTRIES) {
|
|
466
|
+
const oldest = lastWrittenStatus.keys().next().value;
|
|
467
|
+
if (oldest === undefined) break;
|
|
468
|
+
lastWrittenStatus.delete(oldest);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Drop the memo for a status path whose file was deleted (cancel/removal). */
|
|
473
|
+
function forgetWrittenStatus(statusPath: string): void {
|
|
474
|
+
lastWrittenStatus.delete(statusPath);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function writeStatusForRecords(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
|
|
478
|
+
lastSaveStatusWriteCount = 0;
|
|
479
|
+
for (const record of records) {
|
|
480
|
+
// H2 (2026-08-10): per-task status.json is a DENORMALIZED read
|
|
481
|
+
// optimization for the dashboard/notifier, not an authoritative
|
|
482
|
+
// record — agents.json + events.jsonl cover crash recovery.
|
|
483
|
+
// Previously EVERY record (including running/queued progress
|
|
484
|
+
// snapshots) got a full fsync: N+1 fsyncs per saveCrewAgents call
|
|
485
|
+
// (50-task team ≈ 750ms blocking). Only TERMINAL records keep
|
|
486
|
+
// full durability (F4: notifier/dashboard must see the final
|
|
487
|
+
// state immediately); non-terminal per-task status is best-effort
|
|
488
|
+
// coalesced like the upsertCrewAgent non-terminal path.
|
|
489
|
+
if (TERMINAL_AGENT_STATUSES.has(record.status ?? "")) {
|
|
490
|
+
if (!needsDurableStatusWrite(manifest, record)) continue;
|
|
491
|
+
lastSaveStatusWriteCount++;
|
|
492
|
+
writeCrewAgentStatus(manifest, record);
|
|
493
|
+
} else {
|
|
494
|
+
lastSaveStatusWriteCount++;
|
|
495
|
+
writeCrewAgentStatusCoalesced(manifest, record);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
371
500
|
export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
|
|
372
501
|
// P0-3: flush any pending coalesced (best-effort) write first so a stale
|
|
373
502
|
// debounced progress snapshot can't clobber this durable write.
|
|
@@ -379,22 +508,7 @@ export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentReco
|
|
|
379
508
|
// durability.
|
|
380
509
|
atomicWriteJson(filePath, redactSecrets(records));
|
|
381
510
|
asyncAgentReaderCache.delete(filePath);
|
|
382
|
-
|
|
383
|
-
// H2 (2026-08-10): per-task status.json is a DENORMALIZED read
|
|
384
|
-
// optimization for the dashboard/notifier, not an authoritative
|
|
385
|
-
// record — agents.json + events.jsonl cover crash recovery.
|
|
386
|
-
// Previously EVERY record (including running/queued progress
|
|
387
|
-
// snapshots) got a full fsync: N+1 fsyncs per saveCrewAgents call
|
|
388
|
-
// (50-task team ≈ 750ms blocking). Only TERMINAL records keep
|
|
389
|
-
// full durability (F4: notifier/dashboard must see the final
|
|
390
|
-
// state immediately); non-terminal per-task status is best-effort
|
|
391
|
-
// coalesced like the upsertCrewAgent non-terminal path.
|
|
392
|
-
if (TERMINAL_AGENT_STATUSES.has(record.status ?? "")) {
|
|
393
|
-
writeCrewAgentStatus(manifest, record);
|
|
394
|
-
} else {
|
|
395
|
-
writeCrewAgentStatusCoalesced(manifest, record);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
511
|
+
writeStatusForRecords(manifest, records);
|
|
398
512
|
});
|
|
399
513
|
}
|
|
400
514
|
|
|
@@ -431,6 +545,15 @@ export function removeCrewAgent(manifest: TeamRunManifest, taskId: string): { re
|
|
|
431
545
|
// 2. Remove per-task status.json
|
|
432
546
|
try {
|
|
433
547
|
const statusPath = agentStatusPath(manifest, taskId);
|
|
548
|
+
// F10 / RR-016: cancel the pending coalesced status write for THIS exact
|
|
549
|
+
// path BEFORE unlinking. The record was just dropped from the index, so
|
|
550
|
+
// `flushPendingAgentWrites` (which only iterates records still in the
|
|
551
|
+
// index) will never drain — or cancel — its buffered write. Without this
|
|
552
|
+
// the 250ms timer fired AFTER the unlink and RE-CREATED the file with the
|
|
553
|
+
// stale pre-cancel snapshot (measured: status.json reappearing as
|
|
554
|
+
// `status:"running"` after the exit drain while agents.json stayed empty).
|
|
555
|
+
cancelPendingCoalescedWriteForPath(statusPath);
|
|
556
|
+
forgetWrittenStatus(statusPath);
|
|
434
557
|
if (fs.existsSync(statusPath)) {
|
|
435
558
|
fs.unlinkSync(statusPath);
|
|
436
559
|
removedStatus = true;
|
|
@@ -480,6 +603,10 @@ export function writeCrewAgentStatus(manifest: TeamRunManifest, record: CrewAgen
|
|
|
480
603
|
// F4: terminal agent status (completed/failed/cancelled/blocked) — keep full
|
|
481
604
|
// durability so the notifier/dashboard health sees the final state immediately.
|
|
482
605
|
atomicWriteJson(agentStatusPath(manifest, record.taskId), redactSecrets(record), { durability: "full" });
|
|
606
|
+
// F06/RR-017: record the DURABLE snapshot so a later save can skip re-writing
|
|
607
|
+
// byte-identical content for this record (see writeStatusForRecords). Only
|
|
608
|
+
// durable writes memoize — a coalesced write may never reach disk.
|
|
609
|
+
rememberWrittenStatus(manifest, record);
|
|
483
610
|
}
|
|
484
611
|
|
|
485
612
|
// 2.5 — coalesced variants. Buffer per-agent record + aggregate writes for
|
|
@@ -496,7 +623,7 @@ export function saveCrewAgentsCoalesced(manifest: TeamRunManifest, records: Crew
|
|
|
496
623
|
// events.jsonl independently of these JSON files.
|
|
497
624
|
atomicWriteJsonCoalesced(filePath, redactSecrets(records), AGENT_COALESCE_MS, { durability: "best-effort" });
|
|
498
625
|
asyncAgentReaderCache.delete(filePath);
|
|
499
|
-
|
|
626
|
+
writeStatusForRecords(manifest, records);
|
|
500
627
|
}
|
|
501
628
|
|
|
502
629
|
export function writeCrewAgentStatusCoalesced(manifest: TeamRunManifest, record: CrewAgentRecord): void {
|
|
@@ -640,6 +767,183 @@ function readCrewAgentEvents(manifest: TeamRunManifest, taskId: string): unknown
|
|
|
640
767
|
return readCrewAgentEventsCursor(manifest, taskId).events;
|
|
641
768
|
}
|
|
642
769
|
|
|
770
|
+
// ---------------------------------------------------------------------------
|
|
771
|
+
// F07 / RR-017: incremental agent-event cursor.
|
|
772
|
+
//
|
|
773
|
+
// The legacy reader did `readFileSync → split → JSON.parse each line → filter →
|
|
774
|
+
// slice`, applying `sinceSeq`/`limit` only AFTER reading and parsing the WHOLE
|
|
775
|
+
// file. Measured on a 10000-event file: 697788 bytes read per IDLE poll, linear
|
|
776
|
+
// in file size, and the inline panel polls it every 700 ms
|
|
777
|
+
// (src/ui/inline-panel/index.ts:46, consumer agent-transcript.ts:291).
|
|
778
|
+
//
|
|
779
|
+
// The fix REUSES the existing incremental primitive
|
|
780
|
+
// (`readLinesSince` in src/utils/incremental-reader.ts — the same module
|
|
781
|
+
// src/state/event-log/cursor.ts uses) instead of adding a new reader. A per-path
|
|
782
|
+
// watermark holds `{byteOffset, lineCount, prefixMaxSeq, size, mtimeMs, ino,
|
|
783
|
+
// dev}` — O(1) memory per path, bounded to 512 paths:
|
|
784
|
+
// - idle poll (unchanged size/mtimeMs/inode): ZERO event bytes read;
|
|
785
|
+
// - append: reads only the delta [byteOffset, size);
|
|
786
|
+
// - shrink (truncation) or inode replacement (rotation/rewrite): the watermark
|
|
787
|
+
// is discarded and the file is re-read from 0 — the same "rewrite-then-
|
|
788
|
+
// regrow" hole cursor.ts closes with inode stamping.
|
|
789
|
+
//
|
|
790
|
+
// WHY A PREFIX IS NEVER RE-READ (soundness): the cached prefix is the set of
|
|
791
|
+
// events already consumed before `byteOffset`. `prefixMaxSeq` is the maximum seq
|
|
792
|
+
// seen in that prefix, so EVERY prefix event satisfies `seq <= prefixMaxSeq`.
|
|
793
|
+
// When `sinceSeq >= prefixMaxSeq`, no prefix event can pass the
|
|
794
|
+
// `seq > sinceSeq` filter, so the answer is exactly "the delta's matches" —
|
|
795
|
+
// provable without assuming file order equals seq order. When
|
|
796
|
+
// `sinceSeq < prefixMaxSeq` the reader falls back to a full read from 0, so
|
|
797
|
+
// correctness never depends on the bound being tight.
|
|
798
|
+
//
|
|
799
|
+
// LEGACY SEQ SEMANTICS ARE PRESERVED EXACTLY: a line without a numeric `seq`
|
|
800
|
+
// gets `seq = lineIndex + 1`, where lineIndex is the index of that line among
|
|
801
|
+
// the file's NON-EMPTY lines. `readLinesSince` only increments `lineCount` for
|
|
802
|
+
// non-empty lines, so a delta's absolute base index is the cached `lineCount`
|
|
803
|
+
// and the parity holds without re-reading from offset 0.
|
|
804
|
+
//
|
|
805
|
+
// A trailing fragment without "\n" (a mid-append partial line) is held back by
|
|
806
|
+
// `readLinesSince` (it does not advance the committed offset past it), so it is
|
|
807
|
+
// neither lost nor parsed as a torn event — the next poll sees the completed
|
|
808
|
+
// line. Every writer of this file appends a trailing "\n", so this only ever
|
|
809
|
+
// defers a mid-write fragment.
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
interface AgentEventsCursorState {
|
|
813
|
+
byteOffset: number;
|
|
814
|
+
lineCount: number;
|
|
815
|
+
/** Max seq among events already consumed before `byteOffset`. */
|
|
816
|
+
prefixMaxSeq: number;
|
|
817
|
+
size: number;
|
|
818
|
+
mtimeMs: number;
|
|
819
|
+
ino: number;
|
|
820
|
+
dev: number;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const agentEventsCursorCache = new Map<string, AgentEventsCursorState>();
|
|
824
|
+
const AGENT_EVENTS_CURSOR_CACHE_MAX_ENTRIES = 512;
|
|
825
|
+
|
|
826
|
+
// F07 test hook (AC-5/AC-6/AC-11): bytes of event payload the cursor actually
|
|
827
|
+
// read. The tests assert STRUCTURALLY (bytes read, not wall-clock), so the
|
|
828
|
+
// reader exposes this counter instead of the suite monkey-patching `node:fs`
|
|
829
|
+
// (whose ESM named exports are frozen). Mirrors the `__test__agentRecordBufferCount`
|
|
830
|
+
// precedent in this file. Only the incremental read path increments it.
|
|
831
|
+
let agentEventBytesRead = 0;
|
|
832
|
+
|
|
833
|
+
/** @internal Test-only: reset the F07 byte counter. */
|
|
834
|
+
export function __test__resetAgentEventBytesRead(): void {
|
|
835
|
+
agentEventBytesRead = 0;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** @internal Test-only: event-file bytes read since the last reset. */
|
|
839
|
+
export function __test__agentEventBytesRead(): number {
|
|
840
|
+
return agentEventBytesRead;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** @internal Test-only: drop the per-path incremental cursor watermarks. */
|
|
844
|
+
export function __test__clearAgentEventsCursorCache(): void {
|
|
845
|
+
agentEventsCursorCache.clear();
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** @internal Test-only: inspect a watermark (undefined when uncached). */
|
|
849
|
+
export function __test__agentEventsCursorState(filePath: string): AgentEventsCursorState | undefined {
|
|
850
|
+
return agentEventsCursorCache.get(filePath);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function setAgentEventsCursorState(filePath: string, state: AgentEventsCursorState): void {
|
|
854
|
+
if (agentEventsCursorCache.has(filePath)) agentEventsCursorCache.delete(filePath);
|
|
855
|
+
agentEventsCursorCache.set(filePath, state);
|
|
856
|
+
while (agentEventsCursorCache.size > AGENT_EVENTS_CURSOR_CACHE_MAX_ENTRIES) {
|
|
857
|
+
const oldest = agentEventsCursorCache.keys().next().value;
|
|
858
|
+
if (oldest === undefined) break;
|
|
859
|
+
agentEventsCursorCache.delete(oldest);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Parse raw JSONL lines into cursor events, assigning the legacy `seq = index+1`
|
|
865
|
+
* fallback for lines that carry no numeric seq. `lineIndexBase` is the index of
|
|
866
|
+
* the FIRST line in this batch among the file's non-empty lines (0 when reading
|
|
867
|
+
* from offset 0).
|
|
868
|
+
*
|
|
869
|
+
* A single trailing "\r" is stripped so CRLF files behave like the legacy
|
|
870
|
+
* `split(/\r?\n/)` reader (JSON.parse tolerates it either way; the difference
|
|
871
|
+
* only shows up in the `raw` payload of an already-corrupt line).
|
|
872
|
+
*/
|
|
873
|
+
function parseAgentEventLines(lines: string[], lineIndexBase: number): { events: Array<Record<string, unknown>>; maxSeq: number } {
|
|
874
|
+
const events: Array<Record<string, unknown>> = [];
|
|
875
|
+
let maxSeq = 0;
|
|
876
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
877
|
+
const raw = lines[index]!;
|
|
878
|
+
const text = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
879
|
+
const lineIndex = lineIndexBase + index;
|
|
880
|
+
let event: Record<string, unknown>;
|
|
881
|
+
try {
|
|
882
|
+
const parsed = JSON.parse(text) as unknown;
|
|
883
|
+
event = parsed !== null && typeof parsed === "object" ? (parsed as Record<string, unknown>) : { seq: lineIndex + 1, raw: text };
|
|
884
|
+
if (typeof event.seq !== "number") event.seq = lineIndex + 1;
|
|
885
|
+
} catch {
|
|
886
|
+
event = { seq: lineIndex + 1, raw: text };
|
|
887
|
+
}
|
|
888
|
+
events.push(event);
|
|
889
|
+
if (typeof event.seq === "number" && event.seq > maxSeq) maxSeq = event.seq;
|
|
890
|
+
}
|
|
891
|
+
return { events, maxSeq };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Read the agent events file, consuming only the bytes that are new since the
|
|
896
|
+
* last read of this path, and return every event with `seq > sinceSeq` (the
|
|
897
|
+
* legacy `total` is this array's length before `limit` is applied).
|
|
898
|
+
*/
|
|
899
|
+
function readAgentEventMatches(filePath: string, sinceSeq: number): Array<Record<string, unknown>> {
|
|
900
|
+
let stat: fs.Stats;
|
|
901
|
+
try {
|
|
902
|
+
stat = fs.statSync(filePath);
|
|
903
|
+
} catch {
|
|
904
|
+
return [];
|
|
905
|
+
}
|
|
906
|
+
const cached = agentEventsCursorCache.get(filePath);
|
|
907
|
+
const sameFile = cached !== undefined && cached.ino === stat.ino && cached.dev === stat.dev;
|
|
908
|
+
// A shrink (truncate) or an inode change (rotation / rewrite) invalidates the
|
|
909
|
+
// byte offset: the new content must be read from 0 or events are lost.
|
|
910
|
+
const usable = sameFile && stat.size > cached.byteOffset;
|
|
911
|
+
const canServeDelta = usable && sinceSeq >= cached.prefixMaxSeq;
|
|
912
|
+
const canServeIdle = sameFile && stat.size === cached.byteOffset && stat.mtimeMs === cached.mtimeMs && sinceSeq >= cached.prefixMaxSeq;
|
|
913
|
+
|
|
914
|
+
if (canServeIdle) {
|
|
915
|
+
// Nothing changed AND no prefix event can pass the filter — no bytes read.
|
|
916
|
+
return [];
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const baseOffset = canServeDelta ? cached.byteOffset : 0;
|
|
920
|
+
const baseLineCount = canServeDelta ? cached.lineCount : 0;
|
|
921
|
+
const baseMaxSeq = canServeDelta ? cached.prefixMaxSeq : 0;
|
|
922
|
+
const result = readLinesSince(filePath, { byteOffset: baseOffset, lineCount: baseLineCount });
|
|
923
|
+
// `readLinesSince` reads [state.byteOffset, fileSize); account for the bytes
|
|
924
|
+
// it requested (a held-back trailing fragment is still READ, just not parsed).
|
|
925
|
+
agentEventBytesRead += Math.max(0, stat.size - baseOffset);
|
|
926
|
+
const { events, maxSeq } = parseAgentEventLines(result.lines, baseLineCount);
|
|
927
|
+
const nextSeqInFile = Math.max(baseMaxSeq, maxSeq);
|
|
928
|
+
setAgentEventsCursorState(filePath, {
|
|
929
|
+
byteOffset: result.state.byteOffset,
|
|
930
|
+
lineCount: result.state.lineCount,
|
|
931
|
+
prefixMaxSeq: nextSeqInFile,
|
|
932
|
+
// `Math.max` covers a concurrent append that landed between the stat above
|
|
933
|
+
// and the read: storing the SMALLER (stale) size would make the next poll
|
|
934
|
+
// see `size === byteOffset` and needlessly re-read the whole file.
|
|
935
|
+
size: Math.max(stat.size, result.state.byteOffset),
|
|
936
|
+
mtimeMs: stat.mtimeMs,
|
|
937
|
+
ino: stat.ino,
|
|
938
|
+
dev: stat.dev,
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
// `events` is the whole-file parse when we could not serve a delta, and the
|
|
942
|
+
// delta-only parse otherwise — either way it holds EVERY event with
|
|
943
|
+
// seq > sinceSeq, because in the delta case the prefix is provably all <= sinceSeq.
|
|
944
|
+
return events.filter((event) => typeof event.seq === "number" && event.seq > sinceSeq);
|
|
945
|
+
}
|
|
946
|
+
|
|
643
947
|
export function readCrewAgentEventsCursor(
|
|
644
948
|
manifest: TeamRunManifest,
|
|
645
949
|
taskId: string,
|
|
@@ -676,20 +980,8 @@ export function readCrewAgentEventsCursor(
|
|
|
676
980
|
const sinceSeq =
|
|
677
981
|
typeof options.sinceSeq === "number" && Number.isInteger(options.sinceSeq) && options.sinceSeq >= 0 ? options.sinceSeq : 0;
|
|
678
982
|
const limit = typeof options.limit === "number" && Number.isInteger(options.limit) && options.limit >= 0 ? options.limit : undefined;
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
.split(/\r?\n/)
|
|
682
|
-
.filter(Boolean)
|
|
683
|
-
.map((line, index) => {
|
|
684
|
-
try {
|
|
685
|
-
const event = JSON.parse(line) as Record<string, unknown>;
|
|
686
|
-
if (typeof event.seq !== "number") event.seq = index + 1;
|
|
687
|
-
return event;
|
|
688
|
-
} catch {
|
|
689
|
-
return { seq: index + 1, raw: line };
|
|
690
|
-
}
|
|
691
|
-
});
|
|
692
|
-
const filtered = parsed.filter((event) => typeof event.seq === "number" && event.seq > sinceSeq);
|
|
983
|
+
|
|
984
|
+
const filtered = readAgentEventMatches(filePath, sinceSeq);
|
|
693
985
|
const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
|
|
694
986
|
const returnedMaxSeq = events.reduce((max, event) => (typeof event.seq === "number" ? Math.max(max, event.seq) : max), sinceSeq);
|
|
695
987
|
return {
|