acuvo-code 0.2.0
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/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/lease.mjs
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ SEVEN TERMINALS, ONE CHECKOUT — A COOPERATIVE FILE LEASE ────────────
|
|
3
|
+
*
|
|
4
|
+
* `lib/parallel.mjs` coordinates tasks inside ONE process: it runs them in a
|
|
5
|
+
* pool and, crucially, DETECTS when two of them wrote the same file. Seven
|
|
6
|
+
* separate `acuvo` processes on one machine share no memory at all, so that
|
|
7
|
+
* detection cannot see across them. Two terminals will eventually write the
|
|
8
|
+
* same file, the second one wins silently, and the first reports success for
|
|
9
|
+
* work that no longer exists.
|
|
10
|
+
*
|
|
11
|
+
* This file is the missing coordination: a lease taken on a PATH, published on
|
|
12
|
+
* disk, visible to every process in the checkout.
|
|
13
|
+
*
|
|
14
|
+
* ── ⚠️ FOUR DESIGN DECISIONS, AND WHY THEY WENT THIS WAY ────────────────────
|
|
15
|
+
*
|
|
16
|
+
* 1. **PATHS, NOT THE REPO.** A repo-wide lock idles six of seven terminals,
|
|
17
|
+
* which deletes the entire point of running seven. A terminal declares the
|
|
18
|
+
* handful of files it intends to write; everything else stays free.
|
|
19
|
+
*
|
|
20
|
+
* 2. **A TTL PLUS A HEARTBEAT, AND A RECLAIM THAT IS DELIBERATELY SLOW.** A
|
|
21
|
+
* crashed terminal must not block the repo for ever, so a lease goes stale.
|
|
22
|
+
* But reclaiming EAGERLY is strictly worse than deadlocking: a deadlock is a
|
|
23
|
+
* refusal a human can read, while an early reclaim hands two live agents the
|
|
24
|
+
* same file and corrupts the work invisibly. So the reclaim boundary is the
|
|
25
|
+
* TTL *plus another whole TTL* (never less than the grace period), and the
|
|
26
|
+
* takeover is written into the record so it can be seen afterwards.
|
|
27
|
+
*
|
|
28
|
+
* 3. **CONSERVATIVE KEYS.** `src/App.ts` and `src/app.ts` are ONE file on
|
|
29
|
+
* Windows and macOS. Keying on the exact spelling would hand two agents the
|
|
30
|
+
* same file on the machine this actually runs on. Over-locking costs one
|
|
31
|
+
* refusal you can read; under-locking costs corruption you cannot see.
|
|
32
|
+
*
|
|
33
|
+
* 4. **EXCLUSIVITY COMES FROM THE FILESYSTEM, NEVER FROM A READ-THEN-WRITE.**
|
|
34
|
+
* Checking "is it free?" and then writing is a race with a window, and eight
|
|
35
|
+
* processes will find that window. Creating a file is the only operation the
|
|
36
|
+
* OS will do exactly once, so every lease is *published* by an exclusive
|
|
37
|
+
* create (hard link, falling back to O_EXCL) and every *replacement* is
|
|
38
|
+
* serialised behind an exclusively-created gate and lands by rename. A
|
|
39
|
+
* reader therefore only ever sees "no file" or "the whole file" — never the
|
|
40
|
+
* half a file a killed process would otherwise leave behind.
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ NO AMBIENT TIME. The wall clock is injected exactly once, as a default.
|
|
43
|
+
* Every TTL decision in here is a pure function of a number that a test can
|
|
44
|
+
* hand over, which is why the whole TTL surface tests in microseconds.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { createHash } from 'node:crypto';
|
|
48
|
+
import {
|
|
49
|
+
linkSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync,
|
|
50
|
+
} from 'node:fs';
|
|
51
|
+
import { isAbsolute, join, resolve, sep } from 'node:path';
|
|
52
|
+
|
|
53
|
+
/** Bump when the on-disk shape changes; an unknown version reads as corrupt. */
|
|
54
|
+
export const LEASE_VERSION = 1;
|
|
55
|
+
|
|
56
|
+
/** Where lease records live, relative to the workspace root. Always '/'-joined. */
|
|
57
|
+
export const LEASE_DIR = '.acuvo/leases';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* ⚠️ THE TTL BAND. A lease shorter than a second cannot be heartbeated
|
|
61
|
+
* reliably, and one longer than an hour is a crashed terminal nobody notices.
|
|
62
|
+
*/
|
|
63
|
+
export const MIN_TTL_MS = 1_000;
|
|
64
|
+
export const DEFAULT_TTL_MS = 120_000;
|
|
65
|
+
export const MAX_TTL_MS = 3_600_000;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* ⚠️ THE FLOOR ON PATIENCE. Even a one-second lease gets this long of silence
|
|
69
|
+
* before anyone may take it — long enough that a garbage-collection pause or a
|
|
70
|
+
* slow disk cannot look like a dead terminal.
|
|
71
|
+
*/
|
|
72
|
+
export const RECLAIM_GRACE_MS = 30_000;
|
|
73
|
+
|
|
74
|
+
/** A hot path could be taken over for ever; the record must not grow for ever. */
|
|
75
|
+
export const MAX_TAKEOVERS = 8;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* ⚠️ THE ONE AND ONLY READ OF AMBIENT TIME IN THIS MODULE. Everything else
|
|
79
|
+
* takes `clock` as a parameter. A test pins this by counting occurrences, and
|
|
80
|
+
* that test is the reason every TTL case here runs without a single sleep.
|
|
81
|
+
*/
|
|
82
|
+
const wallClock = () => Date.now();
|
|
83
|
+
|
|
84
|
+
/** Link failures that mean "this filesystem cannot", not "somebody beat you". */
|
|
85
|
+
const LINK_UNSUPPORTED = new Set(['EPERM', 'EACCES', 'ENOSYS', 'ENOTSUP', 'EOPNOTSUPP', 'EXDEV', 'EMLINK']);
|
|
86
|
+
|
|
87
|
+
/** Windows can transiently refuse a rename while another process reads the file. */
|
|
88
|
+
const RENAME_RETRYABLE = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
89
|
+
const RENAME_ATTEMPTS = 25;
|
|
90
|
+
|
|
91
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
92
|
+
// PURE HELPERS — no disk, no time, no exceptions
|
|
93
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How long a lease must be SILENT before anybody else may take it.
|
|
97
|
+
*
|
|
98
|
+
* ⭐ Doubling rather than adding a fixed grace is the conservative choice: a
|
|
99
|
+
* ten-minute lease is held by something slow and deliberate, and giving it only
|
|
100
|
+
* thirty extra seconds would reclaim it out from under real work. A short lease
|
|
101
|
+
* still gets the full grace period, so nothing is ever taken in under 30s.
|
|
102
|
+
*/
|
|
103
|
+
export function reclaimableAfter(ttlMs) {
|
|
104
|
+
const ttl = Number.isFinite(ttlMs) ? Math.max(0, ttlMs) : DEFAULT_TTL_MS;
|
|
105
|
+
return ttl + Math.max(RECLAIM_GRACE_MS, ttl);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `live` | `expired` (past TTL, still protected) | `reclaimable` (takeable). */
|
|
109
|
+
function stateOf(silentForMs, ttlMs) {
|
|
110
|
+
if (silentForMs > reclaimableAfter(ttlMs)) return 'reclaimable';
|
|
111
|
+
if (silentForMs > ttlMs) return 'expired';
|
|
112
|
+
return 'live';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function clampTtl(ttlMs) {
|
|
116
|
+
if (typeof ttlMs !== 'number' || !Number.isFinite(ttlMs)) return DEFAULT_TTL_MS;
|
|
117
|
+
return Math.min(MAX_TTL_MS, Math.max(MIN_TTL_MS, Math.round(ttlMs)));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* ⚠️ A BROKEN CLOCK MUST BE AN ERROR, NEVER A RECORD. A lease whose timestamp
|
|
122
|
+
* is NaN compares false against every boundary, so it would be simultaneously
|
|
123
|
+
* un-expirable and un-reclaimable — a file locked for the life of the checkout.
|
|
124
|
+
*/
|
|
125
|
+
function checkClock(clock) {
|
|
126
|
+
if (typeof clock !== 'function') {
|
|
127
|
+
return { ok: false, error: 'clock must be a function returning a millisecond number' };
|
|
128
|
+
}
|
|
129
|
+
let now;
|
|
130
|
+
try {
|
|
131
|
+
now = clock();
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return { ok: false, error: `clock threw: ${err?.message ?? String(err)}` };
|
|
134
|
+
}
|
|
135
|
+
if (typeof now !== 'number' || !Number.isFinite(now)) {
|
|
136
|
+
return { ok: false, error: `clock returned ${JSON.stringify(now) ?? String(now)} — a lease needs a finite millisecond number` };
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, now };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** An anonymous lock cannot be released by its owner or blamed for a stall. */
|
|
142
|
+
function checkHolder(holder) {
|
|
143
|
+
if (typeof holder !== 'string' || !holder.trim()) {
|
|
144
|
+
return { ok: false, error: 'a lease needs a holder name — an anonymous lock cannot be released, renewed or blamed' };
|
|
145
|
+
}
|
|
146
|
+
return { ok: true, holder: holder.trim() };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizePid(pid) {
|
|
150
|
+
return Number.isFinite(pid) ? Math.trunc(pid) : process.pid;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Turn whatever the caller typed into ONE canonical repo-relative path.
|
|
155
|
+
*
|
|
156
|
+
* ⚠️ `./src/app.ts`, `src\app.ts` and `src/./app.ts` are the same file, and a
|
|
157
|
+
* lease keyed on the raw string would let a second terminal walk straight past
|
|
158
|
+
* a held lease by spelling it differently.
|
|
159
|
+
*/
|
|
160
|
+
function normalizePath(root, raw) {
|
|
161
|
+
if (typeof root !== 'string' || !root.trim()) {
|
|
162
|
+
return { ok: false, error: 'a workspace root is required' };
|
|
163
|
+
}
|
|
164
|
+
if (typeof raw !== 'string') {
|
|
165
|
+
return { ok: false, error: `a lease path must be a string, got ${raw === null ? 'null' : typeof raw}` };
|
|
166
|
+
}
|
|
167
|
+
const trimmed = raw.trim();
|
|
168
|
+
if (!trimmed) return { ok: false, error: 'a lease path must not be empty' };
|
|
169
|
+
|
|
170
|
+
// ⚠️ A drive-letter path is absolute on Windows and merely odd on Linux —
|
|
171
|
+
// reject it explicitly so the rule is the same on both.
|
|
172
|
+
if (isAbsolute(trimmed) || /^[a-zA-Z]:[\\/]/.test(trimmed)) {
|
|
173
|
+
return { ok: false, error: `a lease path must be relative to the workspace, got "${raw}"` };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const rootAbs = resolve(root);
|
|
177
|
+
const absolute = resolve(rootAbs, trimmed);
|
|
178
|
+
if (absolute === rootAbs) {
|
|
179
|
+
return { ok: false, error: 'the workspace root itself cannot be leased — lease the paths you intend to write' };
|
|
180
|
+
}
|
|
181
|
+
if (!absolute.startsWith(rootAbs + sep)) {
|
|
182
|
+
return { ok: false, error: `"${raw}" resolves outside the workspace and cannot be leased` };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const path = absolute.slice(rootAbs.length + 1).split(sep).join('/');
|
|
186
|
+
return { ok: true, path, key: path.toLowerCase(), absolute };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The record filename. A hash keeps nested paths flat and legal on every
|
|
191
|
+
* filesystem, and hashing the CASE-FOLDED key is what makes `App.ts` and
|
|
192
|
+
* `app.ts` collide on purpose.
|
|
193
|
+
*/
|
|
194
|
+
function recordName(key) {
|
|
195
|
+
return `${createHash('sha256').update(key).digest('hex').slice(0, 20)}.json`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* ⭐ A TOKEN IS DERIVED, NOT RANDOM. Proof of ownership has to survive being
|
|
200
|
+
* written to disk and read back by another process, and a reproducible token
|
|
201
|
+
* means a test can assert on it. Holder names collide (every terminal defaults
|
|
202
|
+
* to the same one); holder + pid + path + instant does not.
|
|
203
|
+
*/
|
|
204
|
+
function deriveToken({ path, holder, pid, acquiredAt }) {
|
|
205
|
+
return createHash('sha256')
|
|
206
|
+
.update(`${LEASE_VERSION}\u0000${path}\u0000${holder}\u0000${pid}\u0000${acquiredAt}`)
|
|
207
|
+
.digest('hex')
|
|
208
|
+
.slice(0, 24);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Where a lease for `path` lives. Exported so callers can inspect one file. */
|
|
212
|
+
export function leaseFilePath(root, path) {
|
|
213
|
+
const norm = normalizePath(root, path);
|
|
214
|
+
if (!norm.ok) return norm;
|
|
215
|
+
const dir = join(resolve(root), ...LEASE_DIR.split('/'));
|
|
216
|
+
const name = recordName(norm.key);
|
|
217
|
+
return { ok: true, path: norm.path, key: norm.key, target: norm.absolute, dir, name, absolute: join(dir, name) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
221
|
+
// THE RECORD
|
|
222
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
223
|
+
|
|
224
|
+
const serialize = (record) => `${JSON.stringify(record, null, 2)}\n`;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* ⚠️ A PARTIAL OR UNKNOWN RECORD IS NOT A LOCK. Every field is checked, because
|
|
228
|
+
* the one thing worse than a corrupt lease file is a corrupt lease file that
|
|
229
|
+
* still answers "held" and blocks the path for ever.
|
|
230
|
+
*/
|
|
231
|
+
function parseRecord(raw) {
|
|
232
|
+
if (typeof raw !== 'string' || !raw.trim()) return { ok: false, why: 'the file is empty' };
|
|
233
|
+
let rec;
|
|
234
|
+
try {
|
|
235
|
+
rec = JSON.parse(raw);
|
|
236
|
+
} catch {
|
|
237
|
+
return { ok: false, why: 'the file is not valid JSON' };
|
|
238
|
+
}
|
|
239
|
+
if (!rec || typeof rec !== 'object' || Array.isArray(rec)) return { ok: false, why: 'the file is not a lease object' };
|
|
240
|
+
if (rec.version !== LEASE_VERSION) return { ok: false, why: `unknown lease version ${JSON.stringify(rec.version)}` };
|
|
241
|
+
for (const field of ['path', 'key', 'holder', 'token']) {
|
|
242
|
+
if (typeof rec[field] !== 'string' || !rec[field]) return { ok: false, why: `the "${field}" field is missing` };
|
|
243
|
+
}
|
|
244
|
+
for (const field of ['ttlMs', 'acquiredAt', 'renewedAt']) {
|
|
245
|
+
if (typeof rec[field] !== 'number' || !Number.isFinite(rec[field])) return { ok: false, why: `the "${field}" field is missing or not a number` };
|
|
246
|
+
}
|
|
247
|
+
if (!Number.isFinite(rec.pid)) return { ok: false, why: 'the "pid" field is missing or not a number' };
|
|
248
|
+
if (!Array.isArray(rec.takeovers)) return { ok: false, why: 'the "takeovers" field is missing' };
|
|
249
|
+
return { ok: true, record: rec };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** `absent` | `held` | `corrupt` | `unreadable`. Never throws. */
|
|
253
|
+
function readLeaseFile(absolute) {
|
|
254
|
+
let raw;
|
|
255
|
+
try {
|
|
256
|
+
raw = readFileSync(absolute, 'utf8');
|
|
257
|
+
} catch (err) {
|
|
258
|
+
if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return { state: 'absent' };
|
|
259
|
+
// ⚠️ Deliberately NOT treated as free. We cannot prove nobody holds it, and
|
|
260
|
+
// guessing "free" is the failure that corrupts work. A human can read this.
|
|
261
|
+
return { state: 'unreadable', why: err?.message ?? String(err) };
|
|
262
|
+
}
|
|
263
|
+
const parsed = parseRecord(raw);
|
|
264
|
+
if (!parsed.ok) return { state: 'corrupt', why: parsed.why };
|
|
265
|
+
return { state: 'held', record: parsed.record };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
269
|
+
// ATOMIC PUBLISH — the only two ways bytes ever reach a lease file
|
|
270
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Create `target` with `data`, or fail because it already exists. This is the
|
|
274
|
+
* whole mutual-exclusion primitive.
|
|
275
|
+
*
|
|
276
|
+
* ⭐ HARD LINK FIRST, O_EXCL SECOND. `link()` is the classic exclusive create
|
|
277
|
+
* that stays correct on network filesystems where `O_EXCL` historically is not,
|
|
278
|
+
* and it publishes a file that is COMPLETE at the instant it becomes visible —
|
|
279
|
+
* the content is written to the temp name first, so no reader can ever observe
|
|
280
|
+
* a half-written lease. The `O_EXCL` fallback exists for filesystems that
|
|
281
|
+
* refuse links at all, and is still exclusive.
|
|
282
|
+
*/
|
|
283
|
+
function exclusiveCreate(target, data, linkImpl, pid) {
|
|
284
|
+
const temp = `${target}.mk-${pid}`;
|
|
285
|
+
try {
|
|
286
|
+
writeFileSync(temp, data, 'utf8');
|
|
287
|
+
} catch (err) {
|
|
288
|
+
return { ok: false, error: `could not stage ${temp}: ${err?.message ?? String(err)}` };
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
linkImpl(temp, target);
|
|
292
|
+
return { ok: true, via: 'link' };
|
|
293
|
+
} catch (err) {
|
|
294
|
+
if (err?.code === 'EEXIST') return { ok: false, taken: true };
|
|
295
|
+
if (!LINK_UNSUPPORTED.has(err?.code)) return { ok: false, error: err?.message ?? String(err) };
|
|
296
|
+
// fall through: this filesystem cannot link, so try an exclusive open
|
|
297
|
+
} finally {
|
|
298
|
+
try { unlinkSync(temp); } catch { /* the link succeeded or never happened */ }
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
writeFileSync(target, data, { encoding: 'utf8', flag: 'wx' });
|
|
302
|
+
return { ok: true, via: 'wx' };
|
|
303
|
+
} catch (err) {
|
|
304
|
+
if (err?.code === 'EEXIST') return { ok: false, taken: true };
|
|
305
|
+
return { ok: false, error: err?.message ?? String(err) };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Rename, retrying the transient Windows sharing failures. No sleeping. */
|
|
310
|
+
function renameWithRetry(from, to) {
|
|
311
|
+
let last;
|
|
312
|
+
for (let i = 0; i < RENAME_ATTEMPTS; i++) {
|
|
313
|
+
try {
|
|
314
|
+
renameSync(from, to);
|
|
315
|
+
return { ok: true };
|
|
316
|
+
} catch (err) {
|
|
317
|
+
last = err;
|
|
318
|
+
if (!RENAME_RETRYABLE.has(err?.code)) break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return { ok: false, error: last?.message ?? String(last) };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Replace an EXISTING lease record — renew, re-acquire, or take over.
|
|
326
|
+
*
|
|
327
|
+
* ⚠️ THIS IS THE DANGEROUS DIRECTION, so it is serialised. Eight processes that
|
|
328
|
+
* all read the same stale record would all decide "I may take this"; the gate
|
|
329
|
+
* is a shared filename that exactly one of them can create, and the winner
|
|
330
|
+
* re-reads under the gate before committing. The target file is never removed
|
|
331
|
+
* along the way, so a racer can never see the path as momentarily free.
|
|
332
|
+
*/
|
|
333
|
+
function replaceRecord(loc, record, pid, linkImpl, confirm) {
|
|
334
|
+
try {
|
|
335
|
+
mkdirSync(loc.dir, { recursive: true });
|
|
336
|
+
} catch (err) {
|
|
337
|
+
return { ok: false, error: `could not create ${loc.dir}: ${err?.message ?? String(err)}` };
|
|
338
|
+
}
|
|
339
|
+
const gate = `${loc.absolute}.claim`;
|
|
340
|
+
const got = exclusiveCreate(gate, `${pid}\n`, linkImpl, pid);
|
|
341
|
+
if (!got.ok) {
|
|
342
|
+
if (got.taken) return { ok: false, busy: true, error: 'another terminal is changing this lease right now' };
|
|
343
|
+
return { ok: false, error: got.error };
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
// ⭐ Re-read UNDER the gate. Between our first look and winning the gate,
|
|
347
|
+
// another terminal may have completed the very takeover we are attempting.
|
|
348
|
+
const verdict = confirm(readLeaseFile(loc.absolute));
|
|
349
|
+
if (!verdict.ok) return verdict;
|
|
350
|
+
|
|
351
|
+
const temp = `${loc.absolute}.tmp-${pid}`;
|
|
352
|
+
try {
|
|
353
|
+
writeFileSync(temp, serialize(record), 'utf8');
|
|
354
|
+
} catch (err) {
|
|
355
|
+
return { ok: false, error: `could not stage the lease: ${err?.message ?? String(err)}` };
|
|
356
|
+
}
|
|
357
|
+
const moved = renameWithRetry(temp, loc.absolute);
|
|
358
|
+
if (!moved.ok) {
|
|
359
|
+
try { unlinkSync(temp); } catch { /* nothing staged */ }
|
|
360
|
+
return { ok: false, error: `could not publish the lease: ${moved.error}` };
|
|
361
|
+
}
|
|
362
|
+
return { ok: true };
|
|
363
|
+
} finally {
|
|
364
|
+
try { unlinkSync(gate); } catch { /* already gone */ }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
369
|
+
// HANDLES AND REFUSALS
|
|
370
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
371
|
+
|
|
372
|
+
function handleFor(root, loc, record) {
|
|
373
|
+
return {
|
|
374
|
+
root: resolve(root),
|
|
375
|
+
path: record.path,
|
|
376
|
+
key: record.key,
|
|
377
|
+
file: loc.absolute,
|
|
378
|
+
dir: loc.dir,
|
|
379
|
+
holder: record.holder,
|
|
380
|
+
pid: record.pid,
|
|
381
|
+
token: record.token,
|
|
382
|
+
ttlMs: record.ttlMs,
|
|
383
|
+
acquiredAt: record.acquiredAt,
|
|
384
|
+
renewedAt: record.renewedAt,
|
|
385
|
+
takeovers: record.takeovers,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* ⚠️ A REFUSAL IS THE PRODUCT HERE, so it has to be readable. Whoever hits this
|
|
391
|
+
* is a person with seven terminals open wondering why one of them stopped, and
|
|
392
|
+
* "lease held" tells them nothing. Name the holder, the pid, the age, and — if
|
|
393
|
+
* it is expired but still protected — say exactly how long the wait is and why.
|
|
394
|
+
*/
|
|
395
|
+
function refusal(loc, held, now, holder, pid, warnings) {
|
|
396
|
+
const silentForMs = now - held.renewedAt;
|
|
397
|
+
const reclaimableInMs = Math.max(0, reclaimableAfter(held.ttlMs) - silentForMs);
|
|
398
|
+
const expired = silentForMs > held.ttlMs;
|
|
399
|
+
|
|
400
|
+
let error = `${loc.path} is leased by "${held.holder}" (pid ${held.pid}), taken at ${held.acquiredAt} and last heartbeat ${silentForMs}ms ago`;
|
|
401
|
+
if (expired && reclaimableInMs > 0) {
|
|
402
|
+
error += `; the lease expired ${silentForMs - held.ttlMs}ms ago but cannot be reclaimed for another ${reclaimableInMs}ms — taking it early would hand two live agents the same file`;
|
|
403
|
+
}
|
|
404
|
+
if (held.holder === holder && held.pid !== pid) {
|
|
405
|
+
error += `; the holder name matches yours but the pid does not (${held.pid} vs ${pid}), so that is a second terminal, not you`;
|
|
406
|
+
}
|
|
407
|
+
if (held.path !== loc.path) {
|
|
408
|
+
error += `; the lease is recorded as "${held.path}" and you asked for "${loc.path}" — they differ only in case, and on a case-insensitive filesystem that is ONE file`;
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
ok: false,
|
|
412
|
+
heldBy: held.holder,
|
|
413
|
+
heldByPid: held.pid,
|
|
414
|
+
since: held.acquiredAt,
|
|
415
|
+
path: loc.path,
|
|
416
|
+
state: stateOf(silentForMs, held.ttlMs),
|
|
417
|
+
expired,
|
|
418
|
+
silentForMs,
|
|
419
|
+
reclaimableInMs,
|
|
420
|
+
error,
|
|
421
|
+
warnings,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
426
|
+
// THE PUBLIC SURFACE
|
|
427
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Take a lease on one path.
|
|
431
|
+
*
|
|
432
|
+
* → `{ ok: true, lease, warnings, reacquired?, takeover? }`
|
|
433
|
+
* → `{ ok: false, heldBy, since, expired, reclaimableInMs, error, warnings }`
|
|
434
|
+
*/
|
|
435
|
+
export function acquire(root, options = {}) {
|
|
436
|
+
const opts = options ?? {};
|
|
437
|
+
const { path: rawPath, holder: rawHolder, ttlMs, clock = wallClock, linkImpl = linkSync } = opts;
|
|
438
|
+
const warnings = [];
|
|
439
|
+
|
|
440
|
+
const time = checkClock(clock);
|
|
441
|
+
if (!time.ok) return { ok: false, error: time.error, warnings };
|
|
442
|
+
const now = time.now;
|
|
443
|
+
|
|
444
|
+
const who = checkHolder(rawHolder);
|
|
445
|
+
if (!who.ok) return { ok: false, error: who.error, warnings };
|
|
446
|
+
const holder = who.holder;
|
|
447
|
+
|
|
448
|
+
const loc = leaseFilePath(root, rawPath);
|
|
449
|
+
if (!loc.ok) return { ok: false, error: loc.error, warnings };
|
|
450
|
+
|
|
451
|
+
const pid = normalizePid(opts.pid);
|
|
452
|
+
const ttl = clampTtl(ttlMs);
|
|
453
|
+
const existing = readLeaseFile(loc.absolute);
|
|
454
|
+
|
|
455
|
+
if (existing.state === 'unreadable') {
|
|
456
|
+
return { ok: false, error: `the lease file for ${loc.path} could not be read (${existing.why}) — refusing rather than assuming it is free`, warnings };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── Somebody (possibly us) already has a readable record ──────────────────
|
|
460
|
+
if (existing.state === 'held') {
|
|
461
|
+
const held = existing.record;
|
|
462
|
+
|
|
463
|
+
// ⭐ RE-ENTRANCY. A terminal that declares the same file twice — or retries
|
|
464
|
+
// after a hiccup — must not deadlock against itself. Same holder AND same
|
|
465
|
+
// pid is the same terminal, so this is a heartbeat that keeps the token.
|
|
466
|
+
if (held.holder === holder && held.pid === pid) {
|
|
467
|
+
const next = { ...held, path: held.path, ttlMs: ttl, renewedAt: now };
|
|
468
|
+
const wrote = replaceRecord(loc, next, pid, linkImpl, (current) => (
|
|
469
|
+
current.state === 'held' && current.record.token === held.token
|
|
470
|
+
? { ok: true }
|
|
471
|
+
: { ok: false, error: `the lease on ${loc.path} changed hands while re-acquiring it`, warnings }
|
|
472
|
+
));
|
|
473
|
+
if (!wrote.ok) return { ok: false, error: wrote.error, warnings };
|
|
474
|
+
return { ok: true, reacquired: true, lease: handleFor(root, loc, next), warnings };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const silentForMs = now - held.renewedAt;
|
|
478
|
+
if (silentForMs <= reclaimableAfter(held.ttlMs)) {
|
|
479
|
+
return refusal(loc, held, now, holder, pid, warnings);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── The holder has been silent long past its TTL. Take it, and say so. ──
|
|
483
|
+
const takeover = { from: held.holder, pid: held.pid, at: now, silentForMs, token: held.token };
|
|
484
|
+
const record = {
|
|
485
|
+
version: LEASE_VERSION,
|
|
486
|
+
path: loc.path,
|
|
487
|
+
key: loc.key,
|
|
488
|
+
holder,
|
|
489
|
+
pid,
|
|
490
|
+
token: deriveToken({ path: loc.path, holder, pid, acquiredAt: now }),
|
|
491
|
+
ttlMs: ttl,
|
|
492
|
+
acquiredAt: now,
|
|
493
|
+
renewedAt: now,
|
|
494
|
+
takeovers: [...held.takeovers, takeover].slice(-MAX_TAKEOVERS),
|
|
495
|
+
};
|
|
496
|
+
const wrote = replaceRecord(loc, record, pid, linkImpl, (current) => {
|
|
497
|
+
if (current.state !== 'held') return { ok: false, error: `the lease on ${loc.path} vanished mid-takeover`, warnings };
|
|
498
|
+
if (current.record.token !== held.token) return refusal(loc, current.record, now, holder, pid, warnings);
|
|
499
|
+
if (now - current.record.renewedAt <= reclaimableAfter(current.record.ttlMs)) return refusal(loc, current.record, now, holder, pid, warnings);
|
|
500
|
+
return { ok: true };
|
|
501
|
+
});
|
|
502
|
+
if (!wrote.ok) {
|
|
503
|
+
if (wrote.heldBy) return wrote;
|
|
504
|
+
return { ok: false, heldBy: held.holder, since: held.acquiredAt, error: wrote.error, warnings };
|
|
505
|
+
}
|
|
506
|
+
warnings.push(`took over ${loc.path} from "${held.holder}" (pid ${held.pid}) after ${silentForMs}ms of silence`);
|
|
507
|
+
return { ok: true, takeover, lease: handleFor(root, loc, record), warnings };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ── Corrupt: treat as absent, loudly, and never as a lock ─────────────────
|
|
511
|
+
const record = {
|
|
512
|
+
version: LEASE_VERSION,
|
|
513
|
+
path: loc.path,
|
|
514
|
+
key: loc.key,
|
|
515
|
+
holder,
|
|
516
|
+
pid,
|
|
517
|
+
token: deriveToken({ path: loc.path, holder, pid, acquiredAt: now }),
|
|
518
|
+
ttlMs: ttl,
|
|
519
|
+
acquiredAt: now,
|
|
520
|
+
renewedAt: now,
|
|
521
|
+
takeovers: [],
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
if (existing.state === 'corrupt') {
|
|
525
|
+
warnings.push(`corrupt lease file for ${loc.path} (${existing.why}) — treating it as absent and taking the lease`);
|
|
526
|
+
const wrote = replaceRecord(loc, record, pid, linkImpl, (current) => (
|
|
527
|
+
// ⭐ Still corrupt under the gate? Then it really is debris. If it now
|
|
528
|
+
// parses, a real holder appeared while we looked and it is theirs.
|
|
529
|
+
current.state === 'held'
|
|
530
|
+
? refusal(loc, current.record, now, holder, pid, warnings)
|
|
531
|
+
: { ok: true }
|
|
532
|
+
));
|
|
533
|
+
if (!wrote.ok) {
|
|
534
|
+
if (wrote.heldBy) return wrote;
|
|
535
|
+
return { ok: false, error: wrote.error, warnings };
|
|
536
|
+
}
|
|
537
|
+
return { ok: true, lease: handleFor(root, loc, record), warnings };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ── Free: an exclusive create is the whole race ───────────────────────────
|
|
541
|
+
try {
|
|
542
|
+
mkdirSync(loc.dir, { recursive: true });
|
|
543
|
+
} catch (err) {
|
|
544
|
+
return { ok: false, error: `could not create ${loc.dir}: ${err?.message ?? String(err)}`, warnings };
|
|
545
|
+
}
|
|
546
|
+
const made = exclusiveCreate(loc.absolute, serialize(record), linkImpl, pid);
|
|
547
|
+
if (made.ok) return { ok: true, lease: handleFor(root, loc, record), warnings };
|
|
548
|
+
if (!made.taken) return { ok: false, error: made.error, warnings };
|
|
549
|
+
|
|
550
|
+
// We lost the create. Re-read so the refusal names whoever actually won.
|
|
551
|
+
const winner = readLeaseFile(loc.absolute);
|
|
552
|
+
if (winner.state === 'held') return refusal(loc, winner.record, now, holder, pid, warnings);
|
|
553
|
+
return { ok: false, error: `lost a race for ${loc.path} to another terminal`, warnings };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Heartbeat. → `{ ok: true, lease }` or `{ ok: false, reason, heldBy?, error }`
|
|
558
|
+
* where reason is `gone` | `lost` | `expired` | `corrupt` | `busy` | `invalid`.
|
|
559
|
+
*
|
|
560
|
+
* ⚠️⚠️ THE FAILURES HERE ARE THE POINT. An agent that keeps writing under a
|
|
561
|
+
* lease it no longer holds is the exact corruption this module exists to stop,
|
|
562
|
+
* so a heartbeat that cannot prove ownership must fail loudly instead of
|
|
563
|
+
* quietly re-creating the record.
|
|
564
|
+
*/
|
|
565
|
+
export function renew(lease, options = {}) {
|
|
566
|
+
const { clock = wallClock, linkImpl = linkSync } = options ?? {};
|
|
567
|
+
|
|
568
|
+
const time = checkClock(clock);
|
|
569
|
+
if (!time.ok) return { ok: false, reason: 'invalid', error: time.error };
|
|
570
|
+
const now = time.now;
|
|
571
|
+
|
|
572
|
+
if (!lease || typeof lease !== 'object' || typeof lease.file !== 'string' || typeof lease.token !== 'string') {
|
|
573
|
+
return { ok: false, reason: 'invalid', error: 'not a lease handle' };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const current = readLeaseFile(lease.file);
|
|
577
|
+
if (current.state === 'absent') {
|
|
578
|
+
return { ok: false, reason: 'gone', error: `the lease on ${lease.path} no longer exists — it was released or cleaned up` };
|
|
579
|
+
}
|
|
580
|
+
if (current.state === 'unreadable') {
|
|
581
|
+
return { ok: false, reason: 'corrupt', error: `the lease file for ${lease.path} could not be read (${current.why})` };
|
|
582
|
+
}
|
|
583
|
+
if (current.state === 'corrupt') {
|
|
584
|
+
return { ok: false, reason: 'corrupt', error: `the lease file for ${lease.path} is corrupt (${current.why}) — re-acquire rather than heartbeat` };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const held = current.record;
|
|
588
|
+
if (held.token !== lease.token) {
|
|
589
|
+
return {
|
|
590
|
+
ok: false,
|
|
591
|
+
reason: 'lost',
|
|
592
|
+
heldBy: held.holder,
|
|
593
|
+
heldByPid: held.pid,
|
|
594
|
+
error: `${lease.path} is now leased by "${held.holder}" (pid ${held.pid}) — stop writing to it`,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
if (now - held.renewedAt > held.ttlMs) {
|
|
598
|
+
// ⚠️ Past our own TTL a blind write races whoever is mid-takeover. The
|
|
599
|
+
// honest recovery is one `acquire`, which re-checks under the gate.
|
|
600
|
+
return { ok: false, reason: 'expired', error: `the lease on ${lease.path} lapsed ${now - held.renewedAt - held.ttlMs}ms ago — re-acquire it` };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const loc = { absolute: lease.file, dir: lease.dir, path: lease.path };
|
|
604
|
+
const next = { ...held, renewedAt: now };
|
|
605
|
+
const wrote = replaceRecord(loc, next, normalizePid(lease.pid), linkImpl, (under) => (
|
|
606
|
+
under.state === 'held' && under.record.token === lease.token
|
|
607
|
+
? { ok: true }
|
|
608
|
+
: { ok: false, reason: 'lost', heldBy: under.state === 'held' ? under.record.holder : null, error: `${lease.path} changed hands mid-heartbeat` }
|
|
609
|
+
));
|
|
610
|
+
if (!wrote.ok) return { ok: false, reason: wrote.reason ?? (wrote.busy ? 'busy' : 'invalid'), heldBy: wrote.heldBy, error: wrote.error };
|
|
611
|
+
|
|
612
|
+
return { ok: true, lease: { ...lease, renewedAt: now, ttlMs: held.ttlMs } };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Give a lease back. **Always safe** — expired, already released, taken over,
|
|
617
|
+
* a nonsense handle, or a workspace that has been deleted underneath us.
|
|
618
|
+
*
|
|
619
|
+
* ⚠️ NEVER DELETES SOMEBODY ELSE'S LEASE. A terminal that stalled long enough
|
|
620
|
+
* to be taken over would otherwise unlock the file under its new owner on the
|
|
621
|
+
* way out, which is the same corruption arriving by the back door.
|
|
622
|
+
*/
|
|
623
|
+
export function release(lease) {
|
|
624
|
+
if (!lease || typeof lease !== 'object' || typeof lease.file !== 'string' || typeof lease.token !== 'string') {
|
|
625
|
+
return { ok: true, released: false, reason: 'not a lease handle' };
|
|
626
|
+
}
|
|
627
|
+
const current = readLeaseFile(lease.file);
|
|
628
|
+
if (current.state === 'absent') return { ok: true, released: false, reason: 'already released' };
|
|
629
|
+
if (current.state === 'unreadable') return { ok: true, released: false, reason: `unreadable (${current.why})` };
|
|
630
|
+
if (current.state === 'corrupt') {
|
|
631
|
+
try { unlinkSync(lease.file); } catch { /* somebody else got there */ }
|
|
632
|
+
return { ok: true, released: true, reason: 'the record was corrupt and has been cleared' };
|
|
633
|
+
}
|
|
634
|
+
if (current.record.token !== lease.token) {
|
|
635
|
+
return { ok: true, released: false, reason: `${lease.path} is now leased by "${current.record.holder}" (pid ${current.record.pid}) — leaving it alone` };
|
|
636
|
+
}
|
|
637
|
+
try {
|
|
638
|
+
unlinkSync(lease.file);
|
|
639
|
+
} catch (err) {
|
|
640
|
+
if (err?.code !== 'ENOENT') return { ok: true, released: false, reason: err?.message ?? String(err) };
|
|
641
|
+
return { ok: true, released: false, reason: 'already released' };
|
|
642
|
+
}
|
|
643
|
+
return { ok: true, released: true };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
647
|
+
// MANY PATHS AT ONCE
|
|
648
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Take every path or none of them.
|
|
652
|
+
*
|
|
653
|
+
* ⚠️ TWO INVARIANTS AND BOTH ARE ABOUT DEADLOCK. **All-or-nothing**, because a
|
|
654
|
+
* terminal holding three of five files and waiting on the fourth is a stall
|
|
655
|
+
* nobody can diagnose. And a **fixed global order** (case-folded, sorted), so
|
|
656
|
+
* two terminals wanting the same two files always contend in the same
|
|
657
|
+
* direction — lock-ordering deadlock stops being possible rather than becoming
|
|
658
|
+
* unlikely. Everything is validated before a single byte is written.
|
|
659
|
+
*/
|
|
660
|
+
export function acquireAll(root, options = {}) {
|
|
661
|
+
const opts = options ?? {};
|
|
662
|
+
const { paths = [], holder, ttlMs, clock = wallClock, linkImpl = linkSync } = opts;
|
|
663
|
+
const warnings = [];
|
|
664
|
+
|
|
665
|
+
if (!Array.isArray(paths)) return { ok: false, error: 'paths must be an array', leases: [], warnings };
|
|
666
|
+
|
|
667
|
+
const located = [];
|
|
668
|
+
for (const raw of paths) {
|
|
669
|
+
const loc = leaseFilePath(root, raw);
|
|
670
|
+
if (!loc.ok) return { ok: false, error: loc.error, path: raw, leases: [], warnings };
|
|
671
|
+
located.push(loc);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const ordered = [...new Map(located.map((l) => [l.key, l])).values()]
|
|
675
|
+
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
676
|
+
|
|
677
|
+
const taken = [];
|
|
678
|
+
for (const loc of ordered) {
|
|
679
|
+
const got = acquire(root, { path: loc.path, holder, pid: opts.pid, ttlMs, clock, linkImpl });
|
|
680
|
+
if (!got.ok) {
|
|
681
|
+
releaseAll(taken);
|
|
682
|
+
return {
|
|
683
|
+
ok: false,
|
|
684
|
+
error: got.error,
|
|
685
|
+
heldBy: got.heldBy ?? null,
|
|
686
|
+
since: got.since ?? null,
|
|
687
|
+
path: loc.path,
|
|
688
|
+
leases: [],
|
|
689
|
+
warnings: [...warnings, ...(got.warnings ?? [])],
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
warnings.push(...got.warnings);
|
|
693
|
+
taken.push(got.lease);
|
|
694
|
+
}
|
|
695
|
+
return { ok: true, leases: taken, warnings };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Heartbeat a set.
|
|
700
|
+
*
|
|
701
|
+
* ⚠️ REPORTS WHAT IT LOST rather than collapsing to one boolean. "Four of five
|
|
702
|
+
* renewed and here is the one that did not, and who has it now" is actionable;
|
|
703
|
+
* `false` is not.
|
|
704
|
+
*/
|
|
705
|
+
export function renewAll(leases = [], options = {}) {
|
|
706
|
+
const renewed = [];
|
|
707
|
+
const lost = [];
|
|
708
|
+
for (const lease of Array.isArray(leases) ? leases : []) {
|
|
709
|
+
const beat = renew(lease, options);
|
|
710
|
+
if (beat.ok) renewed.push(beat.lease);
|
|
711
|
+
else lost.push({ path: lease?.path ?? null, reason: beat.reason, heldBy: beat.heldBy ?? null, error: beat.error });
|
|
712
|
+
}
|
|
713
|
+
return { ok: lost.length === 0, renewed: renewed.length, leases: renewed, lost };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Give a set back. Never throws, whatever is in the array. */
|
|
717
|
+
export function releaseAll(leases = []) {
|
|
718
|
+
const results = [];
|
|
719
|
+
let released = 0;
|
|
720
|
+
for (const lease of Array.isArray(leases) ? leases : []) {
|
|
721
|
+
const one = release(lease);
|
|
722
|
+
if (one.released) released += 1;
|
|
723
|
+
results.push({ path: lease?.path ?? null, ...one });
|
|
724
|
+
}
|
|
725
|
+
return { ok: true, released, results };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
729
|
+
// INSPECT — who holds what, and is it stale
|
|
730
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Read every lease in a workspace. **Never throws and never fails on debris** —
|
|
734
|
+
* this is the call a stuck human makes to find out why their terminal stopped,
|
|
735
|
+
* so it has to work on exactly the broken workspace they are staring at.
|
|
736
|
+
*/
|
|
737
|
+
export function inspect(root, options = {}) {
|
|
738
|
+
const { clock = wallClock } = options ?? {};
|
|
739
|
+
const time = checkClock(clock);
|
|
740
|
+
if (!time.ok) return { ok: false, error: time.error, leases: [], warnings: [], corrupt: 0 };
|
|
741
|
+
const now = time.now;
|
|
742
|
+
|
|
743
|
+
if (typeof root !== 'string' || !root.trim()) {
|
|
744
|
+
return { ok: false, error: 'a workspace root is required', leases: [], warnings: [], corrupt: 0 };
|
|
745
|
+
}
|
|
746
|
+
const dir = join(resolve(root), ...LEASE_DIR.split('/'));
|
|
747
|
+
|
|
748
|
+
let names;
|
|
749
|
+
try {
|
|
750
|
+
names = readdirSync(dir);
|
|
751
|
+
} catch (err) {
|
|
752
|
+
if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return { ok: true, dir, leases: [], warnings: [], corrupt: 0 };
|
|
753
|
+
return { ok: false, error: `could not read ${dir}: ${err?.message ?? String(err)}`, leases: [], warnings: [], corrupt: 0 };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const leases = [];
|
|
757
|
+
const warnings = [];
|
|
758
|
+
let corrupt = 0;
|
|
759
|
+
|
|
760
|
+
for (const name of names.sort()) {
|
|
761
|
+
// ⚠️ Only records. A `.claim` gate or a `.tmp-` staging file is another
|
|
762
|
+
// terminal mid-write, not debris, and reporting it would be noise.
|
|
763
|
+
if (!name.endsWith('.json')) continue;
|
|
764
|
+
const found = readLeaseFile(join(dir, name));
|
|
765
|
+
if (found.state === 'held') {
|
|
766
|
+
const rec = found.record;
|
|
767
|
+
const silentForMs = now - rec.renewedAt;
|
|
768
|
+
leases.push({
|
|
769
|
+
path: rec.path,
|
|
770
|
+
holder: rec.holder,
|
|
771
|
+
pid: rec.pid,
|
|
772
|
+
token: rec.token,
|
|
773
|
+
state: stateOf(silentForMs, rec.ttlMs),
|
|
774
|
+
ttlMs: rec.ttlMs,
|
|
775
|
+
acquiredAt: rec.acquiredAt,
|
|
776
|
+
renewedAt: rec.renewedAt,
|
|
777
|
+
ageMs: now - rec.acquiredAt,
|
|
778
|
+
silentForMs,
|
|
779
|
+
reclaimableInMs: Math.max(0, reclaimableAfter(rec.ttlMs) - silentForMs),
|
|
780
|
+
takeovers: rec.takeovers,
|
|
781
|
+
file: join(dir, name),
|
|
782
|
+
});
|
|
783
|
+
} else if (found.state === 'corrupt' || found.state === 'unreadable') {
|
|
784
|
+
corrupt += 1;
|
|
785
|
+
warnings.push(`corrupt lease file ${name} (${found.why}) — it holds nothing and can be deleted`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
leases.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
790
|
+
return { ok: true, dir, leases, warnings, corrupt };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Render `inspect()` for a terminal. Returns lines; never throws. */
|
|
794
|
+
export function formatLeaseSummary(view) {
|
|
795
|
+
if (!view || typeof view !== 'object') return ['no lease information available'];
|
|
796
|
+
if (view.ok === false) return [`could not read leases: ${view.error ?? 'unknown error'}`];
|
|
797
|
+
|
|
798
|
+
const leases = Array.isArray(view.leases) ? view.leases : [];
|
|
799
|
+
const lines = [];
|
|
800
|
+
if (leases.length === 0) {
|
|
801
|
+
lines.push('no file leases are held in this workspace');
|
|
802
|
+
} else {
|
|
803
|
+
lines.push(`${leases.length} file lease${leases.length === 1 ? '' : 's'}:`);
|
|
804
|
+
for (const l of leases) {
|
|
805
|
+
const mark = l.state === 'live' ? '·' : l.state === 'expired' ? '!' : 'x';
|
|
806
|
+
let line = ` ${mark} ${l.path} — ${l.holder} (pid ${l.pid}) ${l.state}, silent ${Math.round(l.silentForMs / 1000)}s`;
|
|
807
|
+
if (l.state === 'expired') line += `, reclaimable in ${Math.round(l.reclaimableInMs / 1000)}s`;
|
|
808
|
+
if (Array.isArray(l.takeovers) && l.takeovers.length) {
|
|
809
|
+
const last = l.takeovers[l.takeovers.length - 1];
|
|
810
|
+
line += ` — took over from ${last.from} (pid ${last.pid})`;
|
|
811
|
+
}
|
|
812
|
+
lines.push(line);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
for (const w of Array.isArray(view.warnings) ? view.warnings : []) lines.push(` ! ${w}`);
|
|
816
|
+
return lines;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* ⭐ THE WIRING, WRITTEN DOWN. This package's repeated failure is a finished
|
|
821
|
+
* module that nothing imports, so the exact lines live next to the code.
|
|
822
|
+
*/
|
|
823
|
+
export const REGISTRATION_SNIPPET = `
|
|
824
|
+
// ── WIRING lib/lease.mjs INTO bin/acuvo.mjs ────────────────────────────────
|
|
825
|
+
// The names below already exist in that file: 'root' (line ~281, the resolved
|
|
826
|
+
// workspace), 'opts' (parsed argv), EXIT_OK / EXIT_FAILED (lines 66-67).
|
|
827
|
+
|
|
828
|
+
// 1. one import, beside the parallel.mjs import at the top (line ~34)
|
|
829
|
+
import { acquireAll, renewAll, releaseAll, inspect, formatLeaseSummary } from '../lib/lease.mjs';
|
|
830
|
+
|
|
831
|
+
// 2. a read-only command, before any model work happens.
|
|
832
|
+
// lib/cli-args.mjs must accept 'leases' as a command.
|
|
833
|
+
if (opts.command === 'leases') {
|
|
834
|
+
process.stdout.write(formatLeaseSummary(inspect(root)).join('\\n') + '\\n');
|
|
835
|
+
return EXIT_OK;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// 3. take the files this run intends to write, BEFORE the session starts.
|
|
839
|
+
// 'opts.lease' is a new repeatable flag: --lease src/a.ts --lease src/b.ts
|
|
840
|
+
const held = acquireAll(root, {
|
|
841
|
+
paths: opts.lease ?? [],
|
|
842
|
+
holder: opts.holder ?? String(process.pid),
|
|
843
|
+
ttlMs: 120000,
|
|
844
|
+
});
|
|
845
|
+
if (!held.ok) {
|
|
846
|
+
process.stderr.write(held.error + '\\n');
|
|
847
|
+
return EXIT_FAILED;
|
|
848
|
+
}
|
|
849
|
+
held.warnings.forEach((w) => process.stderr.write(' ! ' + w + '\\n'));
|
|
850
|
+
process.on('exit', () => releaseAll(held.leases));
|
|
851
|
+
|
|
852
|
+
// 4. heartbeat between rounds — call this from the runSession onEvent hook.
|
|
853
|
+
// STOP on a lost lease: continuing means writing over another terminal.
|
|
854
|
+
const beat = renewAll(held.leases);
|
|
855
|
+
if (!beat.ok) {
|
|
856
|
+
process.stderr.write('lost a lease mid-run: ' + JSON.stringify(beat.lost) + '\\n');
|
|
857
|
+
return EXIT_FAILED;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ⚠️ THE HONEST LIMIT OF STEP 3: a coding agent does not know which files it
|
|
861
|
+
// will write until it writes them, so an up-front --lease list is a
|
|
862
|
+
// DECLARATION, not a guarantee. The complete fix is to call acquire() inside
|
|
863
|
+
// the executor's write path (lib/workspace.mjs) so every mutation is covered
|
|
864
|
+
// automatically. That is one more call, in one more lane, and this module is
|
|
865
|
+
// ready for it:
|
|
866
|
+
// const got = acquire(root, { path: relPath, holder, ttlMs: 120000 });
|
|
867
|
+
// if (!got.ok) return { ok: false, error: got.error };
|
|
868
|
+
`.trim();
|