klypix-mcp 1.69.0 → 1.70.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/bin/klypix-mcp.mjs +2 -1
- package/bin/klypix-orphans.mjs +168 -0
- package/bin/klypix-worker.mjs +11 -1
- package/package.json +4 -3
- package/src/agent-presence.mjs +305 -1
- package/src/brain-doctor.mjs +27 -6
- package/src/codex-brain-hook.mjs +22 -7
- package/src/global-brain-hook.mjs +95 -7
- package/src/klypix-format.mjs +155 -1
- package/src/mcp-presence.mjs +291 -14
- package/src/presence-relay.mjs +13 -0
- package/src/repo-state.mjs +33 -1
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -19,7 +19,7 @@ const PKG_VERSION = (() => {
|
|
|
19
19
|
}
|
|
20
20
|
})();
|
|
21
21
|
|
|
22
|
-
const DIRECT = new Set(['install', 'link', 'doctor', 'runtime', 'conformance', 'garden-code', 'init', 'git-driver', 'git-hook', 'brain-history', 'brain-deleted', 'diff', 'pr-brief', 'uninstall', 'bench']);
|
|
22
|
+
const DIRECT = new Set(['install', 'link', 'doctor', 'runtime', 'conformance', 'garden-code', 'init', 'git-driver', 'git-hook', 'brain-history', 'brain-deleted', 'orphans', 'diff', 'pr-brief', 'uninstall', 'bench']);
|
|
23
23
|
|
|
24
24
|
const USAGE = [
|
|
25
25
|
`klypix-mcp ${PKG_VERSION} — shared project brain + MCP coordination server.`,
|
|
@@ -38,6 +38,7 @@ const USAGE = [
|
|
|
38
38
|
' git-hook [install|remove|status] wire the agent-neutral commit-capture hook (any agent/branch/worktree → brain cards)',
|
|
39
39
|
' brain-history [list|restore <id>] restore points for this brain — undo an accidental delete, edit, or overwrite',
|
|
40
40
|
' brain-deleted [list|restore|purge] recycle bin for this brain — cards you deleted, kept recoverable',
|
|
41
|
+
' orphans [brain] [--apply|--areas] orphan gardener backfill: report unconnected cards, link the confident subset (dry-run default)',
|
|
41
42
|
' diff [ref] [--brain <path>] readable brain diff vs a git ref (default HEAD) — markdown to stdout',
|
|
42
43
|
' pr-brief [baseRef] [--brain <path>] brain decisions touching the files changed since baseRef — PR-comment markdown',
|
|
43
44
|
'',
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// klypix-orphans — `npx klypix-mcp orphans`. The orphan gardener's backfill pass:
|
|
3
|
+
// report how many live decision/milestone cards have NO connections, and link the
|
|
4
|
+
// CONFIDENT subset in one pass using the same selector capture uses
|
|
5
|
+
// (proposeOrphanAnchorLinks): at most ONE lexical-anchor edge per card — exact
|
|
6
|
+
// [[wikilink]], exact file-slug tag, or an unambiguous ≥0.6 title overlap —
|
|
7
|
+
// never a fan-out, never a guess among multiple candidates.
|
|
8
|
+
//
|
|
9
|
+
// npx klypix-mcp orphans [brain] # DRY RUN (default) — report, write nothing
|
|
10
|
+
// npx klypix-mcp orphans --apply # draw the confident edges (restore point first)
|
|
11
|
+
// npx klypix-mcp orphans --areas # ALSO include each orphan's area-container edge
|
|
12
|
+
// npx klypix-mcp orphans --json # machine-readable report
|
|
13
|
+
//
|
|
14
|
+
// Contract: additive + lossless — no card is archived, rewritten, or moved; every
|
|
15
|
+
// arrow remains individually removable in KLYPIX. `--apply` takes a FORCED restore
|
|
16
|
+
// point via the brain-history machinery before writing, so the whole pass is one
|
|
17
|
+
// `brain-history restore` away from undone. Dry-run is the default because a
|
|
18
|
+
// backfill touches history, not just this session's capture.
|
|
19
|
+
//
|
|
20
|
+
// `--areas` is opt-in for backfill (unlike capture, where the area edge is part
|
|
21
|
+
// of the no-new-orphans contract): mass-adding hundreds of containment edges to
|
|
22
|
+
// an old brain is a layout decision the human should make deliberately.
|
|
23
|
+
//
|
|
24
|
+
// Synchronous top-level by design: the dispatcher does `await import(this);
|
|
25
|
+
// process.exit(0)`, so all work must complete during module evaluation. ARGV is
|
|
26
|
+
// the standalone shape — the dispatcher splices its verb out first (runVerb).
|
|
27
|
+
import fs from 'fs';
|
|
28
|
+
import path from 'path';
|
|
29
|
+
import { parseKlypix, brainInsights, proposeOrphanAnchorLinks, addBrainConnections, atomicWrite } from '../src/klypix-format.mjs';
|
|
30
|
+
import { snapshotBrain } from '../src/brain-history.mjs';
|
|
31
|
+
import { withAdvisoryWriteLock, brainCaptureLockPath } from '../src/brain-write-lock.mjs';
|
|
32
|
+
|
|
33
|
+
const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const raw = process.argv.slice(2);
|
|
37
|
+
const argv = (raw[0] === 'orphans' && !isDir(path.resolve(raw[0]))) ? raw.slice(1) : raw;
|
|
38
|
+
const apply = argv.includes('--apply');
|
|
39
|
+
const withAreas = argv.includes('--areas');
|
|
40
|
+
const json = argv.includes('--json');
|
|
41
|
+
const pathArg = argv.find(a => !a.startsWith('-'));
|
|
42
|
+
const file = path.resolve(process.cwd(), pathArg || 'brain.klypix');
|
|
43
|
+
if (!fs.existsSync(file)) {
|
|
44
|
+
console.error(`No brain at ${file} — run from the project folder (or pass a path).`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { struct } = await parseKlypix(fs.readFileSync(file));
|
|
49
|
+
const before = brainInsights(struct).orphans;
|
|
50
|
+
const byId = new Map(struct.cards.map(c => [c.id, c]));
|
|
51
|
+
const flat = (id) => {
|
|
52
|
+
const c = byId.get(id);
|
|
53
|
+
if (!c) return id;
|
|
54
|
+
const s = c.type === 'container' ? `[area] ${c.title || ''}` : String(c.text || '').replace(/\s+/g, ' ').trim();
|
|
55
|
+
return s.slice(0, 70);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const proposals = proposeOrphanAnchorLinks(struct);
|
|
59
|
+
const anchored = proposals.filter(p => p.anchor);
|
|
60
|
+
// The confident subset: anchor edges always; area edges only with --areas.
|
|
61
|
+
// Reversed twins (A anchors B, B anchors A) collapse to ONE edge here so the
|
|
62
|
+
// dry-run list, the drawn count, and the apply receipt all agree exactly.
|
|
63
|
+
const seenPair = new Set();
|
|
64
|
+
const edges = [];
|
|
65
|
+
for (const p of proposals) {
|
|
66
|
+
if (p.anchor) {
|
|
67
|
+
const key = [p.cardId, p.anchor.toId].sort().join('|');
|
|
68
|
+
if (!seenPair.has(key)) {
|
|
69
|
+
seenPair.add(key);
|
|
70
|
+
edges.push({ fromId: p.cardId, toId: p.anchor.toId, relationship: 'relates_to', label: 'auto', why: p.anchor.why });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (withAreas && p.areaId) edges.push({ fromId: p.cardId, toId: p.areaId, relationship: 'relates_to', label: 'in area', color: 'rgba(120,120,135,0.45)', width: 1, style: 'dashed', why: 'area container' });
|
|
74
|
+
}
|
|
75
|
+
// An anchor edge between two orphans repairs BOTH endpoints — project on the
|
|
76
|
+
// orphan set, not just the source card.
|
|
77
|
+
const orphanIdSet = new Set(before.map(o => o.id));
|
|
78
|
+
const repairable = new Set();
|
|
79
|
+
for (const e of edges) {
|
|
80
|
+
if (orphanIdSet.has(e.fromId)) repairable.add(e.fromId);
|
|
81
|
+
if (orphanIdSet.has(e.toId)) repairable.add(e.toId);
|
|
82
|
+
}
|
|
83
|
+
const projected = Math.max(0, before.length - repairable.size);
|
|
84
|
+
|
|
85
|
+
if (json && !apply) {
|
|
86
|
+
console.log(JSON.stringify({
|
|
87
|
+
brain: file, dryRun: true, orphans: before.length, confident: anchored.length,
|
|
88
|
+
projectedOrphans: projected,
|
|
89
|
+
edges: edges.map(e => ({ fromId: e.fromId, toId: e.toId, label: e.label, why: e.why })),
|
|
90
|
+
}, null, 2));
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!apply) {
|
|
95
|
+
console.log(`# orphan gardener — ${path.basename(file)} (DRY RUN, nothing written)`);
|
|
96
|
+
console.log('');
|
|
97
|
+
console.log(`Orphans now: ${before.length} live decision/milestone card(s) with no connections.`);
|
|
98
|
+
console.log(`Confident subset: ${anchored.length} card(s) have exactly one unambiguous lexical anchor${withAreas ? `; --areas adds ${proposals.filter(p => p.areaId).length} area-container edge(s)` : ''}.`);
|
|
99
|
+
console.log(`Projected after --apply: ${projected} orphan(s) remain (the rest need brain_connect review or a human).`);
|
|
100
|
+
if (edges.length) {
|
|
101
|
+
console.log('');
|
|
102
|
+
for (const e of edges) console.log(` - ${flat(e.fromId)} → ${flat(e.toId)} (${e.why})`);
|
|
103
|
+
console.log('');
|
|
104
|
+
console.log('Re-run with --apply to draw these. Additive only: no card is archived or rewritten;');
|
|
105
|
+
console.log('a forced restore point is taken first (undo: npx klypix-mcp brain-history).');
|
|
106
|
+
} else {
|
|
107
|
+
console.log('');
|
|
108
|
+
console.log(before.length
|
|
109
|
+
? 'No edge clears the confidence bar — review the rest with brain_connect (dry-run receipts).'
|
|
110
|
+
: 'Nothing to do — this brain has no orphaned decision/milestone cards.');
|
|
111
|
+
}
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!edges.length) {
|
|
116
|
+
console.log(before.length
|
|
117
|
+
? `No edge clears the confidence bar — ${before.length} orphan(s) remain for brain_connect review. Nothing written.`
|
|
118
|
+
: 'Nothing to do — this brain has no orphaned decision/milestone cards. Nothing written.');
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// APPLY — one locked snapshot + read-modify-write. The restore point is taken
|
|
123
|
+
// INSIDE the advisory lock (forced: an additive pass never trips the shrink
|
|
124
|
+
// rule, and the throttle must not skip the one snapshot that makes a whole
|
|
125
|
+
// backfill reversible): taken outside it, a concurrent capture landing between
|
|
126
|
+
// snapshot and write would be silently rolled back by a later restore.
|
|
127
|
+
let snap = null;
|
|
128
|
+
const result = await withAdvisoryWriteLock(brainCaptureLockPath(file), async (locked) => {
|
|
129
|
+
if (!locked) return { ok: false, error: 'brain is busy (another writer holds the capture lock) — retry in a moment; nothing written' };
|
|
130
|
+
snap = snapshotBrain(file, { reason: 'orphan-backfill', force: true });
|
|
131
|
+
const { buffer, added } = await addBrainConnections(fs.readFileSync(file), edges);
|
|
132
|
+
if (!added) return { ok: true, added: 0, after: before.length };
|
|
133
|
+
await atomicWrite(file, buffer, { snapshot: false }); // the forced pre-apply snapshot above is the restore point
|
|
134
|
+
let after = projected;
|
|
135
|
+
try { after = brainInsights((await parseKlypix(buffer)).struct).orphans.length; } catch { /* projected stays the honest fallback */ }
|
|
136
|
+
return { ok: true, added, after };
|
|
137
|
+
});
|
|
138
|
+
if (!result.ok) { console.error(`✗ ${result.error}`); process.exit(1); }
|
|
139
|
+
|
|
140
|
+
// A drawn count below the planned count means the brain changed between the
|
|
141
|
+
// dry-run read and the locked write (a pair already present, an endpoint
|
|
142
|
+
// gone) — slicing the planned list by count would then name the WRONG edges,
|
|
143
|
+
// so the per-edge list is only printed when it is exact.
|
|
144
|
+
const exact = result.added === edges.length;
|
|
145
|
+
// An 'unchanged' skip means the newest restore point already holds these
|
|
146
|
+
// exact bytes — the pass is just as reversible as a fresh snapshot.
|
|
147
|
+
const covered = snap && (snap.saved || snap.skipped === 'unchanged');
|
|
148
|
+
if (json) {
|
|
149
|
+
console.log(JSON.stringify({
|
|
150
|
+
brain: file, dryRun: false, added: result.added,
|
|
151
|
+
orphansBefore: before.length, orphansAfter: result.after,
|
|
152
|
+
restorePoint: covered ? (snap.id || true) : null,
|
|
153
|
+
edges: exact ? edges.map(e => ({ fromId: e.fromId, toId: e.toId, label: e.label, why: e.why })) : null,
|
|
154
|
+
...(exact ? {} : { edgesNote: `${edges.length - result.added} planned edge(s) were skipped (already present or endpoint missing) — re-run the dry run for the current pair list` }),
|
|
155
|
+
}, null, 2));
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
console.log(`✓ Drew ${result.added} additive connection(s). Orphan receipt: ${before.length} → ${result.after} (${Math.max(0, before.length - result.after)} repaired).`);
|
|
159
|
+
console.log(covered
|
|
160
|
+
? 'Restore point taken first — undo the whole pass: npx klypix-mcp brain-history'
|
|
161
|
+
: 'Restore point: not saved (best-effort) — arrows remain individually removable in KLYPIX.');
|
|
162
|
+
if (exact) for (const e of edges) console.log(` - ${flat(e.fromId)} → ${flat(e.toId)} (${e.why})`);
|
|
163
|
+
else console.log(` (${edges.length - result.added} planned edge(s) were already present or lost an endpoint — re-run the dry run for the current pair list)`);
|
|
164
|
+
process.exit(0);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
console.error(`✗ orphans failed (brain unchanged): ${e?.message || e}`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -131,6 +131,11 @@ await runVerb('brain-history', './klypix-brain-history.mjs');
|
|
|
131
131
|
// the card's bytes to graveyard/ instead of destroying them; this lists, restores
|
|
132
132
|
// and (permanently) purges them.
|
|
133
133
|
await runVerb('brain-deleted', './klypix-brain-deleted.mjs');
|
|
134
|
+
// `npx klypix-mcp orphans` — the orphan gardener's backfill: report how many live
|
|
135
|
+
// cards sit outside the graph, link the CONFIDENT subset (one unambiguous lexical
|
|
136
|
+
// anchor each — never a fan-out). Dry-run by default; --apply takes a forced
|
|
137
|
+
// restore point first, so the whole pass is one brain-history restore from undone.
|
|
138
|
+
await runVerb('orphans', './klypix-orphans.mjs');
|
|
134
139
|
await runVerb('diff', './klypix-diff.mjs');
|
|
135
140
|
await runVerb('pr-brief', './klypix-pr-brief.mjs');
|
|
136
141
|
|
|
@@ -868,8 +873,12 @@ server.registerTool('brain_sync', {
|
|
|
868
873
|
// marker before rejecting them. A strict transport schema rejected first,
|
|
869
874
|
// allowing a later result-less completion to bypass that state entirely.
|
|
870
875
|
results: z.unknown().optional().describe('On phase complete, 1-8 result manifests for stable claim keys. The in-handler versioned validator rejects malformed, empty, unknown-field, or incomparable evidence and retains task scope.'),
|
|
876
|
+
releaseIntent: z.object({
|
|
877
|
+
version: z.string().max(64).describe('The version this session intends to release (e.g. "1.70.0").'),
|
|
878
|
+
ref: z.string().max(200).describe('The git ref (branch or tag) the release will be cut from.'),
|
|
879
|
+
}).optional().describe('Declare EXCLUSIVE intent to prepare a release of this project. The first declarer takes a ~2h lease (refreshed by checkpoints, freed by phase "complete", by expiry, or when the holder session ends); a second declarer gets a structured hard conflict naming the holder, version, and ref. While any lease is active every peer\'s sync gains a "release in preparation" footer line.'),
|
|
871
880
|
},
|
|
872
|
-
}, async ({ project, intent, files, phase, include_context, results }, extra) => {
|
|
881
|
+
}, async ({ project, intent, files, phase, include_context, results, releaseIntent }, extra) => {
|
|
873
882
|
const totalStartedAt = Date.now();
|
|
874
883
|
const report = mcpPresence.sync({
|
|
875
884
|
project,
|
|
@@ -877,6 +886,7 @@ server.registerTool('brain_sync', {
|
|
|
877
886
|
files,
|
|
878
887
|
phase,
|
|
879
888
|
results,
|
|
889
|
+
releaseIntent,
|
|
880
890
|
deliverMessages: include_context !== false,
|
|
881
891
|
actionId: extra?.klypixRequestIdentity?.actionId || '',
|
|
882
892
|
preflight: extra?.klypixBrainSyncPreflight,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.70.0",
|
|
4
4
|
"description": "Shared project brain and MCP coordination server for multi-agent coding.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"klypix-append": "bin/klypix-append.mjs",
|
|
45
45
|
"klypix-install": "bin/klypix-install.mjs",
|
|
46
46
|
"klypix-uninstall": "bin/klypix-uninstall.mjs",
|
|
47
|
-
"klypix-project-map": "bin/klypix-project-map.mjs"
|
|
47
|
+
"klypix-project-map": "bin/klypix-project-map.mjs",
|
|
48
|
+
"klypix-orphans": "bin/klypix-orphans.mjs"
|
|
48
49
|
},
|
|
49
50
|
"main": "index.mjs",
|
|
50
51
|
"exports": {
|
|
@@ -83,7 +84,7 @@
|
|
|
83
84
|
"bench": "node bin/klypix-mcp.mjs bench",
|
|
84
85
|
"test:bench": "node test/bench.mjs",
|
|
85
86
|
"pretest": "node test/publish-workflow.mjs",
|
|
86
|
-
"test": "node test/publish-verdict.mjs && node test/remote-client.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/presence-liveness.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
|
|
87
|
+
"test": "node test/publish-verdict.mjs && node test/remote-client.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
|
|
87
88
|
"test:memory": "node test/memory-runtime.mjs",
|
|
88
89
|
"test:memory:soak": "node --expose-gc test/memory-soak.mjs",
|
|
89
90
|
"runtime": "node bin/klypix-runtime.mjs"
|
package/src/agent-presence.mjs
CHANGED
|
@@ -718,6 +718,16 @@ function normalizeFiles(files) {
|
|
|
718
718
|
return out.slice(-20);
|
|
719
719
|
}
|
|
720
720
|
|
|
721
|
+
// Comparison fold for scope coverage checks — mirrors mcp-presence.mjs
|
|
722
|
+
// normalizeFileKey's slash/./-prefix/case fold (duplicated because THAT module
|
|
723
|
+
// imports THIS one). Coverage must match across spellings: a hook observes
|
|
724
|
+
// `src\App.tsx` while brain_sync declared `src/app.tsx`.
|
|
725
|
+
const scopeKey = (file) => String(file || '').replace(/\\/g, '/').replace(/^\.\//, '').trim().toLowerCase();
|
|
726
|
+
|
|
727
|
+
// Cap for the observed-scope lane (automatic scope adoption, 1.70.0). Kept as
|
|
728
|
+
// its own constant so writers and tests agree on the LRU bound.
|
|
729
|
+
export const OBSERVED_FILES_CAP = 20;
|
|
730
|
+
|
|
721
731
|
export function listActiveSessions({ brainPath, home, now = Date.now() }) {
|
|
722
732
|
if (!brainPath) return [];
|
|
723
733
|
const lane = readLane(laneFileFor(brainPath, home));
|
|
@@ -736,6 +746,16 @@ export function upsertSession({
|
|
|
736
746
|
intentSource = null,
|
|
737
747
|
files,
|
|
738
748
|
replaceFiles = false,
|
|
749
|
+
// Automatic scope adoption (1.70.0): paths the HOST reported this session
|
|
750
|
+
// editing (hook PostToolUse / codex tool events) — observations, never
|
|
751
|
+
// declarations. A path already covered by the row's DECLARED scope is
|
|
752
|
+
// ignored (the declaration is the higher-confidence claim); anything else
|
|
753
|
+
// joins `observedFiles` (LRU, cap OBSERVED_FILES_CAP) AND the legacy
|
|
754
|
+
// `files` union so pre-1.70 readers keep their overlap coverage. The
|
|
755
|
+
// `observedFiles` marker is what lets every 1.70+ reader state the
|
|
756
|
+
// observed/declared distinction instead of mistaking a live edit for a
|
|
757
|
+
// declared scope.
|
|
758
|
+
observedFiles,
|
|
739
759
|
event = null,
|
|
740
760
|
channel = null,
|
|
741
761
|
transportStatus = null,
|
|
@@ -779,9 +799,51 @@ export function upsertSession({
|
|
|
779
799
|
transport[String(channel)] = { status: String(status).slice(0, 40), at: now };
|
|
780
800
|
channelOwners[String(channel)] = process.pid;
|
|
781
801
|
}
|
|
782
|
-
|
|
802
|
+
let mergedFiles = files === undefined
|
|
783
803
|
? normalizeFiles(previous.files)
|
|
784
804
|
: normalizeFiles(replaceFiles ? files : [...(previous.files || []), ...(files || [])]);
|
|
805
|
+
// ── Observed-scope maintenance (automatic scope adoption, 1.70.0) ────────
|
|
806
|
+
// `files` arrivals are DECLARATIONS (brain_sync). A path the declaration
|
|
807
|
+
// now names sheds its observed marker — the claim was upgraded, never
|
|
808
|
+
// duplicated. A declaration NOT covering an observed path leaves the
|
|
809
|
+
// marker alone (replaceFiles/completion clears declared scope only:
|
|
810
|
+
// the 1.69.0 completion guard must never erase what a session was
|
|
811
|
+
// OBSERVED editing — those edits are still real in the worktree).
|
|
812
|
+
const prevObserved = normalizeFiles(previous.observedFiles);
|
|
813
|
+
const declarationKeys = files !== undefined
|
|
814
|
+
? new Set(normalizeFiles(files).map(scopeKey))
|
|
815
|
+
: null;
|
|
816
|
+
let nextObserved = declarationKeys
|
|
817
|
+
? prevObserved.filter((file) => !declarationKeys.has(scopeKey(file)))
|
|
818
|
+
: prevObserved;
|
|
819
|
+
if (observedFiles !== undefined) {
|
|
820
|
+
// Declared coverage = files minus observed markers (observed paths ride
|
|
821
|
+
// in `files` too, for pre-1.70 readers — they are not declarations).
|
|
822
|
+
const observedKeys = new Set(nextObserved.map(scopeKey));
|
|
823
|
+
const declaredCovered = new Set(mergedFiles.map(scopeKey).filter((key) => !observedKeys.has(key)));
|
|
824
|
+
for (const file of normalizeFiles(observedFiles)) {
|
|
825
|
+
const key = scopeKey(file);
|
|
826
|
+
if (declaredCovered.has(key)) continue; // declared scope covers it — nothing to adopt
|
|
827
|
+
nextObserved = nextObserved.filter((existing) => scopeKey(existing) !== key);
|
|
828
|
+
nextObserved.push(file); // LRU: a re-observation moves to the tail
|
|
829
|
+
if (!mergedFiles.some((existing) => scopeKey(existing) === key)) mergedFiles.push(file);
|
|
830
|
+
}
|
|
831
|
+
// Cap pressure evicts OBSERVATIONS, never declarations (review-caught,
|
|
832
|
+
// 1.70.0): normalizeFiles' plain slice(-20) let an observation flood push
|
|
833
|
+
// a session's DECLARED files out of files[] — peers silently lost
|
|
834
|
+
// blocking coverage on declared work, and a later re-edit of the evicted
|
|
835
|
+
// path re-entered it as observed, rendering an owned scope as
|
|
836
|
+
// `*`-unconfirmed. Declarations keep the tail (slice keeps newest); the
|
|
837
|
+
// observations fill whatever room remains, oldest evicted first.
|
|
838
|
+
if (mergedFiles.length > 20) {
|
|
839
|
+
const observedKeySet = new Set(nextObserved.map(scopeKey));
|
|
840
|
+
const declared = mergedFiles.filter((file) => !observedKeySet.has(scopeKey(file)));
|
|
841
|
+
const observed = mergedFiles.filter((file) => observedKeySet.has(scopeKey(file)));
|
|
842
|
+
mergedFiles = [...observed.slice(-Math.max(0, 20 - declared.length)), ...declared];
|
|
843
|
+
}
|
|
844
|
+
mergedFiles = normalizeFiles(mergedFiles);
|
|
845
|
+
}
|
|
846
|
+
nextObserved = nextObserved.slice(-OBSERVED_FILES_CAP);
|
|
785
847
|
// Intent freshness is its OWN timestamp: lastSeen is refreshed by heartbeats
|
|
786
848
|
// that never touch intent, so without intentAt a 100-minute-old intent renders
|
|
787
849
|
// under "active just now" (2026-07-29 audit). Stamped only when the intent
|
|
@@ -816,6 +878,11 @@ export function upsertSession({
|
|
|
816
878
|
...(intentChanged ? { intentAt: now, intentSource: intentSource || 'declared' }
|
|
817
879
|
: (previous.intentAt ? { intentAt: previous.intentAt, intentSource: previous.intentSource || null } : {})),
|
|
818
880
|
files: mergedFiles,
|
|
881
|
+
// ADDITIVE observed lane: written only for rows that ever carried it, so
|
|
882
|
+
// pre-1.70 rows stay byte-stable through a touch that changes nothing.
|
|
883
|
+
...(nextObserved.length || previous.observedFiles !== undefined
|
|
884
|
+
? { observedFiles: nextObserved }
|
|
885
|
+
: {}),
|
|
819
886
|
event: event ?? previous.event ?? null,
|
|
820
887
|
...(activityEvent
|
|
821
888
|
? { activityAt: now, activityKind: activityEvent }
|
|
@@ -903,6 +970,13 @@ export function upsertRemoteSessions({ brainPath, rows, machineId = MACHINE_ID,
|
|
|
903
970
|
const existingIdx = sessions.findIndex((session) => session.id === row.id);
|
|
904
971
|
if (existingIdx >= 0 && sessions[existingIdx].via !== 'cloud') continue; // local row wins
|
|
905
972
|
const previous = existingIdx >= 0 ? sessions[existingIdx] : {};
|
|
973
|
+
// REPLACE semantics, like `files` below: the frame is a full snapshot of
|
|
974
|
+
// the sender's row, so an empty/absent observed lane must CLEAR the
|
|
975
|
+
// mirror. The `...previous` spread otherwise pinned a completed remote
|
|
976
|
+
// task's stale markers to the local mirror forever (review-caught
|
|
977
|
+
// 1.70.0) — remote rows carry no completedAt, so no local gate could
|
|
978
|
+
// ever retire them.
|
|
979
|
+
const remoteObserved = normalizeFiles(row.observedFiles).slice(-OBSERVED_FILES_CAP);
|
|
906
980
|
const next = {
|
|
907
981
|
...previous,
|
|
908
982
|
id: String(row.id),
|
|
@@ -914,6 +988,10 @@ export function upsertRemoteSessions({ brainPath, rows, machineId = MACHINE_ID,
|
|
|
914
988
|
// pre-guard build can relay junk intent — never mirror it verbatim.
|
|
915
989
|
intent: looksMachineTurn(row.intent) ? String(previous.intent || '') : String(row.intent || '').slice(0, 160),
|
|
916
990
|
files: normalizeFiles(row.files),
|
|
991
|
+
// Observed/declared distinction crosses machines too (additive — an old
|
|
992
|
+
// sender simply never populates it; see remoteObserved above for the
|
|
993
|
+
// replace-not-merge rule).
|
|
994
|
+
...(remoteObserved.length ? { observedFiles: remoteObserved } : {}),
|
|
917
995
|
machine: String(row.machine),
|
|
918
996
|
host: row.host ?? previous.host ?? null,
|
|
919
997
|
via: 'cloud',
|
|
@@ -924,6 +1002,9 @@ export function upsertRemoteSessions({ brainPath, rows, machineId = MACHINE_ID,
|
|
|
924
1002
|
startedAt: previous.startedAt || now,
|
|
925
1003
|
lastSeen: now,
|
|
926
1004
|
};
|
|
1005
|
+
// The `...previous` spread carried any stale marker into `next`; an empty
|
|
1006
|
+
// snapshot clears it (see the replace-not-merge rule above).
|
|
1007
|
+
if (!remoteObserved.length) delete next.observedFiles;
|
|
927
1008
|
if (existingIdx >= 0) sessions[existingIdx] = next;
|
|
928
1009
|
else sessions.push(next);
|
|
929
1010
|
}
|
|
@@ -1126,6 +1207,229 @@ export function endSession({ brainPath, id, home, now = Date.now(), expectedPid
|
|
|
1126
1207
|
}
|
|
1127
1208
|
}
|
|
1128
1209
|
|
|
1210
|
+
// ── Release lease (1.70.0 measured wave) ────────────────────────────────────
|
|
1211
|
+
// One EXCLUSIVE per-project release-preparation record in the same lane file
|
|
1212
|
+
// every presence reader already parses. First declarer takes it; a second
|
|
1213
|
+
// declarer gets a hard conflict naming the holder+version+ref. The lease is
|
|
1214
|
+
// bounded three ways, all reusing the lane's existing liveness machinery:
|
|
1215
|
+
// • TTL (~2h) refreshed by the holder's checkpoints — a stalled release
|
|
1216
|
+
// cannot squat on the project forever;
|
|
1217
|
+
// • freed explicitly when the holder's task truly completes;
|
|
1218
|
+
// • freed implicitly when the holder stops being a live lane session
|
|
1219
|
+
// (TTL pruning + dead-host sweep, via pruneSessions) — a crashed holder
|
|
1220
|
+
// never blocks the next release engineer.
|
|
1221
|
+
// The stored record is additive lane state: every existing mutator spreads
|
|
1222
|
+
// `...data` on write, so the key survives session/message maintenance, and
|
|
1223
|
+
// old readers simply ignore it.
|
|
1224
|
+
export const RELEASE_LEASE_TTL_MS = 2 * 60 * 60 * 1000;
|
|
1225
|
+
|
|
1226
|
+
const releaseLeaseVersionKey = (value) => String(value || '').trim().replace(/^v/i, '').slice(0, 64);
|
|
1227
|
+
const releaseLeaseRefKey = (value) => String(value || '').trim().slice(0, 200);
|
|
1228
|
+
|
|
1229
|
+
function normalizeReleaseLease(raw) {
|
|
1230
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
1231
|
+
const holderId = recipientKey(raw.holderId);
|
|
1232
|
+
const version = releaseLeaseVersionKey(raw.version);
|
|
1233
|
+
const ref = releaseLeaseRefKey(raw.ref);
|
|
1234
|
+
const takenAt = Number(raw.takenAt || 0);
|
|
1235
|
+
const refreshedAt = Number(raw.refreshedAt || raw.takenAt || 0);
|
|
1236
|
+
if (!holderId || !version || !ref || !takenAt || !refreshedAt) return null;
|
|
1237
|
+
const ttlMs = Math.max(60_000, Number(raw.ttlMs) || RELEASE_LEASE_TTL_MS);
|
|
1238
|
+
return {
|
|
1239
|
+
holderId,
|
|
1240
|
+
holderClient: String(raw.holderClient || 'unknown').slice(0, 40),
|
|
1241
|
+
version,
|
|
1242
|
+
ref,
|
|
1243
|
+
takenAt,
|
|
1244
|
+
refreshedAt,
|
|
1245
|
+
ttlMs,
|
|
1246
|
+
expiresAt: refreshedAt + ttlMs,
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// The holder may have rotated ids since taking the lease — match against the
|
|
1251
|
+
// same identity set message routing uses (id, logical id, aliases).
|
|
1252
|
+
const sessionOwnsLease = (session, holderId) => {
|
|
1253
|
+
const key = recipientKey(holderId);
|
|
1254
|
+
return Boolean(key && session && (recipientKey(session.id) === key
|
|
1255
|
+
|| recipientKey(session.logicalSessionId) === key
|
|
1256
|
+
|| normalizeAliases(session.aliases).includes(key)));
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
// Verdict for a stored lease against pre-PRUNED sessions (the caller must pass
|
|
1260
|
+
// pruneSessions output so TTL pruning and the dead-host sweep both apply):
|
|
1261
|
+
// { status: 'active' | 'expired' | 'dead-holder', lease } or null (no/invalid
|
|
1262
|
+
// record). Only 'active' binds anyone; the other verdicts are reclaimable.
|
|
1263
|
+
export function releaseLeaseVerdict(rawLease, sessions, now = Date.now()) {
|
|
1264
|
+
const lease = normalizeReleaseLease(rawLease);
|
|
1265
|
+
if (!lease) return null;
|
|
1266
|
+
if (now >= lease.expiresAt) return { status: 'expired', lease };
|
|
1267
|
+
const alive = (Array.isArray(sessions) ? sessions : [])
|
|
1268
|
+
.some((session) => sessionOwnsLease(session, lease.holderId));
|
|
1269
|
+
return { status: alive ? 'active' : 'dead-holder', lease };
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// Read-only: the ACTIVE lease or null. Lock-free like every other read surface
|
|
1273
|
+
// (atomic lane writes guarantee a parseable snapshot).
|
|
1274
|
+
export function readReleaseLease({ brainPath, home, now = Date.now() } = {}) {
|
|
1275
|
+
if (!brainPath) return null;
|
|
1276
|
+
const lane = readLane(laneFileFor(brainPath, home));
|
|
1277
|
+
const verdict = releaseLeaseVerdict(lane.releaseLease, pruneSessions(lane.sessions, now), now);
|
|
1278
|
+
return verdict?.status === 'active' ? verdict.lease : null;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// Shared lock/read/verdict/write skeleton for the three mutators. `mutate`
|
|
1282
|
+
// returns { write, lease, outcome }: lease === null deletes the record,
|
|
1283
|
+
// undefined leaves it untouched. Sessions/messages bytes are preserved as-is —
|
|
1284
|
+
// a lease mutation must never race presence maintenance into data loss.
|
|
1285
|
+
function mutateReleaseLease({ brainPath, home, now, mutate }) {
|
|
1286
|
+
if (!brainPath) return { ok: false, status: 'no-brain' };
|
|
1287
|
+
const laneFile = laneFileFor(brainPath, home);
|
|
1288
|
+
const lockFile = laneFile + '.lock';
|
|
1289
|
+
if (!acquireLock(lockFile)) return { ok: false, status: 'lane-locked' };
|
|
1290
|
+
try {
|
|
1291
|
+
const laneRead = readMutableLane(laneFile);
|
|
1292
|
+
if (!laneRead.ok) return { ok: false, status: laneRead.reason };
|
|
1293
|
+
const data = laneRead.data;
|
|
1294
|
+
const sessions = pruneSessions(data.sessions, now);
|
|
1295
|
+
const verdict = releaseLeaseVerdict(data.releaseLease, sessions, now);
|
|
1296
|
+
const result = mutate({ data, sessions, verdict });
|
|
1297
|
+
if (result.write) {
|
|
1298
|
+
const next = { ...data };
|
|
1299
|
+
if (result.lease === null) delete next.releaseLease;
|
|
1300
|
+
else if (result.lease !== undefined) next.releaseLease = result.lease;
|
|
1301
|
+
fs.mkdirSync(path.dirname(laneFile), { recursive: true });
|
|
1302
|
+
writeLaneFileAtomic(laneFile, JSON.stringify(next));
|
|
1303
|
+
}
|
|
1304
|
+
return result.outcome;
|
|
1305
|
+
} finally {
|
|
1306
|
+
releaseLock(lockFile);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const leaseHeldBy = (verdict, sessions, callerId) => {
|
|
1311
|
+
if (!verdict) return false;
|
|
1312
|
+
const me = sessions.find((session) => recipientKey(session.id) === callerId) || null;
|
|
1313
|
+
return recipientKey(verdict.lease.holderId) === callerId
|
|
1314
|
+
|| sessionOwnsLease(me, verdict.lease.holderId);
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
// First-declarer-wins exclusive take. Same holder re-declaring refreshes (and
|
|
1318
|
+
// may retarget version/ref); an expired or dead-holder record is reclaimed with
|
|
1319
|
+
// the reason surfaced; a live foreign holder is a hard conflict carrying the
|
|
1320
|
+
// full holder record so the caller can name it.
|
|
1321
|
+
export function declareReleaseLease({
|
|
1322
|
+
brainPath,
|
|
1323
|
+
sessionId,
|
|
1324
|
+
version,
|
|
1325
|
+
ref,
|
|
1326
|
+
client = 'unknown',
|
|
1327
|
+
home,
|
|
1328
|
+
now = Date.now(),
|
|
1329
|
+
ttlMs = RELEASE_LEASE_TTL_MS,
|
|
1330
|
+
}) {
|
|
1331
|
+
const holderId = recipientKey(sessionId);
|
|
1332
|
+
const nextVersion = releaseLeaseVersionKey(version);
|
|
1333
|
+
const nextRef = releaseLeaseRefKey(ref);
|
|
1334
|
+
if (!holderId || !nextVersion || !nextRef) return { ok: false, status: 'invalid-release-intent' };
|
|
1335
|
+
return mutateReleaseLease({ brainPath, home, now, mutate: ({ sessions, verdict }) => {
|
|
1336
|
+
const mine = leaseHeldBy(verdict, sessions, holderId);
|
|
1337
|
+
if (verdict?.status === 'active' && !mine) {
|
|
1338
|
+
return { write: false, outcome: { ok: false, status: 'conflict', holder: verdict.lease } };
|
|
1339
|
+
}
|
|
1340
|
+
const stillMine = mine && verdict?.status === 'active';
|
|
1341
|
+
const stored = {
|
|
1342
|
+
schemaVersion: 1,
|
|
1343
|
+
holderId,
|
|
1344
|
+
holderClient: String(client || 'unknown').slice(0, 40),
|
|
1345
|
+
version: nextVersion,
|
|
1346
|
+
ref: nextRef,
|
|
1347
|
+
takenAt: stillMine ? verdict.lease.takenAt : now,
|
|
1348
|
+
refreshedAt: now,
|
|
1349
|
+
ttlMs: Math.max(60_000, Number(ttlMs) || RELEASE_LEASE_TTL_MS),
|
|
1350
|
+
};
|
|
1351
|
+
const reclaimed = !stillMine && verdict ? verdict.status : null;
|
|
1352
|
+
return {
|
|
1353
|
+
write: true,
|
|
1354
|
+
lease: stored,
|
|
1355
|
+
outcome: {
|
|
1356
|
+
ok: true,
|
|
1357
|
+
status: stillMine ? 'refreshed' : 'taken',
|
|
1358
|
+
...(reclaimed && reclaimed !== 'active' ? { reclaimed } : {}),
|
|
1359
|
+
lease: normalizeReleaseLease(stored),
|
|
1360
|
+
},
|
|
1361
|
+
};
|
|
1362
|
+
} });
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// Checkpoint refresh: only the live holder advances refreshedAt. A stale
|
|
1366
|
+
// record (expired / dead holder) found by ANY caller is pruned so readers stop
|
|
1367
|
+
// parsing it — the lane's dead-session sweep analogue for this key.
|
|
1368
|
+
export function refreshReleaseLease({ brainPath, sessionId, home, now = Date.now() }) {
|
|
1369
|
+
const callerId = recipientKey(sessionId);
|
|
1370
|
+
if (!callerId) return { ok: false, status: 'no-session' };
|
|
1371
|
+
// Lock-free fast path: this runs on EVERY non-completing sync of every
|
|
1372
|
+
// session, and almost always there is no lease at all. Skipping the lane
|
|
1373
|
+
// lock then keeps lease bookkeeping from doubling lock traffic project-wide.
|
|
1374
|
+
// Race-safe: a lease declared concurrently is simply refreshed on the
|
|
1375
|
+
// caller's NEXT sync, and a just-declared lease is by definition fresh.
|
|
1376
|
+
if (brainPath && readLane(laneFileFor(brainPath, home)).releaseLease === undefined) {
|
|
1377
|
+
return { ok: true, status: 'no-lease' };
|
|
1378
|
+
}
|
|
1379
|
+
return mutateReleaseLease({ brainPath, home, now, mutate: ({ sessions, verdict }) => {
|
|
1380
|
+
if (!verdict) return { write: false, outcome: { ok: true, status: 'no-lease' } };
|
|
1381
|
+
const mine = leaseHeldBy(verdict, sessions, callerId);
|
|
1382
|
+
if (verdict.status !== 'active') {
|
|
1383
|
+
return {
|
|
1384
|
+
write: true,
|
|
1385
|
+
lease: null,
|
|
1386
|
+
outcome: { ok: true, status: mine ? 'lease-lost' : 'stale-pruned', reason: verdict.status },
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
if (!mine) return { write: false, outcome: { ok: true, status: 'not-holder' } };
|
|
1390
|
+
const stored = {
|
|
1391
|
+
schemaVersion: 1,
|
|
1392
|
+
holderId: verdict.lease.holderId,
|
|
1393
|
+
holderClient: verdict.lease.holderClient,
|
|
1394
|
+
version: verdict.lease.version,
|
|
1395
|
+
ref: verdict.lease.ref,
|
|
1396
|
+
takenAt: verdict.lease.takenAt,
|
|
1397
|
+
refreshedAt: now,
|
|
1398
|
+
ttlMs: verdict.lease.ttlMs,
|
|
1399
|
+
};
|
|
1400
|
+
return {
|
|
1401
|
+
write: true,
|
|
1402
|
+
lease: stored,
|
|
1403
|
+
outcome: { ok: true, status: 'refreshed', lease: normalizeReleaseLease(stored) },
|
|
1404
|
+
};
|
|
1405
|
+
} });
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Explicit free — the holder's real task completion. A non-holder can never
|
|
1409
|
+
// free a LIVE peer's lease, but anyone may clear a stale record.
|
|
1410
|
+
export function freeReleaseLease({ brainPath, sessionId, home, now = Date.now() }) {
|
|
1411
|
+
const callerId = recipientKey(sessionId);
|
|
1412
|
+
if (!callerId) return { ok: false, status: 'no-session' };
|
|
1413
|
+
// Same lock-free fast path as refreshReleaseLease: every real completion
|
|
1414
|
+
// calls this, and almost none of them hold a lease. Race-safe for the same
|
|
1415
|
+
// reason — a record this snapshot cannot see is one this caller cannot own.
|
|
1416
|
+
if (brainPath && readLane(laneFileFor(brainPath, home)).releaseLease === undefined) {
|
|
1417
|
+
return { ok: true, status: 'no-lease' };
|
|
1418
|
+
}
|
|
1419
|
+
return mutateReleaseLease({ brainPath, home, now, mutate: ({ sessions, verdict }) => {
|
|
1420
|
+
if (!verdict) return { write: false, outcome: { ok: true, status: 'no-lease' } };
|
|
1421
|
+
const mine = leaseHeldBy(verdict, sessions, callerId);
|
|
1422
|
+
if (!mine && verdict.status === 'active') {
|
|
1423
|
+
return { write: false, outcome: { ok: true, status: 'not-holder' } };
|
|
1424
|
+
}
|
|
1425
|
+
return {
|
|
1426
|
+
write: true,
|
|
1427
|
+
lease: null,
|
|
1428
|
+
outcome: { ok: true, status: mine ? 'released' : 'stale-pruned', lease: verdict.lease },
|
|
1429
|
+
};
|
|
1430
|
+
} });
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1129
1433
|
// A long-lived MCP worker can observe SessionEnd(A) and then receive the first
|
|
1130
1434
|
// request for a brand-new Codex thread B. That is rotation, not a rekey: A's
|
|
1131
1435
|
// tombstone, scope, message audience/receipts, and authorship must remain bound
|