trajectories 0.0.0 → 0.1.1

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.
Files changed (204) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +201 -0
  3. package/README.md +274 -2
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +8542 -0
  6. package/dist/convert.d.ts +31 -0
  7. package/dist/convert.js +431 -0
  8. package/dist/index.d.ts +7 -0
  9. package/dist/index.js +8402 -0
  10. package/dist/log-baseline.d.ts +26 -0
  11. package/dist/log-baseline.js +104 -0
  12. package/dist/log-content.d.ts +16 -0
  13. package/dist/log-content.js +80 -0
  14. package/dist/log-io.d.ts +10 -0
  15. package/dist/log-io.js +121 -0
  16. package/dist/log-read.d.ts +24 -0
  17. package/dist/log-read.js +117 -0
  18. package/dist/log-reconcile.d.ts +5 -0
  19. package/dist/log-reconcile.js +38 -0
  20. package/dist/log-records.d.ts +35 -0
  21. package/dist/log-records.js +85 -0
  22. package/dist/log-recovery.d.ts +18 -0
  23. package/dist/log-recovery.js +123 -0
  24. package/dist/log-stage.d.ts +14 -0
  25. package/dist/log-stage.js +138 -0
  26. package/dist/log-state.d.ts +89 -0
  27. package/dist/log-state.js +92 -0
  28. package/dist/log-switch.d.ts +3 -0
  29. package/dist/log-switch.js +98 -0
  30. package/dist/log-types.d.ts +129 -0
  31. package/dist/log-types.js +8 -0
  32. package/dist/log.d.ts +8 -0
  33. package/dist/log.js +62 -0
  34. package/dist/migrate.d.ts +29 -0
  35. package/dist/migrate.js +173 -0
  36. package/dist/normalization-runtime.d.ts +1 -0
  37. package/dist/normalization-runtime.js +3150 -0
  38. package/dist/portable.d.ts +9 -0
  39. package/dist/portable.js +26 -0
  40. package/dist/readers/canonical.d.ts +13 -0
  41. package/dist/readers/canonical.js +54 -0
  42. package/dist/readers/cline.d.ts +10 -0
  43. package/dist/readers/cline.js +107 -0
  44. package/dist/readers/cursor.d.ts +20 -0
  45. package/dist/readers/cursor.js +374 -0
  46. package/dist/readers/deepagents.d.ts +41 -0
  47. package/dist/readers/deepagents.js +163 -0
  48. package/dist/readers/fx.d.ts +11 -0
  49. package/dist/readers/fx.js +573 -0
  50. package/dist/readers/github-copilot.d.ts +8 -0
  51. package/dist/readers/github-copilot.js +216 -0
  52. package/dist/readers/grok-build.d.ts +12 -0
  53. package/dist/readers/grok-build.js +391 -0
  54. package/dist/readers/guards.d.ts +4 -0
  55. package/dist/readers/guards.js +121 -0
  56. package/dist/readers/pi-compaction.d.ts +7 -0
  57. package/dist/readers/pi-compaction.js +112 -0
  58. package/dist/readers/pi-context.d.ts +13 -0
  59. package/dist/readers/pi-context.js +64 -0
  60. package/dist/store.d.ts +25 -0
  61. package/dist/store.js +218 -0
  62. package/dist/types.d.ts +91 -0
  63. package/dist/types.js +1 -0
  64. package/dist/writers/claude-code.d.ts +5 -0
  65. package/dist/writers/claude-code.js +51 -0
  66. package/dist/writers/cline.d.ts +3 -0
  67. package/dist/writers/cline.js +77 -0
  68. package/dist/writers/codex.d.ts +3 -0
  69. package/dist/writers/codex.js +32 -0
  70. package/dist/writers/cursor.d.ts +3 -0
  71. package/dist/writers/cursor.js +197 -0
  72. package/dist/writers/deepagents.d.ts +3 -0
  73. package/dist/writers/deepagents.js +116 -0
  74. package/dist/writers/entropy.d.ts +5 -0
  75. package/dist/writers/entropy.js +33 -0
  76. package/dist/writers/fx.d.ts +3 -0
  77. package/dist/writers/fx.js +135 -0
  78. package/dist/writers/github-copilot.d.ts +3 -0
  79. package/dist/writers/github-copilot.js +71 -0
  80. package/dist/writers/grok-build.d.ts +3 -0
  81. package/dist/writers/grok-build.js +43 -0
  82. package/dist/writers/helpers.d.ts +17 -0
  83. package/dist/writers/helpers.js +75 -0
  84. package/dist/writers/omp.d.ts +3 -0
  85. package/dist/writers/omp.js +7 -0
  86. package/dist/writers/opencode.d.ts +3 -0
  87. package/dist/writers/opencode.js +65 -0
  88. package/dist/writers/pi.d.ts +3 -0
  89. package/dist/writers/pi.js +45 -0
  90. package/docs/cline-native-format.md +43 -0
  91. package/docs/copilot-native-format.md +78 -0
  92. package/docs/cursor-native-format.md +103 -0
  93. package/docs/deepagents-native-format.md +98 -0
  94. package/docs/evidence-api.md +170 -0
  95. package/docs/fx-native-format.md +87 -0
  96. package/docs/harness-parity-bootstrap.md +78 -0
  97. package/docs/harness-support-parity.md +107 -0
  98. package/docs/inspirations.md +103 -0
  99. package/docs/ledger-migration.md +141 -0
  100. package/docs/pi-compaction-context.md +75 -0
  101. package/docs/pi-provider-context.md +76 -0
  102. package/docs/research/2026-10-06-huihua-and-oar.md +176 -0
  103. package/docs/research/ledger/README.md +96 -0
  104. package/docs/research/ledger/confer/2026-09-13-round-10/README.md +61 -0
  105. package/docs/research/ledger/confer/2026-09-13-round-2/cass.md +72 -0
  106. package/docs/research/ledger/confer/2026-09-13-round-3/README.md +70 -0
  107. package/docs/research/ledger/confer/2026-09-13-round-5/README.md +61 -0
  108. package/docs/research/ledger/confer/2026-09-13-round-6/README.md +68 -0
  109. package/docs/research/ledger/confer/2026-09-13-round-7/README.md +64 -0
  110. package/docs/research/ledger/confer/2026-09-13-round-9/README.md +59 -0
  111. package/docs/research/ledger/confer/2026-09-14-wake/wake.md +146 -0
  112. package/docs/research/ledger/docs/inspirations.md +320 -0
  113. package/docs/research/ledger/docs/pr2-pr6-integration.md +53 -0
  114. package/docs/research/ledger/docs/research/2026-09-07-confer-inspirations.md +163 -0
  115. package/docs/research/ledger/docs/research/2026-09-07-letta-continuous-service.md +104 -0
  116. package/docs/research/ledger/docs/research/2026-09-08-claude-history-retrieval.md +117 -0
  117. package/docs/research/ledger/docs/research/2026-09-09-agentsview-native-discovery.md +87 -0
  118. package/docs/research/ledger/docs/research/2026-09-09-daemon-observation.md +231 -0
  119. package/docs/research/ledger/docs/research/2026-09-09-discovery-pagination.md +114 -0
  120. package/docs/research/ledger/docs/research/2026-09-09-harness-transcript-contract.md +145 -0
  121. package/docs/research/ledger/docs/research/2026-09-09-native-history-discovery.md +180 -0
  122. package/docs/research/ledger/docs/research/2026-09-09-native-history-implementation.md +137 -0
  123. package/docs/research/ledger/docs/research/2026-09-09-other-inspirations.md +279 -0
  124. package/docs/research/ledger/docs/research/2026-09-09-trajectory-and-letta-code.md +128 -0
  125. package/docs/research/ledger/docs/research/2026-09-13-diagnostics-coverage.md +116 -0
  126. package/docs/research/ledger/docs/research/2026-09-13-fts-rebuild.md +83 -0
  127. package/docs/research/ledger/docs/research/2026-09-13-read-path-split.md +74 -0
  128. package/docs/research/ledger/docs/research/2026-09-13-runtime-rollout.md +102 -0
  129. package/docs/research/ledger/docs/research/2026-09-13-scan-outcomes.md +80 -0
  130. package/docs/research/ledger/docs/research/2026-09-13-search-quality.md +105 -0
  131. package/docs/research/ledger/docs/research/2026-09-13-source-coverage.md +64 -0
  132. package/docs/research/ledger/docs/research/2026-09-13-sync-wall-budget.md +82 -0
  133. package/docs/research/ledger/docs/research/2026-09-13-typed-read-gate.md +81 -0
  134. package/docs/research/ledger/docs/research/2026-09-13-writer-lease.md +84 -0
  135. package/docs/research/ledger/docs/research/2026-09-16-c1-copy-fallback.md +227 -0
  136. package/docs/research/ledger/docs/research/2026-09-16-c10-kimi.md +414 -0
  137. package/docs/research/ledger/docs/research/2026-09-16-c2-derive-counter.md +216 -0
  138. package/docs/research/ledger/docs/research/2026-09-16-c3-echo-filter.md +305 -0
  139. package/docs/research/ledger/docs/research/2026-09-16-c4-title-index.md +349 -0
  140. package/docs/research/ledger/docs/research/2026-09-16-c5-trigram.md +268 -0
  141. package/docs/research/ledger/docs/research/2026-09-16-c6-recency-prior.md +438 -0
  142. package/docs/research/ledger/docs/research/2026-09-16-c7-tombstones.md +467 -0
  143. package/docs/research/ledger/docs/research/2026-09-16-c8-remote-truth.md +390 -0
  144. package/docs/research/ledger/docs/research/2026-09-16-c9-grok.md +380 -0
  145. package/docs/research/ledger/docs/research/2026-09-16-wake-port-program.md +38 -0
  146. package/docs/research/ledger/docs/research/2026-09-17-cursor-blobstore.md +157 -0
  147. package/docs/research/ledger/manifest.json +540 -0
  148. package/legacy/NOTICE.md +50 -0
  149. package/legacy/bin/ledger-core.ts +7 -0
  150. package/legacy/bin/ledger.ts +7 -0
  151. package/legacy/bin/traj-bridge.ts +7 -0
  152. package/legacy/scripts/import-claude-history.ts +130 -0
  153. package/legacy/scripts/native-history-smoke.ts +52 -0
  154. package/legacy/scripts/observe-daemon.ts +570 -0
  155. package/legacy/scripts/retitle-sessions.md +19 -0
  156. package/legacy/scripts/verify-runtime-rollout.ts +151 -0
  157. package/legacy/src/canonical.ts +108 -0
  158. package/legacy/src/cli.ts +1293 -0
  159. package/legacy/src/db.ts +2254 -0
  160. package/legacy/src/diagnostics.ts +87 -0
  161. package/legacy/src/discovery.ts +56 -0
  162. package/legacy/src/embeddings.ts +382 -0
  163. package/legacy/src/filter.ts +602 -0
  164. package/legacy/src/gemini_embeddings.ts +103 -0
  165. package/legacy/src/identity.ts +9 -0
  166. package/legacy/src/index.ts +10 -0
  167. package/legacy/src/ingest.ts +791 -0
  168. package/legacy/src/managed.ts +178 -0
  169. package/legacy/src/native/common.ts +68 -0
  170. package/legacy/src/native/cursor.ts +647 -0
  171. package/legacy/src/native/grok.ts +540 -0
  172. package/legacy/src/native/kimi.ts +706 -0
  173. package/legacy/src/native/opencode.ts +470 -0
  174. package/legacy/src/native/sqlite-ro.ts +281 -0
  175. package/legacy/src/native.ts +97 -0
  176. package/legacy/src/normalizer.ts +457 -0
  177. package/legacy/src/parentage.ts +562 -0
  178. package/legacy/src/pressure.ts +162 -0
  179. package/legacy/src/private-storage.ts +74 -0
  180. package/legacy/src/reader.ts +1255 -0
  181. package/legacy/src/scanner.ts +591 -0
  182. package/legacy/src/schema-gate.ts +263 -0
  183. package/legacy/src/schema.ts +829 -0
  184. package/legacy/src/semantic-top-k.ts +71 -0
  185. package/legacy/src/session-hash.ts +49 -0
  186. package/legacy/src/store-status.ts +218 -0
  187. package/legacy/src/sync-preparation.ts +45 -0
  188. package/legacy/src/sync-truth.ts +843 -0
  189. package/legacy/src/sync.ts +1219 -0
  190. package/legacy/src/types.ts +436 -0
  191. package/legacy/src/vocab.txt +30522 -0
  192. package/legacy/src/watch.ts +673 -0
  193. package/legacy/src/writer-lease.ts +310 -0
  194. package/ops/README.md +120 -0
  195. package/ops/install-cli.sh +63 -0
  196. package/ops/install-release.sh +49 -0
  197. package/ops/linux/install-systemd-user.sh +103 -0
  198. package/ops/macos/install-launchd.sh +72 -0
  199. package/ops/sync-settings.sh +37 -0
  200. package/package.json +19 -5
  201. package/skills/trajectories/SKILL.md +138 -0
  202. package/skills/trajectories/scripts/trajectories_recent.sh +26 -0
  203. package/skills/trajectories/scripts/trajectories_search.sh +26 -0
  204. package/index.js +0 -1
@@ -0,0 +1,1219 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { prepareSyncStore, type SyncPreparationJob, type SyncPreparationReply } from "./sync-preparation.js";
3
+ import type { DiscoverySummary } from "./discovery.js";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import { TrajectoryDb } from "./db.js";
7
+ import { TrajectoryReader } from "./reader.js";
8
+ import {
9
+ APPEND_TAIL_BYTES,
10
+ DEFAULT_INGEST_RECORD_BYTES,
11
+ IngestBudgetExceeded,
12
+ ingestDiscoveredSession,
13
+ isIncrementalJsonl,
14
+ sessionNeedsIngest,
15
+ sourceFingerprint,
16
+ type IngestWork,
17
+ } from "./ingest.js";
18
+ import {
19
+ DEFAULT_PRESSURE_STATE,
20
+ adaptPressure,
21
+ samplePressure,
22
+ scaledLimit,
23
+ type AdaptivePressureState,
24
+ type PressureSnapshot,
25
+ } from "./pressure.js";
26
+ import { scanSessions, type SessionScanOptions } from "./scanner.js";
27
+ import type { DiscoveredSession, IngestCursor, IngestQueueEntry } from "./types.js";
28
+ import {
29
+ acquireWriterLease,
30
+ DEFAULT_WRITER_LEASE_WAIT_MS,
31
+ describeLeaseHolder,
32
+ isMemoryStorePath,
33
+ readWriterLease,
34
+ writerLockPath,
35
+ WriterLeaseBusyError,
36
+ type WriterLease,
37
+ type WriterLeaseMetadata,
38
+ } from "./writer-lease.js";
39
+
40
+ export const SYNC_PROTOCOL_VERSION = 2;
41
+ export const DEFAULT_SYNC_SESSION_BUDGET = 25;
42
+ export const DEFAULT_SYNC_SOURCE_BYTES = 64 * 1024 * 1024;
43
+ export const DEFAULT_SYNC_WALL_MS = 120_000;
44
+ export const DEFAULT_SYNC_RETRY_BUDGET = 3;
45
+ export const DEFAULT_SYNC_SESSION_ATTEMPTS = 8;
46
+ export const DEFAULT_SYNC_READ_BYTES_PER_SECOND = 8 * 1024 * 1024;
47
+ export const DEFAULT_SYNC_WORKER_MS = 30_000;
48
+ export const DEFAULT_SYNC_WORKERS = 2;
49
+ export const DEFAULT_SYNC_FRESH_SESSIONS = 5;
50
+ export const DEFAULT_SYNC_FRESH_WINDOW_MS = 60 * 60 * 1000;
51
+ export const DEFAULT_SYNC_CHUNK_BYTES = 1024 * 1024;
52
+ export const DEFAULT_SYNC_MAINTENANCE_MS = 5_000;
53
+ export const DEFAULT_SYNC_FTS_MERGE_PAGES = 64;
54
+ export const DEFAULT_WAL_AUTOCHECKPOINT_PAGES = 1000;
55
+
56
+ /** Grace between SIGTERM and SIGKILL for a disposable child the deadline stopped. */
57
+ export const WORKER_KILL_GRACE_MS = 250;
58
+ /**
59
+ * How long an expired worker wave may take to finish reaping its children: the
60
+ * kill grace plus process exit, so the lease is never released underneath a
61
+ * worker that is still dying. `LEDGER_WAVE_REAP_MS` overrides it for heavily
62
+ * loaded volumes (strict non-negative-integer parse, clamped to at least the
63
+ * kill grace so the window can never undercut the escalation sequence);
64
+ * unparsable values keep the default.
65
+ */
66
+ const WAVE_REAP_MS = WORKER_KILL_GRACE_MS * 2;
67
+
68
+ export function waveReapMsFor(raw: string | undefined): number {
69
+ if (raw === undefined || !/^\d+$/.test(raw.trim())) return WAVE_REAP_MS;
70
+ return Math.max(Number.parseInt(raw.trim(), 10), WORKER_KILL_GRACE_MS);
71
+ }
72
+
73
+ function readWaveReapMs(): number {
74
+ return waveReapMsFor(process.env.LEDGER_WAVE_REAP_MS);
75
+ }
76
+
77
+ export interface SyncBudgets {
78
+ sessions: number;
79
+ sourceBytes: number;
80
+ wallMs: number;
81
+ retries: number;
82
+ attemptsPerSession: number;
83
+ readBytesPerSecond: number;
84
+ workerMs: number;
85
+ workers: number;
86
+ freshSessions: number;
87
+ freshWindowMs: number;
88
+ chunkBytes: number;
89
+ maintenanceMs: number;
90
+ ftsMergePages: number;
91
+ }
92
+
93
+ export interface SyncOptions extends Pick<SessionScanOptions, "sources" | "roots"> {
94
+ dbPath: string;
95
+ scanLimit?: number;
96
+ maxRecordBytes?: number;
97
+ budgets?: Partial<SyncBudgets>;
98
+ pressureMode?: "auto" | "off";
99
+ workerCommand?: string[];
100
+ maintenanceCommand?: string[];
101
+ /** Test/embedding seam; production uses the private leaf preparation verb. */
102
+ preparationCommand?: string[];
103
+ }
104
+
105
+ export interface SyncWorkerJob {
106
+ protocol_version: typeof SYNC_PROTOCOL_VERSION;
107
+ db_path: string;
108
+ item: DiscoveredSession;
109
+ max_chunk_bytes: number;
110
+ max_record_bytes: number;
111
+ max_source_bytes: number;
112
+ read_bytes_per_second: number;
113
+ max_wall_ms: number;
114
+ max_attempts: number;
115
+ }
116
+
117
+ interface CursorState {
118
+ session_id: string | null;
119
+ bytes_ingested: number;
120
+ record_count: number;
121
+ status: string;
122
+ prefix_hash: string | null;
123
+ }
124
+
125
+ export interface SyncWorkerReply {
126
+ protocol_version: typeof SYNC_PROTOCOL_VERSION;
127
+ type: "sync_worker_result";
128
+ outcome: "ok" | "budget_exhausted" | "error";
129
+ results: Record<string, number>;
130
+ attempts: number;
131
+ budget?: "source_bytes" | "wall_time";
132
+ error_code?: "ingest_failed" | "database_busy" | "writer_busy";
133
+ work: IngestWork;
134
+ cursor_before: CursorState | null;
135
+ cursor_after: CursorState | null;
136
+ cursor_changed: boolean;
137
+ needs_ingest_after: boolean;
138
+ stalled: boolean;
139
+ }
140
+
141
+ /** A disposable child the pass stopped at a deadline: which process, under which bound. */
142
+ export interface WorkerStop {
143
+ pid: number;
144
+ timeout_ms: number;
145
+ label: string;
146
+ }
147
+
148
+ export interface WorkerInvocation {
149
+ status: "ok" | "spawn_failed" | "timed_out" | "protocol_error" | "failed";
150
+ reply?: SyncWorkerReply;
151
+ /** Present when a deadline stopped the child instead of it replying. */
152
+ stop?: WorkerStop;
153
+ }
154
+
155
+ export interface SyncMaintenanceJob {
156
+ protocol_version: typeof SYNC_PROTOCOL_VERSION;
157
+ db_path: string;
158
+ fts_merge_pages: number;
159
+ wal_autocheckpoint_pages: number;
160
+ }
161
+
162
+ export interface SyncMaintenanceReply {
163
+ protocol_version: typeof SYNC_PROTOCOL_VERSION;
164
+ type: "sync_maintenance_result";
165
+ fts_changes: number;
166
+ optimize_actions: number;
167
+ wal_busy: number;
168
+ wal_log_frames: number;
169
+ wal_checkpointed_frames: number;
170
+ elapsed_ms: number;
171
+ }
172
+
173
+ export interface MaintenanceInvocation {
174
+ status: "ok" | "spawn_failed" | "timed_out" | "protocol_error" | "failed" | "disabled" | "skipped";
175
+ reply?: SyncMaintenanceReply;
176
+ /** Present when a deadline stopped the child instead of it replying. */
177
+ stop?: WorkerStop;
178
+ }
179
+
180
+ export interface SyncDependencies {
181
+ /** Test-only in-process seam. Production discovery must use the disposable child. */
182
+ scan?: typeof scanSessions;
183
+ runWorker?: (
184
+ job: SyncWorkerJob,
185
+ timeoutMs: number,
186
+ command: string[],
187
+ signal?: AbortSignal,
188
+ ) => Promise<WorkerInvocation>;
189
+ runMaintenance?: (job: SyncMaintenanceJob, timeoutMs: number, command: string[]) => Promise<MaintenanceInvocation>;
190
+ pressure?: () => PressureSnapshot;
191
+ now?: () => Date;
192
+ }
193
+
194
+ export interface SyncSummary {
195
+ type: "sync_summary";
196
+ schema_version: 2;
197
+ /** Completion never implies that deleted, unsupported or unobserved history is archived. */
198
+ completion_scope?: "known_work";
199
+ discovery?: DiscoverySummary;
200
+ preparation_ms?: number;
201
+ outcome: "complete" | "pending" | "pending_budget" | "pending_retry" | "pending_pressure" | "wall_budget_exceeded";
202
+ elapsed_ms: number;
203
+ timing: { scan_ms: number; queue_ms: number; workers_ms: number; maintenance_ms: number; derive_ms: number };
204
+ discovered: number;
205
+ queued_at_start: number;
206
+ sessions_admitted: number;
207
+ lanes: { fresh: number; backlog: number };
208
+ worker_processes: number;
209
+ worker_attempts: number;
210
+ retries_scheduled: number;
211
+ completed: number;
212
+ results: Record<string, number>;
213
+ work: IngestWork & { unavailable_worker_reports: number; source_bytes_reserved: number };
214
+ pending: { total: number; eligible: number; retrying: number };
215
+ budgets: SyncBudgets & {
216
+ exhausted: string[];
217
+ effective: Pick<SyncBudgets, "sessions" | "sourceBytes" | "readBytesPerSecond" | "workers" | "freshSessions">;
218
+ };
219
+ pressure: {
220
+ mode: "auto" | "off";
221
+ initial: PressureSnapshot;
222
+ final: PressureSnapshot;
223
+ state: AdaptivePressureState;
224
+ };
225
+ maintenance: MaintenanceInvocation;
226
+ /** Present only on a scheduled pass that yielded the store to another writer. */
227
+ skipped?: { reason: "writer_busy"; holder: WriterLeaseMetadata | null };
228
+ }
229
+
230
+ function positiveInteger(value: number, name: string): number {
231
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive safe integer`);
232
+ return value;
233
+ }
234
+
235
+ function nonnegativeInteger(value: number, name: string): number {
236
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a nonnegative safe integer`);
237
+ return value;
238
+ }
239
+
240
+ function budgets(input: Partial<SyncBudgets> = {}): SyncBudgets {
241
+ const sessions = positiveInteger(input.sessions ?? DEFAULT_SYNC_SESSION_BUDGET, "session budget");
242
+ return {
243
+ sessions,
244
+ sourceBytes: positiveInteger(input.sourceBytes ?? DEFAULT_SYNC_SOURCE_BYTES, "source byte budget"),
245
+ wallMs: positiveInteger(input.wallMs ?? DEFAULT_SYNC_WALL_MS, "wall budget"),
246
+ retries: nonnegativeInteger(input.retries ?? DEFAULT_SYNC_RETRY_BUDGET, "retry budget"),
247
+ attemptsPerSession: positiveInteger(input.attemptsPerSession ?? DEFAULT_SYNC_SESSION_ATTEMPTS, "per-session attempt budget"),
248
+ readBytesPerSecond: positiveInteger(input.readBytesPerSecond ?? DEFAULT_SYNC_READ_BYTES_PER_SECOND, "read rate"),
249
+ workerMs: positiveInteger(input.workerMs ?? DEFAULT_SYNC_WORKER_MS, "worker deadline"),
250
+ workers: positiveInteger(input.workers ?? DEFAULT_SYNC_WORKERS, "worker concurrency"),
251
+ freshSessions: nonnegativeInteger(input.freshSessions ?? DEFAULT_SYNC_FRESH_SESSIONS, "fresh session budget"),
252
+ freshWindowMs: positiveInteger(input.freshWindowMs ?? DEFAULT_SYNC_FRESH_WINDOW_MS, "fresh window"),
253
+ chunkBytes: positiveInteger(input.chunkBytes ?? DEFAULT_SYNC_CHUNK_BYTES, "sync chunk bytes"),
254
+ maintenanceMs: nonnegativeInteger(input.maintenanceMs ?? DEFAULT_SYNC_MAINTENANCE_MS, "maintenance deadline"),
255
+ ftsMergePages: nonnegativeInteger(input.ftsMergePages ?? DEFAULT_SYNC_FTS_MERGE_PAGES, "FTS merge pages"),
256
+ };
257
+ }
258
+
259
+ function cursorState(cursor: IngestCursor | null): CursorState | null {
260
+ return cursor && {
261
+ session_id: cursor.session_id,
262
+ bytes_ingested: cursor.bytes_ingested,
263
+ record_count: cursor.record_count,
264
+ status: cursor.status,
265
+ prefix_hash: cursor.prefix_hash ?? null,
266
+ };
267
+ }
268
+
269
+ function getCursor(db: TrajectoryDb, item: DiscoveredSession): IngestCursor | null {
270
+ return db.getIngestCursor(item.source, item.id, item.path);
271
+ }
272
+
273
+ function emptyWork(): IngestWork {
274
+ return { source_bytes_read: 0, source_bytes_hashed: 0, historical_records_hashed: 0 };
275
+ }
276
+
277
+ function addWork(target: IngestWork, addition: IngestWork): void {
278
+ target.source_bytes_read += addition.source_bytes_read;
279
+ target.source_bytes_hashed += addition.source_bytes_hashed;
280
+ target.historical_records_hashed += addition.historical_records_hashed;
281
+ }
282
+
283
+ function addCounts(target: Record<string, number>, addition: Record<string, number>): void {
284
+ for (const [key, value] of Object.entries(addition)) target[key] = (target[key] ?? 0) + value;
285
+ }
286
+
287
+ function validateJob(value: unknown): SyncWorkerJob {
288
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_worker_job");
289
+ const job = value as Record<string, unknown>;
290
+ const item = job.item as Record<string, unknown> | undefined;
291
+ if (job.protocol_version !== SYNC_PROTOCOL_VERSION || typeof job.db_path !== "string" || !job.db_path ||
292
+ !item || typeof item.source !== "string" || !item.source || typeof item.id !== "string" || !item.id ||
293
+ typeof item.path !== "string" || !item.path) throw new Error("invalid_worker_job");
294
+ for (const key of ["max_chunk_bytes", "max_record_bytes", "max_source_bytes", "read_bytes_per_second", "max_wall_ms", "max_attempts"] as const) {
295
+ positiveInteger(job[key] as number, key);
296
+ }
297
+ if ((job.max_record_bytes as number) < (job.max_chunk_bytes as number)) throw new Error("invalid_worker_job");
298
+ return value as SyncWorkerJob;
299
+ }
300
+
301
+ export async function executeSyncWorker(
302
+ input: unknown,
303
+ options: { workerChild?: boolean } = {},
304
+ ): Promise<SyncWorkerReply> {
305
+ const job = validateJob(input);
306
+ // The parent's lease is this child's write authority; check it before any
307
+ // store handle exists.
308
+ if (options.workerChild) requireParentLease(job.db_path);
309
+ const db = new TrajectoryDb(job.db_path);
310
+ const before = cursorState(getCursor(db, job.item));
311
+ const totalWork = emptyWork();
312
+ const results: Record<string, number> = {};
313
+ const started = performance.now();
314
+ let attempts = 0;
315
+ let needsIngest = true;
316
+ let stalled = false;
317
+ try {
318
+ while (attempts < job.max_attempts && needsIngest) {
319
+ const elapsed = performance.now() - started;
320
+ const remainingWall = Math.floor(job.max_wall_ms - elapsed);
321
+ const remainingBytes = job.max_source_bytes - totalWork.source_bytes_read;
322
+ if (remainingWall <= 0 || remainingBytes <= 0) {
323
+ const after = cursorState(getCursor(db, job.item));
324
+ return {
325
+ protocol_version: SYNC_PROTOCOL_VERSION, type: "sync_worker_result", outcome: "budget_exhausted",
326
+ budget: remainingWall <= 0 ? "wall_time" : "source_bytes", results, attempts, work: totalWork,
327
+ cursor_before: before, cursor_after: after,
328
+ cursor_changed: JSON.stringify(before) !== JSON.stringify(after),
329
+ needs_ingest_after: true, stalled: false,
330
+ };
331
+ }
332
+ const attemptBefore = cursorState(getCursor(db, job.item));
333
+ attempts++;
334
+ const result = await ingestDiscoveredSession(db, job.item, {
335
+ maxChunkBytes: job.max_chunk_bytes,
336
+ maxRecordBytes: job.max_record_bytes,
337
+ maxSourceBytes: remainingBytes,
338
+ readBytesPerSecond: job.read_bytes_per_second,
339
+ maxWallMs: remainingWall,
340
+ });
341
+ addWork(totalWork, result.work ?? emptyWork());
342
+ results[result.kind] = (results[result.kind] ?? 0) + 1;
343
+ const attemptAfter = cursorState(getCursor(db, job.item));
344
+ needsIngest = sessionNeedsIngest(db, job.item, { maxRecordBytes: job.max_record_bytes });
345
+ if (needsIngest && JSON.stringify(attemptBefore) === JSON.stringify(attemptAfter)) {
346
+ stalled = true;
347
+ break;
348
+ }
349
+ }
350
+ const after = cursorState(getCursor(db, job.item));
351
+ return {
352
+ protocol_version: SYNC_PROTOCOL_VERSION,
353
+ type: "sync_worker_result",
354
+ outcome: "ok",
355
+ results,
356
+ attempts,
357
+ work: totalWork,
358
+ cursor_before: before,
359
+ cursor_after: after,
360
+ cursor_changed: JSON.stringify(before) !== JSON.stringify(after),
361
+ needs_ingest_after: needsIngest,
362
+ stalled,
363
+ };
364
+ } catch (error) {
365
+ const after = cursorState(getCursor(db, job.item));
366
+ if (error instanceof IngestBudgetExceeded) {
367
+ addWork(totalWork, error.work);
368
+ return {
369
+ protocol_version: SYNC_PROTOCOL_VERSION, type: "sync_worker_result", outcome: "budget_exhausted",
370
+ budget: error.budget, results, attempts, work: totalWork, cursor_before: before, cursor_after: after,
371
+ cursor_changed: JSON.stringify(before) !== JSON.stringify(after), needs_ingest_after: true, stalled: false,
372
+ };
373
+ }
374
+ return {
375
+ protocol_version: SYNC_PROTOCOL_VERSION, type: "sync_worker_result", outcome: "error",
376
+ error_code: error instanceof Error && /database is (locked|busy)|SQLITE_BUSY/i.test(error.message)
377
+ ? "database_busy" : "ingest_failed",
378
+ results, attempts, work: totalWork, cursor_before: before, cursor_after: after,
379
+ cursor_changed: JSON.stringify(before) !== JSON.stringify(after), needs_ingest_after: true, stalled: true,
380
+ };
381
+ } finally {
382
+ db.close();
383
+ }
384
+ }
385
+
386
+ export async function executeSyncWorkerStdio(options: { workerChild?: boolean } = {}): Promise<void> {
387
+ const raw = await readBoundedProtocol(Bun.stdin.stream());
388
+ if (raw.length > 64 * 1024) throw new Error("invalid_worker_job");
389
+ const input = JSON.parse(raw);
390
+ let reply: SyncWorkerReply;
391
+ try {
392
+ reply = await executeSyncWorker(input, options);
393
+ } catch (error) {
394
+ if (!(error instanceof WriterLeaseBusyError)) throw error;
395
+ // The refusal is a normal protocol reply, so the parent defers the session
396
+ // durably instead of treating it as a crashed child.
397
+ console.error(error.message);
398
+ reply = {
399
+ protocol_version: SYNC_PROTOCOL_VERSION, type: "sync_worker_result", outcome: "error",
400
+ error_code: "writer_busy", results: {}, attempts: 0, work: emptyWork(),
401
+ cursor_before: null, cursor_after: null, cursor_changed: false,
402
+ needs_ingest_after: true, stalled: false,
403
+ };
404
+ }
405
+ process.stdout.write(JSON.stringify(reply) + "\n");
406
+ }
407
+
408
+ function validWork(value: unknown): value is IngestWork {
409
+ if (!value || typeof value !== "object") return false;
410
+ return ["source_bytes_read", "source_bytes_hashed", "historical_records_hashed"]
411
+ .every((key) => Number.isSafeInteger((value as Record<string, unknown>)[key]) && Number((value as Record<string, unknown>)[key]) >= 0);
412
+ }
413
+
414
+ function validCounts(value: unknown): value is Record<string, number> {
415
+ return !!value && typeof value === "object" && !Array.isArray(value) &&
416
+ Object.entries(value).every(([key, count]) => key.length > 0 && Number.isSafeInteger(count) && Number(count) >= 0);
417
+ }
418
+
419
+ function parseWorkerReply(raw: string): SyncWorkerReply | null {
420
+ if (raw.length > 64 * 1024) return null;
421
+ try {
422
+ const reply = JSON.parse(raw) as SyncWorkerReply;
423
+ if (reply.protocol_version !== SYNC_PROTOCOL_VERSION || reply.type !== "sync_worker_result" ||
424
+ !["ok", "budget_exhausted", "error"].includes(reply.outcome) || !validWork(reply.work) ||
425
+ !validCounts(reply.results) || !Number.isSafeInteger(reply.attempts) || reply.attempts < 0 ||
426
+ typeof reply.cursor_changed !== "boolean" || typeof reply.needs_ingest_after !== "boolean" ||
427
+ typeof reply.stalled !== "boolean") return null;
428
+ return reply;
429
+ } catch {
430
+ return null;
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Kill sequence for a disposable child: SIGTERM for a chance to exit cleanly,
436
+ * SIGKILL after the grace, and the answer is pending until the child is reaped.
437
+ * Callers race it against whatever they were waiting on, so a child that
438
+ * ignores SIGTERM — or a stdout pipe a descendant keeps open — cannot keep the
439
+ * caller's await pending. The kill is pid-scoped: a descendant holding that
440
+ * pipe lives on as its own process, and nothing here waits on it.
441
+ */
442
+ /**
443
+ * Kill escalation is pid-scoped by design: today's workers are leaf `bun
444
+ * __sync-worker` processes with no descendants, and a process-group kill
445
+ * (setsid + kill(-pid)) would make workers survive the parent's death —
446
+ * trading a real orphan regression for a hypothetical descendant. If a worker
447
+ * ever spawns children, switch this to group semantics and pin the orphan
448
+ * behavior (issue #39 item 1; the recorded trigger is this comment).
449
+ */
450
+ async function terminateChild(child: Bun.Subprocess<"pipe", "pipe", "ignore">): Promise<void> {
451
+ if (child.exitCode !== null) return;
452
+ try { child.kill("SIGTERM"); } catch { /* already exited */ }
453
+ await sleep(WORKER_KILL_GRACE_MS);
454
+ try { child.kill("SIGKILL"); } catch { /* already exited */ }
455
+ try { await child.exited; } catch { /* reaped elsewhere */ }
456
+ }
457
+
458
+ export async function readBoundedProtocol(stream: ReadableStream<Uint8Array>, maxBytes = 64 * 1024): Promise<string> {
459
+ const reader = stream.getReader();
460
+ const parts: Uint8Array[] = [];
461
+ let total = 0;
462
+ try {
463
+ for (;;) {
464
+ const { done, value } = await reader.read();
465
+ if (done) break;
466
+ total += value.byteLength;
467
+ if (total > maxBytes) throw new Error("protocol_output_too_large");
468
+ parts.push(value);
469
+ }
470
+ return Buffer.concat(parts).toString("utf8");
471
+ } finally { reader.releaseLock(); }
472
+ }
473
+
474
+ async function spawnJsonChild<T>(
475
+ input: unknown,
476
+ timeoutMs: number,
477
+ command: string[],
478
+ parse: (raw: string) => T | null,
479
+ options: { label: string; env?: Record<string, string | undefined>; signal?: AbortSignal },
480
+ ): Promise<{ status: WorkerInvocation["status"]; reply?: T; stop?: WorkerStop }> {
481
+ let child: Bun.Subprocess<"pipe", "pipe", "ignore">;
482
+ try {
483
+ child = Bun.spawn(command, { stdin: "pipe", stdout: "pipe", stderr: "ignore", env: options.env });
484
+ } catch {
485
+ return { status: "spawn_failed" };
486
+ }
487
+ // Both the child's own deadline and the pass's wall stop it through this one
488
+ // sequence, and the sequence's completion is what settles the call: the reply
489
+ // drain races it, so a stream that never closes loses instead of holding the
490
+ // await open forever.
491
+ const killed = Promise.withResolvers<void>();
492
+ let stopped = false;
493
+ let termination: Promise<void> | null = null;
494
+ const killChild = (): void => {
495
+ stopped = true;
496
+ termination ??= terminateChild(child).then(() => killed.resolve());
497
+ };
498
+ const timer = setTimeout(killChild, timeoutMs);
499
+ const onAbort = (): void => killChild();
500
+ options.signal?.addEventListener("abort", onAbort, { once: true });
501
+ if (options.signal?.aborted) killChild();
502
+ try {
503
+ child.stdin.write(JSON.stringify(input));
504
+ child.stdin.end();
505
+ const drain = (async () => {
506
+ const raw = await readBoundedProtocol(child.stdout);
507
+ return { raw, exitCode: await child.exited };
508
+ })();
509
+ await Promise.race([drain, killed.promise]);
510
+ // The drain can win the race on the SIGTERM EOF while the child is already
511
+ // stopped, so the flag — not the race winner — decides the status.
512
+ if (stopped) {
513
+ return {
514
+ status: "timed_out",
515
+ stop: { pid: child.pid, timeout_ms: timeoutMs, label: options.label },
516
+ };
517
+ }
518
+ const { raw, exitCode } = await drain;
519
+ if (exitCode !== 0) return { status: "failed" };
520
+ const reply = parse(raw.trim());
521
+ return reply ? { status: "ok", reply } : { status: "protocol_error" };
522
+ } catch {
523
+ return stopped ? { status: "timed_out", stop: { pid: child.pid, timeout_ms: timeoutMs, label: options.label } }
524
+ : { status: "protocol_error" };
525
+ } finally {
526
+ clearTimeout(timer);
527
+ options.signal?.removeEventListener("abort", onAbort);
528
+ if (termination) {
529
+ // The stop sequence owns the reap; waiting for it keeps a dying child
530
+ // from outliving the call that opened it.
531
+ await termination;
532
+ } else if (child.exitCode === null) {
533
+ await terminateChild(child);
534
+ }
535
+ }
536
+ }
537
+
538
+ export async function spawnSyncWorker(
539
+ job: SyncWorkerJob,
540
+ timeoutMs: number,
541
+ command: string[],
542
+ signal?: AbortSignal,
543
+ ): Promise<WorkerInvocation> {
544
+ // The parent holds the writer lease across the whole pass; the marker only
545
+ // tells the child it was spawned by one. Its authority is checked against the
546
+ // parent's lease (see requireParentLease), never trusted from the environment.
547
+ return spawnJsonChild(job, timeoutMs, command, parseWorkerReply, {
548
+ label: "sync_worker",
549
+ env: { ...process.env, LEDGER_SYNC_WORKER: "1" },
550
+ signal,
551
+ });
552
+ }
553
+
554
+ function validateMaintenanceJob(value: unknown): SyncMaintenanceJob {
555
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_maintenance_job");
556
+ const job = value as Record<string, unknown>;
557
+ if (job.protocol_version !== SYNC_PROTOCOL_VERSION || typeof job.db_path !== "string" || !job.db_path) {
558
+ throw new Error("invalid_maintenance_job");
559
+ }
560
+ nonnegativeInteger(job.fts_merge_pages as number, "fts_merge_pages");
561
+ positiveInteger(job.wal_autocheckpoint_pages as number, "wal_autocheckpoint_pages");
562
+ return value as SyncMaintenanceJob;
563
+ }
564
+
565
+ export function executeSyncMaintenance(
566
+ input: unknown,
567
+ options: { workerChild?: boolean } = {},
568
+ ): SyncMaintenanceReply {
569
+ const job = validateMaintenanceJob(input);
570
+ if (options.workerChild) requireParentLease(job.db_path);
571
+ const started = performance.now();
572
+ const db = new Database(job.db_path);
573
+ try {
574
+ db.run("PRAGMA busy_timeout = 15000");
575
+ db.run("PRAGMA journal_mode = WAL");
576
+ db.run(`PRAGMA wal_autocheckpoint = ${job.wal_autocheckpoint_pages}`);
577
+ const ftsChanges = job.fts_merge_pages > 0
578
+ ? db.run("INSERT INTO records_fts(records_fts, rank) VALUES ('merge', ?)", [job.fts_merge_pages]).changes
579
+ : 0;
580
+ const optimizeActions = db.query("PRAGMA optimize").all().length;
581
+ const checkpoint = db.query<{ busy: number; log: number; checkpointed: number }, []>(
582
+ "PRAGMA wal_checkpoint(PASSIVE)",
583
+ ).get() ?? { busy: 0, log: 0, checkpointed: 0 };
584
+ return {
585
+ protocol_version: SYNC_PROTOCOL_VERSION,
586
+ type: "sync_maintenance_result",
587
+ fts_changes: ftsChanges,
588
+ optimize_actions: optimizeActions,
589
+ wal_busy: checkpoint.busy,
590
+ wal_log_frames: checkpoint.log,
591
+ wal_checkpointed_frames: checkpoint.checkpointed,
592
+ elapsed_ms: performance.now() - started,
593
+ };
594
+ } finally {
595
+ db.close();
596
+ }
597
+ }
598
+
599
+ export async function executeSyncMaintenanceStdio(options: { workerChild?: boolean } = {}): Promise<void> {
600
+ const raw = await readBoundedProtocol(Bun.stdin.stream());
601
+ if (raw.length > 64 * 1024) throw new Error("invalid_maintenance_job");
602
+ process.stdout.write(JSON.stringify(executeSyncMaintenance(JSON.parse(raw), options)) + "\n");
603
+ }
604
+
605
+ function parseMaintenanceReply(raw: string): SyncMaintenanceReply | null {
606
+ if (raw.length > 64 * 1024) return null;
607
+ try {
608
+ const reply = JSON.parse(raw) as SyncMaintenanceReply;
609
+ if (reply.protocol_version !== SYNC_PROTOCOL_VERSION || reply.type !== "sync_maintenance_result") return null;
610
+ for (const value of [reply.fts_changes, reply.optimize_actions, reply.wal_busy,
611
+ reply.wal_log_frames, reply.wal_checkpointed_frames, reply.elapsed_ms]) {
612
+ if (!Number.isFinite(value) || value < 0) return null;
613
+ }
614
+ return reply;
615
+ } catch {
616
+ return null;
617
+ }
618
+ }
619
+
620
+ export async function spawnSyncMaintenance(
621
+ job: SyncMaintenanceJob,
622
+ timeoutMs: number,
623
+ command: string[],
624
+ ): Promise<MaintenanceInvocation> {
625
+ return spawnJsonChild(job, timeoutMs, command, parseMaintenanceReply, { label: "sync_maintenance" });
626
+ }
627
+
628
+ function retryAt(now: Date, attempts: number): string {
629
+ const delayMs = Math.min(6 * 60 * 60 * 1000, 15 * 60 * 1000 * 2 ** Math.min(attempts, 5));
630
+ return new Date(now.getTime() + delayMs).toISOString();
631
+ }
632
+
633
+ function workerEntry(): string[] {
634
+ return [process.execPath, fileURLToPath(new URL("../bin/ledger-core.ts", import.meta.url)), "__sync-worker"];
635
+ }
636
+
637
+ function maintenanceEntry(): string[] {
638
+ return [process.execPath, fileURLToPath(new URL("../bin/ledger-core.ts", import.meta.url)), "__sync-maintenance"];
639
+ }
640
+
641
+ export function workerReservation(
642
+ item: IngestQueueEntry,
643
+ limits: Pick<SyncBudgets, "attemptsPerSession" | "chunkBytes">,
644
+ remaining: number,
645
+ ): number {
646
+ // Directory-backed native sessions span metadata plus companion files;
647
+ // discovery only sizes the metadata file. Reserve the assembled footprint.
648
+ const sourceBytes = item.native?.kind === "opencode-storage" || item.native?.kind === "fx-directory"
649
+ ? sourceFingerprint(item)?.size ?? item.sizeBytes
650
+ : item.sizeBytes;
651
+ const desired = isIncrementalJsonl(item)
652
+ ? limits.attemptsPerSession * (limits.chunkBytes + APPEND_TAIL_BYTES)
653
+ : Math.max(1, sourceBytes ?? limits.chunkBytes);
654
+ return Math.max(1, Math.min(desired, remaining));
655
+ }
656
+
657
+ function pressureOffSnapshot(): PressureSnapshot {
658
+ return { platform: process.platform, severity: "unavailable", cpu: null, memory: null, io: null,
659
+ cgroup_memory_ratio: null, cgroup_cpu_cores: null, reasons: ["pressure_mode_off"] };
660
+ }
661
+
662
+ function effectiveLimits(limits: SyncBudgets, state: AdaptivePressureState, snapshot: PressureSnapshot) {
663
+ const sessions = scaledLimit(limits.sessions, state.scale_permille);
664
+ const quotaWorkers = snapshot.cgroup_cpu_cores === null
665
+ ? limits.workers
666
+ : Math.max(1, Math.floor(snapshot.cgroup_cpu_cores));
667
+ return {
668
+ sessions,
669
+ sourceBytes: scaledLimit(limits.sourceBytes, state.scale_permille),
670
+ readBytesPerSecond: scaledLimit(limits.readBytesPerSecond, state.scale_permille),
671
+ workers: Math.min(sessions, quotaWorkers, scaledLimit(limits.workers, state.scale_permille)),
672
+ freshSessions: limits.freshSessions === 0 ? 0 : Math.min(sessions, scaledLimit(limits.freshSessions, state.scale_permille)),
673
+ };
674
+ }
675
+
676
+ /** Result of the sync lease policy: this pass owns the store, or it does not run. */
677
+ type SyncLeaseOutcome =
678
+ | { status: "held"; lease: WriterLease }
679
+ | { status: "exempt" }
680
+ | { status: "skipped"; holder: WriterLeaseMetadata | null };
681
+
682
+ /**
683
+ * Lease policy for the sync entry point. A scheduled pass yields the store to a
684
+ * foreground writer instead of queueing behind it, and a foreground verb waits
685
+ * briefly before refusing with a typed error. Nothing here consults the
686
+ * environment: the worker-child marker is a transport hint only, and a child
687
+ * proves its authority by naming its parent (see requireParentLease).
688
+ */
689
+ function acquireSyncLease(dbPath: string): SyncLeaseOutcome {
690
+ if (isMemoryStorePath(dbPath)) return { status: "exempt" };
691
+ const scheduled = process.env.LEDGER_SCHEDULER === "1";
692
+ try {
693
+ return {
694
+ status: "held",
695
+ lease: acquireWriterLease(dbPath, {
696
+ waitMs: scheduled ? 0 : DEFAULT_WRITER_LEASE_WAIT_MS,
697
+ holder: scheduled ? "scheduler" : "sync",
698
+ }),
699
+ };
700
+ } catch (error) {
701
+ if (!scheduled || !(error instanceof WriterLeaseBusyError)) throw error;
702
+ console.error(`[ledger] sync skipped: another writer holds the store (${describeLeaseHolder(error.holder)})`);
703
+ return { status: "skipped", holder: error.holder };
704
+ }
705
+ }
706
+
707
+ /**
708
+ * A disposable worker child writes on its parent's authority, and that authority
709
+ * is the parent's lease. The check is attribution and invocation binding — it
710
+ * decides whether this invocation may write and which process is to be named —
711
+ * NOT mutual exclusion: exclusion rests on the store's own SQLite write lock
712
+ * plus the parent's lease held across the whole pass. Trusting a transport marker
713
+ * instead would let an ambient environment variable, or a hand-invoked private
714
+ * verb, write to a store its caller does not own.
715
+ */
716
+ function requireParentLease(dbPath: string): void {
717
+ const holder = readWriterLease(dbPath);
718
+ if (holder?.pid === process.ppid) return;
719
+ throw new WriterLeaseBusyError(
720
+ writerLockPath(dbPath),
721
+ holder,
722
+ `worker authority check failed: parent does not hold the lease (${describeLeaseHolder(holder)})`,
723
+ );
724
+ }
725
+
726
+ /**
727
+ * Zero-work summary for a scheduled pass that yielded the store. It reports no
728
+ * work instead of a fabricated ingestion, and names the holder that owns the
729
+ * store until it finishes.
730
+ */
731
+ function skippedSyncSummary(
732
+ limits: SyncBudgets,
733
+ started: number,
734
+ pressureMode: "auto" | "off",
735
+ readPressure: () => PressureSnapshot,
736
+ holder: WriterLeaseMetadata | null,
737
+ ): SyncSummary {
738
+ const pressure = pressureMode === "auto" ? readPressure() : pressureOffSnapshot();
739
+ return {
740
+ type: "sync_summary",
741
+ schema_version: 2,
742
+ outcome: "complete",
743
+ elapsed_ms: performance.now() - started,
744
+ timing: { scan_ms: 0, queue_ms: 0, workers_ms: 0, maintenance_ms: 0, derive_ms: 0 },
745
+ discovered: 0,
746
+ queued_at_start: 0,
747
+ sessions_admitted: 0,
748
+ lanes: { fresh: 0, backlog: 0 },
749
+ worker_processes: 0,
750
+ worker_attempts: 0,
751
+ retries_scheduled: 0,
752
+ completed: 0,
753
+ results: {},
754
+ work: { ...emptyWork(), unavailable_worker_reports: 0, source_bytes_reserved: 0 },
755
+ pending: { total: 0, eligible: 0, retrying: 0 },
756
+ budgets: { ...limits, exhausted: [], effective: effectiveLimits(limits, DEFAULT_PRESSURE_STATE, pressure) },
757
+ pressure: { mode: pressureMode, initial: pressure, final: pressure, state: { ...DEFAULT_PRESSURE_STATE } },
758
+ maintenance: limits.maintenanceMs === 0 ? { status: "disabled" } : { status: "skipped" },
759
+ skipped: { reason: "writer_busy", holder },
760
+ };
761
+ }
762
+
763
+ /** One worker wave: each child's report, or `null` for a child the wall stopped. */
764
+ interface WaveWait {
765
+ expired: boolean;
766
+ invocations: Array<WorkerInvocation | null>;
767
+ }
768
+
769
+ /**
770
+ * Wait for one worker wave, but never past the pass's own wall deadline. The
771
+ * children are disposable and ours, so expiry aborts them through the same kill
772
+ * sequence their own deadlines use — a pid-scoped kill — and waits for that
773
+ * sequence to finish reaping them: the lease must not be released underneath a
774
+ * dying writer. A child that never closes its stdout cannot keep this await
775
+ * pending, because the abort settles it, and a sibling that already reported
776
+ * keeps its real result: only the children still pending at the clock are
777
+ * wall-stopped.
778
+ */
779
+ async function awaitWorkerWave(
780
+ jobs: SyncWorkerJob[],
781
+ wallRemainingMs: number,
782
+ run: (job: SyncWorkerJob, signal: AbortSignal) => Promise<WorkerInvocation>,
783
+ ): Promise<WaveWait> {
784
+ const abort = new AbortController();
785
+ const invocations: Array<WorkerInvocation | null> = jobs.map(() => null);
786
+ const wave = Promise.all(jobs.map((job, index) => run(job, abort.signal).then((invocation) => {
787
+ invocations[index] = invocation;
788
+ return invocation;
789
+ })));
790
+ let expired = false;
791
+ // The clock is cleared either way: a pass that finishes early must not keep a
792
+ // pending timer alive for the rest of its wall budget.
793
+ const stopped = Promise.withResolvers<void>();
794
+ const clock = setTimeout(() => { expired = true; stopped.resolve(); }, wallRemainingMs);
795
+ void wave.then(() => stopped.resolve(), () => stopped.resolve());
796
+ await stopped.promise;
797
+ clearTimeout(clock);
798
+ // A rejected runner still fails the pass, exactly as it did without the race.
799
+ if (!expired) { await wave; return { expired: false, invocations }; }
800
+ // Snapshot before the abort: what has reported by now is a report, and what
801
+ // the abort is about to stop must not be mistaken for one.
802
+ const reported = [...invocations];
803
+ abort.abort();
804
+ const reaped = Promise.withResolvers<void>();
805
+ const reap = setTimeout(reaped.resolve, readWaveReapMs());
806
+ void wave.then(() => reaped.resolve(), () => reaped.resolve());
807
+ await reaped.promise;
808
+ clearTimeout(reap);
809
+ return { expired: true, invocations: reported };
810
+ }
811
+
812
+ export class SyncPreparationError extends Error {
813
+ readonly code: string;
814
+ constructor(status: string) {
815
+ super(`Sync preparation ${status}; initialization/discovery did not complete. The committed store and discovery checkpoint remain authoritative; retry a bounded sync.`);
816
+ this.name = "SyncPreparationError";
817
+ this.code = status === "timed_out" ? "SYNC_WALL_TIMEOUT" : "SYNC_PREPARATION_FAILED";
818
+ }
819
+ }
820
+
821
+ function validatePreparationJob(input: unknown): SyncPreparationJob {
822
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("invalid_preparation_job");
823
+ const job = input as SyncPreparationJob;
824
+ if (job.protocol_version !== SYNC_PROTOCOL_VERSION || typeof job.db_path !== "string" || !job.db_path ||
825
+ (job.sources !== undefined && (!Array.isArray(job.sources) || job.sources.some(source => typeof source !== "string"))) ||
826
+ (job.roots !== undefined && (!job.roots || typeof job.roots !== "object" || Array.isArray(job.roots) || Object.values(job.roots).some(root => typeof root !== "string")))) {
827
+ throw new Error("invalid_preparation_job");
828
+ }
829
+ positiveInteger(job.scan_limit, "scan limit");
830
+ positiveInteger(job.max_record_bytes, "max record bytes");
831
+ return job;
832
+ }
833
+
834
+ export async function executeSyncPreparationStdio(): Promise<void> {
835
+ const job = validatePreparationJob(JSON.parse(await readBoundedProtocol(Bun.stdin.stream())));
836
+ requireParentLease(job.db_path);
837
+ try {
838
+ const result = await prepareSyncStore(job);
839
+ process.stdout.write(JSON.stringify(result) + "\n");
840
+ } catch (error) {
841
+ // Preserve typed schema/operator failures without allowing arbitrary stdout
842
+ // to masquerade as a successful phase. No source bodies in protocol errors.
843
+ const message = error instanceof Error ? error.message : String(error);
844
+ process.stdout.write(JSON.stringify({ protocol_version: SYNC_PROTOCOL_VERSION,
845
+ type: "sync_preparation_error", code: message.includes("schema version") ? "SCHEMA_MISMATCH" : "SYNC_PREPARATION_FAILED",
846
+ message: message.slice(0, 2048) }) + "\n");
847
+ }
848
+ }
849
+
850
+ interface PreparationFailure { protocol_version: 2; type: "sync_preparation_error"; code: string; message: string }
851
+ function parsePreparationReply(raw: string): SyncPreparationReply | PreparationFailure | null {
852
+ try {
853
+ const reply = JSON.parse(raw);
854
+ if (reply.protocol_version !== SYNC_PROTOCOL_VERSION) return null;
855
+ if (reply.type === "sync_preparation_error" && typeof reply.code === "string" && typeof reply.message === "string") return reply;
856
+ if (reply.type !== "sync_preparation_result" || !Number.isSafeInteger(reply.discovered) || reply.discovered < 0 ||
857
+ ![reply.init_ms, reply.scan_ms, reply.queue_ms].every(value => Number.isFinite(value) && value >= 0)) return null;
858
+ return reply;
859
+ } catch { return null; }
860
+ }
861
+
862
+ async function prepareInChild(options: SyncOptions, scanLimit: number, maxRecordBytes: number, remaining: number): Promise<SyncPreparationReply> {
863
+ if (remaining <= 0) throw new SyncPreparationError("timed_out");
864
+ const result = await spawnJsonChild<SyncPreparationReply | PreparationFailure>({ protocol_version: SYNC_PROTOCOL_VERSION,
865
+ db_path: options.dbPath, sources: options.sources, roots: options.roots, scan_limit: scanLimit, max_record_bytes: maxRecordBytes },
866
+ remaining, options.preparationCommand ?? [process.execPath, fileURLToPath(new URL("../bin/ledger-core.ts", import.meta.url)), "__sync-prepare"],
867
+ parsePreparationReply, { label: "sync_preparation" });
868
+ if (result.status !== "ok" || !result.reply) throw new SyncPreparationError(result.status);
869
+ if (result.reply.type === "sync_preparation_error") {
870
+ const error = new SyncPreparationError("failed");
871
+ Object.assign(error, { code: result.reply.code, message: result.reply.message });
872
+ throw error;
873
+ }
874
+ return result.reply;
875
+ }
876
+
877
+ export async function runSync(options: SyncOptions, dependencies: SyncDependencies = {}): Promise<SyncSummary> {
878
+ if (!options.dbPath) throw new Error("dbPath is required");
879
+ const limits = budgets(options.budgets);
880
+ if (limits.freshSessions > limits.sessions) throw new Error("fresh session budget cannot exceed session budget");
881
+ const maxRecordBytes = positiveInteger(options.maxRecordBytes ?? DEFAULT_INGEST_RECORD_BYTES, "max record bytes");
882
+ if (limits.chunkBytes > maxRecordBytes) throw new Error("sync chunk bytes cannot exceed max record bytes");
883
+ const scanLimit = positiveInteger(options.scanLimit ?? 1000, "scan limit");
884
+ const scan = dependencies.scan ?? scanSessions;
885
+ const runWorker = dependencies.runWorker ?? spawnSyncWorker;
886
+ const runMaintenance = dependencies.runMaintenance ?? spawnSyncMaintenance;
887
+ const readPressure = dependencies.pressure ?? samplePressure;
888
+ const now = dependencies.now ?? (() => new Date());
889
+ const pressureMode = options.pressureMode ?? "auto";
890
+ const started = performance.now();
891
+ const workDeadline = Math.max(1, limits.wallMs - limits.maintenanceMs);
892
+ // The lease attaches before the store handle: a refused or skipped pass must
893
+ // leave the database exactly as it found it.
894
+ const lease = acquireSyncLease(options.dbPath);
895
+ if (lease.status === "skipped") {
896
+ return skippedSyncSummary(limits, started, pressureMode, readPressure, lease.holder);
897
+ }
898
+ const held = lease.status === "held" ? lease.lease : null;
899
+ try {
900
+ const exhausted = new Set<string>();
901
+ // The pass's own deadline is the one exhaustion the pass owns, and it gets its
902
+ // own outcome instead of hiding behind the per-worker and per-budget keys that
903
+ // share `exhausted` (`wall_time` is also a worker budget boundary).
904
+ let wallBudgetExceeded = false;
905
+ const exhaustWallBudget = (): void => { exhausted.add("wall_time"); wallBudgetExceeded = true; };
906
+ const work = { ...emptyWork(), unavailable_worker_reports: 0, source_bytes_reserved: 0 };
907
+ const results: Record<string, number> = {};
908
+ let workerProcesses = 0;
909
+ let workerAttempts = 0;
910
+ let retriesScheduled = 0;
911
+ let completed = 0;
912
+ let maintenance: MaintenanceInvocation = limits.maintenanceMs === 0 ? { status: "disabled" } : { status: "skipped" };
913
+ let scanMs = 0;
914
+ let queueMs = 0;
915
+ let workersMs = 0;
916
+ let maintenanceMs = 0;
917
+ let deriveMs = 0;
918
+ const initialPressure = pressureMode === "auto" ? readPressure() : pressureOffSnapshot();
919
+ const preparationStarted = performance.now();
920
+ const prepared = !dependencies.scan && !isMemoryStorePath(options.dbPath)
921
+ ? await prepareInChild(options, scanLimit, maxRecordBytes, Math.floor(workDeadline - (performance.now() - started)))
922
+ : null;
923
+ const preparationMs = performance.now() - preparationStarted;
924
+ const db = new TrajectoryDb(options.dbPath, { initialize: prepared === null });
925
+ let dbClosed = false;
926
+ try {
927
+ let finalPressure = initialPressure;
928
+ let pressureState = pressureMode === "auto" ? db.getAdaptivePressureState() : { ...DEFAULT_PRESSURE_STATE };
929
+ let pressureReducedThisRun = false;
930
+ if (pressureMode === "auto") {
931
+ const decision = adaptPressure(pressureState, initialPressure);
932
+ pressureState = decision.state;
933
+ pressureReducedThisRun = initialPressure.severity === "constrained" || initialPressure.severity === "critical";
934
+ db.setAdaptivePressureState(pressureState, now().toISOString());
935
+ if (decision.pause) exhausted.add("pressure");
936
+ }
937
+ let effective = effectiveLimits(limits, pressureState, initialPressure);
938
+ // The derive-rule counter's one-shot runs here, under the writer lease,
939
+ // before in-process admission and before workers on the prepared path.
940
+ // It is skipped under critical pressure exactly like the maintenance
941
+ // child — a rebuild is the kind of heavy one-shot this pass defers, and
942
+ // the flag is durable, so the next pass picks it up. A failure
943
+ // propagates: the store could not re-project its own canonical records,
944
+ // which is a broken store, not a pass that ran out of budget.
945
+ if (!exhausted.has("pressure")) {
946
+ const deriveStarted = performance.now();
947
+ if (db.reindexFts()) deriveMs = performance.now() - deriveStarted;
948
+ }
949
+ let discoveredCount = prepared?.discovered ?? 0;
950
+ scanMs = prepared?.scan_ms ?? 0;
951
+ queueMs = prepared?.queue_ms ?? 0;
952
+ if (!prepared) {
953
+ // Injectable scans are a deterministic test seam; file-backed production
954
+ // never enters this in-process branch. The default memory store is local.
955
+ const scanStarted = performance.now();
956
+ let clock: ReturnType<typeof setTimeout> | undefined;
957
+ const remaining = Math.max(1, workDeadline - (performance.now() - started));
958
+ try {
959
+ const result = await Promise.race([
960
+ scan({ sources: options.sources, roots: options.roots, limit: scanLimit, discovery: db.getDiscoveryCheckpoints() }),
961
+ new Promise<never>((_, reject) => { clock = setTimeout(() => reject(new SyncPreparationError("timed_out")), remaining); }),
962
+ ]);
963
+ discoveredCount = result.sessions.length;
964
+ scanMs = performance.now() - scanStarted;
965
+ const admissionStarted = performance.now();
966
+ db.admitDiscovery(result, item => sessionNeedsIngest(db, item, { maxRecordBytes }), now());
967
+ queueMs = performance.now() - admissionStarted;
968
+ } finally { clearTimeout(clock); }
969
+ }
970
+ const queueStarted = performance.now();
971
+ if (performance.now() - started >= workDeadline) exhaustWallBudget();
972
+
973
+ const queuedAtStart = db.getIngestQueueSummary(now().toISOString()).total;
974
+ const cutoff = new Date(now().getTime() - limits.freshWindowMs).toISOString();
975
+ const selected = exhausted.size ? { items: [], fresh: 0, backlog: 0 }
976
+ : db.getQueuedIngestLanes(effective.sessions, effective.freshSessions, cutoff, now().toISOString());
977
+ queueMs += performance.now() - queueStarted;
978
+ const readyAtStart = db.getIngestQueueSummary(now().toISOString()).eligible;
979
+ // An empty selection because an earlier boundary (wall, pressure) stopped the
980
+ // pass before admission is not the session budget's doing: with no lane
981
+ // admitted there is no admission to call limited.
982
+ const sessionBudgetLimited = selected.items.length > 0 && readyAtStart > selected.items.length;
983
+ const queue = [...selected.items];
984
+ let sessionAttemptsLimited = false;
985
+ let budgetCharge = 0;
986
+ let failedAttempts = 0;
987
+
988
+ while (queue.length > 0 && exhausted.size === 0) {
989
+ const elapsed = performance.now() - started;
990
+ if (elapsed >= workDeadline) { exhaustWallBudget(); break; }
991
+ if (workerProcesses >= effective.sessions) { exhausted.add("sessions"); break; }
992
+ if (budgetCharge >= effective.sourceBytes) { exhausted.add("source_bytes"); break; }
993
+ if (failedAttempts >= Math.max(1, limits.retries)) { exhausted.add("retries"); break; }
994
+
995
+ if (pressureMode === "auto") {
996
+ const sampled = readPressure();
997
+ finalPressure = sampled;
998
+ if (sampled.severity === "critical") {
999
+ if (pressureState.last_severity !== "critical") pressureState = adaptPressure(pressureState, sampled).state;
1000
+ pressureReducedThisRun = true;
1001
+ db.setAdaptivePressureState(pressureState, now().toISOString());
1002
+ exhausted.add("pressure");
1003
+ break;
1004
+ }
1005
+ if (sampled.severity === "constrained" && !pressureReducedThisRun) {
1006
+ pressureState = adaptPressure(pressureState, sampled).state;
1007
+ pressureReducedThisRun = true;
1008
+ db.setAdaptivePressureState(pressureState, now().toISOString());
1009
+ effective = effectiveLimits(limits, pressureState, sampled);
1010
+ }
1011
+ }
1012
+
1013
+ const remainingFailureSlots = Math.max(1, limits.retries) - failedAttempts;
1014
+ const maxWave = Math.min(effective.workers, remainingFailureSlots, queue.length, effective.sessions - workerProcesses);
1015
+ const wave: Array<{ item: IngestQueueEntry; reservation: number; job: SyncWorkerJob }> = [];
1016
+ let available = effective.sourceBytes - budgetCharge;
1017
+ while (wave.length < maxWave && queue.length > 0 && available > 0) {
1018
+ const item = queue[0];
1019
+ const remainingSlots = maxWave - wave.length;
1020
+ const fairShare = Math.max(1, Math.floor(available / remainingSlots));
1021
+ const desired = workerReservation(item, limits, fairShare);
1022
+ queue.shift();
1023
+ const reservation = desired;
1024
+ available -= reservation;
1025
+ const remainingWall = Math.max(1, Math.floor(workDeadline - elapsed));
1026
+ wave.push({
1027
+ item,
1028
+ reservation,
1029
+ job: {
1030
+ protocol_version: SYNC_PROTOCOL_VERSION,
1031
+ db_path: options.dbPath,
1032
+ item,
1033
+ max_chunk_bytes: Math.min(limits.chunkBytes, maxRecordBytes),
1034
+ max_record_bytes: maxRecordBytes,
1035
+ max_source_bytes: reservation,
1036
+ read_bytes_per_second: Math.max(1, Math.floor(effective.readBytesPerSecond / maxWave)),
1037
+ max_wall_ms: Math.min(limits.workerMs, remainingWall),
1038
+ max_attempts: limits.attemptsPerSession,
1039
+ },
1040
+ });
1041
+ }
1042
+ if (wave.length === 0) { exhausted.add("source_bytes"); break; }
1043
+ workerProcesses += wave.length;
1044
+ work.source_bytes_reserved += wave.reduce((sum, entry) => sum + entry.reservation, 0);
1045
+ const workersStarted = performance.now();
1046
+ const workerCommand = options.workerCommand ?? workerEntry();
1047
+ // The pass owns the deadline: the wave is waited for at most the wall that
1048
+ // is actually left, whatever the runners do with their own budgets.
1049
+ const wallRemaining = Math.max(0, Math.ceil(workDeadline - (performance.now() - started)));
1050
+ const waited = await awaitWorkerWave(wave.map(entry => entry.job), wallRemaining,
1051
+ (job, signal) => runWorker(job, job.max_wall_ms, workerCommand, signal));
1052
+ workersMs += performance.now() - workersStarted;
1053
+ // The deadline is the pass's boundary, not the session's: nothing is
1054
+ // failed or backed off for it, and a sibling that already reported keeps
1055
+ // its real result below.
1056
+ if (waited.expired) exhaustWallBudget();
1057
+
1058
+ for (let index = 0; index < wave.length; index++) {
1059
+ const { item, reservation, job } = wave[index];
1060
+ const reported = waited.invocations[index];
1061
+ if (reported === null) {
1062
+ // Stopped at the wall with its reads unknown: keep the reservation
1063
+ // charged and the session durably queued for the next pass.
1064
+ budgetCharge += reservation;
1065
+ workerAttempts++;
1066
+ work.unavailable_worker_reports++;
1067
+ results.timed_out = (results.timed_out ?? 0) + 1;
1068
+ continue;
1069
+ }
1070
+ let invocation = reported;
1071
+ const reply = invocation.reply;
1072
+ if (reply && (reply.work.source_bytes_read > reservation || reply.attempts > job.max_attempts)) {
1073
+ invocation = { status: "protocol_error" };
1074
+ }
1075
+ const acceptedReply = invocation.status === "ok" ? invocation.reply : undefined;
1076
+ if (acceptedReply) {
1077
+ budgetCharge += acceptedReply.work.source_bytes_read;
1078
+ addWork(work, acceptedReply.work);
1079
+ workerAttempts += acceptedReply.attempts;
1080
+ addCounts(results, acceptedReply.results);
1081
+ } else {
1082
+ budgetCharge += reservation;
1083
+ workerAttempts++;
1084
+ work.unavailable_worker_reports++;
1085
+ }
1086
+
1087
+ if (invocation.status !== "ok" || !acceptedReply || acceptedReply.outcome === "error") {
1088
+ failedAttempts++;
1089
+ retriesScheduled++;
1090
+ const stamp = now();
1091
+ const status = invocation.status === "ok" ? acceptedReply?.error_code ?? "worker_error" : invocation.status;
1092
+ db.markQueuedIngestRetry(item, status, retryAt(stamp, item.attempt_count), stamp.toISOString());
1093
+ results[status] = (results[status] ?? 0) + 1;
1094
+ continue;
1095
+ }
1096
+ if (acceptedReply.outcome === "budget_exhausted") {
1097
+ const workerBudget = acceptedReply.budget ?? "worker_budget";
1098
+ if (workerBudget === "source_bytes") {
1099
+ budgetCharge += reservation - acceptedReply.work.source_bytes_read;
1100
+ }
1101
+ // A worker whose wall budget was the pass's remaining wall reports the
1102
+ // pass's own boundary: it spent what was left, or could not finish
1103
+ // inside it. A worker budget under the wall stays the worker's own.
1104
+ if (workerBudget === "wall_time" && job.max_wall_ms < limits.workerMs) exhaustWallBudget();
1105
+ else if (workerBudget !== "source_bytes" || budgetCharge >= effective.sourceBytes) exhausted.add(workerBudget);
1106
+ db.markQueuedIngestProgress(item, `pending_${acceptedReply.budget ?? "worker_budget"}`, now().toISOString());
1107
+ results.budget_exhausted = (results.budget_exhausted ?? 0) + 1;
1108
+ continue;
1109
+ }
1110
+ if (!acceptedReply.needs_ingest_after || acceptedReply.results.tombstoned) {
1111
+ // The planner re-checks `sessionNeedsIngest`, but a listing the store
1112
+ // itself refused — the committed id of a grouped source's member file —
1113
+ // still looks ingestable to it, because the group mapping is only
1114
+ // visible after the read. Its answer is final: the session was deleted
1115
+ // by the user, so the item leaves the queue instead of returning as a
1116
+ // retry every pass. A later scan enqueues it again if the deletion is
1117
+ // ever undone, since discovery is what fills the queue.
1118
+ db.removeQueuedIngest(item);
1119
+ completed++;
1120
+ } else if (acceptedReply.stalled || !acceptedReply.cursor_changed) {
1121
+ failedAttempts++;
1122
+ retriesScheduled++;
1123
+ const stamp = now();
1124
+ db.markQueuedIngestRetry(item, "no_progress", retryAt(stamp, item.attempt_count), stamp.toISOString());
1125
+ results.no_progress = (results.no_progress ?? 0) + 1;
1126
+ } else {
1127
+ sessionAttemptsLimited ||= acceptedReply.attempts >= limits.attemptsPerSession;
1128
+ db.markQueuedIngestProgress(item, "progress", now().toISOString());
1129
+ }
1130
+ }
1131
+ // The wall is spent: the admissions stop here and the queue keeps what
1132
+ // this pass did not get to.
1133
+ if (waited.expired) break;
1134
+ }
1135
+
1136
+ const pending = db.getIngestQueueSummary(now().toISOString());
1137
+ const discovery = db.getDiscoverySummary();
1138
+ if (discovery?.pending_sources) exhausted.add("discovery");
1139
+ if (pending.total > 0 && sessionBudgetLimited) exhausted.add("sessions");
1140
+ if (pending.total > 0 && sessionAttemptsLimited) exhausted.add("session_attempts");
1141
+ if (pressureMode === "auto") {
1142
+ finalPressure = readPressure();
1143
+ if (finalPressure.severity === "critical") exhausted.add("pressure");
1144
+ }
1145
+
1146
+ const maintenanceRemaining = Math.floor(limits.wallMs - (performance.now() - started));
1147
+ if (limits.maintenanceMs > 0 && work.source_bytes_read === 0 &&
1148
+ !exhausted.has("pressure") && maintenanceRemaining > 0) {
1149
+ const timeout = Math.min(limits.maintenanceMs, maintenanceRemaining);
1150
+ const maintenanceStarted = performance.now();
1151
+ db.close();
1152
+ dbClosed = true;
1153
+ maintenance = await runMaintenance({
1154
+ protocol_version: SYNC_PROTOCOL_VERSION,
1155
+ db_path: options.dbPath,
1156
+ fts_merge_pages: limits.ftsMergePages,
1157
+ wal_autocheckpoint_pages: DEFAULT_WAL_AUTOCHECKPOINT_PAGES,
1158
+ }, timeout, options.maintenanceCommand ?? maintenanceEntry());
1159
+ maintenanceMs = performance.now() - maintenanceStarted;
1160
+ }
1161
+
1162
+ const outcome: SyncSummary["outcome"] = pending.total === 0 && !discovery?.pending_sources ? "complete"
1163
+ : exhausted.has("pressure") ? "pending_pressure"
1164
+ : wallBudgetExceeded ? "wall_budget_exceeded"
1165
+ : exhausted.size > 0 && !(exhausted.size === 1 && exhausted.has("retries")) ? "pending_budget"
1166
+ : pending.retrying > 0 || exhausted.has("retries") ? "pending_retry" : "pending";
1167
+ // Post-pass durable coverage signal (issue #37): how many sources stand
1168
+ // degraded after this pass recorded its verdicts. Maintenance closes the
1169
+ // writer handle, so re-read through a short-lived reader when needed; the
1170
+ // read-only reader never migrates, so this cannot reopen the write path.
1171
+ let coverageSignal: ReturnType<TrajectoryDb["getSourceCoverageSummary"]> = null;
1172
+ if (!dbClosed) {
1173
+ coverageSignal = db.getSourceCoverageSummary();
1174
+ } else {
1175
+ try {
1176
+ const reader = new TrajectoryReader(options.dbPath);
1177
+ try {
1178
+ coverageSignal = reader.getSourceCoverageSummary();
1179
+ } finally {
1180
+ reader.close();
1181
+ }
1182
+ } catch {
1183
+ // In-memory stores and never-materialized paths have no reader
1184
+ // surface; the signal is simply unknown for this pass.
1185
+ coverageSignal = null;
1186
+ }
1187
+ }
1188
+ return {
1189
+ type: "sync_summary",
1190
+ schema_version: 2,
1191
+ completion_scope: "known_work",
1192
+ ...(discovery ? { discovery } : {}),
1193
+ preparation_ms: preparationMs,
1194
+ outcome,
1195
+ elapsed_ms: performance.now() - started,
1196
+ timing: { scan_ms: scanMs, queue_ms: queueMs, workers_ms: workersMs, maintenance_ms: maintenanceMs, derive_ms: deriveMs },
1197
+ discovered: discoveredCount,
1198
+ queued_at_start: queuedAtStart,
1199
+ sessions_admitted: selected.items.length,
1200
+ lanes: { fresh: selected.fresh, backlog: selected.backlog },
1201
+ worker_processes: workerProcesses,
1202
+ worker_attempts: workerAttempts,
1203
+ retries_scheduled: retriesScheduled,
1204
+ completed,
1205
+ results,
1206
+ work,
1207
+ pending,
1208
+ budgets: { ...limits, exhausted: [...exhausted].sort(), effective },
1209
+ pressure: { mode: pressureMode, initial: initialPressure, final: finalPressure, state: pressureState },
1210
+ maintenance,
1211
+ ...(coverageSignal ? { coverage: coverageSignal } : {}),
1212
+ };
1213
+ } finally {
1214
+ if (!dbClosed) db.close();
1215
+ }
1216
+ } finally {
1217
+ held?.close();
1218
+ }
1219
+ }