pi-crew 0.11.11 → 0.11.13
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 +102 -0
- package/dist/index.mjs +551 -519
- package/package.json +1 -1
- package/src/config/config-validation.ts +9 -0
- package/src/config/defaults.ts +7 -0
- package/src/config/env-vars.ts +24 -0
- package/src/config/types.ts +11 -0
- package/src/extension/registration/tool-registration.ts +7 -0
- package/src/extension/registration/verify-gate-tool.ts +263 -0
- package/src/extension/team-tool/run-deadline.ts +22 -1
- package/src/extension/team-tool/run.ts +38 -2
- package/src/extension/team-tool-types.ts +4 -0
- package/src/extension/team-tool.ts +31 -3
- package/src/prompt/inbox-poll.ts +141 -3
- package/src/runtime/broker/crew-broker.ts +48 -2
- package/src/runtime/broker/protocol/lock-busy.ts +47 -1
- package/src/runtime/broker/protocol/request-parsers.ts +8 -1
- package/src/runtime/budget-enforcement.ts +36 -20
- package/src/runtime/child-pi/child-pi-constants.ts +24 -0
- package/src/runtime/child-pi/child-pi-kill.ts +59 -2
- package/src/runtime/child-pi/child-pi-rpc.ts +1021 -0
- package/src/runtime/child-pi/child-pi-steering.ts +10 -8
- package/src/runtime/child-pi/child-pi-streams.ts +69 -19
- package/src/runtime/child-pi/child-pi.ts +29 -30
- package/src/runtime/child-pi/owned-process.ts +434 -0
- package/src/runtime/child-pi/postmortem-registry.ts +134 -0
- package/src/runtime/classifier/ambient-noise-gate.ts +126 -0
- package/src/runtime/classifier/classifier-service.ts +277 -61
- package/src/runtime/dispatch-batch.ts +32 -0
- package/src/runtime/live-session/live-agent-control.ts +2 -0
- package/src/runtime/live-session/live-agent-manager.ts +72 -2
- package/src/runtime/live-session/live-session-runtime.ts +435 -40
- package/src/runtime/merge-loop.ts +63 -35
- package/src/runtime/recovery/crash-recovery.ts +141 -3
- package/src/runtime/recovery/retry-executor.ts +29 -0
- package/src/runtime/run-tracker.ts +28 -0
- package/src/runtime/stale-reconciler.ts +36 -1
- package/src/runtime/task-runner.ts +103 -0
- package/src/runtime/verification/classifier-pre-gate.ts +262 -0
- package/src/runtime/verification/verification-gates.ts +5 -1
- package/src/runtime/verification/verify-gate.ts +394 -0
- package/src/schema/config-schema.ts +13 -0
- package/src/state/contracts.ts +18 -0
- package/src/state/coordination/mailbox.ts +203 -55
- package/src/state/stores/sqlite-run-state.ts +378 -0
- package/src/state/stores/state-store.ts +134 -6
- package/src/ui/agents-jobs-browser.ts +154 -8
- package/src/ui/events-state-source.ts +358 -0
- package/src/ui/run-snapshot-cache.ts +87 -3
- package/src/ui/widget/widget-model.ts +13 -2
|
@@ -53,10 +53,14 @@ import { existsSync, readdirSync } from "node:fs";
|
|
|
53
53
|
import { join } from "node:path";
|
|
54
54
|
import { fileURLToPath } from "node:url";
|
|
55
55
|
import { matchesKey } from "@earendil-works/pi-tui";
|
|
56
|
+
import { listRecentRuns } from "../extension/run-index.ts";
|
|
56
57
|
import { getScheduledJobs, getScheduledJobsHiddenCountView } from "../extension/team-tool/handle-schedule.ts";
|
|
57
58
|
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
58
59
|
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
59
60
|
import type { LiveAgentHandle } from "../runtime/live-session/live-agent-manager.ts";
|
|
61
|
+
import type { ManifestCache } from "../runtime/manifest-cache.ts";
|
|
62
|
+
import { createManifestCache } from "../runtime/manifest-cache.ts";
|
|
63
|
+
import { isDisplayActiveRun } from "../runtime/process-status.ts";
|
|
60
64
|
import type { ScheduledJob } from "../runtime/scheduling/scheduler.ts";
|
|
61
65
|
import { surfaceGateEnvSnapshot } from "../runtime/surface/resolve-surface.ts";
|
|
62
66
|
import { getTaskUsage } from "../runtime/usage-tracker.ts";
|
|
@@ -70,6 +74,7 @@ import { renderAgentsPane } from "./dashboard-panes/agents-pane.ts";
|
|
|
70
74
|
import { renderScheduleDetails, schedulesHiddenJobsHintLine } from "./dashboard-panes/schedules-pane.ts";
|
|
71
75
|
import { computeLiveDurationMs } from "./live-duration.ts";
|
|
72
76
|
import { CURSOR, canopyLine, formatHint, overflowHint, RAIL, railLeaders, railLine } from "./rail.ts";
|
|
77
|
+
import { createRunSnapshotCache, type RunSnapshotCache } from "./run-snapshot-cache.ts";
|
|
73
78
|
import type { RunUiSnapshot } from "./snapshot-types.ts";
|
|
74
79
|
import { spinnerFrame } from "./spinner.ts";
|
|
75
80
|
import { iconForStatus } from "./status-colors.ts";
|
|
@@ -124,6 +129,17 @@ export interface AgentsJobsBrowserOptions {
|
|
|
124
129
|
rows?: number;
|
|
125
130
|
/** Test seam: replace the live-agent data source. */
|
|
126
131
|
agentsProvider?: () => AgentsBrowserAgentEntry[];
|
|
132
|
+
/**
|
|
133
|
+
* U7 (spec 2026-10-09): committed-state caches for the DEFAULT data path
|
|
134
|
+
* (no agentsProvider). Inject to share the host's caches; when omitted the
|
|
135
|
+
* browser lazily creates its OWN pair — snapshot cache with the opt-in
|
|
136
|
+
* events-state layer (derived view over events.jsonl: snapshot + tail-follow
|
|
137
|
+
* frames via the existing readEventsCursor watermark cursor) — so the 400ms
|
|
138
|
+
* refresh tick reads COMMITTED snapshots instead of re-parsing
|
|
139
|
+
* manifests + agents.json from disk every tick.
|
|
140
|
+
*/
|
|
141
|
+
manifestCache?: ManifestCache;
|
|
142
|
+
snapshotCache?: RunSnapshotCache;
|
|
127
143
|
/** Wired by viewers.ts: open the DurableTranscriptViewer overlay for an agent (the `/crew transcript` experience). */
|
|
128
144
|
onOpenTranscript?: (entry: { runId: string; taskId: string }) => void;
|
|
129
145
|
/** Test seam: replace the job data source (G17 provider by default). */
|
|
@@ -142,6 +158,10 @@ export interface AgentsJobsBrowserOptions {
|
|
|
142
158
|
|
|
143
159
|
const REFRESH_TTL_MS_DEFAULT = 600;
|
|
144
160
|
const POLL_INTERVAL_MS = 400;
|
|
161
|
+
/** U7: legacy-discovery cadence — every Nth data reload (≈ N × refresh TTL
|
|
162
|
+
* while the overlay is open) also runs the lenient run-index scan for runs
|
|
163
|
+
* the cache validators cannot see. Steady-state ticks stay cache-only. */
|
|
164
|
+
const LEGACY_DISCOVERY_TICKS = 12;
|
|
145
165
|
/** Body height clamp — mirrors the dashboard's stable-height lesson: a
|
|
146
166
|
* fluctuating line count shifts the overlay anchor every frame. */
|
|
147
167
|
const MIN_BODY = 8;
|
|
@@ -247,10 +267,32 @@ export class AgentsJobsBrowser {
|
|
|
247
267
|
private notice: { text: string; until: number } | undefined;
|
|
248
268
|
private pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
249
269
|
private readonly manifests = new Map<string, TeamRunManifest | null>();
|
|
270
|
+
/** U7: committed-state caches (default data path only). */
|
|
271
|
+
private readonly manifestCache?: ManifestCache;
|
|
272
|
+
private readonly snapshotCache?: RunSnapshotCache;
|
|
273
|
+
/** True when THIS browser created the cache (dispose must tear it down). */
|
|
274
|
+
private readonly ownsManifestCache: boolean;
|
|
275
|
+
private readonly ownsSnapshotCache: boolean;
|
|
276
|
+
/** Data reloads since construction (legacy-discovery cadence). */
|
|
277
|
+
private reloadCount = 0;
|
|
250
278
|
|
|
251
279
|
constructor(options: AgentsJobsBrowserOptions) {
|
|
252
280
|
this.options = options;
|
|
253
281
|
this.theme = options.theme ?? asCrewTheme(undefined);
|
|
282
|
+
// U7: the DEFAULT data path reads committed snapshots (cache reads, no
|
|
283
|
+
// disk per tick) driven by the events-state layer's frames; the legacy
|
|
284
|
+
// per-tick disk re-parse stays ONLY for the agentsProvider test seam.
|
|
285
|
+
if (!options.agentsProvider) {
|
|
286
|
+
this.ownsManifestCache = options.manifestCache === undefined;
|
|
287
|
+
this.ownsSnapshotCache = options.snapshotCache === undefined;
|
|
288
|
+
this.manifestCache = options.manifestCache ?? createManifestCache(options.cwd);
|
|
289
|
+
this.snapshotCache =
|
|
290
|
+
options.snapshotCache ?? createRunSnapshotCache(options.cwd, { eventsState: { pollMs: POLL_INTERVAL_MS } });
|
|
291
|
+
this.discoverRuns();
|
|
292
|
+
} else {
|
|
293
|
+
this.ownsManifestCache = false;
|
|
294
|
+
this.ownsSnapshotCache = false;
|
|
295
|
+
}
|
|
254
296
|
this.refreshData(true);
|
|
255
297
|
this.pollTimer = setInterval(() => {
|
|
256
298
|
if (this.closed) return;
|
|
@@ -298,6 +340,10 @@ export class AgentsJobsBrowser {
|
|
|
298
340
|
clearInterval(this.pollTimer);
|
|
299
341
|
this.pollTimer = undefined;
|
|
300
342
|
}
|
|
343
|
+
// U7: tear down the caches this browser created (timer + events-state
|
|
344
|
+
// sources with it). Injected caches stay alive — the host still uses them.
|
|
345
|
+
if (this.ownsSnapshotCache) this.snapshotCache?.dispose?.();
|
|
346
|
+
if (this.ownsManifestCache) this.manifestCache?.dispose();
|
|
301
347
|
}
|
|
302
348
|
|
|
303
349
|
// Retired with the p key (2026-09-14): kept for the future "focus the
|
|
@@ -319,23 +365,68 @@ export class AgentsJobsBrowser {
|
|
|
319
365
|
const at = this.nowMs();
|
|
320
366
|
if (!force && ttl > 0 && at - this.cachedAt < ttl) return;
|
|
321
367
|
this.cachedAt = at;
|
|
322
|
-
const agents = this.loadAgents();
|
|
368
|
+
const agents = this.loadAgents(force);
|
|
323
369
|
const { jobs, hiddenCount } = this.loadJobs();
|
|
324
370
|
this.hiddenCount = hiddenCount;
|
|
325
371
|
this.cachedEntries = [...agents, ...jobs.map((job) => ({ kind: "job" as const, job }))];
|
|
326
372
|
if (this.selected >= this.cachedEntries.length) this.selected = Math.max(0, this.cachedEntries.length - 1);
|
|
327
373
|
}
|
|
328
374
|
|
|
329
|
-
private loadAgents(): AgentsBrowserAgentEntry[] {
|
|
375
|
+
private loadAgents(force = false): AgentsBrowserAgentEntry[] {
|
|
330
376
|
if (this.options.agentsProvider) {
|
|
331
377
|
return [...this.options.agentsProvider()].sort((a, b) => statusRank(a.status) - statusRank(b.status));
|
|
332
378
|
}
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
379
|
+
// U7 (spec 2026-10-09): committed-state path — the widget pipeline over
|
|
380
|
+
// the manifest cache (stat-gated parse-if-changed) + snapshot cache
|
|
381
|
+
// (events-state frames → coalesced async refresh). A refresh tick now
|
|
382
|
+
// costs in-memory snapshot reads + a few stats; the per-tick
|
|
383
|
+
// listRecentRuns + readCrewAgents disk re-parse is gone. Parity: the
|
|
384
|
+
// SAME activeWidgetRuns projection the old path used, so the counts can
|
|
385
|
+
// never disagree with the widget.
|
|
386
|
+
if (this.manifestCache && this.snapshotCache) {
|
|
387
|
+
try {
|
|
388
|
+
const runs = activeWidgetRuns(
|
|
389
|
+
this.options.cwd,
|
|
390
|
+
this.manifestCache,
|
|
391
|
+
this.snapshotCache,
|
|
392
|
+
undefined,
|
|
393
|
+
this.options.workspaceId,
|
|
394
|
+
);
|
|
395
|
+
this.discoverRuns();
|
|
396
|
+
const nowMs = this.nowMs();
|
|
397
|
+
// Legacy-parity fallback: runs the cache pipeline cannot SEE (run-index
|
|
398
|
+
// accepts hand-written/minimal manifests that state-store and
|
|
399
|
+
// manifest-cache validators reject). At construct/invalidate (force)
|
|
400
|
+
// plus every LEGACY_DISCOVERY_TICKS-th reload, run the legacy
|
|
401
|
+
// listRecentRuns discovery and read agents.json directly for uncovered
|
|
402
|
+
// active runs — steady-state ticks stay cache-only.
|
|
403
|
+
this.reloadCount += 1;
|
|
404
|
+
if (force || this.reloadCount % LEGACY_DISCOVERY_TICKS === 1) {
|
|
405
|
+
this.appendLegacyUncoveredRuns(runs, nowMs);
|
|
406
|
+
}
|
|
407
|
+
const entries: AgentsBrowserAgentEntry[] = [];
|
|
408
|
+
for (const { run, agents } of runs) {
|
|
409
|
+
for (const record of agents) {
|
|
410
|
+
entries.push({
|
|
411
|
+
kind: "agent",
|
|
412
|
+
runId: run.runId,
|
|
413
|
+
taskId: record.taskId,
|
|
414
|
+
role: record.role || record.agent || "agent",
|
|
415
|
+
agentName: record.agent,
|
|
416
|
+
status: record.status,
|
|
417
|
+
tokPerSec: recordTokPerSec(record, nowMs),
|
|
418
|
+
record,
|
|
419
|
+
manifest: run,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return entries.sort((a, b) => statusRank(a.status) - statusRank(b.status));
|
|
424
|
+
} catch (error) {
|
|
425
|
+
logInternalError("agents-browser.loadAgents", error as Error);
|
|
426
|
+
return [];
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// Legacy path (agent-tool subagents etc.): direct disk read.
|
|
339
430
|
let runs: ReturnType<typeof activeWidgetRuns> = [];
|
|
340
431
|
try {
|
|
341
432
|
runs = activeWidgetRuns(this.options.cwd, undefined, undefined, undefined, this.options.workspaceId);
|
|
@@ -363,6 +454,61 @@ export class AgentsJobsBrowser {
|
|
|
363
454
|
return entries.sort((a, b) => statusRank(a.status) - statusRank(b.status));
|
|
364
455
|
}
|
|
365
456
|
|
|
457
|
+
/**
|
|
458
|
+
* U7: legacy-parity discovery — listRecentRuns (the lenient run-index scan
|
|
459
|
+
* the pre-U7 default path used every tick) for ACTIVE runs the committed
|
|
460
|
+
* path did not cover, reading agents.json directly for each. Normally a
|
|
461
|
+
* no-op (the caches cover every validator-clean run); it exists so
|
|
462
|
+
* hand-written/minimal manifests that state-store + manifest-cache reject
|
|
463
|
+
* stay visible exactly as before.
|
|
464
|
+
*/
|
|
465
|
+
private appendLegacyUncoveredRuns(runs: ReturnType<typeof activeWidgetRuns>, nowMs: number): void {
|
|
466
|
+
try {
|
|
467
|
+
const covered = new Set(runs.map((item) => item.run.runId));
|
|
468
|
+
for (const run of listRecentRuns(this.options.cwd, 20)) {
|
|
469
|
+
if (covered.has(run.runId)) continue;
|
|
470
|
+
if (run.status !== "running" && run.status !== "queued") continue;
|
|
471
|
+
try {
|
|
472
|
+
const agents = readCrewAgents(run);
|
|
473
|
+
if (isDisplayActiveRun(run, agents, nowMs)) runs.push({ run, agents });
|
|
474
|
+
} catch {
|
|
475
|
+
/* unreadable — skip */
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
} catch {
|
|
479
|
+
/* non-fatal — next cadence tick retries */
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* U7: seed the snapshot cache for runs not yet populated. Missing entries
|
|
485
|
+
* are primed through the SYNC build path ONCE per run (Tier 11a
|
|
486
|
+
* read-your-writes — the construct-time sync contract the legacy per-tick
|
|
487
|
+
* disk read provided); afterwards every tick reads committed snapshots and
|
|
488
|
+
* only the events-state frames / TTL refresh the entries.
|
|
489
|
+
*/
|
|
490
|
+
private discoverRuns(): void {
|
|
491
|
+
if (!this.manifestCache || !this.snapshotCache) return;
|
|
492
|
+
try {
|
|
493
|
+
const runs = this.manifestCache.list(20).filter((run) => run.status === "running" || run.status === "queued");
|
|
494
|
+
for (const run of runs) {
|
|
495
|
+
if (this.snapshotCache.get(run.runId)) continue;
|
|
496
|
+
try {
|
|
497
|
+
this.snapshotCache.refreshIfStale(run.runId);
|
|
498
|
+
} catch {
|
|
499
|
+
/* unreadable run — the async preload below retries */
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const runIds = runs.map((run) => run.runId);
|
|
503
|
+
if (runIds.length > 0)
|
|
504
|
+
void this.snapshotCache.preloadAllStale(runIds).catch(() => {
|
|
505
|
+
/* best-effort preload; next cadence tick retries */
|
|
506
|
+
});
|
|
507
|
+
} catch {
|
|
508
|
+
/* non-fatal — next tick retries */
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
366
512
|
private loadJobs(): { jobs: ScheduledJob[]; hiddenCount: number } {
|
|
367
513
|
if (this.options.jobsProvider) return this.options.jobsProvider();
|
|
368
514
|
try {
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* U7 (upgrade spec 2026-10-09, §TIER 2): CommittedStateSource pattern for
|
|
3
|
+
* events.jsonl → views.
|
|
4
|
+
*
|
|
5
|
+
* Borrowed from pi-durable's mount layer: TaskGraph / ConversationView wrap
|
|
6
|
+
* the commit log in a `CommittedStateSource` → `ReplicatedStateSource`
|
|
7
|
+
* (task-graph.ts:83-92, view.ts:104-113). Three properties this adapter
|
|
8
|
+
* mirrors:
|
|
9
|
+
*
|
|
10
|
+
* 1. SNAPSHOT — the initial state is ONE parse of events.jsonl up to the
|
|
11
|
+
* current seq (a storage scan, O(live records), no event replay — the
|
|
12
|
+
* point the spec calls "điểm MẠNH").
|
|
13
|
+
* 2. FRAMES — afterwards the source tail-follows the log with the EXISTING
|
|
14
|
+
* `readEventsCursor` verified-watermark cursor (state/event-log/cursor.ts):
|
|
15
|
+
* each discovered batch of committed events becomes one frame applied to
|
|
16
|
+
* the state and fanned out to subscribers. An unchanged log costs one
|
|
17
|
+
* stat + zero event bytes (the cursor cache serves ring answers).
|
|
18
|
+
* 3. OVERFLOW — durable buffers frames and, when the buffer would exceed
|
|
19
|
+
* 100 frames, replaces the ROOT instead of replaying them
|
|
20
|
+
* (observation.ts:227-232). Here: a poll that needs MORE than
|
|
21
|
+
* `maxBufferedFrames` batches to drain the backlog performs ONE root
|
|
22
|
+
* replacement (fresh snapshot parse) and emits a single `resync` frame.
|
|
23
|
+
*
|
|
24
|
+
* events.jsonl is NEVER written by this module — it is the audit spine; this
|
|
25
|
+
* is a pure derived view layer (spec U7 constraint).
|
|
26
|
+
*
|
|
27
|
+
* DELIBERATE DIVERGENCE from run-snapshot-cache's own events slice: the
|
|
28
|
+
* cursor path only carries events with `metadata.seq` (documented limitation
|
|
29
|
+
* on readEventsCursor), while the cache's tail read keeps seq-less lines too.
|
|
30
|
+
* Consumers that must stay byte-identical to the legacy slice (the snapshot
|
|
31
|
+
* cache parity contract) therefore keep computing that slice themselves and
|
|
32
|
+
* use this source as the CHANGE SIGNAL (frames → refresh), not as the data.
|
|
33
|
+
*/
|
|
34
|
+
import * as fs from "node:fs";
|
|
35
|
+
import type { TeamEvent } from "../state/event-log/event-log.ts";
|
|
36
|
+
import { readEventsCursor } from "../state/event-log/event-log.ts";
|
|
37
|
+
|
|
38
|
+
/** durable observation.ts:227-232 — buffer 100 frames, then root-replace. */
|
|
39
|
+
export const MAX_BUFFERED_FRAMES_DEFAULT = 100;
|
|
40
|
+
/** Recent-events window kept in the committed state (matches the snapshot
|
|
41
|
+
* cache's DEFAULT_RECENT_EVENTS so frames carry a comparable tail). */
|
|
42
|
+
export const DEFAULT_RECENT_EVENTS_WINDOW = 20;
|
|
43
|
+
/** Events per cursor batch = events per frame. Durable commits one frame per
|
|
44
|
+
* commit; pi-crew's buffered writer commits per batch, so one cursor batch
|
|
45
|
+
* (default 20 events) is the natural frame unit. */
|
|
46
|
+
const FRAME_BATCH_EVENTS_DEFAULT = 20;
|
|
47
|
+
/** Registry hard cap — live sources are small but must not grow unbounded. */
|
|
48
|
+
const REGISTRY_MAX_LIVE_SOURCES = 64;
|
|
49
|
+
|
|
50
|
+
export interface EventsStateSnapshot {
|
|
51
|
+
/** Bounded recent-events window (seq-ascending, newest last). */
|
|
52
|
+
events: TeamEvent[];
|
|
53
|
+
/** Last committed seq (0 while the log has no seq-carrying events). */
|
|
54
|
+
seq: number;
|
|
55
|
+
/** Monotonic version — bumped on every commit (frame or resync). */
|
|
56
|
+
version: number;
|
|
57
|
+
/** True when the last commit was a root replacement (resync). */
|
|
58
|
+
resynced: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface EventsStateFrame {
|
|
62
|
+
/** Committed seq BEFORE this frame. */
|
|
63
|
+
fromSeq: number;
|
|
64
|
+
/** Committed seq AFTER this frame. */
|
|
65
|
+
toSeq: number;
|
|
66
|
+
/** Delta events of this frame — the FULL new window when `resync`. */
|
|
67
|
+
events: TeamEvent[];
|
|
68
|
+
/** Snapshot version AFTER applying this frame. */
|
|
69
|
+
version: number;
|
|
70
|
+
/** Root-replacement frame (overflow resync): re-read snapshot(). */
|
|
71
|
+
resync: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type EventsStateListener = (frame: EventsStateFrame) => void;
|
|
75
|
+
|
|
76
|
+
export interface EventsStateStats {
|
|
77
|
+
/** poll() calls (each = one stat + cursor read when enabled). */
|
|
78
|
+
polls: number;
|
|
79
|
+
/** Frames committed (a resync counts as one frame). */
|
|
80
|
+
frames: number;
|
|
81
|
+
/** Root replacements performed (overflow / inode-shrink / forced). */
|
|
82
|
+
resyncs: number;
|
|
83
|
+
/** Events delivered to listeners across all frames. */
|
|
84
|
+
eventsDelivered: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface EventsStateSourceOptions {
|
|
88
|
+
/** Bounded recent-events window (default 20). */
|
|
89
|
+
recentEvents?: number;
|
|
90
|
+
/** Max frames a single poll will apply before root-replacing (default 100). */
|
|
91
|
+
maxBufferedFrames?: number;
|
|
92
|
+
/** Events per cursor batch / frame (default 20). */
|
|
93
|
+
frameBatchEvents?: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface EventsStateSource {
|
|
97
|
+
readonly runId: string;
|
|
98
|
+
readonly eventsPath: string;
|
|
99
|
+
/** Committed state (defensive copy). */
|
|
100
|
+
snapshot(): EventsStateSnapshot;
|
|
101
|
+
/** Frames are emitted synchronously inside poll()/resync(). */
|
|
102
|
+
subscribe(listener: EventsStateListener): () => void;
|
|
103
|
+
/**
|
|
104
|
+
* Tail-follow now. Returns the number of frames committed (a resync
|
|
105
|
+
* counts as one). Cheap on an unchanged log: one stat + a ring-served
|
|
106
|
+
* cursor read (zero event bytes).
|
|
107
|
+
*/
|
|
108
|
+
poll(): number;
|
|
109
|
+
/** Force a root replacement (fresh snapshot parse + resync frame). */
|
|
110
|
+
resync(): EventsStateSnapshot;
|
|
111
|
+
stats(): EventsStateStats;
|
|
112
|
+
/** Refcount release — the shared registry drops the source at zero refs. */
|
|
113
|
+
dispose(): void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface SourceInternals {
|
|
117
|
+
poll(): number;
|
|
118
|
+
resync(): EventsStateSnapshot;
|
|
119
|
+
/** Eviction-only hard teardown — bypasses the refcount (see below). */
|
|
120
|
+
forceDispose(): void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function createEventsStateSource(
|
|
124
|
+
runId: string,
|
|
125
|
+
eventsPath: string,
|
|
126
|
+
options: EventsStateSourceOptions = {},
|
|
127
|
+
): EventsStateSource & SourceInternals {
|
|
128
|
+
const windowSize = Math.max(1, options.recentEvents ?? DEFAULT_RECENT_EVENTS_WINDOW);
|
|
129
|
+
const maxBufferedFrames = Math.max(1, options.maxBufferedFrames ?? MAX_BUFFERED_FRAMES_DEFAULT);
|
|
130
|
+
const batchEvents = Math.max(1, options.frameBatchEvents ?? FRAME_BATCH_EVENTS_DEFAULT);
|
|
131
|
+
const listeners = new Set<EventsStateListener>();
|
|
132
|
+
let events: TeamEvent[] = [];
|
|
133
|
+
let seq = 0;
|
|
134
|
+
let version = 0;
|
|
135
|
+
let resynced = false;
|
|
136
|
+
let disposed = false;
|
|
137
|
+
let lastSize = -1;
|
|
138
|
+
let lastIno = -1;
|
|
139
|
+
const stats: EventsStateStats = { polls: 0, frames: 0, resyncs: 0, eventsDelivered: 0 };
|
|
140
|
+
|
|
141
|
+
function snapshot(): EventsStateSnapshot {
|
|
142
|
+
return { events: [...events], seq, version, resynced };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function emit(frame: EventsStateFrame): void {
|
|
146
|
+
for (const listener of listeners) {
|
|
147
|
+
try {
|
|
148
|
+
listener(frame);
|
|
149
|
+
} catch {
|
|
150
|
+
/* subscriber errors are non-fatal (run-event-bus convention) */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** STORAGE SCAN (spec U7): one parse of the (tail-capped) log to the
|
|
156
|
+
* current seq. No event replay — the root is rebuilt from storage. */
|
|
157
|
+
function rootBuild(): void {
|
|
158
|
+
let scanned: TeamEvent[] | undefined;
|
|
159
|
+
let scannedSeq = 0;
|
|
160
|
+
try {
|
|
161
|
+
const cursor = readEventsCursor(eventsPath, {});
|
|
162
|
+
scanned = cursor.events;
|
|
163
|
+
scannedSeq = cursor.nextSeq;
|
|
164
|
+
} catch {
|
|
165
|
+
/* unreadable log (EISDIR/EACCES mid-rotation, …) — keep the last
|
|
166
|
+
* committed state (the empty root at creation) instead of wiping
|
|
167
|
+
* committed events/seq on a transient read error (review MINOR-1). */
|
|
168
|
+
}
|
|
169
|
+
if (scanned !== undefined && scanned.length === 0 && scannedSeq === 0 && (seq > 0 || events.length > 0) && !fs.existsSync(eventsPath)) {
|
|
170
|
+
// readEventsCursor reports a MISSING log as an empty cursor — but a
|
|
171
|
+
// log that carried committed events a beat ago and now does not exist
|
|
172
|
+
// is a transient disappearance (run dir moving / the rename→wx-create
|
|
173
|
+
// window of rotation), NOT a compaction to empty. Keep the committed
|
|
174
|
+
// state — same policy as poll()'s miss-file branch — and keep the inode
|
|
175
|
+
// stamp so the next poll re-detects the rewrite and retries the resync.
|
|
176
|
+
scanned = undefined;
|
|
177
|
+
}
|
|
178
|
+
// Stamp the inode/size the root was built from so the FIRST poll after
|
|
179
|
+
// an external rewrite (rotation/compaction) already triggers a resync
|
|
180
|
+
// instead of learning the inode one poll late. Stamp ONLY on a successful
|
|
181
|
+
// scan — on a failed scan keep the previous stamp so the next poll
|
|
182
|
+
// re-detects the rewrite and RETRIES the resync.
|
|
183
|
+
if (scanned !== undefined) {
|
|
184
|
+
try {
|
|
185
|
+
const stat = fs.statSync(eventsPath);
|
|
186
|
+
lastIno = stat.ino;
|
|
187
|
+
lastSize = stat.size;
|
|
188
|
+
} catch {
|
|
189
|
+
lastIno = -1;
|
|
190
|
+
lastSize = -1;
|
|
191
|
+
}
|
|
192
|
+
events = scanned.slice(-windowSize);
|
|
193
|
+
seq = scannedSeq;
|
|
194
|
+
}
|
|
195
|
+
resynced = true;
|
|
196
|
+
version += 1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function resync(): EventsStateSnapshot {
|
|
200
|
+
const fromSeq = seq;
|
|
201
|
+
rootBuild();
|
|
202
|
+
stats.resyncs += 1;
|
|
203
|
+
stats.frames += 1;
|
|
204
|
+
const frame: EventsStateFrame = { fromSeq, toSeq: seq, events: [...events], version, resync: true };
|
|
205
|
+
stats.eventsDelivered += frame.events.length;
|
|
206
|
+
emit(frame);
|
|
207
|
+
return snapshot();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function poll(): number {
|
|
211
|
+
if (disposed) return 0;
|
|
212
|
+
stats.polls += 1;
|
|
213
|
+
// Root-replacement triggers BESIDES the frame-buffer overflow: an
|
|
214
|
+
// inode change or size shrink means the log was rewritten (rotation's
|
|
215
|
+
// rename+'wx' create, compaction's temp+rename, forget+import) — the
|
|
216
|
+
// committed window is no longer a suffix of the new file, so replay
|
|
217
|
+
// from the seq cursor alone would strand pre-rewrite events.
|
|
218
|
+
try {
|
|
219
|
+
const stat = fs.statSync(eventsPath);
|
|
220
|
+
if (lastIno !== -1 && (stat.ino !== lastIno || stat.size < lastSize)) {
|
|
221
|
+
lastIno = stat.ino;
|
|
222
|
+
lastSize = stat.size;
|
|
223
|
+
resync();
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
226
|
+
lastIno = stat.ino;
|
|
227
|
+
lastSize = stat.size;
|
|
228
|
+
} catch {
|
|
229
|
+
// File gone (run dir moving / not created yet): keep the last
|
|
230
|
+
// committed state — the audit spine may reappear.
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
let committed = 0;
|
|
234
|
+
for (;;) {
|
|
235
|
+
const cursor = readEventsCursor(eventsPath, { sinceSeq: seq, limit: batchEvents });
|
|
236
|
+
if (cursor.events.length === 0) break;
|
|
237
|
+
const toSeq = Math.max(seq, cursor.nextSeq);
|
|
238
|
+
if (toSeq <= seq) break; // no forward progress — stop (loop guard)
|
|
239
|
+
const frameEvents = cursor.events;
|
|
240
|
+
const fromSeq = seq;
|
|
241
|
+
seq = toSeq;
|
|
242
|
+
events = [...events, ...frameEvents].slice(-windowSize);
|
|
243
|
+
resynced = false;
|
|
244
|
+
version += 1;
|
|
245
|
+
committed += 1;
|
|
246
|
+
stats.frames += 1;
|
|
247
|
+
stats.eventsDelivered += frameEvents.length;
|
|
248
|
+
emit({ fromSeq, toSeq, events: [...frameEvents], version, resync: false });
|
|
249
|
+
if (committed >= maxBufferedFrames) {
|
|
250
|
+
// OVERFLOW (durable :227-232): only root-replace when a backlog
|
|
251
|
+
// still remains — an exact drain at the cap needs no resync.
|
|
252
|
+
let backlog = false;
|
|
253
|
+
try {
|
|
254
|
+
backlog = readEventsCursor(eventsPath, { sinceSeq: seq, limit: 1 }).events.length > 0;
|
|
255
|
+
} catch {
|
|
256
|
+
backlog = false;
|
|
257
|
+
}
|
|
258
|
+
if (backlog) {
|
|
259
|
+
resync();
|
|
260
|
+
return committed + 1;
|
|
261
|
+
}
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return committed;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Initial snapshot: parse events.jsonl to the current seq at creation.
|
|
269
|
+
rootBuild();
|
|
270
|
+
|
|
271
|
+
const source: EventsStateSource & SourceInternals = {
|
|
272
|
+
runId,
|
|
273
|
+
eventsPath,
|
|
274
|
+
snapshot,
|
|
275
|
+
subscribe(listener: EventsStateListener): () => void {
|
|
276
|
+
listeners.add(listener);
|
|
277
|
+
return () => {
|
|
278
|
+
listeners.delete(listener);
|
|
279
|
+
};
|
|
280
|
+
},
|
|
281
|
+
poll,
|
|
282
|
+
resync,
|
|
283
|
+
stats(): EventsStateStats {
|
|
284
|
+
return { ...stats };
|
|
285
|
+
},
|
|
286
|
+
dispose(): void {
|
|
287
|
+
if (disposed) return;
|
|
288
|
+
// Refcount release (spec: sources are SHARED per events log — widget +
|
|
289
|
+
// surface panes tail-follow ONE cursor). While other holders remain,
|
|
290
|
+
// only drop this holder's reference; the instance keeps serving them.
|
|
291
|
+
const entry = registry.get(eventsPath);
|
|
292
|
+
if (entry && entry.source === source && entry.refs > 1) {
|
|
293
|
+
entry.refs -= 1;
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
disposed = true;
|
|
297
|
+
listeners.clear();
|
|
298
|
+
if (entry && entry.source === source) registry.delete(eventsPath);
|
|
299
|
+
},
|
|
300
|
+
// Eviction path (review MINOR-2): the registry's hard cap must dispose
|
|
301
|
+
// the entry REGARDLESS of refcount. Going through the refcounted
|
|
302
|
+
// dispose() above would only decrement an entry the eviction already
|
|
303
|
+
// deleted from the registry — leaving holders with an "undead" source
|
|
304
|
+
// and making the next eventsStateSource() call open a DUPLICATE
|
|
305
|
+
// tail-follow cursor on the same log.
|
|
306
|
+
forceDispose(): void {
|
|
307
|
+
disposed = true;
|
|
308
|
+
listeners.clear();
|
|
309
|
+
const entry = registry.get(eventsPath);
|
|
310
|
+
if (entry && entry.source === source) registry.delete(eventsPath);
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
return source;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ─── Shared registry (get-or-create per events log) ────────────────────────
|
|
317
|
+
|
|
318
|
+
interface RegistryEntry {
|
|
319
|
+
source: EventsStateSource & SourceInternals;
|
|
320
|
+
refs: number;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const registry = new Map<string, RegistryEntry>();
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* U7 adapter entry point: get-or-create the shared CommittedStateSource for
|
|
327
|
+
* one run's events.jsonl. Repeated calls with the same eventsPath return the
|
|
328
|
+
* SAME instance (refcounted) so widget + surface panes share one tail-follow
|
|
329
|
+
* cursor; dispose() each handle you take.
|
|
330
|
+
*/
|
|
331
|
+
export function eventsStateSource(runId: string, eventsPath: string, options: EventsStateSourceOptions = {}): EventsStateSource {
|
|
332
|
+
const existing = registry.get(eventsPath);
|
|
333
|
+
if (existing) {
|
|
334
|
+
existing.refs += 1;
|
|
335
|
+
return existing.source;
|
|
336
|
+
}
|
|
337
|
+
// Hard cap: live sources are tiny, but a pathological session (hundreds
|
|
338
|
+
// of short-lived runs) must not accumulate. Evict the oldest-registered
|
|
339
|
+
// entry wholesale via forceDispose() (refcount-blind, review MINOR-2):
|
|
340
|
+
// its holders keep a disposed no-op source and the next
|
|
341
|
+
// eventsStateSource() call for that path creates a fresh one.
|
|
342
|
+
if (registry.size >= REGISTRY_MAX_LIVE_SOURCES) {
|
|
343
|
+
const oldestKey = registry.keys().next().value;
|
|
344
|
+
if (oldestKey !== undefined) {
|
|
345
|
+
const evicted = registry.get(oldestKey);
|
|
346
|
+
registry.delete(oldestKey);
|
|
347
|
+
evicted?.source.forceDispose();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const source = createEventsStateSource(runId, eventsPath, options);
|
|
351
|
+
registry.set(eventsPath, { source, refs: 1 });
|
|
352
|
+
return source;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Test/observability hook: number of live (refcounted) sources. */
|
|
356
|
+
export function eventsStateSourceRegistrySize(): number {
|
|
357
|
+
return registry.size;
|
|
358
|
+
}
|