@ours.network/fleet 0.17.1 → 0.17.3
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 +38 -2
- package/dist/application/role-removal-service.js +1 -1
- package/dist/application/session-control.d.ts +14 -10
- package/dist/application/session-control.js +14 -3
- package/dist/atomic-file.d.ts +7 -1
- package/dist/atomic-file.js +33 -5
- package/dist/build-info.json +10 -0
- package/dist/capabilities.d.ts +20 -0
- package/dist/capabilities.js +21 -0
- package/dist/cli.js +98 -10
- package/dist/config.d.ts +9 -2
- package/dist/config.js +16 -2
- package/dist/creation.d.ts +16 -0
- package/dist/creation.js +28 -0
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +70 -4
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +87 -2
- package/dist/harness/acp-agent.d.ts +3 -0
- package/dist/harness/acp-agent.js +4 -1
- package/dist/harness/codex-app-server-proxy.d.ts +4 -0
- package/dist/harness/codex-app-server-proxy.js +133 -0
- package/dist/harness/codex.js +79 -11
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/loops/manager.d.ts +42 -1
- package/dist/loops/manager.js +115 -16
- package/dist/loops/state.d.ts +46 -2
- package/dist/loops/state.js +81 -3
- package/dist/monitor.d.ts +21 -0
- package/dist/monitor.js +42 -0
- package/dist/ops.d.ts +6 -0
- package/dist/ops.js +46 -1
- package/dist/owner-channel/channel.d.ts +18 -2
- package/dist/owner-channel/channel.js +146 -2
- package/dist/owner-channel/commands.d.ts +2 -2
- package/dist/owner-channel/commands.js +7 -2
- package/dist/owner-channel/notices.d.ts +2 -0
- package/dist/owner-channel/notices.js +3 -0
- package/dist/provenance.d.ts +77 -0
- package/dist/provenance.js +283 -0
- package/dist/runner.d.ts +7 -1
- package/dist/runner.js +109 -16
- package/dist/session/acp.d.ts +40 -4
- package/dist/session/acp.js +157 -30
- package/dist/session/arbiter.d.ts +28 -2
- package/dist/session/arbiter.js +75 -4
- package/dist/session/control.js +12 -6
- package/dist/session/event-log.d.ts +109 -0
- package/dist/session/event-log.js +247 -0
- package/dist/session/events.d.ts +21 -0
- package/dist/session/events.js +105 -26
- package/dist/session/tmux.d.ts +3 -2
- package/dist/session/tmux.js +2 -0
- package/dist/session/types.d.ts +39 -2
- package/dist/session/types.js +11 -1
- package/dist/spawn.d.ts +3 -3
- package/dist/spawn.js +40 -14
- package/dist/temp-lifecycle.d.ts +62 -0
- package/dist/temp-lifecycle.js +437 -0
- package/dist/web-app/assets/{TerminalView-B3rnVWbo.js → TerminalView-BAVk1Bot.js} +1 -1
- package/dist/web-app/assets/{index-CliHATFt.js → index-C3S-xFRU.js} +5 -5
- package/dist/web-app/index.html +1 -1
- package/package.json +5 -3
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { closeSync, existsSync, fstatSync, ftruncateSync, openSync, readFileSync, readSync, writeSync, } from 'node:fs';
|
|
2
|
+
export const nodeLogIo = {
|
|
3
|
+
openSync: (path, flags, mode) => openSync(path, flags, mode),
|
|
4
|
+
fstatSync: fd => fstatSync(fd),
|
|
5
|
+
readSync: (fd, buffer, offset, length, position) => readSync(fd, buffer, offset, length, position),
|
|
6
|
+
writeSync: (fd, buffer, offset, length) => writeSync(fd, buffer, offset, length),
|
|
7
|
+
ftruncateSync: (fd, length) => ftruncateSync(fd, length),
|
|
8
|
+
closeSync: fd => closeSync(fd),
|
|
9
|
+
};
|
|
10
|
+
const NEWLINE = 0x0a;
|
|
11
|
+
/** Enough to hold a whole event line as evidence without letting one record dominate the sidecar. */
|
|
12
|
+
const MAX_RAW_EVIDENCE_BYTES = 8 * 1024;
|
|
13
|
+
const MAX_QUARANTINE_BYTES = 1024 * 1024;
|
|
14
|
+
export class AtomicAppendError extends Error {
|
|
15
|
+
rollbackFailed;
|
|
16
|
+
cause;
|
|
17
|
+
constructor(message, rollbackFailed, cause) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.rollbackFailed = rollbackFailed;
|
|
20
|
+
this.cause = cause;
|
|
21
|
+
this.name = 'AtomicAppendError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Append one line, all or nothing. A failed write is rolled back to the byte
|
|
26
|
+
* length observed before it started, so a partial record never persists; if the
|
|
27
|
+
* file already ends mid-record (damage from before this fix), the line is put on
|
|
28
|
+
* a fresh line so it cannot fuse with the damaged bytes.
|
|
29
|
+
*/
|
|
30
|
+
export function appendLineAtomic(path, line, options = {}) {
|
|
31
|
+
const io = options.io ?? nodeLogIo;
|
|
32
|
+
const maxAttempts = Math.max(1, options.maxAttempts ?? 2);
|
|
33
|
+
const payload = line.endsWith('\n') ? line : line + '\n';
|
|
34
|
+
let lastError;
|
|
35
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
36
|
+
let fd;
|
|
37
|
+
let sizeBefore = 0;
|
|
38
|
+
try {
|
|
39
|
+
fd = io.openSync(path, 'a+', options.mode ?? 0o600);
|
|
40
|
+
sizeBefore = io.fstatSync(fd).size;
|
|
41
|
+
const repairedBoundary = sizeBefore > 0 && !endsWithNewline(io, fd, sizeBefore);
|
|
42
|
+
const buffer = Buffer.from(repairedBoundary ? '\n' + payload : payload, 'utf8');
|
|
43
|
+
let written = 0;
|
|
44
|
+
try {
|
|
45
|
+
while (written < buffer.length) {
|
|
46
|
+
const advanced = io.writeSync(fd, buffer, written, buffer.length - written);
|
|
47
|
+
// A write that accepts nothing would spin forever and never reach the
|
|
48
|
+
// bounded-retry logic, so it fails here instead.
|
|
49
|
+
if (advanced <= 0)
|
|
50
|
+
throw new Error(`write made no progress at byte ${written} of ${buffer.length}`);
|
|
51
|
+
written += advanced;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (writeError) {
|
|
55
|
+
throw rollback(io, fd, sizeBefore, writeError);
|
|
56
|
+
}
|
|
57
|
+
return { repairedBoundary };
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
lastError = error;
|
|
61
|
+
// A failed rollback leaves bytes we cannot account for; retrying would
|
|
62
|
+
// append on top of them, so stop and let the caller report it.
|
|
63
|
+
if (error instanceof AtomicAppendError && error.rollbackFailed)
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
if (fd !== undefined) {
|
|
68
|
+
try {
|
|
69
|
+
io.closeSync(fd);
|
|
70
|
+
}
|
|
71
|
+
catch { /* the write outcome is what matters */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
throw lastError;
|
|
76
|
+
}
|
|
77
|
+
function endsWithNewline(io, fd, size) {
|
|
78
|
+
const tail = Buffer.alloc(1);
|
|
79
|
+
const read = io.readSync(fd, tail, 0, 1, size - 1);
|
|
80
|
+
// An unreadable tail is treated as damaged: adding a newline is always safe.
|
|
81
|
+
return read === 1 && tail[0] === NEWLINE;
|
|
82
|
+
}
|
|
83
|
+
function rollback(io, fd, sizeBefore, writeError) {
|
|
84
|
+
const detail = writeError instanceof Error ? writeError.message : String(writeError);
|
|
85
|
+
try {
|
|
86
|
+
io.ftruncateSync(fd, sizeBefore);
|
|
87
|
+
return new AtomicAppendError(detail, false, writeError);
|
|
88
|
+
}
|
|
89
|
+
catch (truncateError) {
|
|
90
|
+
const reason = truncateError instanceof Error ? truncateError.message : String(truncateError);
|
|
91
|
+
return new AtomicAppendError(`${detail} (rollback failed: ${reason})`, true, writeError);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Read the log line by line. One bad line costs exactly that line: everything
|
|
96
|
+
* before and after it is kept, and the damage is described rather than dropped.
|
|
97
|
+
*/
|
|
98
|
+
export function readLog(path) {
|
|
99
|
+
const empty = { records: [], sequence: [], maxSeq: 0, damaged: [], gaps: [] };
|
|
100
|
+
if (!existsSync(path))
|
|
101
|
+
return empty;
|
|
102
|
+
let raw;
|
|
103
|
+
try {
|
|
104
|
+
raw = readFileSync(path, 'utf8');
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return empty;
|
|
108
|
+
}
|
|
109
|
+
if (raw === '')
|
|
110
|
+
return empty;
|
|
111
|
+
const terminated = raw.endsWith('\n');
|
|
112
|
+
const segments = raw.split('\n');
|
|
113
|
+
if (terminated)
|
|
114
|
+
segments.pop();
|
|
115
|
+
const records = [];
|
|
116
|
+
const sequence = [];
|
|
117
|
+
const damaged = [];
|
|
118
|
+
let maxSeq = 0;
|
|
119
|
+
segments.forEach((segment, index) => {
|
|
120
|
+
if (segment.trim() === '')
|
|
121
|
+
return;
|
|
122
|
+
let parsed;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(segment);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
const isFinalSegment = index === segments.length - 1;
|
|
128
|
+
damaged.push({
|
|
129
|
+
lineNumber: index + 1,
|
|
130
|
+
bytes: Buffer.byteLength(segment, 'utf8'),
|
|
131
|
+
// An unterminated final line is a write that never committed; anything
|
|
132
|
+
// else sits between records that did, so it is interior corruption.
|
|
133
|
+
reason: isFinalSegment && !terminated ? 'truncated_tail' : 'interior_corruption',
|
|
134
|
+
raw: segment.slice(0, MAX_RAW_EVIDENCE_BYTES),
|
|
135
|
+
truncatedEvidence: segment.length > MAX_RAW_EVIDENCE_BYTES,
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Unknown versions are forward compatibility, not damage: they are not
|
|
140
|
+
// replayed here, but they did claim their sequence number, so they still
|
|
141
|
+
// count towards ordering and continuation.
|
|
142
|
+
const seq = sequenceOf(parsed);
|
|
143
|
+
if (seq === undefined)
|
|
144
|
+
return;
|
|
145
|
+
sequence.push(seq);
|
|
146
|
+
if (seq > maxSeq)
|
|
147
|
+
maxSeq = seq;
|
|
148
|
+
if (isLogRecord(parsed))
|
|
149
|
+
records.push(parsed);
|
|
150
|
+
});
|
|
151
|
+
return { records, sequence, maxSeq, damaged, gaps: findSequenceGaps(sequence) };
|
|
152
|
+
}
|
|
153
|
+
/** The sequence a readable entry claims, whatever version wrote it. */
|
|
154
|
+
function sequenceOf(value) {
|
|
155
|
+
if (typeof value !== 'object' || value === null)
|
|
156
|
+
return undefined;
|
|
157
|
+
const seq = value.seq;
|
|
158
|
+
return typeof seq === 'number' && Number.isFinite(seq) ? seq : undefined;
|
|
159
|
+
}
|
|
160
|
+
function isLogRecord(value) {
|
|
161
|
+
if (typeof value !== 'object' || value === null)
|
|
162
|
+
return false;
|
|
163
|
+
const candidate = value;
|
|
164
|
+
return candidate.version === 1 && typeof candidate.seq === 'number';
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Missing sequence numbers between consecutive records. Records that parse
|
|
168
|
+
* perfectly still leave a hole when the writes between them never landed, so
|
|
169
|
+
* this is computed over whatever ordered run the caller cares about — including
|
|
170
|
+
* a rotated stream followed by its live successor, where the hole falls on the
|
|
171
|
+
* boundary and neither file can see it alone.
|
|
172
|
+
*/
|
|
173
|
+
export function findSequenceGaps(sequence) {
|
|
174
|
+
const gaps = [];
|
|
175
|
+
for (let i = 1; i < sequence.length; i++) {
|
|
176
|
+
const afterSeq = sequence[i - 1];
|
|
177
|
+
const beforeSeq = sequence[i];
|
|
178
|
+
if (beforeSeq > afterSeq + 1)
|
|
179
|
+
gaps.push({ afterSeq, beforeSeq, missing: beforeSeq - afterSeq - 1 });
|
|
180
|
+
}
|
|
181
|
+
return gaps;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Copy damaged bytes to a sidecar for later forensics. The damaged file itself
|
|
185
|
+
* is never read-modify-written, and a sidecar that cannot be created is reported
|
|
186
|
+
* rather than retried into the event writer.
|
|
187
|
+
*/
|
|
188
|
+
export function quarantineDamage(path, damaged, source = path) {
|
|
189
|
+
const sidecar = path + '.corrupt';
|
|
190
|
+
if (damaged.length === 0)
|
|
191
|
+
return { quarantined: false, sidecar };
|
|
192
|
+
let alreadyHeld;
|
|
193
|
+
try {
|
|
194
|
+
alreadyHeld = existingEvidence(sidecar);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
return { quarantined: false, error: describe(error), sidecar };
|
|
198
|
+
}
|
|
199
|
+
const pending = damaged.filter(line => !alreadyHeld.has(evidenceKey(source, line.raw)));
|
|
200
|
+
if (pending.length === 0)
|
|
201
|
+
return { quarantined: true, sidecar };
|
|
202
|
+
for (const line of pending) {
|
|
203
|
+
try {
|
|
204
|
+
appendLineAtomic(sidecar, JSON.stringify({
|
|
205
|
+
quarantinedAt: new Date().toISOString(),
|
|
206
|
+
source,
|
|
207
|
+
lineNumber: line.lineNumber,
|
|
208
|
+
bytes: line.bytes,
|
|
209
|
+
reason: line.reason,
|
|
210
|
+
truncatedEvidence: line.truncatedEvidence,
|
|
211
|
+
raw: line.raw,
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
return { quarantined: false, error: describe(error), sidecar };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { quarantined: true, sidecar };
|
|
219
|
+
}
|
|
220
|
+
function existingEvidence(sidecar) {
|
|
221
|
+
const held = new Set();
|
|
222
|
+
if (!existsSync(sidecar))
|
|
223
|
+
return held;
|
|
224
|
+
const raw = readFileSync(sidecar, 'utf8');
|
|
225
|
+
if (raw.length > MAX_QUARANTINE_BYTES)
|
|
226
|
+
throw new Error(`quarantine sidecar is full (${raw.length} bytes)`);
|
|
227
|
+
for (const line of raw.split('\n')) {
|
|
228
|
+
if (line.trim() === '')
|
|
229
|
+
continue;
|
|
230
|
+
try {
|
|
231
|
+
const entry = JSON.parse(line);
|
|
232
|
+
if (typeof entry.raw === 'string' && typeof entry.source === 'string') {
|
|
233
|
+
held.add(evidenceKey(entry.source, entry.raw));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch { /* an unreadable sidecar entry only costs us dedup */ }
|
|
237
|
+
}
|
|
238
|
+
return held;
|
|
239
|
+
}
|
|
240
|
+
/** Damage is identified by its bytes and which file they came from, so the live
|
|
241
|
+
* stream and its rotated predecessor are never confused for each other. */
|
|
242
|
+
function evidenceKey(source, raw) {
|
|
243
|
+
return `${source}${raw}`;
|
|
244
|
+
}
|
|
245
|
+
function describe(error) {
|
|
246
|
+
return error instanceof Error ? error.message : String(error);
|
|
247
|
+
}
|
package/dist/session/events.d.ts
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
|
+
import { type DamagedLine, type SequenceGap } from './event-log.js';
|
|
1
2
|
import type { SessionEvent, SessionEventKind } from './types.js';
|
|
3
|
+
/** What the on-disk stream is known to have lost or mangled. Never optimistic. */
|
|
4
|
+
export interface StreamIntegrity {
|
|
5
|
+
healthy: boolean;
|
|
6
|
+
damaged: DamagedLine[];
|
|
7
|
+
gaps: SequenceGap[];
|
|
8
|
+
quarantined: boolean;
|
|
9
|
+
quarantineError?: string;
|
|
10
|
+
/** Appends that failed outright; their events survive in memory only. */
|
|
11
|
+
writeFailures: number;
|
|
12
|
+
lastWriteError?: string;
|
|
13
|
+
/** Appends that had to close off a dangling partial record first. */
|
|
14
|
+
boundaryRepairs: number;
|
|
15
|
+
rotationFailed: boolean;
|
|
16
|
+
}
|
|
2
17
|
/** Bounded, typed event stream shared by CLI frontends and future ACP/Toad facades. */
|
|
3
18
|
export declare class SessionEvents {
|
|
4
19
|
private readonly path;
|
|
5
20
|
private seq;
|
|
6
21
|
private readonly events;
|
|
7
22
|
private readonly listeners;
|
|
23
|
+
private readonly integrityState;
|
|
24
|
+
private readonly rotatedPath;
|
|
8
25
|
constructor(path: string);
|
|
9
26
|
emit(kind: SessionEventKind, fields?: Omit<SessionEvent, 'version' | 'seq' | 'at' | 'kind'>): SessionEvent;
|
|
10
27
|
since(seq: number): SessionEvent[];
|
|
11
28
|
subscribe(listener: (event: SessionEvent) => void): () => void;
|
|
29
|
+
/** Snapshot of what is known to be wrong with the persisted stream. */
|
|
30
|
+
integrity(): StreamIntegrity;
|
|
31
|
+
private persist;
|
|
12
32
|
private restore;
|
|
33
|
+
private describeLoss;
|
|
13
34
|
private rotateIfNeeded;
|
|
14
35
|
}
|
package/dist/session/events.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, renameSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { appendLineAtomic, findSequenceGaps, quarantineDamage, readLog, } from './event-log.js';
|
|
2
4
|
const MAX_EVENTS = 1_000;
|
|
3
5
|
const MAX_EVENT_FILE_BYTES = 2 * 1024 * 1024;
|
|
4
6
|
/** Bounded, typed event stream shared by CLI frontends and future ACP/Toad facades. */
|
|
@@ -7,8 +9,14 @@ export class SessionEvents {
|
|
|
7
9
|
seq = 0;
|
|
8
10
|
events = [];
|
|
9
11
|
listeners = new Set();
|
|
12
|
+
integrityState = {
|
|
13
|
+
healthy: true, damaged: [], gaps: [], quarantined: false,
|
|
14
|
+
writeFailures: 0, boundaryRepairs: 0, rotationFailed: false,
|
|
15
|
+
};
|
|
16
|
+
rotatedPath;
|
|
10
17
|
constructor(path) {
|
|
11
18
|
this.path = path;
|
|
19
|
+
this.rotatedPath = path + '.1';
|
|
12
20
|
this.restore();
|
|
13
21
|
}
|
|
14
22
|
emit(kind, fields = {}) {
|
|
@@ -22,16 +30,7 @@ export class SessionEvents {
|
|
|
22
30
|
this.events.push(event);
|
|
23
31
|
if (this.events.length > MAX_EVENTS)
|
|
24
32
|
this.events.shift();
|
|
25
|
-
|
|
26
|
-
this.rotateIfNeeded();
|
|
27
|
-
// Owner-visible commentary remains live/in-memory for the authenticated
|
|
28
|
-
// bridge, but its plaintext must not become diagnostic state on disk.
|
|
29
|
-
const persisted = event.kind === 'agent_text' && event.messagePhase === 'commentary'
|
|
30
|
-
? { ...event, text: '[assistant commentary redacted]' }
|
|
31
|
-
: event;
|
|
32
|
-
appendFileSync(this.path, JSON.stringify(persisted) + '\n', { mode: 0o600 });
|
|
33
|
-
}
|
|
34
|
-
catch { /* diagnostics must never terminate a role */ }
|
|
33
|
+
this.persist(event);
|
|
35
34
|
for (const listener of this.listeners)
|
|
36
35
|
listener(event);
|
|
37
36
|
return event;
|
|
@@ -43,30 +42,110 @@ export class SessionEvents {
|
|
|
43
42
|
this.listeners.add(listener);
|
|
44
43
|
return () => this.listeners.delete(listener);
|
|
45
44
|
}
|
|
46
|
-
|
|
45
|
+
/** Snapshot of what is known to be wrong with the persisted stream. */
|
|
46
|
+
integrity() {
|
|
47
|
+
return { ...this.integrityState, damaged: [...this.integrityState.damaged], gaps: [...this.integrityState.gaps] };
|
|
48
|
+
}
|
|
49
|
+
persist(event) {
|
|
50
|
+
this.rotateIfNeeded();
|
|
51
|
+
// Owner-visible commentary remains live/in-memory for the authenticated
|
|
52
|
+
// bridge, but its plaintext must not become diagnostic state on disk.
|
|
53
|
+
const persisted = event.kind === 'agent_text' && event.messagePhase === 'commentary'
|
|
54
|
+
? { ...event, text: '[assistant commentary redacted]' }
|
|
55
|
+
: event;
|
|
47
56
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const event = JSON.parse(line);
|
|
53
|
-
if (event.version === 1 && typeof event.seq === 'number') {
|
|
54
|
-
this.events.push(event);
|
|
55
|
-
this.seq = Math.max(this.seq, event.seq);
|
|
56
|
-
}
|
|
57
|
+
const { repairedBoundary } = appendLineAtomic(this.path, JSON.stringify(persisted));
|
|
58
|
+
if (repairedBoundary) {
|
|
59
|
+
this.integrityState.boundaryRepairs++;
|
|
60
|
+
this.integrityState.healthy = false;
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
|
-
catch {
|
|
63
|
+
catch (error) {
|
|
64
|
+
// Diagnostics must never terminate a role, but a silent loss is how the
|
|
65
|
+
// 06:32 incident stayed invisible for an hour — record it instead.
|
|
66
|
+
this.integrityState.writeFailures++;
|
|
67
|
+
this.integrityState.lastWriteError = error instanceof Error ? error.message : String(error);
|
|
68
|
+
this.integrityState.healthy = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
restore() {
|
|
72
|
+
// Rotation renames the live stream to `.1` and only then starts a new one.
|
|
73
|
+
// A crash in that window leaves no live file at all, so reading only the
|
|
74
|
+
// live path would restart at seq 1 and re-issue numbers `.1` already holds.
|
|
75
|
+
const rotated = readLog(this.rotatedPath);
|
|
76
|
+
const live = readLog(this.path);
|
|
77
|
+
const records = [...rotated.records, ...live.records];
|
|
78
|
+
for (const record of records.slice(-MAX_EVENTS))
|
|
79
|
+
this.events.push(record);
|
|
80
|
+
// Monotonic even across damage and rotation: a sequence number already on
|
|
81
|
+
// disk, readable or not, is never handed out again.
|
|
82
|
+
this.seq = Math.max(this.seq, rotated.maxSeq, live.maxSeq);
|
|
83
|
+
const damaged = [...rotated.damaged, ...live.damaged];
|
|
84
|
+
// Records that parse perfectly still leave a hole when the writes between
|
|
85
|
+
// them never landed, and a hole on the rotated/live boundary is invisible to
|
|
86
|
+
// either file alone — so gaps are computed over the combined ordered run,
|
|
87
|
+
// and reported whether or not any bytes were mangled.
|
|
88
|
+
const gaps = findSequenceGaps([...rotated.sequence, ...live.sequence]);
|
|
89
|
+
if (damaged.length === 0 && gaps.length === 0)
|
|
90
|
+
return;
|
|
91
|
+
this.integrityState.healthy = false;
|
|
92
|
+
this.integrityState.damaged = damaged;
|
|
93
|
+
this.integrityState.gaps = gaps;
|
|
94
|
+
let error;
|
|
95
|
+
if (damaged.length > 0) {
|
|
96
|
+
const results = [
|
|
97
|
+
quarantineDamage(this.path, rotated.damaged, this.rotatedPath),
|
|
98
|
+
quarantineDamage(this.path, live.damaged, this.path),
|
|
99
|
+
].filter(result => result.quarantined || result.error);
|
|
100
|
+
this.integrityState.quarantined = results.every(result => result.quarantined);
|
|
101
|
+
error = results.find(result => result.error)?.error;
|
|
102
|
+
if (error)
|
|
103
|
+
this.integrityState.quarantineError = error;
|
|
104
|
+
}
|
|
105
|
+
// Reported through the ordinary stream so it reaches replay and audit;
|
|
106
|
+
// never through a second attempt at the writer that just failed.
|
|
107
|
+
this.emit('error', { text: this.describeLoss(damaged, gaps, error) });
|
|
108
|
+
}
|
|
109
|
+
describeLoss(damaged, gaps, error) {
|
|
110
|
+
const interior = damaged.filter(line => line.reason === 'interior_corruption');
|
|
111
|
+
const tails = damaged.filter(line => line.reason === 'truncated_tail');
|
|
112
|
+
const parts = ['event stream integrity'];
|
|
113
|
+
if (interior.length) {
|
|
114
|
+
parts.push(`${interior.length} interior corruption at line ${interior.map(l => l.lineNumber).join(', ')}`);
|
|
115
|
+
}
|
|
116
|
+
if (tails.length)
|
|
117
|
+
parts.push(`${tails.length} truncated tail discarded`);
|
|
118
|
+
const missing = gaps.reduce((total, gap) => total + gap.missing, 0);
|
|
119
|
+
if (missing) {
|
|
120
|
+
const span = gaps.map(gap => `${gap.afterSeq}..${gap.beforeSeq}`).join(', ');
|
|
121
|
+
parts.push(`${missing} record${missing === 1 ? '' : 's'} lost between seq ${span} (irrecoverable)`);
|
|
122
|
+
}
|
|
123
|
+
if (damaged.length) {
|
|
124
|
+
parts.push(this.integrityState.quarantined
|
|
125
|
+
? `damaged bytes quarantined to ${basename(this.path)}.corrupt`
|
|
126
|
+
: `damaged bytes NOT quarantined${error ? ` (${error})` : ''}`);
|
|
127
|
+
}
|
|
128
|
+
return parts.join('; ');
|
|
60
129
|
}
|
|
61
130
|
rotateIfNeeded() {
|
|
62
|
-
if (!existsSync(this.path)
|
|
131
|
+
if (!existsSync(this.path))
|
|
132
|
+
return;
|
|
133
|
+
try {
|
|
134
|
+
if (statSync(this.path).size < MAX_EVENT_FILE_BYTES)
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
63
138
|
return;
|
|
64
|
-
|
|
139
|
+
}
|
|
65
140
|
try {
|
|
66
|
-
renameSync(this.path,
|
|
141
|
+
renameSync(this.path, this.rotatedPath);
|
|
142
|
+
this.integrityState.rotationFailed = false;
|
|
67
143
|
}
|
|
68
144
|
catch {
|
|
69
|
-
|
|
145
|
+
// Fail closed: the previous fallback truncated the live stream, which
|
|
146
|
+
// destroys exactly the evidence an incident needs.
|
|
147
|
+
this.integrityState.rotationFailed = true;
|
|
148
|
+
this.integrityState.healthy = false;
|
|
70
149
|
}
|
|
71
150
|
}
|
|
72
151
|
}
|
package/dist/session/tmux.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Tmux } from '../tmux.js';
|
|
2
|
-
import type { ExitRecord, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnResult } from './types.js';
|
|
2
|
+
import type { ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnResult } from './types.js';
|
|
3
3
|
/** SessionHandle adapter for the existing tmux transport. */
|
|
4
4
|
export declare class TmuxSession implements SessionHandle {
|
|
5
5
|
private readonly name;
|
|
@@ -12,7 +12,8 @@ export declare class TmuxSession implements SessionHandle {
|
|
|
12
12
|
snapshot(): SessionSnapshot;
|
|
13
13
|
queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
|
|
14
14
|
submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
|
|
15
|
-
|
|
15
|
+
/** A keystroke is delivered or it throws; tmux offers no forced-recovery path. */
|
|
16
|
+
interrupt(): Promise<InterruptOutcome>;
|
|
16
17
|
respondPermission(): boolean;
|
|
17
18
|
eventsSince(): SessionEvent[];
|
|
18
19
|
subscribe(): () => void;
|
package/dist/session/tmux.js
CHANGED
|
@@ -51,8 +51,10 @@ export class TmuxSession {
|
|
|
51
51
|
throw error;
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
/** A keystroke is delivered or it throws; tmux offers no forced-recovery path. */
|
|
54
55
|
async interrupt() {
|
|
55
56
|
await this.tmux.sendKey(this.name, 'C-c');
|
|
57
|
+
return { state: 'settled' };
|
|
56
58
|
}
|
|
57
59
|
respondPermission() {
|
|
58
60
|
return false;
|
package/dist/session/types.d.ts
CHANGED
|
@@ -62,10 +62,38 @@ export interface TurnResult {
|
|
|
62
62
|
* session is gone, and `timeout` explicitly does NOT say the prompt was lost.
|
|
63
63
|
*/
|
|
64
64
|
export type ControlFailureKind = 'offline' | 'control-unavailable' | 'timeout' | 'rejected' | 'backend';
|
|
65
|
+
/** Stable body-free reason shared by ACP recovery and durable ingress. */
|
|
66
|
+
export declare const ACP_CANCEL_DEADLINE_EXCEEDED = "ACP_CANCEL_DEADLINE_EXCEEDED";
|
|
65
67
|
export declare class SessionControlError extends Error {
|
|
66
68
|
readonly kind: ControlFailureKind;
|
|
67
|
-
|
|
69
|
+
/** Stable body-free machine reason for recovery/audit decisions. */
|
|
70
|
+
readonly reasonCode?: string | undefined;
|
|
71
|
+
constructor(kind: ControlFailureKind, message: string,
|
|
72
|
+
/** Stable body-free machine reason for recovery/audit decisions. */
|
|
73
|
+
reasonCode?: string | undefined);
|
|
68
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* How an explicit cancellation ended. Forced recovery is a SUCCESS: the turn is
|
|
77
|
+
* over and the session is being reclaimed. Reporting it as a failed interrupt is
|
|
78
|
+
* what made owners, the control plane and the web console retry an operation
|
|
79
|
+
* that had already done exactly what was asked.
|
|
80
|
+
*/
|
|
81
|
+
export interface InterruptOutcome {
|
|
82
|
+
/** `settled` — the turn (or nothing) ended cooperatively. `forced` — the adapter ignored the cancel and was restarted. */
|
|
83
|
+
state: 'settled' | 'forced';
|
|
84
|
+
/** Stable body-free reason present only for a forced recovery. */
|
|
85
|
+
reasonCode?: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* What a `SessionHandle.interrupt` implementation may resolve. Before 0.17.1 the
|
|
89
|
+
* contract was `Promise<void>`, and resolving at all meant the cancellation had
|
|
90
|
+
* taken effect cooperatively — so an implementation written against that
|
|
91
|
+
* contract stays valid and keeps its exact meaning. Only in-tree consumers read
|
|
92
|
+
* the richer outcome, and they normalize through `interruptOutcome` first.
|
|
93
|
+
*/
|
|
94
|
+
export type InterruptResult = InterruptOutcome | void;
|
|
95
|
+
/** The one place the legacy `void` reply is given its meaning: `settled`. */
|
|
96
|
+
export declare function interruptOutcome(result: InterruptResult): InterruptOutcome;
|
|
69
97
|
/**
|
|
70
98
|
* A prompt the live session has taken responsibility for. Interactive callers
|
|
71
99
|
* stop here: the session has the prompt, and waiting for the turn to finish is
|
|
@@ -218,7 +246,16 @@ export interface SessionHandle {
|
|
|
218
246
|
submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
|
|
219
247
|
/** Monitor-only ACP safe-boundary delivery. Never implies human/control cancellation. */
|
|
220
248
|
submitPromptAfterTool?(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
|
|
221
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Cancel the active turn. Resolves when the cancellation has taken effect —
|
|
251
|
+
* cooperatively (`settled`) or through bounded forced recovery (`forced`).
|
|
252
|
+
* It rejects only when the cancellation itself could not be delivered.
|
|
253
|
+
*
|
|
254
|
+
* The return type is widened to `InterruptResult` for one reason: an
|
|
255
|
+
* implementation written against the pre-0.17.1 `Promise<void>` contract must
|
|
256
|
+
* keep compiling. Read it through `interruptOutcome`, never directly.
|
|
257
|
+
*/
|
|
258
|
+
interrupt(source?: TurnCancellationSource): Promise<InterruptResult>;
|
|
222
259
|
respondPermission(permissionId: string, optionId: string): boolean;
|
|
223
260
|
/** Generation-bound browser decision; stale/settled/invalid all fail closed. */
|
|
224
261
|
respondPermissionV2?(permissionId: string, optionId: string, sessionGeneration: string): 'accepted' | 'stale';
|
package/dist/session/types.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
|
+
/** Stable body-free reason shared by ACP recovery and durable ingress. */
|
|
2
|
+
export const ACP_CANCEL_DEADLINE_EXCEEDED = 'ACP_CANCEL_DEADLINE_EXCEEDED';
|
|
1
3
|
export class SessionControlError extends Error {
|
|
2
4
|
kind;
|
|
3
|
-
|
|
5
|
+
reasonCode;
|
|
6
|
+
constructor(kind, message,
|
|
7
|
+
/** Stable body-free machine reason for recovery/audit decisions. */
|
|
8
|
+
reasonCode) {
|
|
4
9
|
super(message);
|
|
5
10
|
this.kind = kind;
|
|
11
|
+
this.reasonCode = reasonCode;
|
|
6
12
|
this.name = 'SessionControlError';
|
|
7
13
|
}
|
|
8
14
|
}
|
|
15
|
+
/** The one place the legacy `void` reply is given its meaning: `settled`. */
|
|
16
|
+
export function interruptOutcome(result) {
|
|
17
|
+
return result ?? { state: 'settled' };
|
|
18
|
+
}
|
|
9
19
|
/**
|
|
10
20
|
* Classify a shell `$?`. Above 128 the shell is reporting 128+signal — the only
|
|
11
21
|
* signal evidence a pane wrapper can give us.
|
package/dist/spawn.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type OpsDeps } from './ops.js';
|
|
|
4
4
|
import { type CreationDeps, type CreationProvenance } from './creation.js';
|
|
5
5
|
import './harness/claude-code.js';
|
|
6
6
|
import './harness/codex.js';
|
|
7
|
+
import { type SupervisorLauncher } from './temp-lifecycle.js';
|
|
7
8
|
/**
|
|
8
9
|
* The provenance record written by the most recent spawn in this process, so
|
|
9
10
|
* the CLI can print the same summary it persisted rather than rebuilding it.
|
|
@@ -94,7 +95,6 @@ export interface SpawnDryRun {
|
|
|
94
95
|
export declare function spawnDryRun(o: SpawnOpts): SpawnDryRun;
|
|
95
96
|
/** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
|
|
96
97
|
export declare function spawnPermanent(o: SpawnOpts, deps: OpsDeps, creation?: CreationDeps): Promise<string>;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
/** Temp spawn: state under ~/.ours-fleet/tmp, plain tmux, auto-clean on exit. */
|
|
98
|
+
export type { SupervisorLauncher } from './temp-lifecycle.js';
|
|
99
|
+
/** Temp spawn: live state under ~/.ours-fleet/tmp, independent transient supervision. */
|
|
100
100
|
export declare function spawnTemp(o: SpawnOpts, binPath: string, launch?: SupervisorLauncher, creation?: CreationDeps): Promise<string>;
|