@manyoars/oars 0.5.1 → 0.5.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.
@@ -5,7 +5,7 @@ import { writeFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { CliError } from '../errors.js';
7
7
  import { machineOf } from './machine.js';
8
- import { ask, currentTerms, DEPOSIT, isObject, networkSender, poolDeposit, poolServer, postBatch, postRaw, runtimeOf, signedIn, textIn, vocabularyExpired } from './pool-command.js';
8
+ import { ask, currentTerms, DEPOSIT, isObject, networkSender, poolDeposit, poolServer, postBatch, postRaw, runtimeOf, SEND_STALL_MS, signedIn, textIn, vocabularyExpired } from './pool-command.js';
9
9
  import { rawStale } from './pool-choices.js';
10
10
  import { heldAcross, lockedBatch, outboxFolder, rescanPulled } from './pool-outbox.js';
11
11
  import { heldRaw } from './pool-raw.js';
@@ -119,7 +119,8 @@ export const nightlyPoolSend = async (deps, via) => {
119
119
  const tally = { outcome: 'failed', batches: 0, raw: 0 };
120
120
  let result;
121
121
  try {
122
- result = await night(deps, via ?? networkSender(deps, DEPOSIT), tally);
122
+ // A pool that goes silent ends the night after SEND_STALL_MS, so it cannot hold the send lock (and with it every `deposit --send`) for as long as the pool stalls. The next run sends what this one did not.
123
+ result = await night(deps, via ?? networkSender(deps, DEPOSIT, { stallMs: SEND_STALL_MS }), tally);
123
124
  }
124
125
  catch {
125
126
  result = { ...tally, outcome: 'failed' };
@@ -5,6 +5,8 @@ export interface LockTimes {
5
5
  /** How long a run waits for the lock before it gives up. */
6
6
  waitMs: number;
7
7
  pollMs: number;
8
+ /** How long a run waits behind a live holder before it tells the person it is waiting. */
9
+ noticeMs: number;
8
10
  }
9
11
  /** What a test replaces: `stat` to read a lock as stale at the moment a waiter looks, `open` to make the create fail the way Windows does, `platform` to pick the platform's rules. */
10
12
  export interface LockFs {
@@ -13,8 +15,76 @@ export interface LockFs {
13
15
  }>;
14
16
  open?: (path: string, flags: 'wx') => Promise<FileHandle>;
15
17
  platform?: NodeJS.Platform;
18
+ /** Whether a process with this id is running on this machine. A test answers for a pid it made up. */
19
+ running?: (pid: number) => boolean;
20
+ /** This run's pid and host, recorded in the lock; a test names the holder it plays. */
21
+ self?: {
22
+ pid: number;
23
+ host: string;
24
+ };
25
+ /** The clock and the pause between polls; a test drives both instead of sleeping. */
26
+ clock?: {
27
+ now: () => number;
28
+ sleep: (ms: number) => Promise<void>;
29
+ };
30
+ }
31
+ export declare const DEFAULT_TIMES: LockTimes;
32
+ /** Who holds the lock, as the holder wrote it into the file: its run, pid, host, and when it took the lock (epoch ms). */
33
+ export interface Holder {
34
+ run: string;
35
+ pid: number;
36
+ host: string;
37
+ since: number;
38
+ }
39
+ /** What a run that waits tells its caller once, when the wait has lasted `noticeMs`: the holder when the file names one (an empty or older-format file names none), how old the lock is, and how long this run will wait. */
40
+ export interface Waiting {
41
+ holder: Holder | undefined;
42
+ ageMs: number;
43
+ waitMs: number;
44
+ }
45
+ /** What a run that gave up knows: the holder, the lock's age now, the time already waited, and the constants the takeover rules use. */
46
+ export interface BusyDetail {
47
+ holder: Holder | undefined;
48
+ /** Time since the holder took the lock, else since the lock file was last touched. */
49
+ ageMs: number;
50
+ /** Time since the lock file was last touched: a live holder touches it every `staleMs / 4`. */
51
+ untouchedMs: number;
52
+ waitedMs: number;
53
+ staleMs: number;
54
+ /** Whether the holder is a process on this host: its heartbeat then keeps the lock fresh while it lives, so only its end (not the clock) frees the lock. */
55
+ local: boolean;
56
+ }
57
+ export interface LockWait {
58
+ /** Called once if the lock is still held after `noticeMs`. */
59
+ onWait?: (waiting: Waiting) => void;
60
+ /** Ends the wait: the run throws the signal's reason. An interrupted `--send` leaves nothing waiting. */
61
+ signal?: AbortSignal;
16
62
  }
17
63
  export declare class SendLockBusy extends Error {
18
- constructor();
64
+ readonly detail: BusyDetail;
65
+ constructor(detail?: BusyDetail);
66
+ }
67
+ export declare const withSendLock: <T>(outbox: string, run: () => Promise<T>, times?: Partial<LockTimes>, fs?: LockFs, wait?: LockWait) => Promise<T>;
68
+ /** Why an interactive send ended before it finished: a signal reached it, or the process that launched it is gone. */
69
+ export declare class SendInterrupted extends Error {
70
+ readonly kind: 'signal' | 'launcher';
71
+ constructor(kind: 'signal' | 'launcher', message: string);
72
+ }
73
+ /** What a test replaces: the process's signal hooks, its launcher, and how that launcher's liveness is read. */
74
+ export interface InterruptHost {
75
+ on: (event: NodeJS.Signals, listener: () => void) => unknown;
76
+ off: (event: NodeJS.Signals, listener: () => void) => unknown;
77
+ /** The pid of the process that started this one; checked every `checkMs`. */
78
+ parent: number;
79
+ running: (pid: number) => boolean;
80
+ checkMs: number;
19
81
  }
20
- export declare const withSendLock: <T>(outbox: string, run: () => Promise<T>, times?: Partial<LockTimes>, fs?: LockFs) => Promise<T>;
82
+ /**
83
+ * The signal an interactive `oars pool deposit --send` waits and sends under. Ctrl-C, a closed terminal, and a kill all end it. A launcher that is killed without a signal reaching its child (Windows `taskkill` of the
84
+ * shell or the `oars.cmd` shim, a `timeout` wrapper) leaves the child running, so the launcher's pid is read every second and a run whose launcher is gone ends too. The detached nightly sender is not watched:
85
+ * it is meant to outlive its starter.
86
+ */
87
+ export declare const watchInterrupt: (host?: InterruptHost) => {
88
+ signal: AbortSignal;
89
+ stop: () => void;
90
+ };
@@ -1,15 +1,46 @@
1
1
  // One sender at a time per outbox: the detached sender (hook-bin.js nightly) and `deposit --send` both take this lock, so a batch is never posted twice by two runs that overlap. The lock is a file created
2
- // with `wx` beside `sent.log`, so it holds across processes as well as inside one. A run that finds it held waits; a lock older than `staleMs` belongs to a run that died, and is taken over by one waiter only, under a guard file. The holder touches the file while it runs, so a long send (a first night of raw parts) is never mistaken for a dead run, and it removes the file only if the file still names its own run.
2
+ // with `wx` beside `sent.log`, so it holds across processes as well as inside one. A run that finds it held waits; a holder on this host whose pid is not running is taken over at once, and a lock untouched for `staleMs` belongs to a run that died (the rule for a holder whose liveness cannot be read: an empty or older-format file, another host's pid). Either takeover is by one waiter only, under a guard file. The holder touches the file while it runs, so a long send (a first night of raw parts) is never mistaken for a dead run, and it removes the file only if the file still names its own run.
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { mkdir, open, readFile, rm, stat, utimes } from 'node:fs/promises';
5
+ import { hostname } from 'node:os';
5
6
  import { join } from 'node:path';
6
7
  import { setTimeout as sleep } from 'node:timers/promises';
7
- const DEFAULT_TIMES = { staleMs: 10 * 60 * 1000, waitMs: 2 * 60 * 1000, pollMs: 50 };
8
+ export const DEFAULT_TIMES = { staleMs: 10 * 60 * 1000, waitMs: 2 * 60 * 1000, pollMs: 50, noticeMs: 500 };
8
9
  export class SendLockBusy extends Error {
9
- constructor() {
10
+ detail;
11
+ constructor(detail = { holder: undefined, ageMs: 0, untouchedMs: 0, waitedMs: 0, staleMs: DEFAULT_TIMES.staleMs, local: false }) {
10
12
  super('Another oars run is sending from this outbox.');
13
+ this.detail = detail;
11
14
  }
12
15
  }
16
+ const running = (pid) => {
17
+ try {
18
+ process.kill(pid, 0);
19
+ return true;
20
+ }
21
+ catch (error) {
22
+ // EPERM: the process exists but belongs to someone else.
23
+ return error instanceof Error && Reflect.get(error, 'code') === 'EPERM';
24
+ }
25
+ };
26
+ /** The holder a lock file names, or `undefined` for a file that is empty, half written, or not in this format: such a lock is judged by the clock alone. */
27
+ const holderOf = async (file) => {
28
+ const text = await readFile(file, 'utf8').catch(() => '');
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(text);
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ if (typeof parsed !== 'object' || parsed === null)
37
+ return undefined;
38
+ const run = Reflect.get(parsed, 'run');
39
+ const pid = Reflect.get(parsed, 'pid');
40
+ const host = Reflect.get(parsed, 'host');
41
+ const since = Reflect.get(parsed, 'since');
42
+ return typeof run === 'string' && typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && typeof host === 'string' && typeof since === 'number' ? { run, pid, host, since } : undefined;
43
+ };
13
44
  // EEXIST means another run holds the file. On Windows an open of a file whose delete is still pending (the holder just released it) answers EPERM or EACCES instead: the same contention, so retry within waitMs.
14
45
  const contended = (error, platform) => {
15
46
  if (!(error instanceof Error))
@@ -18,24 +49,37 @@ const contended = (error, platform) => {
18
49
  return code === 'EEXIST' || (platform === 'win32' && (code === 'EPERM' || code === 'EACCES'));
19
50
  };
20
51
  /**
21
- * A stale lock is removed under a takeover guard (`send.lock.takeover`, created with `wx`), so one waiter at a time reads the lock again and removes it only if it is still stale. A live lock is never moved
22
- * or removed: a waiter that read a lock as stale, but meets a lock another run took over and refreshed in the meantime, finds it fresh under the guard and leaves it. A guard left by a taker that died is
52
+ * Whether a lock can be removed now: its holder is a process on this machine that is not running (taken over at once, whatever the clock says), or the file has not been touched for `staleMs` (the holder's heartbeat stopped).
53
+ * A holder whose liveness cannot be read (an empty file, another host's pid) is judged by the clock alone, which is the only reason `staleMs` stays at ten minutes. A pid the system has handed to another process since reads as
54
+ * running, so pid reuse can only make a waiter wait for the clock, never take a live holder's lock; the lock's age is not taken from the pid for that reason.
55
+ */
56
+ const reclaimable = async (file, staleMs, fs, now) => {
57
+ const held = await fs.stat(file).catch(() => undefined);
58
+ if (held === undefined)
59
+ return false;
60
+ if (now() - held.mtimeMs > staleMs)
61
+ return true;
62
+ const holder = await holderOf(file);
63
+ return holder !== undefined && holder.host === (fs.self?.host ?? hostname()) && !(fs.running ?? running)(holder.pid);
64
+ };
65
+ /**
66
+ * A reclaimable lock is removed under a takeover guard (`send.lock.takeover`, created with `wx`), so one waiter at a time reads the lock again and removes it only if it is still reclaimable. A live lock is never moved
67
+ * or removed: a waiter that read a lock as reclaimable, but meets a lock another run took over and refreshed in the meantime, finds it live under the guard and leaves it. A guard left by a taker that died is
23
68
  * removed once it is older than `staleMs`.
24
69
  */
25
- const takeOver = async (file, staleMs, fs) => {
70
+ const takeOver = async (file, staleMs, fs, now) => {
26
71
  const guard = `${file}.takeover`;
27
72
  try {
28
73
  await (await open(guard, 'wx')).close();
29
74
  }
30
75
  catch {
31
76
  const left = await stat(guard).catch(() => undefined);
32
- if (left !== undefined && Date.now() - left.mtimeMs > staleMs)
77
+ if (left !== undefined && now() - left.mtimeMs > staleMs)
33
78
  await rm(guard, { force: true });
34
79
  return false;
35
80
  }
36
81
  try {
37
- const held = await fs.stat(file).catch(() => undefined);
38
- if (held === undefined || Date.now() - held.mtimeMs <= staleMs)
82
+ if (!(await reclaimable(file, staleMs, fs, now)))
39
83
  return false;
40
84
  await rm(file, { force: true });
41
85
  return true;
@@ -44,18 +88,25 @@ const takeOver = async (file, staleMs, fs) => {
44
88
  await rm(guard, { force: true });
45
89
  }
46
90
  };
47
- export const withSendLock = async (outbox, run, times = {}, fs = { stat }) => {
48
- const { staleMs, waitMs, pollMs } = { ...DEFAULT_TIMES, ...times };
91
+ export const withSendLock = async (outbox, run, times = {}, fs = { stat }, wait = {}) => {
92
+ const { staleMs, waitMs, pollMs, noticeMs } = { ...DEFAULT_TIMES, ...times };
49
93
  const file = join(outbox, 'send.lock');
50
94
  const runId = randomUUID();
51
95
  const create = fs.open ?? open;
52
96
  const platform = fs.platform ?? process.platform;
97
+ const clock = fs.clock ?? { now: () => Date.now(), sleep: (ms) => sleep(ms) };
98
+ const self = fs.self ?? { pid: process.pid, host: hostname() };
53
99
  await mkdir(outbox, { recursive: true });
54
- const giveUp = Date.now() + waitMs;
100
+ const began = clock.now();
101
+ const giveUp = began + waitMs;
102
+ let noticed = false;
55
103
  for (;;) {
104
+ if (wait.signal?.aborted === true)
105
+ throw wait.signal.reason;
56
106
  try {
57
107
  const handle = await create(file, 'wx');
58
- await handle.writeFile(runId);
108
+ const holder = { run: runId, pid: self.pid, host: self.host, since: clock.now() };
109
+ await handle.writeFile(JSON.stringify(holder));
59
110
  await handle.close();
60
111
  break;
61
112
  }
@@ -65,11 +116,19 @@ export const withSendLock = async (outbox, run, times = {}, fs = { stat }) => {
65
116
  }
66
117
  const held = await fs.stat(file).catch(() => undefined);
67
118
  // A takeover that cleared the lock goes straight back to taking it; any other outcome waits, and gives up once waitMs has passed.
68
- if (held !== undefined && Date.now() - held.mtimeMs > staleMs && (await takeOver(file, staleMs, fs)))
119
+ if (held !== undefined && (await reclaimable(file, staleMs, fs, clock.now)) && (await takeOver(file, staleMs, fs, clock.now)))
69
120
  continue;
70
- if (Date.now() >= giveUp)
71
- throw new SendLockBusy();
72
- await sleep(pollMs);
121
+ const at = clock.now();
122
+ const holder = await holderOf(file);
123
+ const untouchedMs = held === undefined ? 0 : Math.max(0, at - held.mtimeMs);
124
+ const ageMs = holder === undefined ? untouchedMs : Math.max(0, at - holder.since);
125
+ if (!noticed && at - began >= noticeMs) {
126
+ noticed = true;
127
+ wait.onWait?.({ holder, ageMs, waitMs });
128
+ }
129
+ if (at >= giveUp)
130
+ throw new SendLockBusy({ holder, ageMs, untouchedMs, waitedMs: at - began, staleMs, local: holder?.host === self.host });
131
+ await clock.sleep(pollMs);
73
132
  }
74
133
  const beat = setInterval(() => {
75
134
  const now = new Date();
@@ -81,7 +140,46 @@ export const withSendLock = async (outbox, run, times = {}, fs = { stat }) => {
81
140
  finally {
82
141
  clearInterval(beat);
83
142
  // The file is ours only while it still names this run; a lock another run took over is theirs.
84
- if ((await readFile(file, 'utf8').catch(() => undefined)) === runId)
143
+ if ((await holderOf(file))?.run === runId)
85
144
  await rm(file, { force: true });
86
145
  }
87
146
  };
147
+ /** Why an interactive send ended before it finished: a signal reached it, or the process that launched it is gone. */
148
+ export class SendInterrupted extends Error {
149
+ kind;
150
+ constructor(kind, message) {
151
+ super(message);
152
+ this.kind = kind;
153
+ this.name = 'SendInterrupted';
154
+ }
155
+ }
156
+ const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
157
+ /**
158
+ * The signal an interactive `oars pool deposit --send` waits and sends under. Ctrl-C, a closed terminal, and a kill all end it. A launcher that is killed without a signal reaching its child (Windows `taskkill` of the
159
+ * shell or the `oars.cmd` shim, a `timeout` wrapper) leaves the child running, so the launcher's pid is read every second and a run whose launcher is gone ends too. The detached nightly sender is not watched:
160
+ * it is meant to outlive its starter.
161
+ */
162
+ export const watchInterrupt = (host = { on: (event, listener) => process.on(event, listener), off: (event, listener) => process.off(event, listener), parent: process.ppid, running, checkMs: 1000 }) => {
163
+ const controller = new AbortController();
164
+ const listeners = SIGNALS.map((name) => [
165
+ name,
166
+ () => {
167
+ controller.abort(new SendInterrupted('signal', `Interrupted by ${name}.`));
168
+ },
169
+ ]);
170
+ for (const [name, listener] of listeners)
171
+ host.on(name, listener);
172
+ const timer = setInterval(() => {
173
+ if (!host.running(host.parent))
174
+ controller.abort(new SendInterrupted('launcher', 'The program that started this send is gone.'));
175
+ }, host.checkMs);
176
+ timer.unref();
177
+ return {
178
+ signal: controller.signal,
179
+ stop: () => {
180
+ clearInterval(timer);
181
+ for (const [name, listener] of listeners)
182
+ host.off(name, listener);
183
+ },
184
+ };
185
+ };
@@ -313,11 +313,12 @@ export declare const SESSION_IDLE_HOURS = 6;
313
313
  /** No session leaves this machine sooner than this after it ends, whatever `session_idle_hours` is: time for `oars sessions deposit --deny` or a delete to act on it first. Today the longer idle window governs; this is the floor under it. */
314
314
  export declare const HOLD_WINDOW_MS: number;
315
315
  /**
316
- * Whether a session's batch may be built at `now`: no transcript of it, main or subagent, has gained a line for `SESSION_IDLE_HOURS`. A transcript gains a line when capture sees it change, which
317
- * stamps a new `rev` in epoch milliseconds, but a transcript can grow after the last capture, so `written` (the newest write to any raw file of the session, 0 when none is known) counts too: the
318
- * session was last active at the later of the two. A subagent that died without an end marker holds its session back at most that long.
316
+ * Whether a session's batch may be built at `now`: no transcript of it, main or subagent, has gained a line for `SESSION_IDLE_HOURS`. A transcript was last active at its own last line (`tsLast`, the
317
+ * session's clock, not ours), or at its capture (`rev`, epoch milliseconds) when it has none, so a store captured a minute ago from days-old transcripts holds sessions that ended days ago. A transcript can
318
+ * also grow after the last capture, so `written` (the newest write to any raw file of the session, 0 when none is known) counts when it is later than every capture: a write at or before the capture is the
319
+ * file arriving, not the session going on. A last line later than the capture is a bad clock, and the capture caps it. A person's build (`sender` false) takes the ended session at once. The sender, which builds and sends in one run, also waits `HOLD_WINDOW_MS` after the newest capture, so a session oars is seeing for the first time (a days-old transcript of a new harness) leaves no sooner than an hour later, time for `--deny` or a delete. A subagent that died without an end marker holds its session back at most that long.
319
320
  */
320
- export declare const hasEnded: (records: SessionRecords, now: number, written: number, idleMs?: number) => boolean;
321
+ export declare const hasEnded: (records: SessionRecords, now: number, written: number, idleMs?: number, sender?: boolean) => boolean;
321
322
  /** Every main session of a store, with its records. */
322
323
  export declare const sessionsOf: (store: {
323
324
  sessions: readonly SessionObservation[];
@@ -89,11 +89,21 @@ const HOUR_MS = 60 * 60 * 1000;
89
89
  /** No session leaves this machine sooner than this after it ends, whatever `session_idle_hours` is: time for `oars sessions deposit --deny` or a delete to act on it first. Today the longer idle window governs; this is the floor under it. */
90
90
  export const HOLD_WINDOW_MS = HOUR_MS;
91
91
  /**
92
- * Whether a session's batch may be built at `now`: no transcript of it, main or subagent, has gained a line for `SESSION_IDLE_HOURS`. A transcript gains a line when capture sees it change, which
93
- * stamps a new `rev` in epoch milliseconds, but a transcript can grow after the last capture, so `written` (the newest write to any raw file of the session, 0 when none is known) counts too: the
94
- * session was last active at the later of the two. A subagent that died without an end marker holds its session back at most that long.
92
+ * Whether a session's batch may be built at `now`: no transcript of it, main or subagent, has gained a line for `SESSION_IDLE_HOURS`. A transcript was last active at its own last line (`tsLast`, the
93
+ * session's clock, not ours), or at its capture (`rev`, epoch milliseconds) when it has none, so a store captured a minute ago from days-old transcripts holds sessions that ended days ago. A transcript can
94
+ * also grow after the last capture, so `written` (the newest write to any raw file of the session, 0 when none is known) counts when it is later than every capture: a write at or before the capture is the
95
+ * file arriving, not the session going on. A last line later than the capture is a bad clock, and the capture caps it. A person's build (`sender` false) takes the ended session at once. The sender, which builds and sends in one run, also waits `HOLD_WINDOW_MS` after the newest capture, so a session oars is seeing for the first time (a days-old transcript of a new harness) leaves no sooner than an hour later, time for `--deny` or a delete. A subagent that died without an end marker holds its session back at most that long.
95
96
  */
96
- export const hasEnded = (records, now, written, idleMs = SESSION_IDLE_HOURS * HOUR_MS) => now - Math.max(written, ...records.transcripts.map(({ session }) => session.rev)) >= Math.max(idleMs, HOLD_WINDOW_MS);
97
+ export const hasEnded = (records, now, written, idleMs = SESSION_IDLE_HOURS * HOUR_MS, sender = false) => {
98
+ const lastLines = records.transcripts.map(({ session }) => {
99
+ const ts = session.tsLast === null ? Number.NaN : Date.parse(session.tsLast);
100
+ return Number.isNaN(ts) ? session.rev : Math.min(ts, session.rev);
101
+ });
102
+ const captured = Math.max(...records.transcripts.map(({ session }) => session.rev));
103
+ if (sender && now - captured < HOLD_WINDOW_MS)
104
+ return false;
105
+ return now - Math.max(written > captured ? written : 0, ...lastLines) >= Math.max(idleMs, HOLD_WINDOW_MS);
106
+ };
97
107
  const groupBy = (items, keyOf) => {
98
108
  const groups = new Map();
99
109
  for (const item of items) {
@@ -1,6 +1,6 @@
1
1
  // Interaction (spec Section 6, order 2): a tool fails more on one tier than another, beyond how those two tiers differ on every other tool. The difference of differences is
2
2
  // what makes it an interaction: a tier that fails more on everything has a main effect, usually the harder work it is given. The axis is the tier assigned, not a model version
3
- // (ruling mrd5591-08: one version per model on this store), and comparisons hold the version bucket constant. Effort is not in a cell (rule 3), so nothing here holds it. Confounders it does not remove: task assignment by
3
+ // (ruling owner-08: one version per model on this store), and comparisons hold the version bucket constant. Effort is not in a cell (rule 3), so nothing here holds it. Confounders it does not remove: task assignment by
4
4
  // tier, which is the operator's own choice and the largest of them, effort, prompt class, and a failure not being a misfit.
5
5
  import { UNKNOWN } from './cells.js';
6
6
  import { candidateOf, differenceOf, estimateOf, screen, TRIALS_FLOOR } from './read-order.js';
@@ -1,5 +1,5 @@
1
1
  // Lead-lag (spec Section 6, order 3), the labeled experiment: does a tool's failure rate moving come before its share moving? Chosen over a model release moving tool choice
2
- // because each model has one version here with no release date (ruling mrd5591-08), and two version boundaries are too few to cut in time, while consecutive days over many tools
2
+ // because each model has one version here with no release date (ruling owner-08), and two version boundaries are too few to cut in time, while consecutive days over many tools
3
3
  // can be cut. A search over pairs of tools was rejected: it manufactures the false patterns Section 6 warns about, and this is one question asked once.
4
4
  //
5
5
  // A pair shares no day between its sides. A failure move ending on day d is paired with the share move from d+1 to d+2, not d to d+1: a burst of failing calls on d lifts both the
@@ -1,4 +1,4 @@
1
- // What this store cannot read, stated rather than forced (ruling mrd5591-08). A family that finds nothing has made no claim about why, so a reader of an empty family could take
1
+ // What this store cannot read, stated rather than forced (ruling owner-08). A family that finds nothing has made no claim about why, so a reader of an empty family could take
2
2
  // "no reads" for "nothing moved". These rows separate the two: a question the store cannot ask from one it asked and got no answer to.
3
3
  import { interactionCandidates } from './read-interaction.js';
4
4
  import { leadLagPairs, PAIRS_FLOOR } from './read-lead-lag.js';
@@ -8,10 +8,19 @@ export declare const makeRedactor: (domains?: readonly string[]) => Redactor;
8
8
  export declare const placeholders: (text: string) => number;
9
9
  /** The names this machine's person goes by in a transcript: the account's and the home folder's. A name under three characters would mask ordinary words, so it is left to the path rules. */
10
10
  export declare const localNames: (home?: string, account?: () => string) => string[];
11
+ /** What only a sample pack asks of the raw-copy rules (t1111). */
12
+ export interface PackRules {
13
+ /** The accounts the operator's repositories live under (a GitHub user or organization): masked as `<owner>` where one stands as a path segment or after a `:` of a remote. */
14
+ handles?: readonly string[];
15
+ /** Rewrites a tool use id (`toolu_...`, `srvtoolu_...`) to the value the pack keys it to, so a call and its result still pair; without it the entropy rule masks each as `<secret>`. */
16
+ toolId?: (id: string) => string;
17
+ /** Keeps a public host the session itself fetched or searched (the URL or `allowed_domains` of a WebFetch or WebSearch input) in the clear in that call's result. */
18
+ fetchedHosts?: boolean;
19
+ }
11
20
  /** A whole transcript body redacted for upload, and how many values were masked (placeholders added, a count and nothing else). `names` are the local usernames to mask wherever they stand as a whole word. */
12
- export declare const redactTranscript: (body: string, domains?: readonly string[], names?: readonly string[], injectedKey?: string) => {
21
+ export declare const redactTranscript: (body: string, domains?: readonly string[], names?: readonly string[], injectedKey?: string, pack?: PackRules) => {
13
22
  text: string;
14
23
  masked: number;
15
24
  };
16
25
  /** The version of the rules above, stated to the pool with every raw part; a change to any rule of `redactTranscript` raises it. */
17
- export declare const REDACTION_VERSION = "3";
26
+ export declare const REDACTION_VERSION = "4";
@@ -8,25 +8,30 @@ import { injectedRule, learnInjected } from './injected.js';
8
8
  export const PATTERNS = [
9
9
  [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, '<secret>'],
10
10
  [/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, '<secret>'],
11
- [/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, '<secret>'],
12
- [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, '<secret>'],
11
+ // A synthetic fixture quoted in a session is masked like a real token (a secret scanner flags the prefix, not the value), so the minimums are short and the
12
+ // left guard is a non-alphanumeric: `FAKE_ghp_...` still starts a token where `\b` would miss it.
13
+ [/(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{8,}(?![A-Za-z0-9])/g, '<secret>'],
14
+ [/(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{8,}(?![A-Za-z0-9])/g, '<secret>'],
13
15
  [/\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}\b/g, '<secret>'],
14
- [/\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}\b/g, '<secret>'],
15
- [/\bwhsec_[A-Za-z0-9]{10,}\b/g, '<secret>'],
16
- [/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g, '<secret>'],
16
+ [/(?<![A-Za-z0-9])(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{6,}(?![A-Za-z0-9])/g, '<secret>'],
17
+ [/(?<![A-Za-z0-9])whsec_[A-Za-z0-9]{6,}(?![A-Za-z0-9])/g, '<secret>'],
18
+ [/(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{6,}(?![A-Za-z0-9])/g, '<secret>'],
17
19
  [/\bAIza[0-9A-Za-z_-]{30,}\b/g, '<secret>'],
18
20
  [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '<secret>'],
19
21
  [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/gi, '$1 <secret>'],
20
22
  // user:password@host inside a URL
21
23
  [/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi, '$1<secret>@'],
22
- // NAME=value and "name": "value" where the name says it is a secret
23
- [/\b([A-Za-z0-9_-]*(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)[A-Za-z0-9_-]*)(["']?\s*[=:]\s*)(["']?)[^\s"'&,;]{4,}\3/gi, '$1$2$3<secret>$3'],
24
+ // NAME=value and "name": "value" where the name says it is a secret. A vendor name before a colon (`1password: ...` in prose) is no key. A plural
25
+ // `tokens` name (`tokens`, `max_tokens`, `inputTokens`) is a count and keeps a bare number, as the JSON walk does; any other `token` name masks a number too.
26
+ [/\b(?!1password(?![A-Za-z0-9_-])["']?\s*:\s)([A-Za-z0-9_-]*(?:password|passwd|secret|api[_-]?key|private[_-]?key|credential)[A-Za-z0-9_-]*)(["']?\s*[=:]\s*)(["']?)[^\s"'&,;]{4,}\3/gi, '$1$2$3<secret>$3'],
27
+ [/\b((?![A-Za-z0-9_-]*tokens(?![A-Za-z0-9_-]))[A-Za-z0-9_-]*token[A-Za-z0-9_-]*)(["']?\s*[=:]\s*)(["']?)[^\s"'&,;]{4,}\3/gi, '$1$2$3<secret>$3'],
28
+ [/\b([A-Za-z0-9_-]*tokens)(["']?\s*[=:]\s*)(["']?)(?!\d+(?![^\s"'&,;]))[^\s"'&,;]{4,}\3/gi, '$1$2$3<secret>$3'],
24
29
  ];
25
30
  const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g;
26
31
  // A pinned alias, `foo@npm:bar@1.0.0` or `jsr:@scope/bar@1.0.0`, ends in `bar@1.0.0`, which EMAIL reads as an address. Digits and dots after
27
32
  // the `@` are a version, never a domain, so that one shape right after `npm:` or `jsr:` (and an optional scope) is kept. An all-numeric four-part tail is an
28
33
  // IP address, a real host, so it stays masked (as in `extract.ts` `VERSIONED`); any other address stays masked too.
29
- const ALIAS_PREFIX = /(?<![A-Za-z0-9])(?:npm|jsr):(?:@[A-Za-z0-9._~-]+\/)?$/;
34
+ const ALIAS_PREFIX = /(?<![A-Za-z0-9])(?:npm|jsr):(?:@[A-Za-z0-9._~-]+\/)?$|(?<![A-Za-z0-9._%+-])@[A-Za-z0-9._~-]+\/$/;
30
35
  const DOTTED_VERSION = /@(?!\d+\.\d+\.\d+\.\d+$)\d+(?:\.\d+)+$/;
31
36
  const TOKEN = /[A-Za-z0-9+_=-]{24,}/g;
32
37
  const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -71,7 +76,7 @@ const PROMPT = /(?:<user>|\b[A-Za-z0-9._-]+)@[A-Za-z0-9-]+(?=:[~/.])/g;
71
76
  // Full, compressed, and loopback forms. A clock time (`10:20:30`) has no `::`, so it is never one.
72
77
  const IPV6 = /(?<![0-9A-Za-z:])(?:(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:){1,7}:(?:[0-9a-f]{1,4}(?::[0-9a-f]{1,4}){0,6})?|::[0-9a-f]{1,4}(?::[0-9a-f]{1,4}){0,6})(?![0-9A-Za-z:])/gi;
73
78
  const MIN_NAME = 3;
74
- const PLACEHOLDER = /<(?:secret|email|org-domain|host|user|env)>/g;
79
+ const PLACEHOLDER = /<(?:secret|email|org-domain|host|user|env|owner)>/g;
75
80
  // A `.env` line whose name is lower case (`mapbox=q8z7...`): only a whole line of that shape, so `--flag=value` and `?page=2` are left alone.
76
81
  const LOWER_ENVIRONMENT = /^([ \t]*(?:export[ \t]+)?[a-z][a-z0-9_]{2,}=)(?!<)(?:(["'])[^"'\n]*\2|[^\s"']+)[ \t]*$/gm;
77
82
  // Shapes `makeRedactor` does not know (t955, census S3). They run only on a raw copy, and before the shared rules, because the entropy rule would
@@ -112,7 +117,7 @@ const parseContainer = (text) => {
112
117
  return undefined;
113
118
  }
114
119
  };
115
- const redactDecoded = (body, redactText, injected) => {
120
+ const redactDecoded = (body, redactText, injected, enter) => {
116
121
  // A name reaches the strings of an array under it (`"tokens": ["..."]`); an object's own keys name their values.
117
122
  const walk = (value, name) => {
118
123
  if (typeof value === 'string')
@@ -133,15 +138,27 @@ const redactDecoded = (body, redactText, injected) => {
133
138
  // The text as it was when nothing in it changed; otherwise the value written again, inside the whitespace (a `\r`, a newline) it had around it.
134
139
  // A transcript line is a top-level object: the harness's own blocks in it are marked first (their hash is of the text as written), then the rest is walked.
135
140
  const rewrite = (text, parsed, top) => {
141
+ if (top && !Array.isArray(parsed))
142
+ enter(parsed);
136
143
  const marked = top && !Array.isArray(parsed) ? injected(parsed) : parsed;
137
144
  const next = walk(marked, '');
138
145
  return next === parsed ? text : `${text.slice(0, text.length - text.trimStart().length)}${JSON.stringify(next)}${text.slice(text.trimEnd().length)}`;
139
146
  };
147
+ // A top-level plain-text run follows no tool result: a host a fetch named is kept in that result's line only (a string inside the line is not top level).
148
+ const redactPlain = (run, top) => {
149
+ if (top)
150
+ enter();
151
+ return redactText(run.join('\n'));
152
+ };
140
153
  // `text` is a whole document, or lines of which some are JSON: the JSON ones are walked, the runs between them take the plain-text path.
141
154
  const redactBlock = (text, top = false) => {
142
155
  const whole = parseContainer(text);
143
- if (whole !== undefined)
156
+ // A top-level array line is no tool-result line: it keeps no fetched host.
157
+ if (whole !== undefined) {
158
+ if (top && Array.isArray(whole))
159
+ enter();
144
160
  return rewrite(text, whole, top);
161
+ }
145
162
  const out = [];
146
163
  let run = [];
147
164
  for (const line of text.split('\n')) {
@@ -151,12 +168,12 @@ const redactDecoded = (body, redactText, injected) => {
151
168
  continue;
152
169
  }
153
170
  if (run.length > 0)
154
- out.push(redactText(run.join('\n')));
171
+ out.push(redactPlain(run, top));
155
172
  run = [];
156
173
  out.push(rewrite(line, parsed, top));
157
174
  }
158
175
  if (run.length > 0)
159
- out.push(redactText(run.join('\n')));
176
+ out.push(redactPlain(run, top));
160
177
  return out.join('\n');
161
178
  };
162
179
  return redactBlock(body, true);
@@ -183,15 +200,70 @@ const nameRule = (names) => {
183
200
  const forms = [...new Set(usable.flatMap((name) => [name, name.replace(/[^A-Za-z0-9]/g, '-')]))].sort((a, b) => b.length - a.length);
184
201
  return forms.length === 0 ? undefined : new RegExp(`(?<![A-Za-z0-9<])(?:${forms.map(escapeRegExp).join('|')})(?![A-Za-z0-9>])`, 'gi');
185
202
  };
203
+ const TOOL_ID = /(?<![A-Za-z0-9])(?:srv)?tool(?:u|use)_(?:bdrk_|vrtx_)?[A-Za-z0-9_-]{8,}/g;
204
+ const FETCH_TOOLS = new Set(['WebFetch', 'WebSearch']);
205
+ const HOST_OF_URL = /^[a-z][a-z0-9+.-]*:\/\/([A-Za-z0-9.-]+)/i;
206
+ const URLS = /\b[a-z][a-z0-9+.-]*:\/\/[^\s/@"'\\]*@?[A-Za-z0-9.-]+/gi;
207
+ // Loopback, an IP literal, a bare name, and the internal suffixes of INTERNAL_NAME are never a public host.
208
+ const INTERNAL_SUFFIX = /\.(?:internal|local|lan|corp|intranet|home|localdomain)$/;
209
+ const publicHost = (host) => host.includes('.') && !/^[\d.]+$/.test(host) && !INTERNAL_SUFFIX.test(host) && host !== 'localhost';
210
+ const hostsIn = (value, found, listed = false) => {
211
+ if (typeof value === 'string') {
212
+ for (const url of value.match(URLS) ?? [])
213
+ if (!url.includes('@'))
214
+ found.add((HOST_OF_URL.exec(url)?.[1] ?? '').toLowerCase());
215
+ // `allowed_domains` lists bare domains.
216
+ if (listed)
217
+ found.add(value.toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, '').replace(/[/:].*$/, ''));
218
+ }
219
+ else if (Array.isArray(value))
220
+ for (const item of value)
221
+ hostsIn(item, found, listed);
222
+ else if (typeof value === 'object' && value !== null)
223
+ for (const [key, item] of Object.entries(value))
224
+ hostsIn(item, found, key === 'allowed_domains');
225
+ };
226
+ /** The blocks of a transcript line's message content, when it has any. */
227
+ const blocksOf = (line) => {
228
+ const message = line['message'];
229
+ const content = typeof message === 'object' && message !== null && !Array.isArray(message) ? message['content'] : undefined;
230
+ return Array.isArray(content) ? content.filter((block) => typeof block === 'object' && block !== null && !Array.isArray(block)) : [];
231
+ };
232
+ /** The public hosts each WebFetch or WebSearch call of a transcript named, by the call's tool use id. */
233
+ const fetchedBy = (lines) => {
234
+ const byId = new Map();
235
+ for (const line of lines) {
236
+ for (const block of blocksOf(line)) {
237
+ const { type, id, name, input } = block;
238
+ if (type !== 'tool_use' || typeof id !== 'string' || typeof name !== 'string' || !FETCH_TOOLS.has(name) || input === undefined)
239
+ continue;
240
+ const found = new Set();
241
+ hostsIn(input, found);
242
+ byId.set(id, new Set([...found].filter(publicHost)));
243
+ }
244
+ }
245
+ return byId;
246
+ };
247
+ // A handle is a whole path segment (`/acme/app`, `\acme\app`, `git@host:acme/app.git`) or a hyphen-bounded word of an encoded project folder (`-repos-acme-app`).
248
+ const handleRule = (handles) => {
249
+ const usable = [...new Set(handles.filter((handle) => handle.length >= MIN_NAME))].sort((a, b) => b.length - a.length);
250
+ return usable.length === 0 ? undefined : new RegExp(String.raw `(?<=[\\/:-])(?:${usable.map(escapeRegExp).join('|')})(?=[\\/.-]|["'\s]|$)`, 'gi');
251
+ };
186
252
  /** A whole transcript body redacted for upload, and how many values were masked (placeholders added, a count and nothing else). `names` are the local usernames to mask wherever they stand as a whole word. */
187
- export const redactTranscript = (body, domains = [], names = [], injectedKey = randomBytes(16).toString('hex')) => {
253
+ export const redactTranscript = (body, domains = [], names = [], injectedKey = randomBytes(16).toString('hex'), pack = {}) => {
188
254
  const named = nameRule(names);
255
+ const owner = handleRule(pack.handles ?? []);
189
256
  const known = makeRedactor(domains);
190
- const redactText = (text) => {
257
+ // The hosts the line being redacted may keep: those its tool results' calls fetched.
258
+ let kept = new Set();
259
+ const keepHost = (match, scheme) => (!match.includes('@') && kept.has((HOST_OF_URL.exec(match)?.[1] ?? '').toLowerCase()) ? match : `${scheme}<host>`);
260
+ const redactText = (original) => {
261
+ const text = pack.toolId === undefined ? original : original.replace(TOOL_ID, pack.toolId);
191
262
  // The names go first: a hyphenated or spaced name must be masked whole before the path rules take its first word.
192
263
  const masked = known(rawRules(text));
193
- return (named === undefined ? masked : masked.replace(named, '<user>'))
194
- .replace(URL_HOST, '$1<host>')
264
+ const mine = named === undefined ? masked : masked.replace(named, '<user>');
265
+ return (owner === undefined ? mine : mine.replace(owner, '<owner>'))
266
+ .replace(URL_HOST, keepHost)
195
267
  .replace(INTERNAL_NAME, '<host>')
196
268
  .replace(IPV4, '<host>')
197
269
  .replace(IPV6, '<host>')
@@ -208,8 +280,12 @@ export const redactTranscript = (body, domains = [], names = [], injectedKey = r
208
280
  for (const parsed of whole !== undefined ? [whole] : body.split('\n').map(parseContainer))
209
281
  if (parsed !== undefined && !Array.isArray(parsed))
210
282
  learnInjected(parsed, repeated);
211
- const redacted = redactDecoded(body, redactText, injectedRule(injectedKey, repeated));
283
+ const calls = pack.fetchedHosts === true ? fetchedBy([...(whole !== undefined ? [whole] : body.split('\n').map(parseContainer))].filter((parsed) => parsed !== undefined && !Array.isArray(parsed))) : new Map();
284
+ const enter = (line) => {
285
+ kept = new Set(line === undefined ? [] : blocksOf(line).flatMap((block) => (block['type'] === 'tool_result' && typeof block['tool_use_id'] === 'string' ? [...(calls.get(block['tool_use_id']) ?? [])] : [])));
286
+ };
287
+ const redacted = redactDecoded(body, redactText, injectedRule(injectedKey, repeated), enter);
212
288
  return { text: redacted, masked: Math.max(0, placeholders(redacted) - placeholders(body)) };
213
289
  };
214
290
  /** The version of the rules above, stated to the pool with every raw part; a change to any rule of `redactTranscript` raises it. */
215
- export const REDACTION_VERSION = '3';
291
+ export const REDACTION_VERSION = '4';
@@ -2,6 +2,13 @@
2
2
  export declare const makeRepoRoot: (exists?: (path: string) => boolean) => ((cwd: string) => string | null);
3
3
  /** `https://User:pw@GitHub.com/org/repo.git`, `git@github.com:org/repo.git`, and `ssh://git@github.com/org/repo/` all read `github.com/org/repo`. */
4
4
  export declare const normalizeRemote: (url: string) => string;
5
+ /** The `url` of `[remote "origin"]` in a git `config`, or null. */
6
+ export declare const originUrl: (config: string) => string | null;
7
+ /**
8
+ * The git `config` text of a repository root, or null: `<root>/.git/config`, or for a linked worktree or submodule (`.git` is a file)
9
+ * the common directory's, reached through the file's `gitdir:` line and that directory's `commondir` file.
10
+ */
11
+ export declare const configOf: (root: string, read?: (path: string) => string | null) => string | null;
5
12
  /**
6
13
  * A resolver from a repository root to its identity string: `origin:<normalized remote>` when the repository's `config` names an
7
14
  * `origin`, else `path:<root>`. The `config` is `<root>/.git/config`, or for a linked worktree (`.git` is a file) the common