@ours.network/fleet 0.17.0 → 0.17.2

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.
Files changed (65) hide show
  1. package/README.md +38 -2
  2. package/dist/application/role-removal-service.js +1 -1
  3. package/dist/application/session-control.d.ts +14 -10
  4. package/dist/application/session-control.js +14 -3
  5. package/dist/atomic-file.d.ts +7 -1
  6. package/dist/atomic-file.js +33 -5
  7. package/dist/build-info.json +10 -0
  8. package/dist/capabilities.d.ts +20 -0
  9. package/dist/capabilities.js +21 -0
  10. package/dist/cli.js +98 -10
  11. package/dist/config.d.ts +9 -2
  12. package/dist/config.js +16 -2
  13. package/dist/creation.d.ts +16 -0
  14. package/dist/creation.js +28 -0
  15. package/dist/docs.d.ts +1 -1
  16. package/dist/docs.js +70 -4
  17. package/dist/doctor.d.ts +5 -0
  18. package/dist/doctor.js +87 -2
  19. package/dist/fleet-proxy.js +2 -2
  20. package/dist/harness/acp-agent.d.ts +3 -0
  21. package/dist/harness/acp-agent.js +4 -1
  22. package/dist/harness/codex-app-server-proxy.d.ts +4 -0
  23. package/dist/harness/codex-app-server-proxy.js +133 -0
  24. package/dist/harness/codex.js +116 -11
  25. package/dist/harness/types.d.ts +2 -0
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.js +1 -0
  28. package/dist/loops/manager.d.ts +42 -1
  29. package/dist/loops/manager.js +115 -16
  30. package/dist/loops/state.d.ts +46 -2
  31. package/dist/loops/state.js +81 -3
  32. package/dist/monitor.d.ts +21 -0
  33. package/dist/monitor.js +42 -0
  34. package/dist/ops.d.ts +6 -0
  35. package/dist/ops.js +46 -1
  36. package/dist/owner-channel/channel.d.ts +18 -2
  37. package/dist/owner-channel/channel.js +146 -2
  38. package/dist/owner-channel/commands.d.ts +2 -2
  39. package/dist/owner-channel/commands.js +7 -2
  40. package/dist/owner-channel/notices.d.ts +2 -0
  41. package/dist/owner-channel/notices.js +3 -0
  42. package/dist/permissions.d.ts +2 -0
  43. package/dist/permissions.js +5 -0
  44. package/dist/provenance.d.ts +77 -0
  45. package/dist/provenance.js +283 -0
  46. package/dist/runner.d.ts +7 -1
  47. package/dist/runner.js +100 -14
  48. package/dist/session/acp.d.ts +40 -4
  49. package/dist/session/acp.js +272 -37
  50. package/dist/session/arbiter.d.ts +28 -2
  51. package/dist/session/arbiter.js +75 -4
  52. package/dist/session/control.js +12 -6
  53. package/dist/session/event-log.d.ts +109 -0
  54. package/dist/session/event-log.js +247 -0
  55. package/dist/session/events.d.ts +21 -0
  56. package/dist/session/events.js +105 -26
  57. package/dist/session/tmux.d.ts +3 -2
  58. package/dist/session/tmux.js +2 -0
  59. package/dist/session/types.d.ts +39 -2
  60. package/dist/session/types.js +11 -1
  61. package/dist/spawn.d.ts +3 -3
  62. package/dist/spawn.js +40 -14
  63. package/dist/temp-lifecycle.d.ts +62 -0
  64. package/dist/temp-lifecycle.js +437 -0
  65. package/package.json +5 -3
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { createConnection, createServer } from 'node:net';
4
4
  import { join } from 'node:path';
5
- import { SessionControlError } from './types.js';
5
+ import { SessionControlError, interruptOutcome } from './types.js';
6
6
  const MAX_LINE_BYTES = 64 * 1024;
7
7
  /** Commands that require protocol version 3. */
8
8
  const V3_COMMANDS = new Set([
@@ -253,10 +253,13 @@ export class RoleControlServer {
253
253
  });
254
254
  return;
255
255
  }
256
- case 'interrupt':
257
- await this.session.interrupt('local-console');
258
- this.write(socket, { version: 1, id: request.id, ok: true });
256
+ case 'interrupt': {
257
+ // Forced recovery cancelled the turn just as surely as a cooperative
258
+ // stop did. Report HOW, never as a failed operation.
259
+ const outcome = interruptOutcome(await this.session.interrupt('local-console'));
260
+ this.write(socket, { version: 1, id: request.id, ok: true, result: outcome });
259
261
  return;
262
+ }
260
263
  case 'loop_status': {
261
264
  if (!this.loopManager)
262
265
  throw new SessionControlError('rejected', 'scheduled loops are unavailable for this role');
@@ -396,8 +399,11 @@ export class RoleControlServer {
396
399
  this.write(socket, { version: 1, id: request.id, ok: true, result: existing });
397
400
  return;
398
401
  }
399
- await this.session.interrupt('local-console');
400
- const receipt = { accepted: true, commandId: request.commandId, at: new Date().toISOString() };
402
+ const outcome = interruptOutcome(await this.session.interrupt('local-console'));
403
+ const receipt = {
404
+ accepted: true, commandId: request.commandId, at: new Date().toISOString(),
405
+ ...outcome,
406
+ };
401
407
  this.interruptCommands.set(request.commandId, receipt);
402
408
  if (this.interruptCommands.size > 200) {
403
409
  const oldest = this.interruptCommands.keys().next().value;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Crash-safe JSONL primitives for the session event stream.
3
+ *
4
+ * The 06:32 disk-full incident showed what an unguarded `appendFileSync` does
5
+ * under ENOSPC: the kernel short-writes, the already-written prefix stays on
6
+ * disk, and the next successful append lands directly on it — fusing a partial
7
+ * record with a valid later one into a single unparseable line. These helpers
8
+ * make an append all-or-nothing, keep a damaged byte range from ever swallowing
9
+ * a later record, and describe damage rather than quietly dropping it.
10
+ *
11
+ * Single-writer assumption: rollback truncates back to the size observed at the
12
+ * start of the append, so exactly one process may append to a given path.
13
+ */
14
+ /** The syscall surface an append needs, injectable so faults can be forced deterministically. */
15
+ export interface LogIo {
16
+ openSync(path: string, flags: string, mode?: number): number;
17
+ fstatSync(fd: number): {
18
+ size: number;
19
+ };
20
+ readSync(fd: number, buffer: Buffer, offset: number, length: number, position: number): number;
21
+ writeSync(fd: number, buffer: Buffer, offset: number, length: number): number;
22
+ ftruncateSync(fd: number, length: number): void;
23
+ closeSync(fd: number): void;
24
+ }
25
+ export declare const nodeLogIo: LogIo;
26
+ export declare class AtomicAppendError extends Error {
27
+ readonly rollbackFailed: boolean;
28
+ readonly cause?: unknown | undefined;
29
+ constructor(message: string, rollbackFailed: boolean, cause?: unknown | undefined);
30
+ }
31
+ export interface AppendResult {
32
+ /** True when a dangling partial record was closed off before this line was written. */
33
+ repairedBoundary: boolean;
34
+ }
35
+ export interface AppendOptions {
36
+ io?: LogIo;
37
+ /** Total attempts, including the first. Bounded so a full disk cannot spin. */
38
+ maxAttempts?: number;
39
+ mode?: number;
40
+ }
41
+ /**
42
+ * Append one line, all or nothing. A failed write is rolled back to the byte
43
+ * length observed before it started, so a partial record never persists; if the
44
+ * file already ends mid-record (damage from before this fix), the line is put on
45
+ * a fresh line so it cannot fuse with the damaged bytes.
46
+ */
47
+ export declare function appendLineAtomic(path: string, line: string, options?: AppendOptions): AppendResult;
48
+ export type DamageReason = 'truncated_tail' | 'interior_corruption';
49
+ export interface DamagedLine {
50
+ /** 1-based line number in the file as read. */
51
+ lineNumber: number;
52
+ /** True byte length of the damaged line, even when `raw` is capped. */
53
+ bytes: number;
54
+ reason: DamageReason;
55
+ /** The damaged bytes, capped for storage; `truncatedEvidence` says when. */
56
+ raw: string;
57
+ truncatedEvidence: boolean;
58
+ }
59
+ /** A run of sequence numbers that is simply gone — never recoverable, only reportable. */
60
+ export interface SequenceGap {
61
+ afterSeq: number;
62
+ beforeSeq: number;
63
+ missing: number;
64
+ }
65
+ export interface LogRecord {
66
+ version: 1;
67
+ seq: number;
68
+ [key: string]: unknown;
69
+ }
70
+ export interface ReadLogResult {
71
+ /** Records this version understands and can replay. */
72
+ records: LogRecord[];
73
+ /**
74
+ * Sequence numbers claimed by every readable entry in file order, whatever
75
+ * its version. A record written by a newer version is not replayable here,
76
+ * but it did occupy its number: counting it is what keeps a forward-compatible
77
+ * file from looking like it has a hole, and keeps continuation past it.
78
+ */
79
+ sequence: number[];
80
+ /** Highest sequence still readable anywhere in the file, for monotonic continuation. */
81
+ maxSeq: number;
82
+ damaged: DamagedLine[];
83
+ gaps: SequenceGap[];
84
+ }
85
+ /**
86
+ * Read the log line by line. One bad line costs exactly that line: everything
87
+ * before and after it is kept, and the damage is described rather than dropped.
88
+ */
89
+ export declare function readLog(path: string): ReadLogResult;
90
+ /**
91
+ * Missing sequence numbers between consecutive records. Records that parse
92
+ * perfectly still leave a hole when the writes between them never landed, so
93
+ * this is computed over whatever ordered run the caller cares about — including
94
+ * a rotated stream followed by its live successor, where the hole falls on the
95
+ * boundary and neither file can see it alone.
96
+ */
97
+ export declare function findSequenceGaps(sequence: number[]): SequenceGap[];
98
+ export interface QuarantineResult {
99
+ quarantined: boolean;
100
+ /** Present when the sidecar could not be written; the source file is untouched either way. */
101
+ error?: string;
102
+ sidecar: string;
103
+ }
104
+ /**
105
+ * Copy damaged bytes to a sidecar for later forensics. The damaged file itself
106
+ * is never read-modify-written, and a sidecar that cannot be created is reported
107
+ * rather than retried into the event writer.
108
+ */
109
+ export declare function quarantineDamage(path: string, damaged: DamagedLine[], source?: string): QuarantineResult;
@@ -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
+ }
@@ -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
  }
@@ -1,4 +1,6 @@
1
- import { appendFileSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
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
- try {
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
- restore() {
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
- if (!existsSync(this.path))
49
- return;
50
- const lines = readFileSync(this.path, 'utf8').trim().split('\n').slice(-MAX_EVENTS);
51
- for (const line of lines) {
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 { /* begin a fresh projection if the diagnostic file is corrupt */ }
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) || statSync(this.path).size < MAX_EVENT_FILE_BYTES)
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
- const rotated = this.path + '.1';
139
+ }
65
140
  try {
66
- renameSync(this.path, rotated);
141
+ renameSync(this.path, this.rotatedPath);
142
+ this.integrityState.rotationFailed = false;
67
143
  }
68
144
  catch {
69
- writeFileSync(this.path, '', { mode: 0o600 });
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
  }
@@ -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
- interrupt(): Promise<void>;
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;
@@ -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;