mixdog 1.3.2 → 1.3.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 +7 -7
- package/scripts/verify-runtime-dependency-versions.mjs +158 -0
- package/src/agents/reviewer/AGENT.md +5 -7
- package/src/agents/reviewer/agent.json +1 -1
- package/src/defaults/shipped-history.json +4 -0
- package/src/defaults/skills/browser-use/SKILL.md +6 -3
- package/src/defaults/skills/local-provider/SKILL.md +33 -6
- package/src/rules/lead/LEAD.md +5 -8
- package/src/rules/shared/45-destructive.md +5 -1
- package/src/rules/shared/60-verification.md +6 -4
- package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +38 -11
- package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +15 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +17 -4
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
- package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +14 -1
- package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/registry.mjs +10 -0
- package/src/runtime/agent/orchestrator/providers/wire-gateway.mjs +13 -1
- package/src/runtime/agent/orchestrator/runtime-core/goal-runtime.mjs +5 -0
- package/src/runtime/agent/orchestrator/runtime-core/goal-turns.mjs +10 -1
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +40 -5
- package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +32 -11
- package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/agent-room.d.mts +13 -0
- package/src/runtime/agent/orchestrator/session/agent-room.mjs +68 -12
- package/src/runtime/agent/orchestrator/session/compact/continuation.mjs +15 -6
- package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +31 -8
- package/src/runtime/agent/orchestrator/session/compact/messages.mjs +44 -0
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +117 -19
- package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/compact/user-message-kind.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/human-gate/delegation.mjs +147 -137
- package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +37 -74
- package/src/runtime/agent/orchestrator/session/human-gate/requests.mjs +0 -2
- package/src/runtime/agent/orchestrator/session/human-gate/run-policy.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +13 -7
- package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/continuation.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/empty-nudge.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/output-limit.mjs +62 -13
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +22 -6
- package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +12 -20
- package/src/runtime/agent/orchestrator/session/loop/tool-exec/call-prep.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec/dispatch.mjs +11 -5
- package/src/runtime/agent/orchestrator/session/manager/agent-listing-notice.mjs +102 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +13 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +24 -15
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-prepare.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-steering-drain.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +32 -1
- package/src/runtime/agent/orchestrator/session/manager/orchestration-mode-switch-notice.mjs +17 -1
- package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +36 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -9
- package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +7 -5
- package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +7 -3
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/routine-result.mjs +7 -4
- package/src/runtime/agent/orchestrator/session/runtime-user-context.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/store/sweep/stale-sweep.mjs +5 -4
- package/src/runtime/agent/orchestrator/session/store/sweep/sub-agent-retention.mjs +140 -0
- package/src/runtime/agent/orchestrator/session/store/sweep/sweep-plan.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/store/sweep/sweep-record.mjs +7 -2
- package/src/runtime/agent/orchestrator/session/store/sweep/sweep-row.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +76 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +33 -1
- package/src/runtime/agent/orchestrator/session/store-summary-visibility.d.mts +5 -0
- package/src/runtime/agent/orchestrator/session/store-summary-visibility.mjs +37 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +18 -7
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/backgrounded-result.mjs +2 -0
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +127 -20
- package/src/runtime/agent/orchestrator/tools/builtin/git-worktree-tool.mjs +557 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +6 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +8 -2
- package/src/runtime/attachments/legacy-office.mjs +110 -0
- package/src/runtime/attachments/prompt-restore.mjs +70 -0
- package/src/runtime/attachments/store.mjs +60 -3
- package/src/runtime/effort-judge/judge-client.mjs +61 -14
- package/src/runtime/effort-judge/model-install.mjs +34 -2
- package/src/runtime/memory/lib/memory-chunk-quality.mjs +14 -2
- package/src/runtime/office/portable/portable-soffice.mjs +132 -30
- package/src/runtime/shared/agent-avatar-sprites.mjs +23 -8
- package/src/runtime/shared/agent-avatar.mjs +3 -2
- package/src/runtime/shared/agent-room-host.d.mts +31 -0
- package/src/runtime/shared/agent-room-host.mjs +58 -0
- package/src/runtime/shared/bot-hops.mjs +16 -11
- package/src/runtime/shared/redact-directories.mjs +18 -0
- package/src/runtime/shared/remove-directory.mjs +28 -0
- package/src/runtime/shared/session-sidebar-host.d.mts +11 -2
- package/src/runtime/shared/session-sidebar-host.mjs +16 -3
- package/src/runtime/shared/tool-execution-contract.mjs +4 -0
- package/src/runtime/shared/tool-work-units.mjs +12 -9
- package/src/runtime/shared/turn-snapshot.mjs +233 -122
- package/src/runtime/shared/user-data-guard.mjs +51 -19
- package/src/runtime/shared/worktrees/activity-gate.mjs +209 -227
- package/src/runtime/shared/worktrees/contract.mjs +11 -3
- package/src/runtime/shared/worktrees/maintenance.mjs +11 -2
- package/src/runtime/shared/worktrees/manager.mjs +419 -52
- package/src/runtime/shared/worktrees/registry.mjs +3 -1
- package/src/runtime/shared/worktrees/tool-host.mjs +39 -0
- package/src/runtime/tidy/process.mjs +41 -8
- package/src/runtime/tidy/resolve.mjs +10 -7
- package/src/session-runtime/agent-home-delivery.mjs +73 -91
- package/src/session-runtime/agent-profile-changes.mjs +16 -0
- package/src/session-runtime/agent-room-delivery.mjs +2 -63
- package/src/session-runtime/agent-task-sessions.mjs +194 -64
- package/src/session-runtime/boot/apis.mjs +3 -0
- package/src/session-runtime/boot/facade.mjs +5 -6
- package/src/session-runtime/boot/tools.mjs +21 -1
- package/src/session-runtime/held-requests.mjs +20 -25
- package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +5 -22
- package/src/session-runtime/model-recency.mjs +1 -0
- package/src/session-runtime/provider-models/row-hydration.mjs +8 -1
- package/src/session-runtime/quick-model-rows/model-meta.mjs +2 -0
- package/src/session-runtime/quick-model-rows/provider-rows.mjs +7 -1
- package/src/session-runtime/runtime-review-api.mjs +4 -1
- package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +34 -19
- package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +3 -0
- package/src/session-runtime/services/provider-admin.mjs +12 -2
- package/src/session-runtime/services/worktree-access.mjs +27 -0
- package/src/session-runtime/session-hooks.mjs +9 -17
- package/src/session-runtime/settings-builtin-tools-api.mjs +2 -0
- package/src/session-runtime/setup-tool/executor.mjs +4 -0
- package/src/session-runtime/setup-tool/settings-contract.mjs +3 -0
- package/src/session-runtime/tool-policy/agent-profile-fold.mjs +98 -14
- package/src/session-runtime/tool-policy/policy-stage.mjs +23 -5
- package/src/session-runtime/tool-policy-refresh.mjs +1 -0
- package/src/session-runtime/tool-surface.mjs +9 -5
- package/src/session-runtime/turn/session-ops.mjs +8 -0
- package/src/session-runtime/turn/turn-run.mjs +5 -1
- package/src/session-runtime/workflow-agents-api/agent-editor.mjs +10 -2
- package/src/session-runtime/workflow-agents-api/agent-route.mjs +39 -17
- package/src/session-runtime/workspace/transition-steps.mjs +1 -18
- package/src/session-runtime/workspace/transitions.mjs +279 -67
- package/src/session-runtime/workspace/worktree-tool.mjs +194 -0
- package/src/standalone/daemon-lag-profiler.mjs +3 -1
- package/src/standalone/daemon-session-control.mjs +140 -23
- package/src/standalone/daemon-telemetry.mjs +9 -0
- package/src/standalone/daemon.mjs +5 -0
- package/src/standalone/local-session-runtime.mjs +11 -2
- package/src/standalone/session-protocol.mjs +1 -0
- package/src/standalone/session-runtime-inline-host/boot-loaders.mjs +2 -0
- package/src/standalone/session-service/agent-route-follow.mjs +69 -0
- package/src/standalone/session-service/session-calls/session-turns.mjs +33 -4
- package/src/standalone/session-service.mjs +10 -0
- package/src/standalone/session-tool-output.mjs +1 -0
- package/src/standalone/staged-import.mjs +47 -0
- package/src/tui/App.jsx +2 -0
- package/src/tui/app/message-selector.mjs +15 -2
- package/src/tui/app/model-options.mjs +16 -2
- package/src/tui/app/model-picker.mjs +2 -1
- package/src/tui/app/route-pickers.mjs +3 -1
- package/src/tui/dist/index.mjs +92 -40
- package/src/tui/paste-attachments.mjs +0 -1
- package/src/tui/paste-text-policy.mjs +4 -5
- package/src/tui/session/agent-job-feed/execution-delivery.mjs +4 -1
- package/src/tui/session/notice-surface.mjs +3 -0
- package/src/tui/session/session-api/intake/abort.mjs +33 -17
- package/src/tui/session/session-api/intake/rewind.mjs +39 -4
- package/src/tui/session/session-api/routes-agent-definitions.mjs +1 -0
- package/src/tui/session/tool-approval.mjs +11 -48
- package/src/session-runtime/room-member-gate.mjs +0 -61
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
4
|
import { isAbsolute, join } from 'node:path';
|
|
5
|
-
import {
|
|
6
|
-
parseJsonText,
|
|
7
|
-
quarantinePath,
|
|
8
|
-
readJsonStateSync,
|
|
9
|
-
unsupportedState,
|
|
10
|
-
updateJsonAtomicSync,
|
|
11
|
-
writeFileAtomicSync,
|
|
12
|
-
writeJsonAtomicSync,
|
|
13
|
-
} from '../atomic-file.mjs';
|
|
5
|
+
import { parseJsonText, quarantinePath, withFileLockSync, writeFileAtomicSync } from '../atomic-file.mjs';
|
|
14
6
|
import { isPidAliveOrUnknown } from '../pid-liveness.mjs';
|
|
15
7
|
import { findGitLayout } from './git-head.mjs';
|
|
16
8
|
import { canonicalPath, isWithin, samePath } from './paths.mjs';
|
|
@@ -43,10 +35,19 @@ export function isExternalCheckout(folder) {
|
|
|
43
35
|
);
|
|
44
36
|
}
|
|
45
37
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
38
|
+
/*
|
|
39
|
+
* The admission ledger lists every turn, command and management lease of a
|
|
40
|
+
* repository. It is kept in two copies with the same content in the Git
|
|
41
|
+
* common directory: `mixdog-workspace-admission.json` and its `.management`
|
|
42
|
+
* mirror. A grant returns its release only once both copies hold it, and a
|
|
43
|
+
* release is forgotten only once both copies dropped it, so damage to either
|
|
44
|
+
* copy alone never loses an acknowledged lease. `recoveredAt` marks a ledger
|
|
45
|
+
* that may be missing leases because no complete copy survived: readers still
|
|
46
|
+
* run, management refuses, and only a quiescent reset that removes both
|
|
47
|
+
* copies clears it (docs/git-github-integration.md). Time, healing and
|
|
48
|
+
* releases never do.
|
|
49
|
+
*/
|
|
50
|
+
const VERSION = 2;
|
|
50
51
|
const isLease = (lease) =>
|
|
51
52
|
!!lease &&
|
|
52
53
|
['turn', 'command', 'management'].includes(lease.kind) &&
|
|
@@ -55,256 +56,245 @@ const isLease = (lease) =>
|
|
|
55
56
|
lease.pid > 0 &&
|
|
56
57
|
typeof lease.path === 'string' &&
|
|
57
58
|
isAbsolute(lease.path);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
59
|
+
// Only a pid PROVEN dead (ESRCH) drops its lease, whatever its kind; an
|
|
60
|
+
// unprobeable pid still counts as running. A live runtime keeps a command
|
|
61
|
+
// whose tree it cannot verify reserved under its own (live) pid until the
|
|
62
|
+
// tree is confirmed gone, so a lease that outlives every pid belongs to a
|
|
63
|
+
// restarted runtime that can no longer release it.
|
|
64
|
+
const aliveLease = (lease) => isPidAliveOrUnknown(lease.pid);
|
|
65
|
+
// A management hold shares one token across its folders, so a lease is
|
|
66
|
+
// identified by token and path.
|
|
67
|
+
const leaseKey = (lease) => `${lease.token}\0${lease.path}`;
|
|
68
|
+
const unverifiable = () => worktreeRefusal('workspace-admission-unreadable');
|
|
69
|
+
const copyPaths = (commonDir) => {
|
|
70
|
+
const primary = join(commonDir, FILE);
|
|
71
|
+
return [primary, `${primary}.management`];
|
|
71
72
|
};
|
|
72
73
|
const envMs = (name, fallback) => {
|
|
73
74
|
const value = Number(process.env[name]);
|
|
74
75
|
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
75
76
|
};
|
|
76
|
-
// How long the leases of a recovered ledger stay unknown. Every process
|
|
77
|
-
// holding a lease republishes it within one heal period, so after this window
|
|
78
|
-
// the ledger is whole again and destructive operations resume on their own.
|
|
79
|
-
const recoveryWindowMs = () => envMs('MIXDOG_ADMISSION_RECOVERY_MS', 15_000);
|
|
80
77
|
const healIntervalMs = () => envMs('MIXDOG_ADMISSION_HEAL_MS', 2_000);
|
|
81
78
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (error?.code !== 'ENOENT') throw worktreeRefusal('workspace-admission-unreadable');
|
|
89
|
-
}
|
|
90
|
-
}
|
|
79
|
+
// The leases this process holds, put back into a ledger that lost them, and
|
|
80
|
+
// the tokens it released or failed to grant whose removal from both copies is
|
|
81
|
+
// not confirmed yet (token -> common dir), retried until it is.
|
|
82
|
+
const held = new Map();
|
|
83
|
+
const pending = new Map();
|
|
84
|
+
let healTimer = null;
|
|
91
85
|
|
|
92
86
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* foreign turn. A crash before the publish leaves the damaged file in place,
|
|
101
|
-
* which the next writer recovers the same way. If the copy cannot be kept the
|
|
102
|
-
* ledger is left untouched and everything refuses.
|
|
87
|
+
* One copy as read under the ledger lock. A copy that cannot be read (access
|
|
88
|
+
* denied, in use, a directory) or that a newer build wrote refuses everything
|
|
89
|
+
* and is never written: neither may pass for an empty ledger. Anything else
|
|
90
|
+
* that is not a ledger, or holds an invalid lease record, is damaged; its
|
|
91
|
+
* valid records still count. A copy is complete when it accounts for every
|
|
92
|
+
* acknowledged lease: intact, not uncertain, and version 2 — or the version 1
|
|
93
|
+
* primary, since the version 1 mirror held management leases only.
|
|
103
94
|
*/
|
|
104
|
-
function
|
|
105
|
-
|
|
95
|
+
function readCopy(path, primary) {
|
|
96
|
+
let bytes;
|
|
106
97
|
try {
|
|
107
|
-
|
|
108
|
-
} catch {
|
|
109
|
-
|
|
98
|
+
bytes = readFileSync(path);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error?.code === 'ENOENT') return { path, leases: [] };
|
|
101
|
+
throw unverifiable();
|
|
110
102
|
}
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function keepAside(path, value) {
|
|
115
|
-
const aside = quarantinePath(path);
|
|
103
|
+
let ledger = null;
|
|
116
104
|
try {
|
|
117
|
-
|
|
118
|
-
process.stderr.write(`[state] ${path} held invalid lease records; kept as ${aside}\n`);
|
|
105
|
+
ledger = parseJsonText(bytes.toString('utf8'));
|
|
119
106
|
} catch {
|
|
120
|
-
//
|
|
107
|
+
// Unparseable: damaged below.
|
|
108
|
+
}
|
|
109
|
+
if (Number.isSafeInteger(ledger?.version) && ledger.version > VERSION) throw unverifiable();
|
|
110
|
+
if (![1, VERSION].includes(ledger?.version) || !Array.isArray(ledger.leases)) {
|
|
111
|
+
return { path, bytes, leases: [], damaged: true };
|
|
121
112
|
}
|
|
113
|
+
const leases = ledger.leases.filter(isLease);
|
|
114
|
+
const damaged = leases.length !== ledger.leases.length;
|
|
115
|
+
const uncertain = Object.hasOwn(ledger, 'recoveredAt');
|
|
116
|
+
const complete = !damaged && !uncertain && (ledger.version === VERSION || primary);
|
|
117
|
+
return { path, bytes, leases, damaged, uncertain, complete, recoveredAt: ledger.recoveredAt };
|
|
122
118
|
}
|
|
123
119
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
120
|
+
/**
|
|
121
|
+
* The ledger recovered from both copies: the union of their valid lease
|
|
122
|
+
* records (one copy lacking a lease never drops it), less proven-dead pids
|
|
123
|
+
* and this process's pending removals, plus the leases this process holds.
|
|
124
|
+
* It stays uncertain when either copy is, and becomes so when a copy exists
|
|
125
|
+
* but none is complete. Both copies missing reads as a new repository.
|
|
126
|
+
*/
|
|
127
|
+
function recover(commonDir, copies) {
|
|
128
|
+
const own = [...held.values()].filter((entry) => entry.commonDir === commonDir).map((entry) => entry.lease);
|
|
129
|
+
const leases = new Map();
|
|
130
|
+
for (const lease of [...copies.flatMap((copy) => copy.leases), ...own]) {
|
|
131
|
+
const key = leaseKey(lease);
|
|
132
|
+
if (!leases.has(key) && pending.get(lease.token) !== commonDir && aliveLease(lease)) leases.set(key, lease);
|
|
134
133
|
}
|
|
134
|
+
const ledger = { version: VERSION, leases: [...leases.values()] };
|
|
135
|
+
const uncertain =
|
|
136
|
+
copies.some((copy) => copy.uncertain) ||
|
|
137
|
+
(copies.some((copy) => copy.bytes) && !copies.some((copy) => copy.complete));
|
|
138
|
+
if (uncertain) ledger.recoveredAt = copies.map((copy) => copy.recoveredAt).find(Number.isFinite) ?? Date.now();
|
|
139
|
+
return ledger;
|
|
135
140
|
}
|
|
136
141
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
+
/**
|
|
143
|
+
* #117: the bytes of a damaged copy are copied aside as `<copy>.corrupt-<ts>`
|
|
144
|
+
* before it is rewritten. If they cannot be kept, nothing is written and
|
|
145
|
+
* everything refuses.
|
|
146
|
+
*/
|
|
147
|
+
function keepDamagedAside(copies) {
|
|
148
|
+
for (const copy of copies.filter((each) => each.damaged)) {
|
|
149
|
+
const aside = quarantinePath(copy.path);
|
|
150
|
+
try {
|
|
151
|
+
writeFileAtomicSync(aside, copy.bytes);
|
|
152
|
+
} catch {
|
|
153
|
+
throw unverifiable();
|
|
154
|
+
}
|
|
155
|
+
process.stderr.write(`[state] ${copy.path} is damaged; kept as ${aside}\n`);
|
|
142
156
|
}
|
|
143
157
|
}
|
|
144
158
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
|
|
159
|
+
/** Writes the primary, then the mirror; a copy that already holds the ledger is left alone. */
|
|
160
|
+
function publish(copies, ledger) {
|
|
161
|
+
const text = Buffer.from(`${JSON.stringify(ledger, null, 2)}\n`);
|
|
162
|
+
for (const copy of copies) {
|
|
163
|
+
if (copy.bytes?.equals(text)) continue;
|
|
164
|
+
copy.bytes = null; // Unknown until the write lands.
|
|
165
|
+
writeFileAtomicSync(copy.path, text);
|
|
166
|
+
copy.bytes = text;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
152
169
|
|
|
153
|
-
function
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
healTimer.unref?.();
|
|
170
|
+
function settle(tokens) {
|
|
171
|
+
for (const token of tokens) pending.delete(token);
|
|
172
|
+
syncHealTimer();
|
|
157
173
|
}
|
|
158
174
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
175
|
+
/**
|
|
176
|
+
* Runs `update(leases, { unknown })` on the recovered ledger under the
|
|
177
|
+
* primary copy's lock and publishes the result to both copies before
|
|
178
|
+
* returning. `unknown`: the ledger may be missing leases. A refused update
|
|
179
|
+
* still publishes the repaired ledger. A grant that cannot be published is
|
|
180
|
+
* refused: its leases become pending removals and the ledger without them is
|
|
181
|
+
* published again; whatever that leaves behind is withdrawn by the next
|
|
182
|
+
* mutation or heal tick, which also blocks every later grant until it lands.
|
|
183
|
+
*/
|
|
184
|
+
function mutate(commonDir, update = () => {}) {
|
|
185
|
+
const paths = copyPaths(commonDir);
|
|
186
|
+
return withFileLockSync(
|
|
187
|
+
`${paths[0]}.lock`,
|
|
188
|
+
() => {
|
|
189
|
+
const copies = paths.map((path, index) => readCopy(path, index === 0));
|
|
190
|
+
keepDamagedAside(copies);
|
|
191
|
+
const withdrawn = [...pending].filter(([, dir]) => dir === commonDir).map(([token]) => token);
|
|
192
|
+
const base = recover(commonDir, copies);
|
|
193
|
+
const leases = [...base.leases];
|
|
194
|
+
let result;
|
|
195
|
+
let refusal = null;
|
|
196
|
+
try {
|
|
197
|
+
result = update(leases, { unknown: Object.hasOwn(base, 'recoveredAt') });
|
|
198
|
+
} catch (error) {
|
|
199
|
+
refusal = error;
|
|
200
|
+
}
|
|
201
|
+
const granted = refusal ? [] : leases.filter((lease) => !base.leases.includes(lease));
|
|
202
|
+
try {
|
|
203
|
+
publish(copies, refusal ? base : { ...base, leases });
|
|
204
|
+
settle(withdrawn);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
process.stderr.write(`[state] ${paths[0]} could not be published (${error?.message})\n`);
|
|
207
|
+
if (refusal) throw refusal;
|
|
208
|
+
for (const lease of granted) pending.set(lease.token, commonDir);
|
|
209
|
+
if (granted.length) {
|
|
210
|
+
try {
|
|
211
|
+
publish(copies, base);
|
|
212
|
+
settle([...withdrawn, ...granted.map((lease) => lease.token)]);
|
|
213
|
+
} catch {
|
|
214
|
+
syncHealTimer();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
throw unverifiable();
|
|
218
|
+
}
|
|
219
|
+
if (refusal) throw refusal;
|
|
220
|
+
return result;
|
|
221
|
+
},
|
|
222
|
+
{ strictOwner: true }
|
|
223
|
+
);
|
|
165
224
|
}
|
|
166
225
|
|
|
167
|
-
|
|
168
|
-
|
|
226
|
+
// Without the lock, a cheap check that both copies are the same current
|
|
227
|
+
// ledger holding every live lease this process holds there and none of the
|
|
228
|
+
// tokens it is withdrawing. Repair, never proof that the ledger is complete.
|
|
229
|
+
function inSync(commonDir) {
|
|
230
|
+
let ledger;
|
|
169
231
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
232
|
+
const [primary, mirror] = copyPaths(commonDir).map((path) => readFileSync(path));
|
|
233
|
+
if (!primary.equals(mirror)) return false;
|
|
234
|
+
ledger = parseJsonText(primary.toString('utf8'));
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
173
237
|
}
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
238
|
+
if (ledger?.version !== VERSION || !Array.isArray(ledger.leases)) return false;
|
|
239
|
+
const stored = new Set(ledger.leases.filter(isLease).map(leaseKey));
|
|
240
|
+
return (
|
|
241
|
+
[...held].every(([key, entry]) => entry.commonDir !== commonDir || stored.has(key) || !aliveLease(entry.lease)) &&
|
|
242
|
+
!ledger.leases.some((lease) => pending.get(lease?.token) === commonDir)
|
|
243
|
+
);
|
|
178
244
|
}
|
|
179
245
|
|
|
180
246
|
function healHeld() {
|
|
181
|
-
const
|
|
182
|
-
for (const
|
|
183
|
-
|
|
184
|
-
if (!ledgerLacks(commonDir, keys)) continue;
|
|
247
|
+
const dirs = new Set([...[...held.values()].map((entry) => entry.commonDir), ...pending.values()]);
|
|
248
|
+
for (const commonDir of dirs) {
|
|
249
|
+
if (inSync(commonDir)) continue;
|
|
185
250
|
try {
|
|
186
|
-
mutate(
|
|
187
|
-
commonDir,
|
|
188
|
-
() => {},
|
|
189
|
-
() => {}
|
|
190
|
-
);
|
|
251
|
+
mutate(commonDir);
|
|
191
252
|
} catch {
|
|
192
253
|
// Retried on the next tick.
|
|
193
254
|
}
|
|
194
255
|
}
|
|
256
|
+
syncHealTimer();
|
|
195
257
|
}
|
|
196
258
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
path,
|
|
211
|
-
(current) => {
|
|
212
|
-
if (isNewer(current)) {
|
|
213
|
-
result = onNewer();
|
|
214
|
-
return undefined;
|
|
215
|
-
}
|
|
216
|
-
const now = Date.now();
|
|
217
|
-
let state;
|
|
218
|
-
let recovered = false;
|
|
219
|
-
if (current === null) {
|
|
220
|
-
state = emptyLedger();
|
|
221
|
-
// `keepWithoutBackup` leaves a ledger whose text does not parse in
|
|
222
|
-
// place, so null with a file present is damage; no file is a new ledger.
|
|
223
|
-
if (existsSync(path)) {
|
|
224
|
-
keepDamagedAside(path);
|
|
225
|
-
recovered = true;
|
|
226
|
-
}
|
|
227
|
-
} else if (!hasLedgerShape(current)) {
|
|
228
|
-
keepAside(path, current);
|
|
229
|
-
state = emptyLedger();
|
|
230
|
-
recovered = true;
|
|
231
|
-
} else {
|
|
232
|
-
state = { ...current, leases: current.leases.filter(isLease) };
|
|
233
|
-
if (state.leases.length !== current.leases.length) {
|
|
234
|
-
keepAside(path, current);
|
|
235
|
-
recovered = true;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
if (recovered) state.recoveredAt = now;
|
|
239
|
-
// Only a pid PROVEN dead (ESRCH) drops its lease, whatever its kind; an
|
|
240
|
-
// unprobeable pid still counts as running. A live runtime keeps a command
|
|
241
|
-
// whose tree it cannot verify reserved under its own (live) pid until the
|
|
242
|
-
// tree is confirmed gone, so a lease that outlives every pid belongs to a
|
|
243
|
-
// restarted runtime that can no longer release it.
|
|
244
|
-
state.leases = state.leases.filter(aliveLease);
|
|
245
|
-
const present = new Set(state.leases.map(leaseKey));
|
|
246
|
-
const restore = [
|
|
247
|
-
...(recovered ? readMirror(mirror) : []),
|
|
248
|
-
...[...held.values()].filter((own) => own.commonDir === commonDir).map((own) => own.lease),
|
|
249
|
-
];
|
|
250
|
-
for (const lease of restore) {
|
|
251
|
-
if (present.has(leaseKey(lease)) || !aliveLease(lease)) continue;
|
|
252
|
-
present.add(leaseKey(lease));
|
|
253
|
-
state.leases.push(lease);
|
|
254
|
-
}
|
|
255
|
-
const unknown = Number.isFinite(state.recoveredAt) && now - state.recoveredAt < recoveryWindowMs();
|
|
256
|
-
if (!unknown) delete state.recoveredAt;
|
|
257
|
-
const managementTokens = () =>
|
|
258
|
-
state.leases
|
|
259
|
-
.filter((lease) => lease.kind === 'management')
|
|
260
|
-
.map((lease) => lease.token)
|
|
261
|
-
.join();
|
|
262
|
-
const before = managementTokens();
|
|
263
|
-
const unchanged = [...state.leases];
|
|
264
|
-
try {
|
|
265
|
-
result = update(state.leases, { unknown });
|
|
266
|
-
} catch (error) {
|
|
267
|
-
// A refusal still publishes the recovery: dropping the write would
|
|
268
|
-
// leave the damaged file, and every retry would recover it again and
|
|
269
|
-
// restart the window, so management would never resume.
|
|
270
|
-
if (!recovered) throw error;
|
|
271
|
-
refusal = error;
|
|
272
|
-
state.leases = unchanged;
|
|
273
|
-
}
|
|
274
|
-
if (recovered || managementTokens() !== before) {
|
|
275
|
-
writeMirror(
|
|
276
|
-
mirror,
|
|
277
|
-
state.leases.filter((lease) => lease.kind === 'management')
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
return state;
|
|
281
|
-
},
|
|
282
|
-
// No backup restore: an older ledger would resurrect leases long released.
|
|
283
|
-
{ strictOwner: true, keepWithoutBackup: true, restoreBackup: false }
|
|
284
|
-
);
|
|
285
|
-
if (refusal) throw refusal;
|
|
286
|
-
return result;
|
|
259
|
+
function syncHealTimer() {
|
|
260
|
+
if (held.size || pending.size) {
|
|
261
|
+
healTimer ??= setInterval(healHeld, healIntervalMs());
|
|
262
|
+
healTimer.unref?.();
|
|
263
|
+
} else if (healTimer) {
|
|
264
|
+
clearInterval(healTimer);
|
|
265
|
+
healTimer = null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function track(commonDir, lease) {
|
|
270
|
+
held.set(leaseKey(lease), { commonDir, lease });
|
|
271
|
+
syncHealTimer();
|
|
287
272
|
}
|
|
288
273
|
|
|
274
|
+
// Forgotten as held first, so nothing puts the lease back; its token stays
|
|
275
|
+
// pending until both copies dropped it.
|
|
289
276
|
function release(commonDir, token) {
|
|
290
277
|
let done = false;
|
|
291
278
|
return () => {
|
|
292
279
|
if (done) return;
|
|
293
|
-
mutate(
|
|
294
|
-
commonDir,
|
|
295
|
-
(leases) => {
|
|
296
|
-
for (let i = leases.length - 1; i >= 0; i--) if (leases[i].token === token) leases.splice(i, 1);
|
|
297
|
-
},
|
|
298
|
-
() => {}
|
|
299
|
-
);
|
|
300
280
|
done = true;
|
|
301
|
-
|
|
281
|
+
for (const [key, entry] of held) if (entry.lease.token === token) held.delete(key);
|
|
282
|
+
pending.set(token, commonDir);
|
|
283
|
+
try {
|
|
284
|
+
mutate(commonDir);
|
|
285
|
+
} catch {
|
|
286
|
+
// Retried by the next mutation or heal tick.
|
|
287
|
+
}
|
|
288
|
+
syncHealTimer();
|
|
302
289
|
};
|
|
303
290
|
}
|
|
304
291
|
|
|
305
292
|
/**
|
|
306
293
|
* Readers share a checkout freely. Null means a management writer currently
|
|
307
|
-
* owns it; a reader in an external checkout is never held back.
|
|
294
|
+
* owns it; a reader in an external checkout is never held back. Throws
|
|
295
|
+
* `workspace-admission-unreadable` when the ledger cannot be read, was
|
|
296
|
+
* written by a newer build, or the grant could not be made durable in both
|
|
297
|
+
* copies: no work may run unrecorded.
|
|
308
298
|
*/
|
|
309
299
|
export function tryAdmitWorkspaceActivity(sessionId, cwd, { pid = process.pid, kind = 'turn' } = {}) {
|
|
310
300
|
if (!['turn', 'command'].includes(kind)) throw new TypeError('Invalid workspace reader kind.');
|
|
@@ -314,18 +304,11 @@ export function tryAdmitWorkspaceActivity(sessionId, cwd, { pid = process.pid, k
|
|
|
314
304
|
const path = canonicalPath(cwd);
|
|
315
305
|
const token = randomUUID();
|
|
316
306
|
const lease = { kind, sessionId, pid, token, path };
|
|
317
|
-
const admitted = mutate(
|
|
318
|
-
|
|
319
|
-
(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
return true;
|
|
323
|
-
},
|
|
324
|
-
// A newer build's ledger cannot be written: the reader goes unrecorded
|
|
325
|
-
// (prompts never wait on it) while management refuses on it.
|
|
326
|
-
() => UNRECORDED
|
|
327
|
-
);
|
|
328
|
-
if (admitted === UNRECORDED) return () => {};
|
|
307
|
+
const admitted = mutate(layout.commonDir, (leases) => {
|
|
308
|
+
if (!external && leases.some((other) => other.kind === 'management' && overlaps(other.path, path))) return false;
|
|
309
|
+
leases.push(lease);
|
|
310
|
+
return true;
|
|
311
|
+
});
|
|
329
312
|
if (!admitted) return null;
|
|
330
313
|
track(layout.commonDir, lease);
|
|
331
314
|
return release(layout.commonDir, token);
|
|
@@ -374,9 +357,8 @@ export function holdWorkspaceActivity(folders, { sessionId = null } = {}) {
|
|
|
374
357
|
paths.some(({ path }) => overlaps(path, lease.path))
|
|
375
358
|
);
|
|
376
359
|
if (conflicts.length) throw worktreeRefusal('folder-in-use');
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
if (unknown) throw worktreeRefusal('workspace-admission-unreadable');
|
|
360
|
+
// The ledger may be missing a live foreign lease: refused until reset.
|
|
361
|
+
if (unknown) throw unverifiable();
|
|
380
362
|
for (const { path, external } of paths) {
|
|
381
363
|
if (external) continue;
|
|
382
364
|
const lease = { kind: 'management', pid: process.pid, token, path };
|
|
@@ -265,13 +265,19 @@ export function nextRevision(workspace) {
|
|
|
265
265
|
* @property {string} repoCommonDir
|
|
266
266
|
* @property {string} projectPath
|
|
267
267
|
* @property {string} path Absolute worktree folder (see managedWorktreePath).
|
|
268
|
-
* @property {string} branch Full branch name, e.g. "mixdog/fix-login".
|
|
268
|
+
* @property {string|null} branch Full branch name, e.g. "mixdog/fix-login"; null only for a snapshot.
|
|
269
269
|
* @property {string|null} baseCommit
|
|
270
270
|
* @property {number} createdAt Epoch milliseconds.
|
|
271
|
-
* @property {string|null} ownerSessionId Lead session that retains it; null once released.
|
|
271
|
+
* @property {string|null} ownerSessionId Lead session that retains it; null once released (and for a snapshot).
|
|
272
272
|
* @property {WorktreeEntryState} state
|
|
273
|
+
* @property {'snapshot'} [kind] A read-only detached checkout of `baseCommit`
|
|
274
|
+
* (WORKTREE_SNAPSHOT_KIND): no branch, bound to no session, removed with
|
|
275
|
+
* the turn that made it.
|
|
273
276
|
*/
|
|
274
277
|
|
|
278
|
+
/** `kind` of a managed read-only snapshot: a detached checkout no session works in. */
|
|
279
|
+
export const WORKTREE_SNAPSHOT_KIND = 'snapshot';
|
|
280
|
+
|
|
275
281
|
/**
|
|
276
282
|
* @param {unknown} input
|
|
277
283
|
* @returns {WorktreeEntry}
|
|
@@ -284,16 +290,18 @@ export function normalizeWorktreeEntry(input) {
|
|
|
284
290
|
const id = text(value.id, 'worktree.id');
|
|
285
291
|
const idError = worktreeNameError(id);
|
|
286
292
|
if (idError) throw new TypeError(`worktree.id is invalid: ${idError}`);
|
|
293
|
+
const snapshot = value.kind === WORKTREE_SNAPSHOT_KIND;
|
|
287
294
|
return {
|
|
288
295
|
id,
|
|
289
296
|
repoCommonDir: text(value.repoCommonDir, 'worktree.repoCommonDir'),
|
|
290
297
|
projectPath: text(value.projectPath, 'worktree.projectPath'),
|
|
291
298
|
path: text(value.path, 'worktree.path'),
|
|
292
|
-
branch: text(value.branch, 'worktree.branch'),
|
|
299
|
+
branch: snapshot ? nullableText(value.branch, 'worktree.branch') : text(value.branch, 'worktree.branch'),
|
|
293
300
|
baseCommit: nullableCommit(value.baseCommit, 'worktree.baseCommit'),
|
|
294
301
|
createdAt: value.createdAt,
|
|
295
302
|
ownerSessionId: nullableText(value.ownerSessionId, 'worktree.ownerSessionId'),
|
|
296
303
|
state: oneOf(value.state, WORKTREE_ENTRY_STATES, 'worktree.state'),
|
|
304
|
+
...(snapshot ? { kind: WORKTREE_SNAPSHOT_KIND } : {}),
|
|
297
305
|
};
|
|
298
306
|
}
|
|
299
307
|
|
|
@@ -72,14 +72,21 @@ export async function listWorktreesWithRetention({
|
|
|
72
72
|
* The manual prune (an explicit request, so independent of autoCleanup):
|
|
73
73
|
* the startup reconcile plus removal of clean, merged worktrees that no
|
|
74
74
|
* stored or live session retains. Dirty or unmerged ones are reported kept.
|
|
75
|
-
*
|
|
75
|
+
* `repoCommonDir` limits the prune to that repository's worktrees.
|
|
76
|
+
* @param {{ liveSessionIds?: string[], listSessionIds?: () => Promise<string[]>, manager?: object, repoCommonDir?: string|null }} [options]
|
|
76
77
|
*/
|
|
77
|
-
export async function pruneWorktrees({
|
|
78
|
+
export async function pruneWorktrees({
|
|
79
|
+
liveSessionIds = [],
|
|
80
|
+
listSessionIds = storedSessionIds,
|
|
81
|
+
manager = null,
|
|
82
|
+
repoCommonDir = null,
|
|
83
|
+
} = {}) {
|
|
78
84
|
const backend = manager || (await loadManager());
|
|
79
85
|
return backend.reconcileManagedWorktrees({
|
|
80
86
|
liveSessionIds,
|
|
81
87
|
retainedSessionIds: await listSessionIds(),
|
|
82
88
|
removeOrphans: true,
|
|
89
|
+
...(repoCommonDir ? { repoCommonDir } : {}),
|
|
83
90
|
});
|
|
84
91
|
}
|
|
85
92
|
|
|
@@ -93,6 +100,8 @@ export async function pruneWorktrees({ liveSessionIds = [], listSessionIds = sto
|
|
|
93
100
|
export async function cleanupDeletedSessionWorktree(sessionId, { manager = null, autoCleanup } = {}) {
|
|
94
101
|
const backend = manager || (await loadManager());
|
|
95
102
|
await backend.forgetSharedWorktreeSession?.(sessionId);
|
|
103
|
+
// Its read-only snapshots go whatever autoCleanup says (they never outlive their holder).
|
|
104
|
+
await backend.removeSessionSnapshots?.(sessionId);
|
|
96
105
|
const entry = await backend.getSessionWorktree(sessionId);
|
|
97
106
|
if (!entry) return null;
|
|
98
107
|
const enabled = autoCleanup ?? (await backend.currentWorktreeSettings()).autoCleanup;
|