mixdog 1.4.1 → 1.4.3
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/package.json +4 -3
- package/scripts/build-shipped-history.mjs +125 -33
- package/scripts/gate-local.mjs +220 -63
- package/scripts/graph-parity.mjs +8 -3
- package/scripts/lib/gate-scheduler.mjs +139 -32
- package/scripts/lib/gate-timing-reporter.mjs +16 -0
- package/scripts/lib/run-node-tests.mjs +71 -34
- package/scripts/lib/scoped-temp.mjs +1 -0
- package/scripts/lib/test-failure-records.mjs +14 -1
- package/scripts/lib/test-failure-summary.mjs +9 -4
- package/scripts/lib/test-spawn-ledger.mjs +43 -3
- package/scripts/post-release-check.mjs +26 -16
- package/scripts/pptx-kit-index.mjs +5 -6
- package/scripts/provider-pingpong-live.mjs +1 -1
- package/scripts/prune-embedding-runtime.mjs +3 -1
- package/scripts/release-paths.mjs +7 -1
- package/scripts/suite-health.mjs +110 -28
- package/scripts/test.mjs +9 -14
- package/src/agents/advisor/AGENT.md +4 -4
- package/src/agents/advisor/agent.json +1 -1
- package/src/agents/explore/AGENT.md +22 -0
- package/src/agents/explore/agent.json +6 -0
- package/src/cli.mjs +7 -0
- package/src/defaults/agents.json +7 -0
- package/src/defaults/shipped-history.json +38 -2
- package/src/defaults/skills/browser-use/SKILL.md +4 -1
- package/src/defaults/skills/code-tidy/SKILL.md +71 -102
- package/src/defaults/skills/code-tidy/references/agent-cleanup.md +66 -114
- package/src/defaults/skills/code-tidy/references/cleanup-report.md +19 -35
- package/src/defaults/skills/code-tidy/references/dead-code.md +3 -9
- package/src/defaults/skills/code-tidy/references/test-suite-slop.md +16 -27
- package/src/defaults/skills/computer-use/SKILL.md +12 -2
- package/src/defaults/skills/setup/SKILL.md +2 -2
- package/src/defaults/skills/setup/references/actions.md +28 -9
- package/src/defaults/skills/setup/references/surfaces.md +5 -2
- package/src/defaults/skills/setup-guide/SKILL.md +11 -3
- package/src/defaults/skills/setup-guide/model-recommendations.json +13 -0
- package/src/defaults/skills/skill-creator/SKILL.md +85 -190
- package/src/defaults/skills/skill-creator/references/design.md +70 -162
- package/src/defaults/skills/skill-creator/references/evaluation.md +33 -92
- package/src/defaults/skills/test-suite/SKILL.md +194 -0
- package/src/defaults/skills/test-suite/references/case-management.md +51 -0
- package/src/defaults/skills/test-suite/references/flaky-policy.md +39 -0
- package/src/defaults/skills/test-suite/references/reliability.md +49 -0
- package/src/defaults/skills/test-suite/references/suite-design.md +78 -0
- package/src/defaults/skills/test-suite/scripts/inventory.mjs +335 -0
- package/src/defaults/skills/visualize/SKILL.md +6 -2
- package/src/lib/instruction-text.cjs +36 -0
- package/src/lib/rules-builder.cjs +40 -50
- package/src/orchestration/balanced.md +23 -0
- package/src/orchestration/focused.md +22 -0
- package/src/orchestration/none.md +5 -0
- package/src/orchestration/swarm.md +24 -0
- package/src/rules/agent-mode/AGENT_MODE.md +3 -1
- package/src/rules/agent-mode/MIXY.md +2 -2
- package/src/rules/lead/LEAD.md +3 -3
- package/src/rules/shared/20-research.md +3 -6
- package/src/rules/shared/40-editing.md +1 -0
- package/src/rules/shared/60-verification.md +10 -9
- package/src/rules/shared/70-delivery.md +4 -3
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/abort-link.mjs +5 -1
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/session-prep.mjs +3 -3
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +10 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +132 -21
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +13 -9
- package/src/runtime/agent/orchestrator/agent-runtime/heartbeat-completion.mjs +2 -2
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +4 -1
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +67 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +1 -0
- package/src/runtime/agent/orchestrator/config.mjs +12 -7
- package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +7 -7
- package/src/runtime/agent/orchestrator/context/role-instructions.mjs +9 -30
- package/src/runtime/agent/orchestrator/internal-agents.mjs +3 -14
- package/src/runtime/agent/orchestrator/mcp/agent-connectors.mjs +124 -12
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +204 -49
- package/src/runtime/agent/orchestrator/mcp/client-config.mjs +16 -7
- package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +157 -57
- package/src/runtime/agent/orchestrator/mcp/client.mjs +54 -23
- package/src/runtime/agent/orchestrator/mcp/oauth-client.mjs +1 -1
- package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +8 -2
- package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +83 -52
- package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +1 -1
- package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +14 -0
- package/src/runtime/agent/orchestrator/mcp/oauth.mjs +43 -27
- package/src/runtime/agent/orchestrator/providers/account-pool.mjs +43 -8
- package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +30 -28
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +31 -21
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/content-block-stop.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth-login.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +40 -0
- package/src/runtime/agent/orchestrator/providers/custom-provider-keys.mjs +34 -0
- package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/devin-auth.mjs +14 -7
- package/src/runtime/agent/orchestrator/providers/devin-wire-transport.mjs +3 -2
- package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +48 -31
- package/src/runtime/agent/orchestrator/providers/lib/oauth-page.mjs +0 -18
- package/src/runtime/agent/orchestrator/providers/lib/oauth-token-utils.mjs +138 -6
- package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/model-cache.mjs +100 -33
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/oauth-secret-fields.fixtures.mjs +45 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +4 -6
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-head-parity.json +2784 -2872
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +5 -8
- package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +26 -28
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload-parity.fixture.json +288 -300
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream/event-switch.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-watchdogs.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/opencode-go-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/registry.mjs +33 -2
- package/src/runtime/agent/orchestrator/providers/vertex-ai-auth.mjs +622 -0
- package/src/runtime/agent/orchestrator/providers/vertex-ai-maas.mjs +185 -0
- package/src/runtime/agent/orchestrator/providers/vertex-ai-models.mjs +445 -0
- package/src/runtime/agent/orchestrator/providers/vertex-ai.mjs +576 -0
- package/src/runtime/agent/orchestrator/providers/xai-tool-search-head-parity.json +10 -30
- package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +32 -0
- package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +0 -11
- package/src/runtime/agent/orchestrator/runtime-core/deferred-tool-availability.mjs +8 -0
- package/src/runtime/agent/orchestrator/runtime-core/deferred-tool-delta.mjs +17 -3
- package/src/runtime/agent/orchestrator/runtime-core/goal-control.mjs +47 -27
- package/src/runtime/agent/orchestrator/runtime-core/goal-deadlines.mjs +141 -10
- package/src/runtime/agent/orchestrator/runtime-core/goal-mutations.mjs +26 -63
- package/src/runtime/agent/orchestrator/runtime-core/goal-reminder.mjs +28 -3
- package/src/runtime/agent/orchestrator/runtime-core/goal-runtime.mjs +36 -11
- package/src/runtime/agent/orchestrator/runtime-core/goal-state.mjs +88 -56
- package/src/runtime/agent/orchestrator/runtime-core/goal-storage.mjs +4 -1
- package/src/runtime/agent/orchestrator/runtime-core/goal-store.mjs +51 -32
- package/src/runtime/agent/orchestrator/runtime-core/goal-text.mjs +45 -145
- package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +15 -98
- package/src/runtime/agent/orchestrator/runtime-core/goal-tool-exec.mjs +32 -99
- package/src/runtime/agent/orchestrator/runtime-core/goal-turns.mjs +23 -78
- package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +69 -47
- package/src/runtime/agent/orchestrator/runtime-core/todo-list.mjs +367 -0
- package/src/runtime/agent/orchestrator/runtime-core/todo-tool-defs.mjs +47 -0
- package/src/runtime/agent/orchestrator/runtime-core/tool-availability.mjs +155 -0
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +12 -0
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +119 -35
- package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +4 -1
- package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +15 -6
- package/src/runtime/agent/orchestrator/runtime-core/workflow-ids.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -22
- package/src/runtime/agent/orchestrator/session/agent-pool-progress.mjs +30 -0
- package/src/runtime/agent/orchestrator/session/agent-room.d.mts +30 -5
- package/src/runtime/agent/orchestrator/session/agent-room.mjs +171 -37
- package/src/runtime/agent/orchestrator/session/attachment-preflight.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/cache-warm.mjs +94 -10
- package/src/runtime/agent/orchestrator/session/catalog-db/index.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/compact/summary-route.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +8 -12
- package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +11 -6
- package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +6 -6
- package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +7 -3
- package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/human-gate/held-message.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/human-gate/requests.mjs +37 -8
- package/src/runtime/agent/orchestrator/session/human-gate/run-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/image-strip-recovery.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +5 -3
- package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +8 -1
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/todo-check.mjs +196 -0
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +116 -50
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +8 -1
- package/src/runtime/agent/orchestrator/session/loop/provider-tool-exclusions.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/route-fallback.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +5 -6
- package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +27 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-exec/dispatch.mjs +18 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +23 -1
- package/src/runtime/agent/orchestrator/session/loop/turn-limits.mjs +13 -8
- package/src/runtime/agent/orchestrator/session/loop/turn-report.mjs +35 -0
- package/src/runtime/agent/orchestrator/session/loop/usage.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +103 -50
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +9 -7
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +21 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-failure.mjs +27 -11
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-open.mjs +37 -34
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-prepare.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +25 -15
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-state.mjs +79 -24
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-steering-drain.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +6 -1
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/manager/pending-spool-path.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/manager/pending-spool-shard.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/prompt-settings.mjs +598 -0
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +22 -14
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/session-fallback-title.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +56 -14
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +31 -0
- package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +59 -20
- package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/session-scratch-environment.mjs +70 -0
- package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +0 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +18 -4
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-context.mjs +8 -9
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +339 -88
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-recovery.mjs +135 -11
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +377 -104
- package/src/runtime/agent/orchestrator/session/manager/turn-durability.mjs +144 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +48 -30
- package/src/runtime/agent/orchestrator/session/manager/turn-reap.mjs +125 -0
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/mixy-name.mjs +12 -6
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +8 -1
- package/src/runtime/agent/orchestrator/session/restart-prefix-parity.fixture.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/store/canonical-reader.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/store/liveness-veto.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +23 -2
- package/src/runtime/agent/orchestrator/session/store/sweep/sub-agent-retention.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/store/transcript-durability.mjs +88 -0
- package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/store-summary-log.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +243 -38
- package/src/runtime/agent/orchestrator/session/store-transcript-cache.mjs +163 -64
- package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +177 -8
- package/src/runtime/agent/orchestrator/session/store-transcript-worker.mjs +142 -18
- package/src/runtime/agent/orchestrator/session/store.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/synthetic-user-envelope.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/tail-reminder-rules.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/transcript-restore/agent-envelope.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +2 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/backgrounded-result.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/completed-result.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/misuse-guard.mjs +21 -3
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/result-format.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/worktree-add-guard.mjs +77 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +0 -1
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +42 -28
- package/src/runtime/agent/orchestrator/tools/builtin/git-worktree-tool.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +58 -20
- package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-fast-paths.mjs +0 -1
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +20 -15
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +0 -10
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +95 -20
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +59 -10
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -24
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary/child-kill.mjs +4 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +1 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/modes/calls.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/modes/symbols.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +4 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +3 -4
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4 -22
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +13 -13
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +99 -2
- package/src/runtime/agent/orchestrator/tools/lib/owned-shell-trees.mjs +2 -0
- package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +105 -15
- package/src/runtime/agent/orchestrator/tools/lib/shell-run-background/promote-job.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/lib/shell-run-settle.mjs +49 -15
- package/src/runtime/agent/orchestrator/tools/lib/shell-run-spawn.mjs +45 -27
- package/src/runtime/agent/orchestrator/tools/lib/shell-run-state.mjs +35 -11
- package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +2 -0
- package/src/runtime/agent/orchestrator/tools/lib/wait-until-fixture.mjs +26 -0
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -6
- package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +0 -1
- package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +13 -13
- package/src/runtime/attachments/attachment-reference.mjs +1 -1
- package/src/runtime/attachments/pdf-extract.mjs +1 -1
- package/src/runtime/attachments/store.d.mts +1 -0
- package/src/runtime/attachments/store.mjs +2 -2
- package/src/runtime/channels/lib/config-reload-watch.mjs +42 -0
- package/src/runtime/channels/lib/owned-runtime/config-reload.mjs +29 -9
- package/src/runtime/channels/lib/scheduler.mjs +33 -0
- package/src/runtime/channels/lib/schedules-db.mjs +11 -4
- package/src/runtime/channels/lib/webhook/session-dispatch.mjs +4 -1
- package/src/runtime/channels/lib/webhooks-db.mjs +9 -3
- package/src/runtime/channels/lib/whisper-port.mjs +19 -10
- package/src/runtime/channels/lib/whisper-server.mjs +22 -13
- package/src/runtime/channels/lib/worker-main.mjs +9 -31
- package/src/runtime/computer-bridge/linux-readiness.mjs +10 -14
- package/src/runtime/connectors/connector-accounts-mcp.mjs +372 -103
- package/src/runtime/connectors/gmail-app-password.mjs +4 -4
- package/src/runtime/connectors/google-mcp.mjs +1 -1
- package/src/runtime/effort-judge/judge-client.mjs +2 -2
- package/src/runtime/effort-judge/judge-input.mjs +4 -4
- package/src/runtime/explore/explorer.mjs +88 -0
- package/src/runtime/explore/tool-defs.mjs +35 -0
- package/src/runtime/explore/tool.mjs +123 -0
- package/src/runtime/local-provider/hardware.mjs +1 -1
- package/src/runtime/media/adapters/codex-image.mjs +3 -3
- package/src/runtime/media/adapters/gemini-video.mjs +1 -1
- package/src/runtime/media/media-usage.mjs +1 -1
- package/src/runtime/media/store.mjs +1 -1
- package/src/runtime/memory/index.mjs +15 -1
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -1
- package/src/runtime/memory/lib/embedding-reindex.mjs +27 -0
- package/src/runtime/memory/lib/embedding-worker.mjs +4 -0
- package/src/runtime/memory/lib/memory-embed.mjs +83 -14
- package/src/runtime/memory/lib/memory-process-lock.mjs +159 -36
- package/src/runtime/memory/lib/memory-text-utils.mjs +6 -1
- package/src/runtime/memory/lib/memory.mjs +17 -0
- package/src/runtime/memory/lib/pg/process.mjs +1 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +16 -96
- package/src/runtime/memory/lib/recall-hybrid/hybrid-sql.mjs +12 -3
- package/src/runtime/office/authoring/docx-html-runner.mjs +1 -1
- package/src/runtime/office/authoring/pptx-author-action.mjs +1 -1
- package/src/runtime/office/authoring/xlsx-html-build.mjs +1 -1
- package/src/runtime/office/authoring/xlsx-html-runner.mjs +1 -1
- package/src/runtime/office/capabilities.mjs +1 -1
- package/src/runtime/office/core/office-core.mjs +1 -1
- package/src/runtime/office/design/content-model.mjs +3 -3
- package/src/runtime/office/design/docx/design-docx/section.mjs +6 -6
- package/src/runtime/office/design/xlsx/design-xlsx-components.mjs +1 -1
- package/src/runtime/office/design/xlsx/design-xlsx.mjs +1 -1
- package/src/runtime/office/pdf/ocr-engine.mjs +1 -1
- package/src/runtime/office/pdf/pdf-writer.mjs +4 -4
- package/src/runtime/office/portable/portable-docx-operations.mjs +2 -2
- package/src/runtime/office/portable/portable-soffice.mjs +8 -6
- package/src/runtime/office/portable/xlsx-contract.mjs +1 -1
- package/src/runtime/office/quality/assurance-structure-docx.mjs +1 -1
- package/src/runtime/shared/agent-route-config.mjs +17 -2
- package/src/runtime/shared/atomic-file.d.mts +8 -0
- package/src/runtime/shared/atomic-file.mjs +103 -2
- package/src/runtime/shared/background-tasks.mjs +37 -10
- package/src/runtime/shared/background-wait.mjs +66 -0
- package/src/runtime/shared/child-guardian.mjs +17 -7
- package/src/runtime/shared/config.mjs +313 -16
- package/src/runtime/shared/connector-accounts.mjs +5 -5
- package/src/runtime/shared/connector-catalog.d.mts +14 -8
- package/src/runtime/shared/connector-catalog.mjs +70 -52
- package/src/runtime/shared/custom-provider-keys.mjs +20 -0
- package/src/runtime/shared/file-has-data.mjs +26 -2
- package/src/runtime/shared/file-lock.mjs +21 -18
- package/src/runtime/shared/git-launch.d.mts +9 -0
- package/src/runtime/shared/git-launch.mjs +213 -0
- package/src/runtime/shared/git-runner-worker.mjs +2 -1
- package/src/runtime/shared/git-runner.mjs +7 -2
- package/src/runtime/shared/idle-gc.mjs +30 -7
- package/src/runtime/shared/llm/cost.mjs +6 -1
- package/src/runtime/shared/llm/http-agent.mjs +316 -29
- package/src/runtime/shared/llm/model-catalog-projection.mjs +3 -2
- package/src/runtime/shared/llm/model-catalog.mjs +46 -6
- package/src/runtime/shared/llm/provider-catalog-registry.mjs +3 -0
- package/src/runtime/shared/llm/system-proxy.mjs +453 -0
- package/src/runtime/shared/llm/usage-ledger-quota.mjs +31 -12
- package/src/runtime/shared/llm/usage-ledger-rollup.mjs +84 -8
- package/src/runtime/shared/llm/usage-ledger.mjs +3 -0
- package/src/runtime/shared/llm/usage-rollup.mjs +48 -0
- package/src/runtime/shared/maintenance-schedule.mjs +109 -0
- package/src/runtime/shared/process-runner.mjs +8 -4
- package/src/runtime/shared/provider-accounts.mjs +52 -2
- package/src/runtime/shared/routine-trigger.d.mts +13 -0
- package/src/runtime/shared/routine-trigger.mjs +34 -0
- package/src/runtime/shared/run-child.d.mts +2 -1
- package/src/runtime/shared/run-child.mjs +70 -18
- package/src/runtime/shared/session-scratch.mjs +14 -2
- package/src/runtime/shared/shipped-definitions.mjs +7 -1
- package/src/runtime/shared/staged-install-worker.mjs +5 -0
- package/src/runtime/shared/staged-update.mjs +47 -2
- package/src/runtime/shared/tool-card-model.golden.json +3 -12
- package/src/runtime/shared/tool-execution-contract.mjs +2 -0
- package/src/runtime/shared/tool-surface.mjs +110 -36
- package/src/runtime/shared/tool-work-units.mjs +35 -6
- package/src/runtime/shared/turn-snapshot.mjs +26 -4
- package/src/runtime/shared/turn-worktree-snapshot.mjs +409 -54
- package/src/runtime/shared/worker-requests.mjs +17 -3
- package/src/runtime/shared/worktrees/activity-gate.mjs +215 -318
- package/src/runtime/shared/worktrees/activity-ledger-worker.mjs +56 -0
- package/src/runtime/shared/worktrees/activity-ledger.mjs +391 -0
- package/src/runtime/shared/worktrees/branch-management.mjs +5 -3
- package/src/runtime/shared/worktrees/contract.mjs +16 -6
- package/src/runtime/shared/worktrees/dependencies.mjs +127 -89
- package/src/runtime/shared/worktrees/disk.mjs +79 -2
- package/src/runtime/shared/worktrees/git.mjs +97 -7
- package/src/runtime/shared/worktrees/include.mjs +6 -4
- package/src/runtime/shared/worktrees/maintenance.mjs +422 -30
- package/src/runtime/shared/worktrees/manager.mjs +962 -197
- package/src/runtime/shared/worktrees/settings.mjs +33 -7
- package/src/runtime/shared/worktrees/test-support.mjs +5 -1
- package/src/runtime/shared/worktrees/worker-snapshots.mjs +82 -0
- package/src/runtime/tidy/install.mjs +95 -15
- package/src/runtime/tidy/languages.mjs +17 -15
- package/src/runtime/tidy/process.mjs +9 -5
- package/src/runtime/tidy/resolve.mjs +19 -16
- package/src/runtime/tidy/runners/biome.mjs +3 -3
- package/src/runtime/tidy/tool.mjs +1 -1
- package/src/runtime/tidy/worktree.mjs +10 -6
- package/src/runtime/visualize-bridge/client.mjs +4 -1
- package/src/runtime/visualize-bridge/tool-defs.mjs +7 -2
- package/src/runtime/web-search/index.mjs +4 -23
- package/src/runtime/web-search/lib/web-tools.mjs +2 -2
- package/src/runtime/web-search/tool-defs.mjs +17 -26
- package/src/session-runtime/agent-profile-changes.mjs +55 -4
- package/src/session-runtime/agent-task-sessions.mjs +410 -353
- package/src/session-runtime/boot/apis.mjs +27 -4
- package/src/session-runtime/boot/begin.mjs +2 -0
- package/src/session-runtime/boot/config.mjs +10 -1
- package/src/session-runtime/boot/facade.mjs +1 -0
- package/src/session-runtime/boot/providers.mjs +70 -3
- package/src/session-runtime/boot/session.mjs +2 -0
- package/src/session-runtime/boot/tools.mjs +71 -25
- package/src/session-runtime/bot-status.mjs +103 -103
- package/src/session-runtime/channel-config-api.mjs +1 -0
- package/src/session-runtime/config-change-effects.mjs +179 -0
- package/src/session-runtime/config-lifecycle/config-writers.mjs +1 -0
- package/src/session-runtime/config-lifecycle.mjs +160 -24
- package/src/session-runtime/connector-merge-migration.mjs +18 -15
- package/src/session-runtime/conversation-import/discovery.mjs +245 -47
- package/src/session-runtime/conversation-import/service.mjs +12 -5
- package/src/session-runtime/goal-facade-api.mjs +49 -3
- package/src/session-runtime/handoff-attachments.mjs +1 -1
- package/src/session-runtime/held-requests.mjs +3 -1
- package/src/session-runtime/internal-tool-executor/ask-user-tool.mjs +3 -0
- package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +3 -2
- package/src/session-runtime/internal-tool-executor/feature-tools.mjs +5 -11
- package/src/session-runtime/internal-tool-executor/knowledge-tools.mjs +8 -0
- package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +161 -120
- package/src/session-runtime/internal-tool-executor.mjs +12 -19
- package/src/session-runtime/lifecycle/cold-heir.mjs +7 -0
- package/src/session-runtime/lifecycle/memory-ingest.mjs +3 -3
- package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +53 -5
- package/src/session-runtime/lifecycle/session-switch.mjs +13 -2
- package/src/session-runtime/lifecycle/teardown/session-close.mjs +10 -5
- package/src/session-runtime/lifecycle/teardown.mjs +1 -1
- package/src/session-runtime/lifecycle-api.mjs +1 -0
- package/src/session-runtime/mcp-glue/server-catalog.mjs +27 -10
- package/src/session-runtime/mcp-glue/server-connection.mjs +34 -8
- package/src/session-runtime/mcp-glue/server-input.mjs +4 -1
- package/src/session-runtime/mixy-heartbeat.mjs +1 -1
- package/src/session-runtime/model-route/set-route.mjs +40 -7
- package/src/session-runtime/native-web-search/routes.mjs +3 -3
- package/src/session-runtime/provider-auth/login.mjs +2 -2
- package/src/session-runtime/provider-auth/refresh.mjs +6 -3
- package/src/session-runtime/provider-auth/setup.mjs +1 -1
- package/src/session-runtime/provider-readiness.mjs +53 -4
- package/src/session-runtime/resource-api.mjs +1 -1
- package/src/session-runtime/resource-extension-sync.mjs +4 -8
- package/src/session-runtime/resource-mcp-api.mjs +98 -35
- package/src/session-runtime/resource-mcp-connector-accounts.mjs +49 -32
- package/src/session-runtime/resource-mcp-secrets.mjs +27 -10
- package/src/session-runtime/resource-mcp-servers.mjs +5 -4
- package/src/session-runtime/resource-plugins-api.mjs +4 -0
- package/src/session-runtime/routine-executor.mjs +152 -0
- package/src/session-runtime/routine-home-turn.mjs +46 -5
- package/src/session-runtime/runtime-facade.mjs +5 -0
- package/src/session-runtime/runtime-feature-gates.mjs +2 -11
- package/src/session-runtime/schedule-session-run.mjs +1 -0
- package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +6 -1
- package/src/session-runtime/services/agent-tool/job-views/session-progress.mjs +7 -0
- package/src/session-runtime/services/agent-tool/send-flow.mjs +120 -8
- package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +12 -0
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +5 -0
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +8 -3
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +28 -7
- package/src/session-runtime/services/agent-tool.mjs +51 -1
- package/src/session-runtime/services/agent-watchdog-registry.mjs +32 -4
- package/src/session-runtime/services/channel-admin.mjs +18 -0
- package/src/session-runtime/services/channel-worker/daemon-spawn.mjs +2 -0
- package/src/session-runtime/services/daemon-candidate.mjs +4 -1
- package/src/session-runtime/services/hook-bus/command-handler.mjs +7 -11
- package/src/session-runtime/services/memory-runtime-proxy/daemon-spawn.mjs +4 -0
- package/src/session-runtime/services/plugin-admin.mjs +14 -6
- package/src/session-runtime/services/provider-admin.mjs +281 -10
- package/src/session-runtime/services/seeds.mjs +7 -0
- package/src/session-runtime/services/usage-stats-model.mjs +32 -11
- package/src/session-runtime/session-create/materialize.mjs +4 -2
- package/src/session-runtime/session-create/session-options.mjs +2 -0
- package/src/session-runtime/session-turn-api.mjs +2 -0
- package/src/session-runtime/settings-builtin-tools-api.mjs +5 -10
- package/src/session-runtime/settings-system-api.mjs +1 -1
- package/src/session-runtime/setup-tool/agent-settings-actions.mjs +13 -3
- package/src/session-runtime/setup-tool/connector-widen-approval.mjs +96 -0
- package/src/session-runtime/setup-tool/executor.mjs +14 -3
- package/src/session-runtime/setup-tool/extended-actions.mjs +2 -0
- package/src/session-runtime/setup-tool/settings-contract.mjs +28 -0
- package/src/session-runtime/setup-tool/tool-defs.mjs +28 -2
- package/src/session-runtime/tool-policy/agent-profile-fold.mjs +40 -43
- package/src/session-runtime/tool-policy/mode-switch.mjs +1 -1
- package/src/session-runtime/tool-policy/policy-stage.mjs +106 -8
- package/src/session-runtime/tool-policy/workflow-overlay.mjs +2 -1
- package/src/session-runtime/tool-policy-refresh.mjs +182 -5
- package/src/session-runtime/tool-surface.mjs +38 -1
- package/src/session-runtime/turn/agent-ops.mjs +33 -2
- package/src/session-runtime/turn/session-ops.mjs +2 -0
- package/src/session-runtime/turn/turn-run/turn-prepare.mjs +5 -0
- package/src/session-runtime/turn/turn-run.mjs +1 -1
- package/src/session-runtime/webhook-session-run.mjs +2 -0
- package/src/session-runtime/workflow-agents-api/agent-connectors.mjs +46 -11
- package/src/session-runtime/workflow-agents-api/agent-route.mjs +2 -0
- package/src/session-runtime/workflow-agents-api/instruction-files.mjs +165 -0
- package/src/session-runtime/workflow-agents-api/style-and-mode.mjs +5 -4
- package/src/session-runtime/workflow-agents-api/workflow-packs.mjs +2 -3
- package/src/session-runtime/workflow-agents-api.mjs +2 -0
- package/src/session-runtime/workspace/activity-guards.mjs +176 -0
- package/src/session-runtime/workspace/project-branches.mjs +1 -1
- package/src/session-runtime/workspace/session-view.mjs +149 -0
- package/src/session-runtime/workspace/side-checkouts.mjs +203 -0
- package/src/session-runtime/workspace/transition-runner.mjs +188 -0
- package/src/session-runtime/workspace/transitions.mjs +128 -1070
- package/src/session-runtime/workspace/workspace-merge.mjs +132 -0
- package/src/session-runtime/workspace/worktree-intents.mjs +242 -0
- package/src/session-runtime/workspace/worktree-maintenance.mjs +200 -0
- package/src/session-runtime/workspace/worktree-setup.mjs +97 -0
- package/src/session-runtime/workspace/worktree-tool.mjs +70 -54
- package/src/standalone/daemon-agent-control.mjs +25 -8
- package/src/standalone/daemon-desktop-runtime.mjs +8 -2
- package/src/standalone/daemon-lag-profiler.mjs +7 -7
- package/src/standalone/daemon-session-control.mjs +151 -136
- package/src/standalone/daemon.mjs +61 -2
- package/src/standalone/desktop-service-registry.mjs +9 -0
- package/src/standalone/local-session-runtime.mjs +21 -1
- package/src/standalone/session-client/daemon-unavailable.mjs +4 -1
- package/src/standalone/session-client.mjs +5 -1
- package/src/standalone/session-frame-stream.mjs +83 -11
- package/src/standalone/session-protocol.mjs +15 -0
- package/src/standalone/session-service/agent-route-follow.mjs +23 -20
- package/src/standalone/session-service/agent-tree/agent-turns.mjs +18 -6
- package/src/standalone/session-service/agent-tree.mjs +45 -9
- package/src/standalone/session-service/projection/transcript-window.mjs +700 -64
- package/src/standalone/session-service/quota-resume.mjs +9 -0
- package/src/standalone/session-service/session-calls/session-action.mjs +51 -1
- package/src/standalone/session-service/session-calls/session-turns.mjs +37 -18
- package/src/standalone/session-service/session-calls/session-views.mjs +160 -104
- package/src/standalone/session-service/session-calls/turn-gate.mjs +34 -0
- package/src/standalone/session-service/session-calls.mjs +2 -1
- package/src/standalone/session-service/stored-reader.mjs +127 -40
- package/src/tui/App.jsx +162 -164
- package/src/tui/app/app-view/floating-panel.jsx +3 -0
- package/src/tui/app/app-view/text-entry-labels.mjs +1 -0
- package/src/tui/app/clipboard.mjs +22 -11
- package/src/tui/app/create-app-pickers.mjs +2 -0
- package/src/tui/app/doctor.mjs +2 -2
- package/src/tui/app/panel-signature.mjs +1 -1
- package/src/tui/app/prompt-handlers/use-prompt-history-nav.mjs +1 -1
- package/src/tui/app/prompt-handlers/use-prompt-interrupt.mjs +3 -0
- package/src/tui/app/prompt-submit/provider-prompts.mjs +3 -1
- package/src/tui/app/provider-setup/api-key-actions.mjs +31 -1
- package/src/tui/app/provider-setup/oauth-actions.mjs +25 -6
- package/src/tui/app/provider-setup-picker.mjs +20 -13
- package/src/tui/app/settings-picker.mjs +53 -8
- package/src/tui/app/shell-layout/row-budgets.mjs +14 -12
- package/src/tui/app/slash-commands.mjs +2 -1
- package/src/tui/app/slash-dispatch/route-commands.mjs +1 -1
- package/src/tui/app/slash-dispatch/session-commands.mjs +47 -1
- package/src/tui/app/slash-dispatch.mjs +1 -1
- package/src/tui/app/use-btw-panel.mjs +29 -0
- package/src/tui/app/use-context-panel-refresh.mjs +31 -0
- package/src/tui/app/use-model-catalog-cache.mjs +13 -10
- package/src/tui/app/use-mouse-input.mjs +11 -0
- package/src/tui/app/use-onboarding-start.mjs +8 -3
- package/src/tui/app/use-panel-transition.mjs +10 -1
- package/src/tui/app/use-pasted-buffers.mjs +28 -20
- package/src/tui/app/use-prompt-draft-flow.mjs +7 -0
- package/src/tui/app/use-prompt-layout-rows.mjs +3 -3
- package/src/tui/app/use-prompt-queue-history.mjs +5 -4
- package/src/tui/app/use-selection-focus.mjs +11 -2
- package/src/tui/app/use-selection-geometry.mjs +18 -9
- package/src/tui/app/use-selection-paint.mjs +4 -4
- package/src/tui/app/use-selection-stitch.mjs +3 -3
- package/src/tui/app/use-slash-palette.mjs +62 -0
- package/src/tui/app/use-terminal-chrome.mjs +2 -2
- package/src/tui/app/use-transcript-activity.mjs +48 -33
- package/src/tui/app/use-transcript-follow.mjs +12 -4
- package/src/tui/app/use-transcript-scroll.mjs +46 -17
- package/src/tui/app/use-transcript-swap-reset.mjs +27 -0
- package/src/tui/app/use-transcript-window/geometry.mjs +20 -6
- package/src/tui/app/use-transcript-window/instance-state.mjs +4 -0
- package/src/tui/app/use-transcript-window/selection-shift.mjs +5 -1
- package/src/tui/app/use-transcript-window.mjs +21 -11
- package/src/tui/app/use-tui-ready.mjs +18 -0
- package/src/tui/app/use-welcome-prompt-hint.mjs +5 -0
- package/src/tui/app/use-workflow-tab-cycle.mjs +5 -3
- package/src/tui/components/Picker.jsx +27 -3
- package/src/tui/components/SignInUrl.jsx +68 -0
- package/src/tui/components/TextEntryPanel.jsx +76 -46
- package/src/tui/components/prompt-input/escape-policy.d.mts +0 -1
- package/src/tui/components/prompt-input/escape-policy.mjs +1 -1
- package/src/tui/components/question-panel-model.mjs +4 -4
- package/src/tui/components/sign-in-url-layout.mjs +24 -0
- package/src/tui/components/tool-execution/standard-card.jsx +3 -4
- package/src/tui/components/user-message-skills.mjs +1 -1
- package/src/tui/dist/index.mjs +2205 -1371
- package/src/tui/markdown/render-ansi.mjs +1 -1
- package/src/tui/session/agent-job-feed/notification-router.mjs +10 -0
- package/src/tui/session/agent-job-feed.mjs +2 -0
- package/src/tui/session/context-state.mjs +3 -0
- package/src/tui/session/goal-turn-state.mjs +6 -2
- package/src/tui/session/quota-resume.mjs +1 -1
- package/src/tui/session/session-api/controls.mjs +4 -0
- package/src/tui/session/session-api/extensions.mjs +8 -0
- package/src/tui/session/session-api/intake/abort.mjs +2 -1
- package/src/tui/session/session-api/intake.mjs +2 -0
- package/src/tui/session/session-api/integrations.mjs +2 -2
- package/src/tui/session/session-api/lifecycle/dispose.mjs +1 -0
- package/src/tui/session/session-api/lifecycle/session-reset.mjs +7 -0
- package/src/tui/session/session-api/routes-agent-definitions.mjs +6 -0
- package/src/tui/session/session-api/settings.mjs +2 -2
- package/src/tui/session/session-api/worktree.mjs +13 -1
- package/src/tui/session/session-flow/drain.mjs +11 -14
- package/src/tui/session/tool-card-results/grouped-result.mjs +2 -2
- package/src/tui/session/transcript-spill/spill-writer.mjs +15 -0
- package/src/tui/session/transcript-spill.mjs +92 -4
- package/src/tui/session/transcript-store.mjs +2 -0
- package/src/tui/session/turn-quiet.mjs +1 -1
- package/src/tui/session/turn.mjs +3 -0
- package/src/tui/session/worktree-setup-notice.mjs +1 -1
- package/src/tui/spinner-meta.mjs +3 -3
- package/src/tui/spinner-verbs.mjs +2 -2
- package/src/defaults/skills/goal-management/SKILL.md +0 -169
- package/src/defaults/skills/skill-creator/references/source-review.md +0 -94
- package/src/runtime/agent/orchestrator/runtime-core/goal-tasks.mjs +0 -156
- package/src/session-runtime/goal-tasks.mjs +0 -1
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
} from './file-recovery.mjs';
|
|
41
41
|
import { lstat, mkdir, readdir, readFile, rm, rmdir, stat } from 'node:fs/promises';
|
|
42
42
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
43
|
+
import { isPidAlive } from '../pid-liveness.mjs';
|
|
43
44
|
import {
|
|
44
45
|
managedWorktreePath,
|
|
45
46
|
managedWorktreeRoot,
|
|
@@ -58,7 +59,7 @@ import {
|
|
|
58
59
|
restoreFromRecoveryRef,
|
|
59
60
|
uncommittedChanges,
|
|
60
61
|
} from './carry.mjs';
|
|
61
|
-
import { freeBytesAt, requiredBytes, trackedCheckoutBytes } from './disk.mjs';
|
|
62
|
+
import { cachedFolderBytes, forgetFolderSize, freeBytesAt, requiredBytes, trackedCheckoutBytes } from './disk.mjs';
|
|
62
63
|
import {
|
|
63
64
|
branchCommit,
|
|
64
65
|
currentBranch,
|
|
@@ -67,7 +68,9 @@ import {
|
|
|
67
68
|
inspectRepo,
|
|
68
69
|
isDiskFullError,
|
|
69
70
|
listWorktrees,
|
|
71
|
+
localBranchCommits,
|
|
70
72
|
localBranches,
|
|
73
|
+
parseStatusEntries,
|
|
71
74
|
parseStatusPaths,
|
|
72
75
|
remoteDefaultBranches,
|
|
73
76
|
runGit,
|
|
@@ -84,9 +87,9 @@ import {
|
|
|
84
87
|
removeIncludeLinks,
|
|
85
88
|
restoreIncludeLinks,
|
|
86
89
|
} from './include.mjs';
|
|
87
|
-
import {
|
|
90
|
+
import { dependencyArtefacts, linkedArtefacts } from './dependencies.mjs';
|
|
88
91
|
import { withRepoLock as withRepoMutationLock } from './lock.mjs';
|
|
89
|
-
import {
|
|
92
|
+
import { holdWorkspaceActivityAsync } from './activity-gate.mjs';
|
|
90
93
|
import { withAdvisoryLocks } from '../../agent/orchestrator/tools/builtin/advisory-lock.mjs';
|
|
91
94
|
import { mergeBranchIntoCheckout, operationInProgress } from './merge.mjs';
|
|
92
95
|
import { readOwnerLease, removeOwnerLease, sameOwner, writeOwnerLease } from './owner.mjs';
|
|
@@ -124,25 +127,23 @@ function sessionRootOf(record) {
|
|
|
124
127
|
// ---------------------------------------------------------------------------
|
|
125
128
|
// Host configuration (settings source, free-space probe); tests override it.
|
|
126
129
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const DEFAULT_CONFIG = Object.freeze({ settings: null, freeBytes: freeBytesAt, processAlive });
|
|
130
|
+
const DEFAULT_CONFIG = Object.freeze({
|
|
131
|
+
settings: null,
|
|
132
|
+
freeBytes: freeBytesAt,
|
|
133
|
+
processAlive: isPidAlive,
|
|
134
|
+
// The automatic cleanup's one deleting step (removeCheckoutVerified).
|
|
135
|
+
gitWorktreeRemove: (cwd, path) => runGit(cwd, ['worktree', 'remove', '--force', path], { allowFailure: true }),
|
|
136
|
+
});
|
|
138
137
|
let config = { ...DEFAULT_CONFIG };
|
|
139
138
|
|
|
140
139
|
/**
|
|
141
140
|
* Override host hooks. `settings`: stored worktree settings object or a
|
|
142
141
|
* function returning it (default: agent config `worktree`). `freeBytes(dir)`:
|
|
143
|
-
* free-space probe. `processAlive(pid)`: lease holder liveness.
|
|
144
|
-
*
|
|
145
|
-
*
|
|
142
|
+
* free-space probe. `processAlive(pid)`: lease holder liveness.
|
|
143
|
+
* `gitWorktreeRemove(cwd, path)`: the automatic cleanup's `git worktree
|
|
144
|
+
* remove --force` ({ code, stderr }). Returns a restore function.
|
|
145
|
+
* @param {{ settings?: unknown, freeBytes?: (dir: string) => Promise<number>, processAlive?: (pid: number) => boolean,
|
|
146
|
+
* gitWorktreeRemove?: (cwd: string, path: string) => Promise<{ code: number, stderr: string }> }} overrides
|
|
146
147
|
*/
|
|
147
148
|
export function configureWorktreeManager(overrides = {}) {
|
|
148
149
|
const previous = config;
|
|
@@ -300,16 +301,22 @@ async function keepRemovedTip(record) {
|
|
|
300
301
|
return { tip, recoveryRef: ref };
|
|
301
302
|
}
|
|
302
303
|
|
|
303
|
-
/**
|
|
304
|
-
|
|
305
|
-
|
|
304
|
+
/**
|
|
305
|
+
* Repository-wide `git worktree prune`: every registration whose folder is
|
|
306
|
+
* gone (managed or not — a deleted scratch checkout, a crashed tool's) is
|
|
307
|
+
* dropped; git keeps locked ones. Only bookkeeping: no folder is touched.
|
|
308
|
+
* @returns {Promise<string[]>} the checkout paths whose registration went
|
|
309
|
+
*/
|
|
310
|
+
async function pruneStaleRegistrations(repoCommonDir) {
|
|
306
311
|
const stale = [];
|
|
307
|
-
for (const
|
|
308
|
-
if (
|
|
309
|
-
const rel = relative(pathKey(root), pathKey(worktreePath));
|
|
310
|
-
if (rel && !rel.startsWith('..') && !rel.includes(sep)) stale.push(worktreePath);
|
|
312
|
+
for (const registration of await adminRegistrations(repoCommonDir)) {
|
|
313
|
+
if (await isGone(registration.worktreePath)) stale.push(registration);
|
|
311
314
|
}
|
|
312
|
-
return
|
|
315
|
+
if (!stale.length) return [];
|
|
316
|
+
await runGit(repoCommonDir, ['--git-dir', repoCommonDir, 'worktree', 'prune'], { allowFailure: true });
|
|
317
|
+
return stale
|
|
318
|
+
.filter((registration) => !existsSync(registration.admin))
|
|
319
|
+
.map((registration) => canonicalPath(registration.worktreePath));
|
|
313
320
|
}
|
|
314
321
|
|
|
315
322
|
async function setResource(record, name, status) {
|
|
@@ -335,23 +342,223 @@ async function unmergedCommitCount(record) {
|
|
|
335
342
|
const reference = (record.mergeTarget && (await branchCommit(cwd, record.mergeTarget))) || record.baseCommit;
|
|
336
343
|
if (!reference) return 0;
|
|
337
344
|
const result = await runGit(cwd, ['rev-list', '--count', tip, '--not', reference]);
|
|
338
|
-
|
|
345
|
+
const count = Number(firstLine(result.stdout)) || 0;
|
|
346
|
+
if (count === 0 || (await changesContainedIn(cwd, tip, reference))) return 0;
|
|
347
|
+
return count;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* A squash, rebase or cherry-pick merge leaves the branch's commits
|
|
352
|
+
* unreachable from `reference` although their changes are there. Merged only
|
|
353
|
+
* when merging `tip` into `reference` would leave `reference`'s tree exactly
|
|
354
|
+
* as it is: a change the target later reverted would come back, so it is
|
|
355
|
+
* unmerged work. A patch-equivalence test alone (`git cherry`) is not enough
|
|
356
|
+
* for that reason and is not used. An unreachable merge commit of the branch's
|
|
357
|
+
* own is unmerged as well (its resolution is work of its own), and anything
|
|
358
|
+
* git cannot decide (a conflict, an old git) counts as unmerged.
|
|
359
|
+
*/
|
|
360
|
+
async function changesContainedIn(cwd, tip, reference) {
|
|
361
|
+
const merges = await runGit(cwd, ['rev-list', '--count', '--merges', tip, '--not', reference], {
|
|
362
|
+
allowFailure: true,
|
|
363
|
+
readOnly: true,
|
|
364
|
+
});
|
|
365
|
+
if (merges.code !== 0 || Number(firstLine(merges.stdout)) !== 0) return false;
|
|
366
|
+
const merged = await runGit(cwd, ['merge-tree', '--write-tree', reference, tip], { allowFailure: true });
|
|
367
|
+
if (merged.code !== 0) return false;
|
|
368
|
+
const target = await runGit(cwd, ['rev-parse', `${reference}^{tree}`], { allowFailure: true, readOnly: true });
|
|
369
|
+
return target.code === 0 && firstLine(merged.stdout) === firstLine(target.stdout);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* A cheap fingerprint of what the cleanup's slow checks (dirtyPaths,
|
|
374
|
+
* unmergedCommitCount) looked at: the checkout's HEAD, its index file (any
|
|
375
|
+
* add, commit, checkout or refresh rewrites it), the folder's own mtime, the
|
|
376
|
+
* branch tip and the merge target's tip. One git call and two stats.
|
|
377
|
+
*/
|
|
378
|
+
async function checkoutToken(record) {
|
|
379
|
+
const cwd = repoCwd(record);
|
|
380
|
+
const [paths, tip, target] = await Promise.all([
|
|
381
|
+
runGit(record.path, ['rev-parse', 'HEAD', '--git-path', 'index'], { allowFailure: true, readOnly: true }),
|
|
382
|
+
record.branch ? branchCommit(cwd, record.branch) : null,
|
|
383
|
+
record.mergeTarget ? branchCommit(cwd, record.mergeTarget) : null,
|
|
384
|
+
]);
|
|
385
|
+
const [head = '', index = ''] = paths.stdout.split(/\r?\n/);
|
|
386
|
+
const stamp = (path) =>
|
|
387
|
+
stat(path).then(
|
|
388
|
+
(info) => `${info.mtimeMs}:${info.size}`,
|
|
389
|
+
() => 'none'
|
|
390
|
+
);
|
|
391
|
+
return JSON.stringify([
|
|
392
|
+
paths.code,
|
|
393
|
+
head.trim(),
|
|
394
|
+
index ? await stamp(resolve(record.path, index.trim())) : 'none',
|
|
395
|
+
await stamp(record.path),
|
|
396
|
+
tip,
|
|
397
|
+
target,
|
|
398
|
+
]);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const DIRTY_STATUS_ARGS = ['status', '--porcelain=v1', '-z', '--untracked-files=all'];
|
|
402
|
+
// An automatic removal reads the ignored paths too; a folder an ignore
|
|
403
|
+
// pattern matches is listed once, not its contents.
|
|
404
|
+
const AUTOMATIC_STATUS_ARGS = [...DIRTY_STATUS_ARGS, '--ignored=matching'];
|
|
405
|
+
// The Cache Directory Tagging signature a CACHEDIR.TAG starts with.
|
|
406
|
+
const CACHEDIR_SIGNATURE = 'Signature: 8a477f597d28d172789f06886806bc55';
|
|
407
|
+
|
|
408
|
+
function identityPath(record) {
|
|
409
|
+
return record.projectSubdir ? `${record.projectSubdir}/${SESSION_PROJECT_ID_PATH}` : SESSION_PROJECT_ID_PATH;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** The status paths that are user work: not the session identity file, not an include copy. */
|
|
413
|
+
function userStatusPaths(record, paths) {
|
|
414
|
+
const includes = record.resources?.includes || [];
|
|
415
|
+
const identity = identityPath(record);
|
|
416
|
+
return paths.filter((path) => path !== identity && !coveredByIncludes(includes, path));
|
|
339
417
|
}
|
|
340
418
|
|
|
341
419
|
/** Uncommitted/untracked paths plus modified include copies. */
|
|
342
420
|
async function dirtyPaths(record) {
|
|
343
421
|
if (!existsSync(record.path)) return [];
|
|
422
|
+
const status = await runGit(record.path, DIRTY_STATUS_ARGS, { readOnly: true });
|
|
423
|
+
return [
|
|
424
|
+
...userStatusPaths(record, parseStatusPaths(status.stdout)),
|
|
425
|
+
...(await modifiedIncludeCopies(record.resources?.includes)),
|
|
426
|
+
];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** AUTOMATIC_STATUS_ARGS output as the changed paths and the ignored entries. */
|
|
430
|
+
function splitIgnored(stdout) {
|
|
431
|
+
const entries = parseStatusEntries(stdout);
|
|
432
|
+
return {
|
|
433
|
+
changed: entries.filter((entry) => entry.code !== '!!').map((entry) => entry.path),
|
|
434
|
+
ignored: entries.filter((entry) => entry.code === '!!').map((entry) => entry.path),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function cacheDirTagged(dir) {
|
|
439
|
+
try {
|
|
440
|
+
return (await readFile(join(dir, 'CACHEDIR.TAG'), 'latin1')).startsWith(CACHEDIR_SIGNATURE);
|
|
441
|
+
} catch {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Of the ignored status entries, the ones no install or rebuild restores:
|
|
448
|
+
* user data only this checkout holds (an env file, a dataset, build output).
|
|
449
|
+
* Left out as reproducible: the session identity file (or its folder holding
|
|
450
|
+
* nothing else), the recorded include items (links go with the removal, a
|
|
451
|
+
* changed copy is dirty already), the dependency artefacts a committed
|
|
452
|
+
* lockfile installs whole (`artefacts`, dependencyArtefacts: node_modules
|
|
453
|
+
* only, so hand edits inside node_modules are deliberately not protected —
|
|
454
|
+
* see the `reinstallable` flag there for why), `__pycache__`
|
|
455
|
+
* folders and folders a CACHEDIR.TAG marks as a cache.
|
|
456
|
+
*/
|
|
457
|
+
async function unreproducibleIgnored(record, entries, artefacts) {
|
|
344
458
|
const includes = record.resources?.includes || [];
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
)
|
|
354
|
-
|
|
459
|
+
const identity = identityPath(record);
|
|
460
|
+
const identityDir = identity.slice(0, identity.lastIndexOf('/'));
|
|
461
|
+
const tagged = new Map();
|
|
462
|
+
const isCache = (dir) => {
|
|
463
|
+
if (!tagged.has(dir)) tagged.set(dir, cacheDirTagged(join(record.path, ...dir.split('/'))));
|
|
464
|
+
return tagged.get(dir);
|
|
465
|
+
};
|
|
466
|
+
const kept = [];
|
|
467
|
+
for (const entry of entries) {
|
|
468
|
+
const path = entry.replace(/\/$/, '');
|
|
469
|
+
if (path === identity || coveredByIncludes(includes, path)) continue;
|
|
470
|
+
if (artefacts.some((artefact) => path === artefact || path.startsWith(`${artefact}/`))) continue;
|
|
471
|
+
if (path === identityDir) {
|
|
472
|
+
const names = await readdir(join(record.path, ...path.split('/'))).catch(() => null);
|
|
473
|
+
if (names?.length === 1 && names[0] === 'project.id') continue;
|
|
474
|
+
}
|
|
475
|
+
const parts = path.split('/');
|
|
476
|
+
const folders = entry.endsWith('/') ? parts : parts.slice(0, -1);
|
|
477
|
+
if (folders.includes('__pycache__')) continue;
|
|
478
|
+
let cache = false;
|
|
479
|
+
for (let depth = 1; depth <= folders.length && !cache; depth++) {
|
|
480
|
+
cache = await isCache(folders.slice(0, depth).join('/'));
|
|
481
|
+
}
|
|
482
|
+
if (!cache) kept.push(path);
|
|
483
|
+
}
|
|
484
|
+
return kept;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** The keep reason of a worktree holding unreproducible ignored files (`files` capped to a preview). */
|
|
488
|
+
function ignoredKeep(paths) {
|
|
489
|
+
return { reason: 'ignored', files: paths.slice(0, DIRTY_CHOICE_PREVIEW), count: paths.length };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* What keeps `record` from an automatic removal: `dirty` (dirtyPaths) and
|
|
494
|
+
* `ignored` (unreproducibleIgnored), with the `artefacts` that decided it.
|
|
495
|
+
*/
|
|
496
|
+
async function automaticKeepPaths(record) {
|
|
497
|
+
if (!existsSync(record.path)) return { dirty: [], ignored: [], artefacts: [] };
|
|
498
|
+
const [status, artefacts] = await Promise.all([
|
|
499
|
+
runGit(record.path, AUTOMATIC_STATUS_ARGS, { readOnly: true }),
|
|
500
|
+
dependencyArtefacts(record.path),
|
|
501
|
+
]);
|
|
502
|
+
const { changed, ignored } = splitIgnored(status.stdout);
|
|
503
|
+
return {
|
|
504
|
+
dirty: [...userStatusPaths(record, changed), ...(await modifiedIncludeCopies(record.resources?.includes))],
|
|
505
|
+
ignored: await unreproducibleIgnored(record, ignored, artefacts),
|
|
506
|
+
artefacts,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** How long the cleanup's last dirty check under the folder hold may take. */
|
|
511
|
+
const FINAL_DIRTY_CHECK_TIMEOUT_MS = 1_500;
|
|
512
|
+
/** How long a timed-out final status is given to exit after its kill. */
|
|
513
|
+
const FINAL_CHECK_EXIT_WAIT_MS = 2_000;
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The cleanup's last word before a removal, under the folder hold: the full
|
|
517
|
+
* status (untracked files at any depth, unstaged edits of tracked ones,
|
|
518
|
+
* ignored files) and the include-copy check again, bounded by `timeoutMs`;
|
|
519
|
+
* `artefacts` are the dependency artefacts read before the hold. null:
|
|
520
|
+
* clean, remove it. `{ reason: 'dirty' }`: something is uncommitted now.
|
|
521
|
+
* `{ reason: 'ignored', files, count }`: unreproducibleIgnored. `{ reason: 'busy' }`:
|
|
522
|
+
* the check timed out, failed or could not finish — never read as clean. A
|
|
523
|
+
* timed-out git is killed and awaited (bounded) before this returns, so none
|
|
524
|
+
* keeps running in the folder; `gitRunning: true` reports one that outlived
|
|
525
|
+
* the wait.
|
|
526
|
+
*/
|
|
527
|
+
async function finalDirtyCheck(record, timeoutMs, artefacts) {
|
|
528
|
+
if (!existsSync(record.path)) return null;
|
|
529
|
+
const started = Date.now();
|
|
530
|
+
let status;
|
|
531
|
+
try {
|
|
532
|
+
status = await runGit(record.path, AUTOMATIC_STATUS_ARGS, {
|
|
533
|
+
readOnly: true,
|
|
534
|
+
timeoutMs,
|
|
535
|
+
waitForExitMs: FINAL_CHECK_EXIT_WAIT_MS,
|
|
536
|
+
});
|
|
537
|
+
} catch (error) {
|
|
538
|
+
return { reason: 'busy', ...(error?.childExited === false ? { gitRunning: true } : {}) };
|
|
539
|
+
}
|
|
540
|
+
const { changed, ignored } = splitIgnored(status.stdout);
|
|
541
|
+
if (userStatusPaths(record, changed).length) return { reason: 'dirty' };
|
|
542
|
+
// The include copies and the ignored entries: file reads only (no child), bounded by what is left.
|
|
543
|
+
let timer;
|
|
544
|
+
try {
|
|
545
|
+
const left = Math.max(0, timeoutMs - (Date.now() - started));
|
|
546
|
+
const [modified, unreproducible] = await Promise.race([
|
|
547
|
+
Promise.all([
|
|
548
|
+
modifiedIncludeCopies(record.resources?.includes),
|
|
549
|
+
unreproducibleIgnored(record, ignored, artefacts),
|
|
550
|
+
]),
|
|
551
|
+
new Promise((_, reject) => {
|
|
552
|
+
timer = setTimeout(() => reject(new Error('include check timed out')), left);
|
|
553
|
+
}),
|
|
554
|
+
]);
|
|
555
|
+
if (modified.length) return { reason: 'dirty' };
|
|
556
|
+
return unreproducible.length ? ignoredKeep(unreproducible) : null;
|
|
557
|
+
} catch {
|
|
558
|
+
return { reason: 'busy' };
|
|
559
|
+
} finally {
|
|
560
|
+
clearTimeout(timer);
|
|
561
|
+
}
|
|
355
562
|
}
|
|
356
563
|
|
|
357
564
|
async function deleteBranchIfFree(record) {
|
|
@@ -515,6 +722,96 @@ async function removeCheckoutSafely(record) {
|
|
|
515
722
|
return [];
|
|
516
723
|
}
|
|
517
724
|
|
|
725
|
+
/**
|
|
726
|
+
* Put back what an automatic removal attempt took apart in a checkout that
|
|
727
|
+
* survived (`undo` as recorded in `deleting.undo`): the recorded include
|
|
728
|
+
* links that are missing again, and Mixdog's worktree lock when the checkout
|
|
729
|
+
* is still registered but no longer locked. Never throws.
|
|
730
|
+
* @returns {Promise<object[]>} what could not be put back
|
|
731
|
+
*/
|
|
732
|
+
async function undoAutomaticAttempt(record, undo) {
|
|
733
|
+
if (!undo || !existsSync(record.path)) return [];
|
|
734
|
+
const failures = [];
|
|
735
|
+
try {
|
|
736
|
+
if (undo.links) {
|
|
737
|
+
for (const failure of await restoreIncludeLinks(record.resources?.includes)) {
|
|
738
|
+
failures.push({ kind: 'link', path: failure.path, error: failure.error });
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
const registration = undo.lock !== null && undo.lock !== undefined ? await registrationFor(record) : null;
|
|
742
|
+
if (registration && registration.locked === null) {
|
|
743
|
+
const locked = await runGit(repoCwd(record), ['worktree', 'lock', '--reason', String(undo.lock), record.path], {
|
|
744
|
+
allowFailure: true,
|
|
745
|
+
});
|
|
746
|
+
if (locked.code !== 0) failures.push({ kind: 'lock', path: record.path, error: firstLine(locked.stderr) });
|
|
747
|
+
}
|
|
748
|
+
} catch (error) {
|
|
749
|
+
failures.push({ kind: 'undo', path: record.path, error: String(error?.message || error) });
|
|
750
|
+
}
|
|
751
|
+
return failures;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* The automatic cleanup's checkout removal (`deleting.automatic`): the caller
|
|
756
|
+
* verified the worktree under its folder hold just before (lease, live
|
|
757
|
+
* sessions, jobs, a full status). Only `git worktree remove --force` deletes
|
|
758
|
+
* here — there is no recursive fallback. When git fails or anything is left
|
|
759
|
+
* (or the checkout is not registered, or carries a lock that is not
|
|
760
|
+
* Mixdog's), it stops: the recorded links it unlinked are put back, its own
|
|
761
|
+
* lock is restored, and what is left is returned for the record to keep.
|
|
762
|
+
* @returns {Promise<object[]>} the remainder (empty when everything is gone)
|
|
763
|
+
*/
|
|
764
|
+
async function removeCheckoutVerified(record) {
|
|
765
|
+
await assertRecoveryStoresClear(record);
|
|
766
|
+
await assertManagedCheckoutPath(record);
|
|
767
|
+
await assertNoUnrecordedDirectoryLinks(record);
|
|
768
|
+
const cwd = repoCwd(record);
|
|
769
|
+
const registration = await registrationFor(record);
|
|
770
|
+
if (!registration) {
|
|
771
|
+
return existsSync(record.path) ? [{ kind: 'directory', path: record.path, error: 'not-registered' }] : [];
|
|
772
|
+
}
|
|
773
|
+
if (existsSync(record.path)) {
|
|
774
|
+
const lock = registration.locked ?? null;
|
|
775
|
+
if (lock !== null && !String(lock).startsWith('mixdog session=')) {
|
|
776
|
+
return [{ kind: 'lock', path: record.path, error: 'external-lock' }];
|
|
777
|
+
}
|
|
778
|
+
// What this attempt takes apart (the recorded links, Mixdog's lock) is
|
|
779
|
+
// written down first, so a failure here or a restart puts it back.
|
|
780
|
+
record.deleting = { ...record.deleting, undo: { links: true, lock } };
|
|
781
|
+
await putRecord(record);
|
|
782
|
+
let removed;
|
|
783
|
+
try {
|
|
784
|
+
const unlinked = await removeIncludeLinks(record.resources?.includes);
|
|
785
|
+
if (unlinked.length) {
|
|
786
|
+
return [
|
|
787
|
+
...unlinked.map((path) => ({ kind: 'link', path })),
|
|
788
|
+
...(await undoAutomaticAttempt(record, record.deleting.undo)),
|
|
789
|
+
];
|
|
790
|
+
}
|
|
791
|
+
// A built-in fsmonitor daemon holds the folder open (Windows) until stopped.
|
|
792
|
+
await runGit(record.path, ['fsmonitor--daemon', 'stop'], { allowFailure: true });
|
|
793
|
+
if (lock !== null) await runGit(cwd, ['worktree', 'unlock', record.path], { allowFailure: true });
|
|
794
|
+
removed = await config.gitWorktreeRemove(cwd, record.path);
|
|
795
|
+
} catch (error) {
|
|
796
|
+
// A rejected git call (timeout, spawn failure) is a failed removal too.
|
|
797
|
+
removed = { code: null, stderr: String(error?.message || error) };
|
|
798
|
+
}
|
|
799
|
+
if (removed.code !== 0 || existsSync(record.path)) {
|
|
800
|
+
// Whatever git left stays as it is; put back what this attempt took apart.
|
|
801
|
+
return [
|
|
802
|
+
{
|
|
803
|
+
kind: 'git-remove',
|
|
804
|
+
path: record.path,
|
|
805
|
+
error: firstLine(removed.stderr) || (removed.code === 0 ? 'folder left behind' : `exit ${removed.code}`),
|
|
806
|
+
},
|
|
807
|
+
...(await undoAutomaticAttempt(record, record.deleting.undo)),
|
|
808
|
+
];
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
await pruneRegistration(record.repoCommonDir, record.path, { allowLocked: true });
|
|
812
|
+
return (await registrationFor(record)) ? [{ kind: 'registration', path: record.path }] : [];
|
|
813
|
+
}
|
|
814
|
+
|
|
518
815
|
/** Report what a failed safe removal could not put back on its error (never swallowed). */
|
|
519
816
|
function withRollbackFailures(error, failures) {
|
|
520
817
|
if (!failures.length) return error;
|
|
@@ -602,6 +899,21 @@ async function rollbackCreation(record, cause) {
|
|
|
602
899
|
return { complete: false, remaining };
|
|
603
900
|
}
|
|
604
901
|
|
|
902
|
+
/**
|
|
903
|
+
* An automatic removal that did not finish (git refused, something was left,
|
|
904
|
+
* or it was interrupted) is not a decided deletion: the record goes back to
|
|
905
|
+
* `ready` with `removalIncomplete` ({ at, reason, remaining }), so only a
|
|
906
|
+
* later pass that verifies it afresh (lease, live sessions, jobs, the full
|
|
907
|
+
* dirty check) tries again — nothing resumes it blindly.
|
|
908
|
+
*/
|
|
909
|
+
async function keepIncompleteRemoval(record, remaining, reason = 'git-remove-failed') {
|
|
910
|
+
record.state = 'ready';
|
|
911
|
+
record.deleting = null;
|
|
912
|
+
record.removalIncomplete = { at: Date.now(), reason, remaining };
|
|
913
|
+
await putRecord(record);
|
|
914
|
+
return { removed: false, retryable: true, incomplete: true, remaining };
|
|
915
|
+
}
|
|
916
|
+
|
|
605
917
|
/** finishDeletion's folder step; what is left over goes to `remaining`. */
|
|
606
918
|
async function removeFolder(record, remaining) {
|
|
607
919
|
await removeOwnerLease(record);
|
|
@@ -613,7 +925,12 @@ async function removeFolder(record, remaining) {
|
|
|
613
925
|
for (const result of results) {
|
|
614
926
|
if (!result.ok) remaining.push({ kind: 'cleanup', name: result.name, error: result.error.message });
|
|
615
927
|
}
|
|
616
|
-
|
|
928
|
+
const remove = record.deleting?.automatic
|
|
929
|
+
? removeCheckoutVerified
|
|
930
|
+
: record.deleting?.safeOnly
|
|
931
|
+
? removeCheckoutSafely
|
|
932
|
+
: removeCheckout;
|
|
933
|
+
remaining.push(...(await remove(record)));
|
|
617
934
|
}
|
|
618
935
|
|
|
619
936
|
/**
|
|
@@ -638,6 +955,7 @@ async function finishDeletion(record) {
|
|
|
638
955
|
} catch (error) {
|
|
639
956
|
remaining.push({ kind: 'error', error: String(error?.message || error) });
|
|
640
957
|
}
|
|
958
|
+
if (remaining.length && record.deleting?.automatic) return keepIncompleteRemoval(record, remaining);
|
|
641
959
|
if (remaining.length) {
|
|
642
960
|
record.deleting = { ...record.deleting, remaining, lastAttemptAt: Date.now() };
|
|
643
961
|
await putRecord(record);
|
|
@@ -645,14 +963,21 @@ async function finishDeletion(record) {
|
|
|
645
963
|
}
|
|
646
964
|
let branch = { deleted: false, reason: 'kept' };
|
|
647
965
|
let kept = { tip: null, recoveryRef: null };
|
|
648
|
-
|
|
966
|
+
// A cleanup decided the branch merged at `mergedTip`: no second commit walk
|
|
967
|
+
// while the tip is still that one.
|
|
968
|
+
const mergedTip = record.deleting?.mergedTip;
|
|
969
|
+
const merged =
|
|
970
|
+
(!!mergedTip && record.branch && (await branchCommit(repoCwd(record), record.branch)) === mergedTip) ||
|
|
971
|
+
(await unmergedCommitCount(record)) === 0;
|
|
649
972
|
if (record.branch && !record.deleting?.preserveBranch && (record.deleting?.force || merged)) {
|
|
650
|
-
// Forced past unmerged commits
|
|
651
|
-
|
|
973
|
+
// Forced past unmerged commits (or a cleanup that deletes branches): the
|
|
974
|
+
// tip stays reachable before `branch -D`.
|
|
975
|
+
if (!merged || record.deleting?.keepTip) kept = await keepRemovedTip(record);
|
|
652
976
|
else kept.tip = await branchCommit(repoCwd(record), record.branch);
|
|
653
977
|
branch = await deleteBranchIfFree(record);
|
|
654
978
|
}
|
|
655
979
|
await deleteRecord(record.id);
|
|
980
|
+
forgetFolderSize(record.path);
|
|
656
981
|
await pruneEmptyRepoRoot(record.repoCommonDir);
|
|
657
982
|
return {
|
|
658
983
|
removed: true,
|
|
@@ -856,11 +1181,42 @@ export async function markWorktreeDisposable(entryId, disposable = true) {
|
|
|
856
1181
|
const current = await getRecord(entryId);
|
|
857
1182
|
if (!current) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
|
|
858
1183
|
current.disposable = disposable === true;
|
|
1184
|
+
// A deleted session's mark: no archive rule applies to it.
|
|
1185
|
+
delete current.disposableReason;
|
|
859
1186
|
await putRecord(current);
|
|
860
1187
|
return entryView(current);
|
|
861
1188
|
});
|
|
862
1189
|
}
|
|
863
1190
|
|
|
1191
|
+
/**
|
|
1192
|
+
* Mark (or unmark) every ready managed worktree `sessionIds` own disposable:
|
|
1193
|
+
* an archived session's, so a removal pass deletes it once clean and merged.
|
|
1194
|
+
* The mark carries `disposableReason: 'archived'`, so the automatic passes
|
|
1195
|
+
* apply the archive rules to it (cleanupArchived, favorites, minimum age).
|
|
1196
|
+
* Snapshots are left alone (they never outlive their turn anyway).
|
|
1197
|
+
* @param {string[]} sessionIds
|
|
1198
|
+
* @returns {Promise<string[]>} the entry ids whose mark changed
|
|
1199
|
+
*/
|
|
1200
|
+
export async function setSessionWorktreesDisposable(sessionIds, disposable = true) {
|
|
1201
|
+
const owners = new Set((Array.isArray(sessionIds) ? sessionIds : []).filter((id) => typeof id === 'string' && id));
|
|
1202
|
+
const changed = [];
|
|
1203
|
+
if (!owners.size) return changed;
|
|
1204
|
+
const want = disposable === true;
|
|
1205
|
+
for (const record of Object.values((await readRegistry()).entries)) {
|
|
1206
|
+
if (record.kind === WORKTREE_SNAPSHOT_KIND || !owners.has(record.ownerSessionId)) continue;
|
|
1207
|
+
await withRepoLock(record.repoCommonDir, async () => {
|
|
1208
|
+
const current = await getRecord(record.id);
|
|
1209
|
+
if (current?.state !== 'ready' || !owners.has(current.ownerSessionId) || current.disposable === want) return;
|
|
1210
|
+
current.disposable = want;
|
|
1211
|
+
if (want) current.disposableReason = 'archived';
|
|
1212
|
+
else delete current.disposableReason;
|
|
1213
|
+
await putRecord(current);
|
|
1214
|
+
changed.push(current.id);
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
return changed;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
864
1220
|
/**
|
|
865
1221
|
* Before a creation (under the repository lock): the entry this transaction
|
|
866
1222
|
* or session already has ready (its lease re-written), else null once every
|
|
@@ -979,6 +1335,164 @@ async function snapshotHolderLive(record, live) {
|
|
|
979
1335
|
return !!lease && lease.pid !== process.pid && leaseProcessAlive(lease);
|
|
980
1336
|
}
|
|
981
1337
|
|
|
1338
|
+
/** The project folder's uncommitted changes under the requested `carryChanges` mode; `ask` refuses while there are any. */
|
|
1339
|
+
async function judgePendingChanges(repo, projectSubdir, carryChanges) {
|
|
1340
|
+
// A carry is judged by the same status read that counts the changes.
|
|
1341
|
+
let pendingChanges = [];
|
|
1342
|
+
if (carryChanges === 'carry') pendingChanges = await assertCarryable(repo.topLevel, projectSubdir);
|
|
1343
|
+
else if (carryChanges) pendingChanges = await uncommittedChanges(repo.topLevel, projectSubdir);
|
|
1344
|
+
if (pendingChanges.length && carryChanges === 'ask') {
|
|
1345
|
+
throw worktreeError('WORKTREE_DIRTY_CHOICE', undefined, {
|
|
1346
|
+
count: pendingChanges.length,
|
|
1347
|
+
paths: pendingChanges.slice(0, DIRTY_CHOICE_PREVIEW),
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
return pendingChanges;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/** The commit a new worktree starts from and the branch a merge targets by default. */
|
|
1354
|
+
async function resolveStartPoint(repo, { baseBranch, startCommit, signal }) {
|
|
1355
|
+
let baseCommit = repo.headCommit;
|
|
1356
|
+
let mergeTarget = repo.currentBranch;
|
|
1357
|
+
if (baseBranch) {
|
|
1358
|
+
baseCommit = await branchCommit(repo.projectPath, baseBranch, { signal });
|
|
1359
|
+
if (!baseCommit) {
|
|
1360
|
+
throw worktreeError('WORKTREE_UNSUPPORTED_REPO', `Base branch ${baseBranch} does not exist.`, { baseBranch });
|
|
1361
|
+
}
|
|
1362
|
+
mergeTarget = baseBranch;
|
|
1363
|
+
}
|
|
1364
|
+
// A new branch at an explicit start point (`git worktree add -b <b> <path> <commit-ish>`).
|
|
1365
|
+
if (startCommit) baseCommit = await resolveCommit(repo.projectPath, startCommit, signal);
|
|
1366
|
+
return { baseCommit, mergeTarget };
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/** The record a creation reserves before git is asked to add the checkout. */
|
|
1370
|
+
function newCreationRecord({
|
|
1371
|
+
slug,
|
|
1372
|
+
branch,
|
|
1373
|
+
repo,
|
|
1374
|
+
owner,
|
|
1375
|
+
txId,
|
|
1376
|
+
baseCommit,
|
|
1377
|
+
mergeTarget,
|
|
1378
|
+
existingBranch,
|
|
1379
|
+
exactBranch,
|
|
1380
|
+
tracked,
|
|
1381
|
+
includePlan,
|
|
1382
|
+
}) {
|
|
1383
|
+
return {
|
|
1384
|
+
id: slug,
|
|
1385
|
+
txId,
|
|
1386
|
+
repoCommonDir: repo.repoCommonDir,
|
|
1387
|
+
projectPath: repo.projectPath,
|
|
1388
|
+
projectSubdir: projectSubdirOf(repo.topLevel, repo.projectPath),
|
|
1389
|
+
path: managedWorktreePath(repo.repoCommonDir, slug),
|
|
1390
|
+
branch,
|
|
1391
|
+
baseCommit,
|
|
1392
|
+
// An existing branch has no base it branched from: it merges where a
|
|
1393
|
+
// merge first takes it (recorded then), and until that its own work is
|
|
1394
|
+
// what it gained here since baseCommit.
|
|
1395
|
+
mergeTarget: existingBranch ? null : mergeTarget,
|
|
1396
|
+
createdAt: Date.now(),
|
|
1397
|
+
ownerSessionId: owner.sessionId,
|
|
1398
|
+
existingBranch,
|
|
1399
|
+
exactBranch,
|
|
1400
|
+
state: 'creating',
|
|
1401
|
+
disposable: false,
|
|
1402
|
+
warnings: [...(tracked.submodules ? ['submodules'] : []), ...includePlan.warnings],
|
|
1403
|
+
resources: { worktree: null, lock: null, owner: null, includes: [] },
|
|
1404
|
+
deleting: null,
|
|
1405
|
+
recovery: null,
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* Locks the fresh checkout, binds its owner lease, moves or leaves the
|
|
1411
|
+
* project folder's changes and applies the includes.
|
|
1412
|
+
* `carry.moved` is set as soon as changes were carried, so a later failure
|
|
1413
|
+
* can put them back.
|
|
1414
|
+
*/
|
|
1415
|
+
async function populateCheckout({
|
|
1416
|
+
record,
|
|
1417
|
+
owner,
|
|
1418
|
+
repo,
|
|
1419
|
+
projectSubdir,
|
|
1420
|
+
signal,
|
|
1421
|
+
pendingChanges,
|
|
1422
|
+
carryChanges,
|
|
1423
|
+
baseCommit,
|
|
1424
|
+
includePlan,
|
|
1425
|
+
carry,
|
|
1426
|
+
}) {
|
|
1427
|
+
await setResource(record, 'worktree', 'created');
|
|
1428
|
+
throwIfAborted(signal);
|
|
1429
|
+
await setResource(record, 'lock', 'pending');
|
|
1430
|
+
await runGit(repo.projectPath, ['worktree', 'lock', '--reason', `mixdog session=${owner.sessionId}`, record.path]);
|
|
1431
|
+
await setResource(record, 'lock', 'created');
|
|
1432
|
+
await setResource(record, 'owner', 'pending');
|
|
1433
|
+
await writeOwnerLease(record, owner);
|
|
1434
|
+
await setResource(record, 'owner', 'created');
|
|
1435
|
+
// Carried before the include entries are copied, so an include never
|
|
1436
|
+
// stands in the way of a carried file; the status is read again here,
|
|
1437
|
+
// right before acting.
|
|
1438
|
+
let changes = pendingChanges.length ? { mode: 'left', count: pendingChanges.length } : null;
|
|
1439
|
+
if (carryChanges === 'carry') {
|
|
1440
|
+
carry.moved = await carryUncommittedChanges({
|
|
1441
|
+
topLevel: repo.topLevel,
|
|
1442
|
+
projectSubdir,
|
|
1443
|
+
worktreePath: record.path,
|
|
1444
|
+
baseCommit,
|
|
1445
|
+
label: record.id,
|
|
1446
|
+
});
|
|
1447
|
+
// The recovery ref stays until the caller has committed the move
|
|
1448
|
+
// (dropCarryRecoveryRef) or put the changes back (restoreCarriedChanges).
|
|
1449
|
+
changes = carry.moved.count
|
|
1450
|
+
? { mode: 'carried', count: carry.moved.count, recoveryRef: carry.moved.recoveryRef }
|
|
1451
|
+
: null;
|
|
1452
|
+
}
|
|
1453
|
+
const applied = await applyIncludes(includePlan, record.path, (ledger) => recordInclude(record, ledger));
|
|
1454
|
+
throwIfAborted(signal);
|
|
1455
|
+
record.warnings = [...record.warnings, ...applied.warnings];
|
|
1456
|
+
const made = (record.resources.includes || []).filter((item) => item.status === 'created');
|
|
1457
|
+
const rels = (predicate) => made.filter(predicate).map((item) => item.rel);
|
|
1458
|
+
// What creation did, for the agent's note at the first turn there (transitions.mjs).
|
|
1459
|
+
const prepared = {
|
|
1460
|
+
root: repo.topLevel,
|
|
1461
|
+
copied: rels((item) => item.mode === 'copy'),
|
|
1462
|
+
linked: rels((item) => item.mode === 'link'),
|
|
1463
|
+
skipped: [...includePlan.warnings, ...applied.warnings],
|
|
1464
|
+
};
|
|
1465
|
+
// Whichever path made it (a worktree task, a branch selection, `git
|
|
1466
|
+
// worktree add`), the first turn a session runs here installs its
|
|
1467
|
+
// dependencies in the worktree folder (claimWorktreeSetup).
|
|
1468
|
+
record.setup = { pending: true, prepared };
|
|
1469
|
+
record.state = 'ready';
|
|
1470
|
+
await putRecord(record);
|
|
1471
|
+
return { ...entryView(record), prepared, ...(changes ? { changes } : {}) };
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/** The error a failed creation throws, after its carried changes and partial checkout are undone. */
|
|
1475
|
+
async function failCreation(error, { record, carried, repo, projectSubdir, needed, root }) {
|
|
1476
|
+
// Carried already: the changes go back to the project folder before the
|
|
1477
|
+
// worktree that holds them is rolled back (the recovery ref keeps them
|
|
1478
|
+
// either way).
|
|
1479
|
+
let carryRestore = null;
|
|
1480
|
+
if (carried?.recoveryRef) {
|
|
1481
|
+
carryRestore = await restoreFromRecoveryRef(repo.topLevel, projectSubdir, carried.recoveryRef)
|
|
1482
|
+
.then(() => ({ restored: true, recoveryRef: carried.recoveryRef }))
|
|
1483
|
+
.catch((restoreError) => ({
|
|
1484
|
+
restored: false,
|
|
1485
|
+
recoveryRef: carried.recoveryRef,
|
|
1486
|
+
error: String(restoreError?.message || restoreError),
|
|
1487
|
+
}));
|
|
1488
|
+
}
|
|
1489
|
+
const rollback = await rollbackCreation(record, error);
|
|
1490
|
+
const failure = isDiskFullError(error) ? await diskFullFailure(error, { needed, root }) : error;
|
|
1491
|
+
if (!rollback.complete) failure.rollback = rollback;
|
|
1492
|
+
if (carryRestore) failure.carryRestore = carryRestore;
|
|
1493
|
+
return failure;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
982
1496
|
/**
|
|
983
1497
|
* `carryChanges` decides the project folder's uncommitted changes (tracked
|
|
984
1498
|
* and untracked, ignored excluded), judged under the repository lock:
|
|
@@ -1034,29 +1548,10 @@ export async function createManagedWorktree({
|
|
|
1034
1548
|
throwIfAborted(signal);
|
|
1035
1549
|
|
|
1036
1550
|
const projectSubdir = projectSubdirOf(repo.topLevel, repo.projectPath);
|
|
1037
|
-
|
|
1038
|
-
let pendingChanges = [];
|
|
1039
|
-
if (carryChanges === 'carry') pendingChanges = await assertCarryable(repo.topLevel, projectSubdir);
|
|
1040
|
-
else if (carryChanges) pendingChanges = await uncommittedChanges(repo.topLevel, projectSubdir);
|
|
1041
|
-
if (pendingChanges.length && carryChanges === 'ask') {
|
|
1042
|
-
throw worktreeError('WORKTREE_DIRTY_CHOICE', undefined, {
|
|
1043
|
-
count: pendingChanges.length,
|
|
1044
|
-
paths: pendingChanges.slice(0, DIRTY_CHOICE_PREVIEW),
|
|
1045
|
-
});
|
|
1046
|
-
}
|
|
1551
|
+
const pendingChanges = await judgePendingChanges(repo, projectSubdir, carryChanges);
|
|
1047
1552
|
|
|
1048
1553
|
const settings = await currentWorktreeSettings();
|
|
1049
|
-
|
|
1050
|
-
let mergeTarget = repo.currentBranch;
|
|
1051
|
-
if (baseBranch) {
|
|
1052
|
-
baseCommit = await branchCommit(repo.projectPath, baseBranch, { signal });
|
|
1053
|
-
if (!baseCommit) {
|
|
1054
|
-
throw worktreeError('WORKTREE_UNSUPPORTED_REPO', `Base branch ${baseBranch} does not exist.`, { baseBranch });
|
|
1055
|
-
}
|
|
1056
|
-
mergeTarget = baseBranch;
|
|
1057
|
-
}
|
|
1058
|
-
// A new branch at an explicit start point (`git worktree add -b <b> <path> <commit-ish>`).
|
|
1059
|
-
if (startCommit) baseCommit = await resolveCommit(repo.projectPath, startCommit, signal);
|
|
1554
|
+
const { baseCommit, mergeTarget } = await resolveStartPoint(repo, { baseBranch, startCommit, signal });
|
|
1060
1555
|
|
|
1061
1556
|
// The main checkout's local env files come along on their own;
|
|
1062
1557
|
// `.worktreeinclude` applies on top.
|
|
@@ -1073,11 +1568,20 @@ export async function createManagedWorktree({
|
|
|
1073
1568
|
});
|
|
1074
1569
|
throwIfAborted(signal);
|
|
1075
1570
|
|
|
1571
|
+
// Every existing local branch under the prefix (all of them with no
|
|
1572
|
+
// prefix): a new worktree never takes over one, whoever made it.
|
|
1076
1573
|
const branches = new Set(
|
|
1077
1574
|
(await localBranches(repo.projectPath, { signal, prefix: settings.branchPrefix })).map((name) =>
|
|
1078
1575
|
name.toLowerCase()
|
|
1079
1576
|
)
|
|
1080
1577
|
);
|
|
1578
|
+
// `x` is also taken while a branch `x/...` exists (the ref would be a directory).
|
|
1579
|
+
const branchTaken = (name) => {
|
|
1580
|
+
const key = name.toLowerCase();
|
|
1581
|
+
if (branches.has(key)) return true;
|
|
1582
|
+
for (const existing of branches) if (existing.startsWith(`${key}/`)) return true;
|
|
1583
|
+
return false;
|
|
1584
|
+
};
|
|
1081
1585
|
const base = slugFromHint(nameHint);
|
|
1082
1586
|
const reserve = async () => {
|
|
1083
1587
|
let reserved = null;
|
|
@@ -1099,7 +1603,7 @@ export async function createManagedWorktree({
|
|
|
1099
1603
|
}
|
|
1100
1604
|
);
|
|
1101
1605
|
let record = await reserve();
|
|
1102
|
-
|
|
1606
|
+
const carry = { moved: null };
|
|
1103
1607
|
|
|
1104
1608
|
try {
|
|
1105
1609
|
await mkdir(root, { recursive: true });
|
|
@@ -1120,84 +1624,20 @@ export async function createManagedWorktree({
|
|
|
1120
1624
|
await setResource(record, 'worktree', 'pending');
|
|
1121
1625
|
await addCheckout(record);
|
|
1122
1626
|
}
|
|
1123
|
-
await
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
'lock',
|
|
1129
|
-
'--reason',
|
|
1130
|
-
`mixdog session=${owner.sessionId}`,
|
|
1131
|
-
record.path,
|
|
1132
|
-
]);
|
|
1133
|
-
await setResource(record, 'lock', 'created');
|
|
1134
|
-
await setResource(record, 'owner', 'pending');
|
|
1135
|
-
await writeOwnerLease(record, owner);
|
|
1136
|
-
await setResource(record, 'owner', 'created');
|
|
1137
|
-
// Carried before the include entries are copied, so an include never
|
|
1138
|
-
// stands in the way of a carried file; the status is read again here,
|
|
1139
|
-
// right before acting.
|
|
1140
|
-
let changes = pendingChanges.length ? { mode: 'left', count: pendingChanges.length } : null;
|
|
1141
|
-
if (carryChanges === 'carry') {
|
|
1142
|
-
carried = await carryUncommittedChanges({
|
|
1143
|
-
topLevel: repo.topLevel,
|
|
1144
|
-
projectSubdir,
|
|
1145
|
-
worktreePath: record.path,
|
|
1146
|
-
baseCommit,
|
|
1147
|
-
label: record.id,
|
|
1148
|
-
});
|
|
1149
|
-
// The recovery ref stays until the caller has committed the move
|
|
1150
|
-
// (dropCarryRecoveryRef) or put the changes back (restoreCarriedChanges).
|
|
1151
|
-
changes = carried.count ? { mode: 'carried', count: carried.count, recoveryRef: carried.recoveryRef } : null;
|
|
1152
|
-
}
|
|
1153
|
-
const applied = await applyIncludes(includePlan, record.path, (ledger) => recordInclude(record, ledger));
|
|
1154
|
-
throwIfAborted(signal);
|
|
1155
|
-
// After the includes: one that already brought a node_modules wins.
|
|
1156
|
-
const linkedDependencies = await linkWorktreeDependencies({
|
|
1157
|
-
mainDir: repo.projectPath,
|
|
1158
|
-
worktreeDir: projectSubdir ? join(record.path, ...projectSubdir.split('/')) : record.path,
|
|
1159
|
-
worktreeRel: projectSubdir,
|
|
1627
|
+
return await populateCheckout({
|
|
1628
|
+
record,
|
|
1629
|
+
owner,
|
|
1630
|
+
repo,
|
|
1631
|
+
projectSubdir,
|
|
1160
1632
|
signal,
|
|
1161
|
-
|
|
1633
|
+
pendingChanges,
|
|
1634
|
+
carryChanges,
|
|
1635
|
+
baseCommit,
|
|
1636
|
+
includePlan,
|
|
1637
|
+
carry,
|
|
1162
1638
|
});
|
|
1163
|
-
throwIfAborted(signal);
|
|
1164
|
-
record.warnings = [...record.warnings, ...applied.warnings];
|
|
1165
|
-
const made = (record.resources.includes || []).filter((item) => item.status === 'created');
|
|
1166
|
-
const rels = (predicate) => made.filter(predicate).map((item) => item.rel);
|
|
1167
|
-
// What creation did, for the agent's note at the first turn there (transitions.mjs).
|
|
1168
|
-
const prepared = {
|
|
1169
|
-
root: repo.topLevel,
|
|
1170
|
-
copied: rels((item) => item.mode === 'copy'),
|
|
1171
|
-
linked: rels((item) => item.mode === 'link' && !item.dependency),
|
|
1172
|
-
dependencies: linkedDependencies,
|
|
1173
|
-
skipped: [...includePlan.warnings, ...applied.warnings],
|
|
1174
|
-
};
|
|
1175
|
-
// Whichever path made it (a worktree task, a branch selection, `git
|
|
1176
|
-
// worktree add`), the first turn a session runs here prepares its
|
|
1177
|
-
// dependencies (claimWorktreeSetup).
|
|
1178
|
-
record.setup = { pending: true, prepared };
|
|
1179
|
-
record.state = 'ready';
|
|
1180
|
-
await putRecord(record);
|
|
1181
|
-
return { ...entryView(record), prepared, ...(changes ? { changes } : {}) };
|
|
1182
1639
|
} catch (error) {
|
|
1183
|
-
|
|
1184
|
-
// worktree that holds them is rolled back (the recovery ref keeps them
|
|
1185
|
-
// either way).
|
|
1186
|
-
let carryRestore = null;
|
|
1187
|
-
if (carried?.recoveryRef) {
|
|
1188
|
-
carryRestore = await restoreFromRecoveryRef(repo.topLevel, projectSubdir, carried.recoveryRef)
|
|
1189
|
-
.then(() => ({ restored: true, recoveryRef: carried.recoveryRef }))
|
|
1190
|
-
.catch((restoreError) => ({
|
|
1191
|
-
restored: false,
|
|
1192
|
-
recoveryRef: carried.recoveryRef,
|
|
1193
|
-
error: String(restoreError?.message || restoreError),
|
|
1194
|
-
}));
|
|
1195
|
-
}
|
|
1196
|
-
const rollback = await rollbackCreation(record, error);
|
|
1197
|
-
const failure = isDiskFullError(error) ? await diskFullFailure(error, { needed, root }) : error;
|
|
1198
|
-
if (!rollback.complete) failure.rollback = rollback;
|
|
1199
|
-
if (carryRestore) failure.carryRestore = carryRestore;
|
|
1200
|
-
throw failure;
|
|
1640
|
+
throw await failCreation(error, { record, carried: carry.moved, repo, projectSubdir, needed, root });
|
|
1201
1641
|
}
|
|
1202
1642
|
|
|
1203
1643
|
async function reserveName() {
|
|
@@ -1206,37 +1646,28 @@ export async function createManagedWorktree({
|
|
|
1206
1646
|
base,
|
|
1207
1647
|
(candidate) =>
|
|
1208
1648
|
ids.has(candidate.toLowerCase()) ||
|
|
1209
|
-
|
|
1649
|
+
branchTaken(`${settings.branchPrefix}${candidate}`) ||
|
|
1210
1650
|
existsSync(join(root, candidate))
|
|
1211
1651
|
);
|
|
1212
1652
|
const branch = requestedBranch ?? worktreeBranchName(slug, settings.branchPrefix);
|
|
1653
|
+
// A new checkout at this path: no size measured for an earlier one counts.
|
|
1654
|
+
forgetFolderSize(managedWorktreePath(repo.repoCommonDir, slug));
|
|
1213
1655
|
if (!(await validBranchName(repo.projectPath, branch))) {
|
|
1214
1656
|
throw worktreeError('WORKTREE_UNSUPPORTED_REPO', `Branch name ${branch} is not valid.`, { branch });
|
|
1215
1657
|
}
|
|
1216
|
-
const candidate = {
|
|
1217
|
-
|
|
1218
|
-
txId,
|
|
1219
|
-
repoCommonDir: repo.repoCommonDir,
|
|
1220
|
-
projectPath: repo.projectPath,
|
|
1221
|
-
projectSubdir: projectSubdirOf(repo.topLevel, repo.projectPath),
|
|
1222
|
-
path: managedWorktreePath(repo.repoCommonDir, slug),
|
|
1658
|
+
const candidate = newCreationRecord({
|
|
1659
|
+
slug,
|
|
1223
1660
|
branch,
|
|
1661
|
+
repo,
|
|
1662
|
+
owner,
|
|
1663
|
+
txId,
|
|
1224
1664
|
baseCommit,
|
|
1225
|
-
|
|
1226
|
-
// merge first takes it (recorded then), and until that its own work is
|
|
1227
|
-
// what it gained here since baseCommit.
|
|
1228
|
-
mergeTarget: existingBranch ? null : mergeTarget,
|
|
1229
|
-
createdAt: Date.now(),
|
|
1230
|
-
ownerSessionId: owner.sessionId,
|
|
1665
|
+
mergeTarget,
|
|
1231
1666
|
existingBranch,
|
|
1232
1667
|
exactBranch: requestedBranch !== null,
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
resources: { worktree: null, lock: null, owner: null, includes: [] },
|
|
1237
|
-
deleting: null,
|
|
1238
|
-
recovery: null,
|
|
1239
|
-
};
|
|
1668
|
+
tracked,
|
|
1669
|
+
includePlan,
|
|
1670
|
+
});
|
|
1240
1671
|
return (await insertRecord(candidate)) ? candidate : null;
|
|
1241
1672
|
}
|
|
1242
1673
|
});
|
|
@@ -1362,9 +1793,12 @@ export async function createSnapshotWorktree({
|
|
|
1362
1793
|
* one is admitted there until the returned release. A folder no longer a
|
|
1363
1794
|
* checkout admits no consumer (activity-gate tracks git checkouts only).
|
|
1364
1795
|
*/
|
|
1365
|
-
function holdSnapshotFolder(record) {
|
|
1796
|
+
async function holdSnapshotFolder(record) {
|
|
1366
1797
|
if (!existsSync(join(record.path, '.git'))) return () => {};
|
|
1367
|
-
|
|
1798
|
+
const release = await holdWorkspaceActivityAsync([record.path]);
|
|
1799
|
+
// Settles once the removal was attempted. It fails only when workspace
|
|
1800
|
+
// admission stopped (reported there), which keeps the folder held.
|
|
1801
|
+
return () => release().catch(() => {});
|
|
1368
1802
|
}
|
|
1369
1803
|
|
|
1370
1804
|
/** A refusal that only says "something still runs there" (retry once it ends). */
|
|
@@ -1429,21 +1863,78 @@ export async function removeSessionSnapshots(sessionId, { txIds = null } = {}) {
|
|
|
1429
1863
|
return report;
|
|
1430
1864
|
}
|
|
1431
1865
|
|
|
1866
|
+
/** The node_modules links into the main checkout an earlier version recorded at creation. */
|
|
1867
|
+
function dependencyLinks(record) {
|
|
1868
|
+
return (record?.resources?.includes || []).filter((item) => item.mode === 'link' && item.dependency);
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/** The links this worktree's .worktreeinclude asked for (rel from the worktree's top level). */
|
|
1872
|
+
function userIncludeLinks(record) {
|
|
1873
|
+
return (record?.resources?.includes || []).filter(
|
|
1874
|
+
(item) => item.mode === 'link' && item.status === 'created' && !item.dependency
|
|
1875
|
+
);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/**
|
|
1879
|
+
* Unlink (never empty) the recorded dependency links of a worktree made by
|
|
1880
|
+
* an earlier version, through which a reset (`npm ci`) in the worktree
|
|
1881
|
+
* empties the main checkout's node_modules, and forget those gone. One that
|
|
1882
|
+
* cannot be unlinked stays recorded, so removal still unlinks it first, and
|
|
1883
|
+
* blocks every install in the worktree (claimWorktreeSetup).
|
|
1884
|
+
* @returns {Promise<boolean>} whether any is gone
|
|
1885
|
+
*/
|
|
1886
|
+
async function detachDependencyLinks(record) {
|
|
1887
|
+
const links = dependencyLinks(record);
|
|
1888
|
+
if (!links.length) return false;
|
|
1889
|
+
const stuck = new Set(await removeIncludeLinks(links));
|
|
1890
|
+
record.resources.includes = record.resources.includes.filter((item) => !links.includes(item) || stuck.has(item.path));
|
|
1891
|
+
return stuck.size < links.length;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1432
1894
|
/**
|
|
1433
1895
|
* The first turn a session runs in managed worktree `entryId` claims its
|
|
1434
1896
|
* dependency preparation, once: `{ prepared }` (what creation did) while it
|
|
1435
1897
|
* is still pending, else null. Claimed before it runs, so a failed or
|
|
1436
|
-
* cancelled preparation is never repeated.
|
|
1898
|
+
* cancelled preparation is never repeated. A worktree that still holds an
|
|
1899
|
+
* earlier version's dependency link has it detached at its next turn and is
|
|
1900
|
+
* claimed again (`prepared` null), so that turn installs in the worktree.
|
|
1901
|
+
* While one cannot be unlinked, or any other installed artefact in the
|
|
1902
|
+
* project folder is a link (linkedArtefacts) that the worktree's
|
|
1903
|
+
* .worktreeinclude did not ask for, the claim is `{ prepared: null,
|
|
1904
|
+
* blocked }` (the links, relative to the worktree's top level) instead:
|
|
1905
|
+
* nothing is installed, since an install reaches beyond its own folder
|
|
1906
|
+
* (`npm ci` at the root resets every workspace package's node_modules), and
|
|
1907
|
+
* the setup stays pending, so each later turn tries again.
|
|
1437
1908
|
*/
|
|
1438
1909
|
export async function claimWorktreeSetup(entryId) {
|
|
1439
1910
|
const record = await getRecord(entryId);
|
|
1440
|
-
if (!record?.setup?.pending) return null;
|
|
1911
|
+
if (!record?.setup?.pending && !dependencyLinks(record).length) return null;
|
|
1441
1912
|
return withRepoLock(record.repoCommonDir, async () => {
|
|
1442
1913
|
const current = await getRecord(entryId);
|
|
1443
|
-
if (current?.state !== 'ready'
|
|
1914
|
+
if (current?.state !== 'ready') return null;
|
|
1915
|
+
const pending = current.setup?.pending === true;
|
|
1916
|
+
const detached = await detachDependencyLinks(current);
|
|
1917
|
+
const blocked = dependencyLinks(current).map((item) => item.rel);
|
|
1918
|
+
if (!blocked.length && !pending && !detached) return null;
|
|
1919
|
+
// Links the user asked for in .worktreeinclude (relative to the project
|
|
1920
|
+
// folder): shared folders on purpose, which the planner leaves alone.
|
|
1921
|
+
const shared = [];
|
|
1922
|
+
if (!blocked.length) {
|
|
1923
|
+
const asked = new Set(userIncludeLinks(current).map((item) => item.rel));
|
|
1924
|
+
for (const rel of await linkedArtefacts(sessionRootOf(current))) {
|
|
1925
|
+
const topLevel = [current.projectSubdir, rel].filter(Boolean).join('/');
|
|
1926
|
+
if (asked.has(topLevel)) shared.push(rel);
|
|
1927
|
+
else blocked.push(topLevel);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
if (blocked.length) {
|
|
1931
|
+
current.setup = { ...current.setup, pending: true };
|
|
1932
|
+
await putRecord(current);
|
|
1933
|
+
return { prepared: null, blocked };
|
|
1934
|
+
}
|
|
1444
1935
|
current.setup = { ...current.setup, pending: false, claimedAt: Date.now() };
|
|
1445
1936
|
await putRecord(current);
|
|
1446
|
-
return { prepared: current.setup.prepared ?? null };
|
|
1937
|
+
return { prepared: pending ? (current.setup.prepared ?? null) : null, shared };
|
|
1447
1938
|
});
|
|
1448
1939
|
}
|
|
1449
1940
|
|
|
@@ -1554,11 +2045,16 @@ export async function releaseWorktreeOwner(entryId, owner, { forgetReference = f
|
|
|
1554
2045
|
export async function listBranchOccupancy({ projectPath, sessionId } = {}) {
|
|
1555
2046
|
requireText(projectPath, 'projectPath');
|
|
1556
2047
|
const repo = await inspectRepo(projectPath);
|
|
1557
|
-
const [
|
|
1558
|
-
|
|
2048
|
+
const [commits, worktrees, doc] = await Promise.all([
|
|
2049
|
+
localBranchCommits(repo.projectPath),
|
|
1559
2050
|
listWorktrees(repo.projectPath),
|
|
1560
2051
|
readRegistry(),
|
|
1561
2052
|
]);
|
|
2053
|
+
const branches = commits.map((commit) => commit.name);
|
|
2054
|
+
// The tip's committer time per branch: the menu groups recent branches by it.
|
|
2055
|
+
const committedAt = Object.fromEntries(
|
|
2056
|
+
commits.filter((commit) => commit.committedAt).map((commit) => [commit.name, commit.committedAt])
|
|
2057
|
+
);
|
|
1562
2058
|
const managed = Object.values(doc.entries).filter((record) => samePath(record.repoCommonDir, repo.repoCommonDir));
|
|
1563
2059
|
const occupancy = branches.map((branch) => {
|
|
1564
2060
|
// A prunable registration (its folder is gone) holds nothing.
|
|
@@ -1572,7 +2068,7 @@ export async function listBranchOccupancy({ projectPath, sessionId } = {}) {
|
|
|
1572
2068
|
const retained = sessionId
|
|
1573
2069
|
? managed.find((record) => record.ownerSessionId === sessionId && record.state === 'ready')
|
|
1574
2070
|
: null;
|
|
1575
|
-
return { branches, occupancy, retainedWorktree: retained ? entryView(retained) : null };
|
|
2071
|
+
return { branches, occupancy, committedAt, retainedWorktree: retained ? entryView(retained) : null };
|
|
1576
2072
|
}
|
|
1577
2073
|
|
|
1578
2074
|
/**
|
|
@@ -1830,7 +2326,11 @@ export async function assertWorktreeRemovable({ entryId, force = false, sessionI
|
|
|
1830
2326
|
* `expected` ({ kind, txId, holderSessionId }) pins the record the caller
|
|
1831
2327
|
* selected: one that no longer matches under the repository lock (removed,
|
|
1832
2328
|
* its slug reused) is left alone and reported `{ removed: false, skipped: true }`.
|
|
1833
|
-
*
|
|
2329
|
+
* `automatic` (a deleted session's leftover): the cleanup's verified path
|
|
2330
|
+
* (removeAutomatically): no force, no recursive delete; a keep is raised as
|
|
2331
|
+
* WORKTREE_DIRTY / WORKTREE_UNMERGED / WORKTREE_BUSY_JOBS, and an incomplete
|
|
2332
|
+
* git removal returns `{ removed: false, incomplete: true }` with the record ready again.
|
|
2333
|
+
* @param {{ entryId: string, force?: boolean, expected?: { kind?: string, txId?: string, holderSessionId?: string }|null, automatic?: boolean }} input
|
|
1834
2334
|
* @returns {Promise<{ removed: boolean, skipped?: boolean, retryable?: boolean, remaining?: object[], branchDeleted?: boolean }>}
|
|
1835
2335
|
*/
|
|
1836
2336
|
export async function removeManagedWorktree({
|
|
@@ -1840,6 +2340,7 @@ export async function removeManagedWorktree({
|
|
|
1840
2340
|
preserveBranch = false,
|
|
1841
2341
|
safeOnly = false,
|
|
1842
2342
|
expected = null,
|
|
2343
|
+
automatic = false,
|
|
1843
2344
|
} = {}) {
|
|
1844
2345
|
const skipped = { removed: false, skipped: true };
|
|
1845
2346
|
const matches = (record) =>
|
|
@@ -1858,24 +2359,48 @@ export async function removeManagedWorktree({
|
|
|
1858
2359
|
if (!current && !expected) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
|
|
1859
2360
|
return skipped;
|
|
1860
2361
|
}
|
|
1861
|
-
return removeLocked(current, { force, sessionId, preserveBranch, safeOnly });
|
|
2362
|
+
return removeLocked(current, { force, sessionId, preserveBranch, safeOnly, automatic });
|
|
1862
2363
|
});
|
|
1863
2364
|
}
|
|
1864
2365
|
|
|
2366
|
+
/** Kept reasons of removeAutomatically as the typed refusals removeManagedWorktree raises. */
|
|
2367
|
+
function automaticRefusal(record, kept) {
|
|
2368
|
+
if (kept.reason === 'dirty') return worktreeError('WORKTREE_DIRTY', undefined, { entryId: record.id });
|
|
2369
|
+
if (kept.reason === 'ignored') {
|
|
2370
|
+
return worktreeError('WORKTREE_DIRTY', 'The worktree holds ignored files no install restores.', {
|
|
2371
|
+
entryId: record.id,
|
|
2372
|
+
ignored: kept.files,
|
|
2373
|
+
count: kept.count,
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
if (kept.reason === 'unmerged') return worktreeError('WORKTREE_UNMERGED', undefined, { entryId: record.id });
|
|
2377
|
+
return worktreeRefusal('folder-in-use', { entryId: record.id, kept: kept.reason });
|
|
2378
|
+
}
|
|
2379
|
+
|
|
1865
2380
|
/** The removal of `current`, under its repository lock. */
|
|
1866
|
-
async function removeLocked(
|
|
2381
|
+
async function removeLocked(
|
|
2382
|
+
current,
|
|
2383
|
+
{ force, sessionId, preserveBranch = false, safeOnly = false, automatic = false }
|
|
2384
|
+
) {
|
|
1867
2385
|
assertNotShared(current, sessionId);
|
|
2386
|
+
// A deleted session's leftover (cleanupDeletedSessionWorktree): the
|
|
2387
|
+
// verified automatic path, never a forced or recursive one.
|
|
2388
|
+
if (automatic && !force && current.state === 'ready' && current.kind !== WORKTREE_SNAPSHOT_KIND) {
|
|
2389
|
+
const outcome = await removeAutomatically(current, { preserveBranch, keepTip: !preserveBranch });
|
|
2390
|
+
if (!outcome.result) throw automaticRefusal(current, outcome.kept);
|
|
2391
|
+
return outcome.result;
|
|
2392
|
+
}
|
|
1868
2393
|
if (current.state === 'creating' || current.state === 'recovery') {
|
|
1869
2394
|
const rollback = await rollbackCreation(current);
|
|
1870
2395
|
return rollback.complete ? { removed: true } : { removed: false, retryable: true, remaining: rollback.remaining };
|
|
1871
2396
|
}
|
|
1872
2397
|
// Every deletion attempt of a snapshot, a resumed one included, waits
|
|
1873
2398
|
// for (refuses on) its consumers.
|
|
1874
|
-
const release = current.kind === WORKTREE_SNAPSHOT_KIND ? holdSnapshotFolder(current) : () => {};
|
|
2399
|
+
const release = current.kind === WORKTREE_SNAPSHOT_KIND ? await holdSnapshotFolder(current) : () => {};
|
|
1875
2400
|
try {
|
|
1876
2401
|
return await decideAndFinishDeletion(current, { force, preserveBranch, safeOnly });
|
|
1877
2402
|
} finally {
|
|
1878
|
-
release();
|
|
2403
|
+
await release();
|
|
1879
2404
|
}
|
|
1880
2405
|
}
|
|
1881
2406
|
|
|
@@ -1885,13 +2410,17 @@ async function removeLocked(current, { force, sessionId, preserveBranch = false,
|
|
|
1885
2410
|
* so an owner that came back (a live session, a running lease holder) or a
|
|
1886
2411
|
* session the store still keeps it for refuses (WORKTREE_BUSY_JOBS). Then
|
|
1887
2412
|
* the ordinary removal guards apply (dirty or unmerged work needs `force`).
|
|
1888
|
-
*
|
|
2413
|
+
* `allowRetained` (the user deleting it from the worktree list): a session
|
|
2414
|
+
* the store keeps no longer protects it, a live owner or another session's
|
|
2415
|
+
* shared reference still does.
|
|
2416
|
+
* @param {{ entryId: string, force?: boolean, liveSessionIds?: string[], retainedSessionIds?: string[], allowRetained?: boolean }} input
|
|
1889
2417
|
*/
|
|
1890
2418
|
export async function removeOrphanedWorktree({
|
|
1891
2419
|
entryId,
|
|
1892
2420
|
force = false,
|
|
1893
2421
|
liveSessionIds = [],
|
|
1894
2422
|
retainedSessionIds = [],
|
|
2423
|
+
allowRetained = false,
|
|
1895
2424
|
} = {}) {
|
|
1896
2425
|
const record = await getRecord(entryId);
|
|
1897
2426
|
if (!record) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
|
|
@@ -1902,6 +2431,13 @@ export async function removeOrphanedWorktree({
|
|
|
1902
2431
|
}
|
|
1903
2432
|
const { ownerLive, removable } = await retentionOf(current, new Set(liveSessionIds), new Set(retainedSessionIds));
|
|
1904
2433
|
if (ownerLive) throw worktreeRefusal('workspace-lease', { entryId });
|
|
2434
|
+
// The owner was just checked (not live; retained only with allowRetained):
|
|
2435
|
+
// only other sessions' shared references refuse below.
|
|
2436
|
+
const sessionId = current.ownerSessionId;
|
|
2437
|
+
if (allowRetained) {
|
|
2438
|
+
await assertRecoveryStoresClear(current);
|
|
2439
|
+
return removeLocked(current, { force, sessionId });
|
|
2440
|
+
}
|
|
1905
2441
|
if (!removable) {
|
|
1906
2442
|
// Retained recovery data names itself; otherwise sessions still keep it.
|
|
1907
2443
|
await assertRecoveryStoresClear(current);
|
|
@@ -1910,7 +2446,7 @@ export async function removeOrphanedWorktree({
|
|
|
1910
2446
|
sessions: [current.ownerSessionId, ...(current.sharedSessionIds ?? [])].filter(Boolean),
|
|
1911
2447
|
});
|
|
1912
2448
|
}
|
|
1913
|
-
return removeLocked(current, { force });
|
|
2449
|
+
return removeLocked(current, { force, sessionId });
|
|
1914
2450
|
});
|
|
1915
2451
|
}
|
|
1916
2452
|
|
|
@@ -1937,21 +2473,53 @@ function leaseProcessAlive(lease) {
|
|
|
1937
2473
|
* `retainedSessionIds`, i.e. still in the session store), `ownerLive` (a
|
|
1938
2474
|
* live session or a running lease holder), and `removable` (what a prune
|
|
1939
2475
|
* would remove: neither, or disposable and not live, with no retained recovery data).
|
|
1940
|
-
*
|
|
2476
|
+
* `inspect` adds, for a ready branch worktree, `dirty` (uncommitted paths,
|
|
2477
|
+
* and the ignored files no install restores, also counted as `ignored`:
|
|
2478
|
+
* both keep it from the automatic cleanup) and `unmerged` (commits the
|
|
2479
|
+
* merge target lacks; 0 when merged) — what the
|
|
2480
|
+
* cleanup policies and the worktree list read; `size` adds `sizeBytes` and
|
|
2481
|
+
* `sizeComplete` (cachedFolderBytes: a cached size returns at once and
|
|
2482
|
+
* refreshes in the background after its TTL).
|
|
2483
|
+
* @param {{ liveSessionIds?: string[], retainedSessionIds?: string[], inspect?: boolean, size?: boolean }} input
|
|
1941
2484
|
*/
|
|
1942
|
-
export async function listManagedWorktrees({
|
|
2485
|
+
export async function listManagedWorktrees({
|
|
2486
|
+
liveSessionIds = [],
|
|
2487
|
+
retainedSessionIds = [],
|
|
2488
|
+
inspect = false,
|
|
2489
|
+
size = false,
|
|
2490
|
+
} = {}) {
|
|
1943
2491
|
const live = new Set(liveSessionIds);
|
|
1944
2492
|
const retained = new Set(retainedSessionIds);
|
|
1945
2493
|
const rows = [];
|
|
1946
2494
|
for (const record of Object.values((await readRegistry()).entries)) {
|
|
1947
2495
|
const { ownerLive, retained: isRetained, removable } = await retentionOf(record, live, retained);
|
|
1948
|
-
|
|
2496
|
+
const row = {
|
|
1949
2497
|
...entryView(record),
|
|
1950
2498
|
projectPath: record.projectPath,
|
|
2499
|
+
sharedSessionIds: [...(record.sharedSessionIds ?? [])],
|
|
1951
2500
|
retained: isRetained,
|
|
1952
2501
|
ownerLive,
|
|
1953
2502
|
removable,
|
|
1954
|
-
}
|
|
2503
|
+
};
|
|
2504
|
+
if (inspect && record.kind !== WORKTREE_SNAPSHOT_KIND && record.state === 'ready') {
|
|
2505
|
+
try {
|
|
2506
|
+
const { dirty, ignored } = await automaticKeepPaths(record);
|
|
2507
|
+
row.dirty = dirty.length + ignored.length;
|
|
2508
|
+
row.ignored = ignored.length;
|
|
2509
|
+
row.unmerged = await unmergedCommitCount(record);
|
|
2510
|
+
} catch {
|
|
2511
|
+
// Unreadable state counts as work to keep.
|
|
2512
|
+
row.dirty = null;
|
|
2513
|
+
row.ignored = null;
|
|
2514
|
+
row.unmerged = null;
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
if (size) {
|
|
2518
|
+
const measured = existsSync(record.path) ? await cachedFolderBytes(record.path) : null;
|
|
2519
|
+
row.sizeBytes = measured?.bytes ?? null;
|
|
2520
|
+
row.sizeComplete = measured?.complete ?? true;
|
|
2521
|
+
}
|
|
2522
|
+
rows.push(row);
|
|
1955
2523
|
}
|
|
1956
2524
|
return rows;
|
|
1957
2525
|
}
|
|
@@ -1979,6 +2547,143 @@ async function retentionOf(record, live, retained) {
|
|
|
1979
2547
|
return { ownerLive, retained: isRetained, removable };
|
|
1980
2548
|
}
|
|
1981
2549
|
|
|
2550
|
+
/**
|
|
2551
|
+
* One automatic removal of the ready worktree `record` (the cleanup's count,
|
|
2552
|
+
* age, archive and orphan removals, and a deleted session's leftover), under
|
|
2553
|
+
* its repository lock. The slow checks (full status with untracked and
|
|
2554
|
+
* ignored files and include copies, the commit walk and merge-tree) run before the folder is
|
|
2555
|
+
* held, so no turn waits through them. Then the folder's workspace admission
|
|
2556
|
+
* is held (a turn, worker, shell or git command already in it refuses the
|
|
2557
|
+
* hold; one starting now waits), and under it: the owner lease and
|
|
2558
|
+
* `inUse(record)` (the caller's current live sessions and jobs), a cheap
|
|
2559
|
+
* token (HEAD, index, tips) against the one taken before the checks, and one
|
|
2560
|
+
* time-bounded full status (finalDirtyCheck). Only then is the deletion
|
|
2561
|
+
* decided, `automatic`: removeCheckoutVerified deletes through git alone and
|
|
2562
|
+
* an incomplete attempt goes back to `ready` (keepIncompleteRemoval).
|
|
2563
|
+
* @returns {Promise<{ result: object } | { kept: { reason: string } & object }>}
|
|
2564
|
+
*/
|
|
2565
|
+
async function removeAutomatically(
|
|
2566
|
+
record,
|
|
2567
|
+
{
|
|
2568
|
+
inUse = async () => false,
|
|
2569
|
+
preserveBranch = false,
|
|
2570
|
+
keepTip = false,
|
|
2571
|
+
finalCheckTimeoutMs = FINAL_DIRTY_CHECK_TIMEOUT_MS,
|
|
2572
|
+
} = {}
|
|
2573
|
+
) {
|
|
2574
|
+
const token = await checkoutToken(record);
|
|
2575
|
+
const { dirty, ignored, artefacts } = await automaticKeepPaths(record);
|
|
2576
|
+
const unmerged = await unmergedCommitCount(record);
|
|
2577
|
+
if (dirty.length || unmerged > 0) return { kept: { reason: dirty.length ? 'dirty' : 'unmerged' } };
|
|
2578
|
+
if (ignored.length) return { kept: ignoredKeep(ignored) };
|
|
2579
|
+
let release;
|
|
2580
|
+
try {
|
|
2581
|
+
release = await holdSnapshotFolder(record);
|
|
2582
|
+
} catch (error) {
|
|
2583
|
+
if (!snapshotInUse(error)) throw error;
|
|
2584
|
+
return { kept: { reason: 'in-use' } };
|
|
2585
|
+
}
|
|
2586
|
+
try {
|
|
2587
|
+
if (leaseProcessAlive(await readOwnerLease(record)) || (await inUse(record))) return { kept: { reason: 'in-use' } };
|
|
2588
|
+
if ((await checkoutToken(record)) !== token) return { kept: { reason: 'changed' } };
|
|
2589
|
+
// The token misses nested untracked files and unstaged edits: one
|
|
2590
|
+
// bounded full status decides. Dirty, slow or failed → kept.
|
|
2591
|
+
const finalCheck = await finalDirtyCheck(record, finalCheckTimeoutMs, artefacts);
|
|
2592
|
+
if (finalCheck) return { kept: finalCheck };
|
|
2593
|
+
await assertRecoveryStoresClear(record);
|
|
2594
|
+
record.state = 'deleting';
|
|
2595
|
+
delete record.removalIncomplete;
|
|
2596
|
+
record.deleting = {
|
|
2597
|
+
force: false,
|
|
2598
|
+
automatic: true,
|
|
2599
|
+
...(preserveBranch ? { preserveBranch: true } : keepTip ? { keepTip: true } : {}),
|
|
2600
|
+
// Checked above; finishDeletion trusts it while the tip is this one.
|
|
2601
|
+
mergedTip: record.branch ? await branchCommit(repoCwd(record), record.branch) : null,
|
|
2602
|
+
startedAt: Date.now(),
|
|
2603
|
+
remaining: [],
|
|
2604
|
+
};
|
|
2605
|
+
await putRecord(record);
|
|
2606
|
+
return { result: await finishDeletion(record) };
|
|
2607
|
+
} finally {
|
|
2608
|
+
await release();
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
/**
|
|
2613
|
+
* Whether the automatic pass would try to remove the ready, not-live
|
|
2614
|
+
* worktree `record` (the archive/policy rules of reconcileManagedWorktrees);
|
|
2615
|
+
* the verification (dirty, unmerged, in use) still decides at removal time.
|
|
2616
|
+
*/
|
|
2617
|
+
function cleanupWants(record, isRetained, { selected, favorites, minAgeMs, now, cleanupArchived }) {
|
|
2618
|
+
const ruleAllows = () =>
|
|
2619
|
+
![record.ownerSessionId, ...(record.sharedSessionIds ?? [])].some((id) => id && favorites.has(id)) &&
|
|
2620
|
+
now - (Number(record.createdAt) || 0) >= minAgeMs;
|
|
2621
|
+
if (!isRetained) return true;
|
|
2622
|
+
if (record.disposable === true) {
|
|
2623
|
+
if (record.disposableReason !== 'archived') return true;
|
|
2624
|
+
if (cleanupArchived && ruleAllows()) return true;
|
|
2625
|
+
}
|
|
2626
|
+
return selected.has(record.id) && ruleAllows();
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
/**
|
|
2630
|
+
* Read-only counterpart of reconcileManagedWorktrees: what a prune with the
|
|
2631
|
+
* same options could act on, from the registry, the retention and the git
|
|
2632
|
+
* admin folders alone (no status, merge or lock). A candidate may still be
|
|
2633
|
+
* kept at removal time after verification. Same options as the reconcile;
|
|
2634
|
+
* `removeOrphans` is implied.
|
|
2635
|
+
* @returns {Promise<{ ids: string[], stale: string[] }>} entry ids and the
|
|
2636
|
+
* checkout paths of registrations whose folder is gone
|
|
2637
|
+
*/
|
|
2638
|
+
export async function previewManagedWorktreeCleanup({
|
|
2639
|
+
liveSessionIds = [],
|
|
2640
|
+
retainedSessionIds = [],
|
|
2641
|
+
repoCommonDir: onlyRepo = null,
|
|
2642
|
+
removeEntryIds = [],
|
|
2643
|
+
protectedSessionIds = [],
|
|
2644
|
+
minAgeMs = 0,
|
|
2645
|
+
now = Date.now(),
|
|
2646
|
+
cleanupArchived = true,
|
|
2647
|
+
} = {}) {
|
|
2648
|
+
const live = new Set(liveSessionIds);
|
|
2649
|
+
const retained = new Set(retainedSessionIds);
|
|
2650
|
+
const rules = {
|
|
2651
|
+
selected: new Set(removeEntryIds),
|
|
2652
|
+
favorites: new Set(protectedSessionIds),
|
|
2653
|
+
minAgeMs,
|
|
2654
|
+
now,
|
|
2655
|
+
cleanupArchived,
|
|
2656
|
+
};
|
|
2657
|
+
const ids = [];
|
|
2658
|
+
const repos = new Map();
|
|
2659
|
+
for (const record of Object.values((await readRegistry()).entries)) {
|
|
2660
|
+
const key = pathKey(record.repoCommonDir);
|
|
2661
|
+
if (onlyRepo && key !== pathKey(onlyRepo)) continue;
|
|
2662
|
+
repos.set(key, record.repoCommonDir);
|
|
2663
|
+
if (record.sharedSessionIds?.length) continue;
|
|
2664
|
+
const { ownerLive, retained: isRetained } = await retentionOf(record, live, retained);
|
|
2665
|
+
if (record.kind === WORKTREE_SNAPSHOT_KIND) {
|
|
2666
|
+
if (!ownerLive) ids.push(record.id);
|
|
2667
|
+
} else if (record.state === 'deleting') {
|
|
2668
|
+
ids.push(record.id);
|
|
2669
|
+
} else if (record.state === 'creating' || record.state === 'recovery') {
|
|
2670
|
+
if (!isRetained) ids.push(record.id);
|
|
2671
|
+
} else if (!ownerLive && (record.removalIncomplete || cleanupWants(record, isRetained, rules))) {
|
|
2672
|
+
ids.push(record.id);
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
const stale = [];
|
|
2676
|
+
for (const [, repoCommonDir] of repos) {
|
|
2677
|
+
if (!existsSync(repoCommonDir)) continue;
|
|
2678
|
+
for (const registration of await adminRegistrations(repoCommonDir)) {
|
|
2679
|
+
// git keeps a locked registration (a `locked` file) through prune.
|
|
2680
|
+
if (existsSync(join(registration.admin, 'locked'))) continue;
|
|
2681
|
+
if (await isGone(registration.worktreePath)) stale.push(canonicalPath(registration.worktreePath));
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
return { ids, stale };
|
|
2685
|
+
}
|
|
2686
|
+
|
|
1982
2687
|
/**
|
|
1983
2688
|
* Conservative reconcile (daemon startup, and the manual prune). Always:
|
|
1984
2689
|
* resumes interrupted deletions, rolls back interrupted creations of no
|
|
@@ -1990,20 +2695,62 @@ async function retentionOf(record, live, retained) {
|
|
|
1990
2695
|
* session in the store still references. A snapshot whose holder is neither
|
|
1991
2696
|
* live nor running in another process is removed in either mode.
|
|
1992
2697
|
* `repoCommonDir` limits the whole reconcile to that repository.
|
|
1993
|
-
*
|
|
2698
|
+
*
|
|
2699
|
+
* The automatic cleanup (maintenance.mjs) adds, all with `removeOrphans`:
|
|
2700
|
+
* `removeEntryIds` — worktrees its count/age limits selected, removed even
|
|
2701
|
+
* though a stored session still names them; `protectedSessionIds` (favorite
|
|
2702
|
+
* sessions) and `minAgeMs`/`now` (younger worktrees) exempt those and
|
|
2703
|
+
* archived-session marks (`disposableReason: 'archived'`, which
|
|
2704
|
+
* `cleanupArchived: false` ignores); a deleted session's mark and a true
|
|
2705
|
+
* orphan are not subject to them. `preserveBranch` keeps the branch of every
|
|
2706
|
+
* worktree it removes; otherwise `keepTip` writes the recovery ref before the
|
|
2707
|
+
* branch goes. `maxRemovals` caps the count/age/archive removals of one pass
|
|
2708
|
+
* together (`kept: budget` beyond it). Dirty, unmerged, live, shared or
|
|
2709
|
+
* recovery-holding worktrees are always kept, as are ones holding ignored
|
|
2710
|
+
* files no install restores (`kept: ignored` with `files`, `count`:
|
|
2711
|
+
* unreproducibleIgnored), and every removal first holds
|
|
2712
|
+
* the folder's workspace admission (a turn, worker, shell or git command in
|
|
2713
|
+
* it keeps it; one starting meanwhile waits) and asks `inUse(record)` again
|
|
2714
|
+
* under that hold (the caller's current live sessions and running jobs).
|
|
2715
|
+
* @param {{ liveSessionIds?: string[], retainedSessionIds?: string[], removeOrphans?: boolean,
|
|
2716
|
+
* repoCommonDir?: string|null, removeEntryIds?: string[], protectedSessionIds?: string[],
|
|
2717
|
+
* minAgeMs?: number, now?: number, cleanupArchived?: boolean, preserveBranch?: boolean, keepTip?: boolean,
|
|
2718
|
+
* maxRemovals?: number, inUse?: (record: object) => Promise<boolean>|boolean,
|
|
2719
|
+
* finalCheckTimeoutMs?: number }} input
|
|
2720
|
+
* `finalCheckTimeoutMs` bounds the last dirty check under the hold (FINAL_DIRTY_CHECK_TIMEOUT_MS);
|
|
2721
|
+
* a check that does not finish in time keeps the worktree (`kept: busy`).
|
|
1994
2722
|
*/
|
|
1995
2723
|
export async function reconcileManagedWorktrees({
|
|
1996
2724
|
liveSessionIds = [],
|
|
1997
2725
|
retainedSessionIds = [],
|
|
1998
2726
|
removeOrphans = false,
|
|
1999
2727
|
repoCommonDir: onlyRepo = null,
|
|
2728
|
+
removeEntryIds = [],
|
|
2729
|
+
protectedSessionIds = [],
|
|
2730
|
+
minAgeMs = 0,
|
|
2731
|
+
now = Date.now(),
|
|
2732
|
+
cleanupArchived = true,
|
|
2733
|
+
preserveBranch = false,
|
|
2734
|
+
keepTip = false,
|
|
2735
|
+
maxRemovals = Infinity,
|
|
2736
|
+
inUse = async () => false,
|
|
2737
|
+
finalCheckTimeoutMs = FINAL_DIRTY_CHECK_TIMEOUT_MS,
|
|
2000
2738
|
} = {}) {
|
|
2001
2739
|
const live = new Set(liveSessionIds);
|
|
2002
2740
|
const retained = new Set(retainedSessionIds);
|
|
2741
|
+
const selected = new Set(removeEntryIds);
|
|
2742
|
+
let budgetUsed = 0;
|
|
2743
|
+
const rules = { selected, favorites: new Set(protectedSessionIds), minAgeMs, now, cleanupArchived };
|
|
2744
|
+
const wanted = (record, isRetained) => cleanupWants(record, isRetained, rules);
|
|
2003
2745
|
const report = { removed: [], recovered: [], kept: [], leasesReleased: [], pruned: [], missing: [], errors: [] };
|
|
2004
2746
|
const reportDeletion = (record, result) => {
|
|
2005
2747
|
if (result.removed) report.removed.push(record.id);
|
|
2006
|
-
else
|
|
2748
|
+
else
|
|
2749
|
+
report.kept.push({
|
|
2750
|
+
id: record.id,
|
|
2751
|
+
reason: result.incomplete ? 'incomplete' : 'deleting',
|
|
2752
|
+
remaining: result.remaining,
|
|
2753
|
+
});
|
|
2007
2754
|
};
|
|
2008
2755
|
const byRepo = new Map();
|
|
2009
2756
|
for (const record of Object.values((await readRegistry()).entries)) {
|
|
@@ -2044,7 +2791,7 @@ export async function reconcileManagedWorktrees({
|
|
|
2044
2791
|
// surviving folder since still keeps it.
|
|
2045
2792
|
let release;
|
|
2046
2793
|
try {
|
|
2047
|
-
release = holdSnapshotFolder(record);
|
|
2794
|
+
release = await holdSnapshotFolder(record);
|
|
2048
2795
|
} catch (error) {
|
|
2049
2796
|
if (!snapshotInUse(error)) throw error;
|
|
2050
2797
|
report.kept.push({ id: record.id, reason: 'snapshot-in-use' });
|
|
@@ -2053,10 +2800,23 @@ export async function reconcileManagedWorktrees({
|
|
|
2053
2800
|
try {
|
|
2054
2801
|
reportDeletion(record, await finishDeletion(record));
|
|
2055
2802
|
} finally {
|
|
2056
|
-
release();
|
|
2803
|
+
await release();
|
|
2057
2804
|
}
|
|
2058
2805
|
continue;
|
|
2059
2806
|
}
|
|
2807
|
+
if (record.state === 'deleting' && record.deleting?.automatic) {
|
|
2808
|
+
// An automatic removal that was interrupted is never resumed
|
|
2809
|
+
// blindly: what it took apart is put back (its recorded undo)
|
|
2810
|
+
// and the record is ready again, verified afresh by a later pass.
|
|
2811
|
+
const notRestored = await undoAutomaticAttempt(record, record.deleting.undo);
|
|
2812
|
+
await keepIncompleteRemoval(
|
|
2813
|
+
record,
|
|
2814
|
+
[...(record.deleting.remaining ?? []), ...notRestored],
|
|
2815
|
+
'interrupted'
|
|
2816
|
+
);
|
|
2817
|
+
report.kept.push({ id: record.id, reason: 'incomplete', remaining: record.removalIncomplete.remaining });
|
|
2818
|
+
continue;
|
|
2819
|
+
}
|
|
2060
2820
|
if (record.state === 'deleting') {
|
|
2061
2821
|
// A decided deletion: its session already left the folder.
|
|
2062
2822
|
reportDeletion(record, await finishDeletion(record));
|
|
@@ -2069,7 +2829,7 @@ export async function reconcileManagedWorktrees({
|
|
|
2069
2829
|
let release = null;
|
|
2070
2830
|
if (!(await snapshotHolderLive(record, live))) {
|
|
2071
2831
|
try {
|
|
2072
|
-
release = holdSnapshotFolder(record);
|
|
2832
|
+
release = await holdSnapshotFolder(record);
|
|
2073
2833
|
} catch (error) {
|
|
2074
2834
|
if (!snapshotInUse(error)) throw error;
|
|
2075
2835
|
}
|
|
@@ -2085,7 +2845,7 @@ export async function reconcileManagedWorktrees({
|
|
|
2085
2845
|
await putRecord(record);
|
|
2086
2846
|
reportDeletion(record, await finishDeletion(record));
|
|
2087
2847
|
} finally {
|
|
2088
|
-
release();
|
|
2848
|
+
await release();
|
|
2089
2849
|
}
|
|
2090
2850
|
continue;
|
|
2091
2851
|
}
|
|
@@ -2096,26 +2856,31 @@ export async function reconcileManagedWorktrees({
|
|
|
2096
2856
|
report.leasesReleased.push(record.id);
|
|
2097
2857
|
}
|
|
2098
2858
|
if (!existsSync(record.path)) report.missing.push(record.id);
|
|
2099
|
-
if (removeOrphans === true && !ownerLive && (record
|
|
2100
|
-
|
|
2101
|
-
const
|
|
2102
|
-
if (
|
|
2103
|
-
report.kept.push({ id: record.id, reason:
|
|
2859
|
+
if (removeOrphans === true && !ownerLive && wanted(record, isRetained)) {
|
|
2860
|
+
// Count, age and archive removals share one budget per pass.
|
|
2861
|
+
const budgeted = isRetained && (selected.has(record.id) || record.disposableReason === 'archived');
|
|
2862
|
+
if (budgeted && budgetUsed >= maxRemovals) {
|
|
2863
|
+
report.kept.push({ id: record.id, reason: 'budget' });
|
|
2104
2864
|
continue;
|
|
2105
2865
|
}
|
|
2106
|
-
await
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2866
|
+
const outcome = await removeAutomatically(record, {
|
|
2867
|
+
inUse,
|
|
2868
|
+
preserveBranch,
|
|
2869
|
+
keepTip,
|
|
2870
|
+
finalCheckTimeoutMs,
|
|
2871
|
+
});
|
|
2872
|
+
if (!outcome.result) {
|
|
2873
|
+
report.kept.push({ id: record.id, ...outcome.kept });
|
|
2874
|
+
continue;
|
|
2875
|
+
}
|
|
2876
|
+
reportDeletion(record, outcome.result);
|
|
2877
|
+
if (budgeted && outcome.result.removed) budgetUsed += 1;
|
|
2111
2878
|
}
|
|
2112
2879
|
} catch (error) {
|
|
2113
2880
|
report.errors.push({ id: record.id, error: String(error?.message || error) });
|
|
2114
2881
|
}
|
|
2115
2882
|
}
|
|
2116
|
-
|
|
2117
|
-
if (await pruneRegistration(repoCommonDir, stale)) report.pruned.push(canonicalPath(stale));
|
|
2118
|
-
}
|
|
2883
|
+
report.pruned.push(...(await pruneStaleRegistrations(repoCommonDir)));
|
|
2119
2884
|
});
|
|
2120
2885
|
} catch (error) {
|
|
2121
2886
|
report.errors.push({ repoCommonDir, error: String(error?.message || error) });
|