@yemi33/minions 0.1.2447 → 0.1.2449
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/bin/cli-api-client.js +1 -1
- package/bin/install-internal-minions.js +1382 -44
- package/bin/install-layout.js +150 -0
- package/bin/minions.js +460 -167
- package/dashboard/docs/typography.md +65 -12
- package/dashboard/js/command-center.js +66 -7
- package/dashboard/js/detail-panel.js +36 -0
- package/dashboard/js/memory-panel.js +59 -12
- package/dashboard/js/qa.js +186 -45
- package/dashboard/js/refresh.js +148 -12
- package/dashboard/js/render-dispatch.js +3 -4
- package/dashboard/js/render-inbox.js +2 -2
- package/dashboard/js/render-other.js +3 -3
- package/dashboard/js/render-pipelines.js +14 -0
- package/dashboard/js/render-plans.js +57 -9
- package/dashboard/js/render-prd.js +132 -23
- package/dashboard/js/render-prs.js +195 -166
- package/dashboard/js/render-schedules.js +63 -3
- package/dashboard/js/render-utils.js +3 -3
- package/dashboard/js/render-watches.js +19 -3
- package/dashboard/js/render-work-items.js +238 -30
- package/dashboard/js/settings.js +205 -54
- package/dashboard/js/utils.js +51 -1
- package/dashboard/pages/home.html +1 -1
- package/dashboard/pages/qa.html +1 -16
- package/dashboard/pages/work.html +40 -0
- package/dashboard/shared/cc-limits.js +79 -0
- package/dashboard/shared/pr-filters.js +21 -38
- package/dashboard/shared/project-git-summary.js +1 -1
- package/dashboard/shared/record-filters.js +169 -0
- package/dashboard/shared/watches-source.js +1 -1
- package/dashboard/shared/welcome-popup.js +1 -1
- package/dashboard/shared/wi-filters.js +302 -0
- package/dashboard/slim/body.html +1 -0
- package/dashboard/slim/js/command-send.js +26 -0
- package/dashboard/slim/js/modals-tiles.js +380 -39
- package/dashboard/slim/js/status.js +13 -21
- package/dashboard/slim/layout.html +1 -0
- package/dashboard/slim/panel-bootstrap.js +6 -2
- package/dashboard/slim/styles.css +38 -0
- package/dashboard/styles.css +159 -55
- package/dashboard-build.js +52 -6
- package/dashboard.js +956 -423
- package/docs/README.md +11 -6
- package/docs/api-errors.md +2 -2
- package/docs/architecture-review-2026-07-09.md +1 -1
- package/docs/architecture.excalidraw +2 -2
- package/docs/auto-discovery.md +18 -9
- package/docs/branch-derivation.md +4 -4
- package/docs/capture-demos.js +39 -2
- package/docs/ci-runner-canary.md +123 -0
- package/docs/claude-md-propagation.md +2 -2
- package/docs/cloud-agent-dispatch.md +204 -0
- package/docs/command-center.md +7 -7
- package/docs/completion-reports.md +43 -20
- package/docs/constants.md +10 -3
- package/docs/constellation-bridge.md +134 -6
- package/docs/constellation-style-telemetry.md +4 -4
- package/docs/contracts/capability-protocol.v1.json +165 -0
- package/docs/cooldown-merge-semantics.md +12 -12
- package/docs/copilot-cli-schema.md +7 -7
- package/docs/cross-repo-plans.md +10 -10
- package/docs/dead-code-audit-retractions.md +5 -5
- package/docs/default-branch-ci.md +173 -0
- package/docs/deprecated.json +31 -31
- package/docs/design-inbox-entries-schema.md +3 -3
- package/docs/design-language.md +1051 -0
- package/docs/design-state-storage.md +11 -11
- package/docs/diagnostics-crash-reports.md +9 -9
- package/docs/diagnostics-memory.md +5 -5
- package/docs/documentation-audit-2026-07-09.md +7 -7
- package/docs/engine-restart.md +90 -5
- package/docs/harness-mode.md +1 -1
- package/docs/internal-install.md +338 -39
- package/docs/kb-dedup-duplicate-pair-investigation.md +5 -5
- package/docs/kb-pr3223-cascade-archiving.md +1 -1
- package/docs/kb-pr696-merge-conflict-docs.md +6 -6
- package/docs/kb-sweep.md +35 -35
- package/docs/keep-processes.md +1 -1
- package/docs/live-checkout-mode.md +30 -30
- package/docs/managed-spawn.md +18 -14
- package/docs/named-agents.md +7 -7
- package/docs/plan-lifecycle.md +69 -2
- package/docs/pr-author-identity.md +114 -0
- package/docs/pr-auto-fix-dispatch.md +19 -4
- package/docs/pr-comment-followup.md +6 -6
- package/docs/pr-review-fix-loop.md +59 -10
- package/docs/process-termination.md +40 -0
- package/docs/proposals/repo-pool-for-live-checkout.md +13 -13
- package/docs/qa-runbook-lifecycle.md +367 -17
- package/docs/qa-runbooks.md +3 -3
- package/docs/rfc-completion-json.md +18 -18
- package/docs/runtime-adapters.md +26 -21
- package/docs/security.md +6 -6
- package/docs/self-improvement.md +4 -4
- package/docs/shared-lifecycle-module-map.md +473 -472
- package/docs/skills.md +52 -3
- package/docs/slim-ux/concepts.md +121 -116
- package/docs/specs/agent-configurability.md +18 -18
- package/docs/specs/agent-rename.md +18 -18
- package/docs/team-memory.md +38 -21
- package/docs/timeouts-and-liveness.md +118 -10
- package/docs/tutorials/01-install-and-connect.md +1 -1
- package/docs/watches.md +40 -39
- package/docs/workspace-manifests.md +4 -4
- package/docs/worktree-lifecycle.md +293 -14
- package/engine/README.md +46 -0
- package/engine/{ado-comment.js → ado/comment.js} +8 -8
- package/engine/{ado-git-auth.js → ado/git-auth.js} +4 -4
- package/engine/{ado.js → ado/index.js} +417 -63
- package/engine/{ado-status.js → ado/status.js} +6 -8
- package/engine/{ado-token.js → ado/token.js} +1 -1
- package/engine/{acp-transport.js → agents/acp-transport.js} +62 -22
- package/engine/{agent-worker-pool.js → agents/agent-worker-pool.js} +17 -8
- package/engine/{cc-worker-pool.js → agents/cc-worker-pool.js} +16 -6
- package/engine/{claude-md-context.js → agents/claude-md-context.js} +5 -5
- package/engine/{harness-context.js → agents/harness-context.js} +5 -5
- package/engine/{harness.js → agents/harness.js} +3 -3
- package/engine/{llm.js → agents/llm.js} +18 -14
- package/engine/{model-discovery.js → agents/model-discovery.js} +2 -2
- package/engine/{playbook.js → agents/playbook.js} +155 -22
- package/engine/{pooled-agent-process.js → agents/pooled-agent-process.js} +14 -12
- package/engine/{preflight.js → agents/preflight.js} +29 -10
- package/engine/{spawn-agent.js → agents/spawn-agent.js} +25 -14
- package/engine/{spawn-phase-watchdog.js → agents/spawn-phase-watchdog.js} +16 -7
- package/engine/{steering.js → agents/steering.js} +5 -5
- package/engine/{tools-inventory.js → agents/tools-inventory.js} +2 -2
- package/engine/{agent-api-validation.js → api/agent-api-validation.js} +2 -2
- package/engine/{api-validation.js → api/api-validation.js} +1 -1
- package/engine/api/bridge.js +787 -0
- package/engine/{cc-api-validation.js → api/cc-api-validation.js} +1 -1
- package/engine/api/companion.js +560 -0
- package/engine/{content-api-validation.js → api/content-api-validation.js} +2 -2
- package/engine/{pr-issue-validation.js → api/pr-issue-validation.js} +33 -6
- package/engine/{settings-validation.js → api/settings-validation.js} +32 -4
- package/engine/api-contracts/agent-content.js +4 -4
- package/engine/api-contracts/capability-manifest.js +236 -0
- package/engine/api-contracts/capability-protocol.js +333 -0
- package/engine/api-contracts/cc-ops.js +1 -1
- package/engine/api-contracts/config-runtime.js +5 -0
- package/engine/api-contracts/core.js +28 -1
- package/engine/api-contracts/index.js +100 -0
- package/engine/api-contracts/orchestration.js +18 -5
- package/engine/api-contracts/pull-requests.js +37 -6
- package/engine/api-contracts/qa-process.js +29 -6
- package/engine/api-contracts/work-plan-prd.js +21 -1
- package/engine/cloud/contract.js +212 -0
- package/engine/cloud/index.js +159 -0
- package/engine/{execution-model.js → core/execution-model.js} +1 -1
- package/engine/{features.js → core/features.js} +4 -4
- package/engine/{operator-identity.js → core/operator-identity.js} +1 -1
- package/engine/{queries.js → core/queries.js} +201 -36
- package/engine/{safe-expr.js → core/safe-expr.js} +1 -1
- package/engine/{shared.js → core/shared.js} +1637 -175
- package/engine/{stdio-timestamps.js → core/stdio-timestamps.js} +1 -1
- package/engine/{untrusted-fence.js → core/untrusted-fence.js} +3 -3
- package/engine/db/index.js +11 -2
- package/engine/db/migrations/002-dispatches.js +3 -3
- package/engine/db/migrations/003-work-items.js +1 -1
- package/engine/db/migrations/004-pull-requests.js +1 -1
- package/engine/db/migrations/006-metrics.js +1 -1
- package/engine/db/migrations/007-watches.js +2 -2
- package/engine/db/migrations/008-small-state.js +1 -1
- package/engine/db/migrations/009-qa.js +1 -1
- package/engine/db/migrations/010-pr-links.js +1 -1
- package/engine/db/migrations/011-remaining-state.js +1 -1
- package/engine/db/migrations/012-steering-deliveries.js +2 -2
- package/engine/db/migrations/013-backfill-broken-note-links.js +1 -1
- package/engine/db/migrations/014-pr-fix-target-prefs.js +2 -2
- package/engine/db/migrations/015-plans-prds.js +0 -0
- package/engine/db/migrations/018-sql-only-cutover.js +2 -2
- package/engine/db/migrations/021-archived-work-items.js +1 -1
- package/engine/db/migrations/022-global-cc-session.js +1 -1
- package/engine/db/migrations/023-engine-state.js +1 -1
- package/engine/db/migrations/025-malformed-work-item-phantoms.js +1 -1
- package/engine/db/migrations/027-review-learning-lifecycle.js +1 -1
- package/engine/db/migrations/029-repair-reused-versions.js +20 -0
- package/engine/db/migrations/031-pr-author-identity.js +137 -0
- package/engine/{consolidation.js → memory/consolidation.js} +6 -6
- package/engine/{kb-sweep-runner.js → memory/kb-sweep-runner.js} +2 -2
- package/engine/{kb-sweep.js → memory/kb-sweep.js} +9 -7
- package/engine/{memory-retrieval.js → memory/memory-retrieval.js} +46 -4
- package/engine/{memory-store.js → memory/memory-store.js} +3 -3
- package/engine/{promotion.js → memory/promotion.js} +3 -3
- package/engine/{review-learning-backfill.js → memory/review-learning-backfill.js} +6 -6
- package/engine/{review-learning.js → memory/review-learning.js} +10 -5
- package/engine/{diagnostics-memory.js → observability/diagnostics-memory.js} +1 -1
- package/engine/{logs-store.js → observability/logs-store.js} +5 -5
- package/engine/{metrics-store.js → observability/metrics-store.js} +4 -4
- package/engine/{check-status.js → operations/check-status.js} +3 -3
- package/engine/{cli.js → operations/cli.js} +271 -113
- package/engine/{distribution.js → operations/distribution.js} +5 -6
- package/engine/{cleanup.js → orchestration/cleanup.js} +72 -45
- package/engine/{cooldown.js → orchestration/cooldown.js} +5 -5
- package/engine/{dispatch-events.js → orchestration/dispatch-events.js} +2 -2
- package/engine/{dispatch.js → orchestration/dispatch.js} +129 -36
- package/engine/orchestration/failed-scheduled-cleanup.js +274 -0
- package/engine/{lifecycle.js → orchestration/lifecycle.js} +198 -90
- package/engine/{meeting.js → orchestration/meeting.js} +6 -16
- package/engine/{pipeline.js → orchestration/pipeline.js} +12 -12
- package/engine/{pre-dispatch-eval.js → orchestration/pre-dispatch-eval.js} +10 -9
- package/engine/{routing.js → orchestration/routing.js} +3 -3
- package/engine/{schedule-bootstrap.js → orchestration/schedule-bootstrap.js} +4 -4
- package/engine/{scheduler.js → orchestration/scheduler.js} +38 -8
- package/engine/{timeout.js → orchestration/timeout.js} +158 -109
- package/engine/{db-events.js → persistence/db-events.js} +2 -2
- package/engine/{dispatch-store.js → persistence/dispatch-store.js} +7 -7
- package/engine/{inbox-store.js → persistence/inbox-store.js} +2 -2
- package/engine/{note-link-backfill.js → persistence/note-link-backfill.js} +4 -4
- package/engine/{pr-fix-target-store.js → persistence/pr-fix-target-store.js} +8 -8
- package/engine/{pull-requests-store.js → persistence/pull-requests-store.js} +21 -7
- package/engine/{small-state-store.js → persistence/small-state-store.js} +31 -31
- package/engine/persistence/state-operations.js +350 -0
- package/engine/{steering-store.js → persistence/steering-store.js} +6 -6
- package/engine/{issues.js → planning/issues.js} +2 -2
- package/engine/{plan-prd-validation.js → planning/plan-prd-validation.js} +8 -2
- package/engine/planning/prd-result-sidecar.js +190 -0
- package/engine/{prd-store.js → planning/prd-store.js} +17 -17
- package/engine/{project-discovery.js → planning/project-discovery.js} +5 -5
- package/engine/{projects.js → planning/projects.js} +10 -10
- package/engine/{resolve-area.js → planning/resolve-area.js} +1 -1
- package/engine/{work-item-validation.js → planning/work-item-validation.js} +39 -3
- package/engine/{work-items-store.js → planning/work-items-store.js} +29 -21
- package/engine/{keep-process-sweep.js → processes/keep-process-sweep.js} +57 -17
- package/engine/{managed-spawn-launcher.js → processes/managed-spawn-launcher.js} +3 -3
- package/engine/{managed-spawn.js → processes/managed-spawn.js} +97 -46
- package/engine/{process-utils.js → processes/process-utils.js} +599 -55
- package/engine/{abandoned-pr-reconciliation.js → providers/abandoned-pr-reconciliation.js} +17 -7
- package/engine/{comment-classifier.js → providers/comment-classifier.js} +85 -17
- package/engine/{comment-format.js → providers/comment-format.js} +5 -5
- package/engine/{gh-comment.js → providers/gh-comment.js} +15 -15
- package/engine/{gh-token.js → providers/gh-token.js} +4 -4
- package/engine/{github.js → providers/github.js} +131 -54
- package/engine/{pr-action.js → providers/pr-action.js} +13 -12
- package/engine/{pr-clone-keep.js → providers/pr-clone-keep.js} +7 -7
- package/engine/{pr-devbox.js → providers/pr-devbox.js} +6 -6
- package/engine/{pr-fix-target.js → providers/pr-fix-target.js} +13 -13
- package/engine/{pr-remote-patch.js → providers/pr-remote-patch.js} +4 -4
- package/engine/{pr-resolve.js → providers/pr-resolve.js} +7 -7
- package/engine/{pr-temp-clone.js → providers/pr-temp-clone.js} +5 -5
- package/engine/{pr-track.js → providers/pr-track.js} +11 -13
- package/engine/{shared-branch-pr-reconcile.js → providers/shared-branch-pr-reconcile.js} +4 -4
- package/engine/qa/auto-prd-qa.js +313 -0
- package/engine/{qa-from-prd.js → qa/from-prd.js} +42 -12
- package/engine/qa/prd-session.js +240 -0
- package/engine/{qa-process-validation.js → qa/process-validation.js} +14 -9
- package/engine/{qa-runbooks.js → qa/runbooks.js} +1 -1
- package/engine/{qa-runs.js → qa/runs.js} +286 -15
- package/engine/{qa-sessions.js → qa/sessions.js} +595 -49
- package/engine/qa/visual-journey.js +654 -0
- package/engine/{qa-runners.js → qa-runners/index.js} +7 -7
- package/engine/qa-runners/maestro.js +3 -3
- package/engine/qa-runners/playwright.js +2 -2
- package/engine/{restart-health.js → recovery/restart-health.js} +48 -4
- package/engine/recovery/stop-stack.js +607 -0
- package/engine/{supervisor.js → recovery/supervisor.js} +105 -175
- package/engine/{watchdog.js → recovery/watchdog.js} +136 -13
- package/engine/runtimes/claude.js +14 -12
- package/engine/runtimes/codex.js +8 -6
- package/engine/runtimes/copilot.js +17 -16
- package/engine/{watch-actions.js → watches/actions.js} +13 -13
- package/engine/{watches.js → watches/index.js} +43 -32
- package/engine/{watches-store.js → watches/store.js} +4 -4
- package/engine/{create-pr-worktree.js → worktrees/create-pr.js} +1 -1
- package/engine/{worktree-gc.js → worktrees/gc.js} +70 -22
- package/engine/worktrees/inventory.js +671 -0
- package/engine/{live-checkout.js → worktrees/live-checkout.js} +4 -4
- package/engine/{worktree-pool.js → worktrees/pool.js} +2 -2
- package/engine/{worktree-preflight.js → worktrees/preflight.js} +1 -0
- package/engine/worktrees/quarantine-refs.js +173 -0
- package/engine.js +1137 -208
- package/minions.js +147 -77
- package/package.json +10 -6
- package/playbooks/_pr-description-audit.md +110 -78
- package/playbooks/build-fix-complex.md +2 -0
- package/playbooks/fix.md +16 -12
- package/playbooks/implement-shared.md +2 -0
- package/playbooks/implement.md +19 -20
- package/playbooks/plan-to-prd.md +18 -3
- package/playbooks/qa-session-draft.md +136 -1
- package/playbooks/qa-session-execute.md +80 -2
- package/playbooks/qa-session-setup.md +17 -1
- package/playbooks/qa-validate.md +1 -1
- package/playbooks/setup.md +2 -0
- package/playbooks/shared-rules.md +25 -32
- package/playbooks/templates/followup-dispatch.md +4 -3
- package/playbooks/verify.md +1 -1
- package/prompts/cc-system.md +19 -27
- package/watch-plugins/README.md +92 -0
- package/watch-plugins/ado-author-prs.js +336 -0
- package/watch-plugins/gh-author-prs.js +375 -0
- package/watch-plugins/http.js +474 -0
- package/watch-plugins/teams-channel.js +869 -0
- package/docs/dev-composite-workflow.md +0 -101
- package/docs/pr-screenshots/pr-886/after-single-header.png +0 -0
- package/docs/pr-screenshots/pr-886/before-duplicate-header.png +0 -0
- package/docs/pr-screenshots/pr-895/01-cancellation-reason-detail.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-901/projects-tab-default.png +0 -0
- package/docs/pr-screenshots/pr-901/projects-tab-fmf-selected.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-AFTER-crop.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-BEFORE-crop.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-dropdown-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
- package/docs/visual-evidence-ci.md +0 -103
- package/engine/bridge.js +0 -379
- package/engine/quarantine-refs.js +0 -103
- package/engine/state-operations.js +0 -178
- /package/engine/{steering-constraints.js → agents/steering-constraints.js} +0 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/worktrees/inventory.js — worktree lifecycle observability
|
|
3
|
+
* (W-ms5tb6ha016fda10)
|
|
4
|
+
*
|
|
5
|
+
* The engine already has several destructive sweeps, but no read-only answer to
|
|
6
|
+
* the question an operator actually asks when `D:\worktrees` has 142 directories
|
|
7
|
+
* and only 6 registrations: *what are all these, and which ones is the engine
|
|
8
|
+
* ever going to clean up?*
|
|
9
|
+
*
|
|
10
|
+
* This module classifies every directory under each project's configured
|
|
11
|
+
* worktree root, plus every out-of-root git registration, into disjoint buckets:
|
|
12
|
+
*
|
|
13
|
+
* active — a non-terminal dispatch currently claims the path
|
|
14
|
+
* (`shared.isWorktreePathLive`). NEVER removable.
|
|
15
|
+
* pooled — a warm worktree-pool entry.
|
|
16
|
+
* anchored — held by a live keep_processes / managed_spawn cwd.
|
|
17
|
+
* registered — known to `git worktree list` but not claimed above.
|
|
18
|
+
* ownedOrphan — carries the engine ownership marker, unregistered, not
|
|
19
|
+
* live. THIS is the reclaimable backlog.
|
|
20
|
+
* quarantined — a `-quarantine-<ts>` recovery artifact (with the
|
|
21
|
+
* `protected` sub-count for the ones whose WIP has no
|
|
22
|
+
* durable recovery ref).
|
|
23
|
+
* foreignUnmanaged — no ownership marker. The engine must NEVER delete these.
|
|
24
|
+
*
|
|
25
|
+
* The disjoint buckets roll up into a `lifecycle` split (`owned` vs
|
|
26
|
+
* `foreignUnmanaged`) that is the ONLY supported source for any headline
|
|
27
|
+
* "how many worktrees does Minions have?" metric — see
|
|
28
|
+
* `summarizeLifecycleCounts`. A raw filesystem scan cannot answer that question
|
|
29
|
+
* without calling an operator's hand-made scratch checkout an active Minions
|
|
30
|
+
* worktree.
|
|
31
|
+
*
|
|
32
|
+
* It is strictly READ-ONLY: no git mutation, no filesystem writes, no removal.
|
|
33
|
+
* Every probe fails safe — a dir whose ownership cannot be determined counts as
|
|
34
|
+
* `foreignUnmanaged`, and a project whose git registry cannot be read reports an
|
|
35
|
+
* error entry rather than silently reporting zero orphans.
|
|
36
|
+
*
|
|
37
|
+
* Zero dependencies — Node built-ins + engine/core/shared + the sibling worktree
|
|
38
|
+
* modules. Cross-platform: all path comparison goes through the same
|
|
39
|
+
* normalizers the GC uses.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const fs = require('fs');
|
|
43
|
+
const path = require('path');
|
|
44
|
+
|
|
45
|
+
const shared = require('../core/shared');
|
|
46
|
+
const worktreePool = require('./pool');
|
|
47
|
+
const quarantineRefs = require('./quarantine-refs');
|
|
48
|
+
|
|
49
|
+
// Bound the async pre-read fan-out the same way the sibling quarantine-ref
|
|
50
|
+
// sweep does: enough parallelism to hide per-syscall latency, few enough
|
|
51
|
+
// handles that a 150-directory root cannot exhaust the pool.
|
|
52
|
+
const WORKTREE_PROBE_CONCURRENCY = 8;
|
|
53
|
+
|
|
54
|
+
function _emptyBuckets() {
|
|
55
|
+
return {
|
|
56
|
+
active: 0,
|
|
57
|
+
pooled: 0,
|
|
58
|
+
anchored: 0,
|
|
59
|
+
registered: 0,
|
|
60
|
+
ownedOrphan: 0,
|
|
61
|
+
quarantined: 0,
|
|
62
|
+
quarantinedProtected: 0,
|
|
63
|
+
foreignUnmanaged: 0,
|
|
64
|
+
total: 0,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _addBuckets(target, source) {
|
|
69
|
+
for (const key of Object.keys(target)) target[key] += source[key] || 0;
|
|
70
|
+
return target;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _isLiveFailOpen(isWorktreePathLive, candidate, liveOpts) {
|
|
74
|
+
// Mirrors engine/worktrees/gc.js: SQL trouble means "assume live" so a
|
|
75
|
+
// reporting glitch can never make a live worktree look reclaimable.
|
|
76
|
+
try { return !!isWorktreePathLive(candidate, liveOpts); }
|
|
77
|
+
catch { return true; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* W-ms9q3dql00vxd4a8 — the default liveness probe, resolved ONCE per inventory
|
|
82
|
+
* build instead of once per directory. `shared.isWorktreePathLive` re-scans the
|
|
83
|
+
* whole pending+active dispatch set for every path it is asked about, which is
|
|
84
|
+
* the right trade for the handful of destructive wipe sites but costs O(n)
|
|
85
|
+
* queries when classifying a 150-directory worktree root on the dashboard's
|
|
86
|
+
* refresh path.
|
|
87
|
+
*
|
|
88
|
+
* The fail-open contract is identical: an unreadable dispatch table means
|
|
89
|
+
* "assume everything is live", never "assume everything is reclaimable".
|
|
90
|
+
*/
|
|
91
|
+
function _makeBatchedLiveProbe(db) {
|
|
92
|
+
let live;
|
|
93
|
+
try { live = shared.listLiveWorktreePaths(db ? { db } : undefined); }
|
|
94
|
+
catch { live = null; }
|
|
95
|
+
if (!live || live.ok !== true) return () => true;
|
|
96
|
+
return (candidate) => {
|
|
97
|
+
try { return live.paths.has(shared._normalizeWorktreePath(candidate)); }
|
|
98
|
+
catch { return true; }
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _collectAnchoredCwdPaths(listManagedSpecs, listKeepProcessCwds) {
|
|
103
|
+
const out = [];
|
|
104
|
+
try {
|
|
105
|
+
for (const rec of (listManagedSpecs() || [])) {
|
|
106
|
+
if (!rec || typeof rec.cwd !== 'string' || !rec.cwd) continue;
|
|
107
|
+
out.push(rec.cwd);
|
|
108
|
+
}
|
|
109
|
+
} catch { /* managed-spawn state unavailable */ }
|
|
110
|
+
try {
|
|
111
|
+
for (const cwd of (listKeepProcessCwds() || [])) {
|
|
112
|
+
if (typeof cwd !== 'string' || !cwd) continue;
|
|
113
|
+
out.push(cwd);
|
|
114
|
+
}
|
|
115
|
+
} catch { /* keep-process state unavailable */ }
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _collectAnchoredCwds(listManagedSpecs, listKeepProcessCwds) {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const cwd of _collectAnchoredCwdPaths(listManagedSpecs, listKeepProcessCwds)) {
|
|
122
|
+
try { out.push(shared.pathForComparison(cwd)); } catch { /* unreadable cwd */ }
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* W-ms9q3dql00vxd4a8 — anchors are resolved to their COMPARISON form once per
|
|
129
|
+
* build, not once per candidate.
|
|
130
|
+
*
|
|
131
|
+
* `shared.isPathInsideOrEqual` symlink-resolves BOTH sides on every call, so the
|
|
132
|
+
* old shape cost O(directories x anchors) `realpathSync` syscalls: a single live
|
|
133
|
+
* managed-spawn service turned a 150-directory root into a >100 ms synchronous
|
|
134
|
+
* block on the dashboard's event loop. Comparing pre-resolved paths through the
|
|
135
|
+
* shared containment rule keeps the semantics byte-for-byte and drops the
|
|
136
|
+
* per-candidate cost to one resolution.
|
|
137
|
+
*/
|
|
138
|
+
function _isAnchored(candidate, ctx) {
|
|
139
|
+
if (!ctx.anchoredCwds.length) return false;
|
|
140
|
+
let candidateCompare;
|
|
141
|
+
try { candidateCompare = ctx.comparePathFor(candidate); } catch { return false; }
|
|
142
|
+
for (const cwd of ctx.anchoredCwds) {
|
|
143
|
+
try { if (shared.isResolvedPathInsideOrEqual(cwd, candidateCompare)) return true; }
|
|
144
|
+
catch { /* incomparable paths are not anchors */ }
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function _isQuarantineDirName(name) {
|
|
150
|
+
return /-quarantine-(?:protected-)?\d+(?:$|-)/.test(String(name || ''));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Count dispatches that ran without allocating a repository checkout because
|
|
155
|
+
* they declared the control-plane execution surface. Derived from the dispatch
|
|
156
|
+
* record itself (`executionSurface`), so there is no counter to migrate, reset,
|
|
157
|
+
* or drift out of sync with reality.
|
|
158
|
+
*/
|
|
159
|
+
function countAvoidedAllocations(dispatchSnap) {
|
|
160
|
+
const surface = shared.EXECUTION_SURFACE.CONTROL_PLANE;
|
|
161
|
+
let active = 0;
|
|
162
|
+
let completed = 0;
|
|
163
|
+
for (const entry of (Array.isArray(dispatchSnap?.active) ? dispatchSnap.active : [])) {
|
|
164
|
+
if (entry?.executionSurface === surface) active++;
|
|
165
|
+
}
|
|
166
|
+
for (const entry of (Array.isArray(dispatchSnap?.completed) ? dispatchSnap.completed : [])) {
|
|
167
|
+
if (entry?.executionSurface === surface) completed++;
|
|
168
|
+
}
|
|
169
|
+
return { active, completed, total: active + completed };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _classifyDirectory(candidate, ctx) {
|
|
173
|
+
const name = path.basename(candidate);
|
|
174
|
+
const normalized = worktreePool._normalizePath(candidate);
|
|
175
|
+
|
|
176
|
+
if (_isLiveFailOpen(ctx.isWorktreePathLive, candidate, ctx.liveOpts)) return 'active';
|
|
177
|
+
if (ctx.poolPaths.has(normalized)) return 'pooled';
|
|
178
|
+
if (_isAnchored(candidate, ctx)) return 'anchored';
|
|
179
|
+
|
|
180
|
+
let owned = false;
|
|
181
|
+
try { owned = !!ctx.hasWorktreeOwnerMarker(candidate); } catch { owned = false; }
|
|
182
|
+
|
|
183
|
+
if (_isQuarantineDirName(name)) {
|
|
184
|
+
// A quarantine dir is always engine residue: it is a rename of a worktree
|
|
185
|
+
// the engine itself created. Report it as quarantined regardless of whether
|
|
186
|
+
// the marker survived the rename, but never let that reclassify a dir the
|
|
187
|
+
// engine cannot prove it owns — an unmarked one is still reported under
|
|
188
|
+
// `foreignUnmanaged` for the removal decision.
|
|
189
|
+
return owned ? 'quarantined' : 'foreignUnmanaged';
|
|
190
|
+
}
|
|
191
|
+
if (!owned) return 'foreignUnmanaged';
|
|
192
|
+
if (ctx.registeredPaths.has(normalized)) return 'registered';
|
|
193
|
+
return 'ownedOrphan';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Build the read-only worktree inventory.
|
|
198
|
+
*
|
|
199
|
+
* @param {object} opts
|
|
200
|
+
* @param {Array<{name?:string, localPath?:string}>} opts.projects
|
|
201
|
+
* @param {object} [opts.dispatchSnap] — `{active, pending, completed}` snapshot
|
|
202
|
+
* @param {object} [opts.config] — for `engine.worktreeRoot`
|
|
203
|
+
* @param {Map} [opts.parsedTreesByProject] — pre-read `git worktree list`
|
|
204
|
+
* output keyed on resolved localPath, so an async caller can pay the git
|
|
205
|
+
* shell-out cost off the event loop (same injection shape as
|
|
206
|
+
* engine/orchestration/cleanup.js uses for the GC pruners).
|
|
207
|
+
* @param {Map} [opts.entriesByWorktreeRoot] — pre-read worktree-root
|
|
208
|
+
* directory listings keyed on the resolved worktree root.
|
|
209
|
+
* @param {Map} [opts.probesByPath] — pre-read per-directory filesystem
|
|
210
|
+
* probes (ownership marker, protected-quarantine marker, comparison path)
|
|
211
|
+
* keyed on the normalized directory path. Any path missing from the map (or
|
|
212
|
+
* any field missing from its record) falls back to the real synchronous
|
|
213
|
+
* probe, so a partial pre-read can never change a classification.
|
|
214
|
+
* Injection points (all optional, default to the real implementations) mirror
|
|
215
|
+
* engine/worktrees/gc.js so unit tests can drive this deterministically:
|
|
216
|
+
* fs, execSilent, parseWorktreePorcelain, isWorktreePathLive,
|
|
217
|
+
* hasWorktreeOwnerMarker, hasProtectedQuarantineMarker, listPoolPaths,
|
|
218
|
+
* listManagedSpecs, listKeepProcessCwds, db
|
|
219
|
+
* @returns {{ perProject: object, totals: object, avoidedAllocations: object,
|
|
220
|
+
* recoverableRefs: object, errors: Array }}
|
|
221
|
+
*/
|
|
222
|
+
function buildWorktreeInventory(opts = {}) {
|
|
223
|
+
const projects = (Array.isArray(opts.projects) ? opts.projects : [])
|
|
224
|
+
.filter(p => p && p.localPath);
|
|
225
|
+
const _fs = opts.fs || fs;
|
|
226
|
+
const _execSilent = typeof opts.execSilent === 'function' ? opts.execSilent : shared.execSilent;
|
|
227
|
+
const _parsePorcelain = typeof opts.parseWorktreePorcelain === 'function'
|
|
228
|
+
? opts.parseWorktreePorcelain
|
|
229
|
+
: shared.parseWorktreePorcelain;
|
|
230
|
+
const _parsedTreesByProject = opts.parsedTreesByProject instanceof Map
|
|
231
|
+
? opts.parsedTreesByProject
|
|
232
|
+
: null;
|
|
233
|
+
const _entriesByWorktreeRoot = opts.entriesByWorktreeRoot instanceof Map
|
|
234
|
+
? opts.entriesByWorktreeRoot
|
|
235
|
+
: null;
|
|
236
|
+
const _probesByPath = opts.probesByPath instanceof Map ? opts.probesByPath : null;
|
|
237
|
+
const _probeFor = (candidate) => {
|
|
238
|
+
if (!_probesByPath) return null;
|
|
239
|
+
try { return _probesByPath.get(worktreePool._normalizePath(candidate)) || null; }
|
|
240
|
+
catch { return null; }
|
|
241
|
+
};
|
|
242
|
+
const ctx = {
|
|
243
|
+
isWorktreePathLive: typeof opts.isWorktreePathLive === 'function'
|
|
244
|
+
? opts.isWorktreePathLive
|
|
245
|
+
: _makeBatchedLiveProbe(opts.db),
|
|
246
|
+
// A pre-read probe is used when present; anything the async pre-read could
|
|
247
|
+
// not resolve falls through to the real synchronous helper, so the
|
|
248
|
+
// fail-safe direction of each classification is unchanged.
|
|
249
|
+
hasWorktreeOwnerMarker: typeof opts.hasWorktreeOwnerMarker === 'function'
|
|
250
|
+
? opts.hasWorktreeOwnerMarker
|
|
251
|
+
: (candidate) => {
|
|
252
|
+
const probe = _probeFor(candidate);
|
|
253
|
+
return typeof probe?.ownerMarker === 'boolean'
|
|
254
|
+
? probe.ownerMarker
|
|
255
|
+
: shared.hasWorktreeOwnerMarker(candidate);
|
|
256
|
+
},
|
|
257
|
+
comparePathFor: (candidate) => {
|
|
258
|
+
const probe = _probeFor(candidate);
|
|
259
|
+
return probe?.comparePath || shared.pathForComparison(candidate);
|
|
260
|
+
},
|
|
261
|
+
liveOpts: opts.db ? { db: opts.db } : undefined,
|
|
262
|
+
anchoredCwds: Array.isArray(opts.anchoredCwds)
|
|
263
|
+
? opts.anchoredCwds
|
|
264
|
+
: _collectAnchoredCwds(
|
|
265
|
+
typeof opts.listManagedSpecs === 'function'
|
|
266
|
+
? opts.listManagedSpecs
|
|
267
|
+
: () => require('../processes/managed-spawn').listManagedSpecs(),
|
|
268
|
+
typeof opts.listKeepProcessCwds === 'function'
|
|
269
|
+
? opts.listKeepProcessCwds
|
|
270
|
+
: () => require('../processes/keep-process-sweep').getActiveKeepProcessCwds(),
|
|
271
|
+
),
|
|
272
|
+
poolPaths: new Set(),
|
|
273
|
+
registeredPaths: new Set(),
|
|
274
|
+
};
|
|
275
|
+
const hasProtectedQuarantineMarker = typeof opts.hasProtectedQuarantineMarker === 'function'
|
|
276
|
+
? opts.hasProtectedQuarantineMarker
|
|
277
|
+
: (candidate) => {
|
|
278
|
+
const probe = _probeFor(candidate);
|
|
279
|
+
return typeof probe?.protectedQuarantineMarker === 'boolean'
|
|
280
|
+
? probe.protectedQuarantineMarker
|
|
281
|
+
: shared.hasProtectedQuarantineMarker(candidate);
|
|
282
|
+
};
|
|
283
|
+
const listPoolPaths = typeof opts.listPoolPaths === 'function'
|
|
284
|
+
? opts.listPoolPaths
|
|
285
|
+
: (project) => {
|
|
286
|
+
try {
|
|
287
|
+
return [
|
|
288
|
+
...(worktreePool.getActiveBorrowedPaths(project) || []),
|
|
289
|
+
...(worktreePool.getIdlePaths(project) || []),
|
|
290
|
+
];
|
|
291
|
+
} catch { return []; }
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const worktreeRootRel = opts.config?.engine?.worktreeRoot || shared.ENGINE_DEFAULTS.worktreeRoot;
|
|
295
|
+
const perProject = {};
|
|
296
|
+
const totals = _emptyBuckets();
|
|
297
|
+
const errors = [];
|
|
298
|
+
// W-ms9q3dql00vxd4a8 — dedupe ACROSS projects, not just within one. Two
|
|
299
|
+
// projects whose `worktreeRoot` resolves to the same directory (the
|
|
300
|
+
// monorepo-style layout the GC already groups by root) otherwise classify
|
|
301
|
+
// every directory twice and double the reported total. Attribution goes to
|
|
302
|
+
// the first project that claims the root; `perProject[label].worktreeRoot`
|
|
303
|
+
// still identifies which root each project resolved to.
|
|
304
|
+
const seen = new Set();
|
|
305
|
+
|
|
306
|
+
for (const project of projects) {
|
|
307
|
+
const label = project.name || project.localPath;
|
|
308
|
+
const rootDir = path.resolve(String(project.localPath));
|
|
309
|
+
const buckets = _emptyBuckets();
|
|
310
|
+
|
|
311
|
+
ctx.poolPaths = new Set(
|
|
312
|
+
(listPoolPaths(project) || [])
|
|
313
|
+
.filter(p => typeof p === 'string' && p)
|
|
314
|
+
.map(p => worktreePool._normalizePath(p)),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// Registered paths come from git itself. A read failure is REPORTED, never
|
|
318
|
+
// treated as "nothing is registered" — that inversion would relabel every
|
|
319
|
+
// live worktree as an orphan.
|
|
320
|
+
ctx.registeredPaths = new Set();
|
|
321
|
+
const outOfRoot = [];
|
|
322
|
+
let worktreeRoot;
|
|
323
|
+
try { worktreeRoot = path.resolve(rootDir, worktreeRootRel); }
|
|
324
|
+
catch { worktreeRoot = null; }
|
|
325
|
+
let registryReadable = false;
|
|
326
|
+
try {
|
|
327
|
+
let trees;
|
|
328
|
+
if (_parsedTreesByProject && _parsedTreesByProject.has(rootDir)) {
|
|
329
|
+
trees = _parsedTreesByProject.get(rootDir);
|
|
330
|
+
if (!Array.isArray(trees)) throw new Error('pre-read worktree registry is not an array');
|
|
331
|
+
} else {
|
|
332
|
+
const raw = String(_execSilent('git --no-optional-locks worktree list --porcelain', {
|
|
333
|
+
cwd: rootDir, timeout: 15000, windowsHide: true,
|
|
334
|
+
}) || '');
|
|
335
|
+
trees = _parsePorcelain(raw) || [];
|
|
336
|
+
}
|
|
337
|
+
for (const tree of trees) {
|
|
338
|
+
if (!tree?.path) continue;
|
|
339
|
+
const abs = path.resolve(tree.path);
|
|
340
|
+
if (worktreePool._normalizePath(abs) === worktreePool._normalizePath(rootDir)) continue;
|
|
341
|
+
ctx.registeredPaths.add(worktreePool._normalizePath(abs));
|
|
342
|
+
if (worktreeRoot && !shared.isPathInsideOrEqual(worktreeRoot, abs)) outOfRoot.push(abs);
|
|
343
|
+
}
|
|
344
|
+
registryReadable = true;
|
|
345
|
+
} catch (e) {
|
|
346
|
+
errors.push({ project: label, scope: 'git-registry', error: e?.message || String(e) });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let entries = [];
|
|
350
|
+
if (worktreeRoot) {
|
|
351
|
+
const preRead = _entriesByWorktreeRoot ? _entriesByWorktreeRoot.get(worktreeRoot) : null;
|
|
352
|
+
if (Array.isArray(preRead)) {
|
|
353
|
+
entries = preRead;
|
|
354
|
+
} else {
|
|
355
|
+
try {
|
|
356
|
+
entries = _fs.readdirSync(worktreeRoot, { withFileTypes: true })
|
|
357
|
+
.filter(entry => entry.isDirectory() && !shared.isWorktreeRootInfraEntry(entry.name))
|
|
358
|
+
.map(entry => path.join(worktreeRoot, entry.name));
|
|
359
|
+
} catch (e) {
|
|
360
|
+
if (e?.code !== 'ENOENT') {
|
|
361
|
+
errors.push({ project: label, scope: 'worktree-root', error: e?.message || String(e) });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const seenBefore = seen.size;
|
|
368
|
+
for (const candidate of [...entries, ...outOfRoot]) {
|
|
369
|
+
const normalized = worktreePool._normalizePath(candidate);
|
|
370
|
+
if (seen.has(normalized)) continue;
|
|
371
|
+
seen.add(normalized);
|
|
372
|
+
// Without a readable registry we cannot distinguish `registered` from
|
|
373
|
+
// `ownedOrphan`, so an owned dir is reported as `registered` (the
|
|
374
|
+
// conservative direction — nothing gets reported as reclaimable).
|
|
375
|
+
let bucket = _classifyDirectory(candidate, ctx);
|
|
376
|
+
if (!registryReadable && bucket === 'ownedOrphan') bucket = 'registered';
|
|
377
|
+
buckets[bucket]++;
|
|
378
|
+
buckets.total++;
|
|
379
|
+
if (bucket === 'quarantined') {
|
|
380
|
+
let isProtected = true;
|
|
381
|
+
try { isProtected = !!hasProtectedQuarantineMarker(candidate); } catch { isProtected = true; }
|
|
382
|
+
if (isProtected) buckets.quarantinedProtected++;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
perProject[label] = {
|
|
387
|
+
...buckets,
|
|
388
|
+
worktreeRoot: worktreeRoot || null,
|
|
389
|
+
registryReadable,
|
|
390
|
+
// True when every directory under this project's root was already
|
|
391
|
+
// classified for an earlier project sharing the same root.
|
|
392
|
+
sharedRoot: seen.size === seenBefore && (entries.length + outOfRoot.length) > 0,
|
|
393
|
+
};
|
|
394
|
+
_addBuckets(totals, buckets);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
perProject,
|
|
399
|
+
totals,
|
|
400
|
+
lifecycle: _summarizeLifecycle(totals),
|
|
401
|
+
avoidedAllocations: countAvoidedAllocations(opts.dispatchSnap),
|
|
402
|
+
errors,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* W-ms9q3dql00vxd4a8 — the lifecycle-owned split, derived from the SAME buckets
|
|
408
|
+
* the inventory already produces so there is exactly one classifier.
|
|
409
|
+
*
|
|
410
|
+
* `owned` is every directory the engine's own lifecycle created and is
|
|
411
|
+
* therefore responsible for: live dispatches, warm pool members, process
|
|
412
|
+
* anchors, git-registered worktrees, reclaimable owned orphans, and quarantine
|
|
413
|
+
* residue on its retention clock. `foreignUnmanaged` is everything the engine
|
|
414
|
+
* has NO ownership proof for — operator scratch worktrees and unclassifiable
|
|
415
|
+
* residue — which stays fail-open and must never be presented as an active
|
|
416
|
+
* Minions worktree.
|
|
417
|
+
*/
|
|
418
|
+
function _summarizeLifecycle(totals) {
|
|
419
|
+
const buckets = totals || _emptyBuckets();
|
|
420
|
+
const total = Number(buckets.total) || 0;
|
|
421
|
+
const foreignUnmanaged = Number(buckets.foreignUnmanaged) || 0;
|
|
422
|
+
return {
|
|
423
|
+
owned: Math.max(0, total - foreignUnmanaged),
|
|
424
|
+
foreignUnmanaged,
|
|
425
|
+
total,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function summarizeLifecycleCounts(inventory) {
|
|
430
|
+
return _summarizeLifecycle(inventory?.totals);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Pre-read `git worktree list --porcelain` for each project WITHOUT blocking
|
|
435
|
+
* the event loop, keyed on the resolved localPath so `buildWorktreeInventory`
|
|
436
|
+
* can skip its synchronous shell-out. A per-project failure is left unset so
|
|
437
|
+
* the sync builder falls back to self-shelling and still reports the error.
|
|
438
|
+
*/
|
|
439
|
+
async function readWorktreeRegistriesAsync(opts = {}) {
|
|
440
|
+
const parse = typeof opts.parseWorktreePorcelain === 'function'
|
|
441
|
+
? opts.parseWorktreePorcelain
|
|
442
|
+
: shared.parseWorktreePorcelain;
|
|
443
|
+
const runGit = typeof opts.shellSafeGit === 'function' ? opts.shellSafeGit : shared.shellSafeGit;
|
|
444
|
+
const map = new Map();
|
|
445
|
+
for (const project of (Array.isArray(opts.projects) ? opts.projects : [])) {
|
|
446
|
+
if (!project?.localPath) continue;
|
|
447
|
+
let rootDir;
|
|
448
|
+
try { rootDir = path.resolve(String(project.localPath)); } catch { continue; }
|
|
449
|
+
if (map.has(rootDir)) continue;
|
|
450
|
+
try {
|
|
451
|
+
const raw = await runGit(['--no-optional-locks', 'worktree', 'list', '--porcelain'], {
|
|
452
|
+
cwd: rootDir, timeout: 15000,
|
|
453
|
+
});
|
|
454
|
+
const trees = parse(String(raw || ''));
|
|
455
|
+
if (Array.isArray(trees)) map.set(rootDir, trees);
|
|
456
|
+
} catch { /* leave unset — the sync builder self-shells and records the error */ }
|
|
457
|
+
}
|
|
458
|
+
return map;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Pre-read each project's worktree-root directory listing WITHOUT blocking the
|
|
463
|
+
* event loop, keyed on the resolved worktree root. A root that does not exist
|
|
464
|
+
* yet maps to an empty list (the sync builder's ordinary ENOENT case); any
|
|
465
|
+
* OTHER failure is left unset so the sync builder re-reads it and records the
|
|
466
|
+
* error, exactly as it does today.
|
|
467
|
+
*/
|
|
468
|
+
async function readWorktreeRootEntriesAsync(opts = {}) {
|
|
469
|
+
// Honor an injected `fs` when it exposes the promises API, so the async
|
|
470
|
+
// pre-read obeys exactly the same injection contract as the sync builder
|
|
471
|
+
// instead of quietly reaching past it to the real filesystem.
|
|
472
|
+
const readdir = typeof opts.fs?.promises?.readdir === 'function'
|
|
473
|
+
? opts.fs.promises.readdir
|
|
474
|
+
: fs.promises.readdir;
|
|
475
|
+
const worktreeRootRel = opts.config?.engine?.worktreeRoot || shared.ENGINE_DEFAULTS.worktreeRoot;
|
|
476
|
+
const map = new Map();
|
|
477
|
+
for (const project of (Array.isArray(opts.projects) ? opts.projects : [])) {
|
|
478
|
+
if (!project?.localPath) continue;
|
|
479
|
+
let worktreeRoot;
|
|
480
|
+
try { worktreeRoot = path.resolve(path.resolve(String(project.localPath)), worktreeRootRel); }
|
|
481
|
+
catch { continue; }
|
|
482
|
+
if (map.has(worktreeRoot)) continue;
|
|
483
|
+
try {
|
|
484
|
+
const dirents = await readdir(worktreeRoot, { withFileTypes: true });
|
|
485
|
+
map.set(worktreeRoot, dirents
|
|
486
|
+
.filter(entry => entry.isDirectory() && !shared.isWorktreeRootInfraEntry(entry.name))
|
|
487
|
+
.map(entry => path.join(worktreeRoot, entry.name)));
|
|
488
|
+
} catch (e) {
|
|
489
|
+
if (e?.code === 'ENOENT') map.set(worktreeRoot, []);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return map;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Every directory the classifier could ask about: a SUPERSET is deliberate and
|
|
497
|
+
* safe — an unused probe costs one map entry, while a missing one silently
|
|
498
|
+
* falls back to a synchronous syscall. Building the superset from the two
|
|
499
|
+
* pre-reads avoids re-deriving the builder's in-root/out-of-root split here.
|
|
500
|
+
*/
|
|
501
|
+
function _probeCandidates(entriesByWorktreeRoot, parsedTreesByProject) {
|
|
502
|
+
const out = new Set();
|
|
503
|
+
for (const entries of entriesByWorktreeRoot.values()) {
|
|
504
|
+
for (const entry of (Array.isArray(entries) ? entries : [])) out.add(entry);
|
|
505
|
+
}
|
|
506
|
+
for (const trees of parsedTreesByProject.values()) {
|
|
507
|
+
for (const tree of (Array.isArray(trees) ? trees : [])) {
|
|
508
|
+
if (!tree?.path) continue;
|
|
509
|
+
try { out.add(path.resolve(tree.path)); } catch { /* unusable registry path */ }
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return [...out];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Pre-read the per-directory filesystem probes the classifier needs — the
|
|
517
|
+
* ownership marker, the protected-quarantine marker, and the symlink-resolved
|
|
518
|
+
* comparison path — with bounded concurrency and no synchronous syscalls.
|
|
519
|
+
*
|
|
520
|
+
* A probe that fails is simply not recorded, so the synchronous classifier
|
|
521
|
+
* re-probes that one path and applies its own fail-safe. The pre-read can
|
|
522
|
+
* therefore only make the scan cheaper, never change what it reports.
|
|
523
|
+
*/
|
|
524
|
+
async function readWorktreeProbesAsync(candidates, opts = {}) {
|
|
525
|
+
const ownerMarkerProbe = typeof opts.hasWorktreeOwnerMarkerAsync === 'function'
|
|
526
|
+
? opts.hasWorktreeOwnerMarkerAsync
|
|
527
|
+
: shared.hasWorktreeOwnerMarkerAsync;
|
|
528
|
+
const protectedProbe = typeof opts.hasProtectedQuarantineMarkerAsync === 'function'
|
|
529
|
+
? opts.hasProtectedQuarantineMarkerAsync
|
|
530
|
+
: shared.hasProtectedQuarantineMarkerAsync;
|
|
531
|
+
const resolveCompare = typeof opts.pathForComparisonAsync === 'function'
|
|
532
|
+
? opts.pathForComparisonAsync
|
|
533
|
+
: shared.pathForComparisonAsync;
|
|
534
|
+
|
|
535
|
+
const list = [...new Set((Array.isArray(candidates) ? candidates : [])
|
|
536
|
+
.filter(candidate => typeof candidate === 'string' && candidate))];
|
|
537
|
+
const map = new Map();
|
|
538
|
+
await shared.mapWithConcurrency(list, WORKTREE_PROBE_CONCURRENCY, async (candidate) => {
|
|
539
|
+
const probe = {};
|
|
540
|
+
let usable = false;
|
|
541
|
+
try { probe.ownerMarker = !!(await ownerMarkerProbe(candidate)); usable = true; }
|
|
542
|
+
catch { /* fall back to the synchronous probe for this path */ }
|
|
543
|
+
try { probe.comparePath = await resolveCompare(candidate); usable = true; }
|
|
544
|
+
catch { /* ditto */ }
|
|
545
|
+
if (_isQuarantineDirName(path.basename(candidate))) {
|
|
546
|
+
try { probe.protectedQuarantineMarker = !!(await protectedProbe(candidate)); usable = true; }
|
|
547
|
+
catch { /* ditto */ }
|
|
548
|
+
}
|
|
549
|
+
if (!usable) return;
|
|
550
|
+
try { map.set(worktreePool._normalizePath(candidate), probe); } catch { /* unusable key */ }
|
|
551
|
+
});
|
|
552
|
+
return map;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Resolve the anchored `keep_processes` / `managed_spawn` cwds to their
|
|
557
|
+
* comparison form off the event loop. Resolved with the SAME async twin the
|
|
558
|
+
* candidate paths use, so both sides of every containment test agree.
|
|
559
|
+
*
|
|
560
|
+
* A cwd the async resolver cannot handle falls back to the synchronous helper
|
|
561
|
+
* rather than being dropped: losing an anchor silently downgrades a HELD
|
|
562
|
+
* worktree to `ownedOrphan`, which is the reclaimable bucket. Only a path that
|
|
563
|
+
* neither resolver can read is dropped — the pre-existing behaviour.
|
|
564
|
+
*/
|
|
565
|
+
async function _readAnchoredCwdsAsync(opts = {}) {
|
|
566
|
+
const resolveCompare = typeof opts.pathForComparisonAsync === 'function'
|
|
567
|
+
? opts.pathForComparisonAsync
|
|
568
|
+
: shared.pathForComparisonAsync;
|
|
569
|
+
const raw = _collectAnchoredCwdPaths(
|
|
570
|
+
typeof opts.listManagedSpecs === 'function'
|
|
571
|
+
? opts.listManagedSpecs
|
|
572
|
+
: () => require('../processes/managed-spawn').listManagedSpecs(),
|
|
573
|
+
typeof opts.listKeepProcessCwds === 'function'
|
|
574
|
+
? opts.listKeepProcessCwds
|
|
575
|
+
: () => require('../processes/keep-process-sweep').getActiveKeepProcessCwds(),
|
|
576
|
+
);
|
|
577
|
+
const out = [];
|
|
578
|
+
for (const cwd of raw) {
|
|
579
|
+
let resolved = null;
|
|
580
|
+
try { resolved = await resolveCompare(cwd); }
|
|
581
|
+
catch { /* fall through to the synchronous resolver */ }
|
|
582
|
+
if (!resolved) {
|
|
583
|
+
try { resolved = shared.pathForComparison(cwd); } catch { /* unreadable cwd */ }
|
|
584
|
+
}
|
|
585
|
+
if (resolved) out.push(resolved);
|
|
586
|
+
}
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Async front door for callers that must not block the event loop (the
|
|
592
|
+
* dashboard's background worktree-count refresh and `GET
|
|
593
|
+
* /api/worktree-inventory`). Every filesystem and git cost is paid
|
|
594
|
+
* asynchronously up front; the SAME synchronous classifier then runs over the
|
|
595
|
+
* pre-read results, so the count and the inventory page can never disagree.
|
|
596
|
+
*
|
|
597
|
+
* W-ms9q3dql00vxd4a8 — the pre-reads are what keep the invariant honest.
|
|
598
|
+
* Classification itself is a tight loop over N directories, so leaving its
|
|
599
|
+
* probes synchronous made the whole scan one contiguous blocking block whose
|
|
600
|
+
* cost scales with the `foreignUnmanaged` residue the engine deliberately never
|
|
601
|
+
* reclaims. Measured on a 150-directory root with three live process anchors:
|
|
602
|
+
* >100 ms of event-loop stall every `worktreeCountCacheTtl`, against the 100 ms
|
|
603
|
+
* budget /api/status has to stay inside.
|
|
604
|
+
*/
|
|
605
|
+
async function buildWorktreeInventoryAsync(opts = {}) {
|
|
606
|
+
const parsedTreesByProject = opts.parsedTreesByProject instanceof Map
|
|
607
|
+
? opts.parsedTreesByProject
|
|
608
|
+
: await readWorktreeRegistriesAsync(opts);
|
|
609
|
+
const entriesByWorktreeRoot = opts.entriesByWorktreeRoot instanceof Map
|
|
610
|
+
? opts.entriesByWorktreeRoot
|
|
611
|
+
: await readWorktreeRootEntriesAsync(opts);
|
|
612
|
+
const anchoredCwds = Array.isArray(opts.anchoredCwds)
|
|
613
|
+
? opts.anchoredCwds
|
|
614
|
+
: await _readAnchoredCwdsAsync(opts);
|
|
615
|
+
const probesByPath = opts.probesByPath instanceof Map
|
|
616
|
+
? opts.probesByPath
|
|
617
|
+
: await readWorktreeProbesAsync(
|
|
618
|
+
_probeCandidates(entriesByWorktreeRoot, parsedTreesByProject),
|
|
619
|
+
opts,
|
|
620
|
+
);
|
|
621
|
+
return buildWorktreeInventory({
|
|
622
|
+
...opts,
|
|
623
|
+
parsedTreesByProject,
|
|
624
|
+
entriesByWorktreeRoot,
|
|
625
|
+
anchoredCwds,
|
|
626
|
+
probesByPath,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Async wrapper that also resolves the recoverable quarantine refs (a git
|
|
632
|
+
* shell-out per project). Kept separate so the pure classification above stays
|
|
633
|
+
* synchronous and directly unit-testable.
|
|
634
|
+
*/
|
|
635
|
+
async function buildWorktreeInventoryWithRefs(opts = {}) {
|
|
636
|
+
const inventory = await buildWorktreeInventoryAsync(opts);
|
|
637
|
+
let refs = [];
|
|
638
|
+
try {
|
|
639
|
+
refs = await (typeof opts.listQuarantineRefs === 'function'
|
|
640
|
+
? opts.listQuarantineRefs(opts.projects || [])
|
|
641
|
+
: quarantineRefs.listQuarantineRefs(opts.projects || []));
|
|
642
|
+
} catch (e) {
|
|
643
|
+
inventory.errors.push({ scope: 'quarantine-refs', error: e?.message || String(e) });
|
|
644
|
+
}
|
|
645
|
+
const items = Array.isArray(refs) ? refs : [];
|
|
646
|
+
inventory.recoverableRefs = {
|
|
647
|
+
total: items.filter(item => item && !item.error).length,
|
|
648
|
+
wip: items.filter(item => item && item.kind === 'wip').length,
|
|
649
|
+
commits: items.filter(item => item && item.kind === 'commits').length,
|
|
650
|
+
// W-ms87rbe601a66d13 — report the window actually in effect (operator
|
|
651
|
+
// override included), not the built-in constant, so the inventory does not
|
|
652
|
+
// advertise a 30-day recovery story on an install that shortened it.
|
|
653
|
+
retentionMs: shared.resolveQuarantineRetentionMs(opts.config),
|
|
654
|
+
};
|
|
655
|
+
for (const item of items) {
|
|
656
|
+
if (item && item.error) inventory.errors.push({ project: item.project, scope: 'quarantine-refs', error: item.error });
|
|
657
|
+
}
|
|
658
|
+
return inventory;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
module.exports = {
|
|
662
|
+
buildWorktreeInventory,
|
|
663
|
+
buildWorktreeInventoryAsync,
|
|
664
|
+
buildWorktreeInventoryWithRefs,
|
|
665
|
+
readWorktreeRegistriesAsync,
|
|
666
|
+
readWorktreeRootEntriesAsync,
|
|
667
|
+
readWorktreeProbesAsync,
|
|
668
|
+
summarizeLifecycleCounts,
|
|
669
|
+
countAvoidedAllocations,
|
|
670
|
+
_isQuarantineDirName, // exported for testing
|
|
671
|
+
};
|