mixdog 1.4.2 → 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 +2 -2
- package/scripts/build-shipped-history.mjs +15 -5
- package/scripts/gate-local.mjs +65 -19
- package/scripts/graph-parity.mjs +8 -3
- package/scripts/lib/gate-scheduler.mjs +2 -1
- package/scripts/lib/run-node-tests.mjs +17 -5
- package/scripts/lib/test-failure-summary.mjs +1 -8
- package/scripts/lib/test-spawn-ledger.mjs +33 -2
- package/scripts/post-release-check.mjs +26 -16
- package/scripts/provider-pingpong-live.mjs +1 -1
- package/scripts/prune-embedding-runtime.mjs +3 -1
- package/scripts/release-paths.mjs +7 -1
- package/src/defaults/shipped-history.json +20 -0
- package/src/defaults/skills/setup/SKILL.md +2 -2
- package/src/defaults/skills/setup/references/actions.md +16 -3
- package/src/defaults/skills/setup/references/surfaces.md +3 -0
- package/src/defaults/skills/setup-guide/SKILL.md +6 -1
- package/src/lib/instruction-text.cjs +36 -0
- package/src/lib/rules-builder.cjs +34 -20
- 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/runtime/agent/orchestrator/agent-runtime/agent-dispatch/session-prep.mjs +3 -3
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +0 -1
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -2
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +0 -9
- package/src/runtime/agent/orchestrator/agent-runtime/heartbeat-completion.mjs +2 -2
- 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/context/role-instructions.mjs +4 -4
- package/src/runtime/agent/orchestrator/mcp/agent-connectors.mjs +3 -1
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +199 -46
- package/src/runtime/agent/orchestrator/mcp/client-config.mjs +3 -5
- package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +9 -3
- package/src/runtime/agent/orchestrator/mcp/client.mjs +3 -5
- 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-probe.mjs +1 -1
- package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +14 -0
- package/src/runtime/agent/orchestrator/mcp/oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/content-block-stop.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +2 -2
- 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-responses-payload-parity.fixture.json +288 -300
- package/src/runtime/agent/orchestrator/providers/vertex-ai.mjs +10 -1
- package/src/runtime/agent/orchestrator/providers/xai-tool-search-head-parity.json +10 -30
- package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +30 -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 -48
- 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 +9 -0
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +119 -35
- package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +1 -1
- 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/attachment-preflight.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/cache-warm.mjs +2 -2
- 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 +1 -1
- package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +7 -3
- package/src/runtime/agent/orchestrator/session/human-gate/held-message.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/human-gate/requests.mjs +1 -2
- 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/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 +6 -1
- package/src/runtime/agent/orchestrator/session/loop/tool-exec/dispatch.mjs +16 -2
- 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 +57 -26
- 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 +19 -0
- package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +19 -10
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/manager/session-fallback-title.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +31 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +31 -0
- package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +42 -6
- package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +11 -1
- 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 +1 -1
- 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 +1 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +8 -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-transcript-projection.mjs +14 -5
- package/src/runtime/agent/orchestrator/session/store-transcript-worker.mjs +2 -2
- 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/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 +7 -3
- package/src/runtime/agent/orchestrator/tools/builtin/git-worktree-tool.mjs +27 -9
- package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +2 -2
- 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/search-tool.mjs +0 -10
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +1 -2
- 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/lib/shell-descendants.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/lib/shell-run-state.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +0 -4
- package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +0 -1
- 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-server.mjs +12 -12
- 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 +169 -33
- package/src/runtime/connectors/gmail-app-password.mjs +2 -2
- 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 +0 -5
- package/src/runtime/explore/tool.mjs +3 -3
- 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 +11 -1
- package/src/runtime/memory/lib/embedding-reindex.mjs +27 -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/shared/atomic-file.d.mts +8 -0
- package/src/runtime/shared/background-tasks.mjs +22 -4
- 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-catalog.d.mts +4 -5
- package/src/runtime/shared/connector-catalog.mjs +11 -1
- package/src/runtime/shared/file-lock.mjs +8 -3
- package/src/runtime/shared/git-runner.mjs +7 -2
- package/src/runtime/shared/idle-gc.mjs +30 -7
- package/src/runtime/shared/llm/http-agent.mjs +8 -5
- package/src/runtime/shared/llm/system-proxy.mjs +2 -1
- 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/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/tool-card-model.golden.json +3 -12
- package/src/runtime/shared/tool-execution-contract.mjs +2 -0
- package/src/runtime/shared/tool-work-units.mjs +31 -6
- package/src/runtime/shared/turn-snapshot.mjs +26 -4
- package/src/runtime/shared/turn-worktree-snapshot.mjs +6 -11
- package/src/runtime/shared/worker-requests.mjs +17 -3
- package/src/runtime/shared/worktrees/branch-management.mjs +1 -5
- 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 +44 -11
- 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 +772 -75
- 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/process.mjs +2 -1
- package/src/runtime/web-search/index.mjs +3 -22
- 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/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 +69 -25
- 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/conversation-import/discovery.mjs +86 -7
- package/src/session-runtime/conversation-import/service.mjs +3 -2
- package/src/session-runtime/goal-facade-api.mjs +49 -3
- 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 +3 -11
- package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +18 -4
- 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 +4 -4
- package/src/session-runtime/lifecycle-api.mjs +1 -0
- package/src/session-runtime/mcp-glue/server-catalog.mjs +14 -3
- package/src/session-runtime/mcp-glue/server-connection.mjs +34 -8
- 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/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-connector-accounts.mjs +5 -1
- package/src/session-runtime/resource-mcp-secrets.mjs +3 -3
- 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 +110 -6
- 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-run.mjs +24 -6
- package/src/session-runtime/services/agent-tool.mjs +51 -1
- package/src/session-runtime/services/channel-admin.mjs +18 -0
- package/src/session-runtime/services/hook-bus/command-handler.mjs +7 -11
- package/src/session-runtime/services/plugin-admin.mjs +4 -0
- 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/settings-builtin-tools-api.mjs +1 -9
- package/src/session-runtime/setup-tool/connector-widen-approval.mjs +1 -1
- package/src/session-runtime/setup-tool/executor.mjs +8 -1
- 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 +26 -1
- 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 +10 -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 +1 -4
- package/src/session-runtime/workflow-agents-api/agent-route.mjs +3 -10
- 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/project-branches.mjs +1 -1
- package/src/session-runtime/workspace/session-view.mjs +1 -1
- package/src/session-runtime/workspace/transitions.mjs +20 -4
- package/src/session-runtime/workspace/worktree-maintenance.mjs +126 -6
- package/src/session-runtime/workspace/worktree-setup.mjs +13 -1
- package/src/session-runtime/workspace/worktree-tool.mjs +16 -0
- package/src/standalone/daemon-agent-control.mjs +25 -8
- package/src/standalone/daemon-desktop-runtime.mjs +6 -2
- package/src/standalone/daemon-lag-profiler.mjs +7 -7
- package/src/standalone/daemon.mjs +54 -2
- package/src/standalone/local-session-runtime.mjs +16 -0
- package/src/standalone/session-client/daemon-unavailable.mjs +1 -1
- package/src/standalone/session-protocol.mjs +11 -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/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/turn-gate.mjs +34 -0
- package/src/standalone/session-service/session-calls.mjs +2 -1
- package/src/tui/App.jsx +8 -0
- package/src/tui/app/clipboard.mjs +1 -1
- 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/settings-picker.mjs +53 -8
- package/src/tui/app/shell-layout/row-budgets.mjs +2 -2
- package/src/tui/app/slash-commands.mjs +1 -1
- package/src/tui/app/slash-dispatch/route-commands.mjs +1 -1
- package/src/tui/app/slash-dispatch/session-commands.mjs +1 -1
- package/src/tui/app/use-prompt-queue-history.mjs +2 -2
- package/src/tui/app/use-transcript-activity.mjs +1 -1
- package/src/tui/app/use-transcript-window/geometry.mjs +1 -1
- 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/tool-execution/standard-card.jsx +3 -4
- package/src/tui/components/user-message-skills.mjs +1 -1
- package/src/tui/dist/index.mjs +309 -30
- 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/quota-resume.mjs +1 -1
- 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 +5 -0
- 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/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/runtime/agent/orchestrator/runtime-core/goal-tasks.mjs +0 -156
- package/src/session-runtime/goal-tasks.mjs +0 -1
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
restoreFromRecoveryRef,
|
|
60
60
|
uncommittedChanges,
|
|
61
61
|
} from './carry.mjs';
|
|
62
|
-
import { freeBytesAt, requiredBytes, trackedCheckoutBytes } from './disk.mjs';
|
|
62
|
+
import { cachedFolderBytes, forgetFolderSize, freeBytesAt, requiredBytes, trackedCheckoutBytes } from './disk.mjs';
|
|
63
63
|
import {
|
|
64
64
|
branchCommit,
|
|
65
65
|
currentBranch,
|
|
@@ -68,7 +68,9 @@ import {
|
|
|
68
68
|
inspectRepo,
|
|
69
69
|
isDiskFullError,
|
|
70
70
|
listWorktrees,
|
|
71
|
+
localBranchCommits,
|
|
71
72
|
localBranches,
|
|
73
|
+
parseStatusEntries,
|
|
72
74
|
parseStatusPaths,
|
|
73
75
|
remoteDefaultBranches,
|
|
74
76
|
runGit,
|
|
@@ -85,7 +87,7 @@ import {
|
|
|
85
87
|
removeIncludeLinks,
|
|
86
88
|
restoreIncludeLinks,
|
|
87
89
|
} from './include.mjs';
|
|
88
|
-
import {
|
|
90
|
+
import { dependencyArtefacts, linkedArtefacts } from './dependencies.mjs';
|
|
89
91
|
import { withRepoLock as withRepoMutationLock } from './lock.mjs';
|
|
90
92
|
import { holdWorkspaceActivityAsync } from './activity-gate.mjs';
|
|
91
93
|
import { withAdvisoryLocks } from '../../agent/orchestrator/tools/builtin/advisory-lock.mjs';
|
|
@@ -125,15 +127,23 @@ function sessionRootOf(record) {
|
|
|
125
127
|
// ---------------------------------------------------------------------------
|
|
126
128
|
// Host configuration (settings source, free-space probe); tests override it.
|
|
127
129
|
|
|
128
|
-
const DEFAULT_CONFIG = Object.freeze({
|
|
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
|
+
});
|
|
129
137
|
let config = { ...DEFAULT_CONFIG };
|
|
130
138
|
|
|
131
139
|
/**
|
|
132
140
|
* Override host hooks. `settings`: stored worktree settings object or a
|
|
133
141
|
* function returning it (default: agent config `worktree`). `freeBytes(dir)`:
|
|
134
|
-
* free-space probe. `processAlive(pid)`: lease holder liveness.
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
|
137
147
|
*/
|
|
138
148
|
export function configureWorktreeManager(overrides = {}) {
|
|
139
149
|
const previous = config;
|
|
@@ -291,16 +301,22 @@ async function keepRemovedTip(record) {
|
|
|
291
301
|
return { tip, recoveryRef: ref };
|
|
292
302
|
}
|
|
293
303
|
|
|
294
|
-
/**
|
|
295
|
-
|
|
296
|
-
|
|
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) {
|
|
297
311
|
const stale = [];
|
|
298
|
-
for (const
|
|
299
|
-
if (
|
|
300
|
-
const rel = relative(pathKey(root), pathKey(worktreePath));
|
|
301
|
-
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);
|
|
302
314
|
}
|
|
303
|
-
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));
|
|
304
320
|
}
|
|
305
321
|
|
|
306
322
|
async function setResource(record, name, status) {
|
|
@@ -326,23 +342,223 @@ async function unmergedCommitCount(record) {
|
|
|
326
342
|
const reference = (record.mergeTarget && (await branchCommit(cwd, record.mergeTarget))) || record.baseCommit;
|
|
327
343
|
if (!reference) return 0;
|
|
328
344
|
const result = await runGit(cwd, ['rev-list', '--count', tip, '--not', reference]);
|
|
329
|
-
|
|
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));
|
|
330
417
|
}
|
|
331
418
|
|
|
332
419
|
/** Uncommitted/untracked paths plus modified include copies. */
|
|
333
420
|
async function dirtyPaths(record) {
|
|
334
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) {
|
|
335
458
|
const includes = record.resources?.includes || [];
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
)
|
|
345
|
-
|
|
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
|
+
}
|
|
346
562
|
}
|
|
347
563
|
|
|
348
564
|
async function deleteBranchIfFree(record) {
|
|
@@ -506,6 +722,96 @@ async function removeCheckoutSafely(record) {
|
|
|
506
722
|
return [];
|
|
507
723
|
}
|
|
508
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
|
+
|
|
509
815
|
/** Report what a failed safe removal could not put back on its error (never swallowed). */
|
|
510
816
|
function withRollbackFailures(error, failures) {
|
|
511
817
|
if (!failures.length) return error;
|
|
@@ -593,6 +899,21 @@ async function rollbackCreation(record, cause) {
|
|
|
593
899
|
return { complete: false, remaining };
|
|
594
900
|
}
|
|
595
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
|
+
|
|
596
917
|
/** finishDeletion's folder step; what is left over goes to `remaining`. */
|
|
597
918
|
async function removeFolder(record, remaining) {
|
|
598
919
|
await removeOwnerLease(record);
|
|
@@ -604,7 +925,12 @@ async function removeFolder(record, remaining) {
|
|
|
604
925
|
for (const result of results) {
|
|
605
926
|
if (!result.ok) remaining.push({ kind: 'cleanup', name: result.name, error: result.error.message });
|
|
606
927
|
}
|
|
607
|
-
|
|
928
|
+
const remove = record.deleting?.automatic
|
|
929
|
+
? removeCheckoutVerified
|
|
930
|
+
: record.deleting?.safeOnly
|
|
931
|
+
? removeCheckoutSafely
|
|
932
|
+
: removeCheckout;
|
|
933
|
+
remaining.push(...(await remove(record)));
|
|
608
934
|
}
|
|
609
935
|
|
|
610
936
|
/**
|
|
@@ -629,6 +955,7 @@ async function finishDeletion(record) {
|
|
|
629
955
|
} catch (error) {
|
|
630
956
|
remaining.push({ kind: 'error', error: String(error?.message || error) });
|
|
631
957
|
}
|
|
958
|
+
if (remaining.length && record.deleting?.automatic) return keepIncompleteRemoval(record, remaining);
|
|
632
959
|
if (remaining.length) {
|
|
633
960
|
record.deleting = { ...record.deleting, remaining, lastAttemptAt: Date.now() };
|
|
634
961
|
await putRecord(record);
|
|
@@ -636,14 +963,21 @@ async function finishDeletion(record) {
|
|
|
636
963
|
}
|
|
637
964
|
let branch = { deleted: false, reason: 'kept' };
|
|
638
965
|
let kept = { tip: null, recoveryRef: null };
|
|
639
|
-
|
|
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;
|
|
640
972
|
if (record.branch && !record.deleting?.preserveBranch && (record.deleting?.force || merged)) {
|
|
641
|
-
// Forced past unmerged commits
|
|
642
|
-
|
|
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);
|
|
643
976
|
else kept.tip = await branchCommit(repoCwd(record), record.branch);
|
|
644
977
|
branch = await deleteBranchIfFree(record);
|
|
645
978
|
}
|
|
646
979
|
await deleteRecord(record.id);
|
|
980
|
+
forgetFolderSize(record.path);
|
|
647
981
|
await pruneEmptyRepoRoot(record.repoCommonDir);
|
|
648
982
|
return {
|
|
649
983
|
removed: true,
|
|
@@ -847,11 +1181,42 @@ export async function markWorktreeDisposable(entryId, disposable = true) {
|
|
|
847
1181
|
const current = await getRecord(entryId);
|
|
848
1182
|
if (!current) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
|
|
849
1183
|
current.disposable = disposable === true;
|
|
1184
|
+
// A deleted session's mark: no archive rule applies to it.
|
|
1185
|
+
delete current.disposableReason;
|
|
850
1186
|
await putRecord(current);
|
|
851
1187
|
return entryView(current);
|
|
852
1188
|
});
|
|
853
1189
|
}
|
|
854
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
|
+
|
|
855
1220
|
/**
|
|
856
1221
|
* Before a creation (under the repository lock): the entry this transaction
|
|
857
1222
|
* or session already has ready (its lease re-written), else null once every
|
|
@@ -1043,7 +1408,7 @@ function newCreationRecord({
|
|
|
1043
1408
|
|
|
1044
1409
|
/**
|
|
1045
1410
|
* Locks the fresh checkout, binds its owner lease, moves or leaves the
|
|
1046
|
-
* project folder's changes
|
|
1411
|
+
* project folder's changes and applies the includes.
|
|
1047
1412
|
* `carry.moved` is set as soon as changes were carried, so a later failure
|
|
1048
1413
|
* can put them back.
|
|
1049
1414
|
*/
|
|
@@ -1087,15 +1452,6 @@ async function populateCheckout({
|
|
|
1087
1452
|
}
|
|
1088
1453
|
const applied = await applyIncludes(includePlan, record.path, (ledger) => recordInclude(record, ledger));
|
|
1089
1454
|
throwIfAborted(signal);
|
|
1090
|
-
// After the includes: one that already brought a node_modules wins.
|
|
1091
|
-
const linkedDependencies = await linkWorktreeDependencies({
|
|
1092
|
-
mainDir: repo.projectPath,
|
|
1093
|
-
worktreeDir: projectSubdir ? join(record.path, ...projectSubdir.split('/')) : record.path,
|
|
1094
|
-
worktreeRel: projectSubdir,
|
|
1095
|
-
signal,
|
|
1096
|
-
record: (ledger) => recordInclude(record, ledger),
|
|
1097
|
-
});
|
|
1098
|
-
throwIfAborted(signal);
|
|
1099
1455
|
record.warnings = [...record.warnings, ...applied.warnings];
|
|
1100
1456
|
const made = (record.resources.includes || []).filter((item) => item.status === 'created');
|
|
1101
1457
|
const rels = (predicate) => made.filter(predicate).map((item) => item.rel);
|
|
@@ -1103,13 +1459,12 @@ async function populateCheckout({
|
|
|
1103
1459
|
const prepared = {
|
|
1104
1460
|
root: repo.topLevel,
|
|
1105
1461
|
copied: rels((item) => item.mode === 'copy'),
|
|
1106
|
-
linked: rels((item) => item.mode === 'link'
|
|
1107
|
-
dependencies: linkedDependencies,
|
|
1462
|
+
linked: rels((item) => item.mode === 'link'),
|
|
1108
1463
|
skipped: [...includePlan.warnings, ...applied.warnings],
|
|
1109
1464
|
};
|
|
1110
1465
|
// Whichever path made it (a worktree task, a branch selection, `git
|
|
1111
|
-
// worktree add`), the first turn a session runs here
|
|
1112
|
-
// dependencies (claimWorktreeSetup).
|
|
1466
|
+
// worktree add`), the first turn a session runs here installs its
|
|
1467
|
+
// dependencies in the worktree folder (claimWorktreeSetup).
|
|
1113
1468
|
record.setup = { pending: true, prepared };
|
|
1114
1469
|
record.state = 'ready';
|
|
1115
1470
|
await putRecord(record);
|
|
@@ -1213,11 +1568,20 @@ export async function createManagedWorktree({
|
|
|
1213
1568
|
});
|
|
1214
1569
|
throwIfAborted(signal);
|
|
1215
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.
|
|
1216
1573
|
const branches = new Set(
|
|
1217
1574
|
(await localBranches(repo.projectPath, { signal, prefix: settings.branchPrefix })).map((name) =>
|
|
1218
1575
|
name.toLowerCase()
|
|
1219
1576
|
)
|
|
1220
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
|
+
};
|
|
1221
1585
|
const base = slugFromHint(nameHint);
|
|
1222
1586
|
const reserve = async () => {
|
|
1223
1587
|
let reserved = null;
|
|
@@ -1282,10 +1646,12 @@ export async function createManagedWorktree({
|
|
|
1282
1646
|
base,
|
|
1283
1647
|
(candidate) =>
|
|
1284
1648
|
ids.has(candidate.toLowerCase()) ||
|
|
1285
|
-
|
|
1649
|
+
branchTaken(`${settings.branchPrefix}${candidate}`) ||
|
|
1286
1650
|
existsSync(join(root, candidate))
|
|
1287
1651
|
);
|
|
1288
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));
|
|
1289
1655
|
if (!(await validBranchName(repo.projectPath, branch))) {
|
|
1290
1656
|
throw worktreeError('WORKTREE_UNSUPPORTED_REPO', `Branch name ${branch} is not valid.`, { branch });
|
|
1291
1657
|
}
|
|
@@ -1497,21 +1863,78 @@ export async function removeSessionSnapshots(sessionId, { txIds = null } = {}) {
|
|
|
1497
1863
|
return report;
|
|
1498
1864
|
}
|
|
1499
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
|
+
|
|
1500
1894
|
/**
|
|
1501
1895
|
* The first turn a session runs in managed worktree `entryId` claims its
|
|
1502
1896
|
* dependency preparation, once: `{ prepared }` (what creation did) while it
|
|
1503
1897
|
* is still pending, else null. Claimed before it runs, so a failed or
|
|
1504
|
-
* 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.
|
|
1505
1908
|
*/
|
|
1506
1909
|
export async function claimWorktreeSetup(entryId) {
|
|
1507
1910
|
const record = await getRecord(entryId);
|
|
1508
|
-
if (!record?.setup?.pending) return null;
|
|
1911
|
+
if (!record?.setup?.pending && !dependencyLinks(record).length) return null;
|
|
1509
1912
|
return withRepoLock(record.repoCommonDir, async () => {
|
|
1510
1913
|
const current = await getRecord(entryId);
|
|
1511
|
-
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
|
+
}
|
|
1512
1935
|
current.setup = { ...current.setup, pending: false, claimedAt: Date.now() };
|
|
1513
1936
|
await putRecord(current);
|
|
1514
|
-
return { prepared: current.setup.prepared ?? null };
|
|
1937
|
+
return { prepared: pending ? (current.setup.prepared ?? null) : null, shared };
|
|
1515
1938
|
});
|
|
1516
1939
|
}
|
|
1517
1940
|
|
|
@@ -1622,11 +2045,16 @@ export async function releaseWorktreeOwner(entryId, owner, { forgetReference = f
|
|
|
1622
2045
|
export async function listBranchOccupancy({ projectPath, sessionId } = {}) {
|
|
1623
2046
|
requireText(projectPath, 'projectPath');
|
|
1624
2047
|
const repo = await inspectRepo(projectPath);
|
|
1625
|
-
const [
|
|
1626
|
-
|
|
2048
|
+
const [commits, worktrees, doc] = await Promise.all([
|
|
2049
|
+
localBranchCommits(repo.projectPath),
|
|
1627
2050
|
listWorktrees(repo.projectPath),
|
|
1628
2051
|
readRegistry(),
|
|
1629
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
|
+
);
|
|
1630
2058
|
const managed = Object.values(doc.entries).filter((record) => samePath(record.repoCommonDir, repo.repoCommonDir));
|
|
1631
2059
|
const occupancy = branches.map((branch) => {
|
|
1632
2060
|
// A prunable registration (its folder is gone) holds nothing.
|
|
@@ -1640,7 +2068,7 @@ export async function listBranchOccupancy({ projectPath, sessionId } = {}) {
|
|
|
1640
2068
|
const retained = sessionId
|
|
1641
2069
|
? managed.find((record) => record.ownerSessionId === sessionId && record.state === 'ready')
|
|
1642
2070
|
: null;
|
|
1643
|
-
return { branches, occupancy, retainedWorktree: retained ? entryView(retained) : null };
|
|
2071
|
+
return { branches, occupancy, committedAt, retainedWorktree: retained ? entryView(retained) : null };
|
|
1644
2072
|
}
|
|
1645
2073
|
|
|
1646
2074
|
/**
|
|
@@ -1898,7 +2326,11 @@ export async function assertWorktreeRemovable({ entryId, force = false, sessionI
|
|
|
1898
2326
|
* `expected` ({ kind, txId, holderSessionId }) pins the record the caller
|
|
1899
2327
|
* selected: one that no longer matches under the repository lock (removed,
|
|
1900
2328
|
* its slug reused) is left alone and reported `{ removed: false, skipped: true }`.
|
|
1901
|
-
*
|
|
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
|
|
1902
2334
|
* @returns {Promise<{ removed: boolean, skipped?: boolean, retryable?: boolean, remaining?: object[], branchDeleted?: boolean }>}
|
|
1903
2335
|
*/
|
|
1904
2336
|
export async function removeManagedWorktree({
|
|
@@ -1908,6 +2340,7 @@ export async function removeManagedWorktree({
|
|
|
1908
2340
|
preserveBranch = false,
|
|
1909
2341
|
safeOnly = false,
|
|
1910
2342
|
expected = null,
|
|
2343
|
+
automatic = false,
|
|
1911
2344
|
} = {}) {
|
|
1912
2345
|
const skipped = { removed: false, skipped: true };
|
|
1913
2346
|
const matches = (record) =>
|
|
@@ -1926,13 +2359,37 @@ export async function removeManagedWorktree({
|
|
|
1926
2359
|
if (!current && !expected) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
|
|
1927
2360
|
return skipped;
|
|
1928
2361
|
}
|
|
1929
|
-
return removeLocked(current, { force, sessionId, preserveBranch, safeOnly });
|
|
2362
|
+
return removeLocked(current, { force, sessionId, preserveBranch, safeOnly, automatic });
|
|
1930
2363
|
});
|
|
1931
2364
|
}
|
|
1932
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
|
+
|
|
1933
2380
|
/** The removal of `current`, under its repository lock. */
|
|
1934
|
-
async function removeLocked(
|
|
2381
|
+
async function removeLocked(
|
|
2382
|
+
current,
|
|
2383
|
+
{ force, sessionId, preserveBranch = false, safeOnly = false, automatic = false }
|
|
2384
|
+
) {
|
|
1935
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
|
+
}
|
|
1936
2393
|
if (current.state === 'creating' || current.state === 'recovery') {
|
|
1937
2394
|
const rollback = await rollbackCreation(current);
|
|
1938
2395
|
return rollback.complete ? { removed: true } : { removed: false, retryable: true, remaining: rollback.remaining };
|
|
@@ -1953,13 +2410,17 @@ async function removeLocked(current, { force, sessionId, preserveBranch = false,
|
|
|
1953
2410
|
* so an owner that came back (a live session, a running lease holder) or a
|
|
1954
2411
|
* session the store still keeps it for refuses (WORKTREE_BUSY_JOBS). Then
|
|
1955
2412
|
* the ordinary removal guards apply (dirty or unmerged work needs `force`).
|
|
1956
|
-
*
|
|
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
|
|
1957
2417
|
*/
|
|
1958
2418
|
export async function removeOrphanedWorktree({
|
|
1959
2419
|
entryId,
|
|
1960
2420
|
force = false,
|
|
1961
2421
|
liveSessionIds = [],
|
|
1962
2422
|
retainedSessionIds = [],
|
|
2423
|
+
allowRetained = false,
|
|
1963
2424
|
} = {}) {
|
|
1964
2425
|
const record = await getRecord(entryId);
|
|
1965
2426
|
if (!record) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
|
|
@@ -1970,6 +2431,13 @@ export async function removeOrphanedWorktree({
|
|
|
1970
2431
|
}
|
|
1971
2432
|
const { ownerLive, removable } = await retentionOf(current, new Set(liveSessionIds), new Set(retainedSessionIds));
|
|
1972
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
|
+
}
|
|
1973
2441
|
if (!removable) {
|
|
1974
2442
|
// Retained recovery data names itself; otherwise sessions still keep it.
|
|
1975
2443
|
await assertRecoveryStoresClear(current);
|
|
@@ -1978,7 +2446,7 @@ export async function removeOrphanedWorktree({
|
|
|
1978
2446
|
sessions: [current.ownerSessionId, ...(current.sharedSessionIds ?? [])].filter(Boolean),
|
|
1979
2447
|
});
|
|
1980
2448
|
}
|
|
1981
|
-
return removeLocked(current, { force });
|
|
2449
|
+
return removeLocked(current, { force, sessionId });
|
|
1982
2450
|
});
|
|
1983
2451
|
}
|
|
1984
2452
|
|
|
@@ -2005,21 +2473,53 @@ function leaseProcessAlive(lease) {
|
|
|
2005
2473
|
* `retainedSessionIds`, i.e. still in the session store), `ownerLive` (a
|
|
2006
2474
|
* live session or a running lease holder), and `removable` (what a prune
|
|
2007
2475
|
* would remove: neither, or disposable and not live, with no retained recovery data).
|
|
2008
|
-
*
|
|
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
|
|
2009
2484
|
*/
|
|
2010
|
-
export async function listManagedWorktrees({
|
|
2485
|
+
export async function listManagedWorktrees({
|
|
2486
|
+
liveSessionIds = [],
|
|
2487
|
+
retainedSessionIds = [],
|
|
2488
|
+
inspect = false,
|
|
2489
|
+
size = false,
|
|
2490
|
+
} = {}) {
|
|
2011
2491
|
const live = new Set(liveSessionIds);
|
|
2012
2492
|
const retained = new Set(retainedSessionIds);
|
|
2013
2493
|
const rows = [];
|
|
2014
2494
|
for (const record of Object.values((await readRegistry()).entries)) {
|
|
2015
2495
|
const { ownerLive, retained: isRetained, removable } = await retentionOf(record, live, retained);
|
|
2016
|
-
|
|
2496
|
+
const row = {
|
|
2017
2497
|
...entryView(record),
|
|
2018
2498
|
projectPath: record.projectPath,
|
|
2499
|
+
sharedSessionIds: [...(record.sharedSessionIds ?? [])],
|
|
2019
2500
|
retained: isRetained,
|
|
2020
2501
|
ownerLive,
|
|
2021
2502
|
removable,
|
|
2022
|
-
}
|
|
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);
|
|
2023
2523
|
}
|
|
2024
2524
|
return rows;
|
|
2025
2525
|
}
|
|
@@ -2047,6 +2547,143 @@ async function retentionOf(record, live, retained) {
|
|
|
2047
2547
|
return { ownerLive, retained: isRetained, removable };
|
|
2048
2548
|
}
|
|
2049
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
|
+
|
|
2050
2687
|
/**
|
|
2051
2688
|
* Conservative reconcile (daemon startup, and the manual prune). Always:
|
|
2052
2689
|
* resumes interrupted deletions, rolls back interrupted creations of no
|
|
@@ -2058,20 +2695,62 @@ async function retentionOf(record, live, retained) {
|
|
|
2058
2695
|
* session in the store still references. A snapshot whose holder is neither
|
|
2059
2696
|
* live nor running in another process is removed in either mode.
|
|
2060
2697
|
* `repoCommonDir` limits the whole reconcile to that repository.
|
|
2061
|
-
*
|
|
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`).
|
|
2062
2722
|
*/
|
|
2063
2723
|
export async function reconcileManagedWorktrees({
|
|
2064
2724
|
liveSessionIds = [],
|
|
2065
2725
|
retainedSessionIds = [],
|
|
2066
2726
|
removeOrphans = false,
|
|
2067
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,
|
|
2068
2738
|
} = {}) {
|
|
2069
2739
|
const live = new Set(liveSessionIds);
|
|
2070
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);
|
|
2071
2745
|
const report = { removed: [], recovered: [], kept: [], leasesReleased: [], pruned: [], missing: [], errors: [] };
|
|
2072
2746
|
const reportDeletion = (record, result) => {
|
|
2073
2747
|
if (result.removed) report.removed.push(record.id);
|
|
2074
|
-
else
|
|
2748
|
+
else
|
|
2749
|
+
report.kept.push({
|
|
2750
|
+
id: record.id,
|
|
2751
|
+
reason: result.incomplete ? 'incomplete' : 'deleting',
|
|
2752
|
+
remaining: result.remaining,
|
|
2753
|
+
});
|
|
2075
2754
|
};
|
|
2076
2755
|
const byRepo = new Map();
|
|
2077
2756
|
for (const record of Object.values((await readRegistry()).entries)) {
|
|
@@ -2125,6 +2804,19 @@ export async function reconcileManagedWorktrees({
|
|
|
2125
2804
|
}
|
|
2126
2805
|
continue;
|
|
2127
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
|
+
}
|
|
2128
2820
|
if (record.state === 'deleting') {
|
|
2129
2821
|
// A decided deletion: its session already left the folder.
|
|
2130
2822
|
reportDeletion(record, await finishDeletion(record));
|
|
@@ -2164,26 +2856,31 @@ export async function reconcileManagedWorktrees({
|
|
|
2164
2856
|
report.leasesReleased.push(record.id);
|
|
2165
2857
|
}
|
|
2166
2858
|
if (!existsSync(record.path)) report.missing.push(record.id);
|
|
2167
|
-
if (removeOrphans === true && !ownerLive && (record
|
|
2168
|
-
|
|
2169
|
-
const
|
|
2170
|
-
if (
|
|
2171
|
-
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' });
|
|
2172
2864
|
continue;
|
|
2173
2865
|
}
|
|
2174
|
-
await
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
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;
|
|
2179
2878
|
}
|
|
2180
2879
|
} catch (error) {
|
|
2181
2880
|
report.errors.push({ id: record.id, error: String(error?.message || error) });
|
|
2182
2881
|
}
|
|
2183
2882
|
}
|
|
2184
|
-
|
|
2185
|
-
if (await pruneRegistration(repoCommonDir, stale)) report.pruned.push(canonicalPath(stale));
|
|
2186
|
-
}
|
|
2883
|
+
report.pruned.push(...(await pruneStaleRegistrations(repoCommonDir)));
|
|
2187
2884
|
});
|
|
2188
2885
|
} catch (error) {
|
|
2189
2886
|
report.errors.push({ repoCommonDir, error: String(error?.message || error) });
|