pi-crew 0.9.55 → 0.9.57

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 (309) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +58 -0
  3. package/README.md +53 -5
  4. package/dist/index.mjs +5666 -4370
  5. package/docs/README.md +54 -0
  6. package/package.json +12 -13
  7. package/skills/real-test-pi-crew/SKILL.md +93 -3
  8. package/src/agents/discover-agents.ts +68 -10
  9. package/src/benchmark/feedback-loop.ts +1 -1
  10. package/src/config/config.ts +2 -1
  11. package/src/config/types.ts +12 -0
  12. package/src/extension/action-suggestions.ts +16 -63
  13. package/src/extension/async-notifier.ts +3 -3
  14. package/src/extension/command-completions.ts +2 -2
  15. package/src/extension/crew-cleanup.ts +3 -3
  16. package/src/extension/crew-vibes/footer.ts +4 -2
  17. package/src/extension/cross-extension-rpc.ts +10 -5
  18. package/src/extension/knowledge-injection.ts +57 -10
  19. package/src/extension/register.ts +2 -2
  20. package/src/extension/registration/artifact-cleanup.ts +41 -3
  21. package/src/extension/registration/commands.ts +4 -4
  22. package/src/extension/registration/context-builder.ts +10 -10
  23. package/src/extension/registration/crash-recovery-cache.ts +3 -3
  24. package/src/extension/registration/foreground-run-controller.ts +1 -1
  25. package/src/extension/registration/lazy-configurers.ts +2 -2
  26. package/src/extension/registration/lifecycle-handlers.ts +12 -12
  27. package/src/extension/registration/lifecycle.ts +2 -2
  28. package/src/extension/registration/observability.ts +2 -2
  29. package/src/extension/registration/registration-types.ts +3 -3
  30. package/src/extension/registration/runtime-cleanup.ts +1 -1
  31. package/src/extension/registration/subagent-helpers.ts +2 -2
  32. package/src/extension/registration/subagent-manager-setup.ts +2 -2
  33. package/src/extension/registration/subagent-tools.ts +21 -16
  34. package/src/extension/registration/team-tool.ts +109 -17
  35. package/src/extension/registration/viewers.ts +3 -4
  36. package/src/extension/registration/wire-cross-extension.ts +7 -7
  37. package/src/extension/rpc-hmac.ts +3 -2
  38. package/src/extension/run-bundle-schema.ts +13 -4
  39. package/src/extension/run-export.ts +2 -2
  40. package/src/extension/run-index.ts +2 -3
  41. package/src/extension/run-maintenance.ts +57 -1
  42. package/src/extension/team-manager-command.ts +3 -1
  43. package/src/extension/team-tool/anchor.ts +8 -2
  44. package/src/extension/team-tool/api.ts +117 -39
  45. package/src/extension/team-tool/cancel.ts +23 -8
  46. package/src/extension/team-tool/chain-executor.ts +1 -1
  47. package/src/extension/team-tool/dispatch/automate.ts +17 -2
  48. package/src/extension/team-tool/dispatch/control.ts +17 -2
  49. package/src/extension/team-tool/dispatch/manage.ts +34 -2
  50. package/src/extension/team-tool/dispatch/run.ts +17 -2
  51. package/src/extension/team-tool/dispatch/status.ts +37 -5
  52. package/src/extension/team-tool/doctor.ts +2 -2
  53. package/src/extension/team-tool/explain.ts +1 -1
  54. package/src/extension/team-tool/goal-wrap.ts +5 -5
  55. package/src/extension/team-tool/goal.ts +30 -9
  56. package/src/extension/team-tool/handle-schedule.ts +37 -19
  57. package/src/extension/team-tool/inspect.ts +21 -5
  58. package/src/extension/team-tool/lifecycle-actions.ts +53 -10
  59. package/src/extension/team-tool/parallel-dispatch.ts +14 -5
  60. package/src/extension/team-tool/param-error.ts +138 -0
  61. package/src/extension/team-tool/respond.ts +20 -6
  62. package/src/extension/team-tool/run.ts +27 -17
  63. package/src/extension/team-tool/status.ts +17 -6
  64. package/src/extension/team-tool.ts +26 -19
  65. package/src/hooks/registry.ts +1 -1
  66. package/src/i18n.ts +22 -0
  67. package/src/observability/event-bus.ts +1 -1
  68. package/src/prompt/prompt-runtime.ts +2 -2
  69. package/src/runtime/README.md +35 -0
  70. package/src/runtime/agent-control.ts +1 -1
  71. package/src/runtime/anchor-manager.ts +0 -2
  72. package/src/runtime/async-runner.ts +1 -1
  73. package/src/runtime/attention-events.ts +3 -2
  74. package/src/runtime/auto-summarize.ts +0 -2
  75. package/src/runtime/background-runner.ts +103 -30
  76. package/src/runtime/{crew-broker-child.ts → broker/crew-broker-child.ts} +2 -2
  77. package/src/runtime/{crew-broker-client.ts → broker/crew-broker-client.ts} +4 -7
  78. package/src/runtime/{crew-broker.ts → broker/crew-broker.ts} +13 -21
  79. package/src/runtime/chain-runner.ts +50 -5
  80. package/src/runtime/{child-pi-constants.ts → child-pi/child-pi-constants.ts} +1 -1
  81. package/src/runtime/{child-pi-kill.ts → child-pi/child-pi-kill.ts} +3 -3
  82. package/src/runtime/{child-pi-spawn.ts → child-pi/child-pi-spawn.ts} +28 -12
  83. package/src/runtime/{child-pi-steering.ts → child-pi/child-pi-steering.ts} +1 -1
  84. package/src/runtime/{child-pi-streams.ts → child-pi/child-pi-streams.ts} +23 -4
  85. package/src/runtime/{child-pi-transcript.ts → child-pi/child-pi-transcript.ts} +6 -6
  86. package/src/runtime/{child-pi.ts → child-pi/child-pi.ts} +47 -78
  87. package/src/runtime/compaction/compact-stages/bounded-tail.ts +84 -0
  88. package/src/runtime/{compact-stages → compaction/compact-stages}/index.ts +2 -2
  89. package/src/runtime/{compaction-summary.ts → compaction/compaction-summary.ts} +3 -3
  90. package/src/runtime/{tool-output-pruner.ts → compaction/tool-output-pruner.ts} +1 -1
  91. package/src/runtime/crew-agent-records.ts +48 -2
  92. package/src/runtime/custom-tools/irc-tool.ts +2 -2
  93. package/src/runtime/delivery-coordinator.ts +0 -3
  94. package/src/runtime/diagnostic-export.ts +5 -4
  95. package/src/runtime/foreground-control.ts +1 -1
  96. package/src/runtime/foreground-watchdog.ts +1 -1
  97. package/src/runtime/{adaptive-plan.ts → goal-workflow/adaptive-plan.ts} +8 -8
  98. package/src/runtime/{dynamic-workflow-context.ts → goal-workflow/dynamic-workflow-context.ts} +16 -16
  99. package/src/runtime/{dynamic-workflow-runner.ts → goal-workflow/dynamic-workflow-runner.ts} +10 -10
  100. package/src/runtime/{goal-achievement.ts → goal-workflow/goal-achievement.ts} +3 -3
  101. package/src/runtime/{goal-evaluator.ts → goal-workflow/goal-evaluator.ts} +8 -8
  102. package/src/runtime/{goal-loop-runner.ts → goal-workflow/goal-loop-runner.ts} +27 -25
  103. package/src/runtime/{goal-state-store.ts → goal-workflow/goal-state-store.ts} +8 -8
  104. package/src/runtime/{plan-templates.ts → goal-workflow/plan-templates.ts} +1 -1
  105. package/src/runtime/group-join.ts +3 -3
  106. package/src/runtime/handoff-manager.ts +0 -2
  107. package/src/runtime/{heartbeat-watcher.ts → heartbeat/heartbeat-watcher.ts} +7 -7
  108. package/src/runtime/iteration-hooks.ts +1 -1
  109. package/src/runtime/{live-agent-control.ts → live-session/live-agent-control.ts} +2 -2
  110. package/src/runtime/{live-agent-manager.ts → live-session/live-agent-manager.ts} +4 -4
  111. package/src/runtime/{live-session-runtime.ts → live-session/live-session-runtime.ts} +39 -41
  112. package/src/runtime/manifest-cache.ts +6 -2
  113. package/src/runtime/{model-fallback.ts → model/model-fallback.ts} +1 -1
  114. package/src/runtime/{pi-args.ts → model/pi-args.ts} +20 -5
  115. package/src/runtime/model/provider-extensions.ts +116 -0
  116. package/src/runtime/{runtime-policy.ts → model/runtime-policy.ts} +2 -2
  117. package/src/runtime/{runtime-resolver.ts → model/runtime-resolver.ts} +3 -3
  118. package/src/runtime/{runtime-warmup.ts → model/runtime-warmup.ts} +8 -8
  119. package/src/runtime/orphan-worker-registry.ts +1 -1
  120. package/src/runtime/{progress-tracker.ts → output/progress-tracker.ts} +1 -1
  121. package/src/runtime/{result-extractor.ts → output/result-extractor.ts} +4 -4
  122. package/src/runtime/{sidechain-output.ts → output/sidechain-output.ts} +2 -2
  123. package/src/runtime/{stream-preview.ts → output/stream-preview.ts} +1 -1
  124. package/src/runtime/{streaming-output.ts → output/streaming-output.ts} +2 -2
  125. package/src/runtime/{tool-progress.ts → output/tool-progress.ts} +1 -1
  126. package/src/runtime/parent-guard.ts +30 -10
  127. package/src/runtime/pipeline-runner.ts +2 -2
  128. package/src/runtime/policy-engine.ts +2 -2
  129. package/src/runtime/{cancellation.ts → process/cancellation.ts} +1 -1
  130. package/src/runtime/{post-exit-stdio-guard.ts → process/post-exit-stdio-guard.ts} +1 -1
  131. package/src/runtime/{process-lifecycle.ts → process/process-lifecycle.ts} +3 -3
  132. package/src/runtime/{checkpoint.ts → recovery/checkpoint.ts} +4 -4
  133. package/src/runtime/{crash-recovery.ts → recovery/crash-recovery.ts} +93 -21
  134. package/src/runtime/{overflow-recovery.ts → recovery/overflow-recovery.ts} +1 -1
  135. package/src/runtime/{recovery-recipes.ts → recovery/recovery-recipes.ts} +1 -1
  136. package/src/runtime/{retry-executor.ts → recovery/retry-executor.ts} +2 -2
  137. package/src/runtime/{retry-runner.ts → recovery/retry-runner.ts} +2 -8
  138. package/src/runtime/run-tracker.ts +1 -1
  139. package/src/runtime/run-worker.ts +3 -3
  140. package/src/runtime/{coalesce-tasks.ts → scheduling/coalesce-tasks.ts} +3 -3
  141. package/src/runtime/{concurrency.ts → scheduling/concurrency.ts} +9 -3
  142. package/src/runtime/{parallel-research.ts → scheduling/parallel-research.ts} +4 -2
  143. package/src/runtime/{run-coalesced-task-group.ts → scheduling/run-coalesced-task-group.ts} +95 -36
  144. package/src/runtime/{semaphore.ts → scheduling/semaphore.ts} +4 -1
  145. package/src/runtime/{task-graph-scheduler.ts → scheduling/task-graph-scheduler.ts} +1 -1
  146. package/src/runtime/session-resources.ts +1 -1
  147. package/src/runtime/settings-store.ts +1 -1
  148. package/src/runtime/single-agent-compose.ts +1 -0
  149. package/src/runtime/stale-reconciler.ts +1 -1
  150. package/src/runtime/subagent-manager.ts +1 -1
  151. package/src/runtime/supervisor-contact.ts +28 -13
  152. package/src/runtime/task-output-context.ts +7 -5
  153. package/src/runtime/task-runner/child-executor.ts +67 -31
  154. package/src/runtime/task-runner/live-executor.ts +8 -8
  155. package/src/runtime/task-runner/post-execution.ts +12 -12
  156. package/src/runtime/task-runner/pre-execution.ts +6 -6
  157. package/src/runtime/task-runner/progress.ts +1 -1
  158. package/src/runtime/task-runner/run-projection.ts +1 -1
  159. package/src/runtime/task-runner/scaffold-executor.ts +1 -1
  160. package/src/runtime/task-runner/state-helpers.ts +2 -2
  161. package/src/runtime/task-runner.ts +4 -4
  162. package/src/runtime/team-runner.ts +441 -175
  163. package/src/runtime/{completion-guard.ts → verification/completion-guard.ts} +1 -1
  164. package/src/runtime/{green-contract.ts → verification/green-contract.ts} +1 -1
  165. package/src/runtime/{post-checks.ts → verification/post-checks.ts} +4 -4
  166. package/src/runtime/{verification-gates.ts → verification/verification-gates.ts} +5 -5
  167. package/src/runtime/{verification-integrity.ts → verification/verification-integrity.ts} +1 -1
  168. package/src/schema/config-schema.ts +1 -0
  169. package/src/schema/team-tool-schema.ts +149 -185
  170. package/src/state/README.md +44 -0
  171. package/src/state/atomic-write.ts +24 -3
  172. package/src/state/{locks.ts → coordination/locks.ts} +118 -38
  173. package/src/state/{mailbox.ts → coordination/mailbox.ts} +14 -8
  174. package/src/state/{schedule.ts → coordination/schedule.ts} +3 -3
  175. package/src/state/{task-claims.ts → coordination/task-claims.ts} +1 -1
  176. package/src/state/decision-ledger.ts +1 -1
  177. package/src/state/{event-log-rotation.ts → event-log/event-log-rotation.ts} +189 -31
  178. package/src/state/{event-log.ts → event-log/event-log.ts} +77 -39
  179. package/src/state/{jsonl-writer.ts → event-log/jsonl-writer.ts} +14 -6
  180. package/src/state/hook-instinct-bridge.ts +2 -2
  181. package/src/state/{active-run-registry.ts → stores/active-run-registry.ts} +7 -7
  182. package/src/state/{artifact-store.ts → stores/artifact-store.ts} +62 -4
  183. package/src/state/{blob-store.ts → stores/blob-store.ts} +3 -3
  184. package/src/state/{health-store.ts → stores/health-store.ts} +3 -3
  185. package/src/state/{instinct-store.ts → stores/instinct-store.ts} +3 -3
  186. package/src/state/{observation-store.ts → stores/observation-store.ts} +2 -2
  187. package/src/state/{run-cache.ts → stores/run-cache.ts} +4 -4
  188. package/src/state/{run-graph.ts → stores/run-graph.ts} +4 -4
  189. package/src/state/{run-metrics.ts → stores/run-metrics.ts} +3 -3
  190. package/src/state/{state-store.ts → stores/state-store.ts} +254 -21
  191. package/src/state/types.ts +27 -3
  192. package/src/ui/component.ts +55 -0
  193. package/src/ui/crew-select-list.ts +3 -1
  194. package/src/ui/dashboard-panes/agents-pane.ts +1 -1
  195. package/src/ui/dwf-phase-display.ts +1 -1
  196. package/src/ui/heartbeat-aggregator.ts +1 -1
  197. package/src/ui/keybinding-map.ts +179 -6
  198. package/src/ui/layout-primitives.ts +9 -5
  199. package/src/ui/live-conversation-overlay.ts +1 -1
  200. package/src/ui/live-run-sidebar.ts +1 -1
  201. package/src/ui/overlay-stack.ts +148 -0
  202. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  203. package/src/ui/overlays/mailbox-compose-overlay.ts +1 -1
  204. package/src/ui/overlays/mailbox-detail-overlay.ts +2 -2
  205. package/src/ui/powerbar-publisher.ts +1 -1
  206. package/src/ui/render-scheduler.ts +1 -1
  207. package/src/ui/run-action-dispatcher.ts +3 -3
  208. package/src/ui/run-dashboard.ts +5 -33
  209. package/src/ui/run-event-bus.ts +3 -3
  210. package/src/ui/run-snapshot-cache.ts +37 -18
  211. package/src/ui/settings-overlay.ts +13 -21
  212. package/src/ui/snapshot-types.ts +1 -1
  213. package/src/ui/status-colors.ts +0 -2
  214. package/src/ui/terminal-status.ts +1 -1
  215. package/src/ui/theme-adapter.ts +33 -13
  216. package/src/ui/tool-renderers/index.ts +8 -0
  217. package/src/ui/transcript-viewer.ts +37 -13
  218. package/src/ui/widget/index.ts +32 -9
  219. package/src/ui/widget/widget-formatters.ts +62 -4
  220. package/src/ui/widget/widget-model.ts +2 -2
  221. package/src/ui/widget/widget-renderer.ts +1 -1
  222. package/src/utils/conflict-detect.ts +58 -5
  223. package/src/utils/ndjson.ts +2 -2
  224. package/src/utils/redaction.ts +39 -0
  225. package/src/utils/socket-path.ts +1 -1
  226. package/src/workflows/workflow-config.ts +1 -1
  227. package/src/worktree/cleanup.ts +1 -1
  228. package/src/worktree/worktree-manager.ts +111 -47
  229. package/workflows/chain.workflow.md +2 -2
  230. package/dist/build-meta.json +0 -16807
  231. package/index.bundle.ts +0 -25
  232. package/scripts/bench-check.mjs +0 -96
  233. package/scripts/bench-cold-start.mjs +0 -216
  234. package/scripts/build-bundle.mjs +0 -92
  235. package/scripts/check-all-skills.ts +0 -294
  236. package/scripts/check-bundle-size.mjs +0 -38
  237. package/scripts/check-bundle-staleness.mjs +0 -97
  238. package/scripts/check-conflict-markers.mjs +0 -91
  239. package/scripts/check-lazy-imports.mjs +0 -28
  240. package/scripts/dev-runner.mjs +0 -106
  241. package/scripts/profile-startup.mjs +0 -125
  242. package/scripts/release-smoke.mjs +0 -74
  243. package/scripts/run-bench.mjs +0 -47
  244. package/scripts/run-real-chain.ts +0 -41
  245. package/scripts/test-issue-29-crash.ts +0 -111
  246. package/scripts/test-issue-29-e2e.ts +0 -183
  247. package/scripts/test-issue-29-real-runtime.ts +0 -330
  248. package/scripts/test-issue-29-real-tasks.ts +0 -387
  249. package/scripts/test-issue-29-team-tool.ts +0 -105
  250. package/scripts/test-runner.mjs +0 -84
  251. package/scripts/verify-flicker-fix.ts +0 -109
  252. package/scripts/verify-skill.ts +0 -550
  253. package/scripts/watch-bundle.mjs +0 -259
  254. package/scripts/watchdog-harness.ts +0 -119
  255. package/src/adapters/claude-adapter.ts +0 -23
  256. package/src/adapters/codex-adapter.ts +0 -21
  257. package/src/adapters/cursor-adapter.ts +0 -17
  258. package/src/adapters/export-util.ts +0 -143
  259. package/src/adapters/index.ts +0 -15
  260. package/src/adapters/registry.ts +0 -18
  261. package/src/adapters/types.ts +0 -23
  262. package/src/runtime/code-summary.ts +0 -292
  263. package/src/runtime/cross-extension-rpc.ts +0 -143
  264. package/src/runtime/hidden-handoff.ts +0 -407
  265. package/src/runtime/prose-compressor.ts +0 -162
  266. package/src/subagents/async-entry.ts +0 -1
  267. package/src/subagents/index.ts +0 -3
  268. package/src/subagents/live/control.ts +0 -1
  269. package/src/subagents/live/manager.ts +0 -1
  270. package/src/subagents/live/realtime.ts +0 -1
  271. package/src/subagents/live/session-runtime.ts +0 -1
  272. package/src/subagents/manager.ts +0 -1
  273. package/src/subagents/spawn.ts +0 -1
  274. package/test-integration-check.ts +0 -118
  275. /package/src/runtime/{broker-issuer.ts → broker/broker-issuer.ts} +0 -0
  276. /package/src/runtime/{crew-broker-tokens.ts → broker/crew-broker-tokens.ts} +0 -0
  277. /package/src/runtime/{child-pi-pool.ts → child-pi/child-pi-pool.ts} +0 -0
  278. /package/src/runtime/{compact-pipeline.ts → compaction/compact-pipeline.ts} +0 -0
  279. /package/src/runtime/{compact-stages → compaction/compact-stages}/ansi-strip-stage.ts +0 -0
  280. /package/src/runtime/{compact-stages → compaction/compact-stages}/blank-collapse-stage.ts +0 -0
  281. /package/src/runtime/{compact-stages → compaction/compact-stages}/deduplicate-stage.ts +0 -0
  282. /package/src/runtime/{compact-stages → compaction/compact-stages}/head-snap-stage.ts +0 -0
  283. /package/src/runtime/{compact-stages → compaction/compact-stages}/tail-capture-stage.ts +0 -0
  284. /package/src/runtime/{compact-stages → compaction/compact-stages}/truncation-stage.ts +0 -0
  285. /package/src/runtime/{important-line-classifier.ts → compaction/important-line-classifier.ts} +0 -0
  286. /package/src/runtime/{heartbeat-gradient.ts → heartbeat/heartbeat-gradient.ts} +0 -0
  287. /package/src/runtime/{worker-heartbeat.ts → heartbeat/worker-heartbeat.ts} +0 -0
  288. /package/src/runtime/{worker-startup.ts → heartbeat/worker-startup.ts} +0 -0
  289. /package/src/runtime/{intercom-bridge.ts → live-session/intercom-bridge.ts} +0 -0
  290. /package/src/runtime/{live-control-realtime.ts → live-session/live-control-realtime.ts} +0 -0
  291. /package/src/runtime/{live-extension-bridge.ts → live-session/live-extension-bridge.ts} +0 -0
  292. /package/src/runtime/{live-irc.ts → live-session/live-irc.ts} +0 -0
  293. /package/src/runtime/{live-session-health.ts → live-session/live-session-health.ts} +0 -0
  294. /package/src/runtime/{model-resolver.ts → model/model-resolver.ts} +0 -0
  295. /package/src/runtime/{model-scope.ts → model/model-scope.ts} +0 -0
  296. /package/src/runtime/{output-validator.ts → output/output-validator.ts} +0 -0
  297. /package/src/runtime/{pi-json-output.ts → output/pi-json-output.ts} +0 -0
  298. /package/src/runtime/{progress-event-coalescer.ts → output/progress-event-coalescer.ts} +0 -0
  299. /package/src/runtime/{cancellation-token.ts → process/cancellation-token.ts} +0 -0
  300. /package/src/runtime/{zombie-scanner.ts → process/zombie-scanner.ts} +0 -0
  301. /package/src/runtime/{crash-classification.ts → recovery/crash-classification.ts} +0 -0
  302. /package/src/runtime/{batch-barrier.ts → scheduling/batch-barrier.ts} +0 -0
  303. /package/src/runtime/{global-worker-cap.ts → scheduling/global-worker-cap.ts} +0 -0
  304. /package/src/runtime/{parallel-utils.ts → scheduling/parallel-utils.ts} +0 -0
  305. /package/src/runtime/{scheduler.ts → scheduling/scheduler.ts} +0 -0
  306. /package/src/runtime/{task-graph.ts → scheduling/task-graph.ts} +0 -0
  307. /package/src/runtime/{verification-worktree.ts → verification/verification-worktree.ts} +0 -0
  308. /package/src/state/{event-reconstructor.ts → event-log/event-reconstructor.ts} +0 -0
  309. /package/src/state/{worker-atomic-writer.ts → event-log/worker-atomic-writer.ts} +0 -0
@@ -2,11 +2,11 @@ import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { randomUUID, timingSafeEqual } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
- import { DEFAULT_LOCKS } from "../config/defaults.ts";
6
- import { logInternalError } from "../utils/internal-error.ts";
7
- import { sleepSync } from "../utils/sleep.ts";
8
- import { isSymlinkSafePath } from "./atomic-write.ts";
9
- import type { TeamRunManifest } from "./types.ts";
5
+ import { DEFAULT_LOCKS } from "../../config/defaults.ts";
6
+ import { logInternalError } from "../../utils/internal-error.ts";
7
+ import { sleepSync } from "../../utils/sleep.ts";
8
+ import { isSymlinkSafePath } from "../atomic-write.ts";
9
+ import type { TeamRunManifest } from "../types.ts";
10
10
 
11
11
  export interface RunLockOptions {
12
12
  staleMs?: number;
@@ -381,15 +381,25 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
381
381
  // append, or even the lock acquisition itself) would race with the lock.
382
382
  const lockFile = `${filePath}.flock`;
383
383
  const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
384
- // FIX (Round 29): re-entrance guard — mirrors withRunLockSync below.
385
- // When the same call stack already holds the file lock (e.g.
386
- // registerWorker -> cleanupOrphanWorkers -> readRegistry), the second
387
- // acquisition would otherwise read its own freshly-written lock file
388
- // (same pid, fresh createdAt), fail the steal check, and deadlock for
389
- // the full staleMs window. Strace-confirmed in
390
- // .github/issues/pre-existing-2026-06-10/04-orphan-worker-registry-tests.md:75-86.
391
- const existingToken = fileLockHeldByUs.get(lockFile);
392
- if (existingToken) {
384
+ // ST-14: re-entrance guard is scoped to the current async context, mirroring
385
+ // the H-1 fix for withRunLockSync (lockCtx). Previously this used the
386
+ // process-global fileLockHeldByUs map, so a re-entrance decision in one
387
+ // async context could leak into another (same bug class as H-1).
388
+ // fileLockSyncCtx (AsyncLocalStorage) scopes the held set to the current
389
+ // async context: a call from a DIFFERENT async context sees no held set
390
+ // and properly serializes against the on-disk lock. True nested calls in
391
+ // the SAME async context still bypass via fileLockSyncCtx.getStore().
392
+ if (fileLockSyncCtx.getStore()?.has(lockFile)) {
393
+ return fn();
394
+ }
395
+ // Cross-tier bypass: if withFileLockAsync currently holds the .flock in
396
+ // THIS process, bypass to avoid a sleepSync deadlock (the sync retry loop
397
+ // blocks the event loop, starving the async holder). In production this
398
+ // never fires: child-process mode uses sync-only mailbox operations;
399
+ // live-session mode uses async-only. Mirrors the symmetric check in
400
+ // withFileLockAsync. fileLockHeldByUs remains process-global by design —
401
+ // it tracks actual on-disk holds, not re-entrance.
402
+ if (fileLockHeldByUs.get(lockFile)) {
393
403
  return fn();
394
404
  }
395
405
  // FIX: Validate the parent directory is not a symlink BEFORE calling mkdirSync.
@@ -422,14 +432,27 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
422
432
  }
423
433
  }
424
434
  if (token === "") throw new Error(`Run '${path.basename(lockFile)}' is locked by another operation.`);
425
- fileLockHeldByUs.set(lockFile, token);
426
- try {
427
- return fn();
428
- } finally {
429
- // Token-guarded release: don't rm the lock if it has been stolen.
430
- fileLockHeldByUs.delete(lockFile);
431
- releaseLock(lockFile, token);
432
- }
435
+ // ST-14: enter a new async context carrying the held set, mirroring
436
+ // withRunLockSync's lockCtx.run pattern. Nested same-context calls see
437
+ // the held set and bypass (re-entrant); cross-context callers do not.
438
+ const prevHeld = fileLockSyncCtx.getStore() ?? new Set<string>();
439
+ const newHeld = new Set(prevHeld);
440
+ newHeld.add(lockFile);
441
+ return fileLockSyncCtx.run(newHeld, () => {
442
+ fileLockHeldByUs.set(lockFile, token);
443
+ // ST-3-FIX: register the SYNC hold separately so withFileLockAsync's bypass
444
+ // can detect it WITHOUT seeing async's own holds (which would break
445
+ // async↔async mutual exclusion).
446
+ fileSyncLockHeldByUs.add(lockFile);
447
+ try {
448
+ return fn();
449
+ } finally {
450
+ // Token-guarded release: don't rm the lock if it has been stolen.
451
+ fileLockHeldByUs.delete(lockFile);
452
+ fileSyncLockHeldByUs.delete(lockFile);
453
+ releaseLock(lockFile, token);
454
+ }
455
+ });
433
456
  }
434
457
 
435
458
  // H-1: track re-entrant run-lock acquisitions PER ASYNC CONTEXT (not process-global).
@@ -443,16 +466,36 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
443
466
  // async context (e.g. handleResume -> executeTeamRun -> executeTeamRunCore) still
444
467
  // bypass via lockCtx.getStore(), avoiding the deadlock the bypass was designed for.
445
468
  const lockCtx = new AsyncLocalStorage<Set<string>>();
446
- // Round 29: parallel map for withFileLockSync re-entrance. See the comment
447
- // at the top of withFileLockSync for the full deadlock mechanism.
469
+ // ST-14: track re-entrant withFileLockSync acquisitions PER ASYNC CONTEXT
470
+ // (not process-global), mirroring the H-1 fix (lockCtx) for withRunLockSync.
471
+ // Previously withFileLockSync used the process-global fileLockHeldByUs map for
472
+ // re-entrance detection, so a re-entrance hit/miss decision in one async context
473
+ // could leak into another (same bug class as H-1). fileLockSyncCtx scopes the
474
+ // held set to the current async context.
475
+ const fileLockSyncCtx = new AsyncLocalStorage<Set<string>>();
476
+ // Round 29: process-global map for cross-tier sync↔async coordination. Set by
477
+ // BOTH withFileLockSync and withFileLockAsync when they hold the on-disk .flock.
478
+ // Consumed by withFileLockSync's bypass (detects when ASYNC holds the .flock, so
479
+ // the sync retry loop doesn't sleepSync-block the event loop and starve the
480
+ // async holder). NOT a re-entrance guard — re-entrance is tracked per async
481
+ // context (fileLockSyncCtx for sync, fileAsyncLockCtx for async).
448
482
  const fileLockHeldByUs = new Map<string, string>(); // lockFile -> token
483
+ // ST-3-FIX: process-global set populated ONLY by withFileLockSync when it holds
484
+ // the on-disk .flock. Consumed by withFileLockAsync's bypass to detect SYNC
485
+ // holds (so async skips sleepSync-wait when sync holds, preventing sync↔async
486
+ // deadlock). SEPARATE from fileLockHeldByUs so that a SECOND concurrent ASYNC
487
+ // caller does NOT see the FIRST async caller's hold and bypass — that broke
488
+ // async↔async mutual exclusion (the in-process promise chain + on-disk .flock
489
+ // were both skipped). fileLockHeldByUs is set by BOTH tiers; this set is
490
+ // SYNC-only.
491
+ const fileSyncLockHeldByUs = new Set<string>(); // lockFile (held by sync path)
449
492
 
450
493
  // --- Async file lock (non-blocking alternative to withFileLockSync) ---
451
- // Uses a promise-chain pattern to serialize per-path access without blocking
452
- // the Node.js event loop. Unlike withFileLockSync (which uses O_EXCL +
453
- // sleepSync for cross-process safety), this is **in-process only** —
454
- // sufficient for single-process mailbox writes (team-runner is single-process).
455
- // Mirrors the structure of withEventLogLockAsync in event-log.ts.
494
+ // Two-tier: in-process promise chain (serialize within this process) + on-disk
495
+ // .flock (serialize cross-process via O_EXCL + async retry). The on-disk tier
496
+ // was added in ST-3 to fix message loss when the sync append path
497
+ // (withFileLockSync, also .flock) and the async append path (previously
498
+ // in-process only) ran concurrently across processes on the same mailbox file.
456
499
  const fileAsyncLocks = new Map<string, Promise<unknown>>();
457
500
 
458
501
  // FIND-02 follow-up (P3): re-entrance guard for the async file lock, mirroring
@@ -465,37 +508,74 @@ const fileAsyncLocks = new Map<string, Promise<unknown>>();
465
508
  const fileAsyncLockCtx = new AsyncLocalStorage<Set<string>>();
466
509
 
467
510
  export async function withFileLockAsync<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
511
+ const lockFile = `${filePath}.flock`;
468
512
  // Re-entrant within the same async context — run fn() directly (no chaining).
469
- // Same semantics as the sync guard in withFileLockSync (fileLockHeldByUs)
470
- // and the async guard in withRunLock (lockCtx).
471
- if (fileAsyncLockCtx.getStore()?.has(filePath)) {
513
+ // Same semantics as the sync guard in withFileLockSync (fileLockSyncCtx,
514
+ // ST-14) and the async guard in withRunLock (lockCtx).
515
+ if (fileAsyncLockCtx.getStore()?.has(lockFile)) {
516
+ return await fn();
517
+ }
518
+ // Cross-tier bypass for SYNC holds: if withFileLockSync currently holds the
519
+ // .flock in this process, bypass to avoid a sleepSync deadlock (the sync
520
+ // retry loop blocks the event loop, starving our async holder). In production
521
+ // this never fires: child-process mode uses sync-only mailbox operations;
522
+ // live-session mode uses async-only. The bypass is safe for O_APPEND writes
523
+ // on POSIX (the mailbox append path).
524
+ // ST-3-FIX: consult fileSyncLockHeldByUs (SYNC-only) — NOT fileLockHeldByUs.
525
+ // Previously this checked fileLockHeldByUs, which withFileLockAsync ALSO
526
+ // populates (~line 536), so a SECOND concurrent ASYNC caller for the same
527
+ // file saw the FIRST async caller's hold and bypassed BOTH the in-process
528
+ // promise chain AND the on-disk .flock → broke async↔async mutual exclusion.
529
+ // Checking the SYNC-only set restores async↔async serialization while
530
+ // preserving sync↔async deadlock prevention.
531
+ if (fileSyncLockHeldByUs.has(lockFile)) {
472
532
  return await fn();
473
533
  }
474
534
  // Merge with the parent context's held set so nested DIFFERENT-path locks
475
535
  // also bypass correctly (prevents cross-path deadlock, matching withRunLock).
476
536
  const prevHeld = fileAsyncLockCtx.getStore() ?? new Set<string>();
477
537
  const held = new Set(prevHeld);
478
- held.add(filePath);
479
- const prev = fileAsyncLocks.get(filePath) ?? Promise.resolve();
538
+ held.add(lockFile);
539
+ const prev = fileAsyncLocks.get(lockFile) ?? Promise.resolve();
480
540
  // Chain fn after the previous holder. `next` may reject (propagating to the
481
541
  // caller), but `stored` never rejects so subsequent waiters aren't blocked.
482
542
  // fn() is wrapped in fileAsyncLockCtx.run so nested same-context calls see
483
543
  // `held` and bypass the promise chain (re-entrant), while cross-context
484
544
  // callers chain normally via `prev`.
485
- const next = prev.then(() => fileAsyncLockCtx.run(held, () => fn()));
545
+ const next = prev.then(() =>
546
+ fileAsyncLockCtx.run(held, async () => {
547
+ // ST-3: cross-process tier — acquire the on-disk .flock so that a
548
+ // concurrent process (sync append, reply-rewrite) cannot interleave.
549
+ // acquireLockWithRetryAsync uses `await sleep` (timer), NOT sleepSync,
550
+ // so the event loop is not blocked during contention.
551
+ if (!isSymlinkSafePath(path.dirname(lockFile))) throw new Error("Refusing: parent of lock directory is a symlink");
552
+ fs.mkdirSync(path.dirname(lockFile), { recursive: true });
553
+ const token = await acquireLockWithRetryAsync(lockFile, DEFAULT_STALE_MS, "file");
554
+ // Register in fileLockHeldByUs so a concurrent withFileLockSync in
555
+ // THIS process detects our hold and bypasses (prevents sleepSync
556
+ // deadlock). See the bypass check at the top of this function.
557
+ fileLockHeldByUs.set(lockFile, token);
558
+ try {
559
+ return await fn();
560
+ } finally {
561
+ fileLockHeldByUs.delete(lockFile);
562
+ releaseLock(lockFile, token);
563
+ }
564
+ }),
565
+ );
486
566
  const stored = next.then(
487
567
  () => undefined,
488
568
  () => undefined,
489
569
  );
490
- fileAsyncLocks.set(filePath, stored);
570
+ fileAsyncLocks.set(lockFile, stored);
491
571
  try {
492
572
  return await next;
493
573
  } finally {
494
574
  // Compare-and-delete: only remove our entry if it still points at `stored`.
495
575
  // With 3+ overlapping callers, an earlier caller's finally would otherwise
496
576
  // delete a later caller's promise, breaking mutual exclusion.
497
- if (fileAsyncLocks.get(filePath) === stored) {
498
- fileAsyncLocks.delete(filePath);
577
+ if (fileAsyncLocks.get(lockFile) === stored) {
578
+ fileAsyncLocks.delete(lockFile);
499
579
  }
500
580
  }
501
581
  }
@@ -1,12 +1,11 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { DEFAULT_MAILBOX } from "../config/defaults.ts";
4
- import { logInternalError } from "../utils/internal-error.ts";
5
- import { redactSecrets } from "../utils/redaction.ts";
6
- import { atomicWriteFile } from "./atomic-write.ts";
7
- import { withEventLogLockSync } from "./event-log.ts";
3
+ import { DEFAULT_MAILBOX } from "../../config/defaults.ts";
4
+ import { logInternalError } from "../../utils/internal-error.ts";
5
+ import { redactSecrets } from "../../utils/redaction.ts";
6
+ import { atomicWriteFile } from "../atomic-write.ts";
7
+ import type { TeamRunManifest } from "../types.ts";
8
8
  import { withFileLockAsync, withFileLockSync } from "./locks.ts";
9
- import type { TeamRunManifest } from "./types.ts";
10
9
 
11
10
  export type MailboxDirection = "inbox" | "outbox";
12
11
  export type MailboxMessageStatus = "queued" | "delivered" | "acknowledged";
@@ -555,11 +554,18 @@ export function appendMailboxMessage(
555
554
  repliedAt: message.repliedAt,
556
555
  replyContent: message.replyContent,
557
556
  };
558
- // H2 fix: wrap append in cross-process lock to prevent interleaving on Windows.
557
+ // ST-3: collapse to ONE lock namespace (.flock) for ALL mailbox-file
558
+ // operations — sync append, async append, and full-file rewrite. Previously
559
+ // this used withEventLogLockSync (.mkdirlock) while async append used an
560
+ // in-process promise chain (no on-disk artifact) and reply-rewrite used
561
+ // withFileLockSync (.flock). The three DISJOINT namespaces let concurrent
562
+ // operations interleave, silently losing messages (especially during the
563
+ // rotation rename↔recreate window). Now all three use withFileLockSync /
564
+ // withFileLockAsync — both backed by the same .flock sidecar.
559
565
  // B8: rotation (rename + recreate) also runs INSIDE the lock so it is serialized
560
566
  // with appends — otherwise a concurrent append between rename and recreate can
561
567
  // be truncated to an empty file.
562
- withEventLogLockSync(mailboxFile(manifest, complete.direction, complete.taskId), () => {
568
+ withFileLockSync(mailboxFile(manifest, complete.direction, complete.taskId), () => {
563
569
  fs.appendFileSync(
564
570
  mailboxFile(manifest, complete.direction, complete.taskId),
565
571
  `${JSON.stringify(redactSecrets(complete))}\n`,
@@ -9,10 +9,10 @@
9
9
  */
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
- import { logInternalError } from "../utils/internal-error.ts";
13
- import { atomicWriteJson } from "./atomic-write.ts";
12
+ import { logInternalError } from "../../utils/internal-error.ts";
13
+ import { atomicWriteJson } from "../atomic-write.ts";
14
14
 
15
- import type { ScheduledTask, ScheduleStoreData } from "./types.ts";
15
+ import type { ScheduledTask, ScheduleStoreData } from "../types.ts";
16
16
 
17
17
  export type DetectedSchedule =
18
18
  | { type: "cron"; normalized: string }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID, timingSafeEqual } from "node:crypto";
2
- import type { TeamTaskState } from "./types.ts";
2
+ import type { TeamTaskState } from "../types.ts";
3
3
 
4
4
  export interface TaskClaimState {
5
5
  owner: string;
@@ -2,7 +2,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr
2
2
  import { dirname, join } from "node:path";
3
3
  import { projectCrewRoot } from "../utils/paths.ts";
4
4
  import { assertSafePathId } from "../utils/safe-paths.ts";
5
- import { withFileLockSync } from "./locks.ts";
5
+ import { withFileLockSync } from "./coordination/locks.ts";
6
6
 
7
7
  export interface CoherenceMark {
8
8
  matchesPrior: boolean;
@@ -1,7 +1,112 @@
1
1
  import * as fs from "node:fs";
2
- import { logInternalError } from "../utils/internal-error.ts";
3
- import { atomicWriteFile } from "./atomic-write.ts";
4
- import { readEvents, type TeamEvent, withEventLogLockSync } from "./event-log.ts";
2
+ import * as path from "node:path";
3
+ import { logInternalError } from "../../utils/internal-error.ts";
4
+ import { atomicWriteFile } from "../atomic-write.ts";
5
+ import { type TeamEvent, withEventLogLockSync } from "./event-log.ts";
6
+
7
+ /**
8
+ * ST-11: Stream a JSONL file line-by-line using a bounded read buffer (8 KB
9
+ * chunks), invoking `onLine` for each non-empty line. Replaces the previous
10
+ * `readFileSync` approach that loaded the ENTIRE event log (up to 4 MB /
11
+ * 50 000 events) into a single string before splitting + JSON.parsing every
12
+ * line into an array. Memory is now bounded by the chunk size plus the
13
+ * longest single line, and parsing is interleaved with I/O so the append
14
+ * lock is held for less wall-clock time.
15
+ */
16
+ function forEachLineSync(filePath: string, onLine: (line: string) => void): void {
17
+ const fd = fs.openSync(filePath, "r");
18
+ try {
19
+ const chunkSize = 8192;
20
+ const buf = Buffer.alloc(chunkSize);
21
+ let leftover = "";
22
+ let offset = 0;
23
+ let bytesRead: number;
24
+ while ((bytesRead = fs.readSync(fd, buf, 0, chunkSize, offset)) > 0) {
25
+ offset += bytesRead;
26
+ leftover += buf.subarray(0, bytesRead).toString("utf-8");
27
+ let nl: number;
28
+ while ((nl = leftover.indexOf("\n")) >= 0) {
29
+ const line = leftover.slice(0, nl);
30
+ leftover = leftover.slice(nl + 1);
31
+ if (line.length > 0) onLine(line);
32
+ }
33
+ }
34
+ if (leftover.length > 0) {
35
+ // Trailing line without a final newline.
36
+ onLine(leftover);
37
+ }
38
+ } finally {
39
+ fs.closeSync(fd);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * ST-11: Stream the event log and keep only the last `maxKeep` valid (parseable)
45
+ * events using a fixed-size ring buffer. Bounds memory to O(maxKeep) event
46
+ * objects regardless of file size — previously the entire file was parsed
47
+ * into a single array just to `slice(-compactToCount)` it. Corrupt lines
48
+ * are skipped (matching `readEvents` behaviour). Returns the ordered
49
+ * last-N events and the total count of valid events seen.
50
+ */
51
+ function readLastEvents(filePath: string, maxKeep: number): { events: TeamEvent[]; totalCount: number } {
52
+ if (maxKeep <= 0) {
53
+ let totalCount = 0;
54
+ forEachLineSync(filePath, (line) => {
55
+ try {
56
+ JSON.parse(line);
57
+ totalCount++;
58
+ } catch {
59
+ /* skip corrupt */
60
+ }
61
+ });
62
+ return { events: [], totalCount };
63
+ }
64
+ const ring: TeamEvent[] = new Array(maxKeep);
65
+ let ringFilled = 0;
66
+ let writeIdx = 0;
67
+ let totalCount = 0;
68
+ forEachLineSync(filePath, (line) => {
69
+ let event: TeamEvent;
70
+ try {
71
+ event = JSON.parse(line) as TeamEvent;
72
+ } catch {
73
+ return; // skip corrupt — not counted (matches readEvents)
74
+ }
75
+ ring[writeIdx] = event;
76
+ writeIdx = (writeIdx + 1) % maxKeep;
77
+ if (ringFilled < maxKeep) ringFilled++;
78
+ totalCount++;
79
+ });
80
+ const events: TeamEvent[] = [];
81
+ if (ringFilled < maxKeep) {
82
+ for (let i = 0; i < ringFilled; i++) events.push(ring[i]);
83
+ } else {
84
+ for (let i = 0; i < maxKeep; i++) events.push(ring[(writeIdx + i) % maxKeep]);
85
+ }
86
+ return { events, totalCount };
87
+ }
88
+
89
+ /**
90
+ * ST-11: Stream the event log to count valid events and collect their seq
91
+ * numbers, without materialising the full event array. Used by
92
+ * `applyCompactionUnlocked` for the post-write recovery check. Memory is
93
+ * bounded to the count + seq-set of the (small, post-compaction) file.
94
+ */
95
+ function countEventsAndSeqs(filePath: string): { count: number; seqs: Set<number> } {
96
+ const seqs = new Set<number>();
97
+ let count = 0;
98
+ forEachLineSync(filePath, (line) => {
99
+ try {
100
+ const event = JSON.parse(line) as TeamEvent;
101
+ count++;
102
+ const seq = event.metadata?.seq;
103
+ if (typeof seq === "number") seqs.add(seq);
104
+ } catch {
105
+ /* skip corrupt */
106
+ }
107
+ });
108
+ return { count, seqs };
109
+ }
5
110
 
6
111
  export interface RotationConfig {
7
112
  maxFileSizeBytes: number;
@@ -80,7 +185,13 @@ export function compactEventLog(eventsPath: string, config?: Partial<RotationCon
80
185
 
81
186
  /** Round 24 (BUG 1): the lock-free pre-read for compaction. Safe to run
82
187
  * outside the lock (read-only). Returns the compacted lines + stats needed
83
- * for the write phase. */
188
+ * for the write phase.
189
+ *
190
+ * ST-11: streams the event log line-by-line through a ring buffer instead of
191
+ * `readEvents` (full readFileSync + JSON.parse of every line into a single
192
+ * array). Memory is bounded to O(compactToCount) events regardless of file
193
+ * size, and the append lock is held for less time because parsing is
194
+ * interleaved with I/O rather than deferred until the entire file is loaded. */
84
195
  export function prepareCompaction(
85
196
  eventsPath: string,
86
197
  config?: Partial<RotationConfig>,
@@ -100,10 +211,8 @@ export function prepareCompaction(
100
211
  } catch {
101
212
  return undefined;
102
213
  }
103
- const allEvents = readEvents(eventsPath);
104
- const originalCount = allEvents.length;
214
+ const { events: kept, totalCount: originalCount } = readLastEvents(eventsPath, cfg.compactToCount);
105
215
  if (originalCount <= cfg.compactToCount) return undefined;
106
- const kept = allEvents.slice(-cfg.compactToCount);
107
216
  const lines = kept.map((e) => JSON.stringify(e)).join("\n") + "\n";
108
217
  return { lines, originalSize, originalCount, kept };
109
218
  }
@@ -128,26 +237,21 @@ export function applyCompactionUnlocked(
128
237
  return undefined;
129
238
  }
130
239
  // C2: Re-read to recover any events appended during the compaction window.
131
- // Events appended during the compaction window are preserved because they
132
- // appear in afterWrite and the condition afterWrite.length >= kept.length is
133
- // true, so they are included in the return stats without entering the
134
- // recovery branch.
240
+ // ST-11: stream the post-write file instead of `readEvents` (full
241
+ // readFileSync + JSON.parse per line into an array). We only need the event
242
+ // count and the set of seq numbers for the recovery check, so memory is
243
+ // bounded without materialising every event object.
135
244
  try {
136
- const afterWrite = readEvents(eventsPath);
137
- // FIX: Check if events were actually lost (afterWrite.length < kept.length)
138
- // rather than using appendedDuringWindow >= 0 which is always true.
139
- // Also use sequence numbers for comparison instead of JSON.stringify
140
- // which is fragile due to key ordering and floating point differences.
141
- if (afterWrite.length >= kept.length) {
245
+ const { count: afterWriteCount, seqs: afterSeqs } = countEventsAndSeqs(eventsPath);
246
+ if (afterWriteCount >= kept.length) {
142
247
  return {
143
248
  originalSize,
144
249
  compactedSize: fs.statSync(eventsPath).size,
145
250
  eventsRemoved: originalCount - kept.length,
146
- eventsKept: kept.length + Math.max(0, afterWrite.length - kept.length),
251
+ eventsKept: kept.length + Math.max(0, afterWriteCount - kept.length),
147
252
  };
148
253
  }
149
- // afterWrite.length < kept.length — events were lost during compaction window.
150
- const afterSeqs = new Set(afterWrite.map((e) => e.metadata?.seq).filter((s): s is number => s !== undefined));
254
+ // afterWriteCount < kept.length — events were lost during compaction window.
151
255
  const missingEvents = kept.filter((e) => e.metadata?.seq === undefined || !afterSeqs.has(e.metadata.seq));
152
256
  let recoveredCount = 0;
153
257
  let recoveryFailed = false;
@@ -242,14 +346,40 @@ function bumpGenerationUnlocked(eventsPath: string): number {
242
346
  export function rotateEventLog(eventsPath: string): boolean {
243
347
  if (!fs.existsSync(eventsPath)) return false;
244
348
  // FIX: Wrap rotation in lock to prevent race conditions with concurrent readers.
245
- // Order of operations: (1) create new empty file, (2) rename old file to archive.
246
- // This ensures eventsPath always exists — a reader never sees a missing file.
349
+ // Order of operations: (1) rename old live file to archive, (2) create new
350
+ // empty live file. The rename is atomic (POSIX) so eventsPath is briefly
351
+ // absent between (1) and (2); readers using readEvents tolerate a missing
352
+ // file (return []). See rotateEventLogUnlocked for the ST-8 rationale.
247
353
  //
248
354
  // NOTE (Round 24 BUG 1): callers ALREADY holding the lock must call
249
355
  // rotateEventLogUnlocked directly — this locked variant is NOT re-entrant.
250
356
  return withEventLogLockSync(eventsPath, () => rotateEventLogUnlocked(eventsPath));
251
357
  }
252
358
 
359
+ /** H2 retention: delete `<eventsPath>.*.archive.jsonl` files older than the window so
360
+ * they don't accumulate forever (mirrors notification/metric-sink rotateOldFiles). */
361
+ const ARCHIVE_RETENTION_DAYS = 7;
362
+ function sweepOldArchives(eventsPath: string, now = Date.now()): void {
363
+ const dir = path.dirname(eventsPath);
364
+ const base = path.basename(eventsPath);
365
+ let entries: string[];
366
+ try {
367
+ entries = fs.readdirSync(dir);
368
+ } catch {
369
+ return;
370
+ }
371
+ const cutoff = now - ARCHIVE_RETENTION_DAYS * 24 * 60 * 60 * 1000;
372
+ for (const name of entries) {
373
+ if (!name.startsWith(`${base}.`) || !name.endsWith(".archive.jsonl")) continue;
374
+ const archivePath = path.join(dir, name);
375
+ try {
376
+ if (fs.statSync(archivePath).mtimeMs < cutoff) fs.unlinkSync(archivePath);
377
+ } catch (error) {
378
+ logInternalError("event-log.archive-sweep", error, `archivePath=${archivePath}`, "debug");
379
+ }
380
+ }
381
+ }
382
+
253
383
  /** Round 24 (BUG 1): the lock-free core of rotation. Assumes the caller
254
384
  * already holds the event-log lock (or accepts the unlocked race). */
255
385
  export function rotateEventLogUnlocked(eventsPath: string): boolean {
@@ -265,19 +395,47 @@ export function rotateEventLogUnlocked(eventsPath: string): boolean {
265
395
  archivePath = `${eventsPath}.${ts}.${collision}.archive.jsonl`;
266
396
  collision++;
267
397
  }
268
- // BUGFIX (Round 12 C1): the previous order (atomicWriteFile empty THEN
269
- // rename) destroyed ALL events — atomicWriteFile replaces the file
270
- // in place, so the rename then moved an EMPTY file to the archive.
271
- // FIX: copy current content to the archive first (archive is populated,
272
- // original still intact), then truncate the original to empty in place.
273
- // copyFileSync + writeFileSync("") ensures eventsPath ALWAYS exists
274
- // (no missing-file window for concurrent readers).
275
- fs.copyFileSync(eventsPath, archivePath);
276
- atomicWriteFile(eventsPath, "");
398
+ // ST-8: rename+create instead of copy+truncate. The old approach
399
+ // (copyFileSync + atomicWriteFile("")) had two data-loss windows:
400
+ //
401
+ // 1. Copy window: events appended between copyFileSync and the
402
+ // atomicWriteFile truncate landed on the OLD inode. After
403
+ // atomicWriteFile replaced eventsPath with a new (empty) inode,
404
+ // those appends were on an orphaned inode with no path → lost
405
+ // forever once the writer's fd closed.
406
+ // 2. atomicWriteFile replaces the inode → any in-flight writer fd
407
+ // (opened before rotation) continues writing to the old inode,
408
+ // which is now orphaned.
409
+ //
410
+ // rename+create fixes both:
411
+ // - rename(2) is atomic: the live log's inode moves to the archive
412
+ // path in one step, carrying ALL pre-rotation content. No copy
413
+ // window exists — there is nothing to lose between "read" and
414
+ // "truncate" because both happen in the single rename syscall.
415
+ // - In-flight writer fds that opened the old inode before the rename
416
+ // continue writing to it — but it is now the ARCHIVE (it has a
417
+ // path), so their appends are preserved, not orphaned. The next
418
+ // append (all write paths open per-call) naturally opens the new
419
+ // inode at eventsPath.
420
+ // - The new live file is created with O_CREAT|O_EXCL ("wx"): if a
421
+ // concurrent writer (different lock class — sync `.mkdirlock` vs
422
+ // async `.alock` are separate) already recreated the file between
423
+ // our rename and here, we leave their data intact (EEXIST → skip).
424
+ fs.renameSync(eventsPath, archivePath);
425
+ try {
426
+ const fd = fs.openSync(eventsPath, "wx", 0o644);
427
+ fs.closeSync(fd);
428
+ } catch (err) {
429
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
430
+ // A concurrent writer recreated the file — their data is preserved.
431
+ }
277
432
  // R-03: bump the generation sidecar so byte-offset cursor readers
278
433
  // detect the truncation and reset (re-read from offset 0 of the new
279
434
  // file), instead of reading past EOF and missing post-rotation events.
280
435
  bumpGenerationUnlocked(eventsPath);
436
+ // H2: retention — sweep archive files older than the window so they don't
437
+ // accumulate forever (mirrors notification/metric-sink rotateOldFiles).
438
+ sweepOldArchives(eventsPath);
281
439
  return true;
282
440
  } catch (error) {
283
441
  logInternalError("event-log.rotate", error, `eventsPath=${eventsPath}`);