@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/session.js
CHANGED
|
@@ -1,25 +1,26 @@
|
|
|
1
|
-
import { access, readdir, realpath } from 'node:fs/promises';
|
|
1
|
+
import { access, lstat, mkdtemp, readdir, readFile, realpath, rm, stat, truncate, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
3
4
|
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
4
5
|
import { createCas } from "./cas.js";
|
|
5
6
|
import { createReader, DEFAULT_MAX_BYTES } from "./reader.js";
|
|
6
7
|
import { createLog } from "./log.js";
|
|
7
|
-
import { createEngine } from "./engine.js";
|
|
8
|
+
import { addUnknownScope, createEngine, isUnderUnknownScope } from "./engine.js";
|
|
9
|
+
import { detectCaptureScope } from "./capture-scope.js";
|
|
8
10
|
import { createHealth } from "./health.js";
|
|
9
11
|
import { acquireSessionLock } from "./lock.js";
|
|
10
12
|
import { recoverSession } from "./recovery.js";
|
|
11
13
|
import { StorageError, assertOwnerOnly, mkdirpDurable } from "./storage.js";
|
|
12
14
|
import { snapshotsEqual } from "./snapshot.js";
|
|
13
|
-
import { isValidSessionId } from "./store-reader.js";
|
|
15
|
+
import { finalRecord, isValidSessionId } from "./store-reader.js";
|
|
14
16
|
import { createPlatform } from "./platform.js";
|
|
15
17
|
import { createAttributionProducer, DEFAULT_ENRICHMENT_POLICY, } from "./attribution-producer.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
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;
|
|
18
|
+
import { EVENT_TYPES } from "./event.js";
|
|
19
|
+
import { normalizeAsk, questionBody, questionResult, readQuestionContext, QuestionError, QUESTION_TTL_MS, QUESTION_LIMIT, assertAnswerText, answerResult, normalizeExchange, exchangeBody, exchangeResult, sameSourceContext, normalizeRelayUpdate, relayUpdateBody, relayUpdateResult } from "./questions.js";
|
|
21
20
|
/** A `request_id` was reused with a different title — a caller mistake, not a
|
|
22
21
|
* retry, so it never commits a second declaration. */
|
|
22
|
+
export class RootChangedError extends Error {
|
|
23
|
+
}
|
|
23
24
|
export class InvalidTitleError extends Error {
|
|
24
25
|
code = 'INVALID_TITLE';
|
|
25
26
|
constructor(message) {
|
|
@@ -27,7 +28,15 @@ export class InvalidTitleError extends Error {
|
|
|
27
28
|
this.name = 'InvalidTitleError';
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
|
-
const
|
|
31
|
+
const CORE_EVENT_TYPES = new Set(EVENT_TYPES);
|
|
32
|
+
const isCoreEvent = (event) => CORE_EVENT_TYPES.has(event.type);
|
|
33
|
+
const defaultDependencies = {
|
|
34
|
+
createLog,
|
|
35
|
+
platform: createPlatform(),
|
|
36
|
+
enumerate,
|
|
37
|
+
readQuestionContext,
|
|
38
|
+
detectScope: detectCaptureScope,
|
|
39
|
+
};
|
|
31
40
|
/** A relative path escapes its base only via a leading `..` segment (or when it
|
|
32
41
|
* comes back absolute); a filename that merely starts with `..`, like
|
|
33
42
|
* `..notes.ts`, stays inside. */
|
|
@@ -37,6 +46,37 @@ function escapesBase(rel) {
|
|
|
37
46
|
function isUnder(child, parent) {
|
|
38
47
|
return !escapesBase(relative(parent, child));
|
|
39
48
|
}
|
|
49
|
+
/** A frozen copy of the root `.slipstreamignore`, handed to git as its
|
|
50
|
+
* lowest-precedence excludes file so capture can drop extra paths — including
|
|
51
|
+
* tracked ones git would merge. Copied once so a mid-capture edit does not
|
|
52
|
+
* change this capture's scope; a later restart re-reads it, matching how git's
|
|
53
|
+
* own rule changes take effect. Returns undefined when the file is absent or
|
|
54
|
+
* empty. A file that exists but cannot be read rejects rather than silently
|
|
55
|
+
* widening scope to paths the user meant to exclude. Only consulted inside a
|
|
56
|
+
* git work tree; a non-git root ignores the file. */
|
|
57
|
+
async function freezeSlipstreamIgnore(root) {
|
|
58
|
+
const path = join(root, '.slipstreamignore');
|
|
59
|
+
// Stat (which follows symlinks but never blocks) before opening: a named pipe
|
|
60
|
+
// or other non-regular file would otherwise hang the read and stall startup.
|
|
61
|
+
let info;
|
|
62
|
+
try {
|
|
63
|
+
info = await stat(path);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (err.code === 'ENOENT')
|
|
67
|
+
return undefined;
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
if (!info.isFile())
|
|
71
|
+
throw new Error(`.slipstreamignore is not a regular file`);
|
|
72
|
+
const content = await readFile(path, 'utf8');
|
|
73
|
+
if (content.trim() === '')
|
|
74
|
+
return undefined;
|
|
75
|
+
const dir = await mkdtemp(join(tmpdir(), 'slip-ignore-'));
|
|
76
|
+
const file = join(dir, 'excludes');
|
|
77
|
+
await writeFile(file, content);
|
|
78
|
+
return { dir, file };
|
|
79
|
+
}
|
|
40
80
|
export async function startCapture(opts, dependencies = {}) {
|
|
41
81
|
const deps = { ...defaultDependencies, ...dependencies };
|
|
42
82
|
if (opts.sessionId !== undefined) {
|
|
@@ -50,13 +90,45 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
50
90
|
// The native watcher reports realpaths; resolve symlinks in the root (e.g.
|
|
51
91
|
// macOS /var -> /private/var) so relative-path math against events matches.
|
|
52
92
|
const root = await realpath(opts.root);
|
|
93
|
+
const rootStat = await stat(root, { bigint: true });
|
|
94
|
+
const expected = opts.rootIdentity;
|
|
95
|
+
const replaced = (st) => expected !== undefined
|
|
96
|
+
&& (st?.dev.toString() !== expected.dev || st.ino.toString() !== expected.ino);
|
|
97
|
+
if (replaced(rootStat)) {
|
|
98
|
+
throw new RootChangedError(`the workspace root ${root} was replaced; a directory recreated there is captured under a new session`);
|
|
99
|
+
}
|
|
100
|
+
// Validate the store before freezing anything, so a bad store directory fails
|
|
101
|
+
// without leaving a frozen copy of the ignore rules behind.
|
|
53
102
|
await mkdirpDurable(opts.storeDir);
|
|
54
103
|
const storeDir = await realpath(opts.storeDir);
|
|
55
104
|
await assertOwnerOnly(storeDir, 'dir');
|
|
56
105
|
if (isUnder(root, storeDir)) {
|
|
57
106
|
throw new Error('store directory must not equal or contain the watched root');
|
|
58
107
|
}
|
|
108
|
+
// Decided before anything is written: a git root git cannot read rejects here
|
|
109
|
+
// rather than silently capturing what git would not merge.
|
|
110
|
+
const baseScope = await deps.detectScope(root);
|
|
111
|
+
// Only a git work tree honors .slipstreamignore; a non-git root never reads it
|
|
112
|
+
// (so an unreadable file there does not block capture). Frozen before
|
|
113
|
+
// classification so a mid-capture edit does not change this capture's scope;
|
|
114
|
+
// cleaned up on a failed start, in the catches below, and in stop().
|
|
115
|
+
let frozenSlip;
|
|
116
|
+
let scope = baseScope;
|
|
117
|
+
if (baseScope.policy === 'git') {
|
|
118
|
+
try {
|
|
119
|
+
frozenSlip = await freezeSlipstreamIgnore(root);
|
|
120
|
+
if (frozenSlip)
|
|
121
|
+
scope = await deps.detectScope(root, frozenSlip.file);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (frozenSlip)
|
|
125
|
+
await rm(frozenSlip.dir, { recursive: true, force: true }).catch(() => { });
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const slipstreamApplied = frozenSlip !== undefined;
|
|
59
130
|
const resuming = opts.resumeSessionId !== undefined;
|
|
131
|
+
const captureModeField = opts.captureMode ? { capture_mode: opts.captureMode } : {};
|
|
60
132
|
const sessionId = opts.resumeSessionId ?? opts.sessionId ?? randomUUID();
|
|
61
133
|
const blobsDir = join(storeDir, 'blobs');
|
|
62
134
|
const sessionDir = join(storeDir, 'sessions', sessionId);
|
|
@@ -64,7 +136,7 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
64
136
|
await mkdirpDurable(sessionDir);
|
|
65
137
|
await assertOwnerOnly(sessionDir, 'dir'); // blobsDir perms are checked by createCas
|
|
66
138
|
const logPath = join(sessionDir, 'events.jsonl');
|
|
67
|
-
const cas = await createCas(blobsDir);
|
|
139
|
+
const cas = opts.cas ?? await createCas(blobsDir);
|
|
68
140
|
// Durable task boundaries (Stage 3). `currentTaskId` is the latest committed
|
|
69
141
|
// declaration in shared append order — the grouping hint stamped onto changes.
|
|
70
142
|
// The two maps are the idempotency index keyed by `request_id`: `committedTasks`
|
|
@@ -74,41 +146,96 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
74
146
|
const committedQuestions = new Map();
|
|
75
147
|
const inflightQuestions = new Map();
|
|
76
148
|
const reservedQuestions = new Set();
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
const
|
|
149
|
+
// Exchanges are a separate namespace: they never queue, expire, count against
|
|
150
|
+
// QUESTION_LIMIT, or appear in the ask follow-up lookup.
|
|
151
|
+
const committedExchanges = new Map();
|
|
152
|
+
const inflightExchanges = new Map();
|
|
153
|
+
const exchangesById = new Map();
|
|
154
|
+
// relay_id -> question_id for every committed send. A relay_id is reserved while
|
|
155
|
+
// its append is in flight, so a concurrent reuse is refused before either lands.
|
|
156
|
+
const relayQuestions = new Map();
|
|
157
|
+
const reservedRelays = new Set();
|
|
158
|
+
const committedRelayUpdates = new Map();
|
|
159
|
+
const inflightRelayUpdates = new Map();
|
|
160
|
+
const sourceContext = (context) => ({
|
|
161
|
+
change_seq: context.change_seq,
|
|
162
|
+
path: context.path,
|
|
163
|
+
snapshot_sha256: context.snapshot_sha256,
|
|
164
|
+
line_start: context.line_start,
|
|
165
|
+
line_end: context.line_end,
|
|
166
|
+
});
|
|
167
|
+
// Questions a chat may claim. Removed once dispatch is attempted (durably
|
|
168
|
+
// consumed either way) or answered, so eligibility counting and claim lookup
|
|
169
|
+
// scan only outstanding questions, not every question ever queued by a
|
|
170
|
+
// long-lived capture. An unlink re-arms the unanswered ones for the next chat.
|
|
171
|
+
const claimableQuestions = new Map();
|
|
81
172
|
// Kept for the capture's lifetime: a question stays answerable after it
|
|
82
|
-
// leaves the
|
|
173
|
+
// leaves the claimable queue or passes its claim TTL.
|
|
83
174
|
const questionsById = new Map();
|
|
84
|
-
|
|
175
|
+
// Every chat a question was handed to, in attempt order.
|
|
176
|
+
const attemptsById = new Map();
|
|
177
|
+
let latestResumeSeq = 0n;
|
|
85
178
|
const committedAnswers = new Map();
|
|
86
179
|
const inflightAnswers = new Map();
|
|
87
180
|
let currentTaskId;
|
|
88
181
|
const committedTasks = new Map();
|
|
89
182
|
const inflightTasks = new Map();
|
|
183
|
+
const pruneExpiredClaimableQuestions = () => {
|
|
184
|
+
const currentTime = now();
|
|
185
|
+
for (const [id, queued] of claimableQuestions) {
|
|
186
|
+
if (currentTime >= queued.data.expires_at_ms)
|
|
187
|
+
claimableQuestions.delete(id);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
90
190
|
// The attribution producer, constructed once the append path exists below. Its
|
|
91
191
|
// `noteCommitted` is fed from the log's single in-order commit hook, so every
|
|
92
192
|
// durably-committed change/evidence/policy reaches it exactly once, in order.
|
|
93
193
|
let producer;
|
|
194
|
+
const noteAttempt = (event) => {
|
|
195
|
+
const id = event.data.question_id;
|
|
196
|
+
// An attempt written before attempts recorded their chat went to the queued target.
|
|
197
|
+
const target = event.data.target ?? questionsById.get(id).data.target;
|
|
198
|
+
const attempts = attemptsById.get(id) ?? [];
|
|
199
|
+
attempts.push({ target, seq: event.seq });
|
|
200
|
+
attemptsById.set(id, attempts);
|
|
201
|
+
};
|
|
94
202
|
const seedTaskState = (rec) => {
|
|
95
203
|
committedQuestions.clear();
|
|
96
|
-
|
|
204
|
+
claimableQuestions.clear();
|
|
205
|
+
questionsById.clear();
|
|
206
|
+
attemptsById.clear();
|
|
207
|
+
committedAnswers.clear();
|
|
208
|
+
latestResumeSeq = rec.resumeSeq;
|
|
97
209
|
for (const event of rec.questions) {
|
|
98
210
|
committedQuestions.set(event.data.request_id, { body: questionBody(event.data), result: questionResult(event) });
|
|
99
|
-
|
|
211
|
+
questionsById.set(event.data.question_id, event);
|
|
212
|
+
// Questions queued before the latest resume stay history only (D2).
|
|
213
|
+
if (BigInt(event.seq) > rec.resumeSeq)
|
|
214
|
+
claimableQuestions.set(event.data.question_id, event);
|
|
100
215
|
}
|
|
101
|
-
for (const event of rec.questionAttempts)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
for (const event of rec.
|
|
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)
|
|
216
|
+
for (const event of rec.questionAttempts) {
|
|
217
|
+
noteAttempt(event);
|
|
218
|
+
if (BigInt(event.seq) > rec.unlinkSeq)
|
|
219
|
+
claimableQuestions.delete(event.data.question_id);
|
|
220
|
+
}
|
|
221
|
+
for (const event of rec.questionAnswers) {
|
|
111
222
|
committedAnswers.set(event.data.question_id, { text: event.data.text, result: answerResult(event) });
|
|
223
|
+
claimableQuestions.delete(event.data.question_id);
|
|
224
|
+
}
|
|
225
|
+
pruneExpiredClaimableQuestions();
|
|
226
|
+
committedExchanges.clear();
|
|
227
|
+
exchangesById.clear();
|
|
228
|
+
for (const event of rec.questionExchanges) {
|
|
229
|
+
committedExchanges.set(event.data.request_id, { body: exchangeBody(event.data), result: exchangeResult(event) });
|
|
230
|
+
exchangesById.set(event.data.question_id, sourceContext(event.data.context));
|
|
231
|
+
}
|
|
232
|
+
relayQuestions.clear();
|
|
233
|
+
for (const [relayId, questionId] of rec.relayQuestions)
|
|
234
|
+
relayQuestions.set(relayId, questionId);
|
|
235
|
+
committedRelayUpdates.clear();
|
|
236
|
+
for (const event of rec.questionRelayUpdates) {
|
|
237
|
+
committedRelayUpdates.set(event.data.request_id, { body: relayUpdateBody(event.data), result: relayUpdateResult(event) });
|
|
238
|
+
}
|
|
112
239
|
currentTaskId = rec.currentTaskId;
|
|
113
240
|
committedTasks.clear();
|
|
114
241
|
for (const [requestId, decl] of rec.taskDeclarations) {
|
|
@@ -137,22 +264,48 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
137
264
|
};
|
|
138
265
|
},
|
|
139
266
|
onCommitted: (event) => {
|
|
267
|
+
// A resume or an unlinked chat leaves no current task (D1). A resume leaves no
|
|
268
|
+
// claimable questions (D2); an unlink hands every unanswered question queued
|
|
269
|
+
// since the latest resume to whichever chat is linked next.
|
|
270
|
+
if (event.type === 'slipstream.session.resumed.v1') {
|
|
271
|
+
currentTaskId = undefined;
|
|
272
|
+
latestResumeSeq = BigInt(event.seq);
|
|
273
|
+
claimableQuestions.clear();
|
|
274
|
+
}
|
|
275
|
+
if (event.type === 'slipstream.agent.unlinked.v1') {
|
|
276
|
+
currentTaskId = undefined;
|
|
277
|
+
// Rebuilt in queue order so claims stay oldest first.
|
|
278
|
+
claimableQuestions.clear();
|
|
279
|
+
for (const [id, queued] of questionsById) {
|
|
280
|
+
if (BigInt(queued.seq) > latestResumeSeq && !committedAnswers.has(id))
|
|
281
|
+
claimableQuestions.set(id, queued);
|
|
282
|
+
}
|
|
283
|
+
pruneExpiredClaimableQuestions();
|
|
284
|
+
}
|
|
140
285
|
if (event.type === 'slipstream.task.started.v1')
|
|
141
286
|
currentTaskId = event.data.task_id;
|
|
142
287
|
// Route every durable commit to attribution in the same total order.
|
|
143
288
|
if (event.type === 'slipstream.question.queued.v1') {
|
|
144
|
-
|
|
145
|
-
questionsById.set(event.data.question_id, event
|
|
289
|
+
claimableQuestions.set(event.data.question_id, event);
|
|
290
|
+
questionsById.set(event.data.question_id, event);
|
|
146
291
|
}
|
|
147
292
|
if (event.type === 'slipstream.question.dispatch_attempted.v1') {
|
|
148
|
-
|
|
149
|
-
|
|
293
|
+
claimableQuestions.delete(event.data.question_id);
|
|
294
|
+
noteAttempt(event);
|
|
150
295
|
}
|
|
151
296
|
if (event.type === 'slipstream.question.answered.v1') {
|
|
152
297
|
committedAnswers.set(event.data.question_id, { text: event.data.text, result: answerResult(event) });
|
|
298
|
+
claimableQuestions.delete(event.data.question_id);
|
|
299
|
+
}
|
|
300
|
+
if (event.type === 'slipstream.question.exchange_recorded.v1') {
|
|
301
|
+
exchangesById.set(event.data.question_id, sourceContext(event.data.context));
|
|
302
|
+
if (event.data.relay)
|
|
303
|
+
relayQuestions.set(event.data.relay.relay_id, event.data.question_id);
|
|
304
|
+
}
|
|
305
|
+
if (event.type === 'slipstream.question.relay_updated.v1' && event.data.update.kind === 'sent') {
|
|
306
|
+
relayQuestions.set(event.data.relay_id, event.data.question_id);
|
|
153
307
|
}
|
|
154
|
-
if (event
|
|
155
|
-
&& event.type !== 'slipstream.question.answered.v1')
|
|
308
|
+
if (isCoreEvent(event))
|
|
156
309
|
producer?.noteCommitted(event);
|
|
157
310
|
},
|
|
158
311
|
};
|
|
@@ -179,10 +332,22 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
179
332
|
// duplicates seqs. Resuming existing history requires resumeSessionId. The
|
|
180
333
|
// check runs while we hold the session lock so a concurrent caller cannot
|
|
181
334
|
// create then release the same id between the check and log creation.
|
|
182
|
-
|
|
183
|
-
|
|
335
|
+
// A log holding no complete record (a first start that failed before its
|
|
336
|
+
// `session.started` became durable) recorded nothing, so it starts afresh.
|
|
337
|
+
try {
|
|
338
|
+
const logExists = await access(logPath).then(() => true, () => false);
|
|
339
|
+
const unstarted = logExists && await finalRecord(logPath).then((r) => r === undefined, () => false);
|
|
340
|
+
if (unstarted)
|
|
341
|
+
await truncate(logPath, 0);
|
|
342
|
+
if (logExists && !unstarted) {
|
|
343
|
+
throw new Error(`cannot start a fresh session ${sessionId}: a log already exists at ${logPath}; use resumeSessionId to continue it`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
184
347
|
await lock.release();
|
|
185
|
-
|
|
348
|
+
if (frozenSlip)
|
|
349
|
+
await rm(frozenSlip.dir, { recursive: true, force: true }).catch(() => { });
|
|
350
|
+
throw err;
|
|
186
351
|
}
|
|
187
352
|
}
|
|
188
353
|
let recovered;
|
|
@@ -203,6 +368,8 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
203
368
|
}
|
|
204
369
|
catch (err) {
|
|
205
370
|
await lock.release(); // a failed resume must not leave the session locked
|
|
371
|
+
if (frozenSlip)
|
|
372
|
+
await rm(frozenSlip.dir, { recursive: true, force: true }).catch(() => { });
|
|
206
373
|
throw err;
|
|
207
374
|
}
|
|
208
375
|
}
|
|
@@ -221,6 +388,8 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
221
388
|
}
|
|
222
389
|
catch (err) {
|
|
223
390
|
await lock.release();
|
|
391
|
+
if (frozenSlip)
|
|
392
|
+
await rm(frozenSlip.dir, { recursive: true, force: true }).catch(() => { });
|
|
224
393
|
throw err;
|
|
225
394
|
}
|
|
226
395
|
let stopped = false;
|
|
@@ -239,9 +408,12 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
239
408
|
const enterFailing = (failure) => {
|
|
240
409
|
if (surrendered)
|
|
241
410
|
return; // ownership lost: do not fight the new owner
|
|
411
|
+
const newOutage = outageEpisodeId === undefined;
|
|
242
412
|
outageEpisodeId ??= randomUUID();
|
|
243
413
|
const alreadySupervising = supervising;
|
|
244
414
|
health.markFailing(failure);
|
|
415
|
+
if (newOutage)
|
|
416
|
+
opts.onStorage?.('failing', failure);
|
|
245
417
|
// Capture the loop's promise only when starting a fresh one, so `stop()` can
|
|
246
418
|
// await the recovery in progress. runSupervisor sets `supervising` in its
|
|
247
419
|
// synchronous prefix, so a concurrent kick sees it and no-ops.
|
|
@@ -301,36 +473,191 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
301
473
|
const excluded = [storeDir, join(root, '.git')];
|
|
302
474
|
const isExcluded = (abs) => excluded.some((e) => isUnder(abs, e));
|
|
303
475
|
let live = false;
|
|
304
|
-
|
|
476
|
+
// Raw observations not yet handed to the engine. Under the git policy every
|
|
477
|
+
// observation waits here until git has classified it; otherwise only those
|
|
478
|
+
// arriving while capture is not live.
|
|
479
|
+
let buffer = [];
|
|
305
480
|
const onObservation = (abs, observedAtMs) => {
|
|
306
481
|
if (isExcluded(abs))
|
|
307
482
|
return;
|
|
308
483
|
const rel = relative(root, abs);
|
|
309
|
-
if (rel === ''
|
|
484
|
+
if (rel === '') {
|
|
485
|
+
void checkRoot();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (escapesBase(rel))
|
|
310
489
|
return;
|
|
311
|
-
if (live)
|
|
490
|
+
if (live && scope.policy === 'filesystem') {
|
|
312
491
|
engine.notify(rel, observedAtMs);
|
|
313
|
-
|
|
314
|
-
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
buffer.push([rel, observedAtMs]);
|
|
495
|
+
pumpClassifier();
|
|
496
|
+
};
|
|
497
|
+
// Paths skipped because git ignores them. Each is also marked prior-unknown in
|
|
498
|
+
// the engine, so if one later comes into scope it is never reported as newly
|
|
499
|
+
// created. Only the shallowest skipped path is kept.
|
|
500
|
+
const ignoredPaths = new Set();
|
|
501
|
+
const markIgnored = (rel) => {
|
|
502
|
+
if (!addUnknownScope(rel, ignoredPaths))
|
|
503
|
+
return;
|
|
504
|
+
engine.markBaselineUnknown(rel);
|
|
505
|
+
};
|
|
506
|
+
// Whether the git filter is working, as last durably disclosed. Flipped only
|
|
507
|
+
// once the disclosing scope event is committed.
|
|
508
|
+
let scopeAvailable = true;
|
|
509
|
+
const scopeEvent = () => ({
|
|
510
|
+
type: 'slipstream.capture.scope.v1',
|
|
511
|
+
occurred_at_ms: Date.now(),
|
|
512
|
+
data: scope.policy === 'git'
|
|
513
|
+
? (slipstreamApplied ? { policy: 'git', status: 'active', slipstream_ignore: 'active' } : { policy: 'git', status: 'active' })
|
|
514
|
+
: { policy: 'filesystem', status: 'active' },
|
|
515
|
+
});
|
|
516
|
+
const isRegularFile = (rel) => lstat(join(root, rel)).then((st) => st.isFile(), () => false);
|
|
517
|
+
// One serial loop asks git about everything buffered so far in a single call;
|
|
518
|
+
// observations arriving meanwhile go in the next round. It runs only while
|
|
519
|
+
// live: a round that finishes after capture stopped being live puts its batch
|
|
520
|
+
// back to be classified afresh when capture is live again.
|
|
521
|
+
let classifierRunning = false;
|
|
522
|
+
let classifierDone = Promise.resolve();
|
|
523
|
+
const pumpClassifier = () => {
|
|
524
|
+
if (scope.policy !== 'git' || classifierRunning || !live || buffer.length === 0)
|
|
525
|
+
return;
|
|
526
|
+
classifierRunning = true;
|
|
527
|
+
classifierDone = classifyLoop().catch((err) => {
|
|
528
|
+
console.error(`slipstream: git classification failed: ${err.message}`);
|
|
529
|
+
});
|
|
530
|
+
};
|
|
531
|
+
const classifyLoop = async () => {
|
|
532
|
+
if (scope.policy !== 'git')
|
|
533
|
+
return;
|
|
534
|
+
try {
|
|
535
|
+
while (live && buffer.length > 0) {
|
|
536
|
+
const batch = buffer.splice(0);
|
|
537
|
+
let ignored;
|
|
538
|
+
try {
|
|
539
|
+
ignored = await scope.ignored([...new Set(batch.map(([rel]) => rel))]);
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
if (!live) {
|
|
543
|
+
buffer = batch.concat(buffer); // no spread: a batch can exceed the argument limit
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
// Fail closed: never capture what might be ignored. The dropped paths'
|
|
547
|
+
// prior state is now unknown, and the outage is disclosed once.
|
|
548
|
+
console.error(`slipstream: git could not classify ${batch.length} observation(s): ${err.message}`);
|
|
549
|
+
for (const [rel] of batch)
|
|
550
|
+
engine.markBaselineUnknown(rel);
|
|
551
|
+
if (scopeAvailable) {
|
|
552
|
+
await appendEvent({
|
|
553
|
+
type: 'slipstream.capture.scope.v1',
|
|
554
|
+
occurred_at_ms: Date.now(),
|
|
555
|
+
data: slipstreamApplied
|
|
556
|
+
? { policy: 'git', status: 'unavailable', slipstream_ignore: 'active' }
|
|
557
|
+
: { policy: 'git', status: 'unavailable' },
|
|
558
|
+
}).then(() => { scopeAvailable = false; }, () => { });
|
|
559
|
+
}
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const forward = [];
|
|
563
|
+
for (const [rel, ts] of batch) {
|
|
564
|
+
if (ignored.has(rel))
|
|
565
|
+
markIgnored(rel);
|
|
566
|
+
// An ignored directory is out of scope once deleted too, though git no
|
|
567
|
+
// longer matches its vanished path. Directories are never captured. A
|
|
568
|
+
// file forwarded here, or one captured before, is in scope, so its
|
|
569
|
+
// deletion is captured.
|
|
570
|
+
else if (!ignoredPaths.has(rel))
|
|
571
|
+
forward.push([rel, ts]);
|
|
572
|
+
else if (engine.isRecorded(rel) || await isRegularFile(rel)) {
|
|
573
|
+
ignoredPaths.delete(rel);
|
|
574
|
+
forward.push([rel, ts]);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (live && !scopeAvailable) {
|
|
578
|
+
await appendEvent(scopeEvent()).then(() => { scopeAvailable = true; }, () => { });
|
|
579
|
+
}
|
|
580
|
+
if (!live || !scopeAvailable) {
|
|
581
|
+
buffer = batch.concat(buffer); // no spread: a batch can exceed the argument limit
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
for (const [rel, ts] of forward)
|
|
585
|
+
engine.notify(rel, ts);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
finally {
|
|
589
|
+
classifierRunning = false;
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
// Walk the worktree, skipping what git ignores; skipped paths are marked
|
|
593
|
+
// prior-unknown. Ignored entries listed up front prune whole directories. A
|
|
594
|
+
// file created after that listing is caught by asking git about every file
|
|
595
|
+
// found before any is read.
|
|
596
|
+
const walk = async (handlers) => {
|
|
597
|
+
if (scope.policy !== 'git')
|
|
598
|
+
return deps.enumerate(root, root, isExcluded, handlers);
|
|
599
|
+
const entries = new Set(await scope.ignoredEntries());
|
|
600
|
+
for (const rel of entries)
|
|
601
|
+
markIgnored(rel);
|
|
602
|
+
const files = [];
|
|
603
|
+
await deps.enumerate(root, root, (abs) => isExcluded(abs) || entries.has(relative(root, abs)), {
|
|
604
|
+
onFile: async (rel) => { files.push(rel); },
|
|
605
|
+
onDirError: handlers.onDirError,
|
|
606
|
+
});
|
|
607
|
+
const ignored = await scope.ignored(files);
|
|
608
|
+
for (const rel of files) {
|
|
609
|
+
if (ignored.has(rel))
|
|
610
|
+
markIgnored(rel);
|
|
611
|
+
else
|
|
612
|
+
await handlers.onFile(rel);
|
|
613
|
+
}
|
|
315
614
|
};
|
|
316
615
|
// A native-watcher error means delivery may have lapsed; disclose an honest
|
|
317
616
|
// session-wide coverage gap instead of letting the miss vanish silently.
|
|
617
|
+
const watcherGap = () => appendEvent({
|
|
618
|
+
type: 'slipstream.capture.gap.v1',
|
|
619
|
+
occurred_at_ms: Date.now(),
|
|
620
|
+
data: { scope: { kind: 'session' }, reason: 'watcher-error' },
|
|
621
|
+
}).catch(() => { });
|
|
318
622
|
const onError = (err) => {
|
|
319
623
|
console.error(`slipstream: watcher error: ${err.message}`);
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}).catch(() => { });
|
|
624
|
+
if (terminal)
|
|
625
|
+
return;
|
|
626
|
+
void watcherGap();
|
|
627
|
+
void checkRoot();
|
|
325
628
|
};
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
629
|
+
// The activation ends, once, when the root it watches is gone or the watcher
|
|
630
|
+
// can no longer deliver for it.
|
|
631
|
+
let terminal = false;
|
|
632
|
+
const end = (reason, message) => {
|
|
633
|
+
if (terminal || stopped)
|
|
634
|
+
return;
|
|
635
|
+
terminal = true;
|
|
636
|
+
opts.onTerminal?.(reason, message);
|
|
637
|
+
};
|
|
638
|
+
const checkRoot = async () => {
|
|
639
|
+
let now;
|
|
640
|
+
try {
|
|
641
|
+
now = await stat(root, { bigint: true });
|
|
331
642
|
}
|
|
332
|
-
|
|
643
|
+
catch (err) {
|
|
644
|
+
const code = err.code;
|
|
645
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR')
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
if (now?.isDirectory() && now.dev === rootStat.dev && now.ino === rootStat.ino)
|
|
649
|
+
return false;
|
|
650
|
+
end('root-missing', now ? `the workspace root ${root} was replaced` : `the workspace root ${root} no longer exists`);
|
|
651
|
+
return true;
|
|
333
652
|
};
|
|
653
|
+
const onClosed = async (err) => {
|
|
654
|
+
console.error(`slipstream: watcher subscription closed: ${err.message}`);
|
|
655
|
+
if (terminal || stopped || await checkRoot())
|
|
656
|
+
return;
|
|
657
|
+
await watcherGap();
|
|
658
|
+
end('watcher-failed', `the watcher stopped delivering changes for ${root}: ${err.message}`);
|
|
659
|
+
};
|
|
660
|
+
const priorFor = (path, unknownDirs) => isUnderUnknownScope(path, unknownDirs) ? { kind: 'unavailable', reason: 'baseline-unknown' } : { kind: 'absent' };
|
|
334
661
|
// Re-observe the whole worktree against the last committed snapshots and emit a
|
|
335
662
|
// reconciliation change for every differing endpoint. Enumeration omission is
|
|
336
663
|
// NOT deletion — a known path missing from the scan is actually re-read, so a
|
|
@@ -338,13 +665,25 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
338
665
|
const reconcile = async (state, gapSeq, append) => {
|
|
339
666
|
const current = new Map();
|
|
340
667
|
const newUnknownDirs = new Set();
|
|
341
|
-
await
|
|
668
|
+
await walk({
|
|
342
669
|
onFile: async (rel) => { current.set(rel, await reader.read(rel)); },
|
|
343
670
|
onDirError: async (relDir) => { newUnknownDirs.add(relDir); },
|
|
344
671
|
});
|
|
345
672
|
const unknownDirs = new Set([...state.baselineUnknownDirs, ...newUnknownDirs]);
|
|
673
|
+
// Under the git policy a path first seen now may have existed, ignored,
|
|
674
|
+
// while capture was down: its prior state is unknown, never absent.
|
|
675
|
+
if (scope.policy === 'git')
|
|
676
|
+
unknownDirs.add('');
|
|
677
|
+
// A recorded path the walk did not find is either gone or now ignored. An
|
|
678
|
+
// ignored one is out of scope: keep its last recorded state, never read it.
|
|
679
|
+
const unscanned = [...state.committed.keys()].filter((p) => !current.has(p));
|
|
680
|
+
const ignoredUnscanned = scope.policy === 'git' ? await scope.ignored(unscanned) : new Set();
|
|
346
681
|
const union = new Set([...state.committed.keys(), ...current.keys()]);
|
|
347
682
|
for (const path of union) {
|
|
683
|
+
if (ignoredUnscanned.has(path)) {
|
|
684
|
+
engine.setBaseline(path, state.committed.get(path));
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
348
687
|
const after = current.get(path) ?? (await reader.read(path));
|
|
349
688
|
const before = state.committed.get(path) ?? priorFor(path, unknownDirs);
|
|
350
689
|
if (!snapshotsEqual(before, after)) {
|
|
@@ -369,7 +708,9 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
369
708
|
// Install every unknown directory — recovered as well as newly-failed — so a
|
|
370
709
|
// later live observation of a never-baselined path reports baseline-unknown
|
|
371
710
|
// rather than a fabricated absent prior state.
|
|
372
|
-
for (const relDir of
|
|
711
|
+
for (const relDir of state.baselineUnknownDirs)
|
|
712
|
+
engine.markBaselineUnknown(relDir);
|
|
713
|
+
for (const relDir of newUnknownDirs)
|
|
373
714
|
engine.markBaselineUnknown(relDir);
|
|
374
715
|
};
|
|
375
716
|
const goLive = () => {
|
|
@@ -379,6 +720,10 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
379
720
|
if (stopped)
|
|
380
721
|
return;
|
|
381
722
|
live = true;
|
|
723
|
+
if (scope.policy === 'git') {
|
|
724
|
+
pumpClassifier();
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
382
727
|
for (const [rel, ts] of buffer)
|
|
383
728
|
engine.notify(rel, ts);
|
|
384
729
|
buffer.length = 0;
|
|
@@ -468,6 +813,7 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
468
813
|
goLive();
|
|
469
814
|
health.markHealthy();
|
|
470
815
|
outageEpisodeId = undefined; // outage resolved; a later fault opens a new episode
|
|
816
|
+
opts.onStorage?.('healthy');
|
|
471
817
|
return true;
|
|
472
818
|
}
|
|
473
819
|
catch {
|
|
@@ -486,7 +832,14 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
486
832
|
// scan is missed; buffered events reconcile against the baseline afterward.
|
|
487
833
|
let subscription;
|
|
488
834
|
try {
|
|
489
|
-
subscription = await deps.platform.watch({ root, ignore: excluded, onObservation, onError
|
|
835
|
+
subscription = await deps.platform.watch({ root, ignore: excluded, onObservation, onError,
|
|
836
|
+
onClosed: (err) => { void onClosed(err); } });
|
|
837
|
+
// The root may have been swapped between its first stat and the watcher's
|
|
838
|
+
// install; nothing is written for a directory this session never watched.
|
|
839
|
+
const watched = await stat(root, { bigint: true }).catch(() => undefined);
|
|
840
|
+
if (!watched || watched.dev !== rootStat.dev || watched.ino !== rootStat.ino) {
|
|
841
|
+
throw new RootChangedError(watched ? `the workspace root ${root} was replaced` : `the workspace root ${root} no longer exists`);
|
|
842
|
+
}
|
|
490
843
|
if (resuming) {
|
|
491
844
|
// Replay barrier: rebuild the producer's projections and reconstruct
|
|
492
845
|
// outstanding work from the durable log, seeding prior results so a
|
|
@@ -499,6 +852,7 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
499
852
|
data: {
|
|
500
853
|
recovered_through_seq: recovered.recoveredThroughSeq.toString(),
|
|
501
854
|
discarded_tail_bytes: recovered.discardedTailBytes,
|
|
855
|
+
...captureModeField,
|
|
502
856
|
},
|
|
503
857
|
});
|
|
504
858
|
const restartGap = await appendEvent({
|
|
@@ -506,6 +860,7 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
506
860
|
occurred_at_ms: Date.now(),
|
|
507
861
|
data: { scope: { kind: 'session' }, reason: 'restart' },
|
|
508
862
|
});
|
|
863
|
+
await appendEvent(scopeEvent());
|
|
509
864
|
await producer.ensurePolicy(enrichmentPolicy);
|
|
510
865
|
await reconcile({
|
|
511
866
|
committed: recovered.committed,
|
|
@@ -516,10 +871,11 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
516
871
|
await appendEvent({
|
|
517
872
|
type: 'slipstream.session.started.v1',
|
|
518
873
|
occurred_at_ms: Date.now(),
|
|
519
|
-
data: { root, max_bytes: maxBytes },
|
|
874
|
+
data: { root, max_bytes: maxBytes, ...captureModeField },
|
|
520
875
|
});
|
|
876
|
+
await appendEvent(scopeEvent());
|
|
521
877
|
const unknownScopes = [];
|
|
522
|
-
await
|
|
878
|
+
await walk({
|
|
523
879
|
onFile: async (rel) => {
|
|
524
880
|
const snapshot = await reader.read(rel);
|
|
525
881
|
await appendEvent({
|
|
@@ -549,6 +905,8 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
549
905
|
producer.start([]);
|
|
550
906
|
await producer.ensurePolicy(enrichmentPolicy);
|
|
551
907
|
}
|
|
908
|
+
// Every append above resolved durable, so the barrier has passed.
|
|
909
|
+
await appendEvent({ type: 'slipstream.capture.ready.v1', occurred_at_ms: Date.now(), data: {} });
|
|
552
910
|
}
|
|
553
911
|
catch (err) {
|
|
554
912
|
// A failed startup may already have kicked the recovery supervisor (an append
|
|
@@ -562,35 +920,25 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
562
920
|
await engine.drain().catch(() => { });
|
|
563
921
|
await underlying.close().catch(() => { });
|
|
564
922
|
await lock.release();
|
|
923
|
+
if (frozenSlip)
|
|
924
|
+
await rm(frozenSlip.dir, { recursive: true, force: true }).catch(() => { });
|
|
565
925
|
throw err;
|
|
566
926
|
}
|
|
567
927
|
goLive();
|
|
568
928
|
health.markHealthy();
|
|
569
929
|
const ingestEvidence = (evidence) => producer.ingestEvidence(evidence);
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
// evidence flows through the same deduped, append-only producer
|
|
573
|
-
// re-read (including a late transcript after a restart) revises
|
|
574
|
-
// duplicating.
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
}
|
|
930
|
+
// Transcript reading (A2): configured harnesses' transcripts for this worktree
|
|
931
|
+
// are read as revisable evidence on the shared scheduler. It never creates a
|
|
932
|
+
// change and its evidence flows through the same deduped, append-only producer
|
|
933
|
+
// path, so a re-read (including a late transcript after a restart) revises
|
|
934
|
+
// without duplicating.
|
|
935
|
+
const unregisterTranscripts = opts.transcript?.register({
|
|
936
|
+
root,
|
|
937
|
+
sink: { ingest: ingestEvidence },
|
|
938
|
+
publish: async (data) => {
|
|
939
|
+
await appendEvent({ type: 'slipstream.enrichment.coverage.v1', occurred_at_ms: Date.now(), data });
|
|
940
|
+
},
|
|
941
|
+
});
|
|
594
942
|
const beginTask = async ({ title, requestId }) => {
|
|
595
943
|
// Readiness: only a live, healthy, still-owned session may declare a task.
|
|
596
944
|
// Domain error codes (CAPTURE_NOT_READY, STORAGE_UNAVAILABLE, ...) are a later
|
|
@@ -684,10 +1032,7 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
684
1032
|
if (pending)
|
|
685
1033
|
return { ...await pending.promise, duplicate: true };
|
|
686
1034
|
if (req.reply_to_question_id !== undefined) {
|
|
687
|
-
|
|
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) {
|
|
1035
|
+
if (!sameSourceContext(questionsById.get(req.reply_to_question_id)?.data.context, req.context)) {
|
|
691
1036
|
throw new QuestionError('INVALID_CONTEXT', 'a follow-up must reply to a question in this capture about the same source');
|
|
692
1037
|
}
|
|
693
1038
|
}
|
|
@@ -695,11 +1040,11 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
695
1040
|
// An expired-but-never-attempted entry can never become eligible again, so it
|
|
696
1041
|
// is dropped here rather than kept forever awaiting a claim that will not come.
|
|
697
1042
|
let eligible = 0;
|
|
698
|
-
for (const [id, q] of
|
|
1043
|
+
for (const [id, q] of claimableQuestions) {
|
|
699
1044
|
if (now() < q.data.expires_at_ms)
|
|
700
1045
|
eligible += 1;
|
|
701
1046
|
else
|
|
702
|
-
|
|
1047
|
+
claimableQuestions.delete(id);
|
|
703
1048
|
}
|
|
704
1049
|
const reserved = [...inflightQuestions.keys()].filter(id => !committedQuestions.has(id)).length;
|
|
705
1050
|
if (eligible + reserved >= QUESTION_LIMIT)
|
|
@@ -730,12 +1075,124 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
730
1075
|
inflightQuestions.delete(req.request_id);
|
|
731
1076
|
}
|
|
732
1077
|
};
|
|
1078
|
+
// A committed send is registered by onCommitted (or rebuilt by recovery), so a
|
|
1079
|
+
// reservation can be dropped once its append settles either way.
|
|
1080
|
+
const isKnownRelay = (relayId) => relayQuestions.has(relayId) || reservedRelays.has(relayId);
|
|
1081
|
+
const recordQuestionExchange = async (input, assertOwnership = () => { }) => {
|
|
1082
|
+
const req = normalizeExchange(input);
|
|
1083
|
+
if (req.session_id !== sessionId)
|
|
1084
|
+
throw new QuestionError('SESSION_NOT_SELECTED', 'exchange addresses a different capture');
|
|
1085
|
+
assertOwnership();
|
|
1086
|
+
questionReady();
|
|
1087
|
+
const body = exchangeBody(req);
|
|
1088
|
+
const settled = committedExchanges.get(req.request_id);
|
|
1089
|
+
const pending = inflightExchanges.get(req.request_id);
|
|
1090
|
+
const existing = settled ?? pending;
|
|
1091
|
+
if (existing && existing.body !== body)
|
|
1092
|
+
throw new QuestionError('REQUEST_CONFLICT', 'request_id was used for a different exchange or context');
|
|
1093
|
+
if (settled)
|
|
1094
|
+
return { ...settled.result, duplicate: true };
|
|
1095
|
+
if (pending)
|
|
1096
|
+
return { ...await pending.promise, duplicate: true };
|
|
1097
|
+
if (req.reply_to_question_id !== undefined
|
|
1098
|
+
&& !sameSourceContext(exchangesById.get(req.reply_to_question_id), req.context)) {
|
|
1099
|
+
throw new QuestionError('INVALID_CONTEXT', 'a follow-up must reply to an exchange in this capture about the same source');
|
|
1100
|
+
}
|
|
1101
|
+
const relayId = req.relay?.relay_id;
|
|
1102
|
+
if (relayId !== undefined && isKnownRelay(relayId))
|
|
1103
|
+
throw new QuestionError('INVALID_RELAY_UPDATE', 'relay_id was already sent in this capture');
|
|
1104
|
+
// Defer starting I/O until the in-flight reservation is installed synchronously.
|
|
1105
|
+
const promise = Promise.resolve().then(async () => {
|
|
1106
|
+
const selected_text = await deps.readQuestionContext({ storeDir, logPath, sessionId,
|
|
1107
|
+
boundary: BigInt(health.snapshot().durable_seq), context: req.context });
|
|
1108
|
+
assertOwnership();
|
|
1109
|
+
questionReady();
|
|
1110
|
+
const recorded_at_ms = now();
|
|
1111
|
+
const event = await appendEvent({ type: 'slipstream.question.exchange_recorded.v1', occurred_at_ms: recorded_at_ms,
|
|
1112
|
+
data: { question_id: randomUUID(), request_id: req.request_id, text: req.text,
|
|
1113
|
+
context: { ...req.context, selected_text }, answer_text: req.answer_text, recorded_at_ms,
|
|
1114
|
+
conversation: { ...req.conversation, reported_by: 'client' },
|
|
1115
|
+
...(req.reply_to_question_id !== undefined ? { reply_to_question_id: req.reply_to_question_id } : {}),
|
|
1116
|
+
...(req.relay !== undefined ? { relay: req.relay } : {}) } });
|
|
1117
|
+
if (surrendered)
|
|
1118
|
+
throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
|
|
1119
|
+
assertOwnership();
|
|
1120
|
+
const result = exchangeResult(event);
|
|
1121
|
+
committedExchanges.set(req.request_id, { body, result });
|
|
1122
|
+
return result;
|
|
1123
|
+
});
|
|
1124
|
+
inflightExchanges.set(req.request_id, { body, promise });
|
|
1125
|
+
if (relayId !== undefined)
|
|
1126
|
+
reservedRelays.add(relayId);
|
|
1127
|
+
try {
|
|
1128
|
+
return await promise;
|
|
1129
|
+
}
|
|
1130
|
+
finally {
|
|
1131
|
+
inflightExchanges.delete(req.request_id);
|
|
1132
|
+
if (relayId !== undefined)
|
|
1133
|
+
reservedRelays.delete(relayId);
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
const recordQuestionRelayUpdate = async (input, assertOwnership = () => { }) => {
|
|
1137
|
+
const req = normalizeRelayUpdate(input);
|
|
1138
|
+
if (req.session_id !== sessionId)
|
|
1139
|
+
throw new QuestionError('SESSION_NOT_SELECTED', 'relay update addresses a different capture');
|
|
1140
|
+
assertOwnership();
|
|
1141
|
+
questionReady();
|
|
1142
|
+
const body = relayUpdateBody(req);
|
|
1143
|
+
const settled = committedRelayUpdates.get(req.request_id);
|
|
1144
|
+
const pending = inflightRelayUpdates.get(req.request_id);
|
|
1145
|
+
const existing = settled ?? pending;
|
|
1146
|
+
if (existing && existing.body !== body)
|
|
1147
|
+
throw new QuestionError('REQUEST_CONFLICT', 'request_id was used for a different relay update');
|
|
1148
|
+
if (settled)
|
|
1149
|
+
return { ...settled.result, duplicate: true };
|
|
1150
|
+
if (pending)
|
|
1151
|
+
return { ...await pending.promise, duplicate: true };
|
|
1152
|
+
if (!exchangesById.has(req.question_id))
|
|
1153
|
+
throw new QuestionError('INVALID_RELAY_UPDATE', 'question_id names no recorded exchange in this capture');
|
|
1154
|
+
const isSend = req.update.kind === 'sent';
|
|
1155
|
+
if (isSend && isKnownRelay(req.relay_id))
|
|
1156
|
+
throw new QuestionError('INVALID_RELAY_UPDATE', 'relay_id was already sent in this capture');
|
|
1157
|
+
if (!isSend) {
|
|
1158
|
+
const owner = relayQuestions.get(req.relay_id);
|
|
1159
|
+
if (owner === undefined)
|
|
1160
|
+
throw new QuestionError('INVALID_RELAY_UPDATE', 'relay_id was never sent in this capture');
|
|
1161
|
+
if (owner !== req.question_id)
|
|
1162
|
+
throw new QuestionError('INVALID_RELAY_UPDATE', 'relay_id belongs to another question');
|
|
1163
|
+
}
|
|
1164
|
+
const promise = Promise.resolve().then(async () => {
|
|
1165
|
+
assertOwnership();
|
|
1166
|
+
questionReady();
|
|
1167
|
+
const recorded_at_ms = now();
|
|
1168
|
+
const event = await appendEvent({ type: 'slipstream.question.relay_updated.v1', occurred_at_ms: recorded_at_ms,
|
|
1169
|
+
data: { question_id: req.question_id, relay_id: req.relay_id, request_id: req.request_id, update: req.update, recorded_at_ms } });
|
|
1170
|
+
if (surrendered)
|
|
1171
|
+
throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
|
|
1172
|
+
assertOwnership();
|
|
1173
|
+
const result = relayUpdateResult(event);
|
|
1174
|
+
committedRelayUpdates.set(req.request_id, { body, result });
|
|
1175
|
+
return result;
|
|
1176
|
+
});
|
|
1177
|
+
inflightRelayUpdates.set(req.request_id, { body, promise });
|
|
1178
|
+
if (isSend)
|
|
1179
|
+
reservedRelays.add(req.relay_id);
|
|
1180
|
+
try {
|
|
1181
|
+
return await promise;
|
|
1182
|
+
}
|
|
1183
|
+
finally {
|
|
1184
|
+
inflightRelayUpdates.delete(req.request_id);
|
|
1185
|
+
if (isSend)
|
|
1186
|
+
reservedRelays.delete(req.relay_id);
|
|
1187
|
+
}
|
|
1188
|
+
};
|
|
733
1189
|
const claimQuestion = async (target, assertOwnership = () => { }) => {
|
|
734
1190
|
assertOwnership();
|
|
735
1191
|
questionReady();
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
1192
|
+
pruneExpiredClaimableQuestions();
|
|
1193
|
+
// The control handler admits only the linked chat, so a question queued for an
|
|
1194
|
+
// earlier chat of this worktree is the current chat's to claim.
|
|
1195
|
+
const queued = [...claimableQuestions.values()].find(q => q.data.target.worktree === target.worktree
|
|
739
1196
|
&& now() < q.data.expires_at_ms
|
|
740
1197
|
&& !reservedQuestions.has(q.data.question_id));
|
|
741
1198
|
if (!queued)
|
|
@@ -745,7 +1202,7 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
745
1202
|
try {
|
|
746
1203
|
const attempted_at_ms = now();
|
|
747
1204
|
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 } });
|
|
1205
|
+
data: { question_id: id, queued_seq: queued.seq, attempted_at_ms, target } });
|
|
749
1206
|
if (surrendered)
|
|
750
1207
|
throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
|
|
751
1208
|
assertOwnership();
|
|
@@ -756,18 +1213,18 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
756
1213
|
}
|
|
757
1214
|
};
|
|
758
1215
|
// Order: INVALID_ANSWER, then QUESTION_NOT_FOUND (which also hides questions
|
|
759
|
-
// dispatched to
|
|
1216
|
+
// never dispatched to this harness session), then duplicate or ANSWER_CONFLICT.
|
|
760
1217
|
const answerQuestion = async (input, target, assertOwnership = () => { }) => {
|
|
761
1218
|
const { question_id: id, text } = input;
|
|
762
1219
|
assertAnswerText(text);
|
|
763
1220
|
assertOwnership();
|
|
764
1221
|
questionReady();
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
if (typeof id !== 'string' ||
|
|
768
|
-
|| owner.harness_session_id !== target.harness_session_id || owner.worktree !== target.worktree) {
|
|
1222
|
+
const attempt = typeof id === 'string' ? attemptsById.get(id)?.findLast(a => a.target.harness === target.harness
|
|
1223
|
+
&& a.target.harness_session_id === target.harness_session_id && a.target.worktree === target.worktree) : undefined;
|
|
1224
|
+
if (typeof id !== 'string' || attempt === undefined) {
|
|
769
1225
|
throw new QuestionError('QUESTION_NOT_FOUND', 'no question with that id was dispatched to this harness session');
|
|
770
1226
|
}
|
|
1227
|
+
const attemptSeq = attempt.seq;
|
|
771
1228
|
const settled = committedAnswers.get(id);
|
|
772
1229
|
const pending = inflightAnswers.get(id);
|
|
773
1230
|
const existing = settled ?? pending;
|
|
@@ -794,22 +1251,40 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
794
1251
|
inflightAnswers.delete(id);
|
|
795
1252
|
}
|
|
796
1253
|
};
|
|
797
|
-
const
|
|
1254
|
+
const recordAgent = async (input, assertOwnership = () => { }) => {
|
|
1255
|
+
assertOwnership();
|
|
1256
|
+
questionReady();
|
|
1257
|
+
const event = await appendEvent(input);
|
|
1258
|
+
if (input.type === 'slipstream.agent.binding.v1')
|
|
1259
|
+
recordedBindings.add(input.data.binding_generation);
|
|
1260
|
+
if (surrendered)
|
|
1261
|
+
throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
|
|
1262
|
+
assertOwnership();
|
|
1263
|
+
return event;
|
|
1264
|
+
};
|
|
1265
|
+
const recordedBindings = new Set(recovered?.bindingGenerations);
|
|
1266
|
+
const doStop = async (opts) => {
|
|
798
1267
|
stopped = true;
|
|
799
1268
|
// Stop feeding the engine immediately: if the watcher unsubscribe below
|
|
800
1269
|
// rejects, late observations buffer instead of appending after the lock is
|
|
801
1270
|
// 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
|
|
1271
|
+
// closing the log or releasing the lock; the first error is rethrown. Under
|
|
1272
|
+
// the git policy capture stays live until git has classified what the
|
|
1273
|
+
// watcher already delivered, so stopping never drops an observed edit.
|
|
1274
|
+
if (scope.policy === 'filesystem')
|
|
1275
|
+
live = false;
|
|
804
1276
|
let firstError;
|
|
805
1277
|
const record = (err) => {
|
|
806
1278
|
if (firstError === undefined)
|
|
807
1279
|
firstError = err;
|
|
808
1280
|
};
|
|
809
|
-
// Stop
|
|
1281
|
+
// Stop transcript reading before the producer and log close: it appends
|
|
810
1282
|
// evidence and coverage through both, and an append after close would throw.
|
|
811
|
-
await
|
|
1283
|
+
await unregisterTranscripts?.().catch(record);
|
|
812
1284
|
await subscription?.close().catch(record);
|
|
1285
|
+
while (classifierRunning)
|
|
1286
|
+
await classifierDone;
|
|
1287
|
+
live = false;
|
|
813
1288
|
// Await any in-flight recovery: it may be mid-reopen, and appending after
|
|
814
1289
|
// we release the lock would let a second owner's writes interleave. The
|
|
815
1290
|
// loop stops starting new attempts once `stopped` is set.
|
|
@@ -818,9 +1293,30 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
818
1293
|
// Fence the attribution generation and flush its in-flight appends before the
|
|
819
1294
|
// log closes: a stale evaluation must never append after the log is gone.
|
|
820
1295
|
await producer?.stop().catch(record);
|
|
821
|
-
await Promise.allSettled([...inflightQuestions.values(), ...inflightAnswers.values()].map(q => q.promise));
|
|
1296
|
+
await Promise.allSettled([...inflightQuestions.values(), ...inflightExchanges.values(), ...inflightRelayUpdates.values(), ...inflightAnswers.values()].map(q => q.promise));
|
|
1297
|
+
// Never manufactured: after a lost lock or during a storage outage the stop
|
|
1298
|
+
// stays unrecorded and stop() rejects, so no caller can report it stopped.
|
|
1299
|
+
if (opts && firstError === undefined && (surrendered || health.snapshot().state !== 'healthy')) {
|
|
1300
|
+
record(new Error(`the stop could not be recorded: capture storage is ${surrendered ? 'owned by another process' : health.snapshot().state}`));
|
|
1301
|
+
}
|
|
1302
|
+
else if (opts && firstError === undefined) {
|
|
1303
|
+
try {
|
|
1304
|
+
if (opts.unlinkGeneration !== undefined) {
|
|
1305
|
+
opts.assertOwnership?.();
|
|
1306
|
+
await appendEvent({ type: 'slipstream.agent.unlinked.v1', occurred_at_ms: now(),
|
|
1307
|
+
data: { binding_generation: opts.unlinkGeneration, reason: opts.reason } });
|
|
1308
|
+
}
|
|
1309
|
+
opts.assertOwnership?.();
|
|
1310
|
+
await appendEvent({ type: 'slipstream.capture.stopped.v1', occurred_at_ms: now(), data: { reason: opts.reason } });
|
|
1311
|
+
}
|
|
1312
|
+
catch (err) {
|
|
1313
|
+
record(err);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
822
1316
|
await underlying.close().catch(record);
|
|
823
1317
|
await lock.release().catch(record);
|
|
1318
|
+
if (frozenSlip)
|
|
1319
|
+
await rm(frozenSlip.dir, { recursive: true, force: true }).catch(record);
|
|
824
1320
|
if (firstError !== undefined)
|
|
825
1321
|
throw firstError;
|
|
826
1322
|
};
|
|
@@ -835,10 +1331,14 @@ export async function startCapture(opts, dependencies = {}) {
|
|
|
835
1331
|
health,
|
|
836
1332
|
beginTask,
|
|
837
1333
|
askQuestion,
|
|
1334
|
+
recordQuestionExchange,
|
|
1335
|
+
recordQuestionRelayUpdate,
|
|
838
1336
|
claimQuestion,
|
|
839
1337
|
answerQuestion,
|
|
1338
|
+
recordAgent,
|
|
1339
|
+
bindingRecorded: (generation) => recordedBindings.has(generation),
|
|
840
1340
|
ingestEvidence,
|
|
841
|
-
stop: () => (stopPromise ??= doStop()),
|
|
1341
|
+
stop: (opts) => (stopPromise ??= doStop(opts)),
|
|
842
1342
|
};
|
|
843
1343
|
}
|
|
844
1344
|
async function enumerate(root, dir, isExcluded, handlers) {
|