pi-crew 0.9.51 → 0.9.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +216 -20
- package/dist/build-meta.json +2090 -1638
- package/dist/index.mjs +18057 -17119
- package/docs/actions-reference.md +431 -1
- package/index.ts +39 -10
- package/package.json +19 -9
- package/schema.json +41 -0
- package/scripts/check-bundle-size.mjs +38 -0
- package/scripts/test-runner.mjs +11 -1
- package/dist/index.mjs.map +0 -7
- package/docs/FEATURE_INTAKE.md +0 -126
- package/docs/HARNESS.md +0 -87
- package/docs/HARNESS_BACKLOG.md +0 -89
- package/docs/PROJECT_REVIEW.md +0 -270
- package/docs/PROJECT_REVIEW_FIXES.md +0 -342
- package/docs/PROJECT_REVIEW_ROUND4.md +0 -156
- package/docs/PROJECT_REVIEW_ROUND5.md +0 -86
- package/docs/REVIEW-FINDINGS-2026-06 +0 -357
- package/docs/REVIEW-FINDINGS-2026-07-CORE.md +0 -139
- package/docs/REVIEW-FINDINGS-2026-07.md +0 -125
- package/docs/TEST_MATRIX.md +0 -51
- package/docs/archive/README.md +0 -91
- package/docs/archive/followup-review-round3-2026-05-12.md +0 -342
- package/docs/archive/followup-review-round4-2026-05-13.md +0 -107
- package/docs/archive/pi-crew-bugs.md +0 -961
- package/docs/archive/pi-crew-test-final.md +0 -120
- package/docs/archive/pi-crew-test-results.md +0 -260
- package/docs/archive/pi-crew-test-round2.md +0 -136
- package/docs/archive/pi-crew-test-round4.md +0 -100
- package/docs/archive/pi-crew-test-round5.md +0 -70
- package/docs/archive/pi-crew-test-round6.md +0 -110
- package/docs/archive/pi-crew-v0.5.10-audit-fix-plan.md +0 -60
- package/docs/archive/pi-crew-v0.5.11-audit-fix-plan.md +0 -92
- package/docs/archive/pi-crew-v0.5.12-audit-fix-plan.md +0 -76
- package/docs/archive/pi-crew-v0.5.13-audit-fix-plan.md +0 -75
- package/docs/archive/pi-crew-v0.5.14-audit-fix-plan.md +0 -75
- package/docs/archive/pi-crew-v0.5.16-audit-fix-plan.md +0 -35
- package/docs/archive/pi-crew-v0.5.17-audit-fix-plan.md +0 -80
- package/docs/archive/pi-crew-v0.5.5-audit-fix-plan.md +0 -133
- package/docs/archive/pi-crew-v0.5.9-audit-fix-plan.md +0 -88
- package/docs/archive/pi-mono-opportunities.md +0 -969
- package/docs/archive/pi-mono-review.md +0 -291
- package/docs/archive/pi-subagent4-comparison.md +0 -261
- package/docs/archive/pi-subagents3-deep-analysis.md +0 -508
- package/docs/bugs/bug-021-notification-badge-counter-misleading.md +0 -293
- package/docs/bugs/bug-023-chain-windows-path-resolution.md +0 -106
- package/docs/bugs/bug-quota-display-truncation.md +0 -223
- package/docs/bugs/cross-session-notification-leakage.md +0 -82
- package/docs/code-review-2026-05-11.md +0 -592
- package/docs/coding-agent-optimization.md +0 -268
- package/docs/comparison-pi-subagents-vs-pi-crew.md +0 -303
- package/docs/decisions/0001-durable-state.md +0 -41
- package/docs/decisions/0002-child-process-for-async.md +0 -42
- package/docs/decisions/0003-depth-guard.md +0 -36
- package/docs/decisions/0004-execfile-over-exec.md +0 -34
- package/docs/decisions/0005-no-parameter-properties.md +0 -49
- package/docs/decisions/0006-publish-bundled-esm.md +0 -63
- package/docs/decisions/0007-active-run-binary-index.md +0 -54
- package/docs/decisions/0008-child-pi-warm-pool.md +0 -61
- package/docs/decisions/2026-07-21-broker-phase4-default-on.md +0 -77
- package/docs/decisions/2026-07-21-broker-windows-perms.md +0 -91
- package/docs/decisions/2026-07-22-broker-phase4-gated-on.md +0 -99
- package/docs/decisions/2026-07-24-oidc-trusted-publishing.md +0 -112
- package/docs/decisions/2026-07-26-c6-mascot-visibility-not-wired.md +0 -84
- package/docs/decisions/README.md +0 -26
- package/docs/deep-review-report.md +0 -384
- package/docs/distillation/cybersecurity-patterns.md +0 -294
- package/docs/feature-analysis-subagent4.md +0 -305
- package/docs/fix-plan-disabletools-exit-null.md +0 -219
- package/docs/fixes/BATCH_A_H1_H2.md +0 -86
- package/docs/fixes/bug-006-foreground-cancel-concurrent.md +0 -78
- package/docs/fixes/bug-007-async-notifier-stale-ctx.md +0 -112
- package/docs/fixes/bug-008-child-process-silent-timeout.md +0 -100
- package/docs/fixes/bug-009-executor-yield-limit-needs-attention.md +0 -75
- package/docs/fixes/bug-010-child-process-api-key-filtered.md +0 -109
- package/docs/fixes/bug-011-spawn-pi-enoent.md +0 -92
- package/docs/fixes/bug-012-essential-env-stripped.md +0 -89
- package/docs/fixes/bug-013-background-runner-death.md +0 -84
- package/docs/fixes/bug-014-infinite-retry-loop-needs-attention.md +0 -82
- package/docs/fixes/bug-015-background-runner-sigterm.md +0 -65
- package/docs/fixes/bug-017-background-runner-session-shutdown.md +0 -66
- package/docs/fixes/bug-017-background-runner-sigkill-double-fork.md +0 -28
- package/docs/fixes/bug-018-child-pi-worker-stdin-hang.md +0 -61
- package/docs/fixes/bug-019-phantom-runs-temp-workspace.md +0 -52
- package/docs/fixes/bug-020-infinite-retry-loop-needs-attention.md +0 -47
- package/docs/fixes/v0.9.10/locks-fix-verify.md +0 -3
- package/docs/fixes/v0.9.10/smoke-test.md +0 -12
- package/docs/followup-plan-2026-05-12.md +0 -463
- package/docs/followup-review-2026-05-12.md +0 -297
- package/docs/goals.md +0 -59
- package/docs/implementation-plan-top3.md +0 -333
- package/docs/issue-29-analysis.md +0 -189
- package/docs/live-mailbox-runtime.md +0 -36
- package/docs/migration/atomic-write-v2-migration.md +0 -299
- package/docs/next-upgrade-roadmap.md +0 -808
- package/docs/oh-my-pi-research.md +0 -509
- package/docs/optimization-plan-2026-07.md +0 -366
- package/docs/optimization-plan.md +0 -644
- package/docs/patterns/command-agent-skill.md +0 -71
- package/docs/perf/baseline-2026-05.md +0 -113
- package/docs/perf/final-report-2026-05.md +0 -206
- package/docs/perf/optimization-plan-2026-07-verified.md +0 -396
- package/docs/perf/performance-audit-report-2026-07.md +0 -948
- package/docs/perf/performance-review-2026-07.md +0 -287
- package/docs/perf/sprint-1-report.md +0 -71
- package/docs/perf/sprint-2-report.md +0 -81
- package/docs/perf/sprint-2.5-report.md +0 -53
- package/docs/perf/sprint-3-report.md +0 -36
- package/docs/perf/sprint-4-report.md +0 -47
- package/docs/perf/sprint-5-report.md +0 -51
- package/docs/perf/sprint-6-report.md +0 -94
- package/docs/perf/sprint-7-report.md +0 -74
- package/docs/perf/upgrade-plan-2026-05.md +0 -147
- package/docs/performance-optimization-execution-plan.md +0 -520
- package/docs/phase4-triage.md +0 -58
- package/docs/pi-crew-investigation-report.md +0 -411
- package/docs/product/README.md +0 -31
- package/docs/product/platform.md +0 -27
- package/docs/product/runtime-safety.md +0 -37
- package/docs/product/team-run.md +0 -39
- package/docs/product/team-tool.md +0 -37
- package/docs/runtime-analysis-child-vs-live.md +0 -171
- package/docs/runtime-migration-in-process-analysis.md +0 -250
- package/docs/skills/REFERENCE.md +0 -138
- package/docs/stories/README.md +0 -32
- package/docs/stories/US-DEPS-major-upgrade.md +0 -62
- package/docs/stories/backlog.md +0 -36
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +0 -1270
- package/docs/templates/decision.md +0 -27
- package/docs/templates/story.md +0 -44
- package/docs/templates/validation-report.md +0 -32
- package/docs/ui-optimization-plan.md +0 -447
- package/src/adapters/claude-adapter.ts +0 -23
- package/src/adapters/codex-adapter.ts +0 -21
- package/src/adapters/cursor-adapter.ts +0 -17
- package/src/adapters/export-util.ts +0 -143
- package/src/adapters/index.ts +0 -15
- package/src/adapters/registry.ts +0 -18
- package/src/adapters/types.ts +0 -23
- package/src/agents/agent-config.ts +0 -194
- package/src/agents/agent-search.ts +0 -98
- package/src/agents/agent-serializer.ts +0 -38
- package/src/agents/discover-agents.ts +0 -623
- package/src/benchmark/benchmark-runner.ts +0 -313
- package/src/benchmark/feedback-loop.ts +0 -73
- package/src/config/config.ts +0 -1269
- package/src/config/defaults.ts +0 -193
- package/src/config/drift-detector.ts +0 -290
- package/src/config/markers.ts +0 -330
- package/src/config/resilient-parser.ts +0 -117
- package/src/config/role-tools.ts +0 -118
- package/src/config/suggestions.ts +0 -75
- package/src/config/types.ts +0 -278
- package/src/errors.ts +0 -194
- package/src/extension/action-suggestions.ts +0 -117
- package/src/extension/async-notifier.ts +0 -179
- package/src/extension/autonomous-policy.ts +0 -209
- package/src/extension/command-completions.ts +0 -120
- package/src/extension/context-status-injection.ts +0 -185
- package/src/extension/crew-autocomplete.ts +0 -131
- package/src/extension/crew-cleanup.ts +0 -168
- package/src/extension/crew-input-router.ts +0 -93
- package/src/extension/crew-shortcuts.ts +0 -87
- package/src/extension/crew-vibes/cat-frames.ts +0 -18
- package/src/extension/crew-vibes/config.ts +0 -195
- package/src/extension/crew-vibes/figures.ts +0 -101
- package/src/extension/crew-vibes/font-detect.ts +0 -71
- package/src/extension/crew-vibes/footer.ts +0 -292
- package/src/extension/crew-vibes/index.ts +0 -432
- package/src/extension/crew-vibes/provider-usage.ts +0 -396
- package/src/extension/crew-vibes/render.ts +0 -206
- package/src/extension/crew-vibes/speed.ts +0 -286
- package/src/extension/cross-extension-rpc.ts +0 -350
- package/src/extension/help.ts +0 -62
- package/src/extension/import-index.ts +0 -74
- package/src/extension/knowledge-injection.ts +0 -402
- package/src/extension/management.ts +0 -571
- package/src/extension/message-renderers.ts +0 -108
- package/src/extension/notification-router.ts +0 -152
- package/src/extension/notification-sink.ts +0 -54
- package/src/extension/pi-api.ts +0 -56
- package/src/extension/plan-orchestrate.ts +0 -302
- package/src/extension/project-init.ts +0 -170
- package/src/extension/register.ts +0 -113
- package/src/extension/registration/artifact-cleanup.ts +0 -20
- package/src/extension/registration/command-registration.ts +0 -58
- package/src/extension/registration/command-utils.ts +0 -58
- package/src/extension/registration/commands.ts +0 -1228
- package/src/extension/registration/compaction-guard.ts +0 -333
- package/src/extension/registration/context-builder.ts +0 -135
- package/src/extension/registration/crash-recovery-cache.ts +0 -54
- package/src/extension/registration/foreground-run-controller.ts +0 -238
- package/src/extension/registration/hook-registration.ts +0 -119
- package/src/extension/registration/lazy-configurers.ts +0 -124
- package/src/extension/registration/lifecycle-handlers.ts +0 -923
- package/src/extension/registration/lifecycle.ts +0 -259
- package/src/extension/registration/observability.ts +0 -322
- package/src/extension/registration/registration-types.ts +0 -158
- package/src/extension/registration/runtime-cleanup.ts +0 -230
- package/src/extension/registration/subagent-helpers.ts +0 -125
- package/src/extension/registration/subagent-manager-setup.ts +0 -330
- package/src/extension/registration/subagent-tools.ts +0 -456
- package/src/extension/registration/team-tool.ts +0 -220
- package/src/extension/registration/tool-registration.ts +0 -50
- package/src/extension/registration/ui.ts +0 -172
- package/src/extension/registration/viewers.ts +0 -121
- package/src/extension/registration/wire-cross-extension.ts +0 -37
- package/src/extension/result-watcher.ts +0 -139
- package/src/extension/rpc-hmac.ts +0 -256
- package/src/extension/run-bundle-schema.ts +0 -104
- package/src/extension/run-export.ts +0 -97
- package/src/extension/run-import.ts +0 -168
- package/src/extension/run-index.ts +0 -131
- package/src/extension/run-maintenance.ts +0 -190
- package/src/extension/session-summary.ts +0 -18
- package/src/extension/team-manager-command.ts +0 -153
- package/src/extension/team-onboard.ts +0 -177
- package/src/extension/team-recommendation.ts +0 -288
- package/src/extension/team-tool/anchor.ts +0 -172
- package/src/extension/team-tool/api.ts +0 -1244
- package/src/extension/team-tool/auto-summarize.ts +0 -142
- package/src/extension/team-tool/cache-control.ts +0 -19
- package/src/extension/team-tool/cancel.ts +0 -337
- package/src/extension/team-tool/chain-dispatch.ts +0 -95
- package/src/extension/team-tool/chain-executor.ts +0 -379
- package/src/extension/team-tool/config-patch.ts +0 -50
- package/src/extension/team-tool/context.ts +0 -149
- package/src/extension/team-tool/destructive-gate.ts +0 -52
- package/src/extension/team-tool/doctor.ts +0 -431
- package/src/extension/team-tool/explain.ts +0 -282
- package/src/extension/team-tool/failure-patterns.ts +0 -118
- package/src/extension/team-tool/goal-wrap.ts +0 -330
- package/src/extension/team-tool/goal.ts +0 -556
- package/src/extension/team-tool/handle-schedule.ts +0 -332
- package/src/extension/team-tool/handle-settings.ts +0 -520
- package/src/extension/team-tool/health-monitor.ts +0 -481
- package/src/extension/team-tool/inspect.ts +0 -88
- package/src/extension/team-tool/intent-policy.ts +0 -45
- package/src/extension/team-tool/lifecycle-actions.ts +0 -615
- package/src/extension/team-tool/orchestrate.ts +0 -98
- package/src/extension/team-tool/parallel-dispatch.ts +0 -183
- package/src/extension/team-tool/plan.ts +0 -50
- package/src/extension/team-tool/respond.ts +0 -150
- package/src/extension/team-tool/run-not-found.ts +0 -49
- package/src/extension/team-tool/run.ts +0 -1035
- package/src/extension/team-tool/status.ts +0 -260
- package/src/extension/team-tool/workflow-manage.ts +0 -261
- package/src/extension/team-tool-types.ts +0 -26
- package/src/extension/team-tool.ts +0 -1116
- package/src/extension/tool-result.ts +0 -16
- package/src/extension/validate-resources.ts +0 -108
- package/src/hooks/registry.ts +0 -200
- package/src/hooks/types.ts +0 -69
- package/src/i18n.ts +0 -212
- package/src/observability/correlation.ts +0 -50
- package/src/observability/event-bus.ts +0 -86
- package/src/observability/event-to-metric.ts +0 -170
- package/src/observability/exporters/adapter.ts +0 -27
- package/src/observability/exporters/otlp-exporter.ts +0 -356
- package/src/observability/exporters/prometheus-exporter.ts +0 -54
- package/src/observability/metric-registry.ts +0 -100
- package/src/observability/metric-retention.ts +0 -64
- package/src/observability/metric-sink.ts +0 -99
- package/src/observability/metrics-primitives.ts +0 -219
- package/src/plugins/plugin-define.ts +0 -6
- package/src/plugins/plugin-registry.ts +0 -32
- package/src/plugins/plugins/index.ts +0 -3
- package/src/plugins/plugins/nextjs.ts +0 -19
- package/src/plugins/plugins/vite.ts +0 -10
- package/src/plugins/plugins/vitest.ts +0 -9
- package/src/prompt/prompt-runtime.ts +0 -380
- package/src/runtime/adaptive-plan.ts +0 -563
- package/src/runtime/agent-control.ts +0 -215
- package/src/runtime/agent-memory.ts +0 -81
- package/src/runtime/agent-observability.ts +0 -122
- package/src/runtime/anchor-manager.ts +0 -475
- package/src/runtime/async-marker.ts +0 -32
- package/src/runtime/async-runner.ts +0 -381
- package/src/runtime/attention-events.ts +0 -28
- package/src/runtime/auto-summarize.ts +0 -344
- package/src/runtime/background-runner.ts +0 -837
- package/src/runtime/batch-barrier.ts +0 -147
- package/src/runtime/broker-issuer.ts +0 -37
- package/src/runtime/cancellation-token.ts +0 -99
- package/src/runtime/cancellation.ts +0 -99
- package/src/runtime/capability-inventory.ts +0 -137
- package/src/runtime/chain-parser.ts +0 -237
- package/src/runtime/chain-runner.ts +0 -606
- package/src/runtime/checkpoint.ts +0 -291
- package/src/runtime/child-pi-constants.ts +0 -42
- package/src/runtime/child-pi-kill.ts +0 -180
- package/src/runtime/child-pi-pool.ts +0 -68
- package/src/runtime/child-pi-spawn.ts +0 -287
- package/src/runtime/child-pi-steering.ts +0 -128
- package/src/runtime/child-pi-streams.ts +0 -296
- package/src/runtime/child-pi-transcript.ts +0 -169
- package/src/runtime/child-pi.ts +0 -1105
- package/src/runtime/coalesce-tasks.ts +0 -268
- package/src/runtime/code-summary.ts +0 -292
- package/src/runtime/command-trace.ts +0 -105
- package/src/runtime/compact-pipeline.ts +0 -56
- package/src/runtime/compact-stages/ansi-strip-stage.ts +0 -25
- package/src/runtime/compact-stages/blank-collapse-stage.ts +0 -31
- package/src/runtime/compact-stages/deduplicate-stage.ts +0 -34
- package/src/runtime/compact-stages/head-snap-stage.ts +0 -57
- package/src/runtime/compact-stages/index.ts +0 -23
- package/src/runtime/compact-stages/tail-capture-stage.ts +0 -77
- package/src/runtime/compact-stages/truncation-stage.ts +0 -74
- package/src/runtime/compaction-summary.ts +0 -278
- package/src/runtime/completion-guard.ts +0 -207
- package/src/runtime/concurrency.ts +0 -58
- package/src/runtime/crash-classification.ts +0 -229
- package/src/runtime/crash-recovery.ts +0 -577
- package/src/runtime/crew-agent-records.ts +0 -593
- package/src/runtime/crew-agent-runtime.ts +0 -62
- package/src/runtime/crew-broker-child.ts +0 -88
- package/src/runtime/crew-broker-client.ts +0 -673
- package/src/runtime/crew-broker-tokens.ts +0 -84
- package/src/runtime/crew-broker.ts +0 -1276
- package/src/runtime/crew-hooks.ts +0 -227
- package/src/runtime/cross-extension-rpc.ts +0 -143
- package/src/runtime/custom-tools/irc-tool.ts +0 -282
- package/src/runtime/custom-tools/submit-result-tool.ts +0 -102
- package/src/runtime/deadletter.ts +0 -47
- package/src/runtime/delivery-coordinator.ts +0 -211
- package/src/runtime/delta-conflict.ts +0 -348
- package/src/runtime/deterministic-ast.ts +0 -161
- package/src/runtime/diagnostic-export.ts +0 -171
- package/src/runtime/direct-run.ts +0 -45
- package/src/runtime/dwf-state-store.ts +0 -102
- package/src/runtime/dynamic-workflow-context.ts +0 -1016
- package/src/runtime/dynamic-workflow-runner.ts +0 -310
- package/src/runtime/effectiveness.ts +0 -90
- package/src/runtime/errors/crew-errors.ts +0 -162
- package/src/runtime/event-stream-bridge.ts +0 -98
- package/src/runtime/foreground-control.ts +0 -193
- package/src/runtime/foreground-watchdog.ts +0 -132
- package/src/runtime/global-worker-cap.ts +0 -96
- package/src/runtime/goal-achievement.ts +0 -148
- package/src/runtime/goal-evaluator.ts +0 -361
- package/src/runtime/goal-loop-runner.ts +0 -828
- package/src/runtime/goal-state-store.ts +0 -219
- package/src/runtime/green-contract.ts +0 -55
- package/src/runtime/group-join.ts +0 -267
- package/src/runtime/handoff-manager.ts +0 -598
- package/src/runtime/heartbeat-gradient.ts +0 -36
- package/src/runtime/heartbeat-watcher.ts +0 -239
- package/src/runtime/hidden-handoff.ts +0 -407
- package/src/runtime/important-line-classifier.ts +0 -140
- package/src/runtime/intercom-bridge.ts +0 -187
- package/src/runtime/iteration-hooks.ts +0 -305
- package/src/runtime/live-agent-control.ts +0 -136
- package/src/runtime/live-agent-manager.ts +0 -704
- package/src/runtime/live-control-realtime.ts +0 -56
- package/src/runtime/live-extension-bridge.ts +0 -148
- package/src/runtime/live-irc.ts +0 -97
- package/src/runtime/live-session-health.ts +0 -108
- package/src/runtime/live-session-runtime.ts +0 -1139
- package/src/runtime/loop-gates.ts +0 -128
- package/src/runtime/manifest-cache.ts +0 -375
- package/src/runtime/mcp-proxy.ts +0 -105
- package/src/runtime/metric-parser.ts +0 -36
- package/src/runtime/model-fallback.ts +0 -409
- package/src/runtime/model-resolver.ts +0 -126
- package/src/runtime/model-scope.ts +0 -158
- package/src/runtime/orphan-worker-registry.ts +0 -465
- package/src/runtime/output-validator.ts +0 -202
- package/src/runtime/overflow-recovery.ts +0 -206
- package/src/runtime/parallel-research.ts +0 -68
- package/src/runtime/parallel-utils.ts +0 -160
- package/src/runtime/parent-guard.ts +0 -134
- package/src/runtime/path-overlap.ts +0 -150
- package/src/runtime/peer-dep.ts +0 -292
- package/src/runtime/per-write-validator.ts +0 -181
- package/src/runtime/phase-progress.ts +0 -217
- package/src/runtime/phase-tracker.ts +0 -385
- package/src/runtime/pi-args.ts +0 -655
- package/src/runtime/pi-json-output.ts +0 -170
- package/src/runtime/pi-spawn.ts +0 -273
- package/src/runtime/pipeline-runner.ts +0 -523
- package/src/runtime/plan-templates.ts +0 -201
- package/src/runtime/policy-engine.ts +0 -115
- package/src/runtime/post-checks.ts +0 -142
- package/src/runtime/post-exit-stdio-guard.ts +0 -104
- package/src/runtime/process-lifecycle.ts +0 -491
- package/src/runtime/process-status.ts +0 -154
- package/src/runtime/progress-event-coalescer.ts +0 -44
- package/src/runtime/progress-tracker.ts +0 -124
- package/src/runtime/prose-compressor.ts +0 -162
- package/src/runtime/recovery-recipes.ts +0 -203
- package/src/runtime/replace.ts +0 -570
- package/src/runtime/resilient-edit.ts +0 -153
- package/src/runtime/result-extractor.ts +0 -213
- package/src/runtime/retry-executor.ts +0 -100
- package/src/runtime/retry-runner.ts +0 -336
- package/src/runtime/role-permission.ts +0 -59
- package/src/runtime/run-coalesced-task-group.ts +0 -308
- package/src/runtime/run-drift.ts +0 -219
- package/src/runtime/run-tracker.ts +0 -116
- package/src/runtime/runtime-policy.ts +0 -29
- package/src/runtime/runtime-resolver.ts +0 -172
- package/src/runtime/runtime-warmup.ts +0 -160
- package/src/runtime/scheduler.ts +0 -353
- package/src/runtime/semaphore.ts +0 -141
- package/src/runtime/sensitive-paths.ts +0 -93
- package/src/runtime/session-resources.ts +0 -25
- package/src/runtime/session-snapshot.ts +0 -59
- package/src/runtime/session-usage.ts +0 -79
- package/src/runtime/settings-store.ts +0 -155
- package/src/runtime/sidechain-output.ts +0 -35
- package/src/runtime/single-agent-compose.ts +0 -84
- package/src/runtime/skill-effectiveness.ts +0 -524
- package/src/runtime/skill-instructions.ts +0 -391
- package/src/runtime/stale-reconciler.ts +0 -680
- package/src/runtime/stream-preview.ts +0 -184
- package/src/runtime/streaming-output.ts +0 -50
- package/src/runtime/subagent-manager.ts +0 -561
- package/src/runtime/subprocess-tool-registry.ts +0 -70
- package/src/runtime/supervisor-contact.ts +0 -64
- package/src/runtime/task-display.ts +0 -52
- package/src/runtime/task-graph-scheduler.ts +0 -227
- package/src/runtime/task-graph.ts +0 -290
- package/src/runtime/task-health.ts +0 -83
- package/src/runtime/task-id.ts +0 -161
- package/src/runtime/task-output-context.ts +0 -602
- package/src/runtime/task-packet.ts +0 -268
- package/src/runtime/task-quality.ts +0 -199
- package/src/runtime/task-runner/capabilities.ts +0 -78
- package/src/runtime/task-runner/context-retrieval.ts +0 -163
- package/src/runtime/task-runner/live-executor.ts +0 -227
- package/src/runtime/task-runner/output-splitter.ts +0 -152
- package/src/runtime/task-runner/progress.ts +0 -157
- package/src/runtime/task-runner/prompt-builder.ts +0 -278
- package/src/runtime/task-runner/prompt-pipeline.ts +0 -88
- package/src/runtime/task-runner/result-utils.ts +0 -16
- package/src/runtime/task-runner/retrieval-orchestrator.ts +0 -310
- package/src/runtime/task-runner/run-projection.ts +0 -125
- package/src/runtime/task-runner/state-helpers.ts +0 -176
- package/src/runtime/task-runner/tail-read.ts +0 -34
- package/src/runtime/task-runner.ts +0 -1420
- package/src/runtime/team-runner-artifacts.ts +0 -13
- package/src/runtime/team-runner.ts +0 -1993
- package/src/runtime/tool-output-pruner.ts +0 -333
- package/src/runtime/tool-progress.ts +0 -278
- package/src/runtime/usage-tracker.ts +0 -73
- package/src/runtime/verification-gates.ts +0 -571
- package/src/runtime/verification-integrity.ts +0 -105
- package/src/runtime/verification-worktree.ts +0 -186
- package/src/runtime/worker-heartbeat.ts +0 -25
- package/src/runtime/worker-startup.ts +0 -83
- package/src/runtime/workflow-state.ts +0 -195
- package/src/runtime/workspace-lock.ts +0 -443
- package/src/runtime/workspace-tree.ts +0 -312
- package/src/runtime/yield-handler.ts +0 -224
- package/src/runtime/zombie-scanner.ts +0 -298
- package/src/schema/config-schema.ts +0 -327
- package/src/schema/team-tool-schema.ts +0 -415
- package/src/schema/validation-types.ts +0 -159
- package/src/skills/discover-skills.ts +0 -197
- package/src/skills/skill-templates.ts +0 -456
- package/src/skills/validate.ts +0 -285
- package/src/state/active-run-registry.ts +0 -439
- package/src/state/artifact-store.ts +0 -176
- package/src/state/atomic-write.ts +0 -903
- package/src/state/blob-store.ts +0 -308
- package/src/state/contracts.ts +0 -158
- package/src/state/crew-init.ts +0 -269
- package/src/state/decision-ledger.ts +0 -345
- package/src/state/event-log-rotation.ts +0 -342
- package/src/state/event-log.ts +0 -1311
- package/src/state/event-reconstructor.ts +0 -229
- package/src/state/gitignore-manager.ts +0 -46
- package/src/state/health-store.ts +0 -79
- package/src/state/hook-instinct-bridge.ts +0 -94
- package/src/state/hook-integrations.ts +0 -51
- package/src/state/instinct-store.ts +0 -275
- package/src/state/jsonl-writer.ts +0 -107
- package/src/state/locks.ts +0 -562
- package/src/state/mailbox.ts +0 -916
- package/src/state/observation-store.ts +0 -176
- package/src/state/run-cache.ts +0 -193
- package/src/state/run-graph.ts +0 -183
- package/src/state/run-metrics.ts +0 -165
- package/src/state/schedule.ts +0 -166
- package/src/state/session-state-map.ts +0 -51
- package/src/state/state-store.ts +0 -958
- package/src/state/task-claims.ts +0 -61
- package/src/state/tiered-eval.ts +0 -480
- package/src/state/types-eval.ts +0 -58
- package/src/state/types.ts +0 -440
- package/src/state/usage.ts +0 -138
- package/src/state/worker-atomic-writer.ts +0 -194
- package/src/subagents/async-entry.ts +0 -1
- package/src/subagents/index.ts +0 -3
- package/src/subagents/live/control.ts +0 -1
- package/src/subagents/live/manager.ts +0 -1
- package/src/subagents/live/realtime.ts +0 -1
- package/src/subagents/live/session-runtime.ts +0 -1
- package/src/subagents/manager.ts +0 -1
- package/src/subagents/spawn.ts +0 -1
- package/src/teams/discover-teams.ts +0 -187
- package/src/teams/team-config.ts +0 -27
- package/src/teams/team-serializer.ts +0 -38
- package/src/tools/safe-bash-extension.ts +0 -54
- package/src/tools/safe-bash.ts +0 -505
- package/src/types/diff.d.ts +0 -18
- package/src/types/new-api-types.ts +0 -35
- package/src/ui/agent-management-overlay.ts +0 -160
- package/src/ui/card-colors.ts +0 -139
- package/src/ui/crew-footer.ts +0 -102
- package/src/ui/crew-select-list.ts +0 -111
- package/src/ui/dashboard-panes/agents-pane.ts +0 -164
- package/src/ui/dashboard-panes/cancellation-pane.ts +0 -66
- package/src/ui/dashboard-panes/capability-pane.ts +0 -77
- package/src/ui/dashboard-panes/health-pane.ts +0 -31
- package/src/ui/dashboard-panes/mailbox-pane.ts +0 -35
- package/src/ui/dashboard-panes/metrics-pane.ts +0 -37
- package/src/ui/dashboard-panes/progress-pane.ts +0 -38
- package/src/ui/dashboard-panes/transcript-pane.ts +0 -10
- package/src/ui/deploy-bundled-themes.ts +0 -71
- package/src/ui/dwf-phase-display.ts +0 -151
- package/src/ui/dynamic-border.ts +0 -35
- package/src/ui/heartbeat-aggregator.ts +0 -74
- package/src/ui/key-utils.ts +0 -42
- package/src/ui/keybinding-map.ts +0 -248
- package/src/ui/layout-primitives.ts +0 -106
- package/src/ui/live-conversation-overlay.ts +0 -183
- package/src/ui/live-duration.ts +0 -55
- package/src/ui/live-run-sidebar.ts +0 -293
- package/src/ui/loaders.ts +0 -6
- package/src/ui/mascot.ts +0 -449
- package/src/ui/overlays/agent-picker-overlay.ts +0 -65
- package/src/ui/overlays/confirm-overlay.ts +0 -60
- package/src/ui/overlays/help-overlay.ts +0 -177
- package/src/ui/overlays/mailbox-compose-overlay.ts +0 -181
- package/src/ui/overlays/mailbox-compose-preview.ts +0 -78
- package/src/ui/overlays/mailbox-detail-overlay.ts +0 -154
- package/src/ui/pi-ui-compat.ts +0 -68
- package/src/ui/powerbar-publisher.ts +0 -459
- package/src/ui/render-coalescer.ts +0 -101
- package/src/ui/render-diff.ts +0 -160
- package/src/ui/render-scheduler.ts +0 -278
- package/src/ui/run-action-dispatcher.ts +0 -182
- package/src/ui/run-dashboard.ts +0 -904
- package/src/ui/run-event-bus.ts +0 -381
- package/src/ui/run-snapshot-cache.ts +0 -1057
- package/src/ui/settings-overlay.ts +0 -1067
- package/src/ui/shared-overlay-scheduler.ts +0 -96
- package/src/ui/snapshot-types.ts +0 -90
- package/src/ui/spinner.ts +0 -17
- package/src/ui/status-colors.ts +0 -143
- package/src/ui/syntax-highlight.ts +0 -136
- package/src/ui/terminal-status.ts +0 -269
- package/src/ui/theme-adapter.ts +0 -271
- package/src/ui/theme-discovery.ts +0 -199
- package/src/ui/tool-progress-formatter.ts +0 -93
- package/src/ui/tool-render.ts +0 -453
- package/src/ui/tool-renderers/brief-mode.ts +0 -225
- package/src/ui/tool-renderers/index.ts +0 -726
- package/src/ui/transcript-cache.ts +0 -128
- package/src/ui/transcript-entries.ts +0 -256
- package/src/ui/transcript-viewer.ts +0 -424
- package/src/ui/widget/index.ts +0 -391
- package/src/ui/widget/widget-formatters.ts +0 -182
- package/src/ui/widget/widget-model.ts +0 -110
- package/src/ui/widget/widget-renderer.ts +0 -182
- package/src/ui/widget/widget-types.ts +0 -36
- package/src/utils/bm25-search.ts +0 -235
- package/src/utils/completion-dedupe.ts +0 -63
- package/src/utils/conflict-detect.ts +0 -668
- package/src/utils/env-allowlist.ts +0 -30
- package/src/utils/env-filter.ts +0 -155
- package/src/utils/file-coalescer.ts +0 -98
- package/src/utils/fingerprint.ts +0 -180
- package/src/utils/frontmatter.ts +0 -70
- package/src/utils/fs-watch.ts +0 -37
- package/src/utils/gh-protocol.ts +0 -556
- package/src/utils/git.ts +0 -260
- package/src/utils/guards.ts +0 -107
- package/src/utils/ids.ts +0 -24
- package/src/utils/incremental-reader.ts +0 -220
- package/src/utils/internal-error.ts +0 -10
- package/src/utils/names.ts +0 -36
- package/src/utils/ndjson.ts +0 -115
- package/src/utils/paths.ts +0 -227
- package/src/utils/project-detector.ts +0 -160
- package/src/utils/redaction.ts +0 -347
- package/src/utils/resolve-shell.ts +0 -36
- package/src/utils/run-watcher-registry.ts +0 -152
- package/src/utils/safe-paths.ts +0 -383
- package/src/utils/scan-cache.ts +0 -144
- package/src/utils/session-utils.ts +0 -110
- package/src/utils/sleep.ts +0 -54
- package/src/utils/socket-path.ts +0 -127
- package/src/utils/sse-parser.ts +0 -129
- package/src/utils/task-name-generator.ts +0 -337
- package/src/utils/timings.ts +0 -33
- package/src/utils/token-counter.ts +0 -200
- package/src/utils/visual.ts +0 -207
- package/src/workflows/cost-estimator.ts +0 -34
- package/src/workflows/discover-workflows.ts +0 -290
- package/src/workflows/intermediate-store.ts +0 -166
- package/src/workflows/preflight-validator.ts +0 -168
- package/src/workflows/topology-analyzer.ts +0 -203
- package/src/workflows/validate-workflow.ts +0 -50
- package/src/workflows/workflow-config.ts +0 -70
- package/src/workflows/workflow-serializer.ts +0 -32
- package/src/worktree/branch-freshness.ts +0 -112
- package/src/worktree/cleanup.ts +0 -341
- package/src/worktree/worktree-manager.ts +0 -1200
package/src/state/event-log.ts
DELETED
|
@@ -1,1311 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import * as fs from "node:fs";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import { DEFAULT_EVENT_LOG } from "../config/defaults.ts";
|
|
5
|
-
import { errors } from "../errors.ts";
|
|
6
|
-
import { emitFromTeamEvent } from "../ui/run-event-bus.ts";
|
|
7
|
-
import { type IncrementalReadState, readJsonlSince, readJsonlTail } from "../utils/incremental-reader.ts";
|
|
8
|
-
import { logInternalError } from "../utils/internal-error.ts";
|
|
9
|
-
import { redactSecrets } from "../utils/redaction.ts";
|
|
10
|
-
import { sleepSync } from "../utils/sleep.ts";
|
|
11
|
-
import { atomicWriteFile } from "./atomic-write.ts";
|
|
12
|
-
import {
|
|
13
|
-
applyCompactionUnlocked,
|
|
14
|
-
compactEventLog,
|
|
15
|
-
needsRotation,
|
|
16
|
-
prepareCompaction,
|
|
17
|
-
rotateEventLog,
|
|
18
|
-
rotateEventLogUnlocked,
|
|
19
|
-
} from "./event-log-rotation.ts";
|
|
20
|
-
import { appendFileViaWorker, isWorkerAtomicWriterEnabled } from "./worker-atomic-writer.ts";
|
|
21
|
-
|
|
22
|
-
export type TeamEventProvenance = "live_worker" | "test" | "healthcheck" | "replay" | "api" | "background" | "team_runner";
|
|
23
|
-
export type TeamWatcherAction = "act" | "observe" | "ignore";
|
|
24
|
-
|
|
25
|
-
export interface TeamEventSessionIdentity {
|
|
26
|
-
title: string;
|
|
27
|
-
workspace: string;
|
|
28
|
-
purpose: string;
|
|
29
|
-
placeholderReason?: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface TeamEventOwnership {
|
|
33
|
-
owner: string;
|
|
34
|
-
workflowScope: string;
|
|
35
|
-
watcherAction: TeamWatcherAction;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface TeamEventMetadata {
|
|
39
|
-
seq: number;
|
|
40
|
-
provenance: TeamEventProvenance;
|
|
41
|
-
parentEventId?: string;
|
|
42
|
-
attemptId?: string;
|
|
43
|
-
branchId?: string;
|
|
44
|
-
causationId?: string;
|
|
45
|
-
correlationId?: string;
|
|
46
|
-
sessionIdentity?: TeamEventSessionIdentity;
|
|
47
|
-
ownership?: TeamEventOwnership;
|
|
48
|
-
nudgeId?: string;
|
|
49
|
-
appended?: boolean;
|
|
50
|
-
fingerprint?: string;
|
|
51
|
-
confidence?: "low" | "medium" | "high";
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface TeamEvent {
|
|
55
|
-
time: string;
|
|
56
|
-
type: string;
|
|
57
|
-
runId: string;
|
|
58
|
-
taskId?: string;
|
|
59
|
-
message?: string;
|
|
60
|
-
data?: Record<string, unknown>;
|
|
61
|
-
metadata?: TeamEventMetadata;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export type AppendTeamEvent = Omit<TeamEvent, "time" | "metadata"> & {
|
|
65
|
-
metadata?: Partial<TeamEventMetadata>;
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const TERMINAL_EVENT_TYPES = new Set<string>(DEFAULT_EVENT_LOG.terminalEventTypes);
|
|
69
|
-
const MAX_EVENTS_BYTES = 50 * 1024 * 1024;
|
|
70
|
-
|
|
71
|
-
const sequenceCache = new Map<string, { size: number; mtimeMs: number; seq: number; lastAccessMs: number }>();
|
|
72
|
-
const MAX_SEQUENCE_CACHE_ENTRIES = 256;
|
|
73
|
-
let appendCounter = 0;
|
|
74
|
-
let overflowCounter = 0;
|
|
75
|
-
|
|
76
|
-
/** Simple cross-process lock for an eventsPath to prevent JSONL interleave on concurrent append.
|
|
77
|
-
* Detects stale locks by checking the owner PID written inside the lock directory.
|
|
78
|
-
*
|
|
79
|
-
* @deprecated Prefer `appendEventAsync()` for callers in async contexts. The sync lock
|
|
80
|
-
* uses `sleepSync` which blocks the event loop and prevents AbortSignal handlers from firing.
|
|
81
|
-
*
|
|
82
|
-
* SECURITY WARNING: This function uses `sleepSync` in its lock-acquire retry loop, which
|
|
83
|
-
* blocks the Node.js event loop for up to 5s. During that time, AbortSignal handlers
|
|
84
|
-
* cannot fire, SIGTERM handlers are delayed, and the process appears unresponsive to
|
|
85
|
-
* orchestrator health checks. Known callers include `appendEvent` (sync path),
|
|
86
|
-
* `flushOneEventLogBuffer`, and `state/mailbox.ts`. Prefer the async alternative
|
|
87
|
-
* (`appendEventAsync`) for all new code.
|
|
88
|
-
*/
|
|
89
|
-
export function withEventLogLockSync<T>(eventsPath: string, fn: () => T, options?: { timeoutMs?: number; staleMs?: number }): T {
|
|
90
|
-
// Ensure parent directory exists before attempting lock
|
|
91
|
-
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
|
|
92
|
-
const lockDir = `${eventsPath}.lock`;
|
|
93
|
-
const pidFile = path.join(lockDir, "pid");
|
|
94
|
-
const start = Date.now();
|
|
95
|
-
// SECURITY (HIGH #2 fix): Reduced from 120s to 5s to prevent blocking the
|
|
96
|
-
// event loop indefinitely. ~100 retries × 50ms ≈ 5s max. After timeout, we
|
|
97
|
-
// throw a clear error instead of blocking forever. This ensures AbortSignal
|
|
98
|
-
// handlers, SIGTERM, and graceful shutdown can fire within seconds.
|
|
99
|
-
const timeout = options?.timeoutMs ?? 5000;
|
|
100
|
-
const staleMs = options?.staleMs ?? 10000;
|
|
101
|
-
let acquired = false;
|
|
102
|
-
while (true) {
|
|
103
|
-
try {
|
|
104
|
-
// NOTE: mkdir-based lock is acceptable here. On POSIX systems, directory
|
|
105
|
-
// creation via mkdir with O_CREAT|O_EXCL semantics is atomic — equivalent
|
|
106
|
-
// to O_EXCL file open. The stale detection below uses process.kill(pid, 0)
|
|
107
|
-
// which has a TOCTOU race, but O_EXCL is used to atomically verify-and-remove
|
|
108
|
-
// the stale lock in one operation, eliminating the race. The 5s timeout
|
|
109
|
-
// (reduced from 120s) is appropriate.
|
|
110
|
-
fs.mkdirSync(lockDir);
|
|
111
|
-
try {
|
|
112
|
-
atomicWriteFile(pidFile, String(process.pid));
|
|
113
|
-
} catch {
|
|
114
|
-
/* best-effort */
|
|
115
|
-
}
|
|
116
|
-
acquired = true;
|
|
117
|
-
break;
|
|
118
|
-
} catch {
|
|
119
|
-
if (Date.now() - start > timeout) {
|
|
120
|
-
// SECURITY (HIGH #2 fix): Throw instead of continuing without lock.
|
|
121
|
-
// Previously this logged and broke out of the loop, executing the
|
|
122
|
-
// operation without lock protection. Now we throw so callers can retry.
|
|
123
|
-
// E1 (Round 15): structured CrewError (E010) with help hint so users know
|
|
124
|
-
// to check for orphaned .lock dirs / stale processes.
|
|
125
|
-
throw errors.eventLogLockTimeout(eventsPath, timeout);
|
|
126
|
-
}
|
|
127
|
-
// Round 26 (BUG 3): mtime-based stale check INDEPENDENT of pidFile.
|
|
128
|
-
// If the holder crashed between mkdir and writing pidFile, there is no
|
|
129
|
-
// pidFile to read — the old code just slept until the 5s timeout, then
|
|
130
|
-
// threw, leaving the dir orphaned FOREVER (every retry repeats the
|
|
131
|
-
// timeout). Now: if the lock dir's mtime exceeds staleMs, reclaim it.
|
|
132
|
-
try {
|
|
133
|
-
const dirStat = fs.statSync(lockDir);
|
|
134
|
-
if (Date.now() - dirStat.mtimeMs > staleMs) {
|
|
135
|
-
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
} catch {
|
|
139
|
-
/* dir vanished — let loop retry */
|
|
140
|
-
}
|
|
141
|
-
// Round 26 (BUG 4): the mtime check was previously NESTED inside
|
|
142
|
-
// `if (!alive)`, so a recycled PID (crashed holder's PID reused by an
|
|
143
|
-
// unrelated live process) kept `alive=true` and the mtime check NEVER
|
|
144
|
-
// fired → permanent wedge. mtime is now checked FIRST (above) for ALL
|
|
145
|
-
// holders. The PID check below is a secondary fast-path: if the holder
|
|
146
|
-
// PID is provably dead AND the lock isn't stale yet, we still wait
|
|
147
|
-
// (don't steal a fresh lock just because the pid lookup raced).
|
|
148
|
-
try {
|
|
149
|
-
const raw = fs.readFileSync(pidFile, "utf-8").trim();
|
|
150
|
-
const ownerPid = Number.parseInt(raw, 10);
|
|
151
|
-
if (!Number.isNaN(ownerPid) && ownerPid !== process.pid) {
|
|
152
|
-
let alive = false;
|
|
153
|
-
try {
|
|
154
|
-
process.kill(ownerPid, 0);
|
|
155
|
-
alive = true;
|
|
156
|
-
} catch {
|
|
157
|
-
/* dead */
|
|
158
|
-
}
|
|
159
|
-
// (mtime already handled above; nothing to do here for dead-but-fresh.)
|
|
160
|
-
void alive;
|
|
161
|
-
}
|
|
162
|
-
} catch {
|
|
163
|
-
/* no pid file — mtime check above already handles it */
|
|
164
|
-
}
|
|
165
|
-
sleepSync(50);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
try {
|
|
169
|
-
return fn();
|
|
170
|
-
} finally {
|
|
171
|
-
if (acquired) {
|
|
172
|
-
// Round 26 (BUG 5): token/PID-guarded release. Previously the release
|
|
173
|
-
// was an UNCONDITIONAL rmSync. If our fn exceeded staleMs, another
|
|
174
|
-
// process could steal our lock (rm our dir, make its own); when our fn
|
|
175
|
-
// finished our finally block would then DELETE THE STEALER's dir → both
|
|
176
|
-
// in the critical section + lost lock. Verify the pidFile still records
|
|
177
|
-
// OUR pid before removing; if it doesn't, the lock was stolen and the
|
|
178
|
-
// current holder owns the dir.
|
|
179
|
-
try {
|
|
180
|
-
const currentPid = fs.readFileSync(pidFile, "utf-8").trim();
|
|
181
|
-
if (currentPid === String(process.pid)) {
|
|
182
|
-
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
183
|
-
}
|
|
184
|
-
} catch {
|
|
185
|
-
/* lock stolen or already gone — do not touch */
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function evictOldestSequenceCacheEntries(): void {
|
|
192
|
-
// FIX: Evict by lastAccessMs (access time), not insertion order.
|
|
193
|
-
// Frequently accessed entries should be retained even if older.
|
|
194
|
-
const toEvict = Math.ceil(MAX_SEQUENCE_CACHE_ENTRIES / 2);
|
|
195
|
-
// Sort entries by lastAccessMs ascending (oldest first)
|
|
196
|
-
const entries = [...sequenceCache.entries()].sort((a, b) => a[1].lastAccessMs - b[1].lastAccessMs);
|
|
197
|
-
// Evict the oldest half
|
|
198
|
-
for (let i = 0; i < toEvict && i < entries.length; i++) {
|
|
199
|
-
sequenceCache.delete(entries[i][0]);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** @internal — exported for sequence-cache LRU testing (Round 19). */
|
|
204
|
-
export function __test__sequenceCacheSize(): number {
|
|
205
|
-
return sequenceCache.size;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** @internal — seed an entry into the sequence cache for testing. */
|
|
209
|
-
export function __test__seedSequenceCache(eventsPath: string, lastAccessMs: number): void {
|
|
210
|
-
sequenceCache.set(eventsPath, {
|
|
211
|
-
size: 1,
|
|
212
|
-
mtimeMs: 0,
|
|
213
|
-
seq: 0,
|
|
214
|
-
lastAccessMs,
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** @internal — expose eviction for testing. */
|
|
219
|
-
export function __test__evictOldestSequenceCacheEntries(): void {
|
|
220
|
-
evictOldestSequenceCacheEntries();
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** @internal — clear the sequence cache. */
|
|
224
|
-
export function __test__clearSequenceCache(): void {
|
|
225
|
-
sequenceCache.clear();
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** @internal — clear the in-process seqCounters Map so nextSequence seeds
|
|
229
|
-
* fresh from the sidecar/file (simulates a process restart for testing). */
|
|
230
|
-
export function __test__clearSeqCounters(): void {
|
|
231
|
-
seqCounters.clear();
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** @internal — the raw nextSequence for testing (forces re-seed from disk
|
|
235
|
-
* by requiring the caller to have already cleared both caches). */
|
|
236
|
-
export function __test__nextSequence(eventsPath: string): number {
|
|
237
|
-
return nextSequence(eventsPath);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** @internal — the max sequence cache entries bound. */
|
|
241
|
-
export const MAX_SEQUENCE_CACHE_ENTRIES_VALUE = MAX_SEQUENCE_CACHE_ENTRIES;
|
|
242
|
-
|
|
243
|
-
export function sequencePath(eventsPath: string): string {
|
|
244
|
-
return `${eventsPath}.seq`;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function parseSequence(raw: string): number | undefined {
|
|
248
|
-
const value = Number.parseInt(raw.trim(), 10);
|
|
249
|
-
return Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
export function scanSequence(eventsPath: string): number {
|
|
253
|
-
if (!fs.existsSync(eventsPath)) return 0;
|
|
254
|
-
let max = 0;
|
|
255
|
-
let skipped = 0;
|
|
256
|
-
for (const line of fs.readFileSync(eventsPath, "utf-8").split("\n")) {
|
|
257
|
-
if (!line.trim()) continue;
|
|
258
|
-
try {
|
|
259
|
-
const event = JSON.parse(line) as TeamEvent;
|
|
260
|
-
max = Math.max(max, event.metadata?.seq ?? 0);
|
|
261
|
-
} catch {
|
|
262
|
-
skipped++;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
if (skipped > 0) {
|
|
266
|
-
logInternalError("event-log.scanSequence.corrupt_lines", undefined, `${eventsPath}: skipped ${skipped} corrupt line(s)`);
|
|
267
|
-
}
|
|
268
|
-
return max;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function readStoredSequence(eventsPath: string): number | undefined {
|
|
272
|
-
try {
|
|
273
|
-
return parseSequence(fs.readFileSync(sequencePath(eventsPath), "utf-8"));
|
|
274
|
-
} catch {
|
|
275
|
-
return undefined;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function nextSequence(eventsPath: string): number {
|
|
280
|
-
if (!fs.existsSync(eventsPath)) return 1;
|
|
281
|
-
const stat = fs.statSync(eventsPath);
|
|
282
|
-
const cached = sequenceCache.get(eventsPath);
|
|
283
|
-
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
|
|
284
|
-
return cached.seq + 1;
|
|
285
|
-
}
|
|
286
|
-
// FIX: Trust the sidecar seq file if it exists and the file is non-empty.
|
|
287
|
-
// Explicitly check for file shrinkage (stat.size < cached.size) to trigger
|
|
288
|
-
// re-scan when rotation or compaction has occurred.
|
|
289
|
-
const stored = readStoredSequence(eventsPath);
|
|
290
|
-
const fileShrunk = cached && stat.size < cached.size;
|
|
291
|
-
if (stored !== undefined && !fileShrunk) {
|
|
292
|
-
// EL-1: the sidecar can regress via sync/async interleave —
|
|
293
|
-
// appendEventAsync reserves a seq, yields during appendFile/fsync, a
|
|
294
|
-
// concurrent sync appendEvent reserves+persists a higher seq, then the
|
|
295
|
-
// async path persists its lower seq — regressing the sidecar below the
|
|
296
|
-
// file's true max. Trusting the regressed sidecar alone would return a
|
|
297
|
-
// duplicate seq on the next append. Take max with scanSequence so a
|
|
298
|
-
// regressed sidecar cannot cause duplicate sequence numbers.
|
|
299
|
-
const fileMax = scanSequence(eventsPath);
|
|
300
|
-
const safeSeq = Math.max(stored, fileMax);
|
|
301
|
-
sequenceCache.set(eventsPath, {
|
|
302
|
-
size: stat.size,
|
|
303
|
-
mtimeMs: stat.mtimeMs,
|
|
304
|
-
seq: safeSeq,
|
|
305
|
-
lastAccessMs: Date.now(),
|
|
306
|
-
});
|
|
307
|
-
return safeSeq + 1;
|
|
308
|
-
}
|
|
309
|
-
const current = scanSequence(eventsPath);
|
|
310
|
-
sequenceCache.set(eventsPath, {
|
|
311
|
-
size: stat.size,
|
|
312
|
-
mtimeMs: stat.mtimeMs,
|
|
313
|
-
seq: current,
|
|
314
|
-
lastAccessMs: Date.now(),
|
|
315
|
-
});
|
|
316
|
-
persistSequence(eventsPath, current);
|
|
317
|
-
return current + 1;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function persistSequence(eventsPath: string, seq: number): void {
|
|
321
|
-
try {
|
|
322
|
-
atomicWriteFile(sequencePath(eventsPath), String(seq));
|
|
323
|
-
} catch (error) {
|
|
324
|
-
logInternalError("event-log.persist-sequence-file", error, `eventsPath=${eventsPath}`);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// B7: single in-process monotonic sequence counter per eventsPath. The three
|
|
329
|
-
// append paths — sync appendEvent (withEventLogLockSync file lock), buffered
|
|
330
|
-
// flush (asyncLocks promise chain), and direct appendEventAsync (asyncQueues
|
|
331
|
-
// promise chain) — use DIFFERENT locks, so the old read-sidecar / compute /
|
|
332
|
-
// persist-sidecar sequence logic in nextSequence() raced ACROSS paths and
|
|
333
|
-
// produced duplicate sequence numbers (observed live: distinct events sharing
|
|
334
|
-
// a seq; no data loss — only the counter collided). A single in-process counter
|
|
335
|
-
// makes assignment atomic (JS is single-threaded); persistSequence() keeps the
|
|
336
|
-
// sidecar durable for crash recovery across restarts.
|
|
337
|
-
const seqCounters = new Map<string, number>();
|
|
338
|
-
|
|
339
|
-
/** Atomically reserve the next sequence number for `eventsPath`. */
|
|
340
|
-
function reserveSequence(eventsPath: string): number {
|
|
341
|
-
let last = seqCounters.get(eventsPath);
|
|
342
|
-
if (last === undefined) {
|
|
343
|
-
// Seed once from the authoritative source (sidecar / cache / file scan).
|
|
344
|
-
// nextSequence() returns the NEXT seq to assign, so the last assigned is one less.
|
|
345
|
-
last = nextSequence(eventsPath) - 1;
|
|
346
|
-
}
|
|
347
|
-
const next = last + 1;
|
|
348
|
-
seqCounters.set(eventsPath, next);
|
|
349
|
-
return next;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/** Keep the in-process counter monotonic w.r.t. an explicitly-provided seq
|
|
353
|
-
* (e.g. baseMetadata.seq) so a later auto-assigned seq never collides with it. */
|
|
354
|
-
function advanceSequenceCounter(eventsPath: string, seq: number): void {
|
|
355
|
-
const last = seqCounters.get(eventsPath);
|
|
356
|
-
if (last === undefined || seq > last) seqCounters.set(eventsPath, seq);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId" | "taskId" | "data">): string {
|
|
360
|
-
return createHash("sha256")
|
|
361
|
-
.update(
|
|
362
|
-
JSON.stringify({
|
|
363
|
-
type: event.type,
|
|
364
|
-
runId: event.runId,
|
|
365
|
-
taskId: event.taskId,
|
|
366
|
-
data: event.data ?? null,
|
|
367
|
-
}),
|
|
368
|
-
)
|
|
369
|
-
.digest("hex")
|
|
370
|
-
.slice(0, 16);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* Check for sequence gaps between the sidecar file and the events file.
|
|
375
|
-
* This detects situations where the sidecar records a sequence number that has
|
|
376
|
-
* no corresponding event in the file (e.g., due to a crash between
|
|
377
|
-
* persistSequence and appendFile in older code, or other corruption).
|
|
378
|
-
*
|
|
379
|
-
* Returns an array of gap info: for each gap found, { missing: n } indicates
|
|
380
|
-
* sequence n is recorded in sidecar but has no corresponding event.
|
|
381
|
-
* An empty array means no gaps were found.
|
|
382
|
-
*/
|
|
383
|
-
export function checkSequenceGaps(eventsPath: string): { missing: number }[] {
|
|
384
|
-
if (!fs.existsSync(eventsPath)) return [];
|
|
385
|
-
const gaps: { missing: number }[] = [];
|
|
386
|
-
const storedSeq = readStoredSequence(eventsPath);
|
|
387
|
-
if (storedSeq === undefined) return [];
|
|
388
|
-
const maxInFile = scanSequence(eventsPath);
|
|
389
|
-
// If sidecar is ahead of file, report the missing sequences
|
|
390
|
-
// (sidecar stores the NEXT sequence to use, so storedSeq is the last written)
|
|
391
|
-
if (storedSeq > maxInFile) {
|
|
392
|
-
for (let i = maxInFile + 1; i <= storedSeq; i++) {
|
|
393
|
-
gaps.push({ missing: i });
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
return gaps;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* @deprecated Prefer `appendEventAsync()` in async contexts. The sync lock uses
|
|
401
|
-
* `sleepSync` which blocks the Node.js event loop, preventing AbortSignal handlers
|
|
402
|
-
* from firing and degrading live-agent responsiveness.
|
|
403
|
-
*/
|
|
404
|
-
export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEvent {
|
|
405
|
-
// NOTE: appendEvent is a sync function that uses withEventLogLockSync (sleepSync).
|
|
406
|
-
// It cannot route through appendEventBuffered because the buffer timer requires
|
|
407
|
-
// the event loop to fire, which sleepSync blocks. Both terminal and non-terminal
|
|
408
|
-
// events use the direct sync path here. For non-terminal events, callers should
|
|
409
|
-
// prefer appendEventAsync (which routes through the buffer for coalesced writes).
|
|
410
|
-
return withEventLogLockSync(eventsPath, () => appendEventInsideLock(eventsPath, event));
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// --- Async write queue (non-blocking alternative to withEventLogLockSync) ---
|
|
414
|
-
const asyncQueues = new Map<string, Promise<unknown>>();
|
|
415
|
-
|
|
416
|
-
// --- Async lock for flush operations (non-blocking alternative to withEventLogLockSync) ---
|
|
417
|
-
// Uses promise-chain pattern to ensure sequential lock acquisition without blocking the event loop.
|
|
418
|
-
const asyncLocks = new Map<string, Promise<unknown>>();
|
|
419
|
-
|
|
420
|
-
/** Drain all pending async writes by awaiting all in-flight queue promises.
|
|
421
|
-
* Called on process exit to minimize event loss for crash-sensitive events.
|
|
422
|
-
* Note: SIGKILL (kill -9) cannot be intercepted and will still lose events.
|
|
423
|
-
*/
|
|
424
|
-
async function drainAsyncQueues(): Promise<void> {
|
|
425
|
-
const promises = [...asyncQueues.values()];
|
|
426
|
-
if (promises.length === 0) return;
|
|
427
|
-
// Use allSettled to ensure a rejected promise doesn't prevent others from completing.
|
|
428
|
-
await Promise.allSettled(promises);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/** Async lock using promise-chain pattern to avoid blocking the Node.js event loop.
|
|
432
|
-
* Unlike withEventLogLockSync, this uses async I/O and does not use sleepSync,
|
|
433
|
-
* allowing AbortSignal handlers and SIGTERM handlers to proceed while waiting.
|
|
434
|
-
*/
|
|
435
|
-
async function withEventLogLockAsync(eventsPath: string, fn: () => Promise<void>): Promise<void> {
|
|
436
|
-
const queueKey = eventsPath;
|
|
437
|
-
const prev = asyncLocks.get(queueKey) ?? Promise.resolve();
|
|
438
|
-
const next = prev.then(async (): Promise<void> => {
|
|
439
|
-
await fn();
|
|
440
|
-
});
|
|
441
|
-
asyncLocks.set(queueKey, next);
|
|
442
|
-
try {
|
|
443
|
-
await next;
|
|
444
|
-
} finally {
|
|
445
|
-
// Compare-and-delete: only remove our entry if it still points at our
|
|
446
|
-
// promise. With 3+ overlapping callers, an earlier caller's finally would
|
|
447
|
-
// otherwise delete a later caller's promise, letting the next caller start
|
|
448
|
-
// immediately (in parallel) -> broken mutual exclusion, duplicate seqs.
|
|
449
|
-
if (asyncLocks.get(queueKey) === next) {
|
|
450
|
-
asyncLocks.delete(queueKey);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/** Reset event log mode (for testing only). */
|
|
456
|
-
export function resetEventLogMode(): void {
|
|
457
|
-
asyncQueues.clear();
|
|
458
|
-
// B7: clear in-process sequence counters alongside async state so tests
|
|
459
|
-
// don't leak seq state between runs.
|
|
460
|
-
seqCounters.clear();
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* Append an event to the event log using non-blocking async I/O.
|
|
465
|
-
*
|
|
466
|
-
* Uses a per-eventsPath promise-chain queue to ensure sequential writes without
|
|
467
|
-
* blocking the Node.js event loop. This allows AbortSignal handlers and other
|
|
468
|
-
* async operations to proceed while events are being persisted.
|
|
469
|
-
*
|
|
470
|
-
* For callers that are already in an async context (team-runner, task-runner,
|
|
471
|
-
* foreground-control, etc.), prefer this over the sync `appendEvent()`.
|
|
472
|
-
*/
|
|
473
|
-
export async function appendEventAsync(eventsPath: string, event: AppendTeamEvent): Promise<TeamEvent> {
|
|
474
|
-
// FIX (v0.9.26): Do NOT route non-terminal events through appendEventBuffered.
|
|
475
|
-
// The buffer uses a 20ms timer + withEventLogLockAsync (promise chain), while
|
|
476
|
-
// the sync appendEvent path uses withEventLogLockSync (file lock with sleepSync).
|
|
477
|
-
// Mixing these two lock mechanisms on the same eventsPath causes a deadlock:
|
|
478
|
-
// the buffer timer can't fire while sleepSync blocks the event loop, and
|
|
479
|
-
// sleepSync can't acquire the lock while the buffer holds it via the promise
|
|
480
|
-
// chain. This deadlocked adaptive-implementation, implementation-fanout,
|
|
481
|
-
// parallel-research-dynamic, run-analysis, and team-run tests (>300s timeout).
|
|
482
|
-
// Reverted to v0.9.19 behavior: ALL events use the asyncQueues direct path.
|
|
483
|
-
// The buffer (appendEventBuffered) is still available for explicit callers
|
|
484
|
-
// that want coalesced writes (e.g., appendEventFireAndForget), but
|
|
485
|
-
// appendEventAsync itself does NOT buffer.
|
|
486
|
-
const queueKey = eventsPath;
|
|
487
|
-
const prev = asyncQueues.get(queueKey) ?? Promise.resolve();
|
|
488
|
-
const next = prev.then(async (): Promise<TeamEvent> => {
|
|
489
|
-
// Ensure directory exists
|
|
490
|
-
await fs.promises.mkdir(path.dirname(eventsPath), { recursive: true });
|
|
491
|
-
|
|
492
|
-
// Build metadata (same logic as appendEventInsideLock)
|
|
493
|
-
// FIX: Sequence is computed INSIDE the promise chain. We NO LONGER persist
|
|
494
|
-
// the sequence number before the append — that caused sequence reuse if
|
|
495
|
-
// appendFile failed after persistSequence succeeded. Instead, we persist
|
|
496
|
-
// ONLY AFTER successful appendFile, so the sidecar is only updated when
|
|
497
|
-
// the event is definitively written. If appendFile fails, the sidecar is
|
|
498
|
-
// not updated and nextSequence() will re-scan on next call, returning the
|
|
499
|
-
// correct value without reuse.
|
|
500
|
-
const baseMetadata = event.metadata;
|
|
501
|
-
let seq: number;
|
|
502
|
-
if (baseMetadata?.seq !== undefined) {
|
|
503
|
-
seq = baseMetadata.seq;
|
|
504
|
-
advanceSequenceCounter(eventsPath, seq);
|
|
505
|
-
} else {
|
|
506
|
-
seq = reserveSequence(eventsPath);
|
|
507
|
-
// NOTE: We do NOT call persistSequence here. It will be called AFTER
|
|
508
|
-
// successful appendFile below to ensure sidecar is only updated when
|
|
509
|
-
// the event is actually written.
|
|
510
|
-
}
|
|
511
|
-
let metadata: TeamEventMetadata = {
|
|
512
|
-
seq,
|
|
513
|
-
provenance: baseMetadata?.provenance ?? "team_runner",
|
|
514
|
-
...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
|
|
515
|
-
...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
|
|
516
|
-
...(baseMetadata?.branchId ? { branchId: baseMetadata.branchId } : {}),
|
|
517
|
-
...(baseMetadata?.causationId ? { causationId: baseMetadata.causationId } : {}),
|
|
518
|
-
...(baseMetadata?.correlationId ? { correlationId: baseMetadata.correlationId } : {}),
|
|
519
|
-
...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
|
|
520
|
-
...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
|
|
521
|
-
...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
|
|
522
|
-
...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
|
|
523
|
-
};
|
|
524
|
-
const fullEvent: TeamEvent = {
|
|
525
|
-
time: new Date().toISOString(),
|
|
526
|
-
...event,
|
|
527
|
-
metadata,
|
|
528
|
-
};
|
|
529
|
-
if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
|
|
530
|
-
metadata = {
|
|
531
|
-
...metadata,
|
|
532
|
-
fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent),
|
|
533
|
-
};
|
|
534
|
-
fullEvent.metadata = metadata;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// Overflow handling: same logic as sync path
|
|
538
|
-
const isTerminal = TERMINAL_EVENT_TYPES.has(fullEvent.type);
|
|
539
|
-
let skippedDueToSize = false;
|
|
540
|
-
let fileStat: fs.Stats | undefined;
|
|
541
|
-
try {
|
|
542
|
-
fileStat = await fs.promises.stat(eventsPath).catch(() => undefined);
|
|
543
|
-
} catch {
|
|
544
|
-
/* file does not exist */
|
|
545
|
-
}
|
|
546
|
-
// FIND-10: track whether overflow handling modified the file so we can
|
|
547
|
-
// reuse fileStat for the post-overflow size check (avoids redundant stat).
|
|
548
|
-
let overflowHandled = false;
|
|
549
|
-
if (!isTerminal && fileStat) {
|
|
550
|
-
const stat = fileStat;
|
|
551
|
-
if (stat.size > MAX_EVENTS_BYTES) {
|
|
552
|
-
overflowHandled = true;
|
|
553
|
-
try {
|
|
554
|
-
compactEventLog(eventsPath);
|
|
555
|
-
} catch (error) {
|
|
556
|
-
logInternalError("event-log.immediate-compact", error, `eventsPath=${eventsPath}`);
|
|
557
|
-
}
|
|
558
|
-
let afterCompactStat: fs.Stats | undefined;
|
|
559
|
-
try {
|
|
560
|
-
afterCompactStat = await fs.promises.stat(eventsPath).catch(() => undefined);
|
|
561
|
-
} catch {
|
|
562
|
-
/* file does not exist */
|
|
563
|
-
}
|
|
564
|
-
if (afterCompactStat) {
|
|
565
|
-
if (afterCompactStat.size > MAX_EVENTS_BYTES) {
|
|
566
|
-
rotateEventLog(eventsPath);
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
// FIND-10: collapse redundant stat. If no overflow handling occurred,
|
|
572
|
-
// the file hasn't changed since fileStat — reuse it instead of re-stat'ing.
|
|
573
|
-
let sizeCheckStat: fs.Stats | undefined;
|
|
574
|
-
if (overflowHandled) {
|
|
575
|
-
try {
|
|
576
|
-
sizeCheckStat = await fs.promises.stat(eventsPath).catch(() => undefined);
|
|
577
|
-
} catch {
|
|
578
|
-
/* file does not exist */
|
|
579
|
-
}
|
|
580
|
-
} else {
|
|
581
|
-
sizeCheckStat = fileStat;
|
|
582
|
-
}
|
|
583
|
-
try {
|
|
584
|
-
if (sizeCheckStat && sizeCheckStat.size > MAX_EVENTS_BYTES) {
|
|
585
|
-
logInternalError(
|
|
586
|
-
"event-log.size-limit",
|
|
587
|
-
new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes after compaction`),
|
|
588
|
-
`eventsPath=${eventsPath}`,
|
|
589
|
-
);
|
|
590
|
-
skippedDueToSize = true;
|
|
591
|
-
}
|
|
592
|
-
} catch (error) {
|
|
593
|
-
logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// FIND-10: post-append stat captured from the same fd (non-worker path)
|
|
597
|
-
// for reuse in the cache update below, avoiding a redundant path stat.
|
|
598
|
-
let postAppendStat: fs.Stats | undefined;
|
|
599
|
-
if (!skippedDueToSize) {
|
|
600
|
-
const line = JSON.stringify(redactSecrets(fullEvent)) + "\n";
|
|
601
|
-
// Phase 1.5: when worker atomic writer is enabled, append via worker.
|
|
602
|
-
if (isWorkerAtomicWriterEnabled()) {
|
|
603
|
-
await appendFileViaWorker(eventsPath, line);
|
|
604
|
-
// Worker path: fsync via a separate open (worker manages its own fd).
|
|
605
|
-
const fd = await fs.promises.open(eventsPath, "r+");
|
|
606
|
-
try {
|
|
607
|
-
await fd.sync();
|
|
608
|
-
} finally {
|
|
609
|
-
await fd.close();
|
|
610
|
-
}
|
|
611
|
-
} else {
|
|
612
|
-
// FIND-10: single-fd append+fsync. Opens in append mode, writes,
|
|
613
|
-
// fsyncs on the SAME fd, then closes — eliminating the separate
|
|
614
|
-
// open("r+") + sync that previously doubled the fd count. The
|
|
615
|
-
// fsync (seq-integrity protection) is preserved exactly: it still
|
|
616
|
-
// closes the crash window between append and persistSequence.
|
|
617
|
-
const fd = await fs.promises.open(eventsPath, "a");
|
|
618
|
-
try {
|
|
619
|
-
await fd.appendFile(line, "utf-8");
|
|
620
|
-
await fd.sync();
|
|
621
|
-
// FIND-10 R1 fix: the cache-optimization fd.stat() must NOT sit in the
|
|
622
|
-
// seq-durability critical path. If it threw (rare — fd invalidated),
|
|
623
|
-
// it would skip persistSequence below and reopen the seq-reuse
|
|
624
|
-
// window the fsync just closed. Guard it; fall back to undefined
|
|
625
|
-
// (the later cache-update takes a path stat instead).
|
|
626
|
-
try {
|
|
627
|
-
postAppendStat = await fd.stat();
|
|
628
|
-
} catch {
|
|
629
|
-
postAppendStat = undefined;
|
|
630
|
-
}
|
|
631
|
-
} finally {
|
|
632
|
-
await fd.close();
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
// FIX: Persist sequence AFTER successful appendFile to ensure sidecar
|
|
636
|
-
// is only updated when the event is definitively written. If appendFile
|
|
637
|
-
// threw, we would not reach here and the sidecar would not be updated,
|
|
638
|
-
// preventing sequence reuse on restart.
|
|
639
|
-
persistSequence(eventsPath, seq);
|
|
640
|
-
}
|
|
641
|
-
// FIND-10: track whether compaction happened after the append so the
|
|
642
|
-
// cache-update stat can safely reuse postAppendStat (file unchanged).
|
|
643
|
-
let compactedAfterAppend = false;
|
|
644
|
-
if (appendCounter % 100 === 0 && needsRotation(eventsPath)) {
|
|
645
|
-
compactedAfterAppend = true;
|
|
646
|
-
try {
|
|
647
|
-
compactEventLog(eventsPath);
|
|
648
|
-
} catch (error) {
|
|
649
|
-
logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`);
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
try {
|
|
653
|
-
emitFromTeamEvent(fullEvent);
|
|
654
|
-
} catch (error) {
|
|
655
|
-
logInternalError("event-log.emit", error);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// FIX: Sequence was persisted AFTER appendFile in the append block above.
|
|
659
|
-
// Only update the cache here (the sidecar persist is already done).
|
|
660
|
-
const finalSeq = fullEvent.metadata?.seq ?? 0;
|
|
661
|
-
try {
|
|
662
|
-
// FIND-10: reuse post-append fd stat when available and no compaction
|
|
663
|
-
// happened after the append (file unchanged). Falls back to path stat
|
|
664
|
-
// for the worker path, skipped events, or post-compaction cases.
|
|
665
|
-
let statResult: fs.Stats | undefined;
|
|
666
|
-
if (postAppendStat && !compactedAfterAppend) {
|
|
667
|
-
statResult = postAppendStat;
|
|
668
|
-
} else {
|
|
669
|
-
try {
|
|
670
|
-
statResult = await fs.promises.stat(eventsPath).catch(() => undefined);
|
|
671
|
-
} catch {
|
|
672
|
-
/* file may not exist */
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
if (statResult) {
|
|
676
|
-
if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
|
|
677
|
-
evictOldestSequenceCacheEntries();
|
|
678
|
-
}
|
|
679
|
-
sequenceCache.set(eventsPath, {
|
|
680
|
-
size: statResult.size,
|
|
681
|
-
mtimeMs: statResult.mtimeMs,
|
|
682
|
-
seq: finalSeq,
|
|
683
|
-
lastAccessMs: Date.now(),
|
|
684
|
-
});
|
|
685
|
-
}
|
|
686
|
-
// Note: persistSequence is NOT called here again - it was already called
|
|
687
|
-
// after the append to ensure the sidecar is current after the event is written.
|
|
688
|
-
} catch (error) {
|
|
689
|
-
logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
|
|
690
|
-
}
|
|
691
|
-
return fullEvent;
|
|
692
|
-
});
|
|
693
|
-
const tail = next.then(
|
|
694
|
-
() => {
|
|
695
|
-
// Compare-and-delete: only remove our entry if it still points at our
|
|
696
|
-
// tail promise. An older caller deleting unconditionally would wipe a
|
|
697
|
-
// newer caller's promise, letting the next caller bypass serialization
|
|
698
|
-
// -> duplicate seqs / interleaved appends.
|
|
699
|
-
if (asyncQueues.get(queueKey) === tail) {
|
|
700
|
-
asyncQueues.delete(queueKey);
|
|
701
|
-
}
|
|
702
|
-
},
|
|
703
|
-
(error) => {
|
|
704
|
-
// FIX: Wrap error handler in try-catch to ensure asyncQueues.delete
|
|
705
|
-
// always runs, even if logging itself throws.
|
|
706
|
-
try {
|
|
707
|
-
logInternalError("event-log.async-queue", error, eventsPath);
|
|
708
|
-
} catch {
|
|
709
|
-
// logging failed — ensure queue is still cleaned up
|
|
710
|
-
}
|
|
711
|
-
// FIX: Reset queue to a resolved state instead of deleting it.
|
|
712
|
-
// This prevents cascading failures where a single transient error
|
|
713
|
-
// (e.g., ENOSPC) causes all subsequent events on the same path to fail.
|
|
714
|
-
asyncQueues.set(queueKey, Promise.resolve());
|
|
715
|
-
},
|
|
716
|
-
);
|
|
717
|
-
asyncQueues.set(queueKey, tail);
|
|
718
|
-
return next;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/**
|
|
722
|
-
* Body of `appendEvent` assuming the caller already holds
|
|
723
|
-
* `withEventLogLockSync` for `eventsPath`. Used by `appendEventBuffered` to
|
|
724
|
-
* write a whole batch of pending events under a single lock acquire.
|
|
725
|
-
*/
|
|
726
|
-
/**
|
|
727
|
-
* Batch variant used by the buffered flush path. Computes metadata for each
|
|
728
|
-
* event, writes the whole batch in a single appendFileSync + fsync, persists
|
|
729
|
-
* the sequence sidecar once with the last seq, and updates the sequence cache
|
|
730
|
-
* once. Resolves each item with its finalized event (carrying the assigned
|
|
731
|
-
* seq). This collapses N fsyncs into 1 for the buffered write path, which is
|
|
732
|
-
* the entire point of buffering — the previous per-event fsync made buffer
|
|
733
|
-
* coalescing useless and added ~30ms/event on tmpfs.
|
|
734
|
-
*/
|
|
735
|
-
async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedAppend[]): Promise<void> {
|
|
736
|
-
if (queue.length === 0) return;
|
|
737
|
-
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
|
|
738
|
-
|
|
739
|
-
// Pre-flight size check (mirrors appendEventInsideLock). We do it once for
|
|
740
|
-
// the batch instead of once per event.
|
|
741
|
-
try {
|
|
742
|
-
if (fs.existsSync(eventsPath)) {
|
|
743
|
-
const stat = fs.statSync(eventsPath);
|
|
744
|
-
if (stat.size > MAX_EVENTS_BYTES) {
|
|
745
|
-
try {
|
|
746
|
-
const prepared = prepareCompaction(eventsPath);
|
|
747
|
-
if (prepared) applyCompactionUnlocked(eventsPath, prepared);
|
|
748
|
-
} catch (error) {
|
|
749
|
-
logInternalError("event-log.batch-immediate-compact", error, `eventsPath=${eventsPath}`);
|
|
750
|
-
}
|
|
751
|
-
if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
|
|
752
|
-
rotateEventLogUnlocked(eventsPath);
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
} catch (error) {
|
|
757
|
-
logInternalError("event-log.batch-size-check", error, `eventsPath=${eventsPath}`);
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
// Phase 1: compute metadata + JSON lines for every event in the batch.
|
|
761
|
-
// Initialize nextSeq ONCE from nextSequence (or the first event's baseMetadata.seq),
|
|
762
|
-
// then increment locally for each subsequent event in the batch. Calling
|
|
763
|
-
// nextSequence() per-event would re-read file stat/sidecar with no writes
|
|
764
|
-
// in between — every call would see the same file state and return the same
|
|
765
|
-
// seq, breaking the "unique monotonic seq" contract. The cache update +
|
|
766
|
-
// persistSequence at the end refreshes the sidecar to the last assigned seq.
|
|
767
|
-
// B7: use reserveSequence for atomic seq assignment across all paths.
|
|
768
|
-
const startingSeq = queue[0]?.event.metadata?.seq ?? reserveSequence(eventsPath);
|
|
769
|
-
let nextSeq = startingSeq;
|
|
770
|
-
const finalized: { item: BufferedAppend; line: string; fullEvent: TeamEvent }[] = [];
|
|
771
|
-
let lastSeq = 0;
|
|
772
|
-
for (const item of queue) {
|
|
773
|
-
const baseMetadata = item.event.metadata;
|
|
774
|
-
const seq = baseMetadata?.seq ?? nextSeq++;
|
|
775
|
-
let metadata: TeamEventMetadata = {
|
|
776
|
-
seq,
|
|
777
|
-
provenance: baseMetadata?.provenance ?? "team_runner",
|
|
778
|
-
...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
|
|
779
|
-
...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
|
|
780
|
-
...(baseMetadata?.branchId ? { branchId: baseMetadata.branchId } : {}),
|
|
781
|
-
...(baseMetadata?.causationId ? { causationId: baseMetadata.causationId } : {}),
|
|
782
|
-
...(baseMetadata?.correlationId ? { correlationId: baseMetadata.correlationId } : {}),
|
|
783
|
-
...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
|
|
784
|
-
...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
|
|
785
|
-
...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
|
|
786
|
-
...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
|
|
787
|
-
};
|
|
788
|
-
const fullEvent: TeamEvent = {
|
|
789
|
-
time: new Date().toISOString(),
|
|
790
|
-
...item.event,
|
|
791
|
-
metadata,
|
|
792
|
-
};
|
|
793
|
-
if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
|
|
794
|
-
metadata = {
|
|
795
|
-
...metadata,
|
|
796
|
-
fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent),
|
|
797
|
-
};
|
|
798
|
-
fullEvent.metadata = metadata;
|
|
799
|
-
}
|
|
800
|
-
finalized.push({ item, line: `${JSON.stringify(redactSecrets(fullEvent))}\n`, fullEvent });
|
|
801
|
-
lastSeq = seq;
|
|
802
|
-
}
|
|
803
|
-
// B7: advance counter past the entire batch so next reserveSequence returns the correct value.
|
|
804
|
-
advanceSequenceCounter(eventsPath, lastSeq);
|
|
805
|
-
|
|
806
|
-
// Phase 2: single appendFileSync + single fsync + single persistSequence.
|
|
807
|
-
// Before this fix, each event in the batch triggered its own fsync, which
|
|
808
|
-
// was the dominant cost on tmpfs and CI runners.
|
|
809
|
-
try {
|
|
810
|
-
if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
|
|
811
|
-
logInternalError(
|
|
812
|
-
"event-log.size-limit",
|
|
813
|
-
new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes after compaction`),
|
|
814
|
-
`eventsPath=${eventsPath}`,
|
|
815
|
-
);
|
|
816
|
-
// Reject the batch — caller will surface the error per item.
|
|
817
|
-
for (const { item } of finalized) item.reject(new Error("event log size limit exceeded"));
|
|
818
|
-
return;
|
|
819
|
-
}
|
|
820
|
-
} catch (error) {
|
|
821
|
-
logInternalError("event-log.batch-size-check-post", error, `eventsPath=${eventsPath}`);
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
fs.appendFileSync(eventsPath, finalized.map((f) => f.line).join(""), "utf-8");
|
|
825
|
-
const fd = fs.openSync(eventsPath, "r+");
|
|
826
|
-
try {
|
|
827
|
-
fs.fsyncSync(fd);
|
|
828
|
-
} catch {
|
|
829
|
-
// EPERM on Windows CI: best-effort flush
|
|
830
|
-
} finally {
|
|
831
|
-
fs.closeSync(fd);
|
|
832
|
-
}
|
|
833
|
-
persistSequence(eventsPath, lastSeq);
|
|
834
|
-
|
|
835
|
-
// Phase 3: cache update + resolve all promises.
|
|
836
|
-
try {
|
|
837
|
-
const stat = fs.statSync(eventsPath);
|
|
838
|
-
if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
|
|
839
|
-
evictOldestSequenceCacheEntries();
|
|
840
|
-
}
|
|
841
|
-
sequenceCache.set(eventsPath, {
|
|
842
|
-
size: stat.size,
|
|
843
|
-
mtimeMs: stat.mtimeMs,
|
|
844
|
-
seq: lastSeq,
|
|
845
|
-
lastAccessMs: Date.now(),
|
|
846
|
-
});
|
|
847
|
-
} catch (error) {
|
|
848
|
-
logInternalError("event-log.batch-cache-update", error, `eventsPath=${eventsPath}`);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
for (const { item, fullEvent } of finalized) item.resolve(fullEvent);
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): TeamEvent {
|
|
855
|
-
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
|
|
856
|
-
const baseMetadata = event.metadata;
|
|
857
|
-
// B7: use reserveSequence for atomic seq assignment across all paths.
|
|
858
|
-
const explicitSeq = baseMetadata?.seq;
|
|
859
|
-
const seq = explicitSeq ?? reserveSequence(eventsPath);
|
|
860
|
-
if (explicitSeq !== undefined) advanceSequenceCounter(eventsPath, seq);
|
|
861
|
-
let metadata: TeamEventMetadata = {
|
|
862
|
-
seq,
|
|
863
|
-
provenance: baseMetadata?.provenance ?? "team_runner",
|
|
864
|
-
...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
|
|
865
|
-
...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
|
|
866
|
-
...(baseMetadata?.branchId ? { branchId: baseMetadata.branchId } : {}),
|
|
867
|
-
...(baseMetadata?.causationId ? { causationId: baseMetadata.causationId } : {}),
|
|
868
|
-
...(baseMetadata?.correlationId ? { correlationId: baseMetadata.correlationId } : {}),
|
|
869
|
-
...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
|
|
870
|
-
...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
|
|
871
|
-
...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
|
|
872
|
-
...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
|
|
873
|
-
};
|
|
874
|
-
const fullEvent: TeamEvent = {
|
|
875
|
-
time: new Date().toISOString(),
|
|
876
|
-
...event,
|
|
877
|
-
metadata,
|
|
878
|
-
};
|
|
879
|
-
if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
|
|
880
|
-
metadata = {
|
|
881
|
-
...metadata,
|
|
882
|
-
fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent),
|
|
883
|
-
};
|
|
884
|
-
fullEvent.metadata = metadata;
|
|
885
|
-
}
|
|
886
|
-
// H1 fix: handle overflow before appending.
|
|
887
|
-
// 1. Terminal events must always be persisted regardless of size.
|
|
888
|
-
// 2. Non-terminal events exceeding MAX_EVENTS_BYTES trigger immediate compact.
|
|
889
|
-
// 3. After compact, if still over limit, rotate.
|
|
890
|
-
const isTerminal = TERMINAL_EVENT_TYPES.has(fullEvent.type);
|
|
891
|
-
let skippedDueToSize = false;
|
|
892
|
-
if (!isTerminal && fs.existsSync(eventsPath)) {
|
|
893
|
-
const stat = fs.statSync(eventsPath);
|
|
894
|
-
if (stat.size > MAX_EVENTS_BYTES) {
|
|
895
|
-
// Try immediate compact (not waiting for counter % 100).
|
|
896
|
-
// Round 24 (BUG 1): we are INSIDE withEventLogLockSync. Use the unlocked
|
|
897
|
-
// apply/rotate cores — the locked variants would deadlock (mkdir lock
|
|
898
|
-
// is not re-entrant → 5s timeout → compaction/rotation never ran →
|
|
899
|
-
// unbounded log growth → events silently dropped past 50MB).
|
|
900
|
-
try {
|
|
901
|
-
const prepared = prepareCompaction(eventsPath);
|
|
902
|
-
if (prepared) applyCompactionUnlocked(eventsPath, prepared);
|
|
903
|
-
} catch (error) {
|
|
904
|
-
logInternalError("event-log.immediate-compact", error, `eventsPath=${eventsPath}`);
|
|
905
|
-
}
|
|
906
|
-
// Check if still too large after compact — if so, rotate
|
|
907
|
-
if (fs.existsSync(eventsPath)) {
|
|
908
|
-
const afterCompact = fs.statSync(eventsPath);
|
|
909
|
-
if (afterCompact.size > MAX_EVENTS_BYTES) {
|
|
910
|
-
rotateEventLogUnlocked(eventsPath);
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
try {
|
|
916
|
-
if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
|
|
917
|
-
// Only reach here for non-terminal events that still overflow after compact+rotate.
|
|
918
|
-
// Log and mark as not appended.
|
|
919
|
-
logInternalError(
|
|
920
|
-
"event-log.size-limit",
|
|
921
|
-
new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes after compaction`),
|
|
922
|
-
`eventsPath=${eventsPath}`,
|
|
923
|
-
);
|
|
924
|
-
skippedDueToSize = true;
|
|
925
|
-
}
|
|
926
|
-
} catch (error) {
|
|
927
|
-
logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
|
|
928
|
-
}
|
|
929
|
-
// seq is already computed above via reserveSequence — reuse it for persist/cache.
|
|
930
|
-
// const seq declaration removed (B7: seq is now computed before metadata object).
|
|
931
|
-
if (!skippedDueToSize) {
|
|
932
|
-
fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
|
|
933
|
-
// F3a: skip data fsync for non-terminal events. We still call `persistSequence`
|
|
934
|
-
// below, which means the .seq sidecar might briefly outpace the actual data
|
|
935
|
-
// on disk in a crash, but the event-reconstructor (`event-reconstructor.ts`)
|
|
936
|
-
// already handles inconsistent-tail recovery (appends after the sidecar's
|
|
937
|
-
// claimed sequence are simply ignored on a lossless recovery scan). We trade
|
|
938
|
-
// 1 ms of fsync per event on Windows for informational events; terminal
|
|
939
|
-
// events keep the strict window between append and persistSequence.
|
|
940
|
-
if (isTerminal) {
|
|
941
|
-
const fd = fs.openSync(eventsPath, "r+");
|
|
942
|
-
try {
|
|
943
|
-
fs.fsyncSync(fd);
|
|
944
|
-
} catch {
|
|
945
|
-
// EPERM on Windows CI: best-effort flush
|
|
946
|
-
} finally {
|
|
947
|
-
fs.closeSync(fd);
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
// FIX: Persist sequence AFTER the event append to prevent sequence reuse
|
|
951
|
-
// on crash. Only update the sidecar when the event is definitively written.
|
|
952
|
-
persistSequence(eventsPath, seq);
|
|
953
|
-
// FIX: Update cache AFTER append so cache and log are consistent with each other.
|
|
954
|
-
// This matches the async path behavior where cache is updated after the append.
|
|
955
|
-
// If a crash occurs after append but before cache update, the .seq file is
|
|
956
|
-
// already correct and nextSequence() will return the correct value on restart.
|
|
957
|
-
try {
|
|
958
|
-
const stat = fs.statSync(eventsPath);
|
|
959
|
-
if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
|
|
960
|
-
evictOldestSequenceCacheEntries();
|
|
961
|
-
}
|
|
962
|
-
sequenceCache.set(eventsPath, {
|
|
963
|
-
size: stat.size,
|
|
964
|
-
mtimeMs: stat.mtimeMs,
|
|
965
|
-
seq,
|
|
966
|
-
lastAccessMs: Date.now(),
|
|
967
|
-
});
|
|
968
|
-
} catch (error) {
|
|
969
|
-
logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
appendCounter++;
|
|
973
|
-
if (appendCounter % 100 === 0 && needsRotation(eventsPath)) {
|
|
974
|
-
// Round 24 (BUG 1): we are INSIDE withEventLogLockSync here (called via
|
|
975
|
-
// appendEventInsideLock). The mkdir lock is NOT re-entrant, so calling the
|
|
976
|
-
// locked compactEventLog would deadlock → 5s timeout → compaction never
|
|
977
|
-
// ran → unbounded log growth → events silently dropped past 50MB. Use the
|
|
978
|
-
// unlocked apply path instead (lock already held).
|
|
979
|
-
try {
|
|
980
|
-
const prepared = prepareCompaction(eventsPath);
|
|
981
|
-
if (prepared) applyCompactionUnlocked(eventsPath, prepared);
|
|
982
|
-
} catch (error) {
|
|
983
|
-
logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`);
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
try {
|
|
987
|
-
emitFromTeamEvent(fullEvent);
|
|
988
|
-
} catch (error) {
|
|
989
|
-
logInternalError("event-log.emit", error);
|
|
990
|
-
}
|
|
991
|
-
return fullEvent;
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
// 2.2 — Buffered append API. Caller queues events and they are flushed under
|
|
995
|
-
// a single `withEventLogLockSync` acquire after `bufferingMs` ms. The seq
|
|
996
|
-
// invariant is preserved because the flush still goes through
|
|
997
|
-
// appendEventInsideLock sequentially.
|
|
998
|
-
//
|
|
999
|
-
// Caveat: events still in the buffer at process kill -9 are lost. Callers
|
|
1000
|
-
// for whom durability is critical (lifecycle terminal events) should keep
|
|
1001
|
-
// using `appendEvent`. Used opportunistically for high-frequency events
|
|
1002
|
-
// like `task.progress` once integration tests cover crash semantics.
|
|
1003
|
-
interface BufferedAppend {
|
|
1004
|
-
event: AppendTeamEvent;
|
|
1005
|
-
resolve: (event: TeamEvent) => void;
|
|
1006
|
-
reject: (error: unknown) => void;
|
|
1007
|
-
}
|
|
1008
|
-
const bufferedQueues = new Map<string, BufferedAppend[]>();
|
|
1009
|
-
const bufferedTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
1010
|
-
const DEFAULT_BUFFER_MS = 20;
|
|
1011
|
-
|
|
1012
|
-
export function appendEventBuffered(eventsPath: string, event: AppendTeamEvent, bufferMs = DEFAULT_BUFFER_MS): Promise<TeamEvent> {
|
|
1013
|
-
// FIX: Terminal events must bypass buffer to ensure they're written immediately.
|
|
1014
|
-
// Previously, terminal events like task.failed could be lost on process crash.
|
|
1015
|
-
if (TERMINAL_EVENT_TYPES.has(event.type)) {
|
|
1016
|
-
// FIX: Flush any pending buffered events before writing terminal event
|
|
1017
|
-
// to ensure durability of events that precede the terminal event in the
|
|
1018
|
-
// same flush cycle. Without this, a kill -9 after terminal event write
|
|
1019
|
-
// but before buffer flush would lose the buffered events.
|
|
1020
|
-
if (bufferedQueues.has(eventsPath)) {
|
|
1021
|
-
flushOneEventLogBuffer(eventsPath);
|
|
1022
|
-
}
|
|
1023
|
-
// For terminal events, write synchronously to ensure durability
|
|
1024
|
-
return Promise.resolve(appendEvent(eventsPath, event));
|
|
1025
|
-
}
|
|
1026
|
-
return new Promise<TeamEvent>((resolve, reject) => {
|
|
1027
|
-
const queue = bufferedQueues.get(eventsPath) ?? [];
|
|
1028
|
-
queue.push({ event, resolve, reject });
|
|
1029
|
-
bufferedQueues.set(eventsPath, queue);
|
|
1030
|
-
if (!bufferedTimers.has(eventsPath)) {
|
|
1031
|
-
// Wrap flush in async IIFE so the returned Promise is awaited (avoids
|
|
1032
|
-
// "floating promise" warnings under --test-force-exit and prevents
|
|
1033
|
-
// the timer from being treated as done before the flush actually
|
|
1034
|
-
// completes its async work).
|
|
1035
|
-
const timer = setTimeout(() => {
|
|
1036
|
-
flushOneEventLogBuffer(eventsPath).catch((error) => {
|
|
1037
|
-
logInternalError("event-log.buffered-flush", error, `eventsPath=${eventsPath}`);
|
|
1038
|
-
});
|
|
1039
|
-
}, bufferMs);
|
|
1040
|
-
bufferedTimers.set(eventsPath, timer);
|
|
1041
|
-
timer.unref();
|
|
1042
|
-
}
|
|
1043
|
-
});
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
async function flushOneEventLogBuffer(eventsPath: string): Promise<void> {
|
|
1047
|
-
const queue = bufferedQueues.get(eventsPath);
|
|
1048
|
-
bufferedQueues.delete(eventsPath);
|
|
1049
|
-
const timer = bufferedTimers.get(eventsPath);
|
|
1050
|
-
// Timer is cleared in the finally block to ensure cleanup happens even on error
|
|
1051
|
-
try {
|
|
1052
|
-
if (!queue || queue.length === 0) return;
|
|
1053
|
-
|
|
1054
|
-
// FIX (Round 14, H3): When truncating the queue, explicitly reject the
|
|
1055
|
-
// dropped entries' promises. Previously `queue.splice()` silently
|
|
1056
|
-
// discarded the oldest items, and their associated Promises were never
|
|
1057
|
-
// resolved or rejected — causing callers to await forever and leaking
|
|
1058
|
-
// memory. We now reject with a clear error so callers can fall back.
|
|
1059
|
-
if (queue.length > 1000) {
|
|
1060
|
-
const dropped = queue.splice(0, queue.length - 500);
|
|
1061
|
-
overflowCounter++;
|
|
1062
|
-
// FIX: Include first/last dropped event type and sequence number in error
|
|
1063
|
-
// message to make debugging easier when events are dropped.
|
|
1064
|
-
const firstDroppedMeta = dropped[0]?.event.metadata;
|
|
1065
|
-
const lastDroppedMeta = dropped[dropped.length - 1]?.event.metadata;
|
|
1066
|
-
logInternalError(
|
|
1067
|
-
"event-log.buffer-overflow",
|
|
1068
|
-
new Error(
|
|
1069
|
-
`Buffer overflow #${overflowCounter}: Dropped ${dropped.length} events: first seq=${firstDroppedMeta?.seq} type=${dropped[0]?.event.type}, last seq=${lastDroppedMeta?.seq} type=${dropped[dropped.length - 1]?.event.type}`,
|
|
1070
|
-
),
|
|
1071
|
-
`${eventsPath}: ${queue.length + dropped.length} entries > 1000 cap`,
|
|
1072
|
-
);
|
|
1073
|
-
for (const item of dropped) {
|
|
1074
|
-
item.reject(
|
|
1075
|
-
new Error(
|
|
1076
|
-
`Event log buffer overflow: ${queue.length + dropped.length} entries > 1000 cap; oldest ${dropped.length} dropped to keep memory bounded; first dropped seq=${firstDroppedMeta?.seq} type=${dropped[0]?.event.type}`,
|
|
1077
|
-
),
|
|
1078
|
-
);
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
// FIX (Issue 2): Use async lock instead of withEventLogLockSync to avoid
|
|
1083
|
-
// blocking the event loop. The sync lock uses sleepSync which blocks for
|
|
1084
|
-
// up to 5s and prevents AbortSignal handlers from firing.
|
|
1085
|
-
// FIX (P0 follow-up): Batch the file write + fsync + persistSequence across
|
|
1086
|
-
// the whole queue. Previously each event triggered its own fsyncSync,
|
|
1087
|
-
// turning 100 buffered events into 100 fsyncs (~3s on tmpfs). Now we do
|
|
1088
|
-
// 1 appendFileSync + 1 fsync + 1 persistSequence for the whole batch.
|
|
1089
|
-
await withEventLogLockAsync(eventsPath, async () => {
|
|
1090
|
-
await appendEventBatchInsideLock(eventsPath, queue);
|
|
1091
|
-
});
|
|
1092
|
-
} catch (error) {
|
|
1093
|
-
// Lock acquire failed — fail every queued item so callers can fall back.
|
|
1094
|
-
if (queue) for (const item of queue) item.reject(error);
|
|
1095
|
-
} finally {
|
|
1096
|
-
bufferedTimers.delete(eventsPath);
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
/** Asynchronously flush every queued buffered event across all paths. */
|
|
1101
|
-
export async function flushEventLogBuffer(): Promise<void> {
|
|
1102
|
-
for (const eventsPath of [...bufferedQueues.keys()]) await flushOneEventLogBuffer(eventsPath);
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
/**
|
|
1106
|
-
* EL-2: Synchronously flush every queued buffered event across all paths.
|
|
1107
|
-
* Used by the `exit` / `uncaughtException` / `SIGTERM` / `SIGINT` handlers,
|
|
1108
|
-
* which CANNOT await async work (process is terminating). The async
|
|
1109
|
-
* flushEventLogBuffer() previously called from these handlers created
|
|
1110
|
-
* floating promises that never resolved, and the process exited before
|
|
1111
|
-
* any buffered events were written — losing all events buffered via
|
|
1112
|
-
* appendEventBuffered (task.progress etc.). This sync variant writes the
|
|
1113
|
-
* buffered batches using the sync event-log lock + appendFileSync + fsync
|
|
1114
|
-
* + persistSequence, recovering buffered events before termination.
|
|
1115
|
-
* In-flight asyncQueues (appendEventAsync writes already dispatched to
|
|
1116
|
-
* the thread pool) remain best-effort and cannot be awaited on `exit` —
|
|
1117
|
-
* we clear them to drop stale state. SIGKILL cannot be intercepted.
|
|
1118
|
-
*/
|
|
1119
|
-
export function flushBufferedQueuesSync(): void {
|
|
1120
|
-
for (const eventsPath of [...bufferedQueues.keys()]) {
|
|
1121
|
-
const queue = bufferedQueues.get(eventsPath);
|
|
1122
|
-
bufferedQueues.delete(eventsPath);
|
|
1123
|
-
if (!queue || queue.length === 0) continue;
|
|
1124
|
-
try {
|
|
1125
|
-
withEventLogLockSync(eventsPath, () => {
|
|
1126
|
-
// appendEventBatchInsideLock is declared async but its body is fully
|
|
1127
|
-
// synchronous (fs.appendFileSync + fs.fsyncSync + persistSequence,
|
|
1128
|
-
// no awaits). Invoking without await runs the body synchronously and
|
|
1129
|
-
// returns a resolved Promise that we discard.
|
|
1130
|
-
void appendEventBatchInsideLock(eventsPath, queue);
|
|
1131
|
-
});
|
|
1132
|
-
} catch (error) {
|
|
1133
|
-
logInternalError("event-log.sync-flush", error, eventsPath);
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
for (const eventsPath of [...bufferedTimers.keys()]) bufferedTimers.delete(eventsPath);
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
/**
|
|
1140
|
-
* Schedule an async event append without waiting for the result.
|
|
1141
|
-
* Uses the non-blocking async queue to avoid blocking the event loop.
|
|
1142
|
-
* Use only for events whose return value is ignored (high-frequency `task.progress`).
|
|
1143
|
-
* Errors are logged via logInternalError.
|
|
1144
|
-
*/
|
|
1145
|
-
export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEvent): void {
|
|
1146
|
-
appendEventAsync(eventsPath, event).catch((error) => logInternalError("event-log.fire-and-forget", error, eventsPath));
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
// Auto-flush on process exit so buffered events do not silently leak.
|
|
1150
|
-
// Defense-in-depth: SIGTERM/SIGINT use setImmediate so the handler returns
|
|
1151
|
-
// immediately and the main thread is not blocked by sync I/O.
|
|
1152
|
-
// FIX (P0 follow-up): Only call flushEventLogBuffer() / drainAsyncQueues() if
|
|
1153
|
-
// there is actually pending work. Calling them unconditionally creates a new
|
|
1154
|
-
// floating Promise that the test runner detects under --test-force-exit,
|
|
1155
|
-
// failing tests with "Promise resolution is still pending but the event loop
|
|
1156
|
-
// has already resolved" even when the test body completed cleanly.
|
|
1157
|
-
process.on("exit", () => {
|
|
1158
|
-
// EL-2: synchronously flush buffered events before the process terminates.
|
|
1159
|
-
// `exit` is sync-only and cannot await async work; the previous async
|
|
1160
|
-
// flushEventLogBuffer()/drainAsyncQueues() created floating promises that
|
|
1161
|
-
// never resolved, and the process exited before any buffered events were
|
|
1162
|
-
// written. flushBufferedQueuesSync uses the sync lock + appendFileSync +
|
|
1163
|
-
// fsync + persistSequence to recover buffered events.
|
|
1164
|
-
flushBufferedQueuesSync();
|
|
1165
|
-
asyncQueues.clear();
|
|
1166
|
-
});
|
|
1167
|
-
|
|
1168
|
-
// FIX (P0 follow-up): Drain buffered events on `beforeExit` (async-aware).
|
|
1169
|
-
// The `exit` handler above is sync-only and cannot await the flush, leaving
|
|
1170
|
-
// pending promises that the test runner detects under --test-force-exit as
|
|
1171
|
-
// "Promise resolution is still pending but the event loop has already
|
|
1172
|
-
// resolved". `beforeExit` fires when the event loop drains naturally and
|
|
1173
|
-
// supports async handlers, so we can await the flush here and let the loop
|
|
1174
|
-
// drain cleanly before process.exit() is called.
|
|
1175
|
-
process.on("beforeExit", async () => {
|
|
1176
|
-
if (bufferedQueues.size > 0) {
|
|
1177
|
-
try {
|
|
1178
|
-
await flushEventLogBuffer();
|
|
1179
|
-
} catch {
|
|
1180
|
-
/* best-effort */
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
if (asyncQueues.size > 0) {
|
|
1184
|
-
try {
|
|
1185
|
-
await drainAsyncQueues();
|
|
1186
|
-
} catch {
|
|
1187
|
-
/* best-effort */
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
});
|
|
1191
|
-
process.on("SIGTERM", () => setImmediate(() => flushBufferedQueuesSync()));
|
|
1192
|
-
process.on("SIGINT", () => setImmediate(() => flushBufferedQueuesSync()));
|
|
1193
|
-
// FIX (Issue 1): Handle uncaught exceptions to flush buffered events before
|
|
1194
|
-
// the process terminates. The async queues use promise chains that will be
|
|
1195
|
-
// abandoned on crash; clearing the map prevents memory leaks and stale state.
|
|
1196
|
-
// Note: SIGKILL (kill -9) cannot be intercepted and is not handled.
|
|
1197
|
-
process.on("uncaughtException", (error) => {
|
|
1198
|
-
// EL-2: synchronously flush buffered events before re-throwing (which
|
|
1199
|
-
// terminates the process). The previous async flushEventLogBuffer() +
|
|
1200
|
-
// drainAsyncQueues() couldn't complete before process exit; use the sync
|
|
1201
|
-
// variant to recover buffered events.
|
|
1202
|
-
flushBufferedQueuesSync();
|
|
1203
|
-
asyncQueues.clear();
|
|
1204
|
-
// Re-throw to preserve default uncaught exception behavior (process exit)
|
|
1205
|
-
throw error;
|
|
1206
|
-
});
|
|
1207
|
-
|
|
1208
|
-
export function readEvents(eventsPath: string): TeamEvent[] {
|
|
1209
|
-
if (!fs.existsSync(eventsPath)) return [];
|
|
1210
|
-
return fs
|
|
1211
|
-
.readFileSync(eventsPath, "utf-8")
|
|
1212
|
-
.split("\n")
|
|
1213
|
-
.map((line) => line.trim())
|
|
1214
|
-
.filter(Boolean)
|
|
1215
|
-
.flatMap((line) => {
|
|
1216
|
-
try {
|
|
1217
|
-
return [JSON.parse(line) as TeamEvent];
|
|
1218
|
-
} catch {
|
|
1219
|
-
return [];
|
|
1220
|
-
}
|
|
1221
|
-
});
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
export interface EventCursorOptions {
|
|
1225
|
-
sinceSeq?: number;
|
|
1226
|
-
limit?: number;
|
|
1227
|
-
fromByteOffset?: number;
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
export interface EventCursorResult {
|
|
1231
|
-
events: TeamEvent[];
|
|
1232
|
-
nextSeq: number;
|
|
1233
|
-
total: number;
|
|
1234
|
-
nextByteOffset?: number;
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
function positiveInteger(value: number | undefined): number | undefined {
|
|
1238
|
-
return value !== undefined && Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
export function readEventsCursor(eventsPath: string, options: EventCursorOptions = {}): EventCursorResult {
|
|
1242
|
-
// Incremental byte-offset path: read only new bytes since last known offset
|
|
1243
|
-
if (options.fromByteOffset !== undefined) {
|
|
1244
|
-
const byteOffset = positiveInteger(options.fromByteOffset) ?? 0;
|
|
1245
|
-
const initialState: IncrementalReadState = { byteOffset, lineCount: 0 };
|
|
1246
|
-
const { items, state: newState, eof } = readJsonlSince<TeamEvent>(eventsPath, initialState);
|
|
1247
|
-
const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
|
|
1248
|
-
const filtered = items.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
|
|
1249
|
-
const limit = positiveInteger(options.limit);
|
|
1250
|
-
const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
|
|
1251
|
-
const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
|
|
1252
|
-
return {
|
|
1253
|
-
events,
|
|
1254
|
-
nextSeq: returnedMaxSeq,
|
|
1255
|
-
total: filtered.length,
|
|
1256
|
-
nextByteOffset: newState.byteOffset,
|
|
1257
|
-
};
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
// FIND-05 default path: byte-level tail read (last 4MB) instead of
|
|
1261
|
-
// full-file read. Bounds CPU to O(tail bytes) instead of O(total
|
|
1262
|
-
// events). The legacy readEvents() full parse path is preserved for
|
|
1263
|
-
// callers that explicitly need the full history (e.g. tests that
|
|
1264
|
-
// assert exact contents) and as a small-file fallback.
|
|
1265
|
-
//
|
|
1266
|
-
// The 5000-event tail cap and the "event-log.cursor-full-read"
|
|
1267
|
-
// warning are preserved. A separate cursor-tail-truncated warning is
|
|
1268
|
-
// emitted whenever the file exceeds the 4MB tail budget, signalling
|
|
1269
|
-
// that a prefix was dropped and callers should pass fromByteOffset for
|
|
1270
|
-
// streaming reads.
|
|
1271
|
-
const TAIL_BYTES = 4 * 1024 * 1024; // 4 MB
|
|
1272
|
-
const TAIL_EVENT_CAP = 5000;
|
|
1273
|
-
const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
|
|
1274
|
-
const limit = positiveInteger(options.limit);
|
|
1275
|
-
|
|
1276
|
-
const tail = readJsonlTail<TeamEvent>(eventsPath, TAIL_BYTES);
|
|
1277
|
-
let all = tail.items;
|
|
1278
|
-
if (tail.truncated) {
|
|
1279
|
-
logInternalError("event-log.cursor-tail-truncated", {
|
|
1280
|
-
eventsPath,
|
|
1281
|
-
returned: all.length,
|
|
1282
|
-
tailBytes: TAIL_BYTES,
|
|
1283
|
-
});
|
|
1284
|
-
}
|
|
1285
|
-
if (all.length > TAIL_EVENT_CAP) {
|
|
1286
|
-
logInternalError(
|
|
1287
|
-
"event-log.cursor-full-read",
|
|
1288
|
-
new Error(`readEventsCursor tail read dropped events from a larger log; pass fromByteOffset for incremental reads`),
|
|
1289
|
-
`eventsPath=${eventsPath}`,
|
|
1290
|
-
);
|
|
1291
|
-
all = all.slice(-TAIL_EVENT_CAP);
|
|
1292
|
-
}
|
|
1293
|
-
const filtered = all.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
|
|
1294
|
-
const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
|
|
1295
|
-
const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
|
|
1296
|
-
return { events, nextSeq: returnedMaxSeq, total: filtered.length };
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
export function dedupeTerminalEvents(events: TeamEvent[]): TeamEvent[] {
|
|
1300
|
-
const seen = new Set<string>();
|
|
1301
|
-
const output: TeamEvent[] = [];
|
|
1302
|
-
for (const event of events) {
|
|
1303
|
-
const fingerprint = event.metadata?.fingerprint;
|
|
1304
|
-
if (fingerprint && TERMINAL_EVENT_TYPES.has(event.type)) {
|
|
1305
|
-
if (seen.has(fingerprint)) continue;
|
|
1306
|
-
seen.add(fingerprint);
|
|
1307
|
-
}
|
|
1308
|
-
output.push(event);
|
|
1309
|
-
}
|
|
1310
|
-
return output;
|
|
1311
|
-
}
|