@vibecompass/vibecompass 0.10.8 → 0.11.1
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 +36 -1
- package/README.md +32 -0
- package/package.json +1 -1
- package/src/cli.js +602 -8
- package/src/compatibility.js +34 -1
- package/src/decision-index.js +386 -0
- package/src/decisions.js +195 -0
- package/src/docs-review.js +27 -27
- package/src/docs-update.js +459 -24
- package/src/generators/agent-files/index.js +8 -0
- package/src/generators/agent-files/template.js +6 -3
- package/src/git-binding.js +676 -0
- package/src/init.js +9 -0
- package/src/lane-marker.js +339 -0
- package/src/lane-runtime.js +254 -0
- package/src/manifest.js +19 -0
- package/src/project-memory.js +27 -1
- package/src/refresh-workflow.js +8 -0
- package/src/scaffold.js +19 -11
- package/src/serialization.js +244 -0
- package/src/session.js +1000 -75
- package/src/simple-yaml.js +24 -4
- package/src/status.js +43 -5
- package/src/sync.js +5 -0
- package/src/workflows/registry.js +1 -1
package/src/project-memory.js
CHANGED
|
@@ -34,8 +34,12 @@ const KNOWN_PROJECT_FIELDS = new Set([
|
|
|
34
34
|
'repos',
|
|
35
35
|
'default_branch',
|
|
36
36
|
'sync',
|
|
37
|
+
'runtime',
|
|
37
38
|
'metadata',
|
|
38
39
|
]);
|
|
40
|
+
// D-282 runtime-isolation settings; deep value validation (ranges, absolute
|
|
41
|
+
// paths) lives at the consumption point in lane-runtime.js.
|
|
42
|
+
const KNOWN_RUNTIME_FIELDS = new Set(['port_base', 'port_step', 'tmp_base']);
|
|
39
43
|
const KNOWN_REPO_FIELDS = new Set(['id', 'source', 'remote', 'path', 'default_branch']);
|
|
40
44
|
const KNOWN_SYNC_FIELDS = new Set([
|
|
41
45
|
'provider',
|
|
@@ -97,7 +101,12 @@ export async function scanProjectMemory(rootDir) {
|
|
|
97
101
|
const sessionDocuments = await parseMarkdownTree({
|
|
98
102
|
rootDir,
|
|
99
103
|
directoryName: 'sessions',
|
|
100
|
-
|
|
104
|
+
// D-278: everything under sessions/active/ is local-only lane scratch,
|
|
105
|
+
// never canonical — a stray lane-local file (e.g. a plan note) must not
|
|
106
|
+
// fail-close the whole scan as a malformed session note. The filename set
|
|
107
|
+
// still covers the legacy root-level wip.md/handoff.md.
|
|
108
|
+
shouldInclude: (filename, relativePath) =>
|
|
109
|
+
!NON_CANONICAL_SESSION_FILES.has(filename) && !relativePath.startsWith('sessions/active/'),
|
|
101
110
|
parseDocument: (documentPath, relativePath, content) =>
|
|
102
111
|
parseSessionDocument({
|
|
103
112
|
content,
|
|
@@ -336,6 +345,23 @@ function validateProjectData(data, relativePath) {
|
|
|
336
345
|
}
|
|
337
346
|
}
|
|
338
347
|
|
|
348
|
+
if (data.runtime !== undefined) {
|
|
349
|
+
if (!isPlainObject(data.runtime)) {
|
|
350
|
+
warnings.push(
|
|
351
|
+
createWarning(
|
|
352
|
+
'project-runtime-invalid',
|
|
353
|
+
'project.yaml "runtime" must be a mapping of runtime-isolation settings (D-282); the defaults apply.',
|
|
354
|
+
),
|
|
355
|
+
);
|
|
356
|
+
} else {
|
|
357
|
+
for (const key of Object.keys(data.runtime)) {
|
|
358
|
+
if (!KNOWN_RUNTIME_FIELDS.has(key)) {
|
|
359
|
+
warnings.push(createWarning('project-runtime-unknown-field', `Unknown project.yaml runtime field "${key}".`));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
339
365
|
if (data.metadata !== undefined && JSON.stringify(data.metadata).length > 500) {
|
|
340
366
|
warnings.push(
|
|
341
367
|
createWarning(
|
package/src/refresh-workflow.js
CHANGED
|
@@ -6,11 +6,19 @@ import { serializeProjectConfig } from './project-yaml.js';
|
|
|
6
6
|
import { buildWorkflowScaffoldFiles } from './scaffold.js';
|
|
7
7
|
import { parseSimpleYaml } from './simple-yaml.js';
|
|
8
8
|
import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
|
|
9
|
+
import { withMemoryRootLock } from './serialization.js';
|
|
9
10
|
import { PACKAGE_VERSION } from './version.js';
|
|
10
11
|
|
|
11
12
|
export async function refreshWorkflow(options = {}) {
|
|
12
13
|
const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
|
|
13
14
|
const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
|
|
15
|
+
if (!options.apply) {
|
|
16
|
+
return refreshWorkflowResolved(options, cwd, rootDir);
|
|
17
|
+
}
|
|
18
|
+
return withMemoryRootLock(rootDir, 'refresh-workflow', () => refreshWorkflowResolved(options, cwd, rootDir));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function refreshWorkflowResolved(options, cwd, rootDir) {
|
|
14
22
|
const toolingRootDir = options.toolingRootDir
|
|
15
23
|
? path.resolve(cwd, options.toolingRootDir)
|
|
16
24
|
: cwd;
|
package/src/scaffold.js
CHANGED
|
@@ -222,16 +222,22 @@ This workspace uses VibeCompass project memory rooted at \`${rootRelativePath}\`
|
|
|
222
222
|
## Derived and scratch files
|
|
223
223
|
- \`${rootRelativePath}/context.md\` — generated AI-facing workflow context
|
|
224
224
|
- \`${rootRelativePath}/state/manifest.json\` — machine-owned local state; do not hand-edit
|
|
225
|
-
- \`${rootRelativePath}/sessions/active/index.yaml\` — active session lane
|
|
226
|
-
- \`${rootRelativePath}/sessions/active/<lane-id>/session.yaml\` — lane metadata
|
|
225
|
+
- \`${rootRelativePath}/sessions/active/index.yaml\` — active session lane inventory and continuity pointer
|
|
226
|
+
- \`${rootRelativePath}/sessions/active/<lane-id>/session.yaml\` — lane metadata (including the recorded \`lane_marker\` when one exists)
|
|
227
227
|
- \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\` — lane-local builder scratchpad
|
|
228
228
|
- \`${rootRelativePath}/sessions/active/<lane-id>/handoff.md\` — lane-local builder/reviewer relay
|
|
229
|
+
- \`.vibecompass-lane.yaml\` — worktree-local lane marker (D-280); lives outside the memory root, written only by \`vibecompass write-lane-marker\` or worktree provisioning, never synced
|
|
230
|
+
- \`${rootRelativePath}/decisions/INDEX.md\` — derived grouped decision index; refresh with \`vibecompass refresh-decision-index\` (D-283, structure-preserving) instead of hand-editing rows
|
|
229
231
|
|
|
230
232
|
## Session model
|
|
231
233
|
- VibeCompass active builder sessions are named lanes. Use one lane per active feature or workstream.
|
|
232
234
|
- \`vibecompass start-session\` requires \`--id <lane-id>\` so each active lane has a meaningful feature or workstream name.
|
|
233
235
|
- The active lane scratch files live under \`${rootRelativePath}/sessions/active/<lane-id>/\`.
|
|
234
|
-
-
|
|
236
|
+
- Lane selection follows D-277: an explicit \`--session\` wins, then the nearest worktree lane marker (\`.vibecompass-lane.yaml\`, walking up from cwd), then the single active lane. With two or more active lanes there is no implicit current-lane fallback.
|
|
237
|
+
- \`${rootRelativePath}/sessions/active/index.yaml\` is the lane inventory; its \`current\` pointer and the tool-specific Current session block are human-readable continuity summaries, not the lane-selection source of truth.
|
|
238
|
+
- Optional git binding (D-281): \`start-session --branch <name> --repo <id> [--worktree]\` creates or reuses the branch in every bound repo; \`--worktree\` additionally provisions per-repo worktrees under \`<workspace>/worktrees/<lane-id>/<repo-id>\` with the lane marker written into the container, so commands run from inside a worktree need neither \`--root\` nor \`--session\`. Binding is opt-in and git is never required for lanes.
|
|
239
|
+
- At close, \`close-session\` removes a bound lane's recorded clean worktrees (guarded and never forced: dirty, in-use, or unverifiable worktrees survive with guidance, the lane marker is kept while any worktree survives, and branches are never deleted). Do not hand-remove provisioned worktrees; follow the printed guidance instead.
|
|
240
|
+
- Every lane gets a per-lane runtime assignment at start (D-282): a lane port and temp dir recorded in \`session.yaml\` and exported with \`eval "$(vibecompass lane-env)"\` (includes conventional \`PORT\`/\`TMPDIR\` aliases), so parallel lanes never fight over dev-server ports or temp paths. Defaults are configurable under \`project.yaml\` \`runtime:\`; \`close-session\` removes the lane temp dir under guards.
|
|
235
241
|
- Finalized sessions are append-only notes named \`${rootRelativePath}/sessions/YYYY-MM-DD-N-title.md\`; multiple sessions on the same day increment \`N\`.
|
|
236
242
|
- Decisions remain append-only and independent from session notes. A session note may reference decisions, but the decision entry in \`${rootRelativePath}/decisions/\` is the durable decision record for this root.
|
|
237
243
|
|
|
@@ -264,13 +270,13 @@ ${renderWorkflowDefaults(workflow)}
|
|
|
264
270
|
## Session startup
|
|
265
271
|
1. Read \`${rootRelativePath}/project.yaml\`.
|
|
266
272
|
2. Read the latest finalized session note in \`${rootRelativePath}/sessions/\`.
|
|
267
|
-
3. If present, read \`${rootRelativePath}/sessions/active/index.yaml\`
|
|
273
|
+
3. If present, read \`${rootRelativePath}/sessions/active/index.yaml\` for the lane inventory; select the lane from an explicit \`--session\`, the nearest worktree lane marker, or the single active lane (D-277).
|
|
268
274
|
4. If present, read \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\`.
|
|
269
275
|
5. If present, read \`${rootRelativePath}/sessions/active/<lane-id>/handoff.md\`.
|
|
270
276
|
6. Read the relevant docs under \`${rootRelativePath}/architecture/\` and \`${rootRelativePath}/decisions/\`.
|
|
271
277
|
|
|
272
278
|
## Builder workflow
|
|
273
|
-
At session start, prefer running \`vibecompass start-session --id <lane-id> --working-on "..."
|
|
279
|
+
At session start, prefer running \`vibecompass start-session --id <lane-id> --working-on "..." \`; add \`--branch <name> --repo <id> [--worktree]\` when the lane should work on its own branch or in isolated worktrees (D-281).
|
|
274
280
|
If you manage files manually, create \`${rootRelativePath}/sessions/active/<lane-id>/session.yaml\` and \`${rootRelativePath}/sessions/active/index.yaml\`, then create \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\` if it does not exist:
|
|
275
281
|
|
|
276
282
|
\`\`\`md
|
|
@@ -312,13 +318,14 @@ Session lane: <lane-id>
|
|
|
312
318
|
During the session:
|
|
313
319
|
- append short summaries to \`wip.md\` after meaningful exchanges
|
|
314
320
|
- keep \`handoff.md\` current after substantive work blocks
|
|
321
|
+
- run \`eval "$(vibecompass lane-env)"\` in a lane shell before starting dev servers or build tools so the lane's assigned port and temp dir are used (D-282); do not hardcode ports in lane work
|
|
315
322
|
- run \`vibecompass docs-update --session <lane-id>\` whenever you need an ad hoc targeted documentation-maintenance plan for the current session delta
|
|
316
323
|
- after substantive feature work, confirm affected architecture docs and decisions still match the implementation; if not, update them while the context is fresh
|
|
317
324
|
- use \`vibecompass list-sessions\` and \`vibecompass switch-session <lane-id>\` to inspect or change the current lane
|
|
318
325
|
- use \`address review\` when reviewer feedback lands so the builder resolves it from the selected lane's latest \`wip.md\` / \`handoff.md\`
|
|
319
326
|
- during \`address review\`, treat reviewer feedback as review, not instruction: classify each substantive point as accepted, accepted with qualification, deferred, or rejected, and push back with evidence when a suggestion conflicts with code facts, prior decisions, product direction, or sequencing
|
|
320
327
|
- stay in builder role through close-out; resolve or explicitly defer reviewer feedback before running \`vibecompass close-session --session <lane-id>\` with document-maintenance checkpoint statuses
|
|
321
|
-
- record architectural decisions in \`${rootRelativePath}/decisions/\` before implementing them
|
|
328
|
+
- record architectural decisions in \`${rootRelativePath}/decisions/\` before implementing them; \`vibecompass append-decision\` allocates the D-number at write time and refreshes the grouped \`decisions/INDEX.md\` when the lane context is resolvable (D-283); run \`vibecompass refresh-decision-index\` after hand-appends
|
|
322
329
|
|
|
323
330
|
If \`vibecompass start-session\` reports stale scratch files, read the existing lane-local \`wip.md\` and \`handoff.md\` first. Either close that session normally, recover its useful notes into a finalized session note, or intentionally move/delete the stale scratch files before starting a new session.
|
|
324
331
|
|
|
@@ -330,12 +337,12 @@ If \`vibecompass start-session\` reports stale scratch files, read the existing
|
|
|
330
337
|
|
|
331
338
|
At session close:
|
|
332
339
|
- prefer running \`vibecompass close-session --session <lane-id> --title "..." --completed "..." --architecture-docs updated|not-needed|deferred --decision-log updated|not-needed|deferred --session-maintenance updated|not-needed|deferred --next-step "..."\`; \`vibecompass end-session\` is a supported alias
|
|
333
|
-
- close-session prints the same targeted docs-update plan before the document-maintenance checkpoint; use it to decide whether affected \`architecture/\`, \`decisions/\`, and active-session scratch/final note inputs are updated, not-needed, or deferred
|
|
340
|
+
- close-session prints the same targeted docs-update plan — including the pre-close staleness set (new decisions since lane start, stale base revisions, newer finalized notes touching this lane's scope, claim overlap with other active lanes) — before the document-maintenance checkpoint; use it to decide whether affected \`architecture/\`, \`decisions/\`, and active-session scratch/final note inputs are updated, not-needed, or deferred
|
|
334
341
|
- document-maintenance checkpoint statuses are required before close-session writes the finalized note; the package validates the status values, while the closer owns semantic doc authorship
|
|
335
342
|
- follow the stored close-session defaults from \`${rootRelativePath}/project.yaml\`
|
|
336
343
|
- finalize the lane-local \`wip.md\` into \`${rootRelativePath}/sessions/YYYY-MM-DD-N-title.md\`; the permanent note distills decisions, completions, blockers, and next steps rather than preserving the full \`## Review log\`
|
|
337
344
|
- if a granular reviewer trail must remain durable, summarize it explicitly in the session note inputs or create a separate finalized session note before close-session deletes lane scratch files
|
|
338
|
-
-
|
|
345
|
+
- close-session deletes the closed lane directory under \`${rootRelativePath}/sessions/active/<lane-id>/\` and cleans up provisioned worktrees, the lane marker, the container, and the lane temp dir when they are safely removable; leftover pieces come with printed guidance
|
|
339
346
|
- refresh any affected architecture/decision docs
|
|
340
347
|
- for local-primary roots with hosted sync configured, run \`vibecompass push --root ${rootRelativePath}\` after canonical docs/session files are finalized when the hosted dashboard should reflect the session; pass \`--sync-target <name>\` when using a non-default or named target
|
|
341
348
|
- for hosted-only projects, there is no local authoritative push; confirm hosted dashboard/proposal/Understanding state was updated or record the refresh/apply work as deferred
|
|
@@ -529,8 +536,9 @@ Append-only decision log for ${projectConfig.name}.
|
|
|
529
536
|
## Conventions
|
|
530
537
|
- store canonical decision entries in domain-grouped files such as \`cross-cutting.md\`
|
|
531
538
|
- never edit or delete prior decision entries
|
|
532
|
-
- use headings in the form \`### D-<number> — Title\`
|
|
539
|
+
- use headings in the form \`### D-<number> — Title\` (em-dash; hyphen-only headings cannot be indexed)
|
|
533
540
|
- include \`**Timestamp:**\`, \`**Decision:**\`, and \`**Rationale:**\`
|
|
541
|
+
- \`INDEX.md\` is a derived grouped index: refresh it with \`vibecompass refresh-decision-index\` (structure-preserving, D-283) rather than hand-editing rows; \`vibecompass append-decision\` refreshes it automatically when it can label the session group
|
|
534
542
|
|
|
535
543
|
## Example
|
|
536
544
|
|
|
@@ -563,8 +571,8 @@ Finalized session notes for ${projectConfig.name} live here.
|
|
|
563
571
|
- \`## Next session should start with\`
|
|
564
572
|
|
|
565
573
|
## Active-session scratch files
|
|
566
|
-
- \`active/index.yaml\` — active lane
|
|
567
|
-
- \`active/<lane-id>/session.yaml\` — lane metadata
|
|
574
|
+
- \`active/index.yaml\` — active lane inventory and continuity pointer (not a resolver with 2+ lanes; D-277)
|
|
575
|
+
- \`active/<lane-id>/session.yaml\` — lane metadata, including the recorded \`lane_marker\` when one exists
|
|
568
576
|
- \`active/<lane-id>/wip.md\` — builder scratchpad during an active lane
|
|
569
577
|
- \`active/<lane-id>/handoff.md\` — reviewer/builder baton-pass during an active lane
|
|
570
578
|
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Single-machine serialization tier for shared project-memory writers (D-276).
|
|
8
|
+
*
|
|
9
|
+
* Three layers cooperate so every package-owned writer of shared memory runs
|
|
10
|
+
* one at a time per root:
|
|
11
|
+
* - an on-disk mkdir-as-mutex lock directory under `state/` serializes across
|
|
12
|
+
* processes (mkdir without `recursive` is atomic on APFS/ext4)
|
|
13
|
+
* - an in-process promise queue serializes concurrent async callers inside one
|
|
14
|
+
* process, where the disk lock alone cannot arbitrate
|
|
15
|
+
* - AsyncLocalStorage tracks held roots so nested calls inside a lock holder
|
|
16
|
+
* re-enter instead of deadlocking (e.g. close-session refreshing the
|
|
17
|
+
* manifest, which is itself lock-wrapped)
|
|
18
|
+
*
|
|
19
|
+
* This tier is intentionally local-only. Cross-machine serialization belongs
|
|
20
|
+
* to git merge (local-primary teams) or the hosted database (hosted-only);
|
|
21
|
+
* no distributed locking is ever built on top of this file.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const LOCK_DIR_NAME = 'memory-root.lock';
|
|
25
|
+
const OWNER_FILE_NAME = 'owner.json';
|
|
26
|
+
const DEFAULT_STALE_MS = 30_000;
|
|
27
|
+
const DEFAULT_ATTEMPTS = 120;
|
|
28
|
+
const RETRY_BASE_DELAY_MS = 25;
|
|
29
|
+
const RETRY_MAX_DELAY_MS = 100;
|
|
30
|
+
|
|
31
|
+
const heldRoots = new AsyncLocalStorage();
|
|
32
|
+
const processQueues = new Map();
|
|
33
|
+
|
|
34
|
+
export class MemoryRootLockError extends Error {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'MemoryRootLockError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function memoryRootLockPath(rootDir) {
|
|
42
|
+
return path.join(path.resolve(rootDir), 'state', LOCK_DIR_NAME);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Runs `fn` while holding the serialization lock for `rootDir`. Reentrant:
|
|
47
|
+
* a nested call from inside the lock holder runs immediately.
|
|
48
|
+
*/
|
|
49
|
+
export async function withMemoryRootLock(rootDir, label, fn, options = {}) {
|
|
50
|
+
const key = await canonicalizeMemoryRootKey(rootDir);
|
|
51
|
+
const held = heldRoots.getStore();
|
|
52
|
+
if (await findHeldMemoryRootKey(held, key)) {
|
|
53
|
+
return fn();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const previous = processQueues.get(key) ?? Promise.resolve();
|
|
57
|
+
let releaseQueue;
|
|
58
|
+
const queueSlot = new Promise((resolve) => {
|
|
59
|
+
releaseQueue = resolve;
|
|
60
|
+
});
|
|
61
|
+
processQueues.set(key, previous.then(() => queueSlot));
|
|
62
|
+
await previous;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const ownerToken = await acquireDiskLock(key, label, options);
|
|
66
|
+
try {
|
|
67
|
+
const nextHeld = new Set(held ?? []);
|
|
68
|
+
nextHeld.add(key);
|
|
69
|
+
return await heldRoots.run(nextHeld, fn);
|
|
70
|
+
} finally {
|
|
71
|
+
await releaseDiskLock(key, ownerToken);
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
releaseQueue();
|
|
75
|
+
if (processQueues.get(key) === queueSlot) {
|
|
76
|
+
processQueues.delete(key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function canonicalizeMemoryRootKey(rootDir) {
|
|
82
|
+
const resolved = path.resolve(rootDir);
|
|
83
|
+
try {
|
|
84
|
+
return await realpath(resolved);
|
|
85
|
+
} catch {
|
|
86
|
+
try {
|
|
87
|
+
return path.join(await realpath(path.dirname(resolved)), path.basename(resolved));
|
|
88
|
+
} catch {
|
|
89
|
+
return resolved;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function findHeldMemoryRootKey(held, key) {
|
|
95
|
+
if (!held) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (held.has(key)) {
|
|
100
|
+
return key;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const heldKey of held) {
|
|
104
|
+
if (await memoryRootKeysReferToSamePath(heldKey, key)) {
|
|
105
|
+
return heldKey;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function memoryRootKeysReferToSamePath(left, right) {
|
|
113
|
+
try {
|
|
114
|
+
return (await realpath(left)) === (await realpath(right));
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function acquireDiskLock(key, label, options) {
|
|
121
|
+
const lockDir = memoryRootLockPath(key);
|
|
122
|
+
const attempts = options.attempts ?? DEFAULT_ATTEMPTS;
|
|
123
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
124
|
+
await mkdir(path.dirname(lockDir), { recursive: true });
|
|
125
|
+
|
|
126
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
127
|
+
const ownerToken = randomUUID();
|
|
128
|
+
try {
|
|
129
|
+
await mkdir(lockDir);
|
|
130
|
+
await writeFile(
|
|
131
|
+
path.join(lockDir, OWNER_FILE_NAME),
|
|
132
|
+
`${JSON.stringify({ pid: process.pid, token: ownerToken, label, acquired_at: new Date().toISOString() })}\n`,
|
|
133
|
+
'utf8',
|
|
134
|
+
);
|
|
135
|
+
return ownerToken;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error?.code !== 'EEXIST') {
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (await isAbandonedLock(lockDir, staleMs)) {
|
|
143
|
+
await reclaimAbandonedLock(lockDir);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (attempt >= attempts) {
|
|
148
|
+
const owner = await readLockOwner(lockDir);
|
|
149
|
+
const ownerText = owner
|
|
150
|
+
? `held by live pid ${owner.pid ?? 'unknown'} (${owner.label ?? 'unknown command'}) since ${owner.acquired_at ?? 'unknown'}`
|
|
151
|
+
: 'owner unreadable';
|
|
152
|
+
throw new MemoryRootLockError(
|
|
153
|
+
`Timed out waiting for the project-memory lock at ${lockDir} — ${ownerText}. ` +
|
|
154
|
+
'Locks held by a live process are never stolen, however long they run. ' +
|
|
155
|
+
'If no other vibecompass command is running against this root, remove the lock directory and retry.',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await delay(RETRY_BASE_DELAY_MS + Math.floor(Math.random() * RETRY_MAX_DELAY_MS));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A lock is abandoned only when its owner process is provably gone (dead PID),
|
|
165
|
+
* or the owner file never became readable within the grace window (a crash
|
|
166
|
+
* between mkdir and the owner write). A lock held by a live process is NEVER
|
|
167
|
+
* treated as stale, regardless of age — long-running holders time out waiters
|
|
168
|
+
* with an actionable error instead of being silently stolen.
|
|
169
|
+
*/
|
|
170
|
+
async function isAbandonedLock(lockDir, staleMs) {
|
|
171
|
+
const owner = await readLockOwner(lockDir);
|
|
172
|
+
|
|
173
|
+
if (owner && Number.isInteger(owner.pid)) {
|
|
174
|
+
return !isProcessAlive(owner.pid);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const stats = await stat(lockDir);
|
|
179
|
+
return Date.now() - stats.mtimeMs > staleMs;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error?.code === 'ENOENT') {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Reclaims an abandoned lock via atomic rename so racing waiters cannot
|
|
190
|
+
* double-free: exactly one contender wins the rename and deletes the moved
|
|
191
|
+
* directory; the others see ENOENT and simply retry the mkdir claim. This can
|
|
192
|
+
* never delete a freshly acquired lock, because a new claim creates a new
|
|
193
|
+
* directory at the original path rather than reusing the renamed one.
|
|
194
|
+
*/
|
|
195
|
+
async function reclaimAbandonedLock(lockDir) {
|
|
196
|
+
const reclaimPath = `${lockDir}.reclaim-${process.pid}-${randomUUID()}`;
|
|
197
|
+
try {
|
|
198
|
+
await rename(lockDir, reclaimPath);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (error?.code === 'ENOENT') {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
await rm(reclaimPath, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Releases only a lock this caller still owns (token compare), so a holder
|
|
210
|
+
* whose lock was reclaimed after a crash-recovery cannot delete a successor's
|
|
211
|
+
* live lock.
|
|
212
|
+
*/
|
|
213
|
+
async function releaseDiskLock(key, ownerToken) {
|
|
214
|
+
const lockDir = memoryRootLockPath(key);
|
|
215
|
+
const owner = await readLockOwner(lockDir);
|
|
216
|
+
if (owner?.token !== ownerToken) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function readLockOwner(lockDir) {
|
|
223
|
+
try {
|
|
224
|
+
return JSON.parse(await readFile(path.join(lockDir, OWNER_FILE_NAME), 'utf8'));
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isProcessAlive(pid) {
|
|
231
|
+
try {
|
|
232
|
+
process.kill(pid, 0);
|
|
233
|
+
return true;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
// EPERM means the process exists but belongs to another user.
|
|
236
|
+
return error?.code === 'EPERM';
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function delay(ms) {
|
|
241
|
+
return new Promise((resolve) => {
|
|
242
|
+
setTimeout(resolve, ms);
|
|
243
|
+
});
|
|
244
|
+
}
|