pi-crew 0.9.68 → 0.10.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 +222 -0
- package/NOTICE.md +21 -0
- package/README.md +44 -2
- package/agents/analyst.md +1 -1
- package/agents/cold-verifier.md +3 -1
- package/agents/critic.md +1 -1
- package/agents/executor.md +1 -1
- package/agents/explorer.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/verifier.md +1 -1
- package/agents/writer.md +1 -1
- package/dist/index.mjs +68113 -60774
- package/docs/README.md +2 -0
- package/docs/actions-reference.md +31 -0
- package/docs/commands-reference.md +17 -6
- package/docs/resource-formats.md +13 -0
- package/package.json +4 -2
- package/schema.json +503 -91
- package/scripts/resource-sampler.mjs +36 -2
- package/skills/requirements-to-task-packet/SKILL.md +26 -0
- package/skills/widget-rendering/SKILL.md +7 -7
- package/src/agents/agent-config.ts +2 -1
- package/src/agents/discover-agents.ts +23 -14
- package/src/config/config-merge.ts +183 -0
- package/src/config/config-validation.ts +687 -0
- package/src/config/config.ts +22 -864
- package/src/config/defaults.ts +43 -2
- package/src/config/drift-detector.ts +1 -1
- package/src/config/env-vars.ts +691 -0
- package/src/config/role-tools.ts +11 -9
- package/src/config/sanitize-project-config.ts +172 -0
- package/src/config/types.ts +49 -1
- package/src/extension/async-notifier.ts +25 -2
- package/src/extension/crew-cleanup.ts +13 -0
- package/src/extension/crew-vibes/config.ts +2 -1
- package/src/extension/crew-vibes/footer.ts +19 -0
- package/src/extension/crew-vibes/index.ts +11 -1
- package/src/extension/plan-orchestrate.ts +132 -0
- package/src/extension/register.ts +8 -0
- package/src/extension/registration/command-registration.ts +1 -0
- package/src/extension/registration/commands/dashboard.ts +158 -0
- package/src/extension/registration/commands/index.ts +35 -0
- package/src/extension/registration/commands/manage.ts +303 -0
- package/src/extension/registration/commands/run.ts +228 -0
- package/src/extension/registration/commands/shared.ts +639 -0
- package/src/extension/registration/commands/status.ts +60 -0
- package/src/extension/registration/commands.ts +13 -1224
- package/src/extension/registration/foreground-run-controller.ts +10 -2
- package/src/extension/registration/lifecycle-handlers.ts +178 -17
- package/src/extension/registration/runtime-cleanup.ts +23 -5
- package/src/extension/registration/subagent-tools.ts +218 -9
- package/src/extension/registration/team-tool.ts +5 -1
- package/src/extension/registration/ui.ts +5 -4
- package/src/extension/rpc-hmac.ts +5 -3
- package/src/extension/team-tool/api/heartbeat.ts +47 -10
- package/src/extension/team-tool/api/plan-approval.ts +9 -0
- package/src/extension/team-tool/api/task-claims.ts +109 -40
- package/src/extension/team-tool/cancel.ts +84 -50
- package/src/extension/team-tool/dispatch/index.ts +1 -0
- package/src/extension/team-tool/dispatch/run.ts +4 -1
- package/src/extension/team-tool/doctor.ts +103 -1
- package/src/extension/team-tool/orchestrate.ts +66 -1
- package/src/extension/team-tool/plans.ts +192 -0
- package/src/extension/team-tool/respond.ts +197 -65
- package/src/extension/team-tool/run-deadline.ts +35 -3
- package/src/extension/team-tool/run-intent.ts +63 -0
- package/src/extension/team-tool/run.ts +74 -20
- package/src/extension/team-tool/status.ts +84 -26
- package/src/extension/team-tool.ts +11 -2
- package/src/hooks/registry.ts +1 -6
- package/src/i18n.ts +9 -0
- package/src/prompt/prompt-runtime.ts +521 -2
- package/src/prompt/worker-events-channel.ts +173 -0
- package/src/runtime/README.md +8 -8
- package/src/runtime/async-runner.ts +7 -3
- package/src/runtime/background-runner.ts +42 -14
- package/src/runtime/broker/broker-issuer.ts +9 -2
- package/src/runtime/broker/crew-broker-tokens.ts +43 -6
- package/src/runtime/broker/crew-broker.ts +838 -10
- package/src/runtime/broker/wait-status-cache.ts +157 -0
- package/src/runtime/budget-enforcement.ts +281 -0
- package/src/runtime/child-pi/child-pi-constants.ts +8 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
- package/src/runtime/child-pi/child-pi-streams.ts +21 -1
- package/src/runtime/child-pi/child-pi-timers.ts +324 -0
- package/src/runtime/child-pi/child-pi.ts +97 -201
- package/src/runtime/child-pi/mock-fixtures.ts +16 -2
- package/src/runtime/crew-agent-records.ts +259 -14
- package/src/runtime/delegate-spawn.ts +148 -0
- package/src/runtime/detached-run-results.ts +90 -0
- package/src/runtime/deterministic-ast.ts +2 -1
- package/src/runtime/dispatch-batch.ts +945 -0
- package/src/runtime/finalize-run.ts +557 -0
- package/src/runtime/goal-workflow/adaptive-plan.ts +116 -15
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +8 -3
- package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
- package/src/runtime/group-join.ts +11 -125
- package/src/runtime/live-session/live-session-runtime.ts +26 -1
- package/src/runtime/merge-gate.ts +32 -10
- package/src/runtime/merge-loop.ts +130 -0
- package/src/runtime/model/model-budget-summary.ts +53 -0
- package/src/runtime/model/model-fallback.ts +36 -2
- package/src/runtime/model/pi-args.ts +10 -0
- package/src/runtime/model/provider-extensions.ts +10 -0
- package/src/runtime/orphan-worker-registry.ts +1 -1
- package/src/runtime/output/output-validator.ts +45 -0
- package/src/runtime/parent-guard.ts +3 -1
- package/src/runtime/peer-dep.ts +2 -1
- package/src/runtime/per-write-validator.ts +0 -5
- package/src/runtime/pi-spawn.ts +61 -15
- package/src/runtime/plan-approval.ts +125 -0
- package/src/runtime/plan-replan.ts +151 -0
- package/src/runtime/process-status.ts +16 -1
- package/src/runtime/recovery/checkpoint.ts +0 -18
- package/src/runtime/recovery/crash-recovery.ts +111 -46
- package/src/runtime/run-tracker.ts +77 -10
- package/src/runtime/scheduler-context.ts +98 -0
- package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
- package/src/runtime/scheduling/global-worker-cap.ts +2 -1
- package/src/runtime/scheduling/nested-slots.ts +70 -0
- package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
- package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
- package/src/runtime/settings-store.ts +219 -0
- package/src/runtime/spawn-policy.ts +217 -0
- package/src/runtime/stale-reconciler.ts +87 -6
- package/src/runtime/subagent-manager.ts +25 -1
- package/src/runtime/task-output-context.ts +230 -9
- package/src/runtime/task-packet.ts +23 -1
- package/src/runtime/task-runner/child-executor.ts +106 -7
- package/src/runtime/task-runner/post-execution.ts +125 -1
- package/src/runtime/task-runner/pre-execution.ts +39 -1
- package/src/runtime/task-runner/prompt-builder.ts +51 -1
- package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
- package/src/runtime/task-runner/spec-evidence.ts +403 -0
- package/src/runtime/task-runner/state-helpers.ts +26 -24
- package/src/runtime/task-runner.ts +11 -0
- package/src/runtime/team-runner.ts +132 -1673
- package/src/runtime/verification/spec-sandbox.ts +255 -0
- package/src/runtime/verification/verification-gates.ts +3 -2
- package/src/runtime/verification/verification-worktree.ts +2 -1
- package/src/runtime/workflow-phase-advance.ts +100 -0
- package/src/runtime/workspace-tree.ts +9 -0
- package/src/schema/config-schema.ts +66 -26
- package/src/schema/sensitive-config-paths.ts +64 -0
- package/src/schema/team-tool-schema.ts +13 -3
- package/src/state/README.md +4 -10
- package/src/state/atomic-write.ts +20 -3
- package/src/state/contracts.ts +38 -0
- package/src/state/coordination/mailbox.ts +12 -2
- package/src/state/event-log/cursor.ts +223 -0
- package/src/state/event-log/event-log-rotation.ts +12 -4
- package/src/state/event-log/event-log.ts +152 -369
- package/src/state/event-log/sequence-cache.ts +373 -0
- package/src/state/event-log/worker-atomic-writer.ts +2 -1
- package/src/state/stores/active-run-registry.ts +3 -2
- package/src/state/stores/manifest-io.ts +237 -0
- package/src/state/stores/ownership-map.ts +162 -0
- package/src/state/stores/plan-store.ts +241 -0
- package/src/state/stores/run-cache.ts +0 -90
- package/src/state/stores/spec-store.ts +189 -0
- package/src/state/stores/state-store.ts +139 -232
- package/src/state/types.ts +199 -0
- package/src/ui/dashboard-panes/plan-pane.ts +136 -0
- package/src/ui/dashboard-panes/progress-pane.ts +6 -0
- package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
- package/src/ui/dock-footer.ts +49 -0
- package/src/ui/heartbeat-aggregator.ts +9 -1
- package/src/ui/inline-panel/agent-pane.ts +375 -0
- package/src/ui/inline-panel/agent-transcript.ts +338 -0
- package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
- package/src/ui/inline-panel/crew-editor.ts +192 -0
- package/src/ui/inline-panel/index.ts +290 -0
- package/src/ui/inline-panel/panel-rows.ts +37 -0
- package/src/ui/inline-panel/panel-selection.ts +157 -0
- package/src/ui/inline-panel/panel-store.ts +111 -0
- package/src/ui/inline-panel/view-session-store.ts +36 -0
- package/src/ui/keybinding-map.ts +54 -13
- package/src/ui/pi-ui-compat.ts +9 -0
- package/src/ui/powerbar-publisher.ts +52 -1
- package/src/ui/run-dashboard.ts +31 -5
- package/src/ui/run-snapshot-cache.ts +57 -30
- package/src/ui/snapshot-types.ts +6 -1
- package/src/ui/widget/index.ts +176 -22
- package/src/ui/widget/task-list.ts +198 -0
- package/src/ui/widget/widget-formatters.ts +240 -4
- package/src/ui/widget/widget-renderer.ts +243 -38
- package/src/ui/widget/widget-types.ts +11 -0
- package/src/utils/child-process-shield.ts +106 -0
- package/src/utils/file-coalescer.ts +0 -4
- package/src/utils/fs-errno.ts +66 -0
- package/src/utils/fs-watch.ts +1 -1
- package/src/utils/internal-error.ts +3 -1
- package/src/utils/paths.ts +11 -3
- package/src/utils/redaction.ts +7 -0
- package/src/utils/safe-abort.ts +45 -0
- package/src/utils/task-name-generator.ts +1 -8
- package/src/workflows/discover-workflows.ts +20 -2
- package/src/workflows/validate-workflow.ts +7 -1
- package/src/workflows/workflow-config.ts +17 -0
- package/src/workflows/workflow-serializer.ts +3 -0
- package/src/worktree/worktree-manager.ts +22 -0
- package/workflows/default.workflow.md +36 -26
- package/workflows/strict-fast-fix.workflow.md +26 -0
- package/src/agents/agent-search.ts +0 -98
- package/src/benchmark/benchmark-runner.ts +0 -313
- package/src/benchmark/feedback-loop.ts +0 -73
- package/src/config/resilient-parser.ts +0 -117
- package/src/extension/crew-vibes/cat-frames.ts +0 -18
- package/src/extension/result-watcher.ts +0 -139
- package/src/observability/exporters/prometheus-exporter.ts +0 -54
- package/src/observability/metric-retention.ts +0 -64
- package/src/runtime/compaction/compaction-summary.ts +0 -278
- package/src/runtime/errors/crew-errors.ts +0 -162
- package/src/runtime/live-session/intercom-bridge.ts +0 -187
- package/src/runtime/loop-gates.ts +0 -128
- package/src/runtime/metric-parser.ts +0 -36
- package/src/runtime/output/stream-preview.ts +0 -184
- package/src/runtime/output/tool-progress.ts +0 -278
- package/src/runtime/phase-tracker.ts +0 -385
- package/src/runtime/pipeline-runner.ts +0 -523
- package/src/runtime/process/process-lifecycle.ts +0 -491
- package/src/runtime/recovery/retry-runner.ts +0 -330
- package/src/runtime/run-drift.ts +0 -219
- package/src/runtime/task-quality.ts +0 -199
- package/src/runtime/task-runner/run-projection.ts +0 -128
- package/src/runtime/verification/post-checks.ts +0 -142
- package/src/state/coordination/schedule.ts +0 -166
- package/src/state/event-log/jsonl-writer.ts +0 -115
- package/src/state/hook-instinct-bridge.ts +0 -94
- package/src/state/hook-integrations.ts +0 -51
- package/src/state/session-state-map.ts +0 -51
- package/src/state/stores/blob-store.ts +0 -308
- package/src/state/stores/instinct-store.ts +0 -275
- package/src/state/stores/observation-store.ts +0 -176
- package/src/state/tiered-eval.ts +0 -480
- package/src/state/types-eval.ts +0 -58
- package/src/tools/safe-bash-extension.ts +0 -54
- package/src/tools/safe-bash.ts +0 -505
- package/src/ui/agent-management-overlay.ts +0 -160
- package/src/ui/crew-footer.ts +0 -102
- package/src/ui/crew-select-list.ts +0 -114
- package/src/ui/dashboard-panes/capability-pane.ts +0 -77
- package/src/ui/transcript-entries.ts +0 -256
- package/src/utils/conflict-detect.ts +0 -721
- package/src/utils/fingerprint.ts +0 -180
- package/src/utils/gh-protocol.ts +0 -556
- package/src/utils/project-detector.ts +0 -160
- package/src/utils/sse-parser.ts +0 -131
- package/src/workflows/cost-estimator.ts +0 -34
- package/src/workflows/intermediate-store.ts +0 -166
|
@@ -4,18 +4,20 @@ import * as path from "node:path";
|
|
|
4
4
|
import { DEFAULT_EVENT_LOG } from "../../config/defaults.ts";
|
|
5
5
|
import { errors } from "../../errors.ts";
|
|
6
6
|
import { emitFromTeamEvent } from "../../ui/run-event-bus.ts";
|
|
7
|
-
import { type IncrementalReadState, readJsonlSince, readJsonlTail } from "../../utils/incremental-reader.ts";
|
|
8
7
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
9
8
|
import { redactSecrets } from "../../utils/redaction.ts";
|
|
10
9
|
import { sleep, sleepSync } from "../../utils/sleep.ts";
|
|
11
10
|
import { atomicWriteFile } from "../atomic-write.ts";
|
|
11
|
+
import { applyCompactionUnlocked, needsRotation, prepareCompaction, rotateEventLogUnlocked } from "./event-log-rotation.ts";
|
|
12
12
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
advanceSequenceCounter,
|
|
14
|
+
evictOldestSequenceCacheEntries,
|
|
15
|
+
MAX_SEQUENCE_CACHE_ENTRIES,
|
|
16
|
+
persistSequenceMonotonic,
|
|
17
|
+
reserveSequence,
|
|
18
|
+
reserveSequenceUnderLockAsync,
|
|
19
|
+
sequenceCache,
|
|
20
|
+
} from "./sequence-cache.ts";
|
|
19
21
|
import { appendFileViaWorker, isWorkerAtomicWriterEnabled } from "./worker-atomic-writer.ts";
|
|
20
22
|
|
|
21
23
|
export type TeamEventProvenance = "live_worker" | "test" | "healthcheck" | "replay" | "api" | "background" | "team_runner";
|
|
@@ -48,6 +50,14 @@ export interface TeamEventMetadata {
|
|
|
48
50
|
appended?: boolean;
|
|
49
51
|
fingerprint?: string;
|
|
50
52
|
confidence?: "low" | "medium" | "high";
|
|
53
|
+
/** R17-S1 (Phase 3.8): set to `true` when the append was SKIPPED because the
|
|
54
|
+
* events file exceeded MAX_EVENTS_BYTES after compaction+rotation and the
|
|
55
|
+
* event is non-terminal. The returned TeamEvent then represents an event
|
|
56
|
+
* that was NOT persisted (and NOT emitted to the run-event-bus) — callers
|
|
57
|
+
* can detect the silent drop instead of treating it as success. Never
|
|
58
|
+
* written to disk (skipped events are not appended), so this is not an
|
|
59
|
+
* on-disk format change. */
|
|
60
|
+
skippedDueToSize?: boolean;
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
export interface TeamEvent {
|
|
@@ -67,8 +77,6 @@ export type AppendTeamEvent = Omit<TeamEvent, "time" | "metadata"> & {
|
|
|
67
77
|
const TERMINAL_EVENT_TYPES = new Set<string>(DEFAULT_EVENT_LOG.terminalEventTypes);
|
|
68
78
|
const MAX_EVENTS_BYTES = 50 * 1024 * 1024;
|
|
69
79
|
|
|
70
|
-
const sequenceCache = new Map<string, { size: number; mtimeMs: number; seq: number; lastAccessMs: number }>();
|
|
71
|
-
const MAX_SEQUENCE_CACHE_ENTRIES = 256;
|
|
72
80
|
// P0-2: per-eventsPath append counter. Previously a single module-global shared
|
|
73
81
|
// across all runs AND never incremented on the async path, so the async rotation
|
|
74
82
|
// gate (`0 % 100 === 0`) was always true → needsRotation ran on EVERY async append
|
|
@@ -205,223 +213,6 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T, options
|
|
|
205
213
|
}
|
|
206
214
|
}
|
|
207
215
|
|
|
208
|
-
function evictOldestSequenceCacheEntries(): void {
|
|
209
|
-
// FIX: Evict by lastAccessMs (access time), not insertion order.
|
|
210
|
-
// Frequently accessed entries should be retained even if older.
|
|
211
|
-
const toEvict = Math.ceil(MAX_SEQUENCE_CACHE_ENTRIES / 2);
|
|
212
|
-
// Sort entries by lastAccessMs ascending (oldest first)
|
|
213
|
-
const entries = [...sequenceCache.entries()].sort((a, b) => a[1].lastAccessMs - b[1].lastAccessMs);
|
|
214
|
-
// Evict the oldest half
|
|
215
|
-
for (let i = 0; i < toEvict && i < entries.length; i++) {
|
|
216
|
-
sequenceCache.delete(entries[i][0]);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** @internal — exported for sequence-cache LRU testing (Round 19). */
|
|
221
|
-
export function __test__sequenceCacheSize(): number {
|
|
222
|
-
return sequenceCache.size;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** @internal — seed an entry into the sequence cache for testing. */
|
|
226
|
-
export function __test__seedSequenceCache(eventsPath: string, lastAccessMs: number): void {
|
|
227
|
-
sequenceCache.set(eventsPath, {
|
|
228
|
-
size: 1,
|
|
229
|
-
mtimeMs: 0,
|
|
230
|
-
seq: 0,
|
|
231
|
-
lastAccessMs,
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** @internal — expose eviction for testing. */
|
|
236
|
-
export function __test__evictOldestSequenceCacheEntries(): void {
|
|
237
|
-
evictOldestSequenceCacheEntries();
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** @internal — clear the sequence cache. */
|
|
241
|
-
export function __test__clearSequenceCache(): void {
|
|
242
|
-
sequenceCache.clear();
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/** @internal — clear the in-process seqCounters Map so nextSequence seeds
|
|
246
|
-
* fresh from the sidecar/file (simulates a process restart for testing). */
|
|
247
|
-
export function __test__clearSeqCounters(): void {
|
|
248
|
-
seqCounters.clear();
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** @internal — the raw nextSequence for testing (forces re-seed from disk
|
|
252
|
-
* by requiring the caller to have already cleared both caches). */
|
|
253
|
-
export function __test__nextSequence(eventsPath: string): number {
|
|
254
|
-
return nextSequence(eventsPath);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** @internal — the max sequence cache entries bound. */
|
|
258
|
-
export const MAX_SEQUENCE_CACHE_ENTRIES_VALUE = MAX_SEQUENCE_CACHE_ENTRIES;
|
|
259
|
-
|
|
260
|
-
export function sequencePath(eventsPath: string): string {
|
|
261
|
-
return `${eventsPath}.seq`;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function parseSequence(raw: string): number | undefined {
|
|
265
|
-
const value = Number.parseInt(raw.trim(), 10);
|
|
266
|
-
return Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
export function scanSequence(eventsPath: string): number {
|
|
270
|
-
if (!fs.existsSync(eventsPath)) return 0;
|
|
271
|
-
let max = 0;
|
|
272
|
-
let skipped = 0;
|
|
273
|
-
for (const line of fs.readFileSync(eventsPath, "utf-8").split("\n")) {
|
|
274
|
-
if (!line.trim()) continue;
|
|
275
|
-
try {
|
|
276
|
-
const event = JSON.parse(line) as TeamEvent;
|
|
277
|
-
max = Math.max(max, event.metadata?.seq ?? 0);
|
|
278
|
-
} catch {
|
|
279
|
-
skipped++;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
if (skipped > 0) {
|
|
283
|
-
logInternalError("event-log.scanSequence.corrupt_lines", undefined, `${eventsPath}: skipped ${skipped} corrupt line(s)`);
|
|
284
|
-
}
|
|
285
|
-
return max;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function readStoredSequence(eventsPath: string): number | undefined {
|
|
289
|
-
try {
|
|
290
|
-
return parseSequence(fs.readFileSync(sequencePath(eventsPath), "utf-8"));
|
|
291
|
-
} catch {
|
|
292
|
-
return undefined;
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function nextSequence(eventsPath: string): number {
|
|
297
|
-
if (!fs.existsSync(eventsPath)) return 1;
|
|
298
|
-
const stat = fs.statSync(eventsPath);
|
|
299
|
-
const cached = sequenceCache.get(eventsPath);
|
|
300
|
-
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
|
|
301
|
-
return cached.seq + 1;
|
|
302
|
-
}
|
|
303
|
-
// FIX: Trust the sidecar seq file if it exists and the file is non-empty.
|
|
304
|
-
// Explicitly check for file shrinkage (stat.size < cached.size) to trigger
|
|
305
|
-
// re-scan when rotation or compaction has occurred.
|
|
306
|
-
const stored = readStoredSequence(eventsPath);
|
|
307
|
-
const fileShrunk = cached && stat.size < cached.size;
|
|
308
|
-
if (stored !== undefined && !fileShrunk) {
|
|
309
|
-
// Trust the sidecar, but guard against a REGRESSED sidecar (e.g. the
|
|
310
|
-
// async path persisted a lower seq, rolling the sidecar back below the
|
|
311
|
-
// file's true max). Take max with a full scan so a regressed sidecar
|
|
312
|
-
// cannot produce a duplicate sequence number (EL-1 regression guard).
|
|
313
|
-
// NOTE (ST-12): a full scan here is acceptable because all three append
|
|
314
|
-
// paths now allocate seqs via reserveSequence/reserveSequenceUnderLock
|
|
315
|
-
// (ST-5: re-read sidecar under lock + max with in-process counter);
|
|
316
|
-
// nextSequence is only consulted for seeding/test helpers, NOT on the
|
|
317
|
-
// production append path. ST-12's perf goal (avoid 4MB scan on first
|
|
318
|
-
// append) is therefore met by ST-5 at the reserveSequence layer.
|
|
319
|
-
const fileMax = scanSequence(eventsPath);
|
|
320
|
-
const safeSeq = Math.max(stored, fileMax);
|
|
321
|
-
sequenceCache.set(eventsPath, {
|
|
322
|
-
size: stat.size,
|
|
323
|
-
mtimeMs: stat.mtimeMs,
|
|
324
|
-
seq: safeSeq,
|
|
325
|
-
lastAccessMs: Date.now(),
|
|
326
|
-
});
|
|
327
|
-
return safeSeq + 1;
|
|
328
|
-
}
|
|
329
|
-
const current = scanSequence(eventsPath);
|
|
330
|
-
sequenceCache.set(eventsPath, {
|
|
331
|
-
size: stat.size,
|
|
332
|
-
mtimeMs: stat.mtimeMs,
|
|
333
|
-
seq: current,
|
|
334
|
-
lastAccessMs: Date.now(),
|
|
335
|
-
});
|
|
336
|
-
persistSequence(eventsPath, current);
|
|
337
|
-
return current + 1;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function persistSequence(eventsPath: string, seq: number): void {
|
|
341
|
-
try {
|
|
342
|
-
// P0-4: the .seq sidecar is disposable (event-reconstructor tolerates an
|
|
343
|
-
// inconsistent tail); best-effort avoids 2 fsyncs per event append.
|
|
344
|
-
atomicWriteFile(sequencePath(eventsPath), String(seq), { durability: "best-effort" });
|
|
345
|
-
} catch (error) {
|
|
346
|
-
logInternalError("event-log.persist-sequence-file", error, `eventsPath=${eventsPath}`);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// B7: single in-process monotonic sequence counter per eventsPath. The three
|
|
351
|
-
// append paths — sync appendEvent (withEventLogLockSync file lock), buffered
|
|
352
|
-
// flush (asyncLocks promise chain), and direct appendEventAsync (asyncQueues
|
|
353
|
-
// promise chain) — use DIFFERENT locks, so the old read-sidecar / compute /
|
|
354
|
-
// persist-sidecar sequence logic in nextSequence() raced ACROSS paths and
|
|
355
|
-
// produced duplicate sequence numbers (observed live: distinct events sharing
|
|
356
|
-
// a seq; no data loss — only the counter collided). A single in-process counter
|
|
357
|
-
// makes assignment atomic (JS is single-threaded); persistSequence() keeps the
|
|
358
|
-
// sidecar durable for crash recovery across restarts.
|
|
359
|
-
const seqCounters = new Map<string, number>();
|
|
360
|
-
// H6 (2026-08-10): FIFO cap — mirrors appendCounters (APPEND_COUNTER_MAX_ENTRIES = 256)
|
|
361
|
-
// and agentEventSeqCache (cap at :434). Without this, a long-lived parent pi process
|
|
362
|
-
// that observes many runs (dev sessions with hundreds of background runs over a week)
|
|
363
|
-
// accumulates one entry per distinct eventsPath forever. Eviction is safe: the next
|
|
364
|
-
// append re-seeds from the `.seq` sidecar via reserveSequenceUnderLock.
|
|
365
|
-
const SEQ_COUNTERS_MAX_ENTRIES = 256;
|
|
366
|
-
|
|
367
|
-
/** Atomically reserve the next sequence number for `eventsPath`.
|
|
368
|
-
*
|
|
369
|
-
* ST-5 (v0.9.56): previously this seeded the in-process `seqCounters` counter
|
|
370
|
-
* ONCE per process (reading the `.seq` sidecar a single time via
|
|
371
|
-
* `nextSequence`) and then served every subsequent call purely from that
|
|
372
|
-
* process-local counter. Two processes that both seeded before either had
|
|
373
|
-
* persisted ended up sharing the same counter base -> duplicate sequence
|
|
374
|
-
* numbers -> `sinceSeq` streaming readers silently dropped the second event.
|
|
375
|
-
* The async path was already fixed via `reserveSequenceUnderLock` (which
|
|
376
|
-
* re-reads the sidecar every call); the sync (`appendEvent`) and buffered
|
|
377
|
-
* (`appendEventBatchInsideLock`) paths were NOT.
|
|
378
|
-
*
|
|
379
|
-
* Now ALL three append paths share one body: re-read the authoritative `.seq`
|
|
380
|
-
* sidecar on EVERY call and take `max(sidecar, inProcess)` so a counter that
|
|
381
|
-
* lags behind a sidecar advanced by another process can never assign a
|
|
382
|
-
* regressed (duplicate) seq. Every call site already holds a cross-process
|
|
383
|
-
* file lock (`withEventLogLockSync` for sync/buffered, `withEventLogLockAsync`
|
|
384
|
-
* for async), so the sidecar re-read is race-free within each lock class. */
|
|
385
|
-
function reserveSequence(eventsPath: string): number {
|
|
386
|
-
return reserveSequenceUnderLock(eventsPath);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/** Keep the in-process counter monotonic w.r.t. an explicitly-provided seq
|
|
390
|
-
* (e.g. baseMetadata.seq) so a later auto-assigned seq never collides with it. */
|
|
391
|
-
function advanceSequenceCounter(eventsPath: string, seq: number): void {
|
|
392
|
-
const last = seqCounters.get(eventsPath);
|
|
393
|
-
if (last === undefined || seq > last) {
|
|
394
|
-
seqCounters.set(eventsPath, seq);
|
|
395
|
-
enforceSeqCountersCap();
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/** H6: FIFO eviction when the seqCounters map exceeds its bounded size. */
|
|
400
|
-
function enforceSeqCountersCap(): void {
|
|
401
|
-
if (seqCounters.size > SEQ_COUNTERS_MAX_ENTRIES) {
|
|
402
|
-
const oldest = seqCounters.keys().next().value;
|
|
403
|
-
if (oldest !== undefined) seqCounters.delete(oldest);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/** C-01: Reserve sequence INSIDE the cross-process lock. Reads the authoritative
|
|
408
|
-
* sidecar (.seq file) for the last seq persisted by ANY process, ensuring
|
|
409
|
-
* cross-process uniqueness. Falls back to scanSequence if no sidecar exists.
|
|
410
|
-
* The in-process seqCounters is kept monotonic via Math.max for defensive
|
|
411
|
-
* consistency with any in-process sequencing that hasn't been persisted yet. */
|
|
412
|
-
function reserveSequenceUnderLock(eventsPath: string): number {
|
|
413
|
-
let stored = readStoredSequence(eventsPath);
|
|
414
|
-
if (stored === undefined) {
|
|
415
|
-
stored = scanSequence(eventsPath);
|
|
416
|
-
}
|
|
417
|
-
const inProcess = seqCounters.get(eventsPath) ?? 0;
|
|
418
|
-
const last = Math.max(stored, inProcess);
|
|
419
|
-
const next = last + 1;
|
|
420
|
-
seqCounters.set(eventsPath, next);
|
|
421
|
-
enforceSeqCountersCap();
|
|
422
|
-
return next;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
216
|
export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId" | "taskId" | "data">): string {
|
|
426
217
|
return createHash("sha256")
|
|
427
218
|
.update(
|
|
@@ -451,6 +242,10 @@ export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEve
|
|
|
451
242
|
}
|
|
452
243
|
|
|
453
244
|
// --- Async write queue (non-blocking alternative to withEventLogLockSync) ---
|
|
245
|
+
// R5-L4: safety cap for error-path reset entries (see the appendEventAsync
|
|
246
|
+
// error handler) — a path whose queue is reset after an error and then never
|
|
247
|
+
// re-accessed would otherwise retain its entry until process exit.
|
|
248
|
+
const MAX_ASYNC_QUEUES = 256;
|
|
454
249
|
const asyncQueues = new Map<string, Promise<unknown>>();
|
|
455
250
|
|
|
456
251
|
// --- Async lock for flush operations (non-blocking alternative to withEventLogLockSync) ---
|
|
@@ -493,6 +288,16 @@ async function drainAsyncQueues(): Promise<void> {
|
|
|
493
288
|
* shared sidecar (extremely unlikely scenario — workers write to their own
|
|
494
289
|
* run-scoped events.jsonl, not the parent's).
|
|
495
290
|
*
|
|
291
|
+
* R16-B1/R17 CORRECTION (Phase 3.6): the "extremely unlikely" claim above
|
|
292
|
+
* was REFUTED empirically — 14 parent-side sync appendEvent sites write the
|
|
293
|
+
* live run's eventsPath while child async appends run, and Round 17 measured
|
|
294
|
+
* 75% duplicate events / 527 duplicate seqs over 3000 events with the natural
|
|
295
|
+
* (unwidened) rate. The family split itself is UNCHANGED (merging the two
|
|
296
|
+
* lock dirs would reintroduce the sleepSync-vs-async-timer deadlock this
|
|
297
|
+
* comment documents); the seq race is instead closed by the shared
|
|
298
|
+
* `.seqlock` (L3) around the .seq sidecar reservation in
|
|
299
|
+
* reserveSequenceUnderLock/Async — see the R16-B1 block near persistSequence.
|
|
300
|
+
*
|
|
496
301
|
* NOT re-entrant: callers inside this lock must use unlocked compaction
|
|
497
302
|
* variants (prepareCompaction + applyCompactionUnlocked, rotateEventLogUnlocked)
|
|
498
303
|
* to avoid self-deadlock. */
|
|
@@ -597,15 +402,6 @@ async function withEventLogLockAsync<T>(
|
|
|
597
402
|
}
|
|
598
403
|
}
|
|
599
404
|
|
|
600
|
-
/** Reset event log mode (for testing only). */
|
|
601
|
-
export function resetEventLogMode(): void {
|
|
602
|
-
asyncQueues.clear();
|
|
603
|
-
asyncLocks.clear();
|
|
604
|
-
// B7: clear in-process sequence counters alongside async state so tests
|
|
605
|
-
// don't leak seq state between runs.
|
|
606
|
-
seqCounters.clear();
|
|
607
|
-
}
|
|
608
|
-
|
|
609
405
|
/**
|
|
610
406
|
* Append an event to the event log using non-blocking async I/O.
|
|
611
407
|
*
|
|
@@ -644,16 +440,25 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
644
440
|
// the event is definitively written. If appendFile fails, the sidecar is
|
|
645
441
|
// not updated and nextSequence() will re-scan on next call, returning the
|
|
646
442
|
// correct value without reuse.
|
|
443
|
+
// R16-B1 (Phase 3.6) PARTIAL SUPERSESSION: the sidecar IS now advanced at
|
|
444
|
+
// reservation time INSIDE the .seqlock (advance-on-reserve, gap-not-dup —
|
|
445
|
+
// see reserveSequenceLocked); the post-append persist below is kept and is
|
|
446
|
+
// idempotent (writes the same value the reservation already persisted).
|
|
647
447
|
const baseMetadata = event.metadata;
|
|
648
448
|
let seq: number;
|
|
649
449
|
if (baseMetadata?.seq !== undefined) {
|
|
650
450
|
seq = baseMetadata.seq;
|
|
651
451
|
advanceSequenceCounter(eventsPath, seq);
|
|
652
452
|
} else {
|
|
653
|
-
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
453
|
+
// R16-B1 (Phase 3.6): reservation now acquires the shared `.seqlock`
|
|
454
|
+
// (async acquire wrapper — never sleepSync on the event loop) and
|
|
455
|
+
// PERSISTS the reserved seq inside it (advance-on-reserve). The old
|
|
456
|
+
// "do NOT call persistSequence here" note is superseded: without the
|
|
457
|
+
// in-lock persist, two processes in different family locks both read
|
|
458
|
+
// sidecar=N and both reserve N+1 (Round 17: 75% dup events). A failed
|
|
459
|
+
// append after reservation now yields a seq GAP (tolerated by the
|
|
460
|
+
// event-reconstructor's lossless recovery), never a duplicate.
|
|
461
|
+
seq = await reserveSequenceUnderLockAsync(eventsPath);
|
|
657
462
|
}
|
|
658
463
|
let metadata: TeamEventMetadata = {
|
|
659
464
|
seq,
|
|
@@ -711,7 +516,18 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
711
516
|
}
|
|
712
517
|
if (afterCompactStat) {
|
|
713
518
|
if (afterCompactStat.size > MAX_EVENTS_BYTES) {
|
|
714
|
-
|
|
519
|
+
// R17-S1 (Phase 3.8): a failed rotation leaves the file over the
|
|
520
|
+
// limit — the next non-terminal appends would be silently skipped.
|
|
521
|
+
// Check the boolean (previously ignored) and surface severity
|
|
522
|
+
// "error" so the chain signals at EVERY step.
|
|
523
|
+
if (!rotateEventLogUnlocked(eventsPath)) {
|
|
524
|
+
logInternalError(
|
|
525
|
+
"event-log.rotate-failed",
|
|
526
|
+
new Error(`event log rotation failed; file remains over ${MAX_EVENTS_BYTES} bytes`),
|
|
527
|
+
`eventsPath=${eventsPath}`,
|
|
528
|
+
"error",
|
|
529
|
+
);
|
|
530
|
+
}
|
|
715
531
|
}
|
|
716
532
|
}
|
|
717
533
|
}
|
|
@@ -734,8 +550,17 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
734
550
|
"event-log.size-limit",
|
|
735
551
|
new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes after compaction`),
|
|
736
552
|
`eventsPath=${eventsPath}`,
|
|
553
|
+
// R17-S1 (Phase 3.8, Round 18 escalation to HIGH): this was default
|
|
554
|
+
// severity "debug" (PI_TEAMS_DEBUG-gated) → fully silent drop in
|
|
555
|
+
// production. "error" always emits.
|
|
556
|
+
"error",
|
|
737
557
|
);
|
|
738
558
|
skippedDueToSize = true;
|
|
559
|
+
// R17-S1: surface the drop on the returned event itself — resolve
|
|
560
|
+
// with an indicator instead of as-if-persisted success. Never
|
|
561
|
+
// throw on the skip path (Round 18 caution), and never emit a
|
|
562
|
+
// non-persisted event to the run-event-bus (gate below).
|
|
563
|
+
metadata.skippedDueToSize = true;
|
|
739
564
|
}
|
|
740
565
|
} catch (error) {
|
|
741
566
|
logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
|
|
@@ -784,11 +609,14 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
784
609
|
await fd.close();
|
|
785
610
|
}
|
|
786
611
|
}
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
|
|
612
|
+
// R16-B1 (Phase 3.6): advance-on-reserve already persisted this exact
|
|
613
|
+
// value INSIDE the .seqlock — re-persisting it here (outside the lock)
|
|
614
|
+
// can REGRESS the sidecar after another process reserved further
|
|
615
|
+
// (observed: re-reserved duplicate seqs in the b9 cross-process bench).
|
|
616
|
+
// Only EXPLICIT (pre-assigned) seqs bypassed the reservation — persist
|
|
617
|
+
// those, monotonically and under the .seqlock, so a lower explicit seq
|
|
618
|
+
// can never roll the sidecar back either.
|
|
619
|
+
if (baseMetadata?.seq !== undefined) persistSequenceMonotonic(eventsPath, seq);
|
|
792
620
|
}
|
|
793
621
|
// FIND-10: track whether compaction happened after the append so the
|
|
794
622
|
// cache-update stat can safely reuse postAppendStat (file unchanged).
|
|
@@ -802,10 +630,15 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
802
630
|
logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`);
|
|
803
631
|
}
|
|
804
632
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
633
|
+
// R17-S1 (Phase 3.8): gate the UI-bus emit on !skippedDueToSize — the bus
|
|
634
|
+
// must never receive events that were NOT persisted (live-UI vs
|
|
635
|
+
// reconstructed-state divergence, Round 18 reviewer addendum).
|
|
636
|
+
if (!skippedDueToSize) {
|
|
637
|
+
try {
|
|
638
|
+
emitFromTeamEvent(fullEvent);
|
|
639
|
+
} catch (error) {
|
|
640
|
+
logInternalError("event-log.emit", error);
|
|
641
|
+
}
|
|
809
642
|
}
|
|
810
643
|
|
|
811
644
|
// FIX: Sequence was persisted AFTER appendFile in the append block above.
|
|
@@ -871,6 +704,13 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
871
704
|
// FIX: Reset queue to a resolved state instead of deleting it.
|
|
872
705
|
// This prevents cascading failures where a single transient error
|
|
873
706
|
// (e.g., ENOSPC) causes all subsequent events on the same path to fail.
|
|
707
|
+
// R5-L4: cap the retained reset entries — evict oldest (Map insertion
|
|
708
|
+
// order) so paths that error and are never re-accessed cannot leak.
|
|
709
|
+
while (asyncQueues.size >= MAX_ASYNC_QUEUES) {
|
|
710
|
+
const oldestKey = asyncQueues.keys().next().value;
|
|
711
|
+
if (oldestKey === undefined) break;
|
|
712
|
+
asyncQueues.delete(oldestKey);
|
|
713
|
+
}
|
|
874
714
|
asyncQueues.set(queueKey, Promise.resolve());
|
|
875
715
|
},
|
|
876
716
|
);
|
|
@@ -911,7 +751,17 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
|
|
|
911
751
|
logInternalError("event-log.batch-immediate-compact", error, `eventsPath=${eventsPath}`);
|
|
912
752
|
}
|
|
913
753
|
if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
|
|
914
|
-
|
|
754
|
+
// R17-S1 (Phase 3.8): check the rotation boolean (previously ignored)
|
|
755
|
+
// and surface severity "error" — a failed rotation leaves the file
|
|
756
|
+
// over the limit and the batch below will be rejected as a result.
|
|
757
|
+
if (!rotateEventLogUnlocked(eventsPath)) {
|
|
758
|
+
logInternalError(
|
|
759
|
+
"event-log.rotate-failed",
|
|
760
|
+
new Error(`event log rotation failed; file remains over ${MAX_EVENTS_BYTES} bytes`),
|
|
761
|
+
`eventsPath=${eventsPath}`,
|
|
762
|
+
"error",
|
|
763
|
+
);
|
|
764
|
+
}
|
|
915
765
|
}
|
|
916
766
|
}
|
|
917
767
|
}
|
|
@@ -927,7 +777,11 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
|
|
|
927
777
|
// seq, breaking the "unique monotonic seq" contract. The cache update +
|
|
928
778
|
// persistSequence at the end refreshes the sidecar to the last assigned seq.
|
|
929
779
|
// B7: use reserveSequence for atomic seq assignment across all paths.
|
|
930
|
-
|
|
780
|
+
// R16-B1 (Phase 3.6): reserve the WHOLE batch range (count = queue.length)
|
|
781
|
+
// under the .seqlock in one acquire — the locally incremented nextSeq below
|
|
782
|
+
// then stays inside the reserved range even when explicit baseMetadata.seq
|
|
783
|
+
// values skip some of the reserved slots (those become gaps, never dups).
|
|
784
|
+
const startingSeq = queue[0]?.event.metadata?.seq ?? reserveSequence(eventsPath, queue.length);
|
|
931
785
|
let nextSeq = startingSeq;
|
|
932
786
|
const finalized: { item: BufferedAppend; line: string; fullEvent: TeamEvent }[] = [];
|
|
933
787
|
let lastSeq = 0;
|
|
@@ -992,7 +846,11 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
|
|
|
992
846
|
} finally {
|
|
993
847
|
fs.closeSync(fd);
|
|
994
848
|
}
|
|
995
|
-
|
|
849
|
+
// R16-B1 (Phase 3.6): monotonic persist under the .seqlock — the reservation
|
|
850
|
+
// already persisted the batch range end; this covers explicit (pre-assigned)
|
|
851
|
+
// seqs that may exceed it and can never REGRESS the sidecar (the old bare
|
|
852
|
+
// persistSequence could write a lower lastSeq over a higher reserved value).
|
|
853
|
+
persistSequenceMonotonic(eventsPath, lastSeq);
|
|
996
854
|
|
|
997
855
|
// Phase 3: cache update + resolve all promises.
|
|
998
856
|
try {
|
|
@@ -1069,7 +927,18 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
|
|
|
1069
927
|
if (fs.existsSync(eventsPath)) {
|
|
1070
928
|
const afterCompact = fs.statSync(eventsPath);
|
|
1071
929
|
if (afterCompact.size > MAX_EVENTS_BYTES) {
|
|
1072
|
-
|
|
930
|
+
// R17-S1 (Phase 3.8): check the rotation boolean (previously
|
|
931
|
+
// ignored) and surface severity "error" — a failed rotation
|
|
932
|
+
// leaves the file over the limit and the size check below will
|
|
933
|
+
// silently skip every subsequent non-terminal append.
|
|
934
|
+
if (!rotateEventLogUnlocked(eventsPath)) {
|
|
935
|
+
logInternalError(
|
|
936
|
+
"event-log.rotate-failed",
|
|
937
|
+
new Error(`event log rotation failed; file remains over ${MAX_EVENTS_BYTES} bytes`),
|
|
938
|
+
`eventsPath=${eventsPath}`,
|
|
939
|
+
"error",
|
|
940
|
+
);
|
|
941
|
+
}
|
|
1073
942
|
}
|
|
1074
943
|
}
|
|
1075
944
|
}
|
|
@@ -1082,8 +951,18 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
|
|
|
1082
951
|
"event-log.size-limit",
|
|
1083
952
|
new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes after compaction`),
|
|
1084
953
|
`eventsPath=${eventsPath}`,
|
|
954
|
+
// R17-S1 (Phase 3.8, Round 18 escalation to HIGH): was default "debug"
|
|
955
|
+
// (PI_TEAMS_DEBUG-gated) → fully silent drop in production. "error"
|
|
956
|
+
// always emits.
|
|
957
|
+
"error",
|
|
1085
958
|
);
|
|
1086
959
|
skippedDueToSize = true;
|
|
960
|
+
// R17-S1: surface the drop on the RETURNED event (never throw — callers
|
|
961
|
+
// run inside withRunLockSync and a throw would fail cancel/save critical
|
|
962
|
+
// sections; Round 18 fix-design caution). The returned TeamEvent carries
|
|
963
|
+
// skippedDueToSize=true so live-path callers can detect the drop; the
|
|
964
|
+
// event is NOT persisted and NOT emitted (gate below).
|
|
965
|
+
metadata.skippedDueToSize = true;
|
|
1087
966
|
}
|
|
1088
967
|
} catch (error) {
|
|
1089
968
|
logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
|
|
@@ -1111,7 +990,13 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
|
|
|
1111
990
|
}
|
|
1112
991
|
// FIX: Persist sequence AFTER the event append to prevent sequence reuse
|
|
1113
992
|
// on crash. Only update the sidecar when the event is definitively written.
|
|
1114
|
-
|
|
993
|
+
// R16-B1 (Phase 3.6): advance-on-reserve already persisted this exact
|
|
994
|
+
// value INSIDE the .seqlock — re-persisting it here (outside the lock)
|
|
995
|
+
// can REGRESS the sidecar after another process reserved further
|
|
996
|
+
// (observed: re-reserved duplicate seqs in the b9 cross-process bench).
|
|
997
|
+
// Only EXPLICIT (pre-assigned) seqs bypassed the reservation — persist
|
|
998
|
+
// those, monotonically and under the .seqlock.
|
|
999
|
+
if (explicitSeq !== undefined) persistSequenceMonotonic(eventsPath, seq);
|
|
1115
1000
|
// FIX: Update cache AFTER append so cache and log are consistent with each other.
|
|
1116
1001
|
// This matches the async path behavior where cache is updated after the append.
|
|
1117
1002
|
// If a crash occurs after append but before cache update, the .seq file is
|
|
@@ -1144,10 +1029,15 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
|
|
|
1144
1029
|
logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`);
|
|
1145
1030
|
}
|
|
1146
1031
|
}
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1032
|
+
// R17-S1 (Phase 3.8): gate the UI-bus emit on !skippedDueToSize — the bus
|
|
1033
|
+
// must never receive events that were NOT persisted (live-UI vs
|
|
1034
|
+
// reconstructed-state divergence, Round 18 reviewer addendum).
|
|
1035
|
+
if (!skippedDueToSize) {
|
|
1036
|
+
try {
|
|
1037
|
+
emitFromTeamEvent(fullEvent);
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
logInternalError("event-log.emit", error);
|
|
1040
|
+
}
|
|
1151
1041
|
}
|
|
1152
1042
|
return fullEvent;
|
|
1153
1043
|
}
|
|
@@ -1369,117 +1259,6 @@ process.on("uncaughtException", (error) => {
|
|
|
1369
1259
|
throw error;
|
|
1370
1260
|
});
|
|
1371
1261
|
|
|
1372
|
-
export function readEvents(eventsPath: string): TeamEvent[] {
|
|
1373
|
-
if (!fs.existsSync(eventsPath)) return [];
|
|
1374
|
-
return fs
|
|
1375
|
-
.readFileSync(eventsPath, "utf-8")
|
|
1376
|
-
.split("\n")
|
|
1377
|
-
.map((line) => line.trim())
|
|
1378
|
-
.filter(Boolean)
|
|
1379
|
-
.flatMap((line) => {
|
|
1380
|
-
try {
|
|
1381
|
-
return [JSON.parse(line) as TeamEvent];
|
|
1382
|
-
} catch {
|
|
1383
|
-
return [];
|
|
1384
|
-
}
|
|
1385
|
-
});
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
export interface EventCursorOptions {
|
|
1389
|
-
sinceSeq?: number;
|
|
1390
|
-
limit?: number;
|
|
1391
|
-
fromByteOffset?: number;
|
|
1392
|
-
/** R-03: generation the caller captured on its previous read. When set, a
|
|
1393
|
-
* mismatch with the live generation signals the file was rotated/truncated
|
|
1394
|
-
* and the byte offset is stale — the cursor resets to 0 so the new file is
|
|
1395
|
-
* re-read from its start instead of missing post-rotation events. */
|
|
1396
|
-
generation?: number;
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
export interface EventCursorResult {
|
|
1400
|
-
events: TeamEvent[];
|
|
1401
|
-
nextSeq: number;
|
|
1402
|
-
total: number;
|
|
1403
|
-
nextByteOffset?: number;
|
|
1404
|
-
/** R-03: live generation of the events file at read time. Callers doing
|
|
1405
|
-
* streaming byte-offset reads should echo this back as `generation` on the
|
|
1406
|
-
* next call so rotation is detected and the cursor resets. */
|
|
1407
|
-
generation?: number;
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
function positiveInteger(value: number | undefined): number | undefined {
|
|
1411
|
-
return value !== undefined && Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
export function readEventsCursor(eventsPath: string, options: EventCursorOptions = {}): EventCursorResult {
|
|
1415
|
-
// Incremental byte-offset path: read only new bytes since last known offset
|
|
1416
|
-
if (options.fromByteOffset !== undefined) {
|
|
1417
|
-
// R-03: detect file rotation/truncation via the generation sidecar BEFORE
|
|
1418
|
-
// reusing the byte offset. If the file was rotated since the caller last
|
|
1419
|
-
// read, it was truncated to empty (pre-rotation content archived to
|
|
1420
|
-
// `<eventsPath>.<ts>.archive.jsonl`) and is growing again from 0 — the
|
|
1421
|
-
// caller's offset now points past EOF, so post-rotation events would be
|
|
1422
|
-
// silently missed. Reset to offset 0 to re-read the current file from its
|
|
1423
|
-
// start. Re-reading from 0 re-delivers no previously-returned events:
|
|
1424
|
-
// those live in the archive, not the (now fresh) current file.
|
|
1425
|
-
const liveGen = currentGeneration(eventsPath);
|
|
1426
|
-
const staleCursor = options.generation !== undefined && options.generation !== liveGen;
|
|
1427
|
-
const byteOffset = staleCursor ? 0 : (positiveInteger(options.fromByteOffset) ?? 0);
|
|
1428
|
-
const initialState: IncrementalReadState = { byteOffset, lineCount: 0 };
|
|
1429
|
-
const { items, state: newState, eof } = readJsonlSince<TeamEvent>(eventsPath, initialState);
|
|
1430
|
-
const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
|
|
1431
|
-
const filtered = items.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
|
|
1432
|
-
const limit = positiveInteger(options.limit);
|
|
1433
|
-
const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
|
|
1434
|
-
const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
|
|
1435
|
-
return {
|
|
1436
|
-
events,
|
|
1437
|
-
nextSeq: returnedMaxSeq,
|
|
1438
|
-
total: filtered.length,
|
|
1439
|
-
nextByteOffset: newState.byteOffset,
|
|
1440
|
-
generation: liveGen,
|
|
1441
|
-
};
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
// FIND-05 default path: byte-level tail read (last 4MB) instead of
|
|
1445
|
-
// full-file read. Bounds CPU to O(tail bytes) instead of O(total
|
|
1446
|
-
// events). The legacy readEvents() full parse path is preserved for
|
|
1447
|
-
// callers that explicitly need the full history (e.g. tests that
|
|
1448
|
-
// assert exact contents) and as a small-file fallback.
|
|
1449
|
-
//
|
|
1450
|
-
// The 5000-event tail cap and the "event-log.cursor-full-read"
|
|
1451
|
-
// warning are preserved. A separate cursor-tail-truncated warning is
|
|
1452
|
-
// emitted whenever the file exceeds the 4MB tail budget, signalling
|
|
1453
|
-
// that a prefix was dropped and callers should pass fromByteOffset for
|
|
1454
|
-
// streaming reads.
|
|
1455
|
-
const TAIL_BYTES = 4 * 1024 * 1024; // 4 MB
|
|
1456
|
-
const TAIL_EVENT_CAP = 5000;
|
|
1457
|
-
const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
|
|
1458
|
-
const limit = positiveInteger(options.limit);
|
|
1459
|
-
|
|
1460
|
-
const tail = readJsonlTail<TeamEvent>(eventsPath, TAIL_BYTES);
|
|
1461
|
-
let all = tail.items;
|
|
1462
|
-
if (tail.truncated) {
|
|
1463
|
-
logInternalError("event-log.cursor-tail-truncated", {
|
|
1464
|
-
eventsPath,
|
|
1465
|
-
returned: all.length,
|
|
1466
|
-
tailBytes: TAIL_BYTES,
|
|
1467
|
-
});
|
|
1468
|
-
}
|
|
1469
|
-
if (all.length > TAIL_EVENT_CAP) {
|
|
1470
|
-
logInternalError(
|
|
1471
|
-
"event-log.cursor-full-read",
|
|
1472
|
-
new Error(`readEventsCursor tail read dropped events from a larger log; pass fromByteOffset for incremental reads`),
|
|
1473
|
-
`eventsPath=${eventsPath}`,
|
|
1474
|
-
);
|
|
1475
|
-
all = all.slice(-TAIL_EVENT_CAP);
|
|
1476
|
-
}
|
|
1477
|
-
const filtered = all.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
|
|
1478
|
-
const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
|
|
1479
|
-
const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
|
|
1480
|
-
return { events, nextSeq: returnedMaxSeq, total: filtered.length };
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
1262
|
export function dedupeTerminalEvents(events: TeamEvent[]): TeamEvent[] {
|
|
1484
1263
|
const seen = new Set<string>();
|
|
1485
1264
|
const output: TeamEvent[] = [];
|
|
@@ -1493,3 +1272,7 @@ export function dedupeTerminalEvents(events: TeamEvent[]): TeamEvent[] {
|
|
|
1493
1272
|
}
|
|
1494
1273
|
return output;
|
|
1495
1274
|
}
|
|
1275
|
+
|
|
1276
|
+
// --- Re-export shim (Phase 2.4): moved modules stay reachable from this path ---
|
|
1277
|
+
export * from "./cursor.ts";
|
|
1278
|
+
export * from "./sequence-cache.ts";
|