flowviant 0.125.18 → 0.125.20
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/lib/artifactCache.mjs +336 -0
- package/bin/lib/artifactIteration.mjs +1 -1
- package/bin/lib/artifactPolicy.mjs +5 -1
- package/bin/lib/artifactReporter.mjs +89 -7
- package/bin/lib/artifacts.mjs +4 -5
- package/bin/lib/fleet.mjs +9 -0
- package/bin/lib/intermediateArtifacts.mjs +40 -8
- package/bin/lib/knowledgeLibrary.mjs +2 -1
- package/bin/lib/localCleanupReport.mjs +34 -0
- package/bin/lib/localCleanupState.mjs +68 -0
- package/bin/lib/logReplay.mjs +6 -0
- package/bin/lib/managedLocalCleanup.mjs +158 -0
- package/bin/lib/resultInputs.mjs +4 -2
- package/bin/lib/runtimeCodex.mjs +1 -1
- package/bin/lib/work.mjs +45 -3
- package/bin/lib/workAgentPlans.mjs +3 -0
- package/bin/lib/workAgentPrecheck.mjs +16 -15
- package/bin/lib/workAgentReview.mjs +2 -2
- package/bin/lib/workAgentTurnExecution.mjs +26 -2
- package/bin/lib/workAgentTurns.mjs +1 -1
- package/bin/lib/workLocalCleanup.mjs +125 -0
- package/bin/lib/workMergeBatch.mjs +19 -4
- package/bin/lib/workSessionGroups.mjs +20 -1
- package/package.json +1 -1
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/** Versioned local cache ownership. Call mutations under the place writer lock.
|
|
2
|
+
* No age, Library membership, or successful upload proves a file disposable.
|
|
3
|
+
* The lifecycle owner must supply a complete, version-bound use/bundle release. */
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { constants, openSync, closeSync, fsyncSync, fstatSync, readFileSync, writeFileSync, renameSync, unlinkSync, linkSync, rmSync, lstatSync, realpathSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
|
|
8
|
+
|
|
9
|
+
const hash = b => createHash('sha256').update(b).digest('hex');
|
|
10
|
+
const safe = n => typeof n === 'string' && n.length <= 500 && !/[\\\x00-\x1f\x7f]/.test(n) && n.split('/').every(p => p && p !== '.' && p !== '..' && !p.startsWith('.'));
|
|
11
|
+
const sha = s => /^[a-f0-9]{64}$/.test(s ?? '');
|
|
12
|
+
const stamp = s => `${s.dev}:${s.ino}:${s.size}:${s.mtimeMs}:${s.ctimeMs}`;
|
|
13
|
+
const MAX = 32 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
export function createArtifactCache({ placeDir, path, scope, fetchVersion, getDiscards = () => new Set(), getUses = () => [], managedKind = null, managedRoot = null, unlink = unlinkSync }) {
|
|
16
|
+
if (managedKind && !['scratch', 'native-image'].includes(managedKind)) throw new Error('unknown-managed-kind');
|
|
17
|
+
const root = managedKind ? resolve(managedRoot) : resolve(placeDir, '.flowviant/artifacts');
|
|
18
|
+
const relative = name => managedKind ? name : `.flowviant/artifacts/${name}`;
|
|
19
|
+
const pathFor = name => join(root, name);
|
|
20
|
+
const identity = JSON.stringify({ ...scope, placeDir: resolve(placeDir), ...(managedKind ? { managedKind, root } : {}) });
|
|
21
|
+
const load = () => {
|
|
22
|
+
if (!path) throw new Error('ownership-unavailable');
|
|
23
|
+
let fd;
|
|
24
|
+
try {
|
|
25
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
26
|
+
const st = fstatSync(fd);
|
|
27
|
+
if (!st.isFile() || st.size > 2 * 1024 * 1024) throw new Error('ownership-unreadable');
|
|
28
|
+
const state = JSON.parse(readFileSync(fd, 'utf8'));
|
|
29
|
+
if (state.version !== 1 || state.identity !== identity || !Array.isArray(state.files) || state.files.length > 2000 || state.files.some(f =>
|
|
30
|
+
!safe(f.name) || f.relativePath !== relative(f.name) || !sha(f.sha256) || typeof f.localVersion !== 'string' || !['local', 'evicting', 'evicted', 'restoring', 'discarded'].includes(f.state))) throw new Error('ownership-unreadable');
|
|
31
|
+
return state;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
if (e.code === 'ENOENT') {
|
|
34
|
+
try { lstatSync(`${path}.guard`); }
|
|
35
|
+
catch (guardError) {
|
|
36
|
+
if (guardError.code === 'ENOENT') return { version: 1, identity, files: [] };
|
|
37
|
+
throw guardError;
|
|
38
|
+
}
|
|
39
|
+
throw new Error('ownership-ledger-missing');
|
|
40
|
+
}
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
44
|
+
};
|
|
45
|
+
const save = state => {
|
|
46
|
+
if (!path || realDirState(dirname(path)) !== 'dir') throw new Error('ownership-unavailable');
|
|
47
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
48
|
+
try {
|
|
49
|
+
// The independent guard distinguishes a legacy unmeasured place from
|
|
50
|
+
// loss of a ledger that may contain eviction receipts. Persist it first.
|
|
51
|
+
let guard;
|
|
52
|
+
try {
|
|
53
|
+
guard = openSync(`${path}.guard`, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
54
|
+
writeFileSync(guard, identity); fsyncSync(guard);
|
|
55
|
+
} catch (e) { if (e.code !== 'EEXIST') throw e; }
|
|
56
|
+
finally { if (guard !== undefined) closeSync(guard); }
|
|
57
|
+
const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
58
|
+
try { writeFileSync(fd, JSON.stringify(state)); fsyncSync(fd); } finally { closeSync(fd); }
|
|
59
|
+
renameSync(tmp, path);
|
|
60
|
+
const parent = openSync(dirname(path), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
61
|
+
try { fsyncSync(parent); } finally { closeSync(parent); }
|
|
62
|
+
}
|
|
63
|
+
finally { rmSync(tmp, { force: true }); }
|
|
64
|
+
};
|
|
65
|
+
const measure = name => {
|
|
66
|
+
if (!safe(name)) throw new Error('unsafe-path');
|
|
67
|
+
// A registered managed root must be physical, not an alias into a shared
|
|
68
|
+
// home or source tree. Every descendant parent is checked separately.
|
|
69
|
+
if (realpathSync(root) !== root) throw new Error('unsafe-parent');
|
|
70
|
+
let parent = root;
|
|
71
|
+
for (const part of (dirname(name) === '.' ? [] : dirname(name).split('/'))) {
|
|
72
|
+
parent = join(parent, part);
|
|
73
|
+
const kind = realDirState(parent);
|
|
74
|
+
if (kind !== 'dir') throw Object.assign(new Error('unsafe-parent'), { code: kind === 'none' ? 'ENOENT' : 'UNSAFE' });
|
|
75
|
+
}
|
|
76
|
+
let fd;
|
|
77
|
+
try {
|
|
78
|
+
fd = openSync(pathFor(name), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
79
|
+
const st = fstatSync(fd);
|
|
80
|
+
if (!st.isFile() || st.size > MAX) throw new Error('unsafe-file');
|
|
81
|
+
const bytes = readFileSync(fd);
|
|
82
|
+
if (stamp(fstatSync(fd)) !== stamp(st) || bytes.length !== st.size) throw new Error('local-version-changed');
|
|
83
|
+
return { bytes, sha256: hash(bytes), signature: stamp(st), size: st.size };
|
|
84
|
+
} finally { if (fd !== undefined) closeSync(fd); }
|
|
85
|
+
};
|
|
86
|
+
/** Only a measured local version paired with the exact successful upload. */
|
|
87
|
+
const record = ({ name, remote, uses = null }) => {
|
|
88
|
+
const local = measure(name);
|
|
89
|
+
if (!remote?.id || remote.sha256 !== local.sha256) throw new Error('remote-version-unverified');
|
|
90
|
+
const state = load(), old = state.files.find(f => f.name === name);
|
|
91
|
+
if (old?.state !== 'discarded' && old?.signature === local.signature && old.sha256 === local.sha256 && old.remote?.id === remote.id && !old.uses?.includes('pending-upload')) return old;
|
|
92
|
+
const ref = { name, relativePath: relative(name), sha256: local.sha256, bytes: local.size,
|
|
93
|
+
signature: local.signature, localVersion: randomUUID(), remote: { id: remote.id, sha256: remote.sha256 },
|
|
94
|
+
uses, bundle: null, state: 'local' };
|
|
95
|
+
state.files = [...state.files.filter(f => f.name !== name), ref]; save(state); return ref;
|
|
96
|
+
};
|
|
97
|
+
/** A queued upload is owned but unrecoverable until its exact remote id
|
|
98
|
+
* arrives. A crash loses no use pin and grants no eviction permission. */
|
|
99
|
+
const trackUpload = name => {
|
|
100
|
+
const local = measure(name), state = load(), old = state.files.find(f => f.name === name);
|
|
101
|
+
const same = old && old.state !== 'discarded' && old.signature === local.signature && old.sha256 === local.sha256;
|
|
102
|
+
const ref = { name, relativePath: relative(name), sha256: local.sha256, bytes: local.size,
|
|
103
|
+
signature: local.signature, localVersion: same ? old.localVersion : randomUUID(), remote: same ? old.remote : null,
|
|
104
|
+
uses: [...new Set([...(same && Array.isArray(old.uses) ? old.uses : ['requirements-unknown']), 'pending-upload'])],
|
|
105
|
+
bundle: null, state: 'local' };
|
|
106
|
+
state.files = [...state.files.filter(f => f.name !== name), ref]; save(state); return ref;
|
|
107
|
+
};
|
|
108
|
+
const recordManaged = ({ name, turnId, preserved = null }) => {
|
|
109
|
+
if (!managedKind || !turnId) throw new Error('ownership-unknown');
|
|
110
|
+
const local = measure(name), state = load();
|
|
111
|
+
const old = state.files.find(f => f.name === name);
|
|
112
|
+
if (old && old.state !== 'discarded' && old.signature === local.signature && old.sha256 === local.sha256) {
|
|
113
|
+
if (old.turnId !== turnId) {
|
|
114
|
+
old.uses = [...new Set([...(old.uses ?? ['requirements-unknown']), `turn:${turnId}`,
|
|
115
|
+
...(managedKind === 'native-image' ? ['resume-inputs-unknown'] : [])])];
|
|
116
|
+
old.turnId = turnId; old.state = 'local';
|
|
117
|
+
}
|
|
118
|
+
if (preserved && preserved.sha256 === local.sha256) { old.preserved = preserved; old.remote = preserved.remote; }
|
|
119
|
+
save(state); return old;
|
|
120
|
+
}
|
|
121
|
+
if (preserved && preserved.sha256 !== local.sha256) throw new Error('preserved-copy-unknown');
|
|
122
|
+
const ref = { name, relativePath: relative(name), kind: managedKind, turnId,
|
|
123
|
+
sha256: local.sha256, bytes: local.size, signature: local.signature,
|
|
124
|
+
localVersion: randomUUID(), remote: preserved?.remote ?? null, preserved,
|
|
125
|
+
uses: [`turn:${turnId}`, ...(managedKind === 'native-image' ? ['resume-inputs-unknown'] : /\.(png|webp|glb|gltf|bin|obj|mtl|html?|json)$/i.test(name) ? ['required-inputs-unknown'] : [])],
|
|
126
|
+
bundle: null, state: 'local' };
|
|
127
|
+
state.files = [...state.files.filter(f => f.name !== name), ref]; save(state); return ref;
|
|
128
|
+
};
|
|
129
|
+
/** Evidence is version-bound. Null uses/bundle means unknown, never released.
|
|
130
|
+
* Include review/revision, pending uploads, producer/consumer and resume pins.
|
|
131
|
+
* Bundle lists exact versions of every viewer, model and original resource. */
|
|
132
|
+
const requirements = ({ name, localVersion, uses, bundle }) => {
|
|
133
|
+
const state = load(), ref = state.files.find(f => f.name === name && f.localVersion === localVersion);
|
|
134
|
+
if (!ref || ref.state !== 'local') throw new Error('local-version-changed');
|
|
135
|
+
if (managedKind === 'scratch') {
|
|
136
|
+
if (!Array.isArray(uses) || !uses.every(u => typeof u === 'string')) throw new Error('requirements-unknown');
|
|
137
|
+
ref.uses = uses; save(state); return;
|
|
138
|
+
}
|
|
139
|
+
if (!Array.isArray(uses) || !uses.every(u => typeof u === 'string') || !bundle?.complete || !Array.isArray(bundle.files) || !bundle.files.length || bundle.files.length > 256 || new Set(bundle.files.map(f => f.name)).size !== bundle.files.length ||
|
|
140
|
+
!bundle.files.every(f => safe(f.name) && sha(f.sha256) && typeof f.id === 'string') ||
|
|
141
|
+
!bundle.files.some(f => f.name === name && f.id === ref.remote?.id && f.sha256 === ref.sha256)) throw new Error('requirements-unknown');
|
|
142
|
+
ref.uses = uses; ref.bundle = bundle; save(state);
|
|
143
|
+
};
|
|
144
|
+
const verified = async ref => {
|
|
145
|
+
if (!fetchVersion) throw new Error('recovery-unavailable');
|
|
146
|
+
const bytes = Buffer.from(await fetchVersion(ref));
|
|
147
|
+
if (bytes.length > MAX || hash(bytes) !== ref.sha256) throw new Error('remote-version-mismatch');
|
|
148
|
+
return bytes;
|
|
149
|
+
};
|
|
150
|
+
const eligibility = ref => {
|
|
151
|
+
if (!ref || ref.state === 'discarded') return 'ownership-unknown';
|
|
152
|
+
if (!Array.isArray(ref.uses) || (managedKind !== 'scratch' && !ref.bundle?.complete)) return 'requirements-unknown';
|
|
153
|
+
if (ref.uses.length) return 'active-use';
|
|
154
|
+
return null;
|
|
155
|
+
};
|
|
156
|
+
const bundleEligibility = (state, ref) => {
|
|
157
|
+
if (managedKind === 'scratch') return null;
|
|
158
|
+
for (const file of ref.bundle?.files ?? []) {
|
|
159
|
+
const member = state.files.find(f => f.name === file.name && f.sha256 === file.sha256 && f.remote?.id === file.id);
|
|
160
|
+
const reason = eligibility(member);
|
|
161
|
+
if (reason || getUses(file.name).length) return reason ?? 'active-use';
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
};
|
|
165
|
+
/** Non-mutating remote phase, safe outside the place writer lock. The
|
|
166
|
+
* caller may memoize these exact bytes for a brief locked eviction phase. */
|
|
167
|
+
const prepareEviction = async ({ name, localVersion }) => {
|
|
168
|
+
try {
|
|
169
|
+
const state = load(), ref = state.files.find(f => f.name === name && f.localVersion === localVersion);
|
|
170
|
+
if (ref?.state === 'evicted') return { status: 'ready', name }; // bookkeeping only; no new deletion
|
|
171
|
+
const reason = eligibility(ref);
|
|
172
|
+
if (reason || getUses(name).length || getDiscards().has(name)) return { status: 'retained', name, reason: reason ?? 'active-use' };
|
|
173
|
+
const bundleReason = bundleEligibility(state, ref);
|
|
174
|
+
if (bundleReason) return { status: 'retained', name, reason: bundleReason };
|
|
175
|
+
let bytes = 0;
|
|
176
|
+
for (const file of ref.bundle?.files ?? []) {
|
|
177
|
+
bytes += (await verified({ ...file, remote: { id: file.id, sha256: file.sha256 } })).length;
|
|
178
|
+
if (bytes > 80 * 1024 * 1024) throw new Error('bundle-too-large');
|
|
179
|
+
}
|
|
180
|
+
return { status: 'ready', name };
|
|
181
|
+
} catch (error) { return { status: 'retained', name, reason: error.message }; }
|
|
182
|
+
};
|
|
183
|
+
const evict = async ({ name, localVersion }) => {
|
|
184
|
+
let reclaimed = null;
|
|
185
|
+
try {
|
|
186
|
+
let state = load(), ref = state.files.find(f => f.name === name && f.localVersion === localVersion);
|
|
187
|
+
if (getDiscards().has(name)) return { status: 'retained', name, reason: 'explicit-discard' };
|
|
188
|
+
if (getUses(name).length) return { status: 'retained', name, reason: 'active-use' };
|
|
189
|
+
const reason = eligibility(ref); if (reason) return { status: 'retained', name, reason };
|
|
190
|
+
const bundleReason = bundleEligibility(state, ref);
|
|
191
|
+
if (bundleReason) return { status: 'retained', name, reason: bundleReason };
|
|
192
|
+
if (!ref.remote && managedKind !== 'scratch') return { status: 'retained', name, reason: 'pending-upload' };
|
|
193
|
+
if (ref.state === 'restoring') return { status: 'retained', name, reason: 'restoration-pending' };
|
|
194
|
+
if (ref.state === 'evicted') {
|
|
195
|
+
try {
|
|
196
|
+
measure(name);
|
|
197
|
+
// A newly present file is a new local use/version, even at the same
|
|
198
|
+
// bytes. Retire the old eviction meaning under the writer lock.
|
|
199
|
+
ref.state = 'discarded'; save(state);
|
|
200
|
+
return { status: 'retained', name, reason: 'local-version-changed', bytes: 0 };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error.code !== 'ENOENT') throw error;
|
|
203
|
+
return { status: 'retained', name, reason: 'already-evicted', bytes: 0 };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Verify the entire exact bundle before deleting even one member.
|
|
207
|
+
let bundleBytes = 0;
|
|
208
|
+
for (const file of ref.bundle?.files ?? []) {
|
|
209
|
+
if (getUses(file.name).length) return { status: 'retained', name, reason: 'active-use' };
|
|
210
|
+
bundleBytes += (await verified({ ...file, remote: { id: file.id, sha256: file.sha256 } })).length;
|
|
211
|
+
if (bundleBytes > 80 * 1024 * 1024) throw new Error('bundle-too-large');
|
|
212
|
+
}
|
|
213
|
+
// Network waits may have admitted a rewrite, pin or explicit discard.
|
|
214
|
+
state = load(); const current = state.files.find(f => f.name === name);
|
|
215
|
+
if (getDiscards().has(name)) return { status: 'retained', name, reason: 'explicit-discard' };
|
|
216
|
+
if (!current || current.localVersion !== localVersion || eligibility(current) || bundleEligibility(state, current) || JSON.stringify(current.bundle) !== JSON.stringify(ref.bundle))
|
|
217
|
+
return { status: 'retained', name, reason: 'requirements-changed' };
|
|
218
|
+
let local;
|
|
219
|
+
try { local = measure(name); }
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (error.code !== 'ENOENT' || current.state !== 'evicting') throw error;
|
|
222
|
+
// A crash may have happened after unlink but before receipt/result.
|
|
223
|
+
// Never infer reclaimed bytes from absence, and never replay discard.
|
|
224
|
+
current.state = 'evicted'; save(state);
|
|
225
|
+
return { status: 'retained', name, bytes: 0, reason: 'interrupted-unlink-unmeasured', localVersion };
|
|
226
|
+
}
|
|
227
|
+
if (local.signature !== current.signature || local.sha256 !== current.sha256) return { status: 'retained', name, reason: 'local-version-changed' };
|
|
228
|
+
// Persist BEFORE unlink: a restart at either boundary must not report a
|
|
229
|
+
// cloud discard. Identity checks still distinguish a later rewrite.
|
|
230
|
+
current.state = 'evicting'; save(state);
|
|
231
|
+
const check = measure(name);
|
|
232
|
+
if (check.signature !== local.signature || check.sha256 !== local.sha256) return { status: 'retained', name, reason: 'local-version-changed' };
|
|
233
|
+
try { unlink(pathFor(name)); }
|
|
234
|
+
catch (error) { return { status: 'failed', name, localVersion, bytes: 0, reason: 'unlink-failed', error: error.message }; }
|
|
235
|
+
reclaimed = { status: 'reclaimed', name, bytes: local.size, localVersion };
|
|
236
|
+
current.state = 'evicted'; save(state);
|
|
237
|
+
return { status: 'reclaimed', name, bytes: local.size, localVersion };
|
|
238
|
+
} catch (error) { return reclaimed ? { ...reclaimed, incomplete: true, reason: error.message } : { status: 'retained', name, reason: error.message }; }
|
|
239
|
+
};
|
|
240
|
+
const discard = (name, remoteId = null) => {
|
|
241
|
+
const state = load(), ref = state.files.find(f => f.name === name);
|
|
242
|
+
if (ref && (!remoteId || ref.remote?.id === remoteId)) { ref.state = 'discarded'; save(state); }
|
|
243
|
+
};
|
|
244
|
+
/** Missing previously evicted files are not discards, even if the current
|
|
245
|
+
* inventory changed: this place never measured holding that newer remote.
|
|
246
|
+
* Existing changed files invalidate the receipt before a later deletion. */
|
|
247
|
+
const absences = (stored, discards = new Set()) => {
|
|
248
|
+
const state = load(), names = new Set(); let changed = false;
|
|
249
|
+
for (const ref of state.files) {
|
|
250
|
+
if (discards.has(ref.name) || ref.state === 'discarded') continue;
|
|
251
|
+
try {
|
|
252
|
+
const local = measure(ref.name);
|
|
253
|
+
if (local.sha256 !== ref.sha256 || (ref.state !== 'restoring' && local.signature !== ref.signature)) { ref.state = 'discarded'; changed = true; }
|
|
254
|
+
} catch (e) {
|
|
255
|
+
if (e.code === 'ENOENT' && ['evicting', 'evicted', 'restoring'].includes(ref.state)) names.add(ref.name);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (changed) save(state);
|
|
259
|
+
return names;
|
|
260
|
+
};
|
|
261
|
+
/** Restore full bundles before the next consumer. Never overwrite a rewrite.
|
|
262
|
+
* All remote bytes are verified first; partial writes remain retryable. */
|
|
263
|
+
const restore = async (discards = new Set()) => {
|
|
264
|
+
if (managedKind) throw new Error('managed-source-is-not-an-output-cache');
|
|
265
|
+
const state = load(), restored = [];
|
|
266
|
+
for (const original of state.files) {
|
|
267
|
+
const ref = load().files.find(f => f.name === original.name);
|
|
268
|
+
if (!ref || !['evicting', 'evicted', 'restoring'].includes(ref.state) || discards.has(ref.name)) continue;
|
|
269
|
+
try {
|
|
270
|
+
const local = measure(ref.name);
|
|
271
|
+
// A later rewrite belongs to the writer; invalidate the old receipt.
|
|
272
|
+
if (local.sha256 !== ref.sha256 || (ref.state !== 'restoring' && local.signature !== ref.signature)) {
|
|
273
|
+
const latest = load(), current = latest.files.find(f => f.name === ref.name);
|
|
274
|
+
if (current?.localVersion === ref.localVersion) { current.state = 'discarded'; save(latest); }
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
} catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
278
|
+
const bundle = ref.bundle?.files;
|
|
279
|
+
if (!bundle?.length) throw new Error('bundle-unknown');
|
|
280
|
+
if (bundle.some(f => discards.has(f.name))) throw new Error('bundle-explicitly-discarded');
|
|
281
|
+
const files = []; let total = 0;
|
|
282
|
+
for (const file of bundle) {
|
|
283
|
+
const bytes = await verified({ ...file, remote: { id: file.id, sha256: file.sha256 } });
|
|
284
|
+
total += bytes.length;
|
|
285
|
+
if (total > 80 * 1024 * 1024) throw new Error('bundle-too-large');
|
|
286
|
+
files.push({ ...file, bytes });
|
|
287
|
+
}
|
|
288
|
+
const writeState = load(), writing = writeState.files.find(f => f.name === ref.name);
|
|
289
|
+
if (files.some(f => getDiscards().has(f.name)) || writing?.state === 'discarded') throw new Error('bundle-explicitly-discarded');
|
|
290
|
+
if (writing?.localVersion !== ref.localVersion) throw new Error('local-version-changed');
|
|
291
|
+
for (const file of files) {
|
|
292
|
+
const member = writeState.files.find(f => f.name === file.name);
|
|
293
|
+
try {
|
|
294
|
+
const local = measure(file.name);
|
|
295
|
+
if (local.sha256 !== file.sha256 || (member && member.state !== 'restoring' && member.signature !== local.signature)) throw new Error('local-version-changed');
|
|
296
|
+
} catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
297
|
+
}
|
|
298
|
+
// A separate intent distinguishes our partially restored bytes from a
|
|
299
|
+
// later same-content rewrite of an ordinary evicted version.
|
|
300
|
+
for (const file of files) {
|
|
301
|
+
const member = writeState.files.find(f => f.name === file.name);
|
|
302
|
+
if (member && ['evicting', 'evicted'].includes(member.state) && member.sha256 === file.sha256 && member.remote?.id === file.id) member.state = 'restoring';
|
|
303
|
+
}
|
|
304
|
+
save(writeState);
|
|
305
|
+
for (const file of files) {
|
|
306
|
+
const recorded = load().files.find(f => f.name === file.name);
|
|
307
|
+
if (recorded && (recorded.state === 'discarded' || recorded.sha256 !== file.sha256 || recorded.remote?.id !== file.id)) throw new Error('local-version-changed');
|
|
308
|
+
// A newer local version is never replaced with the old producer.
|
|
309
|
+
try { if (measure(file.name).sha256 !== file.sha256) throw new Error('local-version-changed'); continue; }
|
|
310
|
+
catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
311
|
+
const relative = `.flowviant/artifacts/${file.name}`;
|
|
312
|
+
if (!ensureRealSubdir(placeDir, dirname(relative))) throw new Error('unsafe-parent');
|
|
313
|
+
const target = join(placeDir, relative), tmp = `${target}.${randomUUID()}.tmp`;
|
|
314
|
+
try {
|
|
315
|
+
const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
316
|
+
try { writeFileSync(fd, file.bytes); fsyncSync(fd); } finally { closeSync(fd); }
|
|
317
|
+
// link is exclusive: a racing writer wins; rename would overwrite it.
|
|
318
|
+
linkSync(tmp, target);
|
|
319
|
+
const parent = openSync(dirname(target), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
320
|
+
try { fsyncSync(parent); } finally { closeSync(parent); }
|
|
321
|
+
} finally { rmSync(tmp, { force: true }); }
|
|
322
|
+
restored.push(file.name);
|
|
323
|
+
}
|
|
324
|
+
// Keep the durable eviction meaning until the full bundle is restored.
|
|
325
|
+
const latest = load(), current = latest.files.find(f => f.name === ref.name);
|
|
326
|
+
if (current?.localVersion !== ref.localVersion || current.state === 'discarded') throw new Error('requirements-changed');
|
|
327
|
+
for (const file of files) {
|
|
328
|
+
const owned = latest.files.find(f => f.name === file.name && f.remote?.id === file.id && f.sha256 === file.sha256);
|
|
329
|
+
if (owned && owned.state !== 'discarded') { owned.state = 'local'; owned.signature = measure(file.name).signature; owned.uses = null; }
|
|
330
|
+
}
|
|
331
|
+
save(latest);
|
|
332
|
+
}
|
|
333
|
+
return restored;
|
|
334
|
+
};
|
|
335
|
+
return { record, recordManaged, verifyRemote: verified, prepareEviction, trackUpload, requirements, evict, restore, discard, absences, records: () => load().files, measure };
|
|
336
|
+
}
|
|
@@ -144,7 +144,7 @@ export function deletedArtifacts(placeDir, before) {
|
|
|
144
144
|
// Only names this place held are candidates: this turn's snapshot and the
|
|
145
145
|
// place's record (`before.seen`). Normalize live names before probing: a
|
|
146
146
|
// draft+1.md is stored as draft_1.md and is not an absent retained file.
|
|
147
|
-
const candidates = new Set([...(before?.keys() ?? []), ...(before?.seen ?? [])]);
|
|
147
|
+
const candidates = new Set([...(before?.keys() ?? []), ...(before?.seen ?? [])].filter(name => !before?.evicted?.has(artifactStoredName(name))));
|
|
148
148
|
const live = presentArtifacts(placeDir);
|
|
149
149
|
if (!live) {
|
|
150
150
|
return flowviantParentState(placeDir) === 'dir' && realDirState(join(placeDir, ARTIFACT_DIR)) === 'none'
|
|
@@ -13,13 +13,17 @@ export const ARTIFACT_POLICY_SOURCE = 'packages/shared/src/schemas/artifact.sche
|
|
|
13
13
|
|
|
14
14
|
/** Per text file and ordinary binary artifact. */
|
|
15
15
|
export const ARTIFACT_MAX_BYTES = 2097152;
|
|
16
|
+
/** Above this size, upload binary bytes as a stream rather than multipart. */
|
|
17
|
+
export const ARTIFACT_MULTIPART_MAX_BYTES = 20971520;
|
|
16
18
|
/** Binary model buffers (the extensions below). */
|
|
17
|
-
export const ARTIFACT_BINARY_MODEL_MAX_BYTES =
|
|
19
|
+
export const ARTIFACT_BINARY_MODEL_MAX_BYTES = 33554432;
|
|
18
20
|
export const ARTIFACT_BINARY_MODEL_EXTS = ['glb', 'bin', 'png', 'webp'];
|
|
19
21
|
/** Per Terminal session, and the per-turn discovery window. */
|
|
20
22
|
export const ARTIFACT_MAX_PER_OWNER = 40;
|
|
21
23
|
/** Per agent — the server keeps as many for one agent. */
|
|
22
24
|
export const ARTIFACT_MAX_PER_AGENT = 400;
|
|
25
|
+
/** Selected result inputs, including exact embedded model textures. */
|
|
26
|
+
export const RESULT_INPUT_MAX_FILES = 256;
|
|
23
27
|
|
|
24
28
|
/** Extension → the one mime the server serves it under; `text` is scrubbed. */
|
|
25
29
|
export const ARTIFACT_TYPES = {
|
|
@@ -37,10 +37,12 @@
|
|
|
37
37
|
* with `includeNames`, so it never attributes a sibling card's files.
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
+
import { createArtifactCache } from './artifactCache.mjs';
|
|
40
41
|
import { artifactStoredName, artifactTypeFor, scanArtifacts } from './artifacts.mjs';
|
|
42
|
+
import { ARTIFACT_MULTIPART_MAX_BYTES } from './artifactPolicy.mjs';
|
|
41
43
|
import { fleetHeaders } from './fleetPost.mjs';
|
|
42
44
|
import {
|
|
43
|
-
imageRevisionProtection, iterationSignature, iterationChanges, deletedArtifacts, localArtifact, discardLocalArtifact, iterationInstructions,
|
|
45
|
+
declaredDiscards, imageRevisionProtection, iterationSignature, iterationChanges, deletedArtifacts, localArtifact, discardLocalArtifact, iterationInstructions,
|
|
44
46
|
presentArtifacts, readSeenArtifacts, writeSeenArtifacts, seenArtifacts, serverBornAt,
|
|
45
47
|
} from './artifactIteration.mjs';
|
|
46
48
|
import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
|
|
@@ -74,17 +76,64 @@ export function createArtifactReporter({
|
|
|
74
76
|
* checkout, anything unreadable) gives a place without a record no
|
|
75
77
|
* legacy evidence at all. */
|
|
76
78
|
placeBornAt = () => null,
|
|
79
|
+
projectIdentity = fleetUrl,
|
|
77
80
|
}) {
|
|
78
81
|
const url = fleetEndpoint('artifact', fleetUrl);
|
|
79
82
|
const doFetch = fetchImpl ?? ((...a) => fetch(...a));
|
|
80
83
|
const pending = new Map(); // `${owner}:${name}` -> { body, tries }
|
|
81
84
|
|
|
82
|
-
const
|
|
85
|
+
const cacheFor = ({ placeDir, sessionId, agentId, cleanupGuard = null, recoveryMemo = null, preparedOnly = () => false }) => {
|
|
86
|
+
const privatePath = seenPath(placeDir, sessionId ?? agentId);
|
|
87
|
+
const project = typeof projectIdentity === 'function' ? projectIdentity() : projectIdentity;
|
|
88
|
+
if (!privatePath || !project) return null;
|
|
89
|
+
return createArtifactCache({ placeDir, path: `${privatePath}.cache`,
|
|
90
|
+
getDiscards: () => declaredDiscards(placeDir),
|
|
91
|
+
getUses: name => (cleanupGuard && cleanupGuard() !== true) || pending.has(`${sessionId ?? agentId}:${name}`) || chains.has(`${sessionId ?? agentId}:${name}`) ? ['pending-upload-or-removal'] : [],
|
|
92
|
+
scope: { projectId: project, ...(sessionId ? { sessionId } : { agentId }) },
|
|
93
|
+
fetchVersion: async ref => {
|
|
94
|
+
const key = `${ref.remote.id}:${ref.sha256}`, cached = recoveryMemo?.get(key);
|
|
95
|
+
if (cached && Date.now() >= cached.at && Date.now() - cached.at < 5_000) return cached.bytes;
|
|
96
|
+
if (preparedOnly()) throw new Error('recovery-verification-expired');
|
|
97
|
+
const query = new URLSearchParams({ ...(sessionId ? { sessionId } : { agentId }), sha256: ref.sha256 });
|
|
98
|
+
const response = await doFetch(`${url}/${encodeURIComponent(ref.remote.id)}/recover?${query}`, {
|
|
99
|
+
method: 'GET', headers: fleetHeaders({ token, json: false }), signal: AbortSignal.timeout(cleanupGuard ? 5_000 : 30_000),
|
|
100
|
+
});
|
|
101
|
+
if (!response.ok) throw new Error(`recovery-unavailable-http-${response.status}`);
|
|
102
|
+
// Bound even an incompatible server's response before buffering it.
|
|
103
|
+
const reader = response.body?.getReader();
|
|
104
|
+
if (!reader) throw new Error('recovery-unreadable');
|
|
105
|
+
const chunks = []; let size = 0;
|
|
106
|
+
try {
|
|
107
|
+
for (;;) {
|
|
108
|
+
const { done, value } = await reader.read(); if (done) break;
|
|
109
|
+
size += value.length;
|
|
110
|
+
if (size > 32 * 1024 * 1024) throw new Error('recovery-too-large');
|
|
111
|
+
chunks.push(Buffer.from(value));
|
|
112
|
+
}
|
|
113
|
+
} finally { await reader.cancel().catch(() => {}); }
|
|
114
|
+
const bytes = Buffer.concat(chunks);
|
|
115
|
+
recoveryMemo?.set(key, { bytes, at: Date.now() });
|
|
116
|
+
return bytes;
|
|
117
|
+
} });
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const prepare = async ({ placeDir, sessionId, agentId, before, idle = false }) => {
|
|
83
121
|
if (!before) return '';
|
|
84
122
|
before.iterationSignature = iterationSignature(placeDir);
|
|
85
123
|
delete before.stored;
|
|
86
124
|
delete before.seen;
|
|
87
125
|
before.inventoryFailed = false;
|
|
126
|
+
const cache = cacheFor({ placeDir, sessionId, agentId });
|
|
127
|
+
if (cache && !idle) {
|
|
128
|
+
// Consumer preparation is outside inventory's best-effort catch: exact
|
|
129
|
+
// recovery failure must stop this turn from consuming missing inputs.
|
|
130
|
+
const restored = await cache.restore(declaredDiscards(placeDir));
|
|
131
|
+
const snapshot = scanArtifacts(placeDir, { all: true });
|
|
132
|
+
for (const name of restored) {
|
|
133
|
+
const entry = snapshot.find(e => e.name === name);
|
|
134
|
+
if (entry) before.set(name, `${entry.size}:${entry.mtimeMs}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
88
137
|
const query = new URLSearchParams(sessionId ? { sessionId } : { agentId });
|
|
89
138
|
try {
|
|
90
139
|
const sentAt = Date.now();
|
|
@@ -100,6 +149,11 @@ export function createArtifactReporter({
|
|
|
100
149
|
const result = await response.json();
|
|
101
150
|
if (!Array.isArray(result.data)) { before.inventoryFailed = true; return ''; }
|
|
102
151
|
before.stored = result.data.filter((a) => a && typeof a.id === 'string' && typeof a.name === 'string');
|
|
152
|
+
if (cache) for (const remote of before.stored) {
|
|
153
|
+
if (!/^[a-f0-9]{64}$/.test(remote.sha256 ?? '')) continue;
|
|
154
|
+
try { cache.record({ name: remote.name, remote }); }
|
|
155
|
+
catch { /* Unknown, missing or scrubbed local bytes are never owned. */ }
|
|
156
|
+
}
|
|
103
157
|
const path = seenPath(placeDir, sessionId ?? agentId);
|
|
104
158
|
before.seen = seenArtifacts(readSeenArtifacts(path), before.stored, presentArtifacts(placeDir) ?? new Set(),
|
|
105
159
|
serverBornAt(placeBornAt(placeDir), response.headers?.get?.('date'), sentAt));
|
|
@@ -130,14 +184,22 @@ export function createArtifactReporter({
|
|
|
130
184
|
}
|
|
131
185
|
const form = new FormData();
|
|
132
186
|
for (const [k, v] of Object.entries(body.fields)) form.set(k, v);
|
|
133
|
-
|
|
187
|
+
const streamed = body.bytes?.length > ARTIFACT_MULTIPART_MAX_BYTES;
|
|
188
|
+
if (body.bytes && !streamed) form.set('file', new Blob([body.bytes]), body.fields.name);
|
|
134
189
|
if (body.preview) form.set('preview', new Blob([body.preview], { type: 'image/png' }), 'preview.png');
|
|
135
190
|
try {
|
|
136
191
|
const res = await doFetch(url, {
|
|
137
192
|
method: 'POST',
|
|
138
|
-
headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent
|
|
193
|
+
headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent,
|
|
194
|
+
...(streamed ? {
|
|
195
|
+
'Content-Type': 'application/octet-stream',
|
|
196
|
+
'Content-Length': String(body.bytes.length),
|
|
197
|
+
'X-Flowviant-Artifact': JSON.stringify(body.fields),
|
|
198
|
+
'X-Content-Sha256': body.fields.sha256,
|
|
199
|
+
} : {}),
|
|
200
|
+
},
|
|
139
201
|
signal: AbortSignal.timeout(30_000),
|
|
140
|
-
body: form,
|
|
202
|
+
body: streamed ? body.bytes : form,
|
|
141
203
|
});
|
|
142
204
|
if (res.ok) {
|
|
143
205
|
const result = typeof res.json === 'function' ? await res.json().catch(() => null) : null;
|
|
@@ -186,6 +248,15 @@ export function createArtifactReporter({
|
|
|
186
248
|
if (genOf.get(key) !== gen) return; // a newer copy decides for this key
|
|
187
249
|
if (result.settled) pending.delete(key);
|
|
188
250
|
else hold(key, body, tries + 1);
|
|
251
|
+
if (result.success && body.cacheInfo && result.id && body.bytes) {
|
|
252
|
+
try { cacheFor(body.cacheInfo)?.record({ name: body.fields.name,
|
|
253
|
+
remote: { id: result.id, sha256: body.fields.sha256 } }); }
|
|
254
|
+
catch (error) { log(`artifact ${body.fields.name}: local cache retained — ${error.message}`); }
|
|
255
|
+
}
|
|
256
|
+
if (result.success && body.removal) {
|
|
257
|
+
try { cacheFor(body.cacheInfo ?? { placeDir: body.placeDir, ...body.removal })?.discard(body.fields.name, body.removal.id); }
|
|
258
|
+
catch (error) { log(`artifact ${body.fields.name}: cache discard bookkeeping incomplete — ${error.message}`); }
|
|
259
|
+
}
|
|
189
260
|
if (result.success && body.replacements?.length) {
|
|
190
261
|
result.cleanupFailed = [];
|
|
191
262
|
for (const removal of body.replacements) {
|
|
@@ -239,6 +310,14 @@ export function createArtifactReporter({
|
|
|
239
310
|
for (const name of [...changes.remove, ...Object.keys(changes.replace)]) if (protectedNames.has(name)) throw new Error(`Image revision retained ${name}; its discard or replacement was not authorized.`);
|
|
240
311
|
}
|
|
241
312
|
if (!before?.stored && (changes.remove.length || Object.keys(changes.replace).length)) throw new Error('Stored artifact inventory could not be measured; removal was not attempted.');
|
|
313
|
+
// An unreadable eviction ledger is unknown evidence, never a discard.
|
|
314
|
+
const cache = cacheFor({ placeDir, sessionId, agentId });
|
|
315
|
+
// A standing replacement is not an unconditional remove: after restart
|
|
316
|
+
// its predecessor stays cached until this report has delivery evidence.
|
|
317
|
+
const standing = iterationChanges(placeDir, null);
|
|
318
|
+
const explicitAbsences = new Set([...standing.remove, ...Object.keys(changes.replace)]);
|
|
319
|
+
try { if (before) before.evicted = cache?.absences(before?.stored ?? [], explicitAbsences) ?? new Set(); }
|
|
320
|
+
catch { if (before) before.evicted = new Set((before?.stored ?? []).map(a => a.name)); }
|
|
242
321
|
const removing = new Set([...changes.remove, ...Object.keys(changes.replace), ...(before?.stored ? deletedArtifacts(placeDir, before).filter(name => !protectedNames.has(name)) : [])]);
|
|
243
322
|
const stored = new Map((before?.stored ?? []).map((a) => [a.name, a]));
|
|
244
323
|
for (const name of [...changes.remove, ...Object.keys(changes.replace)]) {
|
|
@@ -292,6 +371,9 @@ export function createArtifactReporter({
|
|
|
292
371
|
body.fields.renderState = measured.renderState;
|
|
293
372
|
if (measured.preview) body.preview = measured.preview;
|
|
294
373
|
}
|
|
374
|
+
body.cacheInfo = { placeDir, sessionId, agentId };
|
|
375
|
+
try { cache?.trackUpload(body.fields.name); }
|
|
376
|
+
catch (error) { log(`artifact ${body.fields.name}: ownership unavailable — ${error.message}`); }
|
|
295
377
|
body.replacements = Object.entries(changes.replace).filter(([, to]) => to === body.fields.name).map(([name]) => {
|
|
296
378
|
const key = `${sessionId ?? agentId}:${name}`;
|
|
297
379
|
return { fields: { name }, removal: { ...(sessionId ? { sessionId } : { agentId }), id: stored.get(name).id, name },
|
|
@@ -377,9 +459,9 @@ export function createArtifactReporter({
|
|
|
377
459
|
*/
|
|
378
460
|
const reconcile = async ({ placeDir, before, sessionId, agentId }) => {
|
|
379
461
|
if (!placeDir || !before || (!sessionId && !agentId)) return 0;
|
|
380
|
-
await prepare({ placeDir, before, sessionId, agentId });
|
|
462
|
+
await prepare({ placeDir, before, sessionId, agentId, idle: true });
|
|
381
463
|
return report({ placeDir, before, sessionId, agentId, idle: true });
|
|
382
464
|
};
|
|
383
465
|
|
|
384
|
-
return { prepare, report, reconcile, retryPending, pendingCount: () => pending.size };
|
|
466
|
+
return { prepare, report, reconcile, cacheFor, retryPending, pendingCount: () => pending.size };
|
|
385
467
|
}
|
package/bin/lib/artifacts.mjs
CHANGED
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
* that cannot show an artifact must not have the CLI told it will.
|
|
75
75
|
*/
|
|
76
76
|
|
|
77
|
-
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync
|
|
77
|
+
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync } from 'node:fs';
|
|
78
78
|
import { createHash } from 'node:crypto';
|
|
79
79
|
import { join } from 'node:path';
|
|
80
80
|
import { cutKeepingExtension } from './safeFileName.mjs';
|
|
@@ -138,7 +138,7 @@ export function ensureArtifactDir(placeDir) {
|
|
|
138
138
|
* points both variables at it (runtimeCodex.mjs `codexDesignFence`), so the
|
|
139
139
|
* shell works like a shell; nothing here is uploaded, committed or read back.
|
|
140
140
|
*
|
|
141
|
-
*
|
|
141
|
+
* Created before each turn without erasing unrecorded or still-required files, and
|
|
142
142
|
* only as a real directory under a real `.flowviant` — the artifacts
|
|
143
143
|
* directory's rule, for its reason: a fence grant on a path through a
|
|
144
144
|
* committed symlink is a write grant on its target. False creates nothing
|
|
@@ -149,9 +149,8 @@ export function ensureFenceScratch(placeDir) {
|
|
|
149
149
|
const dir = join(placeDir, FENCE_TMP_DIR);
|
|
150
150
|
if (flowviantParentState(placeDir) !== 'dir' || realDirState(dir) === 'other') return false;
|
|
151
151
|
try {
|
|
152
|
-
//
|
|
153
|
-
if (realDirState(dir) === '
|
|
154
|
-
mkdirSync(dir);
|
|
152
|
+
// Reclamation requires a versioned ownership record and released uses.
|
|
153
|
+
if (realDirState(dir) === 'none') mkdirSync(dir);
|
|
155
154
|
} catch {
|
|
156
155
|
return false;
|
|
157
156
|
}
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -278,6 +278,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
278
278
|
retireWorkSessions,
|
|
279
279
|
reportWorktrees,
|
|
280
280
|
reconcileIdleArtifacts,
|
|
281
|
+
reconcileLocalCleanup,
|
|
281
282
|
shutdownWork,
|
|
282
283
|
settleAgentTurns,
|
|
283
284
|
workBusy,
|
|
@@ -291,6 +292,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
291
292
|
getBaseRef,
|
|
292
293
|
getMcpUrl: () => mcpUrl,
|
|
293
294
|
getLeaseTtl: () => leaseTtlSeconds,
|
|
295
|
+
getCleanupPermission: () => holderState === 'mine' && !standingDown() && !readMaintenance(),
|
|
294
296
|
/**
|
|
295
297
|
* The cartographer is a CLI turn too, and it is the one this manager cannot
|
|
296
298
|
* see — it lives in the wiki runner (wikiRunner.mjs), not in
|
|
@@ -649,6 +651,13 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
649
651
|
// once per process, beside no turn (workArtifactReconcile.mjs). After the
|
|
650
652
|
// turn intake above, so a card about to run in a place is not raced.
|
|
651
653
|
void reconcileIdleArtifacts(roster.activeWorkSessions)?.catch(() => {});
|
|
654
|
+
// First authoritative serving roster triggers startup recovery; later
|
|
655
|
+
// idle beats are bounded internally. Refresh evidence on every roster so
|
|
656
|
+
// an in-flight remote check cannot outlive ownership or a peer lease.
|
|
657
|
+
void reconcileLocalCleanup({ activeIds: roster.activeWorkSessions,
|
|
658
|
+
heldElsewhere: roster.sessionsHeldElsewhere, serving: holderState === 'mine',
|
|
659
|
+
draining: standingDown() || Boolean(readMaintenance()),
|
|
660
|
+
});
|
|
652
661
|
// Terminal-session presence, throttled + dedup'd inside; never awaited —
|
|
653
662
|
// the daemon's own worktrees are carved out (a session the daemon spawned
|
|
654
663
|
// is already a tab, not something to offer adopting).
|