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