@playola-radio/slipstream 0.1.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/LICENSE +202 -0
- package/README.md +132 -0
- package/SKILL.md +63 -0
- package/THIRD-PARTY-NOTICES.md +36 -0
- package/contracts/interface/v2/schema.json +480 -0
- package/dist/answer-forwarder.js +58 -0
- package/dist/attribution-engine.js +164 -0
- package/dist/attribution-producer.js +153 -0
- package/dist/attribution-scoring.js +86 -0
- package/dist/attribution.js +120 -0
- package/dist/blob-source.js +100 -0
- package/dist/boundary-registry.js +47 -0
- package/dist/cas.js +99 -0
- package/dist/change-view.js +201 -0
- package/dist/claude-root-transcript.js +110 -0
- package/dist/cli.js +629 -0
- package/dist/clip-blob-reader.js +104 -0
- package/dist/clip-function-parser.js +83 -0
- package/dist/clip-language.js +15 -0
- package/dist/clip-projection-service.js +240 -0
- package/dist/clip-projection-worker.js +18 -0
- package/dist/clip-projection.js +585 -0
- package/dist/clip-worker-pool.js +118 -0
- package/dist/config.js +154 -0
- package/dist/control-client.js +98 -0
- package/dist/control-protocol.js +72 -0
- package/dist/daemon-location.js +33 -0
- package/dist/daemon.js +951 -0
- package/dist/display-fold.js +332 -0
- package/dist/engine.js +112 -0
- package/dist/entrypoint.js +17 -0
- package/dist/event.js +79 -0
- package/dist/evidence-ingest.js +60 -0
- package/dist/harness-context/claude.js +42 -0
- package/dist/harness-context/codex.js +49 -0
- package/dist/harness-context.js +49 -0
- package/dist/health.js +65 -0
- package/dist/http-reader.js +655 -0
- package/dist/http-security.js +50 -0
- package/dist/interface-projection.js +243 -0
- package/dist/interface-range-resolver.js +230 -0
- package/dist/interface-service.js +509 -0
- package/dist/interface-ts-pool.js +111 -0
- package/dist/interface-ts-worker.js +35 -0
- package/dist/interface-v2-comparison.js +135 -0
- package/dist/interface-v2-core.js +123 -0
- package/dist/interface-v2-typescript.js +448 -0
- package/dist/lock.js +197 -0
- package/dist/log-reader.js +148 -0
- package/dist/log.js +59 -0
- package/dist/maintenance.js +255 -0
- package/dist/mcp-forwarder.js +161 -0
- package/dist/mcp-protocol.js +92 -0
- package/dist/platform.js +21 -0
- package/dist/projection-admission.js +296 -0
- package/dist/projection-trace.js +17 -0
- package/dist/public-events.js +27 -0
- package/dist/question-hook.js +110 -0
- package/dist/questions.js +167 -0
- package/dist/reader-runtime.js +38 -0
- package/dist/reader.js +93 -0
- package/dist/recovery.js +303 -0
- package/dist/schema.js +108 -0
- package/dist/session.js +866 -0
- package/dist/sibling-module.js +10 -0
- package/dist/snapshot.js +15 -0
- package/dist/storage.js +111 -0
- package/dist/store-reader.js +185 -0
- package/dist/swift-grammar.js +268 -0
- package/dist/swift-interface-extract.js +344 -0
- package/dist/swift-interface.js +62 -0
- package/dist/swift-parse-host.js +174 -0
- package/dist/swift-parse-worker.js +34 -0
- package/dist/swift-parse.js +134 -0
- package/dist/swift-spans.js +62 -0
- package/dist/task-forwarder.js +152 -0
- package/dist/transcript/claude.js +199 -0
- package/dist/transcript/codex.js +132 -0
- package/dist/transcript/discovery.js +365 -0
- package/dist/transcript/file-reader.js +128 -0
- package/dist/transcript/fs-io.js +287 -0
- package/dist/transcript/runner.js +62 -0
- package/dist/transcript/steppers.js +30 -0
- package/dist/transcript/types.js +30 -0
- package/dist/transcript/watcher.js +133 -0
- package/dist/tui.js +168 -0
- package/package.json +60 -0
- package/schemas/projections/clip.v1.json +195 -0
- package/schemas/projections/clip.v2.json +212 -0
- package/schemas/projections/clip.v3.json +212 -0
- package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
- package/schemas/slipstream.capture.gap.v1.json +112 -0
- package/schemas/slipstream.change.attribution.v1.json +115 -0
- package/schemas/slipstream.enrichment.configured.v1.json +77 -0
- package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
- package/schemas/slipstream.file.baselined.v1.json +122 -0
- package/schemas/slipstream.file.changed.v1.json +179 -0
- package/schemas/slipstream.harness.evidence.v1.json +145 -0
- package/schemas/slipstream.question.answered.v1.json +29 -0
- package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
- package/schemas/slipstream.question.queued.v1.json +55 -0
- package/schemas/slipstream.session.resumed.v1.json +70 -0
- package/schemas/slipstream.session.started.v1.json +69 -0
- package/schemas/slipstream.task.started.v1.json +72 -0
package/dist/session.js
ADDED
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
import { access, readdir, realpath } from 'node:fs/promises';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
4
|
+
import { createCas } from "./cas.js";
|
|
5
|
+
import { createReader, DEFAULT_MAX_BYTES } from "./reader.js";
|
|
6
|
+
import { createLog } from "./log.js";
|
|
7
|
+
import { createEngine } from "./engine.js";
|
|
8
|
+
import { createHealth } from "./health.js";
|
|
9
|
+
import { acquireSessionLock } from "./lock.js";
|
|
10
|
+
import { recoverSession } from "./recovery.js";
|
|
11
|
+
import { StorageError, assertOwnerOnly, mkdirpDurable } from "./storage.js";
|
|
12
|
+
import { snapshotsEqual } from "./snapshot.js";
|
|
13
|
+
import { isValidSessionId } from "./store-reader.js";
|
|
14
|
+
import { createPlatform } from "./platform.js";
|
|
15
|
+
import { createAttributionProducer, DEFAULT_ENRICHMENT_POLICY, } from "./attribution-producer.js";
|
|
16
|
+
import { normalizeAsk, questionBody, questionResult, readQuestionContext, QuestionError, QUESTION_TTL_MS, QUESTION_LIMIT, assertAnswerText, answerResult } from "./questions.js";
|
|
17
|
+
import { createCoverageRunner } from "./transcript/runner.js";
|
|
18
|
+
import { nodeDiscoveryIO, nodeTranscriptFileIO } from "./transcript/fs-io.js";
|
|
19
|
+
/** How often the transcript watcher re-runs discovery and reads new records. */
|
|
20
|
+
export const DEFAULT_TRANSCRIPT_POLL_MS = 2000;
|
|
21
|
+
/** A `request_id` was reused with a different title — a caller mistake, not a
|
|
22
|
+
* retry, so it never commits a second declaration. */
|
|
23
|
+
export class InvalidTitleError extends Error {
|
|
24
|
+
code = 'INVALID_TITLE';
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'InvalidTitleError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const defaultDependencies = { createLog, platform: createPlatform(), enumerate, readQuestionContext };
|
|
31
|
+
/** A relative path escapes its base only via a leading `..` segment (or when it
|
|
32
|
+
* comes back absolute); a filename that merely starts with `..`, like
|
|
33
|
+
* `..notes.ts`, stays inside. */
|
|
34
|
+
function escapesBase(rel) {
|
|
35
|
+
return isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`);
|
|
36
|
+
}
|
|
37
|
+
function isUnder(child, parent) {
|
|
38
|
+
return !escapesBase(relative(parent, child));
|
|
39
|
+
}
|
|
40
|
+
export async function startCapture(opts, dependencies = {}) {
|
|
41
|
+
const deps = { ...defaultDependencies, ...dependencies };
|
|
42
|
+
if (opts.sessionId !== undefined) {
|
|
43
|
+
if (opts.resumeSessionId !== undefined) {
|
|
44
|
+
throw new Error('sessionId and resumeSessionId are mutually exclusive');
|
|
45
|
+
}
|
|
46
|
+
if (!isValidSessionId(opts.sessionId)) {
|
|
47
|
+
throw new Error(`sessionId is not a valid session UUID: ${opts.sessionId}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// The native watcher reports realpaths; resolve symlinks in the root (e.g.
|
|
51
|
+
// macOS /var -> /private/var) so relative-path math against events matches.
|
|
52
|
+
const root = await realpath(opts.root);
|
|
53
|
+
await mkdirpDurable(opts.storeDir);
|
|
54
|
+
const storeDir = await realpath(opts.storeDir);
|
|
55
|
+
await assertOwnerOnly(storeDir, 'dir');
|
|
56
|
+
if (isUnder(root, storeDir)) {
|
|
57
|
+
throw new Error('store directory must not equal or contain the watched root');
|
|
58
|
+
}
|
|
59
|
+
const resuming = opts.resumeSessionId !== undefined;
|
|
60
|
+
const sessionId = opts.resumeSessionId ?? opts.sessionId ?? randomUUID();
|
|
61
|
+
const blobsDir = join(storeDir, 'blobs');
|
|
62
|
+
const sessionDir = join(storeDir, 'sessions', sessionId);
|
|
63
|
+
await mkdirpDurable(blobsDir);
|
|
64
|
+
await mkdirpDurable(sessionDir);
|
|
65
|
+
await assertOwnerOnly(sessionDir, 'dir'); // blobsDir perms are checked by createCas
|
|
66
|
+
const logPath = join(sessionDir, 'events.jsonl');
|
|
67
|
+
const cas = await createCas(blobsDir);
|
|
68
|
+
// Durable task boundaries (Stage 3). `currentTaskId` is the latest committed
|
|
69
|
+
// declaration in shared append order — the grouping hint stamped onto changes.
|
|
70
|
+
// The two maps are the idempotency index keyed by `request_id`: `committedTasks`
|
|
71
|
+
// replays a settled result, `inflightTasks` coalesces concurrent duplicates.
|
|
72
|
+
// Both the pointer and the committed index are rebuilt from the log on recovery.
|
|
73
|
+
const now = opts.now ?? Date.now;
|
|
74
|
+
const committedQuestions = new Map();
|
|
75
|
+
const inflightQuestions = new Map();
|
|
76
|
+
const reservedQuestions = new Set();
|
|
77
|
+
// Questions not yet attempted. Removed once dispatch is attempted (durably
|
|
78
|
+
// consumed either way) so eligibility counting and claim lookup scan only
|
|
79
|
+
// outstanding questions, not every question ever queued by a long-lived capture.
|
|
80
|
+
const unattemptedQuestions = new Map();
|
|
81
|
+
// Kept for the capture's lifetime: a question stays answerable after it
|
|
82
|
+
// leaves the unattempted queue or passes its claim TTL.
|
|
83
|
+
const questionsById = new Map();
|
|
84
|
+
const attemptSeqs = new Map();
|
|
85
|
+
const committedAnswers = new Map();
|
|
86
|
+
const inflightAnswers = new Map();
|
|
87
|
+
let currentTaskId;
|
|
88
|
+
const committedTasks = new Map();
|
|
89
|
+
const inflightTasks = new Map();
|
|
90
|
+
// The attribution producer, constructed once the append path exists below. Its
|
|
91
|
+
// `noteCommitted` is fed from the log's single in-order commit hook, so every
|
|
92
|
+
// durably-committed change/evidence/policy reaches it exactly once, in order.
|
|
93
|
+
let producer;
|
|
94
|
+
const seedTaskState = (rec) => {
|
|
95
|
+
committedQuestions.clear();
|
|
96
|
+
unattemptedQuestions.clear();
|
|
97
|
+
for (const event of rec.questions) {
|
|
98
|
+
committedQuestions.set(event.data.request_id, { body: questionBody(event.data), result: questionResult(event) });
|
|
99
|
+
unattemptedQuestions.set(event.data.question_id, event);
|
|
100
|
+
}
|
|
101
|
+
for (const event of rec.questionAttempts)
|
|
102
|
+
unattemptedQuestions.delete(event.data.question_id);
|
|
103
|
+
questionsById.clear();
|
|
104
|
+
attemptSeqs.clear();
|
|
105
|
+
committedAnswers.clear();
|
|
106
|
+
for (const event of rec.questions)
|
|
107
|
+
questionsById.set(event.data.question_id, event.data);
|
|
108
|
+
for (const event of rec.questionAttempts)
|
|
109
|
+
attemptSeqs.set(event.data.question_id, event.seq);
|
|
110
|
+
for (const event of rec.questionAnswers)
|
|
111
|
+
committedAnswers.set(event.data.question_id, { text: event.data.text, result: answerResult(event) });
|
|
112
|
+
currentTaskId = rec.currentTaskId;
|
|
113
|
+
committedTasks.clear();
|
|
114
|
+
for (const [requestId, decl] of rec.taskDeclarations) {
|
|
115
|
+
committedTasks.set(requestId, {
|
|
116
|
+
title: decl.title,
|
|
117
|
+
result: Object.freeze({ session_id: sessionId, task_id: decl.taskId, event_id: decl.seq, seq: decl.seq }),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
// Stamp grouping metadata inside the log's serialized sequencing section, the
|
|
122
|
+
// single place with a total order over appends: a change is stamped with the
|
|
123
|
+
// task committed before it, and the pointer advances only after a declaration
|
|
124
|
+
// is durable. A newly emitted change carries no attribution — "no result yet"
|
|
125
|
+
// means PENDING, and the producer appends a revisable result asynchronously
|
|
126
|
+
// once the change is durable (never a fabricated inline `unknown` seed).
|
|
127
|
+
const taskSequencer = {
|
|
128
|
+
enrich: (input) => {
|
|
129
|
+
if (input.type !== 'slipstream.file.changed.v1')
|
|
130
|
+
return input;
|
|
131
|
+
return {
|
|
132
|
+
...input,
|
|
133
|
+
data: {
|
|
134
|
+
...input.data,
|
|
135
|
+
...(currentTaskId !== undefined ? { task_hint_id: currentTaskId } : {}),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
onCommitted: (event) => {
|
|
140
|
+
if (event.type === 'slipstream.task.started.v1')
|
|
141
|
+
currentTaskId = event.data.task_id;
|
|
142
|
+
// Route every durable commit to attribution in the same total order.
|
|
143
|
+
if (event.type === 'slipstream.question.queued.v1') {
|
|
144
|
+
unattemptedQuestions.set(event.data.question_id, event);
|
|
145
|
+
questionsById.set(event.data.question_id, event.data);
|
|
146
|
+
}
|
|
147
|
+
if (event.type === 'slipstream.question.dispatch_attempted.v1') {
|
|
148
|
+
unattemptedQuestions.delete(event.data.question_id);
|
|
149
|
+
attemptSeqs.set(event.data.question_id, event.seq);
|
|
150
|
+
}
|
|
151
|
+
if (event.type === 'slipstream.question.answered.v1') {
|
|
152
|
+
committedAnswers.set(event.data.question_id, { text: event.data.text, result: answerResult(event) });
|
|
153
|
+
}
|
|
154
|
+
if (event.type !== 'slipstream.question.queued.v1' && event.type !== 'slipstream.question.dispatch_attempted.v1'
|
|
155
|
+
&& event.type !== 'slipstream.question.answered.v1')
|
|
156
|
+
producer?.noteCommitted(event);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
// Set if the lock heartbeat finds another process has taken ownership: capture
|
|
160
|
+
// must stop acknowledging and must never reopen the log (the new owner has it).
|
|
161
|
+
// Declared before the lock is acquired so a compromise during the startup
|
|
162
|
+
// recovery/open window still flips it; `healthRef` is filled in once health
|
|
163
|
+
// exists, so the failing state is disclosed if the loss happens later.
|
|
164
|
+
let surrendered = false;
|
|
165
|
+
let healthRef;
|
|
166
|
+
const lock = await acquireSessionLock(sessionDir, {
|
|
167
|
+
onCompromised: (reason) => {
|
|
168
|
+
if (surrendered)
|
|
169
|
+
return;
|
|
170
|
+
surrendered = true;
|
|
171
|
+
console.error(`slipstream: session ownership lost: ${reason}`);
|
|
172
|
+
healthRef?.markFailing({ code: 'ELOCKLOST', operation: 'lock', detected_at_ms: Date.now() });
|
|
173
|
+
opts.onCompromised?.(reason);
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
if (!resuming && opts.sessionId !== undefined) {
|
|
177
|
+
// A fresh caller-supplied id must name a session that does not exist yet:
|
|
178
|
+
// opening an existing log in append mode restarts sequencing at zero and
|
|
179
|
+
// duplicates seqs. Resuming existing history requires resumeSessionId. The
|
|
180
|
+
// check runs while we hold the session lock so a concurrent caller cannot
|
|
181
|
+
// create then release the same id between the check and log creation.
|
|
182
|
+
const logExists = await access(logPath).then(() => true, () => false);
|
|
183
|
+
if (logExists) {
|
|
184
|
+
await lock.release();
|
|
185
|
+
throw new Error(`cannot start a fresh session ${sessionId}: a log already exists at ${logPath}; use resumeSessionId to continue it`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
let recovered;
|
|
189
|
+
if (resuming) {
|
|
190
|
+
try {
|
|
191
|
+
await assertOwnerOnly(logPath, 'file').catch((err) => {
|
|
192
|
+
if (err.code !== 'ENOENT')
|
|
193
|
+
throw err;
|
|
194
|
+
});
|
|
195
|
+
recovered = await recoverSession(logPath, sessionId, cas);
|
|
196
|
+
if (recovered.root === undefined) {
|
|
197
|
+
throw new Error(`cannot resume session ${sessionId}: no existing session.started record`);
|
|
198
|
+
}
|
|
199
|
+
if (recovered.root !== root) {
|
|
200
|
+
throw new Error(`session ${sessionId} is bound to ${recovered.root}, not ${root}`);
|
|
201
|
+
}
|
|
202
|
+
seedTaskState(recovered); // rebuild the dedup index + current task before appends
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
await lock.release(); // a failed resume must not leave the session locked
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const maxBytes = recovered?.maxBytes ?? opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
210
|
+
const enrichmentPolicy = opts.enrichmentPolicy ?? DEFAULT_ENRICHMENT_POLICY;
|
|
211
|
+
const health = createHealth(recovered?.recoveredThroughSeq ?? 0n);
|
|
212
|
+
healthRef = health; // the compromise callback can now disclose a lost lock
|
|
213
|
+
let underlying;
|
|
214
|
+
try {
|
|
215
|
+
underlying = await deps.createLog({
|
|
216
|
+
filePath: logPath,
|
|
217
|
+
sessionId,
|
|
218
|
+
startSeq: recovered?.recoveredThroughSeq,
|
|
219
|
+
sequencer: taskSequencer,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
await lock.release();
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
let stopped = false;
|
|
227
|
+
let supervising = false;
|
|
228
|
+
// The active recovery-supervisor loop, so shutdown can await an in-flight
|
|
229
|
+
// recovery before releasing the lock (never append after another process could
|
|
230
|
+
// take ownership). Resolved when no recovery is running.
|
|
231
|
+
let supervisorLoop = Promise.resolve();
|
|
232
|
+
// One outage episode id, minted when acknowledgment first stops and reused
|
|
233
|
+
// across recovery attempts so the disclosing storage gap is written once (Q6).
|
|
234
|
+
let outageEpisodeId;
|
|
235
|
+
// Suspend capture on a storage fault: mark health failing (preserving the
|
|
236
|
+
// first failure of the outage) and kick the recovery supervisor. Used by both
|
|
237
|
+
// the append path and the reader — a CAS publish failure must suspend capture,
|
|
238
|
+
// never be laundered into a fabricated `unavailable` snapshot.
|
|
239
|
+
const enterFailing = (failure) => {
|
|
240
|
+
if (surrendered)
|
|
241
|
+
return; // ownership lost: do not fight the new owner
|
|
242
|
+
outageEpisodeId ??= randomUUID();
|
|
243
|
+
const alreadySupervising = supervising;
|
|
244
|
+
health.markFailing(failure);
|
|
245
|
+
// Capture the loop's promise only when starting a fresh one, so `stop()` can
|
|
246
|
+
// await the recovery in progress. runSupervisor sets `supervising` in its
|
|
247
|
+
// synchronous prefix, so a concurrent kick sees it and no-ops.
|
|
248
|
+
if (!alreadySupervising)
|
|
249
|
+
supervisorLoop = runSupervisor();
|
|
250
|
+
};
|
|
251
|
+
async function appendEvent(input) {
|
|
252
|
+
if (surrendered) {
|
|
253
|
+
throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const event = await underlying.append(input);
|
|
257
|
+
health.setDurableSeq(underlying.durableSeq());
|
|
258
|
+
return event;
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
if (err instanceof StorageError) {
|
|
262
|
+
enterFailing({ code: err.code, operation: err.operation, detected_at_ms: Date.now() });
|
|
263
|
+
}
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
;
|
|
268
|
+
// Wrap the reader so a CAS publish failure (a storage fault, not an unreadable
|
|
269
|
+
// source file) suspends capture and is disclosed, rather than recorded as a
|
|
270
|
+
// change whose bytes we could not durably store.
|
|
271
|
+
const rawReader = createReader({ root, cas, maxBytes });
|
|
272
|
+
const reader = {
|
|
273
|
+
read: async (rel) => {
|
|
274
|
+
try {
|
|
275
|
+
return await rawReader.read(rel);
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
if (err instanceof StorageError) {
|
|
279
|
+
enterFailing({ code: err.code, operation: err.operation, detected_at_ms: Date.now() });
|
|
280
|
+
}
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
const engine = createEngine({ reader, log: { append: appendEvent } });
|
|
286
|
+
// Attribution runs off the same append path. Timers are unref'd so a pending
|
|
287
|
+
// grace window never keeps the process alive; the clock is real wall time.
|
|
288
|
+
producer = createAttributionProducer({
|
|
289
|
+
appendEvent,
|
|
290
|
+
now: Date.now,
|
|
291
|
+
setTimer: (delayMs, fn) => {
|
|
292
|
+
const t = setTimeout(fn, delayMs);
|
|
293
|
+
t.unref?.();
|
|
294
|
+
return t;
|
|
295
|
+
},
|
|
296
|
+
clearTimer: (h) => clearTimeout(h),
|
|
297
|
+
});
|
|
298
|
+
// Never capture the store or version-control metadata. If the store lives
|
|
299
|
+
// inside the watched root, excluding it is what stops the watcher from
|
|
300
|
+
// observing its own output.
|
|
301
|
+
const excluded = [storeDir, join(root, '.git')];
|
|
302
|
+
const isExcluded = (abs) => excluded.some((e) => isUnder(abs, e));
|
|
303
|
+
let live = false;
|
|
304
|
+
const buffer = [];
|
|
305
|
+
const onObservation = (abs, observedAtMs) => {
|
|
306
|
+
if (isExcluded(abs))
|
|
307
|
+
return;
|
|
308
|
+
const rel = relative(root, abs);
|
|
309
|
+
if (rel === '' || escapesBase(rel))
|
|
310
|
+
return;
|
|
311
|
+
if (live)
|
|
312
|
+
engine.notify(rel, observedAtMs);
|
|
313
|
+
else
|
|
314
|
+
buffer.push([rel, observedAtMs]);
|
|
315
|
+
};
|
|
316
|
+
// A native-watcher error means delivery may have lapsed; disclose an honest
|
|
317
|
+
// session-wide coverage gap instead of letting the miss vanish silently.
|
|
318
|
+
const onError = (err) => {
|
|
319
|
+
console.error(`slipstream: watcher error: ${err.message}`);
|
|
320
|
+
void appendEvent({
|
|
321
|
+
type: 'slipstream.capture.gap.v1',
|
|
322
|
+
occurred_at_ms: Date.now(),
|
|
323
|
+
data: { scope: { kind: 'session' }, reason: 'watcher-error' },
|
|
324
|
+
}).catch(() => { });
|
|
325
|
+
};
|
|
326
|
+
const priorFor = (path, unknownDirs) => {
|
|
327
|
+
for (const prefix of unknownDirs) {
|
|
328
|
+
if (prefix === '' || path === prefix || path.startsWith(prefix + sep)) {
|
|
329
|
+
return { kind: 'unavailable', reason: 'baseline-unknown' };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { kind: 'absent' };
|
|
333
|
+
};
|
|
334
|
+
// Re-observe the whole worktree against the last committed snapshots and emit a
|
|
335
|
+
// reconciliation change for every differing endpoint. Enumeration omission is
|
|
336
|
+
// NOT deletion — a known path missing from the scan is actually re-read, so a
|
|
337
|
+
// real delete is distinguished from an unreadable subtree.
|
|
338
|
+
const reconcile = async (state, gapSeq, append) => {
|
|
339
|
+
const current = new Map();
|
|
340
|
+
const newUnknownDirs = new Set();
|
|
341
|
+
await deps.enumerate(root, root, isExcluded, {
|
|
342
|
+
onFile: async (rel) => { current.set(rel, await reader.read(rel)); },
|
|
343
|
+
onDirError: async (relDir) => { newUnknownDirs.add(relDir); },
|
|
344
|
+
});
|
|
345
|
+
const unknownDirs = new Set([...state.baselineUnknownDirs, ...newUnknownDirs]);
|
|
346
|
+
const union = new Set([...state.committed.keys(), ...current.keys()]);
|
|
347
|
+
for (const path of union) {
|
|
348
|
+
const after = current.get(path) ?? (await reader.read(path));
|
|
349
|
+
const before = state.committed.get(path) ?? priorFor(path, unknownDirs);
|
|
350
|
+
if (!snapshotsEqual(before, after)) {
|
|
351
|
+
await append({
|
|
352
|
+
type: 'slipstream.file.changed.v1',
|
|
353
|
+
occurred_at_ms: Date.now(),
|
|
354
|
+
data: {
|
|
355
|
+
path,
|
|
356
|
+
before,
|
|
357
|
+
after,
|
|
358
|
+
observation: 'reconciliation',
|
|
359
|
+
gap_ref: gapSeq,
|
|
360
|
+
// A reconciliation endpoint is a state diff discovered on restart, not a
|
|
361
|
+
// watched transition, so it cannot bound a real interval. Disclose that
|
|
362
|
+
// explicitly rather than fabricate one; attribution reads it as unknown.
|
|
363
|
+
observed_interval_ms: { unavailable: true, reason: 'reconciliation' },
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
engine.setBaseline(path, snapshotsEqual(before, after) ? before : after);
|
|
368
|
+
}
|
|
369
|
+
// Install every unknown directory — recovered as well as newly-failed — so a
|
|
370
|
+
// later live observation of a never-baselined path reports baseline-unknown
|
|
371
|
+
// rather than a fabricated absent prior state.
|
|
372
|
+
for (const relDir of unknownDirs)
|
|
373
|
+
engine.markBaselineUnknown(relDir);
|
|
374
|
+
};
|
|
375
|
+
const goLive = () => {
|
|
376
|
+
// A recovery attempt that finishes during stop() must not re-enable the engine
|
|
377
|
+
// or flush the buffer: stop() has already unsubscribed and is draining toward
|
|
378
|
+
// lock release, and no observation may be appended past that point.
|
|
379
|
+
if (stopped)
|
|
380
|
+
return;
|
|
381
|
+
live = true;
|
|
382
|
+
for (const [rel, ts] of buffer)
|
|
383
|
+
engine.notify(rel, ts);
|
|
384
|
+
buffer.length = 0;
|
|
385
|
+
};
|
|
386
|
+
// Bounded-backoff recovery of a suspended session: reopen and repair the log,
|
|
387
|
+
// disclose ONE storage gap, reconcile, and restore health. Appends here go
|
|
388
|
+
// straight to the reopened log so a repeat fault aborts the attempt cleanly
|
|
389
|
+
// (rather than recursing back into the supervisor).
|
|
390
|
+
const runSupervisor = async () => {
|
|
391
|
+
if (supervising)
|
|
392
|
+
return;
|
|
393
|
+
supervising = true;
|
|
394
|
+
let delay = 20;
|
|
395
|
+
try {
|
|
396
|
+
while (!stopped && !surrendered && health.snapshot().state === 'failing') {
|
|
397
|
+
await new Promise((r) => { const t = setTimeout(r, delay); t.unref?.(); });
|
|
398
|
+
delay = Math.min(delay * 2, 500);
|
|
399
|
+
if (stopped)
|
|
400
|
+
break;
|
|
401
|
+
if (await tryStorageRecovery())
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
406
|
+
supervising = false;
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
const tryStorageRecovery = async () => {
|
|
410
|
+
live = false; // buffer live events while we repair and reconcile
|
|
411
|
+
try {
|
|
412
|
+
// Let in-flight engine tasks settle before swapping the log out from under
|
|
413
|
+
// them; otherwise one could append with a stale committed `before`.
|
|
414
|
+
await engine.drain();
|
|
415
|
+
engine.resetNotifications();
|
|
416
|
+
// Fence the attribution generation before the log is swapped: a stale
|
|
417
|
+
// evaluation from the pre-recovery generation must never append to the
|
|
418
|
+
// reopened log. A fresh generation is armed after the log reopens.
|
|
419
|
+
await producer?.stop();
|
|
420
|
+
if (stopped || surrendered)
|
|
421
|
+
return false; // shutting down / dispossessed: don't reopen
|
|
422
|
+
await underlying.close().catch(() => { });
|
|
423
|
+
const rec = await recoverSession(logPath, sessionId, cas);
|
|
424
|
+
// Rebuild the dedup index + current task from the durable log before the log
|
|
425
|
+
// reopens: an in-process recovery must not leave a duplicate-commit window,
|
|
426
|
+
// and reconciliation changes below must stamp the recovered current task.
|
|
427
|
+
seedTaskState(rec);
|
|
428
|
+
underlying = await deps.createLog({
|
|
429
|
+
filePath: logPath,
|
|
430
|
+
sessionId,
|
|
431
|
+
startSeq: rec.recoveredThroughSeq,
|
|
432
|
+
sequencer: taskSequencer,
|
|
433
|
+
});
|
|
434
|
+
health.setDurableSeq(rec.recoveredThroughSeq);
|
|
435
|
+
health.markRecovering();
|
|
436
|
+
// Replay barrier for the new generation: rebuild projections and outstanding
|
|
437
|
+
// work from the recovered durable log (seeding prior results so nothing is
|
|
438
|
+
// re-attributed), and re-assert the effective policy, before the storage gap
|
|
439
|
+
// and reconciliation changes below route through the producer.
|
|
440
|
+
producer?.start(rec.attributionEvents);
|
|
441
|
+
await producer?.ensurePolicy(enrichmentPolicy);
|
|
442
|
+
const rawAppend = async (input) => {
|
|
443
|
+
// If the lock was lost mid-recovery, stop writing: another process now
|
|
444
|
+
// owns the log. This bounds — it cannot fully prevent — the residual, as
|
|
445
|
+
// an append already awaiting its fsync when surrender flips still lands.
|
|
446
|
+
if (surrendered) {
|
|
447
|
+
throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
|
|
448
|
+
}
|
|
449
|
+
const event = await underlying.append(input);
|
|
450
|
+
health.setDurableSeq(underlying.durableSeq());
|
|
451
|
+
return event;
|
|
452
|
+
};
|
|
453
|
+
// Disclose exactly one storage gap per outage: if a prior attempt's gap
|
|
454
|
+
// survived on disk, reuse its seq instead of appending a duplicate (Q6).
|
|
455
|
+
const episode = (outageEpisodeId ??= randomUUID());
|
|
456
|
+
let gapSeq = rec.storageGapSeqByEpisode.get(episode);
|
|
457
|
+
if (gapSeq === undefined) {
|
|
458
|
+
const gap = await rawAppend({
|
|
459
|
+
type: 'slipstream.capture.gap.v1',
|
|
460
|
+
occurred_at_ms: Date.now(),
|
|
461
|
+
data: { scope: { kind: 'session' }, reason: 'storage', episode_id: episode },
|
|
462
|
+
});
|
|
463
|
+
gapSeq = gap.seq;
|
|
464
|
+
}
|
|
465
|
+
await reconcile({ committed: rec.committed, baselineUnknownDirs: rec.baselineUnknownDirs }, gapSeq, rawAppend);
|
|
466
|
+
if (surrendered)
|
|
467
|
+
return false; // dispossessed during recovery: stay failing, don't mask it
|
|
468
|
+
goLive();
|
|
469
|
+
health.markHealthy();
|
|
470
|
+
outageEpisodeId = undefined; // outage resolved; a later fault opens a new episode
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
// Recovery failed. Restore the failing state (the original failure is
|
|
475
|
+
// preserved) so the supervisor keeps retrying. Stay NOT live: the engine's
|
|
476
|
+
// in-memory baselines may now disagree with the reopened log (a partial
|
|
477
|
+
// reconcile, or a reopen that read a durably-newer state), so replaying
|
|
478
|
+
// buffered events here would append a change against a stale `before` and
|
|
479
|
+
// poison the log. Buffering holds them until a *successful* recovery resets
|
|
480
|
+
// the baselines via reconcile and then goes live.
|
|
481
|
+
health.markFailing({ code: undefined, operation: 'recovery', detected_at_ms: Date.now() });
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
// Install the watcher BEFORE enumerating so nothing that happens during the
|
|
486
|
+
// scan is missed; buffered events reconcile against the baseline afterward.
|
|
487
|
+
let subscription;
|
|
488
|
+
try {
|
|
489
|
+
subscription = await deps.platform.watch({ root, ignore: excluded, onObservation, onError });
|
|
490
|
+
if (resuming) {
|
|
491
|
+
// Replay barrier: rebuild the producer's projections and reconstruct
|
|
492
|
+
// outstanding work from the durable log, seeding prior results so a
|
|
493
|
+
// reproduced attribution is not re-appended, BEFORE any new commit routes
|
|
494
|
+
// through it. The reconciliation changes below then attribute live.
|
|
495
|
+
producer.start(recovered.attributionEvents);
|
|
496
|
+
await appendEvent({
|
|
497
|
+
type: 'slipstream.session.resumed.v1',
|
|
498
|
+
occurred_at_ms: Date.now(),
|
|
499
|
+
data: {
|
|
500
|
+
recovered_through_seq: recovered.recoveredThroughSeq.toString(),
|
|
501
|
+
discarded_tail_bytes: recovered.discardedTailBytes,
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
const restartGap = await appendEvent({
|
|
505
|
+
type: 'slipstream.capture.gap.v1',
|
|
506
|
+
occurred_at_ms: Date.now(),
|
|
507
|
+
data: { scope: { kind: 'session' }, reason: 'restart' },
|
|
508
|
+
});
|
|
509
|
+
await producer.ensurePolicy(enrichmentPolicy);
|
|
510
|
+
await reconcile({
|
|
511
|
+
committed: recovered.committed,
|
|
512
|
+
baselineUnknownDirs: recovered.baselineUnknownDirs,
|
|
513
|
+
}, restartGap.seq, appendEvent);
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
await appendEvent({
|
|
517
|
+
type: 'slipstream.session.started.v1',
|
|
518
|
+
occurred_at_ms: Date.now(),
|
|
519
|
+
data: { root, max_bytes: maxBytes },
|
|
520
|
+
});
|
|
521
|
+
const unknownScopes = [];
|
|
522
|
+
await deps.enumerate(root, root, isExcluded, {
|
|
523
|
+
onFile: async (rel) => {
|
|
524
|
+
const snapshot = await reader.read(rel);
|
|
525
|
+
await appendEvent({
|
|
526
|
+
type: 'slipstream.file.baselined.v1',
|
|
527
|
+
occurred_at_ms: Date.now(),
|
|
528
|
+
data: { path: rel, snapshot },
|
|
529
|
+
});
|
|
530
|
+
engine.setBaseline(rel, snapshot);
|
|
531
|
+
},
|
|
532
|
+
onDirError: async (relDir) => {
|
|
533
|
+
engine.markBaselineUnknown(relDir);
|
|
534
|
+
unknownScopes.push(relDir);
|
|
535
|
+
await appendEvent({
|
|
536
|
+
type: 'slipstream.capture.gap.v1',
|
|
537
|
+
occurred_at_ms: Date.now(),
|
|
538
|
+
data: { scope: { kind: 'directory', path: relDir }, reason: 'baseline-unreadable' },
|
|
539
|
+
});
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
await appendEvent({
|
|
543
|
+
type: 'slipstream.capture.baseline.completed.v1',
|
|
544
|
+
occurred_at_ms: Date.now(),
|
|
545
|
+
data: { unknown_scopes: unknownScopes },
|
|
546
|
+
});
|
|
547
|
+
// Arm attribution and publish the effective policy before going live, so
|
|
548
|
+
// the first live change binds to a committed policy.
|
|
549
|
+
producer.start([]);
|
|
550
|
+
await producer.ensurePolicy(enrichmentPolicy);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
// A failed startup may already have kicked the recovery supervisor (an append
|
|
555
|
+
// fault calls enterFailing). Stop it and await the in-flight attempt before
|
|
556
|
+
// releasing the lock, or its next reopen could write to a log a second owner
|
|
557
|
+
// has since acquired — the same ordering `stop()` relies on.
|
|
558
|
+
stopped = true;
|
|
559
|
+
await subscription?.close().catch(() => { });
|
|
560
|
+
await supervisorLoop.catch(() => { });
|
|
561
|
+
await producer.stop().catch(() => { });
|
|
562
|
+
await engine.drain().catch(() => { });
|
|
563
|
+
await underlying.close().catch(() => { });
|
|
564
|
+
await lock.release();
|
|
565
|
+
throw err;
|
|
566
|
+
}
|
|
567
|
+
goLive();
|
|
568
|
+
health.markHealthy();
|
|
569
|
+
const ingestEvidence = (evidence) => producer.ingestEvidence(evidence);
|
|
570
|
+
// The transcript coverage watcher (A2): reads configured harnesses' transcripts
|
|
571
|
+
// for this worktree as revisable evidence. It never creates a change and its
|
|
572
|
+
// evidence flows through the same deduped, append-only producer path, so a
|
|
573
|
+
// re-read (including a late transcript after a restart) revises without
|
|
574
|
+
// duplicating. Runs only when a harness is actually configured.
|
|
575
|
+
let coverageRunner;
|
|
576
|
+
const tc = opts.transcript;
|
|
577
|
+
if (tc && tc.harnesses.length > 0) {
|
|
578
|
+
coverageRunner = createCoverageRunner({
|
|
579
|
+
harnesses: tc.harnesses,
|
|
580
|
+
homes: tc.homes,
|
|
581
|
+
codexScanLimit: tc.codexScanLimit,
|
|
582
|
+
root,
|
|
583
|
+
sink: { ingest: ingestEvidence },
|
|
584
|
+
publish: async (data) => {
|
|
585
|
+
await appendEvent({ type: 'slipstream.enrichment.coverage.v1', occurred_at_ms: Date.now(), data });
|
|
586
|
+
},
|
|
587
|
+
discoveryIO: tc.discoveryIO ?? nodeDiscoveryIO,
|
|
588
|
+
fileIO: tc.fileIO ?? nodeTranscriptFileIO,
|
|
589
|
+
intervalMs: tc.pollIntervalMs ?? DEFAULT_TRANSCRIPT_POLL_MS,
|
|
590
|
+
onError: (err) => console.error(`slipstream: transcript watcher error: ${err.message}`),
|
|
591
|
+
});
|
|
592
|
+
coverageRunner.start();
|
|
593
|
+
}
|
|
594
|
+
const beginTask = async ({ title, requestId }) => {
|
|
595
|
+
// Readiness: only a live, healthy, still-owned session may declare a task.
|
|
596
|
+
// Domain error codes (CAPTURE_NOT_READY, STORAGE_UNAVAILABLE, ...) are a later
|
|
597
|
+
// PR's forwarder concern; in-process this is a plain refusal.
|
|
598
|
+
if (stopped || surrendered || health.snapshot().state !== 'healthy') {
|
|
599
|
+
throw new Error('capture session is not ready to accept task declarations');
|
|
600
|
+
}
|
|
601
|
+
if (typeof requestId !== 'string' || requestId.length === 0) {
|
|
602
|
+
throw new Error('beginTask requires a non-empty request_id');
|
|
603
|
+
}
|
|
604
|
+
if (typeof title !== 'string' || title.length === 0) {
|
|
605
|
+
throw new Error('beginTask requires a non-empty title');
|
|
606
|
+
}
|
|
607
|
+
// Idempotency keyed by (capture session, request_id); this session is the key's
|
|
608
|
+
// session component, so request_id alone indexes within it. A settled result
|
|
609
|
+
// replays as-is; a same-key call still in flight coalesces onto it; a reused
|
|
610
|
+
// key with a different title is a caller mistake, not a retry.
|
|
611
|
+
const settled = committedTasks.get(requestId);
|
|
612
|
+
if (settled !== undefined) {
|
|
613
|
+
if (settled.title !== title) {
|
|
614
|
+
throw new InvalidTitleError(`request_id ${requestId} was already used for a different title`);
|
|
615
|
+
}
|
|
616
|
+
return settled.result;
|
|
617
|
+
}
|
|
618
|
+
const pending = inflightTasks.get(requestId);
|
|
619
|
+
if (pending !== undefined) {
|
|
620
|
+
if (pending.title !== title) {
|
|
621
|
+
throw new InvalidTitleError(`request_id ${requestId} is in flight for a different title`);
|
|
622
|
+
}
|
|
623
|
+
return pending.promise;
|
|
624
|
+
}
|
|
625
|
+
// The registration below is synchronous (no await precedes it), so a
|
|
626
|
+
// concurrent duplicate observes the in-flight entry and never starts a second
|
|
627
|
+
// append. `task_id` is minted here; `request_id` is the caller's.
|
|
628
|
+
const taskId = randomUUID();
|
|
629
|
+
const promise = (async () => {
|
|
630
|
+
const event = await appendEvent({
|
|
631
|
+
type: 'slipstream.task.started.v1',
|
|
632
|
+
occurred_at_ms: Date.now(),
|
|
633
|
+
data: { task_id: taskId, request_id: requestId, title },
|
|
634
|
+
});
|
|
635
|
+
// The record may have landed durably while ownership was lost mid-fsync (an
|
|
636
|
+
// accepted residual the new owner reconciles). A surrendered session must
|
|
637
|
+
// not ACKNOWLEDGE the commit or cache it as a settled result — acknowledging
|
|
638
|
+
// after surrender is exactly what the lost lock forbids.
|
|
639
|
+
if (surrendered) {
|
|
640
|
+
throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
|
|
641
|
+
}
|
|
642
|
+
// Frozen so a caller cannot mutate the object the dedup index hands back to
|
|
643
|
+
// every future retry (the result is an immutable identity record).
|
|
644
|
+
const result = Object.freeze({
|
|
645
|
+
session_id: sessionId,
|
|
646
|
+
task_id: taskId,
|
|
647
|
+
event_id: event.id, // equals seq by construction
|
|
648
|
+
seq: event.seq,
|
|
649
|
+
});
|
|
650
|
+
committedTasks.set(requestId, { title, result });
|
|
651
|
+
return result;
|
|
652
|
+
})();
|
|
653
|
+
inflightTasks.set(requestId, { title, promise });
|
|
654
|
+
try {
|
|
655
|
+
return await promise;
|
|
656
|
+
}
|
|
657
|
+
finally {
|
|
658
|
+
inflightTasks.delete(requestId);
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
const questionReady = () => {
|
|
662
|
+
if (stopped)
|
|
663
|
+
throw new QuestionError('CAPTURE_NOT_READY', 'capture is stopping');
|
|
664
|
+
if (surrendered || health.snapshot().state === 'failing' || health.snapshot().state === 'recovering') {
|
|
665
|
+
throw new QuestionError('STORAGE_UNAVAILABLE', 'capture storage is unavailable');
|
|
666
|
+
}
|
|
667
|
+
if (health.snapshot().state !== 'healthy')
|
|
668
|
+
throw new QuestionError('CAPTURE_NOT_READY', 'capture is not ready');
|
|
669
|
+
};
|
|
670
|
+
const askQuestion = async (input, target, assertOwnership = () => { }) => {
|
|
671
|
+
const req = normalizeAsk(input);
|
|
672
|
+
if (req.session_id !== sessionId)
|
|
673
|
+
throw new QuestionError('SESSION_NOT_SELECTED', 'question addresses a different capture');
|
|
674
|
+
assertOwnership();
|
|
675
|
+
questionReady();
|
|
676
|
+
const body = questionBody(req);
|
|
677
|
+
const settled = committedQuestions.get(req.request_id);
|
|
678
|
+
const pending = inflightQuestions.get(req.request_id);
|
|
679
|
+
const existing = settled ?? pending;
|
|
680
|
+
if (existing && existing.body !== body)
|
|
681
|
+
throw new QuestionError('REQUEST_CONFLICT', 'request_id was used for a different question or context');
|
|
682
|
+
if (settled)
|
|
683
|
+
return { ...settled.result, duplicate: true };
|
|
684
|
+
if (pending)
|
|
685
|
+
return { ...await pending.promise, duplicate: true };
|
|
686
|
+
if (req.reply_to_question_id !== undefined) {
|
|
687
|
+
const parent = questionsById.get(req.reply_to_question_id)?.context;
|
|
688
|
+
const c = req.context;
|
|
689
|
+
if (!parent || parent.change_seq !== c.change_seq || parent.path !== c.path || parent.snapshot_sha256 !== c.snapshot_sha256
|
|
690
|
+
|| parent.line_start !== c.line_start || parent.line_end !== c.line_end) {
|
|
691
|
+
throw new QuestionError('INVALID_CONTEXT', 'a follow-up must reply to a question in this capture about the same source');
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// A reservation covers source reads as well as append, bounding concurrent scans.
|
|
695
|
+
// An expired-but-never-attempted entry can never become eligible again, so it
|
|
696
|
+
// is dropped here rather than kept forever awaiting a claim that will not come.
|
|
697
|
+
let eligible = 0;
|
|
698
|
+
for (const [id, q] of unattemptedQuestions) {
|
|
699
|
+
if (now() < q.data.expires_at_ms)
|
|
700
|
+
eligible += 1;
|
|
701
|
+
else
|
|
702
|
+
unattemptedQuestions.delete(id);
|
|
703
|
+
}
|
|
704
|
+
const reserved = [...inflightQuestions.keys()].filter(id => !committedQuestions.has(id)).length;
|
|
705
|
+
if (eligible + reserved >= QUESTION_LIMIT)
|
|
706
|
+
throw new QuestionError('QUESTION_LIMIT', 'at most 16 unexpired questions may be queued');
|
|
707
|
+
// Defer starting I/O until the in-flight reservation is installed synchronously.
|
|
708
|
+
const promise = Promise.resolve().then(async () => {
|
|
709
|
+
const selected_text = await deps.readQuestionContext({ storeDir, logPath, sessionId,
|
|
710
|
+
boundary: BigInt(health.snapshot().durable_seq), context: req.context });
|
|
711
|
+
assertOwnership();
|
|
712
|
+
questionReady();
|
|
713
|
+
const queued_at_ms = now();
|
|
714
|
+
const event = await appendEvent({ type: 'slipstream.question.queued.v1', occurred_at_ms: queued_at_ms,
|
|
715
|
+
data: { question_id: randomUUID(), request_id: req.request_id, target, text: req.text,
|
|
716
|
+
context: { ...req.context, selected_text }, queued_at_ms, expires_at_ms: queued_at_ms + QUESTION_TTL_MS,
|
|
717
|
+
...(req.reply_to_question_id !== undefined ? { reply_to_question_id: req.reply_to_question_id } : {}) } });
|
|
718
|
+
if (surrendered)
|
|
719
|
+
throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
|
|
720
|
+
assertOwnership();
|
|
721
|
+
const result = questionResult(event);
|
|
722
|
+
committedQuestions.set(req.request_id, { body, result });
|
|
723
|
+
return result;
|
|
724
|
+
});
|
|
725
|
+
inflightQuestions.set(req.request_id, { body, promise });
|
|
726
|
+
try {
|
|
727
|
+
return await promise;
|
|
728
|
+
}
|
|
729
|
+
finally {
|
|
730
|
+
inflightQuestions.delete(req.request_id);
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
const claimQuestion = async (target, assertOwnership = () => { }) => {
|
|
734
|
+
assertOwnership();
|
|
735
|
+
questionReady();
|
|
736
|
+
const queued = [...unattemptedQuestions.values()].find(q => q.data.target.harness === target.harness
|
|
737
|
+
&& q.data.target.harness_session_id === target.harness_session_id
|
|
738
|
+
&& q.data.target.worktree === target.worktree
|
|
739
|
+
&& now() < q.data.expires_at_ms
|
|
740
|
+
&& !reservedQuestions.has(q.data.question_id));
|
|
741
|
+
if (!queued)
|
|
742
|
+
return null;
|
|
743
|
+
const id = queued.data.question_id;
|
|
744
|
+
reservedQuestions.add(id);
|
|
745
|
+
try {
|
|
746
|
+
const attempted_at_ms = now();
|
|
747
|
+
await appendEvent({ type: 'slipstream.question.dispatch_attempted.v1', occurred_at_ms: attempted_at_ms,
|
|
748
|
+
data: { question_id: id, queued_seq: queued.seq, attempted_at_ms } });
|
|
749
|
+
if (surrendered)
|
|
750
|
+
throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
|
|
751
|
+
assertOwnership();
|
|
752
|
+
return { ...queued.data, queued_seq: queued.seq };
|
|
753
|
+
}
|
|
754
|
+
finally {
|
|
755
|
+
reservedQuestions.delete(id);
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
// Order: INVALID_ANSWER, then QUESTION_NOT_FOUND (which also hides questions
|
|
759
|
+
// dispatched to another harness session), then duplicate or ANSWER_CONFLICT.
|
|
760
|
+
const answerQuestion = async (input, target, assertOwnership = () => { }) => {
|
|
761
|
+
const { question_id: id, text } = input;
|
|
762
|
+
assertAnswerText(text);
|
|
763
|
+
assertOwnership();
|
|
764
|
+
questionReady();
|
|
765
|
+
const owner = typeof id === 'string' ? questionsById.get(id)?.target : undefined;
|
|
766
|
+
const attemptSeq = typeof id === 'string' ? attemptSeqs.get(id) : undefined;
|
|
767
|
+
if (typeof id !== 'string' || !owner || attemptSeq === undefined || owner.harness !== target.harness
|
|
768
|
+
|| owner.harness_session_id !== target.harness_session_id || owner.worktree !== target.worktree) {
|
|
769
|
+
throw new QuestionError('QUESTION_NOT_FOUND', 'no question with that id was dispatched to this harness session');
|
|
770
|
+
}
|
|
771
|
+
const settled = committedAnswers.get(id);
|
|
772
|
+
const pending = inflightAnswers.get(id);
|
|
773
|
+
const existing = settled ?? pending;
|
|
774
|
+
if (existing && existing.text !== text)
|
|
775
|
+
throw new QuestionError('ANSWER_CONFLICT', 'this question already has a different answer');
|
|
776
|
+
if (settled)
|
|
777
|
+
return { ...settled.result, duplicate: true };
|
|
778
|
+
if (pending)
|
|
779
|
+
return { ...await pending.promise, duplicate: true };
|
|
780
|
+
const promise = Promise.resolve().then(async () => {
|
|
781
|
+
const answered_at_ms = now();
|
|
782
|
+
const event = await appendEvent({ type: 'slipstream.question.answered.v1', occurred_at_ms: answered_at_ms,
|
|
783
|
+
data: { question_id: id, attempt_seq: attemptSeq, text, answered_at_ms } });
|
|
784
|
+
if (surrendered)
|
|
785
|
+
throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
|
|
786
|
+
assertOwnership();
|
|
787
|
+
return answerResult(event);
|
|
788
|
+
});
|
|
789
|
+
inflightAnswers.set(id, { text, promise });
|
|
790
|
+
try {
|
|
791
|
+
return await promise;
|
|
792
|
+
}
|
|
793
|
+
finally {
|
|
794
|
+
inflightAnswers.delete(id);
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
const doStop = async () => {
|
|
798
|
+
stopped = true;
|
|
799
|
+
// Stop feeding the engine immediately: if the watcher unsubscribe below
|
|
800
|
+
// rejects, late observations buffer instead of appending after the lock is
|
|
801
|
+
// released. Every step is then best-effort so a single failure never skips
|
|
802
|
+
// closing the log or releasing the lock; the first error is rethrown.
|
|
803
|
+
live = false;
|
|
804
|
+
let firstError;
|
|
805
|
+
const record = (err) => {
|
|
806
|
+
if (firstError === undefined)
|
|
807
|
+
firstError = err;
|
|
808
|
+
};
|
|
809
|
+
// Stop the coverage watcher before the producer and log close: it appends
|
|
810
|
+
// evidence and coverage through both, and an append after close would throw.
|
|
811
|
+
await coverageRunner?.stop().catch(record);
|
|
812
|
+
await subscription?.close().catch(record);
|
|
813
|
+
// Await any in-flight recovery: it may be mid-reopen, and appending after
|
|
814
|
+
// we release the lock would let a second owner's writes interleave. The
|
|
815
|
+
// loop stops starting new attempts once `stopped` is set.
|
|
816
|
+
await supervisorLoop.catch(() => { });
|
|
817
|
+
await engine.drain().catch(record);
|
|
818
|
+
// Fence the attribution generation and flush its in-flight appends before the
|
|
819
|
+
// log closes: a stale evaluation must never append after the log is gone.
|
|
820
|
+
await producer?.stop().catch(record);
|
|
821
|
+
await Promise.allSettled([...inflightQuestions.values(), ...inflightAnswers.values()].map(q => q.promise));
|
|
822
|
+
await underlying.close().catch(record);
|
|
823
|
+
await lock.release().catch(record);
|
|
824
|
+
if (firstError !== undefined)
|
|
825
|
+
throw firstError;
|
|
826
|
+
};
|
|
827
|
+
// Memoized so an overlapping detach + daemon shutdown (both hold the same
|
|
828
|
+
// handle) run teardown once. Two concurrent releases could otherwise both read
|
|
829
|
+
// the current nonce and the loser could unlink a successor's lock.
|
|
830
|
+
let stopPromise;
|
|
831
|
+
return {
|
|
832
|
+
sessionId,
|
|
833
|
+
logPath,
|
|
834
|
+
blobsDir,
|
|
835
|
+
health,
|
|
836
|
+
beginTask,
|
|
837
|
+
askQuestion,
|
|
838
|
+
claimQuestion,
|
|
839
|
+
answerQuestion,
|
|
840
|
+
ingestEvidence,
|
|
841
|
+
stop: () => (stopPromise ??= doStop()),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
async function enumerate(root, dir, isExcluded, handlers) {
|
|
845
|
+
let entries;
|
|
846
|
+
try {
|
|
847
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
848
|
+
}
|
|
849
|
+
catch {
|
|
850
|
+
await handlers.onDirError(relative(root, dir));
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
for (const entry of entries) {
|
|
854
|
+
const abs = join(dir, entry.name);
|
|
855
|
+
if (isExcluded(abs))
|
|
856
|
+
continue;
|
|
857
|
+
if (entry.isSymbolicLink())
|
|
858
|
+
continue; // excluded, never followed
|
|
859
|
+
if (entry.isDirectory()) {
|
|
860
|
+
await enumerate(root, abs, isExcluded, handlers);
|
|
861
|
+
}
|
|
862
|
+
else if (entry.isFile()) {
|
|
863
|
+
await handlers.onFile(relative(root, abs));
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|