@playola-radio/slipstream 0.1.0 → 0.3.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/README.md +427 -101
- package/SKILL.md +4 -1
- package/contracts/interface/v2/schema.json +3 -3
- package/dist/attach-workflow.js +557 -0
- package/dist/attribution-engine.js +2 -3
- package/dist/attribution-scoring.js +70 -13
- package/dist/boundary-registry.js +25 -0
- package/dist/capture-intent.js +70 -0
- package/dist/capture-scope.js +135 -0
- package/dist/cas.js +61 -14
- package/dist/claude-root-transcript.js +18 -11
- package/dist/cli.js +426 -26
- package/dist/clip-function-parser.js +9 -2
- package/dist/clip-projection-service.js +28 -0
- package/dist/clip-projection-worker.js +4 -0
- package/dist/clip-worker-pool.js +13 -1
- package/dist/codex-root-transcript.js +46 -0
- package/dist/conductor-settings.js +178 -0
- package/dist/conductor.js +313 -0
- package/dist/control-protocol.js +2 -1
- package/dist/daemon.js +1718 -470
- package/dist/engine.js +26 -10
- package/dist/ensure-daemon.js +30 -0
- package/dist/http-reader.js +20 -8
- package/dist/intent-store.js +196 -0
- package/dist/interface-range-resolver.js +28 -1
- package/dist/launch-agent.js +179 -0
- package/dist/lock.js +1 -1
- package/dist/public-events.js +26 -2
- package/dist/question-hook.js +11 -1
- package/dist/questions.js +102 -3
- package/dist/recovery.js +62 -6
- package/dist/session.js +604 -104
- package/dist/store-reader.js +112 -7
- package/dist/task-forwarder.js +8 -8
- package/dist/transcript/discovery.js +242 -111
- package/dist/transcript/fs-io.js +10 -1
- package/dist/transcript/scheduler.js +114 -0
- package/dist/transcript/watcher.js +5 -3
- package/dist/withdraw-workflow.js +106 -0
- package/dist/workspace.js +133 -0
- package/package.json +3 -2
- package/schemas/slipstream.agent.binding.v1.json +90 -0
- package/schemas/slipstream.agent.connection.v1.json +34 -0
- package/schemas/slipstream.agent.unlinked.v1.json +68 -0
- package/schemas/slipstream.capture.gap.v1.json +1 -0
- package/schemas/slipstream.capture.ready.v1.json +55 -0
- package/schemas/slipstream.capture.scope.v1.json +85 -0
- package/schemas/slipstream.capture.stopped.v1.json +65 -0
- package/schemas/slipstream.question.dispatch_attempted.v1.json +11 -1
- package/schemas/slipstream.question.exchange_recorded.v1.json +65 -0
- package/schemas/slipstream.question.relay_updated.v1.json +64 -0
- package/schemas/slipstream.session.resumed.v1.json +4 -1
- package/schemas/slipstream.session.started.v1.json +4 -1
- package/dist/transcript/runner.js +0 -62
package/dist/daemon.js
CHANGED
|
@@ -1,79 +1,66 @@
|
|
|
1
1
|
import { createServer, connect } from 'node:net';
|
|
2
|
-
import { chmod, lstat, unlink, realpath,
|
|
3
|
-
import { constants } from 'node:fs';
|
|
2
|
+
import { chmod, lstat, unlink, realpath, stat } from 'node:fs/promises';
|
|
4
3
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { startCapture, InvalidTitleError } from "./session.js";
|
|
7
|
-
import {
|
|
4
|
+
import { join, sep } from 'node:path';
|
|
5
|
+
import { startCapture, InvalidTitleError, RootChangedError } from "./session.js";
|
|
6
|
+
import { createTranscriptScheduler } from "./transcript/scheduler.js";
|
|
7
|
+
import { QuestionError, assertAnswerText, normalizeRelayUpdate } from "./questions.js";
|
|
8
8
|
import { verifyClaudeRootTranscript } from "./claude-root-transcript.js";
|
|
9
|
+
import { verifyCodexRootTranscript } from "./codex-root-transcript.js";
|
|
9
10
|
import { StorageError, mkdirpDurable, assertOwnerOnly } from "./storage.js";
|
|
10
|
-
import { acquireSessionLock, SessionOwnedError } from "./lock.js";
|
|
11
|
+
import { acquireSessionLock, SessionOwnedError, STALE_MS } from "./lock.js";
|
|
11
12
|
import { startReaderServer } from "./http-reader.js";
|
|
12
|
-
import { isValidSessionId, listSessions } from "./store-reader.js";
|
|
13
|
+
import { isValidSessionId, listSessions, findWorkspaceSession, finalRecord, readTombstone, sessionLogPath, WorkspaceLookupError, } from "./store-reader.js";
|
|
14
|
+
import { CorruptLogError } from "./recovery.js";
|
|
15
|
+
import { LogCorruptError } from "./log-reader.js";
|
|
13
16
|
import { publishTombstone, removeSessionHistory, sessionExists, reclaimUnreferencedBlobs, } from "./maintenance.js";
|
|
14
17
|
import { createBoundaryRegistry } from "./boundary-registry.js";
|
|
18
|
+
import { createCas } from "./cas.js";
|
|
19
|
+
import { MAX_BINDINGS, MAX_CAPTURES, admitCapture, emptyIntent, findCapture, findLinked, forgetSession, linkBinding, linkedCount, revokeBinding, runningCount, withdrawCapture, } from "./capture-intent.js";
|
|
20
|
+
import { loadIntent, saveIntent } from "./intent-store.js";
|
|
15
21
|
import { liveBoundary } from "./reader-runtime.js";
|
|
16
22
|
import { createLineDecoder, encodeMessage, } from "./control-protocol.js";
|
|
17
23
|
const HARNESSES = ['claude-code', 'codex'];
|
|
18
|
-
const
|
|
19
|
-
const
|
|
24
|
+
const STORE_LOCK_WAIT_MS = 2_000;
|
|
25
|
+
const STORE_LOCK_POLL_MS = 50;
|
|
26
|
+
const RETRY_BACKOFF = { baseMs: 2_000, maxMs: 5 * 60_000 };
|
|
27
|
+
const SETUP_REOFFER_MS = 30_000;
|
|
28
|
+
const UNAVAILABLE_BY_CLAUDE_REASON = {
|
|
29
|
+
'unavailable': 'transcript-unreadable', 'not-yet': 'transcript-unreadable', 'gap': 'transcript-unreadable',
|
|
30
|
+
'mismatch': 'identity-changed',
|
|
31
|
+
};
|
|
20
32
|
class RootIdentityError extends Error {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
27
|
-
let bytes;
|
|
28
|
-
try {
|
|
29
|
-
if (!(await handle.stat()).isFile())
|
|
30
|
-
throw new Error('transcript is not a file');
|
|
31
|
-
const buffer = Buffer.alloc(MAX_CODEX_META_BYTES + 1);
|
|
32
|
-
let total = 0;
|
|
33
|
-
let end = -1;
|
|
34
|
-
while (total < buffer.length && end < 0) {
|
|
35
|
-
const { bytesRead } = await handle.read(buffer, total, buffer.length - total, total);
|
|
36
|
-
if (bytesRead === 0)
|
|
37
|
-
break;
|
|
38
|
-
end = buffer.subarray(total, total + bytesRead).indexOf(0x0a);
|
|
39
|
-
if (end >= 0)
|
|
40
|
-
end += total;
|
|
41
|
-
total += bytesRead;
|
|
42
|
-
}
|
|
43
|
-
if (end < 0 || end > MAX_CODEX_META_BYTES)
|
|
44
|
-
throw new Error('transcript metadata exceeds limit');
|
|
45
|
-
bytes = buffer.subarray(0, end);
|
|
46
|
-
}
|
|
47
|
-
finally {
|
|
48
|
-
await handle.close();
|
|
49
|
-
}
|
|
50
|
-
const record = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
51
|
-
const payload = record.payload;
|
|
52
|
-
if (record.type !== 'session_meta' || !payload
|
|
53
|
-
|| payload.originator !== 'codex_sdk_ts'
|
|
54
|
-
|| (payload.source !== 'exec' && payload.source !== 'vscode')
|
|
55
|
-
|| !SUPPORTED_CODEX_VERSIONS.has(String(payload.cli_version))
|
|
56
|
-
|| payload.session_id !== sessionId || typeof payload.cwd !== 'string'
|
|
57
|
-
|| await realpath(payload.cwd) !== worktree)
|
|
58
|
-
throw new Error('transcript metadata does not match selected root');
|
|
33
|
+
reason;
|
|
34
|
+
constructor(reason, message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.reason = reason;
|
|
59
37
|
}
|
|
60
|
-
|
|
61
|
-
|
|
38
|
+
}
|
|
39
|
+
/** The intent record could not be written. Every later lifecycle change is
|
|
40
|
+
* refused: acknowledging one the record does not hold would be a lie after a
|
|
41
|
+
* restart. */
|
|
42
|
+
class IntentWriteError extends Error {
|
|
43
|
+
constructor(cause) {
|
|
44
|
+
super(`the capture intent record could not be written (${cause.message}); `
|
|
45
|
+
+ 'this daemon refuses attach, detach and unlink until it is restarted', { cause });
|
|
46
|
+
this.name = 'IntentWriteError';
|
|
62
47
|
}
|
|
63
48
|
}
|
|
64
|
-
/** Build the
|
|
65
|
-
* the operator declared `configured` are read.
|
|
66
|
-
|
|
49
|
+
/** Build the daemon's one transcript scheduler from resolved config: only harnesses
|
|
50
|
+
* the operator declared `configured` are read, and with none there is no scheduler.
|
|
51
|
+
* Whether a chat is linked plays no part (spec unit 8). */
|
|
52
|
+
function transcriptSchedulerFrom(config) {
|
|
67
53
|
if (!config)
|
|
68
54
|
return undefined;
|
|
69
55
|
const harnesses = HARNESSES.filter((h) => config.policy.sources[h] === 'configured');
|
|
70
56
|
if (harnesses.length === 0)
|
|
71
57
|
return undefined;
|
|
72
|
-
return {
|
|
58
|
+
return createTranscriptScheduler({
|
|
73
59
|
harnesses,
|
|
74
60
|
homes: config.transcript.homes,
|
|
75
61
|
codexScanLimit: config.transcript.codexScanLimit,
|
|
76
|
-
|
|
62
|
+
onError: (err) => console.error(`slipstream: transcript watcher error: ${err.message}`),
|
|
63
|
+
});
|
|
77
64
|
}
|
|
78
65
|
export class DaemonAlreadyRunningError extends Error {
|
|
79
66
|
constructor(socketPath) {
|
|
@@ -148,9 +135,33 @@ async function bindControl(server, socketPath) {
|
|
|
148
135
|
return;
|
|
149
136
|
}
|
|
150
137
|
}
|
|
138
|
+
const END_MESSAGES = {
|
|
139
|
+
'root-missing': 'the workspace root was deleted or replaced; a directory recreated at that path is a new capture',
|
|
140
|
+
'watcher-failed': 'the watcher stopped delivering changes for this workspace',
|
|
141
|
+
'maintenance': 'stopped while gc reclaims storage; it resumes on its own afterwards',
|
|
142
|
+
};
|
|
143
|
+
const CAPTURE_LIMIT_MESSAGE = `${MAX_CAPTURES}/${MAX_CAPTURES} capture slots in use. This workspace is NOT recording. `
|
|
144
|
+
+ 'Pause one first.';
|
|
145
|
+
const CAPTURE_PAUSED_MESSAGE = 'This workspace is paused. Record it in Slipstream first, then attach again.';
|
|
146
|
+
const BINDING_LIMIT_MESSAGE = `${MAX_BINDINGS}/${MAX_BINDINGS} chats linked. This workspace is still recording without `
|
|
147
|
+
+ 'a linked chat. Unlink another chat to connect this one.';
|
|
148
|
+
const WATCH_ONLY_MESSAGE = 'this capture is watch-only: no agent chat is linked to it. Run `slipstream attach` from '
|
|
149
|
+
+ 'the agent chat to link one';
|
|
150
|
+
const SETUP_PENDING_MESSAGE = 'the agent chat for this capture has not completed setup; finish the steps slipstream '
|
|
151
|
+
+ 'attach printed';
|
|
152
|
+
function isHarness(v) {
|
|
153
|
+
return HARNESSES.includes(v);
|
|
154
|
+
}
|
|
155
|
+
/** One root contains the other, by whole path components. */
|
|
156
|
+
function overlaps(a, b) {
|
|
157
|
+
const within = (child, parent) => child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
|
|
158
|
+
return within(a, b) || within(b, a);
|
|
159
|
+
}
|
|
151
160
|
export async function startDaemon(opts) {
|
|
152
161
|
const storeDir = opts.storeDir;
|
|
153
162
|
const identityRealpath = opts.identityRealpath ?? realpath;
|
|
163
|
+
const workspaceLookup = opts.workspaceLookup ?? findWorkspaceSession;
|
|
164
|
+
const transcripts = opts.transcriptScheduler ?? transcriptSchedulerFrom(opts.config);
|
|
154
165
|
const socketPath = join(storeDir, 'control.sock');
|
|
155
166
|
await mkdirpDurable(storeDir);
|
|
156
167
|
await assertOwnerOnly(storeDir, 'dir');
|
|
@@ -172,21 +183,45 @@ export async function startDaemon(opts) {
|
|
|
172
183
|
// an onCompromised callback that fires mid-startup never hits a temporal dead
|
|
173
184
|
// zone (it may run with `reader` still undefined — teardown null-checks it).
|
|
174
185
|
const registry = createBoundaryRegistry();
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
186
|
+
const backoff = opts.retryBackoff ?? RETRY_BACKOFF;
|
|
187
|
+
const reofferMs = opts.setupReofferMs ?? SETUP_REOFFER_MS;
|
|
188
|
+
let intent = emptyIntent();
|
|
189
|
+
/** The next intent write not yet started; joining it covers every mutation made
|
|
190
|
+
* before it begins. Writes run one at a time. */
|
|
191
|
+
let intentFlush;
|
|
192
|
+
let intentTail = Promise.resolve();
|
|
193
|
+
let intentBroken;
|
|
194
|
+
/** Every capture this daemon has admitted, by session id; withdrawn ones stay
|
|
195
|
+
* for history until their session is deleted. */
|
|
196
|
+
const captures = new Map();
|
|
197
|
+
/** Stops that settled with their capture `failed`: they recorded no stop. Read
|
|
198
|
+
* by the stop, since a retry waiting on it may requeue the capture first. */
|
|
199
|
+
const unrecordedStops = new WeakSet();
|
|
200
|
+
/** Roots between their canonicalization and their intent write. A second attach
|
|
201
|
+
* of the same root joins the first instead of admitting a duplicate. */
|
|
202
|
+
const admitting = new Map();
|
|
203
|
+
/** Baselines run one at a time, in admission order. */
|
|
204
|
+
let baselines = Promise.resolve();
|
|
205
|
+
/** The store's blob store, shared by every capture (see CaptureOptions.cas).
|
|
206
|
+
* Recreated after maintenance, which may delete blobs it trusts. */
|
|
207
|
+
let cas;
|
|
178
208
|
const connections = new Set();
|
|
179
|
-
|
|
180
|
-
//
|
|
181
|
-
// The slot is claimed synchronously in runMaintenance before its first await, and
|
|
182
|
-
// attach refuses while it is held — so a single-threaded turn makes maintenance
|
|
183
|
-
// and attach mutually exclusive without a lock.
|
|
209
|
+
// At most one maintenance op (delete_session / gc) runs at a time. The slot is
|
|
210
|
+
// claimed synchronously in runMaintenance before its first await.
|
|
184
211
|
let maintenanceInFlight;
|
|
212
|
+
/** A gc is stopping, sweeping or resuming: no capture is admitted or started
|
|
213
|
+
* and no chat is linked or unlinked until it is done. */
|
|
214
|
+
let quiescing = false;
|
|
215
|
+
/** The session a delete_session is removing, and the count of delete_sessions
|
|
216
|
+
* finished so far with the count at which each session's last one finished. An
|
|
217
|
+
* attach never resumes a session being deleted, or one whose delete finished
|
|
218
|
+
* after its lookup began; a later lookup reads the tombstone itself. */
|
|
219
|
+
let deleting;
|
|
220
|
+
let deletesDone = 0;
|
|
221
|
+
const deleteDoneAt = new Map();
|
|
185
222
|
let reader;
|
|
186
223
|
let torn = false;
|
|
187
224
|
let compromised = false; // store lock lost
|
|
188
|
-
let sessionCompromised = false; // current session lock lost mid-attach
|
|
189
|
-
let abortStopError; // a failed stop of a session aborted mid-attach
|
|
190
225
|
// Gates DISPATCH, not acceptance: connections are tracked and given a deadline
|
|
191
226
|
// from the moment they arrive (so bind/chmod-window sockets can never orphan or
|
|
192
227
|
// hang shutdown), but no verb runs until startup is fully ready — which is also
|
|
@@ -216,42 +251,46 @@ export async function startDaemon(opts) {
|
|
|
216
251
|
}
|
|
217
252
|
async function doTeardown() {
|
|
218
253
|
torn = true;
|
|
254
|
+
for (const c of captures.values()) {
|
|
255
|
+
clearTimeout(c.retryTimer);
|
|
256
|
+
if (c.binding)
|
|
257
|
+
cancelRearm(c.binding);
|
|
258
|
+
}
|
|
219
259
|
// Drop live control connections so server.close() cannot block on an idle or
|
|
220
260
|
// half-sent request.
|
|
221
261
|
for (const sock of connections)
|
|
222
262
|
sock.destroy();
|
|
223
263
|
await closeServer();
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
await attachInFlight.catch(() => { });
|
|
264
|
+
// Admissions and baselines in flight observe `torn` and start nothing new; wait
|
|
265
|
+
// for them so every session they opened is in the table and stopped below.
|
|
266
|
+
await Promise.allSettled([...admitting.values()]);
|
|
267
|
+
await baselines;
|
|
229
268
|
// A maintenance op in flight has already seen `torn` (gc aborts its sweep); wait
|
|
230
269
|
// for it to settle so a durable tombstone or blob unlink is not racing shutdown.
|
|
231
270
|
if (maintenanceInFlight)
|
|
232
271
|
await maintenanceInFlight.catch(() => { });
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
// `torn` is already set, so
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
await Promise.allSettled([...inflightTasks]);
|
|
272
|
+
let stopErr;
|
|
273
|
+
for (const c of captures.values()) {
|
|
274
|
+
await c.closing;
|
|
275
|
+
stopErr ??= c.closeError;
|
|
276
|
+
const session = c.session;
|
|
277
|
+
if (!session)
|
|
278
|
+
continue;
|
|
279
|
+
// Drain admitted appends before stopping: `torn` is already set, so no new
|
|
280
|
+
// work is admitted, and an append in flight must not race the log closing.
|
|
281
|
+
await Promise.allSettled([...c.inflight]);
|
|
244
282
|
// Do NOT swallow a failed stop: if capture could not be confirmed stopped,
|
|
245
283
|
// shutdown must not report success. session.stop() still releases the session
|
|
246
284
|
// lock and halts the engine even when it rethrows, so releasing the store
|
|
247
|
-
// lock below is safe; we surface the failure once cleanup is complete.
|
|
285
|
+
// lock below is safe; we surface the failure once cleanup is complete. No
|
|
286
|
+
// stop reason: shutdown is not a withdrawal, so no capture.stopped is written.
|
|
248
287
|
try {
|
|
249
|
-
await
|
|
288
|
+
await session.stop();
|
|
250
289
|
}
|
|
251
290
|
catch (err) {
|
|
252
|
-
stopErr
|
|
291
|
+
stopErr ??= err;
|
|
253
292
|
}
|
|
254
|
-
|
|
293
|
+
c.session = undefined;
|
|
255
294
|
}
|
|
256
295
|
await reader?.close().catch(() => { });
|
|
257
296
|
// Unlink the socket only while we still hold the store lock. On the compromise
|
|
@@ -260,31 +299,54 @@ export async function startDaemon(opts) {
|
|
|
260
299
|
// successor's socket. Leave it for the new owner instead.
|
|
261
300
|
if (!compromised)
|
|
262
301
|
await unlinkOwnSocket();
|
|
302
|
+
await intentTail;
|
|
263
303
|
await lock.release();
|
|
264
304
|
if (stopErr)
|
|
265
305
|
throw stopErr;
|
|
266
306
|
}
|
|
267
307
|
let lock;
|
|
308
|
+
// A CLI recording a withdrawal while no daemon ran holds the store lock for
|
|
309
|
+
// milliseconds; wait it out. A lock still held after that, or a socket that
|
|
310
|
+
// starts answering, is another daemon.
|
|
311
|
+
const lockDeadline = Date.now() + STORE_LOCK_WAIT_MS;
|
|
312
|
+
for (;;) {
|
|
313
|
+
try {
|
|
314
|
+
lock = await acquireSessionLock(storeDir, {
|
|
315
|
+
onCompromised: (reason) => {
|
|
316
|
+
compromised = true;
|
|
317
|
+
console.error(`slipstream daemon: store lock lost: ${reason}`);
|
|
318
|
+
void teardown().catch((teardownErr) => {
|
|
319
|
+
console.error('slipstream daemon: teardown after store-lock loss failed:', teardownErr);
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
if (!(err instanceof SessionOwnedError))
|
|
327
|
+
throw err;
|
|
328
|
+
if (Date.now() >= lockDeadline || await probeSocket(socketPath, PROBE_TIMEOUT_MS) !== 'stale') {
|
|
329
|
+
throw new DaemonAlreadyRunningError(socketPath);
|
|
330
|
+
}
|
|
331
|
+
await new Promise((resolve) => setTimeout(resolve, STORE_LOCK_POLL_MS));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// Recovery reads intent only while holding the store lock, so a withdrawal a
|
|
335
|
+
// CLI recorded while no daemon ran is always seen, and pins every session it
|
|
336
|
+
// will resume before the reader can serve one.
|
|
337
|
+
let restored;
|
|
268
338
|
try {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
compromised = true;
|
|
272
|
-
console.error(`slipstream daemon: store lock lost: ${reason}`);
|
|
273
|
-
void teardown().catch((teardownErr) => {
|
|
274
|
-
console.error('slipstream daemon: teardown after store-lock loss failed:', teardownErr);
|
|
275
|
-
});
|
|
276
|
-
},
|
|
277
|
-
});
|
|
339
|
+
intent = await loadIntent(storeDir);
|
|
340
|
+
restored = await restoreCaptures();
|
|
278
341
|
}
|
|
279
342
|
catch (err) {
|
|
280
|
-
|
|
281
|
-
throw new DaemonAlreadyRunningError(socketPath);
|
|
343
|
+
await lock.release();
|
|
282
344
|
throw err;
|
|
283
345
|
}
|
|
284
346
|
// Bring the reader up before accepting control traffic, so a verb can never be
|
|
285
347
|
// dispatched against a reader that is not yet serving the public view.
|
|
286
348
|
try {
|
|
287
|
-
reader = await startReaderServer({ storeDir, registry });
|
|
349
|
+
reader = await startReaderServer({ storeDir, registry, captures: capturesProjection });
|
|
288
350
|
}
|
|
289
351
|
catch (err) {
|
|
290
352
|
await lock.release();
|
|
@@ -324,198 +386,1181 @@ export async function startDaemon(opts) {
|
|
|
324
386
|
throw new Error('slipstream daemon lost its store lock during startup');
|
|
325
387
|
}
|
|
326
388
|
const readerRef = reader;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
389
|
+
// ---- capture state ------------------------------------------------------
|
|
390
|
+
/** The public state of a capture (contracts.md §6), folded from its intent and
|
|
391
|
+
* its runtime. A withdrawn capture is `stopped` only once its stop is durable. */
|
|
392
|
+
function publicState(c) {
|
|
393
|
+
const entry = findCapture(intent, c.id);
|
|
394
|
+
if (!entry || entry.intent === 'withdrawn') {
|
|
395
|
+
return c.lifecycle === 'stopped' ? { state: 'stopped', reason: entry?.withdrawn_reason ?? 'detached' }
|
|
396
|
+
: { state: 'not_recording', reason: 'stop-unconfirmed', message: 'detach was requested, but the capture could not be confirmed stopped' };
|
|
397
|
+
}
|
|
398
|
+
if (c.ended && ['queued', 'failed', 'stopped'].includes(c.lifecycle))
|
|
399
|
+
return { state: 'stopped', reason: c.ended, message: END_MESSAGES[c.ended] };
|
|
400
|
+
if (entry.last_failure)
|
|
401
|
+
return { state: 'not_recording', reason: entry.last_failure.reason, message: entry.last_failure.message };
|
|
402
|
+
const health = c.session?.health.snapshot().state;
|
|
403
|
+
if (health === 'failing' || health === 'recovering') {
|
|
404
|
+
return { state: 'not_recording', reason: 'storage-unavailable', message: `the store cannot accept events (${health}); capture resumes when it recovers` };
|
|
405
|
+
}
|
|
406
|
+
if (c.lifecycle === 'recording' || c.lifecycle === 'baselining')
|
|
407
|
+
return { state: c.lifecycle, reason: null };
|
|
408
|
+
return { state: 'queued', reason: null };
|
|
409
|
+
}
|
|
410
|
+
/** When recovery will next try a failed capture, while one is scheduled. */
|
|
411
|
+
function retryOf(c) {
|
|
412
|
+
const entry = findCapture(intent, c.id);
|
|
413
|
+
const at = entry?.intent === 'running' ? entry.last_failure?.next_attempt_at_ms : null;
|
|
414
|
+
return typeof at === 'number' ? { next_attempt_at_ms: at } : null;
|
|
415
|
+
}
|
|
416
|
+
function isRunning(c) {
|
|
417
|
+
return findCapture(intent, c.id)?.intent === 'running';
|
|
418
|
+
}
|
|
419
|
+
function isLive(c) {
|
|
420
|
+
const entry = findCapture(intent, c.id);
|
|
421
|
+
return entry !== undefined && entry.intent !== 'withdrawn';
|
|
422
|
+
}
|
|
423
|
+
/** The latest binding intent for a capture, linked or revoked. */
|
|
424
|
+
function bindingRow(c) {
|
|
425
|
+
const entry = intent.bindings.findLast((b) => b.session_id === c.id);
|
|
426
|
+
if (!entry)
|
|
427
|
+
return null;
|
|
428
|
+
const live = entry.intent === 'linked' ? c.binding : undefined;
|
|
429
|
+
const row = { harness: entry.harness, harness_session_id: entry.harness_session_id, binding_generation: entry.binding_generation };
|
|
430
|
+
if (entry.intent === 'revoked')
|
|
431
|
+
return { ...row, state: 'unlinked', reason: null };
|
|
432
|
+
if (entry.unavailable)
|
|
433
|
+
return { ...row, state: 'unavailable', reason: entry.unavailable.reason, message: entry.unavailable.message };
|
|
434
|
+
return { ...row, state: live?.check.state ?? 'setup_pending', reason: null };
|
|
435
|
+
}
|
|
436
|
+
async function statusRows(asOf, includeRuntimeDetails = false) {
|
|
437
|
+
const heartbeatOf = async (lockPath) => {
|
|
438
|
+
try {
|
|
439
|
+
return Math.trunc((await stat(lockPath)).mtimeMs);
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
return null; /* no owner */
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
const heartbeatLive = (heartbeat) => heartbeat !== null && asOf - heartbeat <= STALE_MS;
|
|
446
|
+
const daemonLive = heartbeatLive(await heartbeatOf(join(storeDir, 'owner.lock')));
|
|
447
|
+
return Promise.all([...captures.values()].map(async (c) => {
|
|
448
|
+
let { state, reason, message } = publicState(c);
|
|
449
|
+
let binding = bindingRow(c);
|
|
450
|
+
const heartbeat = await heartbeatOf(join(storeDir, 'sessions', c.id, 'owner.lock'));
|
|
451
|
+
// The public liveness rule (contracts.md §6, §7): a capture with no live
|
|
452
|
+
// session owner is waiting on a live daemon, or not confirmed live at all.
|
|
453
|
+
// Waiting, it has no current activation, so no chat is connected in it.
|
|
454
|
+
if (['queued', 'baselining', 'recording'].includes(state) && !heartbeatLive(heartbeat)) {
|
|
455
|
+
[state, reason] = daemonLive ? ['queued', null] : ['not_recording', 'daemon-unavailable'];
|
|
456
|
+
message = undefined;
|
|
457
|
+
if (daemonLive && binding?.state === 'connected')
|
|
458
|
+
binding = { ...binding, state: 'setup_pending' };
|
|
459
|
+
}
|
|
460
|
+
const row = { session_id: c.id, root: c.root, state, reason, retry: retryOf(c), capture_mode: 'watch-only' };
|
|
461
|
+
if (includeRuntimeDetails && message)
|
|
462
|
+
row.message = message;
|
|
463
|
+
if (includeRuntimeDetails && c.session)
|
|
464
|
+
row.durable_seq = c.session.health.snapshot().durable_seq;
|
|
465
|
+
if (!includeRuntimeDetails)
|
|
466
|
+
row.heartbeat_ms = heartbeat;
|
|
467
|
+
row.binding = binding;
|
|
468
|
+
if (includeRuntimeDetails && c.binding)
|
|
469
|
+
row.binding = { ...row.binding, root_transcript: c.binding.transcript };
|
|
470
|
+
return row;
|
|
471
|
+
}));
|
|
472
|
+
}
|
|
473
|
+
/** `GET /v1/captures` (contracts.md §9). Heartbeats are read from the store and
|
|
474
|
+
* session ownership locks, exactly as an offline client would. */
|
|
475
|
+
async function capturesProjection() {
|
|
476
|
+
const asOf = Date.now();
|
|
477
|
+
const rows = await statusRows(asOf);
|
|
478
|
+
return { as_of_ms: asOf, binding_slots: { used: linkedCount(intent), limit: MAX_BINDINGS }, captures: rows };
|
|
479
|
+
}
|
|
480
|
+
async function statusFields() {
|
|
481
|
+
return {
|
|
482
|
+
reader_url: readerRef.url, attach_features: ['agent-connection-v1', 'capture-table-v1'],
|
|
483
|
+
binding_slots: { used: linkedCount(intent), limit: MAX_BINDINGS },
|
|
484
|
+
captures: await statusRows(Date.now(), true),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
async function status(req) {
|
|
488
|
+
if (req.target === undefined)
|
|
489
|
+
return statusFields();
|
|
490
|
+
const c = await resolveTarget(req.target);
|
|
491
|
+
if (isErrorFields(c))
|
|
492
|
+
return c;
|
|
493
|
+
const capture = (await statusRows(Date.now(), true)).find((row) => row.session_id === c.id);
|
|
494
|
+
return { reader_url: readerRef.url, attach_features: ['agent-connection-v1', 'capture-table-v1'], capture };
|
|
495
|
+
}
|
|
496
|
+
/** A target is a session id or a root path. The id is authoritative; a path
|
|
497
|
+
* matches a stored canonical root by realpath equality, or literally when it can
|
|
498
|
+
* no longer be resolved, never by prefix. */
|
|
499
|
+
async function resolveTarget(target) {
|
|
500
|
+
if (!nonEmptyString(target))
|
|
501
|
+
return errFields('TARGET_NOT_FOUND', 'a target session id or workspace path is required');
|
|
502
|
+
const byId = captures.get(target);
|
|
503
|
+
if (byId)
|
|
504
|
+
return byId;
|
|
505
|
+
let root;
|
|
506
|
+
try {
|
|
507
|
+
root = await realpath(target);
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
root = target;
|
|
511
|
+
}
|
|
512
|
+
const atRoot = [...captures.values()].filter((c) => c.root === root);
|
|
513
|
+
// A root recreated at the same path is a newer capture than the one it replaced.
|
|
514
|
+
const found = atRoot.findLast(isLive) ?? atRoot.at(-1);
|
|
515
|
+
return found ?? errFields('TARGET_NOT_FOUND', `no capture of ${target}`);
|
|
516
|
+
}
|
|
517
|
+
/** The live capture of the directory now at `root`. A capture of a directory
|
|
518
|
+
* that was deleted or replaced there is a different workspace, not a match. */
|
|
519
|
+
function liveCaptureAt(root, identity) {
|
|
520
|
+
return [...captures.values()].find((c) => c.root === root && isLive(c) && sameRoot(c, identity));
|
|
521
|
+
}
|
|
522
|
+
function rootGone(c) {
|
|
523
|
+
return c.ended === 'root-missing' || findCapture(intent, c.id)?.last_failure?.reason === 'root-missing';
|
|
524
|
+
}
|
|
525
|
+
function sameRoot(c, identity) {
|
|
526
|
+
return sameDirectory(findCapture(intent, c.id)?.root_identity, identity);
|
|
527
|
+
}
|
|
528
|
+
function sameDirectory(known, identity) {
|
|
529
|
+
return !known || !identity || (known.dev === identity.dev && known.ino === identity.ino);
|
|
530
|
+
}
|
|
531
|
+
/** The capture a chat identity is linked to, store-wide (at most one). */
|
|
532
|
+
function captureForChat(harness, harnessSessionId) {
|
|
533
|
+
const entry = typeof harness === 'string' && typeof harnessSessionId === 'string'
|
|
534
|
+
? findLinked(intent, harness, harnessSessionId) : undefined;
|
|
535
|
+
return entry ? captures.get(entry.session_id) : undefined;
|
|
536
|
+
}
|
|
537
|
+
/** Record a capture as not recording. A transient failure is retried with
|
|
538
|
+
* backoff, as a new activation. */
|
|
539
|
+
/** A stop is recorded only while the daemon holds the store lock. A shutdown
|
|
540
|
+
* keeps it, so only its loss makes a stop unrecordable. */
|
|
541
|
+
function assertStoreOwned() {
|
|
542
|
+
if (compromised)
|
|
543
|
+
throw new Error('the daemon lost its store lock');
|
|
544
|
+
}
|
|
545
|
+
function fail(c, reason, message, opts) {
|
|
546
|
+
c.lifecycle = 'failed';
|
|
547
|
+
const entry = findCapture(intent, c.id);
|
|
548
|
+
const next = opts.retry && entry?.intent === 'running' && !torn && !compromised ? scheduleRetry(c) : null;
|
|
549
|
+
if (entry)
|
|
550
|
+
entry.last_failure = { reason, message, at_ms: Date.now(), next_attempt_at_ms: next };
|
|
551
|
+
console.error(`slipstream daemon: capture ${c.id} of ${c.root} is not recording: ${message}`
|
|
552
|
+
+ (next === null ? '' : ` (retrying at ${new Date(next).toISOString()})`));
|
|
553
|
+
persistQuietly();
|
|
554
|
+
}
|
|
555
|
+
function scheduleRetry(c) {
|
|
556
|
+
clearTimeout(c.retryTimer);
|
|
557
|
+
c.attempts++;
|
|
558
|
+
const delay = Math.min(backoff.baseMs * 2 ** (c.attempts - 1), backoff.maxMs);
|
|
559
|
+
c.retryTimer = setTimeout(() => {
|
|
560
|
+
c.retryTimer = undefined;
|
|
561
|
+
void retryScheduled(c);
|
|
562
|
+
}, delay);
|
|
563
|
+
return Date.now() + delay;
|
|
564
|
+
}
|
|
565
|
+
/** Not recording, and another activation may fix that. */
|
|
566
|
+
function retryable(c) {
|
|
567
|
+
return c.lifecycle === 'failed' || (c.lifecycle === 'stopped' && c.ended !== undefined);
|
|
568
|
+
}
|
|
569
|
+
async function retryScheduled(c) {
|
|
570
|
+
await c.closing;
|
|
571
|
+
const resume = c.resume || await hasHistory(c.id);
|
|
572
|
+
const entry = findCapture(intent, c.id);
|
|
573
|
+
if (torn || compromised || !retryable(c) || entry?.intent !== 'running' || !entry.last_failure)
|
|
350
574
|
return;
|
|
575
|
+
entry.last_failure.next_attempt_at_ms = null;
|
|
576
|
+
persistQuietly();
|
|
577
|
+
c.lifecycle = 'queued';
|
|
578
|
+
c.resume = resume;
|
|
579
|
+
enqueueStart(c);
|
|
580
|
+
}
|
|
581
|
+
/** Whether a session's log holds a complete record to resume from. A log a
|
|
582
|
+
* failed first start left without one starts afresh under the same id; an
|
|
583
|
+
* unreadable log is resumed, so it fails visibly. */
|
|
584
|
+
async function hasHistory(id) {
|
|
585
|
+
try {
|
|
586
|
+
return await finalRecord(sessionLogPath(storeDir, id)) !== undefined;
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
return true;
|
|
351
590
|
}
|
|
352
|
-
if (state === 'attaching')
|
|
353
|
-
sessionCompromised = true; // startup aborts below
|
|
354
591
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
592
|
+
/** Rebuild the capture table from the intent record after a restart. Every
|
|
593
|
+
* session recovery will resume is pinned at its durable high-water, and every
|
|
594
|
+
* fresh one reserved, before the reader is exposed or anything is written.
|
|
595
|
+
* Returns the running captures, in admission order, to start once ready. */
|
|
596
|
+
async function restoreCaptures() {
|
|
597
|
+
const toStart = [];
|
|
598
|
+
let forgot = false;
|
|
599
|
+
for (const entry of [...intent.captures]) {
|
|
600
|
+
const id = entry.session_id;
|
|
601
|
+
// A delete that crashed after its durable tombstone: finish forgetting it.
|
|
602
|
+
if (await readTombstone(storeDir, id) !== null) {
|
|
603
|
+
forgetSession(intent, id);
|
|
604
|
+
forgot = true;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
let last;
|
|
608
|
+
let unreadable = false;
|
|
609
|
+
try {
|
|
610
|
+
last = await finalRecord(sessionLogPath(storeDir, id));
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
unreadable = true;
|
|
614
|
+
}
|
|
615
|
+
const c = { id, root: entry.root, lifecycle: 'queued', resume: last !== undefined || unreadable,
|
|
616
|
+
inflight: new Set(), attempts: 0 };
|
|
617
|
+
captures.set(id, c);
|
|
618
|
+
if (entry.intent === 'withdrawn') {
|
|
619
|
+
// `stopped` only when the last activation's stop is durable (§6).
|
|
620
|
+
c.lifecycle = c.resume && last?.type !== 'slipstream.capture.stopped.v1' ? 'failed' : 'stopped';
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
// An unreadable log is pinned at 0; its resume fails visibly below.
|
|
624
|
+
if (c.resume)
|
|
625
|
+
registry.freeze(id, last?.seq ?? 0n);
|
|
626
|
+
else
|
|
627
|
+
registry.reserve(id);
|
|
628
|
+
const stoppedFor = last?.type === 'slipstream.capture.stopped.v1' ? last.data?.reason : undefined;
|
|
629
|
+
if (stoppedFor === 'root-missing' || stoppedFor === 'watcher-failed' || stoppedFor === 'maintenance')
|
|
630
|
+
c.ended = stoppedFor;
|
|
631
|
+
// Connection state is not persisted: a linked chat comes back pending and is
|
|
632
|
+
// revalidated when its capture activates (contracts.md §8).
|
|
633
|
+
const linked = intent.bindings.find((b) => b.session_id === id && b.intent === 'linked');
|
|
634
|
+
if (linked) {
|
|
635
|
+
c.binding = { harness: linked.harness, harnessSessionId: linked.harness_session_id,
|
|
636
|
+
transcript: linked.verified_transcript_path, generation: linked.binding_generation,
|
|
637
|
+
check: { state: 'setup_pending', checkId: randomUUID() } };
|
|
638
|
+
}
|
|
639
|
+
toStart.push(c);
|
|
640
|
+
}
|
|
641
|
+
if (forgot)
|
|
642
|
+
await persistIntent();
|
|
643
|
+
return toStart;
|
|
644
|
+
}
|
|
645
|
+
/** Make the in-memory intent durable (contracts.md §8). Mutations are synchronous,
|
|
646
|
+
* so slot counts and the write they back cannot interleave; a lifecycle verb
|
|
647
|
+
* awaits this before it acts or replies. */
|
|
648
|
+
function persistIntent(opts = {}) {
|
|
649
|
+
const refuseOnFailure = opts.refuseOnFailure ?? true;
|
|
650
|
+
if (intentBroken)
|
|
651
|
+
return Promise.reject(intentBroken);
|
|
652
|
+
if (compromised)
|
|
653
|
+
return Promise.reject(new Error('the store lock was lost; the intent record belongs to another daemon'));
|
|
654
|
+
intentFlush ??= intentTail.then(async () => {
|
|
655
|
+
intentFlush = undefined;
|
|
656
|
+
if (intentBroken)
|
|
657
|
+
throw intentBroken;
|
|
658
|
+
if (compromised)
|
|
659
|
+
throw new Error('the store lock was lost; the intent record belongs to another daemon');
|
|
660
|
+
try {
|
|
661
|
+
await saveIntent(storeDir, intent);
|
|
662
|
+
}
|
|
663
|
+
catch (err) {
|
|
664
|
+
throw new IntentWriteError(err);
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
const flush = intentFlush;
|
|
668
|
+
intentTail = flush.catch(() => { });
|
|
669
|
+
return flush.catch((err) => {
|
|
670
|
+
if (!refuseOnFailure) {
|
|
671
|
+
console.error(`slipstream daemon: background capture-state update was not recorded; it will retry after storage recovers (${err.message})`);
|
|
672
|
+
throw err;
|
|
673
|
+
}
|
|
674
|
+
intentBroken ??= err instanceof IntentWriteError ? err : new IntentWriteError(err);
|
|
675
|
+
console.error(`slipstream daemon: ${intentBroken.message}`);
|
|
676
|
+
throw intentBroken;
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
/** Persist a change no acknowledgement depends on (a recorded failure). */
|
|
680
|
+
function persistQuietly() {
|
|
681
|
+
persistIntent({ refuseOnFailure: false }).catch(() => { });
|
|
682
|
+
}
|
|
683
|
+
function intentRefusal() {
|
|
684
|
+
return intentBroken ? errFields('STORAGE_UNAVAILABLE', intentBroken.message) : undefined;
|
|
685
|
+
}
|
|
686
|
+
async function rootIdentityOf(root) {
|
|
687
|
+
try {
|
|
688
|
+
const st = await stat(root, { bigint: true });
|
|
689
|
+
return { dev: st.dev.toString(), ino: st.ino.toString() };
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
/** Find or admit the capture of a canonical root. Only `resumePaused` restarts a
|
|
696
|
+
* withdrawn capture of this root. */
|
|
697
|
+
async function admit(root, opts) {
|
|
698
|
+
if (torn || compromised)
|
|
699
|
+
return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
|
|
700
|
+
const refused = intentRefusal();
|
|
701
|
+
if (refused)
|
|
702
|
+
return refused;
|
|
703
|
+
const identity = await rootIdentityOf(root);
|
|
704
|
+
if (quiescing)
|
|
705
|
+
return errFields('CAPTURE_NOT_READY', 'gc is reclaiming storage; retry shortly');
|
|
706
|
+
const live = liveCaptureAt(root, identity);
|
|
707
|
+
if (live)
|
|
708
|
+
return retryIfFailed(live);
|
|
709
|
+
const pending = admitting.get(root);
|
|
710
|
+
if (pending) {
|
|
711
|
+
const joined = await pending;
|
|
712
|
+
if (isErrorFields(joined) && joined.code === 'CAPTURE_PAUSED' && opts.resumePaused) {
|
|
713
|
+
if (admitting.get(root) === pending)
|
|
714
|
+
admitting.delete(root);
|
|
715
|
+
return admit(root, opts);
|
|
716
|
+
}
|
|
717
|
+
return isErrorFields(joined) ? joined : { capture: joined.capture, created: false };
|
|
718
|
+
}
|
|
719
|
+
// A withdrawn capture still watches its root until its stop completes.
|
|
720
|
+
// A capture whose root is gone watches nothing there any more.
|
|
721
|
+
const watching = [...captures.values()].filter((c) => (isLive(c) || c.lifecycle === 'stopping') && !rootGone(c));
|
|
722
|
+
const other = [...watching.map((c) => c.root), ...admitting.keys()].find((r) => overlaps(r, root));
|
|
723
|
+
if (other) {
|
|
724
|
+
return errFields('ROOT_OVERLAP', `${root} overlaps the capture of ${other}: one contains the other. Nothing was changed`);
|
|
725
|
+
}
|
|
726
|
+
const p = admitNew(root, identity, opts);
|
|
727
|
+
admitting.set(root, p);
|
|
728
|
+
try {
|
|
729
|
+
return await p;
|
|
730
|
+
}
|
|
731
|
+
finally {
|
|
732
|
+
if (admitting.get(root) === p)
|
|
733
|
+
admitting.delete(root);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
async function admitNew(root, rootIdentity, opts) {
|
|
737
|
+
const lookedUpAt = deletesDone;
|
|
738
|
+
let lookup;
|
|
739
|
+
try {
|
|
740
|
+
lookup = await workspaceLookup(storeDir, root);
|
|
741
|
+
}
|
|
742
|
+
catch (err) {
|
|
743
|
+
if (err instanceof WorkspaceLookupError) {
|
|
744
|
+
const remedy = err.corrupt ? corruptRemedy(err.sessionId) : RETRY_REMEDY;
|
|
745
|
+
return errFields('SESSION_RESUME_FAILED', `${err.message}. ${remedy}`);
|
|
746
|
+
}
|
|
747
|
+
return errFields('STORAGE_UNAVAILABLE', `could not look up this workspace's session: ${err.message}`);
|
|
748
|
+
}
|
|
749
|
+
for (const s of lookup.skipped) {
|
|
750
|
+
console.error(`slipstream daemon: skipped session ${s.id} while looking for ${root}'s session: ${s.reason}`);
|
|
751
|
+
}
|
|
752
|
+
// A session of a directory since deleted or replaced at this path is never
|
|
753
|
+
// resumed: the recreated root starts a session of its own.
|
|
754
|
+
const prev = lookup.match && findCapture(intent, lookup.match.id)?.root_identity;
|
|
755
|
+
const match = prev && rootIdentity && (prev.dev !== rootIdentity.dev || prev.ino !== rootIdentity.ino)
|
|
756
|
+
? undefined : lookup.match;
|
|
757
|
+
const id = match?.id ?? randomUUID();
|
|
758
|
+
// A detach of this same session may still be stopping it; its lock must be
|
|
759
|
+
// released before the session can start again.
|
|
760
|
+
const prior = captures.get(id);
|
|
761
|
+
await prior?.closing;
|
|
762
|
+
if (torn || compromised) {
|
|
763
|
+
if (match) {
|
|
764
|
+
return errFields('SESSION_RESUME_FAILED', `could not resume session ${id}: the daemon is shutting down or lost its store lock. ${RETRY_REMEDY}`);
|
|
389
765
|
}
|
|
390
|
-
|
|
766
|
+
return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
|
|
767
|
+
}
|
|
768
|
+
// Checked in the same turn as the intent entry below, which a delete checks.
|
|
769
|
+
if (match && (deleting === id || (deleteDoneAt.get(id) ?? 0) > lookedUpAt)) {
|
|
770
|
+
return errFields('CAPTURE_NOT_READY', `session ${id} was deleted while this attach looked it up; retry attach`);
|
|
771
|
+
}
|
|
772
|
+
// Checked in the same turn as the intent entry below, so a pause or a
|
|
773
|
+
// concurrent admission cannot slip between them. By root, not by log: a
|
|
774
|
+
// capture paused before it ever started has no log to find.
|
|
775
|
+
const latest = intent.captures.findLast((e) => e.root === root);
|
|
776
|
+
if (!opts.resumePaused && latest?.intent === 'withdrawn' && sameDirectory(latest.root_identity, rootIdentity)) {
|
|
777
|
+
return errFields('CAPTURE_PAUSED', CAPTURE_PAUSED_MESSAGE);
|
|
778
|
+
}
|
|
779
|
+
if (runningCount(intent) >= MAX_CAPTURES)
|
|
780
|
+
return errFields('CAPTURE_LIMIT_REACHED', CAPTURE_LIMIT_MESSAGE);
|
|
781
|
+
if (match) {
|
|
782
|
+
// Pin the boundary at the durable high-water BEFORE the log can grow, and
|
|
783
|
+
// hold it until activation: readers never see past it mid-resume.
|
|
784
|
+
registry.freeze(id, registry.get(id)?.boundary.current() ?? match.durableSeq);
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
// Reserve the registry entry (boundary 0) before startCapture creates the log
|
|
788
|
+
// dir on disk, so the reader never over-publishes an uncommitted record.
|
|
789
|
+
registry.reserve(id);
|
|
790
|
+
}
|
|
791
|
+
const entry = admitCapture(intent, { session_id: id, root, root_identity: rootIdentity });
|
|
792
|
+
try {
|
|
793
|
+
await persistIntent();
|
|
794
|
+
}
|
|
795
|
+
catch (err) {
|
|
796
|
+
intent.captures = intent.captures.filter((e) => e !== entry);
|
|
797
|
+
return errFields('STORAGE_UNAVAILABLE', err.message);
|
|
798
|
+
}
|
|
799
|
+
const c = prior ?? { id, root, lifecycle: 'queued', resume: false, inflight: new Set(), attempts: 0 };
|
|
800
|
+
Object.assign(c, { root, lifecycle: 'queued', resume: match !== undefined, closeError: undefined });
|
|
801
|
+
captures.set(id, c);
|
|
802
|
+
enqueueStart(c);
|
|
803
|
+
return { capture: c, created: true };
|
|
804
|
+
}
|
|
805
|
+
/** Re-attaching a capture whose start or storage failed, or whose activation
|
|
806
|
+
* ended on its own, tries it again. */
|
|
807
|
+
async function retryIfFailed(c) {
|
|
808
|
+
if (!retryable(c))
|
|
809
|
+
return { capture: c, created: false };
|
|
810
|
+
await c.closing;
|
|
811
|
+
const resume = c.resume || await hasHistory(c.id);
|
|
812
|
+
const entry = findCapture(intent, c.id);
|
|
813
|
+
if (!retryable(c) || entry?.intent !== 'running' || torn || compromised)
|
|
814
|
+
return { capture: c, created: false };
|
|
815
|
+
clearTimeout(c.retryTimer);
|
|
816
|
+
c.retryTimer = undefined;
|
|
817
|
+
entry.last_failure = null;
|
|
818
|
+
persistQuietly();
|
|
819
|
+
c.lifecycle = 'queued';
|
|
820
|
+
c.resume = resume;
|
|
821
|
+
enqueueStart(c);
|
|
822
|
+
return { capture: c, created: false };
|
|
823
|
+
}
|
|
824
|
+
function enqueueStart(c) {
|
|
825
|
+
const job = baselines.then(() => startOne(c)).catch((err) => {
|
|
826
|
+
console.error(`slipstream daemon: capture ${c.id} start failed unexpectedly:`, err);
|
|
827
|
+
});
|
|
828
|
+
c.starting = job;
|
|
829
|
+
baselines = job;
|
|
830
|
+
}
|
|
831
|
+
/** Start (or resume) one capture. Never rejects: a failure is recorded on the
|
|
832
|
+
* capture, and the other captures are untouched. */
|
|
833
|
+
async function startOne(c) {
|
|
834
|
+
if (torn || compromised || c.lifecycle !== 'queued' || !isRunning(c))
|
|
835
|
+
return;
|
|
836
|
+
c.lifecycle = 'baselining';
|
|
837
|
+
const moved = await rootProblem(c);
|
|
838
|
+
if (c.lifecycle !== 'baselining')
|
|
839
|
+
return;
|
|
840
|
+
if (moved) {
|
|
841
|
+
if (!c.resume)
|
|
842
|
+
registry.freeze(c.id, 0n);
|
|
843
|
+
fail(c, 'root-missing', moved, { retry: false });
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
const identity = findCapture(intent, c.id)?.root_identity;
|
|
847
|
+
const activation = { lost: false };
|
|
848
|
+
c.activation = activation;
|
|
849
|
+
c.ended = undefined;
|
|
850
|
+
let session;
|
|
851
|
+
try {
|
|
852
|
+
cas ??= await createCas(join(storeDir, 'blobs'));
|
|
391
853
|
session = await startCapture({
|
|
392
|
-
root:
|
|
854
|
+
root: c.root,
|
|
393
855
|
storeDir,
|
|
394
|
-
sessionId: id,
|
|
395
|
-
|
|
856
|
+
...(c.resume ? { resumeSessionId: c.id } : { sessionId: c.id }),
|
|
857
|
+
...(identity ? { rootIdentity: identity } : {}),
|
|
858
|
+
captureMode: 'watch-only',
|
|
859
|
+
cas,
|
|
860
|
+
onCompromised: (reason) => onSessionLockLost(c, activation, reason),
|
|
861
|
+
onTerminal: (reason, message) => onActivationEnded(c, activation, reason, message),
|
|
862
|
+
onStorage: (state, failure) => onStorageChanged(c, activation, state, failure),
|
|
396
863
|
...(opts.config ? { enrichmentPolicy: opts.config.policy } : {}),
|
|
397
|
-
...(
|
|
864
|
+
...(transcripts ? { transcript: transcripts } : {}),
|
|
398
865
|
}, opts.captureDependencies);
|
|
399
866
|
}
|
|
400
867
|
catch (err) {
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
if (
|
|
405
|
-
|
|
406
|
-
if (
|
|
407
|
-
return
|
|
408
|
-
if (err instanceof
|
|
409
|
-
|
|
410
|
-
|
|
868
|
+
// A fresh session never activated: freeze at 0, never delete the entry, so
|
|
869
|
+
// the reader cannot fall back to disk for a half-written log. A resumed
|
|
870
|
+
// session keeps the boundary pinned at its pre-resume high-water.
|
|
871
|
+
if (!c.resume)
|
|
872
|
+
registry.freeze(c.id, 0n);
|
|
873
|
+
if (activation.lost || c.lifecycle !== 'baselining')
|
|
874
|
+
return;
|
|
875
|
+
if (err instanceof RootChangedError) {
|
|
876
|
+
fail(c, 'root-missing', err.message, { retry: false });
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (c.resume) {
|
|
880
|
+
const corrupt = err instanceof CorruptLogError || err instanceof LogCorruptError;
|
|
881
|
+
fail(c, 'resume-failed', `could not resume session ${c.id}: ${err.message}. `
|
|
882
|
+
+ (corrupt ? corruptRemedy(c.id) : RETRY_REMEDY), { retry: !corrupt });
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
fail(c, err instanceof StorageError ? 'storage-unavailable' : 'baseline-failed', err.message, { retry: true });
|
|
886
|
+
}
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
activation.session = session;
|
|
890
|
+
c.session = session;
|
|
891
|
+
c.resume = true;
|
|
892
|
+
if (activation.lost) {
|
|
893
|
+
halt(c, session);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
// Shutdown or a detach arrived while starting: whoever is stopping it owns the
|
|
897
|
+
// session now.
|
|
898
|
+
if (torn || compromised || c.lifecycle !== 'baselining')
|
|
899
|
+
return;
|
|
900
|
+
c.lifecycle = 'recording';
|
|
901
|
+
c.attempts = 0;
|
|
902
|
+
const entry = findCapture(intent, c.id);
|
|
903
|
+
if (entry?.last_failure) {
|
|
904
|
+
entry.last_failure = null;
|
|
905
|
+
persistQuietly();
|
|
906
|
+
}
|
|
907
|
+
registry.activate(c.id, liveBoundary(session.health));
|
|
908
|
+
registry.setAgentConnection(c.id, 'none');
|
|
909
|
+
if (activation.ended) {
|
|
910
|
+
endActivation(c, session, activation.ended);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
// A chat stays linked across a restart of its capture, but `connected` is
|
|
914
|
+
// re-earned only by a fresh round trip in the new activation.
|
|
915
|
+
if (c.binding) {
|
|
916
|
+
c.binding.check = { state: 'setup_pending', checkId: randomUUID() };
|
|
917
|
+
c.binding.revalidations = 0;
|
|
918
|
+
startRearm(c, c.binding, session);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
function startRearm(c, b, session) {
|
|
922
|
+
const rearm = (async () => {
|
|
923
|
+
for (;;) {
|
|
924
|
+
let failed;
|
|
925
|
+
try {
|
|
926
|
+
failed = await rearmBinding(c, b, session);
|
|
927
|
+
}
|
|
928
|
+
catch (err) {
|
|
929
|
+
console.error(`slipstream daemon: revalidating the chat linked to ${c.id} failed:`, err);
|
|
930
|
+
failed = controlFailure(err);
|
|
931
|
+
}
|
|
932
|
+
if (!failed || quiescing)
|
|
933
|
+
return;
|
|
934
|
+
const delay = Math.min(backoff.baseMs * 2 ** (b.revalidations ?? 0), backoff.maxMs);
|
|
935
|
+
b.revalidations = (b.revalidations ?? 0) + 1;
|
|
936
|
+
if (!await waitForRearmDelay(b, delay))
|
|
937
|
+
return;
|
|
938
|
+
if (c.binding !== b || c.session !== session || c.lifecycle !== 'recording' || torn || compromised)
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
})();
|
|
942
|
+
b.publishing = rearm;
|
|
943
|
+
const clearPublishing = () => {
|
|
944
|
+
if (b.publishing === rearm)
|
|
945
|
+
b.publishing = undefined;
|
|
946
|
+
};
|
|
947
|
+
void rearm.then(clearPublishing, clearPublishing);
|
|
948
|
+
}
|
|
949
|
+
function waitForRearmDelay(b, delay) {
|
|
950
|
+
return new Promise((resolve) => {
|
|
951
|
+
const timer = setTimeout(() => {
|
|
952
|
+
if (b.cancelRearmDelay === cancel)
|
|
953
|
+
b.cancelRearmDelay = undefined;
|
|
954
|
+
resolve(true);
|
|
955
|
+
}, delay);
|
|
956
|
+
const cancel = () => {
|
|
957
|
+
clearTimeout(timer);
|
|
958
|
+
if (b.cancelRearmDelay === cancel)
|
|
959
|
+
b.cancelRearmDelay = undefined;
|
|
960
|
+
resolve(false);
|
|
961
|
+
};
|
|
962
|
+
b.cancelRearmDelay = cancel;
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
function cancelRearm(b) {
|
|
966
|
+
clearTimeout(b.rearmTimer);
|
|
967
|
+
b.rearmTimer = undefined;
|
|
968
|
+
b.cancelRearmDelay?.();
|
|
969
|
+
}
|
|
970
|
+
/** Revalidate a linked chat's transcript in a new activation, then publish it
|
|
971
|
+
* pending. A link whose `agent.binding` never became durable (a crash after its
|
|
972
|
+
* intent) is recorded now, at its stored generation (contracts.md §6). */
|
|
973
|
+
async function rearmBinding(c, b, session) {
|
|
974
|
+
const entry = findLinked(intent, b.harness, b.harnessSessionId);
|
|
975
|
+
try {
|
|
976
|
+
b.transcript = await verifyRootTranscript(b.harness, b.harnessSessionId, c.root, b.transcript);
|
|
977
|
+
}
|
|
978
|
+
catch (err) {
|
|
979
|
+
if (!(err instanceof RootIdentityError))
|
|
980
|
+
throw err;
|
|
981
|
+
b.unavailable = true;
|
|
982
|
+
if (entry) {
|
|
983
|
+
entry.unavailable = { reason: err.reason, message: err.message };
|
|
984
|
+
persistQuietly();
|
|
985
|
+
}
|
|
986
|
+
console.error(`slipstream daemon: the chat linked to ${c.id} is unavailable: ${err.message}`);
|
|
987
|
+
// A transcript that comes back reconnects without another attach, while
|
|
988
|
+
// this binding is still the capture's in this activation.
|
|
989
|
+
const delay = Math.min(backoff.baseMs * 2 ** (b.revalidations ?? 0), backoff.maxMs);
|
|
990
|
+
b.revalidations = (b.revalidations ?? 0) + 1;
|
|
991
|
+
const timer = setTimeout(() => {
|
|
992
|
+
if (b.rearmTimer === timer)
|
|
993
|
+
b.rearmTimer = undefined;
|
|
994
|
+
if (c.binding === b && b.unavailable && c.session === session && c.lifecycle === 'recording' && !quiescing && !torn && !compromised) {
|
|
995
|
+
startRearm(c, b, session);
|
|
996
|
+
}
|
|
997
|
+
}, delay);
|
|
998
|
+
b.rearmTimer = timer;
|
|
999
|
+
timer.unref();
|
|
1000
|
+
return undefined;
|
|
1001
|
+
}
|
|
1002
|
+
b.unavailable = false;
|
|
1003
|
+
if (entry?.unavailable) {
|
|
1004
|
+
entry.unavailable = null;
|
|
1005
|
+
persistQuietly();
|
|
1006
|
+
}
|
|
1007
|
+
return publishBinding(c, b, { record: !session.bindingRecorded(b.generation), revokeOnFailure: false });
|
|
1008
|
+
}
|
|
1009
|
+
/** Whether a capture's root is still the directory it admitted. A root that is
|
|
1010
|
+
* gone, or whose device or inode changed, is never captured as the same session. */
|
|
1011
|
+
async function rootProblem(c) {
|
|
1012
|
+
const known = findCapture(intent, c.id)?.root_identity;
|
|
1013
|
+
let st;
|
|
1014
|
+
try {
|
|
1015
|
+
st = await stat(c.root, { bigint: true });
|
|
1016
|
+
}
|
|
1017
|
+
catch (err) {
|
|
1018
|
+
const code = err.code;
|
|
1019
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR')
|
|
1020
|
+
return undefined;
|
|
1021
|
+
return `the workspace root ${c.root} no longer exists`;
|
|
1022
|
+
}
|
|
1023
|
+
if (st.isDirectory() && (!known || (st.dev.toString() === known.dev && st.ino.toString() === known.ino)))
|
|
1024
|
+
return undefined;
|
|
1025
|
+
return `the workspace root ${c.root} was replaced; a directory recreated there is captured under a new session`;
|
|
1026
|
+
}
|
|
1027
|
+
function onActivationEnded(c, activation, reason, message) {
|
|
1028
|
+
if (c.activation !== activation || activation.lost || activation.ended)
|
|
1029
|
+
return;
|
|
1030
|
+
activation.ended = { reason, message };
|
|
1031
|
+
console.error(`slipstream daemon: capture ${c.id} of ${c.root} stopped: ${message}`);
|
|
1032
|
+
if (activation.session && c.lifecycle === 'recording')
|
|
1033
|
+
endActivation(c, activation.session, activation.ended);
|
|
1034
|
+
}
|
|
1035
|
+
/** Stop an activation that ended on its own, recording why. Its intent stays
|
|
1036
|
+
* running: a failed watcher is retried with backoff as a new activation; a
|
|
1037
|
+
* missing root is not, since it cannot come back as the same directory. */
|
|
1038
|
+
function endActivation(c, session, { reason, message }) {
|
|
1039
|
+
c.lifecycle = 'stopping';
|
|
1040
|
+
const prior = c.closing;
|
|
1041
|
+
setClosing(c, (async () => {
|
|
1042
|
+
await prior;
|
|
1043
|
+
while (c.inflight.size)
|
|
1044
|
+
await Promise.allSettled([...c.inflight]);
|
|
1045
|
+
if (registry.get(c.id))
|
|
1046
|
+
registry.setAgentConnection(c.id, 'none');
|
|
1047
|
+
if (c.session === session)
|
|
1048
|
+
c.session = undefined;
|
|
1049
|
+
try {
|
|
1050
|
+
await session.stop({ reason, assertOwnership: assertStoreOwned });
|
|
1051
|
+
}
|
|
1052
|
+
catch (err) {
|
|
1053
|
+
registry.freeze(c.id, BigInt(session.health.snapshot().durable_seq));
|
|
1054
|
+
fail(c, 'storage-unavailable', `the capture stopped (${message}), but the stop could not be recorded: ${err.message}`, { retry: true });
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
registry.freeze(c.id, BigInt(session.health.snapshot().durable_seq));
|
|
1058
|
+
c.ended = reason;
|
|
1059
|
+
c.lifecycle = 'stopped';
|
|
1060
|
+
const entry = findCapture(intent, c.id);
|
|
1061
|
+
if (reason === 'watcher-failed' && entry?.intent === 'running' && !torn && !compromised) {
|
|
1062
|
+
entry.last_failure = { reason, message, at_ms: Date.now(), next_attempt_at_ms: scheduleRetry(c) };
|
|
1063
|
+
persistQuietly();
|
|
1064
|
+
}
|
|
1065
|
+
})());
|
|
1066
|
+
}
|
|
1067
|
+
/** Record a live activation's storage outage in the intent, and clear it on
|
|
1068
|
+
* recovery, so offline readers agree with this daemon (contracts.md §6). */
|
|
1069
|
+
function onStorageChanged(c, activation, state, failure) {
|
|
1070
|
+
const entry = findCapture(intent, c.id);
|
|
1071
|
+
if (c.activation !== activation || activation.lost || entry?.intent !== 'running')
|
|
1072
|
+
return;
|
|
1073
|
+
if (state === 'failing' && !entry.last_failure) {
|
|
1074
|
+
entry.last_failure = { reason: 'storage-unavailable', at_ms: Date.now(), next_attempt_at_ms: null,
|
|
1075
|
+
message: `the store cannot accept events (${failure?.operation ?? 'append'}: ${failure?.code ?? 'error'}); capture resumes when it recovers` };
|
|
1076
|
+
persistQuietly();
|
|
1077
|
+
return;
|
|
411
1078
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
1079
|
+
// A recovery that lands while the capture is stopping keeps the outage until
|
|
1080
|
+
// the stop replaces it. Clear synchronously so another outage records a
|
|
1081
|
+
// fresh marker while this clear is still being written.
|
|
1082
|
+
const outage = entry.last_failure;
|
|
1083
|
+
if (state !== 'healthy' || c.lifecycle !== 'recording' || outage?.reason !== 'storage-unavailable'
|
|
1084
|
+
|| outage.next_attempt_at_ms !== null)
|
|
1085
|
+
return;
|
|
1086
|
+
entry.last_failure = null;
|
|
1087
|
+
// If the clear cannot reach disk, retain the outage for both readers. A
|
|
1088
|
+
// newer outage wins over this recovery if it arrives before the write fails.
|
|
1089
|
+
persistIntent({ refuseOnFailure: false }).catch(() => { entry.last_failure ??= outage; });
|
|
1090
|
+
}
|
|
1091
|
+
function onSessionLockLost(c, activation, reason) {
|
|
1092
|
+
console.error(`slipstream daemon: session ${c.id} lost its lock: ${reason}`);
|
|
1093
|
+
if (c.activation !== activation || activation.lost)
|
|
1094
|
+
return;
|
|
1095
|
+
activation.lost = true;
|
|
1096
|
+
fail(c, 'storage-unavailable', `capture lost its session lock: ${reason}`, { retry: true });
|
|
1097
|
+
if (activation.session)
|
|
1098
|
+
halt(c, activation.session);
|
|
1099
|
+
}
|
|
1100
|
+
/** Stop a capture whose storage can no longer be trusted, without recording a
|
|
1101
|
+
* stop it cannot make durable. Its boundary stays at the last durable seq. */
|
|
1102
|
+
function halt(c, session) {
|
|
1103
|
+
registry.freeze(c.id, BigInt(session.health.snapshot().durable_seq));
|
|
1104
|
+
const prior = c.closing;
|
|
1105
|
+
setClosing(c, (async () => {
|
|
1106
|
+
await prior;
|
|
1107
|
+
await Promise.allSettled([...c.inflight]);
|
|
416
1108
|
try {
|
|
417
1109
|
await session.stop();
|
|
418
1110
|
}
|
|
419
1111
|
catch (err) {
|
|
420
|
-
|
|
1112
|
+
console.error(`slipstream daemon: session ${c.id} did not stop cleanly after losing its lock:`, err);
|
|
421
1113
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
1114
|
+
if (c.session === session)
|
|
1115
|
+
c.session = undefined;
|
|
1116
|
+
})());
|
|
1117
|
+
}
|
|
1118
|
+
/** Make `closing` the capture's stop in flight until it settles. Its outcome is
|
|
1119
|
+
* read here, before any later waiter on it can run. */
|
|
1120
|
+
function setClosing(c, closing) {
|
|
1121
|
+
c.closing = closing;
|
|
1122
|
+
const settle = () => {
|
|
1123
|
+
if (c.lifecycle === 'failed')
|
|
1124
|
+
unrecordedStops.add(closing);
|
|
1125
|
+
if (c.closing === closing)
|
|
1126
|
+
c.closing = undefined;
|
|
1127
|
+
};
|
|
1128
|
+
closing.then(settle, settle);
|
|
1129
|
+
}
|
|
1130
|
+
// ---- attach / detach / unlink -------------------------------------------
|
|
1131
|
+
function admissionReply({ capture, created }) {
|
|
1132
|
+
const { state, reason, message } = publicState(capture);
|
|
1133
|
+
return { session_id: capture.id, state, reason, ...(message ? { message } : {}), created };
|
|
1134
|
+
}
|
|
1135
|
+
async function attachWatchOnly(req) {
|
|
1136
|
+
if (!nonEmptyString(req.worktree))
|
|
1137
|
+
return errFields('PROTOCOL', 'attach --watch-only requires a worktree');
|
|
1138
|
+
let root;
|
|
1139
|
+
try {
|
|
1140
|
+
root = await realpath(req.worktree);
|
|
426
1141
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
1142
|
+
catch (err) {
|
|
1143
|
+
return errFields('CAPTURE_NOT_READY', err.message);
|
|
1144
|
+
}
|
|
1145
|
+
const admitted = await admit(root, { resumePaused: true });
|
|
1146
|
+
return isErrorFields(admitted) ? admitted : admissionReply(admitted);
|
|
431
1147
|
}
|
|
432
|
-
|
|
1148
|
+
/** Verify a root chat transcript names this chat in this worktree. */
|
|
1149
|
+
async function verifyRootTranscript(harness, harnessSessionId, root, path) {
|
|
1150
|
+
let resolved;
|
|
1151
|
+
try {
|
|
1152
|
+
resolved = await realpath(path);
|
|
1153
|
+
}
|
|
1154
|
+
catch {
|
|
1155
|
+
throw new RootIdentityError('transcript-missing', harness === 'claude-code'
|
|
1156
|
+
? 'root Claude transcript is unavailable; check its path or retry attach after the first root tool call'
|
|
1157
|
+
: 'root Codex transcript cannot be resolved');
|
|
1158
|
+
}
|
|
1159
|
+
if (harness === 'codex') {
|
|
1160
|
+
if (!await verifyCodexRootTranscript(resolved, harnessSessionId, root)) {
|
|
1161
|
+
throw new RootIdentityError('identity-changed', 'root Codex transcript is missing, mismatched, or from an unsupported runtime');
|
|
1162
|
+
}
|
|
1163
|
+
return resolved;
|
|
1164
|
+
}
|
|
1165
|
+
const verified = await verifyClaudeRootTranscript(resolved, harnessSessionId, root);
|
|
1166
|
+
if (!verified.ok) {
|
|
1167
|
+
throw new RootIdentityError(UNAVAILABLE_BY_CLAUDE_REASON[verified.reason], verified.reason === 'not-yet'
|
|
1168
|
+
? 'root Claude transcript head is not complete yet; retry attach after the first root tool call'
|
|
1169
|
+
: verified.reason === 'gap'
|
|
1170
|
+
? 'root Claude transcript head exceeds the verification bounds; select a new root early in its session'
|
|
1171
|
+
: `root Claude transcript identity is ${verified.reason}`);
|
|
1172
|
+
}
|
|
1173
|
+
return resolved;
|
|
1174
|
+
}
|
|
1175
|
+
/** Attach from a chat. The chat's transcript is verified first, so a chat that
|
|
1176
|
+
* cannot be linked records nothing. The workspace's capture is found or
|
|
1177
|
+
* admitted; once it is recording the chat is linked in place. */
|
|
1178
|
+
async function attachChat(req) {
|
|
433
1179
|
if (!nonEmptyString(req.worktree) || !nonEmptyString(req.harness) || !nonEmptyString(req.harness_session_id)) {
|
|
434
1180
|
// Structural fail-closed identity gate (honesty): an ambiguous or partial
|
|
435
|
-
// identity refuses attachment rather than guessing.
|
|
436
|
-
// that the declared harness is a live real process is the P4 forwarder's job.
|
|
1181
|
+
// identity refuses attachment rather than guessing.
|
|
437
1182
|
return errFields('IDENTITY_UNRESOLVED', 'attach requires non-empty worktree, harness, and harness_session_id');
|
|
438
1183
|
}
|
|
439
|
-
if (
|
|
440
|
-
return errFields('IDENTITY_UNRESOLVED', '
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
449
|
-
// Refuse to attach over an in-flight maintenance op. This check and the
|
|
450
|
-
// `state === 'detached'` guard in runMaintenance are the two halves of the same
|
|
451
|
-
// mutual exclusion: both are synchronous, so whichever claims its slot first in
|
|
452
|
-
// this turn locks the other out. (Kept before `state = 'attaching'` so a refused
|
|
453
|
-
// attach leaves the state machine untouched.)
|
|
454
|
-
if (maintenanceInFlight)
|
|
455
|
-
return errFields('CAPTURE_NOT_READY', 'a maintenance operation is running; retry shortly');
|
|
456
|
-
// Set attaching BEFORE the first await so a concurrent attach is refused.
|
|
457
|
-
state = 'attaching';
|
|
458
|
-
sessionCompromised = false;
|
|
459
|
-
const id = randomUUID();
|
|
460
|
-
// Reserve the registry entry (boundary 0) before startCapture creates the log
|
|
461
|
-
// dir on disk, so the reader never over-publishes an uncommitted record from a
|
|
462
|
-
// session that momentarily appears on disk mid-startup.
|
|
463
|
-
registry.reserve(id);
|
|
464
|
-
const p = startAndActivate(id, req.worktree, req.harness, req.harness_session_id, req.root_transcript);
|
|
465
|
-
attachInFlight = p;
|
|
1184
|
+
if (!nonEmptyString(req.root_transcript)) {
|
|
1185
|
+
return errFields('IDENTITY_UNRESOLVED', 'linking a chat requires its root transcript; to record this workspace '
|
|
1186
|
+
+ 'without a chat, run `slipstream attach --watch-only`');
|
|
1187
|
+
}
|
|
1188
|
+
if (!isHarness(req.harness))
|
|
1189
|
+
return errFields('IDENTITY_UNRESOLVED', 'root_transcript requires a supported harness');
|
|
1190
|
+
const harness = req.harness;
|
|
1191
|
+
const harnessSessionId = req.harness_session_id;
|
|
1192
|
+
let root;
|
|
466
1193
|
try {
|
|
467
|
-
|
|
1194
|
+
root = await realpath(req.worktree);
|
|
1195
|
+
}
|
|
1196
|
+
catch (err) {
|
|
1197
|
+
return errFields('CAPTURE_NOT_READY', err.message);
|
|
1198
|
+
}
|
|
1199
|
+
let transcript;
|
|
1200
|
+
try {
|
|
1201
|
+
transcript = await verifyRootTranscript(harness, harnessSessionId, root, req.root_transcript);
|
|
1202
|
+
}
|
|
1203
|
+
catch (err) {
|
|
1204
|
+
if (err instanceof RootIdentityError)
|
|
1205
|
+
return errFields('IDENTITY_UNRESOLVED', err.message);
|
|
1206
|
+
throw err;
|
|
1207
|
+
}
|
|
1208
|
+
const admitted = await admit(root, { resumePaused: false });
|
|
1209
|
+
if (isErrorFields(admitted))
|
|
1210
|
+
return admitted;
|
|
1211
|
+
const c = admitted.capture;
|
|
1212
|
+
if (c.lifecycle !== 'recording' || !isRunning(c))
|
|
1213
|
+
return admissionReply(admitted);
|
|
1214
|
+
return link(c, { harness, harnessSessionId, transcript }, admitted.created);
|
|
1215
|
+
}
|
|
1216
|
+
function bindingExists(harness, harnessSessionId, c) {
|
|
1217
|
+
return errFields('BINDING_EXISTS', `${harness} chat ${harnessSessionId} is already linked to the capture of ${c.root} `
|
|
1218
|
+
+ `(session ${c.id}). That link was kept. To link this chat here instead, run: slipstream unlink --session ${c.id}, `
|
|
1219
|
+
+ 'then run slipstream attach again from this chat.');
|
|
1220
|
+
}
|
|
1221
|
+
function bindReply(c, b, extra) {
|
|
1222
|
+
return { session_id: c.id, state: 'recording', binding_generation: b.generation, agent_connection: b.check.state, ...extra };
|
|
1223
|
+
}
|
|
1224
|
+
/** Link a chat to a recording capture in place: same session, no baseline, no
|
|
1225
|
+
* resume, no gap. The slot and identity are claimed synchronously, so the tenth
|
|
1226
|
+
* slot and a chat racing itself onto two captures each resolve to one link. */
|
|
1227
|
+
async function link(c, chat, created) {
|
|
1228
|
+
// Finish an admitted unlink before publishing a replacement binding.
|
|
1229
|
+
if (!c.binding)
|
|
1230
|
+
await Promise.allSettled([...c.inflight]);
|
|
1231
|
+
if (torn || compromised || quiescing || c.lifecycle !== 'recording' || !isRunning(c)) {
|
|
1232
|
+
return errFields('CAPTURE_NOT_READY', 'the capture is no longer recording');
|
|
1233
|
+
}
|
|
1234
|
+
const mine = c.binding;
|
|
1235
|
+
if (mine && mine.harness === chat.harness && mine.harnessSessionId === chat.harnessSessionId) {
|
|
1236
|
+
const failed = await mine.publishing;
|
|
1237
|
+
if (failed)
|
|
1238
|
+
return failed;
|
|
1239
|
+
if (c.binding !== mine)
|
|
1240
|
+
return errFields('CAPTURE_NOT_READY', 'the chat was unlinked while it was being linked; retry attach');
|
|
1241
|
+
if (mine.unavailable)
|
|
1242
|
+
return relink(c, mine, chat.transcript);
|
|
1243
|
+
if (mine.check.state === 'setup_pending')
|
|
1244
|
+
mine.check.offeredAtMs = undefined;
|
|
1245
|
+
return bindReply(c, mine, { already_active: true, created: false });
|
|
1246
|
+
}
|
|
1247
|
+
if (mine)
|
|
1248
|
+
return bindingExists(mine.harness, mine.harnessSessionId, c);
|
|
1249
|
+
const elsewhere = captureForChat(chat.harness, chat.harnessSessionId);
|
|
1250
|
+
if (elsewhere)
|
|
1251
|
+
return bindingExists(chat.harness, chat.harnessSessionId, elsewhere);
|
|
1252
|
+
const refused = intentRefusal();
|
|
1253
|
+
if (refused)
|
|
1254
|
+
return refused;
|
|
1255
|
+
if (linkedCount(intent) >= MAX_BINDINGS)
|
|
1256
|
+
return errFields('BINDING_LIMIT_REACHED', BINDING_LIMIT_MESSAGE);
|
|
1257
|
+
const entry = linkBinding(intent, {
|
|
1258
|
+
session_id: c.id, harness: chat.harness, harness_session_id: chat.harnessSessionId,
|
|
1259
|
+
canonical_worktree: c.root, verified_transcript_path: chat.transcript,
|
|
1260
|
+
});
|
|
1261
|
+
const b = { ...chat, generation: entry.binding_generation,
|
|
1262
|
+
check: { state: 'setup_pending', checkId: randomUUID() } };
|
|
1263
|
+
c.binding = b;
|
|
1264
|
+
// The binding is durable before any event names it.
|
|
1265
|
+
const publishing = persistIntent().then(() => publishBinding(c, b, { record: true, revokeOnFailure: true }), (err) => {
|
|
1266
|
+
if (c.binding === b)
|
|
1267
|
+
c.binding = undefined;
|
|
1268
|
+
revokeBinding(intent, c.id);
|
|
1269
|
+
return errFields('STORAGE_UNAVAILABLE', err.message);
|
|
1270
|
+
});
|
|
1271
|
+
b.publishing = publishing;
|
|
1272
|
+
c.inflight.add(publishing);
|
|
1273
|
+
let failed;
|
|
1274
|
+
try {
|
|
1275
|
+
failed = await publishing;
|
|
468
1276
|
}
|
|
469
1277
|
finally {
|
|
470
|
-
|
|
471
|
-
|
|
1278
|
+
c.inflight.delete(publishing);
|
|
1279
|
+
b.publishing = undefined;
|
|
472
1280
|
}
|
|
1281
|
+
return failed ?? bindReply(c, b, { created });
|
|
473
1282
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
1283
|
+
/** The same chat attaching again to its unavailable link, with a transcript that
|
|
1284
|
+
* verifies now: the link keeps its generation and is published pending again. */
|
|
1285
|
+
async function relink(c, b, transcript) {
|
|
1286
|
+
const refused = intentRefusal();
|
|
1287
|
+
if (refused)
|
|
1288
|
+
return refused;
|
|
1289
|
+
const entry = findLinked(intent, b.harness, b.harnessSessionId);
|
|
1290
|
+
if (!entry || entry.session_id !== c.id)
|
|
1291
|
+
return errFields('CAPTURE_NOT_READY', 'the chat was unlinked while it was being linked; retry attach');
|
|
1292
|
+
entry.unavailable = null;
|
|
1293
|
+
entry.verified_transcript_path = transcript;
|
|
1294
|
+
b.transcript = transcript;
|
|
1295
|
+
b.unavailable = false;
|
|
1296
|
+
b.check = { state: 'setup_pending', checkId: randomUUID() };
|
|
1297
|
+
const publishing = persistIntent().then(() => publishBinding(c, b, { record: !c.session.bindingRecorded(b.generation), revokeOnFailure: false }), (err) => errFields('STORAGE_UNAVAILABLE', err.message));
|
|
1298
|
+
b.publishing = publishing;
|
|
1299
|
+
c.inflight.add(publishing);
|
|
1300
|
+
let failed;
|
|
1301
|
+
try {
|
|
1302
|
+
failed = await publishing;
|
|
477
1303
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
1304
|
+
finally {
|
|
1305
|
+
c.inflight.delete(publishing);
|
|
1306
|
+
if (b.publishing === publishing)
|
|
1307
|
+
b.publishing = undefined;
|
|
1308
|
+
}
|
|
1309
|
+
return failed ?? bindReply(c, b, { created: false });
|
|
1310
|
+
}
|
|
1311
|
+
/** Append a binding's events in contract order, then publish it to hooks and
|
|
1312
|
+
* forwarders. A new link that cannot be recorded is revoked again. */
|
|
1313
|
+
async function publishBinding(c, b, how) {
|
|
1314
|
+
const session = c.session;
|
|
1315
|
+
const target = { harness: b.harness, harness_session_id: b.harnessSessionId };
|
|
1316
|
+
const own = () => {
|
|
1317
|
+
if (torn || compromised || c.binding !== b || c.lifecycle !== 'recording' || c.activation?.lost) {
|
|
1318
|
+
throw new QuestionError('STORAGE_UNAVAILABLE', 'the capture stopped or the chat was unlinked while it was being linked');
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
let recorded = false;
|
|
482
1322
|
try {
|
|
483
|
-
|
|
1323
|
+
if (how.record) {
|
|
1324
|
+
const bindingRecord = session.recordAgent({ type: 'slipstream.agent.binding.v1', occurred_at_ms: Date.now(), data: {
|
|
1325
|
+
binding_generation: b.generation, target: { ...target, worktree: c.root }, verified_transcript_path: b.transcript
|
|
1326
|
+
} }, own);
|
|
1327
|
+
b.bindingRecord = bindingRecord;
|
|
1328
|
+
try {
|
|
1329
|
+
await bindingRecord;
|
|
1330
|
+
recorded = true;
|
|
1331
|
+
}
|
|
1332
|
+
finally {
|
|
1333
|
+
if (b.bindingRecord === bindingRecord)
|
|
1334
|
+
b.bindingRecord = undefined;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
await session.recordAgent({ type: 'slipstream.agent.connection.v1', occurred_at_ms: Date.now(), data: {
|
|
1338
|
+
state: 'setup_pending', binding_generation: b.generation, target
|
|
1339
|
+
} }, own);
|
|
484
1340
|
}
|
|
485
1341
|
catch (err) {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
return
|
|
1342
|
+
if (how.revokeOnFailure && c.binding === b) {
|
|
1343
|
+
c.binding = undefined;
|
|
1344
|
+
revokeBinding(intent, c.id);
|
|
1345
|
+
persistQuietly();
|
|
1346
|
+
// The log already says this chat is linked; say it no longer is.
|
|
1347
|
+
if (recorded) {
|
|
1348
|
+
await session.recordAgent({ type: 'slipstream.agent.unlinked.v1', occurred_at_ms: Date.now(),
|
|
1349
|
+
data: { binding_generation: b.generation, reason: 'unlinked' } }).catch(() => { });
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
return controlFailure(err);
|
|
1353
|
+
}
|
|
1354
|
+
registry.setAgentConnection(c.id, 'setup_pending');
|
|
1355
|
+
return undefined;
|
|
1356
|
+
}
|
|
1357
|
+
async function detach(req) {
|
|
1358
|
+
if (req.reason !== undefined && req.reason !== 'archived')
|
|
1359
|
+
return errFields('PROTOCOL', `unknown detach reason: ${String(req.reason)}`);
|
|
1360
|
+
const why = req.reason === 'archived' ? 'archived' : 'detached';
|
|
1361
|
+
const c = await resolveTarget(req.target);
|
|
1362
|
+
if (isErrorFields(c))
|
|
1363
|
+
return c;
|
|
1364
|
+
if (torn || compromised)
|
|
1365
|
+
return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
|
|
1366
|
+
// A path withdraws every capture recorded at it, as the offline path does: an
|
|
1367
|
+
// older capture of a replaced root would otherwise keep its slot.
|
|
1368
|
+
const targets = captures.has(req.target) ? [c] : [...captures.values()].filter((x) => x.root === c.root);
|
|
1369
|
+
const pending = targets.filter((t) => {
|
|
1370
|
+
const entry = findCapture(intent, t.id);
|
|
1371
|
+
return entry !== undefined && entry.intent !== 'withdrawn';
|
|
1372
|
+
});
|
|
1373
|
+
if (pending.length > 0) {
|
|
1374
|
+
const refused = intentRefusal();
|
|
1375
|
+
if (refused)
|
|
1376
|
+
return refused;
|
|
1377
|
+
for (const t of pending) {
|
|
1378
|
+
withdrawCapture(intent, t.id, why);
|
|
1379
|
+
revokeBinding(intent, t.id);
|
|
1380
|
+
}
|
|
1381
|
+
const durable = persistIntent();
|
|
1382
|
+
for (const t of pending) {
|
|
1383
|
+
const prior = t.closing;
|
|
1384
|
+
// Set in this turn, so a concurrent detach waits for the same stop. A
|
|
1385
|
+
// withdrawal that is not durable stops nothing, and is never acknowledged.
|
|
1386
|
+
setClosing(t, durable.then(() => closeCapture(t, prior, why), (err) => { t.withdrawalError = err; }));
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
const already = !pending.includes(c) && findCapture(intent, c.id)?.intent === 'withdrawn' && c.lifecycle === 'stopped';
|
|
1390
|
+
await Promise.all(targets.map((t) => t.closing));
|
|
1391
|
+
const undurable = targets.find((t) => t.withdrawalError);
|
|
1392
|
+
if (undurable)
|
|
1393
|
+
return errFields('STORAGE_UNAVAILABLE', `the withdrawal was not saved: ${undurable.withdrawalError.message}`);
|
|
1394
|
+
if (c.closeError) {
|
|
1395
|
+
return errFields('STORAGE_UNAVAILABLE', `detach did not stop cleanly: ${c.closeError.message}`);
|
|
1396
|
+
}
|
|
1397
|
+
const { state, reason } = publicState(c);
|
|
1398
|
+
return { session_id: c.id, state, reason, ...(already ? { already_stopped: true } : {}) };
|
|
1399
|
+
}
|
|
1400
|
+
/** Stop a withdrawn capture: unlink its chat, then record the stop. */
|
|
1401
|
+
async function closeCapture(c, prior, why) {
|
|
1402
|
+
clearTimeout(c.retryTimer);
|
|
1403
|
+
const from = c.lifecycle;
|
|
1404
|
+
if (from === 'queued') {
|
|
1405
|
+
// Not activated: the start job sees the withdrawal and starts nothing. It is
|
|
1406
|
+
// `stopped` only if its last activation, if any, ended in a durable stop (§6).
|
|
1407
|
+
const last = c.resume ? await finalRecord(sessionLogPath(storeDir, c.id)).catch(() => undefined) : undefined;
|
|
1408
|
+
c.lifecycle = !c.resume || last?.type === 'slipstream.capture.stopped.v1' ? 'stopped' : 'failed';
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
c.lifecycle = 'stopping';
|
|
1412
|
+
if (from === 'baselining')
|
|
1413
|
+
await c.starting;
|
|
1414
|
+
await prior;
|
|
1415
|
+
// An unlink can join while an earlier admitted task is draining.
|
|
1416
|
+
while (c.inflight.size)
|
|
1417
|
+
await Promise.allSettled([...c.inflight]);
|
|
1418
|
+
// Detach already revoked the binding's intent with the withdrawal.
|
|
1419
|
+
const b = c.binding;
|
|
1420
|
+
c.binding = undefined;
|
|
1421
|
+
if (b) {
|
|
1422
|
+
cancelRearm(b);
|
|
1423
|
+
await b.bindingRecord?.catch(() => undefined);
|
|
1424
|
+
}
|
|
1425
|
+
if (registry.get(c.id))
|
|
1426
|
+
registry.setAgentConnection(c.id, 'none');
|
|
1427
|
+
const session = c.session;
|
|
1428
|
+
if (!session) {
|
|
1429
|
+
// Its activation already ended in a durable stop, or its storage failed
|
|
1430
|
+
// earlier and it was halted without one.
|
|
1431
|
+
c.lifecycle = c.ended || !c.activation ? 'stopped' : 'failed';
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
c.session = undefined;
|
|
1435
|
+
try {
|
|
1436
|
+
// A link the log never named (a crash before its `agent.binding`) has no
|
|
1437
|
+
// unlink to record.
|
|
1438
|
+
const named = b && session.bindingRecorded(b.generation);
|
|
1439
|
+
await session.stop(named ? { reason: why, unlinkGeneration: b.generation, assertOwnership: assertStoreOwned }
|
|
1440
|
+
: { reason: why, assertOwnership: assertStoreOwned });
|
|
1441
|
+
c.lifecycle = 'stopped';
|
|
1442
|
+
}
|
|
1443
|
+
catch (err) {
|
|
1444
|
+
c.closeError = err;
|
|
1445
|
+
c.lifecycle = 'failed';
|
|
1446
|
+
}
|
|
1447
|
+
// Pin the reader at the final durable seq (honest even if stop() failed).
|
|
1448
|
+
registry.freeze(c.id, BigInt(session.health.snapshot().durable_seq));
|
|
1449
|
+
}
|
|
1450
|
+
/** Revoke a capture's chat link; the capture keeps recording. */
|
|
1451
|
+
async function unlinkChat(req) {
|
|
1452
|
+
const c = nonEmptyString(req.session_id) ? captures.get(req.session_id) : undefined;
|
|
1453
|
+
if (!c)
|
|
1454
|
+
return errFields('TARGET_NOT_FOUND', `no capture with session ${String(req.session_id)}`);
|
|
1455
|
+
if (torn || compromised)
|
|
1456
|
+
return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
|
|
1457
|
+
if (quiescing)
|
|
1458
|
+
return errFields('CAPTURE_NOT_READY', 'gc is reclaiming storage; retry shortly');
|
|
1459
|
+
const b = c.binding;
|
|
1460
|
+
if (!b) {
|
|
1461
|
+
// An unlink still being saved is not yet acknowledged to anyone.
|
|
1462
|
+
if (c.unlinking && !await c.unlinking.then(() => true, () => false))
|
|
1463
|
+
return intentRefusal();
|
|
1464
|
+
return { session_id: c.id, already_unlinked: true };
|
|
1465
|
+
}
|
|
1466
|
+
const refused = intentRefusal();
|
|
1467
|
+
if (refused)
|
|
1468
|
+
return refused;
|
|
1469
|
+
c.binding = undefined;
|
|
1470
|
+
cancelRearm(b);
|
|
1471
|
+
revokeBinding(intent, c.id);
|
|
1472
|
+
if (registry.get(c.id))
|
|
1473
|
+
registry.setAgentConnection(c.id, 'none');
|
|
1474
|
+
const persisted = persistIntent();
|
|
1475
|
+
c.unlinking = persisted;
|
|
1476
|
+
const session = c.session;
|
|
1477
|
+
if (!session || !['recording', 'stopping'].includes(c.lifecycle)) {
|
|
1478
|
+
await persisted.catch(() => { });
|
|
1479
|
+
return errFields('STORAGE_UNAVAILABLE', 'the chat was unlinked, but its capture cannot record the unlink');
|
|
1480
|
+
}
|
|
1481
|
+
const admitted = [...c.inflight];
|
|
1482
|
+
const append = (async () => {
|
|
1483
|
+
// Revoke immediately, but end task selection only after old admitted tasks
|
|
1484
|
+
// have committed and the revocation is durable. Detach also drains this
|
|
1485
|
+
// promise before closing the log.
|
|
1486
|
+
await persisted;
|
|
1487
|
+
await Promise.allSettled(admitted);
|
|
1488
|
+
await b.bindingRecord?.catch(() => undefined);
|
|
1489
|
+
if (!session.bindingRecorded(b.generation))
|
|
1490
|
+
return;
|
|
1491
|
+
await session.recordAgent({ type: 'slipstream.agent.unlinked.v1', occurred_at_ms: Date.now(),
|
|
1492
|
+
data: { binding_generation: b.generation, reason: 'unlinked' } });
|
|
1493
|
+
})();
|
|
1494
|
+
c.inflight.add(append);
|
|
1495
|
+
try {
|
|
1496
|
+
await append;
|
|
1497
|
+
}
|
|
1498
|
+
catch (err) {
|
|
1499
|
+
return errFields('STORAGE_UNAVAILABLE', `the chat was unlinked, but the unlink could not be recorded: ${err.message}`);
|
|
1500
|
+
}
|
|
1501
|
+
finally {
|
|
1502
|
+
c.inflight.delete(append);
|
|
1503
|
+
}
|
|
1504
|
+
return { session_id: c.id, binding_generation: b.generation };
|
|
1505
|
+
}
|
|
1506
|
+
// ---- chat-facing verbs --------------------------------------------------
|
|
1507
|
+
/** Capture-level ownership check run immediately before an append: nothing
|
|
1508
|
+
* about any chat, only that this capture still holds its storage. */
|
|
1509
|
+
function ownsCapture(c) {
|
|
1510
|
+
return () => {
|
|
1511
|
+
const live = c.lifecycle === 'recording' || c.lifecycle === 'stopping';
|
|
1512
|
+
if (compromised || torn || !live || c.activation?.lost) {
|
|
1513
|
+
throw new QuestionError('STORAGE_UNAVAILABLE', 'the selected capture lost storage ownership');
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
/** Ownership check run immediately before an append on a chat's behalf. A
|
|
1518
|
+
* stopping capture still accepts work it admitted: the stop drains it before
|
|
1519
|
+
* revoking the binding. */
|
|
1520
|
+
function owns(c, b) {
|
|
1521
|
+
const capture = ownsCapture(c);
|
|
1522
|
+
return () => {
|
|
1523
|
+
capture();
|
|
1524
|
+
if (c.binding !== b)
|
|
1525
|
+
throw new QuestionError('STORAGE_UNAVAILABLE', 'the selected capture lost storage ownership');
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
/** The chat's published binding and its capture, if it may act now. */
|
|
1529
|
+
function chatTarget(c) {
|
|
1530
|
+
const b = c?.binding;
|
|
1531
|
+
return c && b && !b.publishing && !b.unavailable && c.lifecycle === 'recording' ? { c, b } : undefined;
|
|
1532
|
+
}
|
|
1533
|
+
/** The chat's binding when a request arrives. A request that awaits before it
|
|
1534
|
+
* acts must still find this same binding afterwards: an unlink and relink in
|
|
1535
|
+
* between is a different binding, even at the same capture. */
|
|
1536
|
+
function bindingAtEntry(req) {
|
|
1537
|
+
return captureForChat(req.harness, req.harness_session_id)?.binding;
|
|
1538
|
+
}
|
|
1539
|
+
function staleBinding(req, c, b) {
|
|
1540
|
+
if (req.session_id !== undefined && req.session_id !== c.id) {
|
|
1541
|
+
return errFields('STALE_BINDING', `session_id ${String(req.session_id)} is not the capture this chat is linked to`);
|
|
1542
|
+
}
|
|
1543
|
+
if (req.binding_generation !== undefined && req.binding_generation !== b.generation) {
|
|
1544
|
+
return errFields('STALE_BINDING', `binding_generation ${String(req.binding_generation)} is no longer current`);
|
|
1545
|
+
}
|
|
1546
|
+
return undefined;
|
|
1547
|
+
}
|
|
1548
|
+
async function track(c, promise, ok) {
|
|
1549
|
+
c.inflight.add(promise);
|
|
1550
|
+
try {
|
|
1551
|
+
return ok(await promise);
|
|
1552
|
+
}
|
|
1553
|
+
catch (err) {
|
|
1554
|
+
if (err instanceof InvalidTitleError)
|
|
1555
|
+
return errFields('INVALID_TITLE', err.message);
|
|
1556
|
+
return controlFailure(err);
|
|
1557
|
+
}
|
|
1558
|
+
finally {
|
|
1559
|
+
c.inflight.delete(promise);
|
|
1560
|
+
}
|
|
500
1561
|
}
|
|
501
1562
|
async function beginTask(req) {
|
|
502
|
-
|
|
503
|
-
return errFields('STORAGE_UNAVAILABLE', 'capture is wedged; its storage is no longer trustworthy');
|
|
504
|
-
if (state !== 'active' || !current) {
|
|
505
|
-
if (state === 'attaching')
|
|
506
|
-
return errFields('CAPTURE_NOT_READY', 'capture is still starting');
|
|
507
|
-
if (state === 'detaching')
|
|
508
|
-
return errFields('CAPTURE_NOT_READY', 'capture is detaching');
|
|
509
|
-
return errFields('SESSION_NOT_SELECTED', 'no active session');
|
|
510
|
-
}
|
|
511
|
-
if (req.session_id !== undefined && req.session_id !== current.id) {
|
|
512
|
-
// The optional target guards a retry after detach+attach-B from misrouting to
|
|
513
|
-
// a different session than the caller declared.
|
|
514
|
-
return errFields('SESSION_NOT_SELECTED', `session_id ${String(req.session_id)} is not the selected session`);
|
|
515
|
-
}
|
|
516
|
-
// Selection guard (locked design, decision X): the caller ships its verified
|
|
517
|
-
// identity triple and only the selected session's exact triple may declare.
|
|
518
|
-
// The P4 forwarder always sends the triple; a missing one is a contract
|
|
1563
|
+
// The caller ships its verified identity triple; a missing one is a contract
|
|
519
1564
|
// violation, so fail closed rather than declare into whatever is selected.
|
|
520
1565
|
if (!nonEmptyString(req.harness) || !nonEmptyString(req.harness_session_id) || !nonEmptyString(req.worktree)) {
|
|
521
1566
|
return errFields('IDENTITY_UNRESOLVED', 'begin_task requires harness, harness_session_id, and worktree');
|
|
@@ -524,123 +1569,120 @@ export async function startDaemon(opts) {
|
|
|
524
1569
|
return errFields('INVALID_TITLE', 'begin_task requires a non-empty title');
|
|
525
1570
|
if (!nonEmptyString(req.request_id))
|
|
526
1571
|
return errFields('CAPTURE_NOT_READY', 'begin_task requires a non-empty request_id');
|
|
527
|
-
// Canonicalize the declared worktree with the
|
|
528
|
-
//
|
|
529
|
-
|
|
530
|
-
let
|
|
1572
|
+
// Canonicalize the declared worktree with the same realpath policy attach
|
|
1573
|
+
// uses, so a symlinked or relative declared path matches the capture's root.
|
|
1574
|
+
const atEntry = bindingAtEntry(req);
|
|
1575
|
+
let worktree;
|
|
531
1576
|
try {
|
|
532
|
-
|
|
1577
|
+
worktree = await identityRealpath(req.worktree);
|
|
533
1578
|
}
|
|
534
1579
|
catch {
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
// Re-verify selection AFTER the realpath await: a concurrent detach, teardown,
|
|
538
|
-
// or store-lock loss may have changed state or swapped the session while we
|
|
539
|
-
// canonicalized. From here through inflightTasks.add there is no await, so
|
|
540
|
-
// neither a detach nor a teardown can slip between these checks and the append
|
|
541
|
-
// (they either observe the task in flight and drain it, or are observed here).
|
|
542
|
-
// Teardown fails closed without touching `state`, so check `torn`/`compromised`
|
|
543
|
-
// explicitly — otherwise a request could append after the daemon has already
|
|
544
|
-
// begun shutting down or lost its store lock.
|
|
545
|
-
if (compromised || torn) {
|
|
546
|
-
return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
|
|
547
|
-
}
|
|
548
|
-
if (state !== 'active' || !current) {
|
|
549
|
-
return errFields('SESSION_NOT_SELECTED', 'the selected session changed before the declaration committed');
|
|
550
|
-
}
|
|
551
|
-
// Re-apply the optional session_id target guard after the await, exactly like
|
|
552
|
-
// the pre-await check: a concurrent detach+attach may have swapped in a session
|
|
553
|
-
// sharing the triple, and a request explicitly addressed to the prior session
|
|
554
|
-
// must not commit into its replacement.
|
|
555
|
-
if (req.session_id !== undefined && req.session_id !== current.id) {
|
|
556
|
-
return errFields('SESSION_NOT_SELECTED', `session_id ${String(req.session_id)} is not the selected session`);
|
|
557
|
-
}
|
|
558
|
-
if (req.harness !== current.harness ||
|
|
559
|
-
req.harness_session_id !== current.harnessSessionId ||
|
|
560
|
-
declaredWorktree !== current.worktree) {
|
|
561
|
-
return errFields('SESSION_NOT_SELECTED', 'the declared identity is not the selected session');
|
|
562
|
-
}
|
|
563
|
-
const { session } = current;
|
|
564
|
-
const promise = session.beginTask({ title: req.title, requestId: req.request_id });
|
|
565
|
-
inflightTasks.add(promise);
|
|
566
|
-
try {
|
|
567
|
-
const result = await promise;
|
|
568
|
-
return {
|
|
569
|
-
session_id: result.session_id,
|
|
570
|
-
task_id: result.task_id,
|
|
571
|
-
event_id: result.event_id,
|
|
572
|
-
seq: result.seq,
|
|
573
|
-
};
|
|
574
|
-
}
|
|
575
|
-
catch (err) {
|
|
576
|
-
if (err instanceof InvalidTitleError)
|
|
577
|
-
return errFields('INVALID_TITLE', err.message);
|
|
578
|
-
if (err instanceof StorageError)
|
|
579
|
-
return errFields('STORAGE_UNAVAILABLE', err.message);
|
|
580
|
-
return errFields('CAPTURE_NOT_READY', err.message);
|
|
1580
|
+
worktree = req.worktree;
|
|
581
1581
|
}
|
|
582
|
-
|
|
583
|
-
|
|
1582
|
+
// Everything below runs without an await until the append is admitted, so a
|
|
1583
|
+
// concurrent unlink or detach either sees it in flight or is seen here.
|
|
1584
|
+
if (compromised || torn)
|
|
1585
|
+
return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
|
|
1586
|
+
const linked = captureForChat(req.harness, req.harness_session_id);
|
|
1587
|
+
if (!linked || linked.root !== worktree) {
|
|
1588
|
+
const here = liveCaptureAt(worktree);
|
|
1589
|
+
if (here && !here.binding)
|
|
1590
|
+
return errFields('AGENT_NOT_CONNECTED', WATCH_ONLY_MESSAGE);
|
|
1591
|
+
return errFields('SESSION_NOT_SELECTED', `this chat is not linked to a capture of ${worktree}`);
|
|
584
1592
|
}
|
|
1593
|
+
if (linked.activation?.lost)
|
|
1594
|
+
return errFields('STORAGE_UNAVAILABLE', 'capture storage is no longer trustworthy');
|
|
1595
|
+
const target = chatTarget(linked);
|
|
1596
|
+
if (!target)
|
|
1597
|
+
return errFields('CAPTURE_NOT_READY', 'the linked capture is not recording yet');
|
|
1598
|
+
if (target.b !== atEntry)
|
|
1599
|
+
return errFields('SESSION_NOT_SELECTED', 'this chat was unlinked or relinked while the request was in flight');
|
|
1600
|
+
const { c, b } = target;
|
|
1601
|
+
const stale = staleBinding(req, c, b);
|
|
1602
|
+
if (stale)
|
|
1603
|
+
return stale;
|
|
1604
|
+
if (b.check.state !== 'connected')
|
|
1605
|
+
return errFields('AGENT_NOT_CONNECTED', SETUP_PENDING_MESSAGE);
|
|
1606
|
+
return track(c, c.session.beginTask({ title: req.title, requestId: req.request_id }), (r) => c.binding !== b
|
|
1607
|
+
? errFields('STALE_BINDING', `task ${r.task_id} was recorded, but this chat was unlinked before it could be acknowledged`)
|
|
1608
|
+
: { session_id: r.session_id, task_id: r.task_id, event_id: r.event_id, seq: r.seq });
|
|
585
1609
|
}
|
|
586
|
-
/** Queue a question on the
|
|
587
|
-
* request reservation synchronously;
|
|
588
|
-
* in the same turn so detach and shutdown drain
|
|
1610
|
+
/** Queue a question on the capture the app names. `askQuestion` installs its own
|
|
1611
|
+
* request reservation synchronously; the daemon-level lifetime promise is
|
|
1612
|
+
* installed in the same turn so detach and shutdown drain it. */
|
|
589
1613
|
async function ask(req) {
|
|
590
1614
|
if (compromised || torn)
|
|
591
1615
|
return errFields('STORAGE_UNAVAILABLE', 'daemon storage ownership is unavailable');
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
if (
|
|
600
|
-
return errFields('
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1616
|
+
const c = nonEmptyString(req.session_id) ? captures.get(req.session_id) : undefined;
|
|
1617
|
+
if (!c || !isLive(c))
|
|
1618
|
+
return errFields('SESSION_NOT_SELECTED', 'no capture with that session id is running');
|
|
1619
|
+
if (c.activation?.lost)
|
|
1620
|
+
return errFields('STORAGE_UNAVAILABLE', 'capture storage is no longer trustworthy');
|
|
1621
|
+
if (!c.binding)
|
|
1622
|
+
return errFields('AGENT_NOT_CONNECTED', WATCH_ONLY_MESSAGE);
|
|
1623
|
+
if (req.binding_generation !== undefined && req.binding_generation !== c.binding.generation) {
|
|
1624
|
+
return errFields('STALE_BINDING', `binding_generation ${String(req.binding_generation)} is no longer current`);
|
|
1625
|
+
}
|
|
1626
|
+
if (c.lifecycle !== 'recording' || !isRunning(c))
|
|
1627
|
+
return errFields('CAPTURE_NOT_READY', 'the capture is not recording yet');
|
|
1628
|
+
const target = chatTarget(c);
|
|
1629
|
+
if (!target)
|
|
1630
|
+
return errFields('AGENT_NOT_CONNECTED', SETUP_PENDING_MESSAGE);
|
|
1631
|
+
const { b } = target;
|
|
1632
|
+
if (b.check.state !== 'connected')
|
|
1633
|
+
return errFields('AGENT_NOT_CONNECTED', SETUP_PENDING_MESSAGE);
|
|
1634
|
+
return track(c, c.session.askQuestion(req, {
|
|
1635
|
+
harness: b.harness, harness_session_id: b.harnessSessionId, worktree: c.root,
|
|
1636
|
+
}, owns(c, b)), (r) => ({ ...r }));
|
|
1637
|
+
}
|
|
1638
|
+
/** Record a question and the answer the client's own forked chat produced. The
|
|
1639
|
+
* client is the writer, so there is no binding, chat or connection to check:
|
|
1640
|
+
* the capture only has to be recording. The conversation ids it reports are
|
|
1641
|
+
* stored as client-reported and are never used to bind or attribute. */
|
|
1642
|
+
async function recordExchange(req) {
|
|
1643
|
+
if (compromised || torn)
|
|
1644
|
+
return errFields('STORAGE_UNAVAILABLE', 'daemon storage ownership is unavailable');
|
|
1645
|
+
const c = nonEmptyString(req.session_id) ? captures.get(req.session_id) : undefined;
|
|
1646
|
+
if (!c || !isLive(c))
|
|
1647
|
+
return errFields('SESSION_NOT_SELECTED', 'no capture with that session id is running');
|
|
1648
|
+
if (c.activation?.lost)
|
|
1649
|
+
return errFields('STORAGE_UNAVAILABLE', 'capture storage is no longer trustworthy');
|
|
1650
|
+
if (c.lifecycle !== 'recording' || !isRunning(c))
|
|
1651
|
+
return errFields('CAPTURE_NOT_READY', 'the capture is not recording yet');
|
|
1652
|
+
return track(c, c.session.recordQuestionExchange(req, ownsCapture(c)), (r) => ({ ...r }));
|
|
1653
|
+
}
|
|
1654
|
+
/** Record one later step of a change request sent from a recorded exchange.
|
|
1655
|
+
* The body is checked first so the bodiless capability probe is PROTOCOL
|
|
1656
|
+
* whatever state the capture is in. */
|
|
1657
|
+
async function recordRelayUpdate(req) {
|
|
1658
|
+
if (compromised || torn)
|
|
1659
|
+
return errFields('STORAGE_UNAVAILABLE', 'daemon storage ownership is unavailable');
|
|
616
1660
|
try {
|
|
617
|
-
|
|
618
|
-
return { ...result };
|
|
1661
|
+
normalizeRelayUpdate(req);
|
|
619
1662
|
}
|
|
620
1663
|
catch (err) {
|
|
621
|
-
|
|
622
|
-
return errFields(err.code, err.message);
|
|
623
|
-
if (err instanceof StorageError)
|
|
624
|
-
return errFields('STORAGE_UNAVAILABLE', err.message);
|
|
625
|
-
return errFields('CAPTURE_NOT_READY', err.message);
|
|
626
|
-
}
|
|
627
|
-
finally {
|
|
628
|
-
inflightTasks.delete(promise);
|
|
1664
|
+
return errFields(err.code, err.message);
|
|
629
1665
|
}
|
|
1666
|
+
const c = captures.get(req.session_id);
|
|
1667
|
+
if (!c || !isLive(c))
|
|
1668
|
+
return errFields('SESSION_NOT_SELECTED', 'no capture with that session id is running');
|
|
1669
|
+
if (c.activation?.lost)
|
|
1670
|
+
return errFields('STORAGE_UNAVAILABLE', 'capture storage is no longer trustworthy');
|
|
1671
|
+
if (c.lifecycle !== 'recording' || !isRunning(c))
|
|
1672
|
+
return errFields('CAPTURE_NOT_READY', 'the capture is not recording yet');
|
|
1673
|
+
return track(c, c.session.recordQuestionRelayUpdate(req, ownsCapture(c)), (r) => ({ ...r }));
|
|
630
1674
|
}
|
|
1675
|
+
/** The question hook asks for this chat's next question (or its setup check).
|
|
1676
|
+
* Only a linked root chat may claim: subagents and other chats are refused. */
|
|
631
1677
|
async function claimQuestion(req) {
|
|
632
|
-
if (compromised || torn
|
|
1678
|
+
if (compromised || torn)
|
|
633
1679
|
return errFields('STORAGE_UNAVAILABLE', 'capture storage is unavailable');
|
|
634
|
-
if (
|
|
635
|
-
return errFields('SESSION_NOT_SELECTED', 'no active capture');
|
|
636
|
-
if ((current.harness !== 'codex' && current.harness !== 'claude-code')
|
|
637
|
-
|| !current.rootTranscript || req.harness !== current.harness
|
|
638
|
-
|| !nonEmptyString(req.harness_session_id) || !nonEmptyString(req.worktree)
|
|
1680
|
+
if (!isHarness(req.harness) || !nonEmptyString(req.harness_session_id) || !nonEmptyString(req.worktree)
|
|
639
1681
|
|| !nonEmptyString(req.transcript_path)
|
|
640
1682
|
|| Object.hasOwn(req, 'agent_id') || Object.hasOwn(req, 'agent_type')) {
|
|
641
1683
|
return errFields('IDENTITY_UNRESOLVED', 'unsupported or incomplete root identity');
|
|
642
1684
|
}
|
|
643
|
-
const
|
|
1685
|
+
const atEntry = bindingAtEntry(req);
|
|
644
1686
|
let worktree;
|
|
645
1687
|
let transcript;
|
|
646
1688
|
try {
|
|
@@ -649,46 +1691,39 @@ export async function startDaemon(opts) {
|
|
|
649
1691
|
catch {
|
|
650
1692
|
return errFields('IDENTITY_UNRESOLVED', 'identity path cannot be resolved');
|
|
651
1693
|
}
|
|
652
|
-
if (compromised || torn
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
throw new QuestionError('STORAGE_UNAVAILABLE', 'the selected capture lost storage ownership');
|
|
661
|
-
}
|
|
662
|
-
});
|
|
663
|
-
inflightTasks.add(promise);
|
|
664
|
-
try {
|
|
665
|
-
const result = await promise;
|
|
666
|
-
return result === null ? { question: null } : { question: result };
|
|
667
|
-
}
|
|
668
|
-
catch (err) {
|
|
669
|
-
if (err instanceof QuestionError)
|
|
670
|
-
return errFields(err.code, err.message);
|
|
671
|
-
if (err instanceof StorageError)
|
|
672
|
-
return errFields('STORAGE_UNAVAILABLE', err.message);
|
|
673
|
-
return errFields('CAPTURE_NOT_READY', err.message);
|
|
1694
|
+
if (compromised || torn)
|
|
1695
|
+
return errFields('STORAGE_UNAVAILABLE', 'capture storage is unavailable');
|
|
1696
|
+
const linked = captureForChat(req.harness, req.harness_session_id);
|
|
1697
|
+
if (linked?.activation?.lost)
|
|
1698
|
+
return errFields('STORAGE_UNAVAILABLE', 'capture storage is no longer trustworthy');
|
|
1699
|
+
const target = chatTarget(linked);
|
|
1700
|
+
if (!target || target.b !== atEntry || target.c.root !== worktree || target.b.transcript !== transcript) {
|
|
1701
|
+
return errFields('SESSION_NOT_SELECTED', 'the callback is not a linked root chat');
|
|
674
1702
|
}
|
|
675
|
-
|
|
676
|
-
|
|
1703
|
+
const { c, b } = target;
|
|
1704
|
+
const stale = staleBinding(req, c, b);
|
|
1705
|
+
if (stale)
|
|
1706
|
+
return stale;
|
|
1707
|
+
if (b.check.state === 'setup_pending') {
|
|
1708
|
+
const now = Date.now();
|
|
1709
|
+
if (b.check.offeredAtMs !== undefined && now - b.check.offeredAtMs < reofferMs)
|
|
1710
|
+
return { question: null };
|
|
1711
|
+
b.check.offeredAtMs = now;
|
|
1712
|
+
return { question: null, setup_check: { check_id: b.check.checkId } };
|
|
677
1713
|
}
|
|
1714
|
+
return track(c, c.session.claimQuestion({ harness: b.harness, harness_session_id: b.harnessSessionId, worktree: c.root }, owns(c, b)), (r) => (r === null ? { question: null } : { question: r }));
|
|
678
1715
|
}
|
|
679
|
-
/** Record an answer returned through
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
1716
|
+
/** Record an answer returned through a linked chat's MCP tool. The caller's
|
|
1717
|
+
* identity routes it to that chat's capture; with no transcript or agent id this
|
|
1718
|
+
* proves the call came through the linked chat, not which agent in it wrote the
|
|
1719
|
+
* answer. A question queued for another chat is not found here. */
|
|
683
1720
|
async function answerQuestion(req) {
|
|
684
1721
|
if (!nonEmptyString(req.harness) || !nonEmptyString(req.harness_session_id) || !nonEmptyString(req.worktree)) {
|
|
685
1722
|
return errFields('IDENTITY_UNRESOLVED', 'answer_question requires harness, harness_session_id, and worktree');
|
|
686
1723
|
}
|
|
687
|
-
if (compromised || torn
|
|
1724
|
+
if (compromised || torn)
|
|
688
1725
|
return errFields('STORAGE_UNAVAILABLE', 'capture storage is unavailable');
|
|
689
|
-
|
|
690
|
-
return errFields('SESSION_NOT_SELECTED', 'no active capture');
|
|
691
|
-
const binding = current;
|
|
1726
|
+
const atEntry = bindingAtEntry(req);
|
|
692
1727
|
let worktree;
|
|
693
1728
|
try {
|
|
694
1729
|
worktree = await identityRealpath(req.worktree);
|
|
@@ -696,45 +1731,66 @@ export async function startDaemon(opts) {
|
|
|
696
1731
|
catch {
|
|
697
1732
|
worktree = req.worktree;
|
|
698
1733
|
}
|
|
699
|
-
if (compromised || torn
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
}
|
|
708
|
-
|
|
1734
|
+
if (compromised || torn)
|
|
1735
|
+
return errFields('STORAGE_UNAVAILABLE', 'capture storage is unavailable');
|
|
1736
|
+
const linked = captureForChat(req.harness, req.harness_session_id);
|
|
1737
|
+
if (linked?.activation?.lost)
|
|
1738
|
+
return errFields('STORAGE_UNAVAILABLE', 'capture storage is no longer trustworthy');
|
|
1739
|
+
const target = chatTarget(linked);
|
|
1740
|
+
if (!target || target.b !== atEntry || target.c.root !== worktree)
|
|
1741
|
+
return errFields('SESSION_NOT_SELECTED', 'the caller is not a linked chat');
|
|
1742
|
+
const { c, b } = target;
|
|
1743
|
+
const stale = staleBinding(req, c, b);
|
|
1744
|
+
if (stale)
|
|
1745
|
+
return stale;
|
|
1746
|
+
if (req.question_id === b.check.checkId)
|
|
1747
|
+
return confirmAgent(c, b, req.text);
|
|
1748
|
+
return track(c, c.session.answerQuestion({ question_id: req.question_id, text: req.text }, { harness: b.harness, harness_session_id: b.harnessSessionId, worktree: c.root }, owns(c, b)), (r) => ({ ...r }));
|
|
1749
|
+
}
|
|
1750
|
+
/** The linked chat returned its setup check: record it connected, once. */
|
|
1751
|
+
async function confirmAgent(c, b, text) {
|
|
1752
|
+
const check = b.check;
|
|
709
1753
|
try {
|
|
710
|
-
|
|
1754
|
+
assertAnswerText(text);
|
|
711
1755
|
}
|
|
712
1756
|
catch (err) {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
1757
|
+
return errFields(err.code, err.message);
|
|
1758
|
+
}
|
|
1759
|
+
if (check.ack)
|
|
1760
|
+
return { ...check.ack, duplicate: true };
|
|
1761
|
+
if (check.confirming) {
|
|
1762
|
+
const settled = await check.confirming;
|
|
1763
|
+
return isErrorFields(settled) ? settled : { ...settled, duplicate: true };
|
|
1764
|
+
}
|
|
1765
|
+
const promise = c.session.recordAgent({ type: 'slipstream.agent.connection.v1', occurred_at_ms: Date.now(), data: {
|
|
1766
|
+
state: 'connected', binding_generation: b.generation, target: { harness: b.harness, harness_session_id: b.harnessSessionId }
|
|
1767
|
+
} }, () => {
|
|
1768
|
+
owns(c, b)();
|
|
1769
|
+
if (b.check !== check)
|
|
1770
|
+
throw new QuestionError('STORAGE_UNAVAILABLE', 'the setup check was re-armed by a restart');
|
|
1771
|
+
}).then((event) => {
|
|
1772
|
+
check.state = 'connected';
|
|
1773
|
+
registry.setAgentConnection(c.id, 'connected');
|
|
1774
|
+
check.ack = { session_id: event.data.session_id, question_id: check.checkId, event_id: event.seq, seq: event.seq,
|
|
1775
|
+
answered_at_ms: Date.parse(event.time), duplicate: false };
|
|
1776
|
+
return check.ack;
|
|
1777
|
+
}, controlFailure);
|
|
1778
|
+
check.confirming = promise;
|
|
1779
|
+
c.inflight.add(promise);
|
|
1780
|
+
try {
|
|
1781
|
+
return await promise;
|
|
718
1782
|
}
|
|
719
1783
|
finally {
|
|
720
|
-
|
|
1784
|
+
c.inflight.delete(promise);
|
|
1785
|
+
check.confirming = undefined;
|
|
721
1786
|
}
|
|
722
1787
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
* `
|
|
726
|
-
* `detached`; anything else — an active/attaching/detaching session, a wedged or
|
|
727
|
-
* compromised store, an op already running — is refused without touching disk. */
|
|
1788
|
+
// ---- maintenance --------------------------------------------------------
|
|
1789
|
+
/** Run one maintenance op at a time. The slot is claimed synchronously through
|
|
1790
|
+
* `maintenanceInFlight`, and `op` runs up to its first await in the same turn. */
|
|
728
1791
|
async function runMaintenance(op) {
|
|
729
1792
|
if (compromised || torn)
|
|
730
1793
|
return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
|
|
731
|
-
if (state !== 'detached') {
|
|
732
|
-
// A capture in any stage — attaching, active, or detaching — is SESSION_ACTIVE;
|
|
733
|
-
// CAPTURE_NOT_READY is reserved for the maintenance-slot contention below.
|
|
734
|
-
if (state === 'wedged')
|
|
735
|
-
return errFields('STORAGE_UNAVAILABLE', 'daemon is wedged; restart it');
|
|
736
|
-
return errFields('SESSION_ACTIVE', `a capture session is ${state}; detach it before deleting or collecting`);
|
|
737
|
-
}
|
|
738
1794
|
if (maintenanceInFlight)
|
|
739
1795
|
return errFields('CAPTURE_NOT_READY', 'another maintenance operation is running; retry shortly');
|
|
740
1796
|
let release;
|
|
@@ -757,75 +1813,243 @@ export async function startDaemon(opts) {
|
|
|
757
1813
|
}
|
|
758
1814
|
const id = req.session_id;
|
|
759
1815
|
return runMaintenance(async () => {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
if (!present)
|
|
768
|
-
return errFields('SESSION_NOT_FOUND', `no session ${id} to delete`);
|
|
769
|
-
// Durable-tombstone-first: publish the marker (fsynced) BEFORE removing any
|
|
770
|
-
// history, so a crash in between leaves a session the reader already serves as
|
|
771
|
-
// gone and a later gc finishes the cleanup. Nothing is committed yet, so a
|
|
772
|
-
// failure here is a plain STORAGE_UNAVAILABLE — no rollback needed.
|
|
773
|
-
try {
|
|
774
|
-
await publishTombstone(storeDir, id);
|
|
1816
|
+
// Checked in the same turn `deleting` is set, which admission checks in the
|
|
1817
|
+
// same turn as its intent entry: neither can slip past the other.
|
|
1818
|
+
const target = captures.get(id);
|
|
1819
|
+
const entry = findCapture(intent, id);
|
|
1820
|
+
if ((entry && entry.intent !== 'withdrawn') || target?.session || (target && !['stopped', 'failed'].includes(target.lifecycle))) {
|
|
1821
|
+
const state = target ? publicState(target).state : entry.intent;
|
|
1822
|
+
return errFields('SESSION_ACTIVE', `session ${id} is ${state}; detach it and let it stop first`);
|
|
775
1823
|
}
|
|
776
|
-
|
|
777
|
-
return errFields('STORAGE_UNAVAILABLE', `could not durably record deletion of ${id}: ${err.message}`);
|
|
778
|
-
}
|
|
779
|
-
// Now that the tombstone is durable, abort any in-flight follower of this
|
|
780
|
-
// session so it re-resolves, re-reads the tombstone, and gets 410 rather than
|
|
781
|
-
// streaming a log we are about to delete. The reader's installIfAbsent gives a
|
|
782
|
-
// followed retained session a registry entry this freeze can reach.
|
|
783
|
-
registry.freeze(id, 0n);
|
|
784
|
-
// The tombstone is durable → the session is logically removed and the marker is
|
|
785
|
-
// never rolled back. If we have since lost the store lock, or history cleanup
|
|
786
|
-
// fails, report that removal committed and cleanup is retryable via gc.
|
|
787
|
-
const committed = `session ${id} is logically removed (tombstone durable); history cleanup is retryable via gc`;
|
|
788
|
-
if (compromised || torn)
|
|
789
|
-
return errFields('STORAGE_UNAVAILABLE', committed);
|
|
1824
|
+
deleting = id;
|
|
790
1825
|
try {
|
|
791
|
-
await
|
|
1826
|
+
return await deleteInactive(id);
|
|
792
1827
|
}
|
|
793
|
-
|
|
794
|
-
|
|
1828
|
+
finally {
|
|
1829
|
+
deleting = undefined;
|
|
1830
|
+
deleteDoneAt.set(id, ++deletesDone);
|
|
795
1831
|
}
|
|
796
|
-
return { session_id: id };
|
|
797
1832
|
});
|
|
798
1833
|
}
|
|
1834
|
+
async function deleteInactive(id) {
|
|
1835
|
+
let present;
|
|
1836
|
+
try {
|
|
1837
|
+
present = await sessionExists(storeDir, id);
|
|
1838
|
+
}
|
|
1839
|
+
catch (err) {
|
|
1840
|
+
return errFields('STORAGE_UNAVAILABLE', err.message);
|
|
1841
|
+
}
|
|
1842
|
+
if (!present)
|
|
1843
|
+
return errFields('SESSION_NOT_FOUND', `no session ${id} to delete`);
|
|
1844
|
+
// Durable-tombstone-first: publish the marker (fsynced) BEFORE removing any
|
|
1845
|
+
// history, so a crash in between leaves a session the reader already serves as
|
|
1846
|
+
// gone and a later gc finishes the cleanup. Nothing is committed yet, so a
|
|
1847
|
+
// failure here is a plain STORAGE_UNAVAILABLE — no rollback needed.
|
|
1848
|
+
try {
|
|
1849
|
+
await publishTombstone(storeDir, id);
|
|
1850
|
+
}
|
|
1851
|
+
catch (err) {
|
|
1852
|
+
return errFields('STORAGE_UNAVAILABLE', `could not durably record deletion of ${id}: ${err.message}`);
|
|
1853
|
+
}
|
|
1854
|
+
// Now that the tombstone is durable, abort any in-flight follower of this
|
|
1855
|
+
// session so it re-resolves, re-reads the tombstone, and gets 410 rather than
|
|
1856
|
+
// streaming a log we are about to delete. The reader's installIfAbsent gives a
|
|
1857
|
+
// followed retained session a registry entry this freeze can reach.
|
|
1858
|
+
registry.freeze(id, 0n);
|
|
1859
|
+
// Deletion removes the session's capture and binding entries (§8).
|
|
1860
|
+
forgetSession(intent, id);
|
|
1861
|
+
captures.delete(id);
|
|
1862
|
+
try {
|
|
1863
|
+
await persistIntent();
|
|
1864
|
+
}
|
|
1865
|
+
catch (err) {
|
|
1866
|
+
return errFields('STORAGE_UNAVAILABLE', `session ${id} is logically removed (tombstone durable), but ${err.message}`);
|
|
1867
|
+
}
|
|
1868
|
+
// The tombstone is durable → the session is logically removed and the marker is
|
|
1869
|
+
// never rolled back. If we have since lost the store lock, or history cleanup
|
|
1870
|
+
// fails, report that removal committed and cleanup is retryable via gc.
|
|
1871
|
+
const committed = `session ${id} is logically removed (tombstone durable); history cleanup is retryable via gc`;
|
|
1872
|
+
if (compromised || torn)
|
|
1873
|
+
return errFields('STORAGE_UNAVAILABLE', committed);
|
|
1874
|
+
try {
|
|
1875
|
+
await removeSessionHistory(storeDir, id);
|
|
1876
|
+
}
|
|
1877
|
+
catch (err) {
|
|
1878
|
+
return errFields('STORAGE_UNAVAILABLE', `${committed}: ${err.message}`);
|
|
1879
|
+
}
|
|
1880
|
+
return { session_id: id };
|
|
1881
|
+
}
|
|
1882
|
+
/** gc as one maintenance transaction (D9): close admission, stop every capture
|
|
1883
|
+
* with `capture.stopped{maintenance}`, sweep, then resume each one stopped here.
|
|
1884
|
+
* Intent is untouched throughout. */
|
|
799
1885
|
async function gc() {
|
|
800
1886
|
return runMaintenance(async () => {
|
|
1887
|
+
quiescing = true;
|
|
801
1888
|
try {
|
|
802
|
-
|
|
803
|
-
//
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1889
|
+
await Promise.allSettled([...admitting.values()]);
|
|
1890
|
+
// Starts already queued run first; none begins until this pass ends.
|
|
1891
|
+
const pass = baselines.then(maintenancePass);
|
|
1892
|
+
baselines = pass.then(() => { }, () => { });
|
|
1893
|
+
return await pass;
|
|
1894
|
+
}
|
|
1895
|
+
finally {
|
|
1896
|
+
quiescing = false;
|
|
1897
|
+
}
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
async function maintenancePass() {
|
|
1901
|
+
const stopped = new Set();
|
|
1902
|
+
const unproven = new Map();
|
|
1903
|
+
try {
|
|
1904
|
+
for (const c of captures.values()) {
|
|
1905
|
+
if (c.session && c.lifecycle === 'recording')
|
|
1906
|
+
stopForMaintenance(c, stopped, unproven);
|
|
1907
|
+
}
|
|
1908
|
+
// A detach, a halt or an ended activation may own a stop too; every stop gc
|
|
1909
|
+
// waits for must finish and record its stop before any blob is swept.
|
|
1910
|
+
// Read as each stop settles: a retry may requeue a failed capture later.
|
|
1911
|
+
const settled = (c, closing) => {
|
|
1912
|
+
const failed = c.lifecycle === 'failed' || (closing !== undefined && unrecordedStops.has(closing));
|
|
1913
|
+
if (failed && !unproven.has(c.id))
|
|
1914
|
+
unproven.set(c.id, `capture ${c.id} stopped without recording its stop`);
|
|
1915
|
+
};
|
|
1916
|
+
// A stop still settling counts whatever the lifecycle reads: a lock lost
|
|
1917
|
+
// mid-detach marks the capture `failed` while its log is still draining.
|
|
1918
|
+
const writing = (c) => c.session || c.lifecycle === 'stopping' || c.closing;
|
|
1919
|
+
for (;;) {
|
|
1920
|
+
const busy = [...captures.values()].filter(writing);
|
|
1921
|
+
if (busy.length === 0)
|
|
1922
|
+
break;
|
|
1923
|
+
const closings = busy.map((c) => c.closing);
|
|
1924
|
+
await Promise.allSettled(busy.map((c, i) => Promise.resolve(closings[i]).then(() => settled(c, closings[i]), () => settled(c, closings[i]))));
|
|
1925
|
+
const stuck = busy.filter((c, i) => c.closing === closings[i] && writing(c));
|
|
1926
|
+
if (stuck.length === busy.length) {
|
|
1927
|
+
for (const c of stuck)
|
|
1928
|
+
unproven.set(c.id, `capture ${c.id} has not stopped`);
|
|
1929
|
+
break;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
if (torn || compromised)
|
|
1933
|
+
throw new StorageError('gc-aborted', new Error('the daemon is shutting down or lost its store lock'));
|
|
1934
|
+
if (unproven.size > 0)
|
|
1935
|
+
return errFields('STORAGE_UNAVAILABLE', `gc swept nothing: ${[...unproven.values()].join('; ')}`);
|
|
1936
|
+
const foreign = await freshSessionLock();
|
|
1937
|
+
if (foreign) {
|
|
1938
|
+
return errFields('STORAGE_UNAVAILABLE', `gc swept nothing: session ${foreign} is still being written by another process`);
|
|
1939
|
+
}
|
|
1940
|
+
return await sweep();
|
|
1941
|
+
}
|
|
1942
|
+
catch (err) {
|
|
1943
|
+
// A corrupt/missing retained log or an aborted sweep deletes nothing; report
|
|
1944
|
+
// the store as unavailable rather than a partial success.
|
|
1945
|
+
return errFields('STORAGE_UNAVAILABLE', err.message);
|
|
1946
|
+
}
|
|
1947
|
+
finally {
|
|
1948
|
+
// Resume even after a failed sweep: storage that refused a sweep may still
|
|
1949
|
+
// record, and a capture that cannot fails visibly on its own.
|
|
1950
|
+
quiescing = false;
|
|
1951
|
+
if (!torn && !compromised) {
|
|
1952
|
+
for (const entry of intent.captures) {
|
|
1953
|
+
const c = captures.get(entry.session_id);
|
|
1954
|
+
if (!c || !stopped.has(c) || entry.intent !== 'running' || c.lifecycle !== 'stopped')
|
|
810
1955
|
continue;
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
await publishTombstone(storeDir, s.id);
|
|
815
|
-
registry.freeze(s.id, 0n);
|
|
816
|
-
await removeSessionHistory(storeDir, s.id);
|
|
1956
|
+
c.lifecycle = 'queued';
|
|
1957
|
+
c.resume = true;
|
|
1958
|
+
enqueueStart(c);
|
|
817
1959
|
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
/** Stop one recording capture for maintenance, keeping its capture and binding
|
|
1964
|
+
* intent. Admitted binding publication and chat work finish first, so a link in
|
|
1965
|
+
* flight is kept rather than revoked; a check issued before the stop never
|
|
1966
|
+
* connects afterwards. */
|
|
1967
|
+
function stopForMaintenance(c, stopped, unproven) {
|
|
1968
|
+
const prior = c.closing;
|
|
1969
|
+
setClosing(c, (async () => {
|
|
1970
|
+
await prior;
|
|
1971
|
+
while (c.binding?.publishing || c.inflight.size) {
|
|
1972
|
+
if (c.binding)
|
|
1973
|
+
cancelRearm(c.binding);
|
|
1974
|
+
await Promise.allSettled([c.binding?.publishing, ...c.inflight]);
|
|
1975
|
+
}
|
|
1976
|
+
const session = c.session;
|
|
1977
|
+
// A detach or an ended activation took it over meanwhile, and owns its stop.
|
|
1978
|
+
if (!session || c.lifecycle !== 'recording' || !isRunning(c) || torn || compromised) {
|
|
1979
|
+
if (session && (torn || compromised))
|
|
1980
|
+
unproven.set(c.id, `capture ${c.id} was not stopped`);
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
c.lifecycle = 'stopping';
|
|
1984
|
+
if (c.binding)
|
|
1985
|
+
c.binding.check = { state: 'setup_pending', checkId: randomUUID() };
|
|
1986
|
+
registry.setAgentConnection(c.id, 'none');
|
|
1987
|
+
c.session = undefined;
|
|
1988
|
+
const freeze = () => registry.freeze(c.id, BigInt(session.health.snapshot().durable_seq));
|
|
1989
|
+
try {
|
|
1990
|
+
await session.stop({ reason: 'maintenance', assertOwnership: assertStoreOwned });
|
|
822
1991
|
}
|
|
823
1992
|
catch (err) {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1993
|
+
freeze();
|
|
1994
|
+
const message = err.message;
|
|
1995
|
+
unproven.set(c.id, `capture ${c.id} could not record its maintenance stop: ${message}`);
|
|
1996
|
+
fail(c, 'storage-unavailable', `gc could not record this capture's maintenance stop: ${message}`, { retry: true });
|
|
1997
|
+
return;
|
|
827
1998
|
}
|
|
828
|
-
|
|
1999
|
+
freeze();
|
|
2000
|
+
c.ended = 'maintenance';
|
|
2001
|
+
c.lifecycle = 'stopped';
|
|
2002
|
+
stopped.add(c);
|
|
2003
|
+
})());
|
|
2004
|
+
}
|
|
2005
|
+
/** A session ownership lock some other writer refreshed recently: the daemon's
|
|
2006
|
+
* own captures are all stopped by now, and released theirs. */
|
|
2007
|
+
async function freshSessionLock() {
|
|
2008
|
+
for (const s of await listSessions(storeDir)) {
|
|
2009
|
+
let mtimeMs;
|
|
2010
|
+
try {
|
|
2011
|
+
mtimeMs = (await stat(join(storeDir, 'sessions', s.id, 'owner.lock'))).mtimeMs;
|
|
2012
|
+
}
|
|
2013
|
+
catch (err) {
|
|
2014
|
+
if (err.code === 'ENOENT')
|
|
2015
|
+
continue;
|
|
2016
|
+
throw err;
|
|
2017
|
+
}
|
|
2018
|
+
if (Date.now() - mtimeMs <= STALE_MS)
|
|
2019
|
+
return s.id;
|
|
2020
|
+
}
|
|
2021
|
+
return undefined;
|
|
2022
|
+
}
|
|
2023
|
+
/** Finish interrupted deletions, then reclaim unreferenced blobs. */
|
|
2024
|
+
async function sweep() {
|
|
2025
|
+
// A session tombstoned by a delete_session that crashed before cleanup still
|
|
2026
|
+
// carries residual history: re-establish the tombstone's durability, invalidate
|
|
2027
|
+
// any follower a partial delete left pinned, then complete the removal. Stop
|
|
2028
|
+
// before any mutation if we lose the store lock: a successor now owns this
|
|
2029
|
+
// store's files.
|
|
2030
|
+
for (const s of await listSessions(storeDir)) {
|
|
2031
|
+
if (!s.removed)
|
|
2032
|
+
continue;
|
|
2033
|
+
if (compromised || torn) {
|
|
2034
|
+
throw new StorageError('gc-aborted', new Error('store ownership lost mid-cleanup'));
|
|
2035
|
+
}
|
|
2036
|
+
await publishTombstone(storeDir, s.id);
|
|
2037
|
+
registry.freeze(s.id, 0n);
|
|
2038
|
+
await removeSessionHistory(storeDir, s.id);
|
|
2039
|
+
}
|
|
2040
|
+
// The shared blob store trusts blobs it published, some of which this sweep
|
|
2041
|
+
// may delete: every resumed capture starts with a fresh instance. The sweep
|
|
2042
|
+
// aborts if we lose the store lock, since a successor may own the blobs.
|
|
2043
|
+
cas = undefined;
|
|
2044
|
+
const removed = await reclaimUnreferencedBlobs(storeDir, () => torn || compromised);
|
|
2045
|
+
return { removed };
|
|
2046
|
+
}
|
|
2047
|
+
async function attach(req) {
|
|
2048
|
+
if (req.mode === 'watch-only')
|
|
2049
|
+
return attachWatchOnly(req);
|
|
2050
|
+
if (req.mode !== undefined)
|
|
2051
|
+
return errFields('PROTOCOL', `unknown attach mode: ${String(req.mode)}`);
|
|
2052
|
+
return attachChat(req);
|
|
829
2053
|
}
|
|
830
2054
|
async function dispatch(req) {
|
|
831
2055
|
if (compromised)
|
|
@@ -833,13 +2057,16 @@ export async function startDaemon(opts) {
|
|
|
833
2057
|
let outcome;
|
|
834
2058
|
switch (req.verb) {
|
|
835
2059
|
case 'status':
|
|
836
|
-
outcome =
|
|
2060
|
+
outcome = await status(req);
|
|
837
2061
|
break;
|
|
838
2062
|
case 'attach':
|
|
839
2063
|
outcome = await attach(req);
|
|
840
2064
|
break;
|
|
841
2065
|
case 'detach':
|
|
842
|
-
outcome = await detach();
|
|
2066
|
+
outcome = await detach(req);
|
|
2067
|
+
break;
|
|
2068
|
+
case 'unlink':
|
|
2069
|
+
outcome = await unlinkChat(req);
|
|
843
2070
|
break;
|
|
844
2071
|
case 'begin_task':
|
|
845
2072
|
outcome = await beginTask(req);
|
|
@@ -847,6 +2074,12 @@ export async function startDaemon(opts) {
|
|
|
847
2074
|
case 'ask':
|
|
848
2075
|
outcome = await ask(req);
|
|
849
2076
|
break;
|
|
2077
|
+
case 'record_question_exchange':
|
|
2078
|
+
outcome = await recordExchange(req);
|
|
2079
|
+
break;
|
|
2080
|
+
case 'record_question_relay_update':
|
|
2081
|
+
outcome = await recordRelayUpdate(req);
|
|
2082
|
+
break;
|
|
850
2083
|
case 'claim_question':
|
|
851
2084
|
outcome = await claimQuestion(req);
|
|
852
2085
|
break;
|
|
@@ -936,6 +2169,8 @@ export async function startDaemon(opts) {
|
|
|
936
2169
|
// Startup is fully ready: reader running, lock held, not torn, readerRef bound.
|
|
937
2170
|
// Only now let the connection handler dispatch verbs (see the `ready` gate).
|
|
938
2171
|
ready = true;
|
|
2172
|
+
for (const c of restored)
|
|
2173
|
+
enqueueStart(c);
|
|
939
2174
|
return {
|
|
940
2175
|
socketPath,
|
|
941
2176
|
readerUrl: reader.url,
|
|
@@ -943,9 +2178,22 @@ export async function startDaemon(opts) {
|
|
|
943
2178
|
stop: teardown,
|
|
944
2179
|
};
|
|
945
2180
|
}
|
|
2181
|
+
function controlFailure(err) {
|
|
2182
|
+
if (err instanceof QuestionError)
|
|
2183
|
+
return errFields(err.code, err.message);
|
|
2184
|
+
if (err instanceof StorageError)
|
|
2185
|
+
return errFields('STORAGE_UNAVAILABLE', err.message);
|
|
2186
|
+
return errFields('CAPTURE_NOT_READY', err.message);
|
|
2187
|
+
}
|
|
946
2188
|
function errFields(code, message) {
|
|
947
2189
|
return { __error: true, code, message };
|
|
948
2190
|
}
|
|
2191
|
+
/** What to do when a session cannot be read or resumed. */
|
|
2192
|
+
function corruptRemedy(id) {
|
|
2193
|
+
return `Restore its data, or remove it with \`slipstream delete ${id}\` (this discards that history)`;
|
|
2194
|
+
}
|
|
2195
|
+
/** A lock, storage or shutdown failure says nothing about the session's data. */
|
|
2196
|
+
const RETRY_REMEDY = 'Retry attach';
|
|
949
2197
|
function isErrorFields(v) {
|
|
950
2198
|
return v.__error === true;
|
|
951
2199
|
}
|