tickmarkr 2.7.0 → 2.7.1
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/dist/adapters/prompt.js +1 -1
- package/dist/adapters/registry.js +19 -3
- package/dist/adapters/types.d.ts +12 -1
- package/dist/adapters/types.js +20 -2
- package/dist/compile/gsd.js +3 -2
- package/dist/compile/native.js +3 -2
- package/dist/drivers/herdr.d.ts +2 -1
- package/dist/drivers/herdr.js +5 -0
- package/dist/drivers/orca.d.ts +2 -1
- package/dist/drivers/orca.js +5 -0
- package/dist/drivers/subprocess.d.ts +2 -1
- package/dist/drivers/subprocess.js +15 -0
- package/dist/drivers/types.d.ts +8 -0
- package/dist/gates/acceptance.d.ts +1 -0
- package/dist/gates/acceptance.js +5 -4
- package/dist/gates/artifact-manifest.js +3 -29
- package/dist/gates/git-paths.d.ts +18 -0
- package/dist/gates/git-paths.js +69 -0
- package/dist/gates/scope.js +3 -1
- package/dist/run/daemon.d.ts +1 -1
- package/dist/run/daemon.js +43 -7
- package/dist/run/git-trust.d.ts +20 -1
- package/dist/run/git-trust.js +32 -11
- package/dist/run/stall.js +1 -1
- package/package.json +1 -1
package/dist/adapters/prompt.js
CHANGED
|
@@ -129,7 +129,7 @@ export function parseWorkerResult(raw, nonce) {
|
|
|
129
129
|
return fail(cause === "malformed-verdict" ? UNPARSEABLE_TRAILER_SUMMARY : NO_TRAILER_SUMMARY, cause);
|
|
130
130
|
}
|
|
131
131
|
// Signatures anchor distinctive CLI-error phrasing — never bare fragments or bare status-code
|
|
132
|
-
// numbers (the
|
|
132
|
+
// numbers (the quota classifier's Pitfall-3 lesson): these run only over no-trailer output, but a stalled
|
|
133
133
|
// worker's harvested pane can still contain ordinary work text. E1: the recorded refresh failure
|
|
134
134
|
// ("access token could not be refreshed") is auth-required — classified, never re-probed or relaunched.
|
|
135
135
|
const AUTH_RE = /not logged in|please (?:log ?in|sign in)|please run [^\n]{0,30}log ?in|authentication[ _](?:required|failed|error)|invalid (?:api key|credentials)|api key (?:is )?(?:not set|missing|invalid|required)|credentials? (?:have )?expired|401 unauthorized|access token could not be refreshed/i;
|
|
@@ -11,7 +11,7 @@ import { FakeAdapter } from "./fake.js";
|
|
|
11
11
|
import { parseWorkerResult } from "./prompt.js";
|
|
12
12
|
import { sealHerdrEnv } from "../drivers/subprocess.js";
|
|
13
13
|
import { catalogEntries, isNativeCliDrive, projectCliEntries, SHIPPED_CLI_CATALOG, } from "./catalog.js";
|
|
14
|
-
import { channelKey, channelsFromConfig, modelAuthed, MODEL_ID_RE, MODEL_PROBE_ERRORS,
|
|
14
|
+
import { channelKey, channelsFromConfig, modelAuthed, MODEL_ID_RE, MODEL_PROBE_ERRORS, QUOTA_PHRASE_RE, shq, STATUS_LABEL, } from "./types.js";
|
|
15
15
|
// Compatibility projection for callers/tests that only need shipped advisory names. The literal
|
|
16
16
|
// list is gone: both advisory and routable catalog views derive from SHIPPED_CLI_CATALOG. Native
|
|
17
17
|
// definitions (claudeCode, codex, cursorAgent, opencode, pi, grok, kimi) are owned by catalog.ts;
|
|
@@ -389,7 +389,23 @@ const MODEL_PROBE_TIMEOUT_MS = 60000;
|
|
|
389
389
|
// probe of any adapter that prints a comma-grouped token count. Lookarounds reject a group that is part of a
|
|
390
390
|
// larger number (26,485 · 1.400 · 400.5) while keeping real bare codes (401 · "error 429" · "403 Forbidden."
|
|
391
391
|
// — trailing sentence punctuation still matches, only a following DIGIT means "decimal, not status code").
|
|
392
|
-
|
|
392
|
+
// Queue row 20 (D-1148 (1)): a bare number is never a status code. qwen's healthy probe telemetry
|
|
393
|
+
// `"totalGenerationDurationMs":438` read as an auth failure and dropped a working channel, so a 4xx counts only
|
|
394
|
+
// where the output labels it as a status — `HTTP 401`, `HTTP/1.1 403`, `status 401`, `status code 401`,
|
|
395
|
+
// `"statusCode":401`, `"httpStatus":401`, `httpStatusCode: 403`, `errorCode: 401`, `code: 403`, `Error: 401`. A
|
|
396
|
+
// label matches only as a WHOLE token — no letter of any script, combining mark, digit or underscore before it:
|
|
397
|
+
// `unicode`, `barcode`, `standardError`, `zipCode`, `foo_code`, `éstatus` are not labels; punctuation such as `“` or
|
|
398
|
+
// `—` is no letter — so compound labels are enumerated (the `u` flag is what lets `\p{…}` name every script). The
|
|
399
|
+
// label only NARROWS the base's bare-4xx match (both `\b`s and the no-decimal/grouping tail stay), so nothing the
|
|
400
|
+
// base read as authed fails here; the base's digit-before guard is implied by the `\b` after a label. The word
|
|
401
|
+
// branches are unchanged.
|
|
402
|
+
// The labelled 4xx needs the `u` flag (\p{…} in STATUS_LABEL). The auth-word branches keep the base's bytes and its
|
|
403
|
+
// "i" flag: under "iu" case folding maps ſ (U+017F) → s and K (U+212A) → k, so `unauthoriſed` would read as a failure
|
|
404
|
+
// the base never read (D-1582).
|
|
405
|
+
const LABELLED_4XX_RE = new RegExp(String.raw `${STATUS_LABEL}\b4\d\d\b(?![.,]\d)`, "iu");
|
|
406
|
+
const AUTH_WORDS_RE = /\bauth(?:entication|orization)?\s+(?:error|failed|failure|denied)|unauthori[sz]ed|forbidden|access denied|credit(?:s)?\s+(?:exhausted|error|denied)/i;
|
|
407
|
+
// The probe's quota reading is the shared QUOTA_PHRASE_RE (types.ts, queue row 106): a labelled 429 is a labelled 4xx
|
|
408
|
+
// above, and an unlabelled `429 Too Many Requests` keeps failing on its phrase.
|
|
393
409
|
const PROBE_REASON_CAP = 240;
|
|
394
410
|
// v1.55 T3: the tail must open at a word boundary — a mid-word slice ("odel 'grok-…'") reads as
|
|
395
411
|
// corruption in operator-facing diagnostics. Input is already space-normalized, so " " is the only
|
|
@@ -421,7 +437,7 @@ function probeFailure(code, stdout, stderr, timedOut, timeoutMs = MODEL_PROBE_TI
|
|
|
421
437
|
const output = `${stderr}\n${stdout}`.trim().replace(/\s+/g, " ");
|
|
422
438
|
// OBS-72: TAIL, not head — the error lands at the END of CLI output; a head slice stores only the
|
|
423
439
|
// startup banner and hid the real "Not inside a trusted directory" failure for a day.
|
|
424
|
-
return code !== 0 ||
|
|
440
|
+
return code !== 0 || QUOTA_PHRASE_RE.test(output) || LABELLED_4XX_RE.test(output) || AUTH_WORDS_RE.test(output)
|
|
425
441
|
? reasonTail(output) || `probe exited ${code}`
|
|
426
442
|
: undefined;
|
|
427
443
|
}
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -237,5 +237,16 @@ export declare const PROMPT_ARGV_CEILING_DEFAULT = 900000;
|
|
|
237
237
|
export declare function promptArgvCeiling(platform?: string): number;
|
|
238
238
|
export declare function promptFitsArgv(promptFile: string, platform?: string): boolean;
|
|
239
239
|
export declare const CAPACITY_RE: RegExp;
|
|
240
|
-
export declare const
|
|
240
|
+
export declare const QUOTA_PHRASE_RE: RegExp;
|
|
241
|
+
export declare const STATUS_LABEL: string;
|
|
242
|
+
export declare const LABELLED_429_RE: RegExp;
|
|
243
|
+
export type QuotaSignal = RegExpExecArray & {
|
|
244
|
+
readonly source: string;
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* The LEFTMOST quota signal in `text` — a phrase or a labelled 429 — with the source of the regex that matched (the
|
|
248
|
+
* quota-banner record names it, so a false positive stays legible from the record alone), or null. A phrase and a
|
|
249
|
+
* label never start at the same index (phrase initials r q u o i t, label initials h e s c); a tie would go to the phrase.
|
|
250
|
+
*/
|
|
251
|
+
export declare function quotaSignal(text: string): QuotaSignal | null;
|
|
241
252
|
export declare const MODEL_ID_RE: RegExp;
|
package/dist/adapters/types.js
CHANGED
|
@@ -302,7 +302,7 @@ export function promptFitsArgv(promptFile, platform = process.platform) {
|
|
|
302
302
|
// on ordinary billing/wallet task output the harness edits (research Pitfall 3, 2026-07-10).
|
|
303
303
|
// OBS-1161: transient provider capacity ("Selected model is at capacity") is NOT quota — the seat
|
|
304
304
|
// comes back within minutes, so it takes bounded same-seat backoff before a same-floor failover and
|
|
305
|
-
// never permanent demotion. Kept apart from
|
|
305
|
+
// never permanent demotion. Kept apart from quotaSignal so permanent quota policy stays untouched, and
|
|
306
306
|
// anchored on the full phrase (the Pitfall-3 lesson: a bare "capacity" fires on ordinary work text).
|
|
307
307
|
// A banner is the CLI speaking, so the phrase must OPEN its row — after at most an error glyph
|
|
308
308
|
// (codex paints "■ Selected model is at capacity. …") or an "error:" label. Anything else in front —
|
|
@@ -310,7 +310,25 @@ export function promptFitsArgv(promptFile, platform = process.platform) {
|
|
|
310
310
|
// ponytail: row-start grammar, not per-adapter fixtures — the ceiling is a worker whose own output
|
|
311
311
|
// opens a row with the bare phrase inside the banner tail; widen the glyph set only from a captured frame.
|
|
312
312
|
export const CAPACITY_RE = /^[ \t■⚠✗✘×!]*(?:(?:stream )?error:[ \t]*)?(?:(?:selected |the )?model is (?:currently |temporarily )?at capacity|(?:provider|service|server) is (?:currently )?at capacity)/im;
|
|
313
|
-
|
|
313
|
+
// Queue row 106 (D-1597): a bare 429 is never a quota signal. Every bare 429 in the captured corpus was a line number
|
|
314
|
+
// or a count — `429:` in a diff listing failed a pinned codex worker over 41 985 bytes before EOF (OBS-926) — and the
|
|
315
|
+
// one real 429 banner also carries a phrase and a labelled status. Quota is a PHRASE, or a 429 the output LABELS.
|
|
316
|
+
export const QUOTA_PHRASE_RE = /rate.?limit|quota|usage limit|out of credits|insufficient credit|insufficient balance or no resource|too many requests/i;
|
|
317
|
+
// A whole-token status label (row 20, D-1581): no letter of any script, combining mark, digit or underscore before it;
|
|
318
|
+
// compound labels enumerated. Its \p{} classes need the "u" flag, kept on the regexes that use it — under "iu" case
|
|
319
|
+
// folding maps ſ → s and K → k, so it never reaches the phrases above (D-1582). Shared with the model probe.
|
|
320
|
+
export const STATUS_LABEL = String.raw `(?<![\p{L}\p{M}\p{N}_])(?:http[_-]?status(?:[_-]?code)?|error[_-]?code|HTTP(?:\/\d(?:\.\d)?)?|status(?:[\s_-]*code)?|code|error)["']?\s*[:=]?\s*["']?`;
|
|
321
|
+
export const LABELLED_429_RE = new RegExp(String.raw `${STATUS_LABEL}\b429\b(?![.,]\d)`, "iu");
|
|
322
|
+
/**
|
|
323
|
+
* The LEFTMOST quota signal in `text` — a phrase or a labelled 429 — with the source of the regex that matched (the
|
|
324
|
+
* quota-banner record names it, so a false positive stays legible from the record alone), or null. A phrase and a
|
|
325
|
+
* label never start at the same index (phrase initials r q u o i t, label initials h e s c); a tie would go to the phrase.
|
|
326
|
+
*/
|
|
327
|
+
export function quotaSignal(text) {
|
|
328
|
+
const phrase = QUOTA_PHRASE_RE.exec(text), label = LABELLED_429_RE.exec(text);
|
|
329
|
+
const [match, re] = phrase && (!label || phrase.index <= label.index) ? [phrase, QUOTA_PHRASE_RE] : label ? [label, LABELLED_429_RE] : [null, null];
|
|
330
|
+
return match && re ? Object.assign(match, { source: re.source }) : null;
|
|
331
|
+
}
|
|
314
332
|
// v1.5 MODEL-01: charset gate for detected model ids (research Pitfall 4, verified 2026-07-10).
|
|
315
333
|
// Ids come from CLI stdout / another program's JSON (models_cache.json) and are echoed into
|
|
316
334
|
// operator-facing lint text and persisted to doctor.json — defense-in-depth for MODEL-05 (config
|
package/dist/compile/gsd.js
CHANGED
|
@@ -307,14 +307,15 @@ export function compileGsd(src, root) {
|
|
|
307
307
|
// snapshot; classifyContextPath remains the single authority for missing, untracked, and glob refs.
|
|
308
308
|
const gitDir = dirname(files[0]);
|
|
309
309
|
const top = spawnSync("git", ["-C", gitDir, "rev-parse", "--show-toplevel"], { encoding: "utf8", maxBuffer: 1 << 28 });
|
|
310
|
-
|
|
310
|
+
// -z (queue row 71): without it a non-ASCII path is C-quoted and a tracked ref reads as untracked
|
|
311
|
+
const tree = spawnSync("git", ["-C", gitDir, "ls-tree", "--full-tree", "-r", "--name-only", "-z", "HEAD"], { encoding: "utf8", maxBuffer: 1 << 28 });
|
|
311
312
|
const repoRoot = typeof top.stdout === "string" ? top.stdout.trim() : "";
|
|
312
313
|
// `root` is the repository whose worktrees will consume these refs. A source nested inside some
|
|
313
314
|
// unrelated repository (as with vendored fixtures) must not be judged against that outer tree.
|
|
314
315
|
const authoritative = root === undefined
|
|
315
316
|
|| (top.status === 0 && repoRoot !== "" && realpathSync(root) === realpathSync(repoRoot));
|
|
316
317
|
if (top.status === 0 && tree.status === 0 && repoRoot && typeof tree.stdout === "string" && authoritative) {
|
|
317
|
-
const tracked = new Set(tree.stdout.split("\
|
|
318
|
+
const tracked = new Set(tree.stdout.split("\0").filter(Boolean));
|
|
318
319
|
const unreachable = [];
|
|
319
320
|
for (const [i, t] of tasks.entries()) {
|
|
320
321
|
for (const entry of compiled[i].refs) {
|
package/dist/compile/native.js
CHANGED
|
@@ -23,11 +23,12 @@ function trackedAtHead(dir) {
|
|
|
23
23
|
// --full-tree is load-bearing: without it `ls-tree` scopes to the CWD's subtree and prints paths
|
|
24
24
|
// relative to it, so a spec in specs/ would see only specs/-relative names and misjudge every
|
|
25
25
|
// path outside it. Caught only by a fixture whose spec is NOT at the repo root.
|
|
26
|
-
|
|
26
|
+
// -z (queue row 71): without it a non-ASCII path is C-quoted and a tracked file reads as untracked
|
|
27
|
+
const r = git("ls-tree", "--full-tree", "-r", "--name-only", "-z", "HEAD");
|
|
27
28
|
// not a repo, or no commits yet — fail open, so non-repo fixtures and fresh inits stay silent
|
|
28
29
|
if (top.status !== 0 || r.status !== 0 || typeof r.stdout !== "string")
|
|
29
30
|
return undefined;
|
|
30
|
-
return { root: top.stdout.trim(), tracked: new Set(r.stdout.split("\
|
|
31
|
+
return { root: top.stdout.trim(), tracked: new Set(r.stdout.split("\0").filter(Boolean)) };
|
|
31
32
|
}
|
|
32
33
|
const GLOB_CHARS = /[*?{[]/;
|
|
33
34
|
/**
|
package/dist/drivers/herdr.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type JournalEvent } from "../run/journal.js";
|
|
2
|
-
import { type ExecutorDriver, type FocusTarget, type FocusResult, type NotifyOpts, type PanesToCloseOpts, type Slot, type SlotOpts } from "./types.js";
|
|
2
|
+
import { type ExecutorDriver, type FocusTarget, type TransportState, type FocusResult, type NotifyOpts, type PanesToCloseOpts, type Slot, type SlotOpts } from "./types.js";
|
|
3
3
|
export declare const TRAILER_SAFE_FLOOR_COLS = 108;
|
|
4
4
|
export declare const TRAILER_WIDTH_MARGIN = 2;
|
|
5
5
|
export declare const DELIVERY_ATTEMPTS = 3;
|
|
@@ -134,6 +134,7 @@ export declare class HerdrDriver implements ExecutorDriver {
|
|
|
134
134
|
waitAgentStatus(slot: Slot, status: string, timeoutMs: number): Promise<boolean>;
|
|
135
135
|
status(slot: Slot): Promise<string>;
|
|
136
136
|
private statusByName;
|
|
137
|
+
transportState(_slot: Slot): Promise<TransportState>;
|
|
137
138
|
read(slot: Slot, lines: number): Promise<string>;
|
|
138
139
|
sendKey(slot: Slot, key: string): Promise<void>;
|
|
139
140
|
notify(msg: string, opts?: NotifyOpts): Promise<void>;
|
package/dist/drivers/herdr.js
CHANGED
|
@@ -1142,6 +1142,11 @@ export class HerdrDriver {
|
|
|
1142
1142
|
return "unknown";
|
|
1143
1143
|
}
|
|
1144
1144
|
}
|
|
1145
|
+
// Queue row 108: a pane's transport closes when the pane is gone, which the dead-worker park already requires
|
|
1146
|
+
// (an empty read), so the answer is closed — parity with the park before row 108, never a new hold.
|
|
1147
|
+
async transportState(_slot) {
|
|
1148
|
+
return { closed: true };
|
|
1149
|
+
}
|
|
1145
1150
|
async read(slot, lines) {
|
|
1146
1151
|
const pane = await this.paneId(slot);
|
|
1147
1152
|
const r = await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${lines}`, slot.cwd);
|
package/dist/drivers/orca.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ShResult } from "../run/git.js";
|
|
2
2
|
import { type JournalEvent } from "../run/journal.js";
|
|
3
3
|
import { type WatchBoardOwner } from "../run/supervision.js";
|
|
4
|
-
import { type ExecutorDriver, type FocusTarget, type FocusResult, type NotifyOpts, type Slot, type SlotOpts } from "./types.js";
|
|
4
|
+
import { type ExecutorDriver, type FocusTarget, type TransportState, type FocusResult, type NotifyOpts, type Slot, type SlotOpts } from "./types.js";
|
|
5
5
|
/** The response families the ONE shared envelope parser serves. There is no second JSON seam. */
|
|
6
6
|
export declare const ORCA_RESPONSE_FAMILIES: readonly ["status", "create", "list", "read", "send", "wait", "show", "close", "worktree-current", "worktree-set", "hooks-status", "split"];
|
|
7
7
|
export type OrcaFamily = (typeof ORCA_RESPONSE_FAMILIES)[number];
|
|
@@ -325,6 +325,7 @@ export declare class OrcaDriver implements ExecutorDriver {
|
|
|
325
325
|
readScreen(slot: Slot): Promise<string>;
|
|
326
326
|
/** A single UNPAGED tail read — exactly what the caller asked for and nothing more. Markers split
|
|
327
327
|
* across cursor pages are not reassembled here; that is waitOutput's job. */
|
|
328
|
+
transportState(_slot: Slot): Promise<TransportState>;
|
|
328
329
|
read(slot: Slot, lines: number): Promise<string>;
|
|
329
330
|
/**
|
|
330
331
|
* Bounded cursor-paged sweep into the slot's accumulated buffer. The first read of a slot carries
|
package/dist/drivers/orca.js
CHANGED
|
@@ -1545,6 +1545,11 @@ export class OrcaDriver {
|
|
|
1545
1545
|
}
|
|
1546
1546
|
/** A single UNPAGED tail read — exactly what the caller asked for and nothing more. Markers split
|
|
1547
1547
|
* across cursor pages are not reassembled here; that is waitOutput's job. */
|
|
1548
|
+
// Queue row 108: a pane's transport closes when the pane is gone, which the dead-worker park already requires
|
|
1549
|
+
// (an empty read), so the answer is closed — parity with the park before row 108, never a new hold.
|
|
1550
|
+
async transportState(_slot) {
|
|
1551
|
+
return { closed: true };
|
|
1552
|
+
}
|
|
1548
1553
|
async read(slot, lines) {
|
|
1549
1554
|
if (!Number.isInteger(lines) || lines <= 0)
|
|
1550
1555
|
throw new OrcaError("read", `invalid line limit ${lines}`, "");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExecutorDriver, NotifyOpts, Slot } from "./types.js";
|
|
1
|
+
import type { ExecutorDriver, NotifyOpts, Slot, TransportState } from "./types.js";
|
|
2
2
|
export declare const MAX_BUF: number;
|
|
3
3
|
export declare const HERDR_CONTROL_VARS: readonly ["HERDR_ENV", "HERDR_SOCKET_PATH", "ORCA_TERMINAL_HANDLE", "ORCA_PANE_KEY", "ORCA_TAB_ID"];
|
|
4
4
|
/**
|
|
@@ -22,6 +22,7 @@ export declare class SubprocessDriver implements ExecutorDriver {
|
|
|
22
22
|
}): Promise<boolean>;
|
|
23
23
|
waitAgentStatus(slot: Slot, status: string, timeoutMs: number): Promise<boolean>;
|
|
24
24
|
status(_slot: Slot): Promise<string>;
|
|
25
|
+
transportState(slot: Slot): Promise<TransportState>;
|
|
25
26
|
read(slot: Slot, lines: number): Promise<string>;
|
|
26
27
|
focus(): Promise<import("./types.js").FocusResult>;
|
|
27
28
|
notify(msg: string, _opts?: NotifyOpts): Promise<void>;
|
|
@@ -75,6 +75,16 @@ export class SubprocessDriver {
|
|
|
75
75
|
p.stderr.on("data", (d) => (s.buf = (s.buf + d).slice(-MAX_BUF)));
|
|
76
76
|
p.on("close", () => (s.exited = true));
|
|
77
77
|
p.on("error", (e) => { s.buf = (s.buf + `\n[tickmarkr subprocess error] ${e}\n`).slice(-MAX_BUF); s.exited = true; });
|
|
78
|
+
// Queue row 108 (D-1622): THIS run's transport state. The slot's `exited` outlives an invocation (a reused slot) and
|
|
79
|
+
// an 'error' sets it before stdio closes, so it is not the fact. This object is fresh per run and written only by
|
|
80
|
+
// THIS process's listeners: a previous worker's late close lands on its own state, never on the current one. Only
|
|
81
|
+
// stdio 'close' closes it — or a spawn that never started (no pid), which has nothing to drain.
|
|
82
|
+
const invocation = { closed: false, exitCode: null, signal: null };
|
|
83
|
+
s.invocation = invocation;
|
|
84
|
+
p.on("exit", (code, signal) => { invocation.exitCode = code; invocation.signal = signal; });
|
|
85
|
+
p.on("close", () => { invocation.closed = true; });
|
|
86
|
+
p.on("error", () => { if (p.pid === undefined)
|
|
87
|
+
invocation.closed = true; });
|
|
78
88
|
}
|
|
79
89
|
async waitOutput(slot, pattern, timeoutMs, opts) {
|
|
80
90
|
const s = this.state(slot);
|
|
@@ -103,6 +113,11 @@ export class SubprocessDriver {
|
|
|
103
113
|
async status(_slot) {
|
|
104
114
|
return "unknown"; // no screen to scrape; never reports blocked
|
|
105
115
|
}
|
|
116
|
+
async transportState(slot) {
|
|
117
|
+
const s = this.state(slot);
|
|
118
|
+
const run = s.invocation;
|
|
119
|
+
return { closed: run?.closed ?? false, bytes: Buffer.byteLength(s.buf), exitCode: run?.exitCode ?? null, signal: run?.signal ?? null };
|
|
120
|
+
}
|
|
106
121
|
async read(slot, lines) {
|
|
107
122
|
return this.state(slot).buf.split("\n").slice(-lines).join("\n");
|
|
108
123
|
}
|
package/dist/drivers/types.d.ts
CHANGED
|
@@ -83,6 +83,13 @@ export interface SlotPlacement {
|
|
|
83
83
|
surface?: string;
|
|
84
84
|
hostPlatform?: string;
|
|
85
85
|
}
|
|
86
|
+
/** Queue row 108: a worker transport's own account of its output stream (ExecutorDriver.transportState). */
|
|
87
|
+
export interface TransportState {
|
|
88
|
+
closed: boolean;
|
|
89
|
+
bytes?: number;
|
|
90
|
+
exitCode?: number | null;
|
|
91
|
+
signal?: string | null;
|
|
92
|
+
}
|
|
86
93
|
export interface ExecutorDriver {
|
|
87
94
|
id: string;
|
|
88
95
|
interactive: boolean;
|
|
@@ -101,6 +108,7 @@ export interface ExecutorDriver {
|
|
|
101
108
|
waitAgentStatus(slot: Slot, status: string, timeoutMs: number): Promise<boolean>;
|
|
102
109
|
status(slot: Slot): Promise<string>;
|
|
103
110
|
read(slot: Slot, lines: number): Promise<string>;
|
|
111
|
+
transportState?(slot: Slot): Promise<TransportState>;
|
|
104
112
|
sendKey?(slot: Slot, key: string): Promise<void>;
|
|
105
113
|
nudge?(slot: Slot, message: string): Promise<boolean>;
|
|
106
114
|
notify(msg: string, opts?: NotifyOpts): Promise<void>;
|
|
@@ -55,6 +55,7 @@ export type AcceptanceCorpusAuditResult = {
|
|
|
55
55
|
};
|
|
56
56
|
};
|
|
57
57
|
export declare function auditAcceptanceCorpus(corpusRoot: string, listedTests: readonly VitestListedTest[]): AcceptanceCorpusAuditResult[];
|
|
58
|
+
export declare function changedLinesByFile(diff: string): Map<string, Set<number>>;
|
|
58
59
|
/** OBS-1151: one criterion of a parsed judge verdict, as the contradiction check compares it. */
|
|
59
60
|
export interface JudgedCriterion {
|
|
60
61
|
id: string;
|
package/dist/gates/acceptance.js
CHANGED
|
@@ -10,6 +10,7 @@ import { checkTaskDiffCaps, fetchTaskDiff, isProtectedEvidence, setAsideReceiptP
|
|
|
10
10
|
import { appendAnchoredReview, COMPLETION_FAKING_CHECKLIST, extractVerdictJson, generateVerdictNonce, runLlm, SeatLaunchError, verdictNonceLine } from "./llm.js";
|
|
11
11
|
import { classifyVerdictCause } from "./verdict-cause.js";
|
|
12
12
|
import { reviewableLogicDiff } from "./artifact-manifest.js";
|
|
13
|
+
import { diffSidePath } from "./git-paths.js";
|
|
13
14
|
import { classifyFailureOutput } from "./baseline.js";
|
|
14
15
|
// Fable F4: acceptance judge shares review's 900s timeout — 300s default killed frontier judges on cap-sized diffs.
|
|
15
16
|
const JUDGE_TIMEOUT_MS = 900_000;
|
|
@@ -248,8 +249,9 @@ function oracleExecutionFailure(label, code, stdout, stderr) {
|
|
|
248
249
|
// holds: a citation outside every hunk, or to an untouched file, still fails (matching the documented
|
|
249
250
|
// "outside every changed hunk → rejected" contract in acceptance.test.ts).
|
|
250
251
|
// ponytail: assumes git's default a/ b/ prefixes (fetchTaskDiff uses plain `git diff`); revisit if a
|
|
251
|
-
// caller passes a --no-prefix diff.
|
|
252
|
-
|
|
252
|
+
// caller passes a --no-prefix diff. Queue row 71b: a +++ side is decoded by the shared git-paths reader (git's
|
|
253
|
+
// C-quoting and the tab that ends a space-holding name), so a non-ASCII file's lines are citable by its real name.
|
|
254
|
+
export function changedLinesByFile(diff) {
|
|
253
255
|
const byFile = new Map();
|
|
254
256
|
let path = null;
|
|
255
257
|
let newLine = 0;
|
|
@@ -263,8 +265,7 @@ function changedLinesByFile(diff) {
|
|
|
263
265
|
if (!inHunk && raw.startsWith("--- "))
|
|
264
266
|
continue;
|
|
265
267
|
if (!inHunk && raw.startsWith("+++ ")) {
|
|
266
|
-
|
|
267
|
-
path = p === "/dev/null" ? null : p.replace(/^[ab]\//, "");
|
|
268
|
+
path = diffSidePath(raw.slice(4));
|
|
268
269
|
continue;
|
|
269
270
|
}
|
|
270
271
|
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* path receives capture accounting only when its manifest row names a
|
|
4
4
|
* registered producer and repeats that producer's current provenance exactly.
|
|
5
5
|
*/
|
|
6
|
+
import { diffSidePath, gitHeaderPaths, unquoteGitPath } from "./git-paths.js";
|
|
6
7
|
const GOLDEN_PROVENANCE = {
|
|
7
8
|
source: "src/tui/cockpit/capture.ts",
|
|
8
9
|
entrypoint: "regenerateGoldenFrames",
|
|
@@ -201,9 +202,7 @@ function deletedPath(section) {
|
|
|
201
202
|
return null;
|
|
202
203
|
const oldPath = /^--- (.+)$/m.exec(section)?.[1]
|
|
203
204
|
?? /^Binary files (.+) and \/dev\/null differ$/m.exec(section)?.[1];
|
|
204
|
-
|
|
205
|
-
return null;
|
|
206
|
-
return unquoteGitPath(oldPath).replace(/^a\//, "");
|
|
205
|
+
return oldPath ? diffSidePath(oldPath) : null;
|
|
207
206
|
}
|
|
208
207
|
/**
|
|
209
208
|
* Whole-file source deletions retain their operation fact instead of spending
|
|
@@ -220,26 +219,6 @@ export function reviewableLogicDiff(diff) {
|
|
|
220
219
|
return `deleted file: ${path}\n`;
|
|
221
220
|
}).join("");
|
|
222
221
|
}
|
|
223
|
-
function unquoteGitPath(raw) {
|
|
224
|
-
const value = raw.trim();
|
|
225
|
-
if (!value.startsWith('"') || !value.endsWith('"'))
|
|
226
|
-
return value;
|
|
227
|
-
try {
|
|
228
|
-
return JSON.parse(value);
|
|
229
|
-
}
|
|
230
|
-
catch {
|
|
231
|
-
return value.slice(1, -1);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
function diffSidePath(raw) {
|
|
235
|
-
const value = unquoteGitPath(raw);
|
|
236
|
-
if (value === "/dev/null")
|
|
237
|
-
return null;
|
|
238
|
-
return value.replace(/^[ab]\//, "");
|
|
239
|
-
}
|
|
240
|
-
function gitHeaderTokens(raw) {
|
|
241
|
-
return raw.match(/"(?:\\.|[^"\\])*"|\S+/g) ?? [];
|
|
242
|
-
}
|
|
243
222
|
function parseContentSection(section) {
|
|
244
223
|
const lines = section.split("\n");
|
|
245
224
|
const minus = lines.findIndex((line) => line.startsWith("--- "));
|
|
@@ -267,12 +246,7 @@ function sectionPaths(section, parsed) {
|
|
|
267
246
|
return [...new Set([renamedFrom, renamedTo].filter((path) => path !== undefined).map(unquoteGitPath))];
|
|
268
247
|
}
|
|
269
248
|
const header = /^diff --git (.+)$/m.exec(section)?.[1];
|
|
270
|
-
|
|
271
|
-
return [];
|
|
272
|
-
const tokens = gitHeaderTokens(header);
|
|
273
|
-
if (tokens.length !== 2)
|
|
274
|
-
return [];
|
|
275
|
-
return [...new Set(tokens.map(diffSidePath).filter((path) => path !== null))];
|
|
249
|
+
return header ? gitHeaderPaths(header) : [];
|
|
276
250
|
}
|
|
277
251
|
function hunkPayload(body, sign) {
|
|
278
252
|
return body
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** A path exactly as git printed it, quoted or not, decoded to the name on disk. Unquoted input is returned as is. */
|
|
2
|
+
export declare function unquoteGitPath(raw: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* One side of a `--- ` / `+++ ` line, after its four-character marker: null for /dev/null, else the path without its
|
|
5
|
+
* a/ or b/ prefix. git ends a side holding a space with ONE tab (quoted or not, measured on git 2.54) — exactly that tab
|
|
6
|
+
* is dropped, never trimmed, so a name that begins or ends with a space survives.
|
|
7
|
+
*/
|
|
8
|
+
export declare function diffSidePath(raw: string): string | null;
|
|
9
|
+
/**
|
|
10
|
+
* The paths a `diff --git <a> <b>` header names, for a section with no ---/+++ or rename lines (a mode-only change, a
|
|
11
|
+
* binary or an empty file). git writes such a header for ONE path, twice: `a/<X> b/<X>`. Each grammar only splits the
|
|
12
|
+
* header into its two sides, and both answer through the same samePath rule (D-1608), so they cannot disagree: an
|
|
13
|
+
* unquoted header splits where git apply's git_header_name does — the ` ` before `b/` at the midpoint, whitespace kept, so
|
|
14
|
+
* a name that ends in (or is) a space survives (D-1604); a quoted one must be two complete quoted strings joined by
|
|
15
|
+
* exactly one space, matched end to end, each unquoted with its prefix kept (D-1607). Anything else is unresolvable: []
|
|
16
|
+
* (the artifact manifest then keeps the section as logic, rendered and charged — over-charged, never hidden).
|
|
17
|
+
*/
|
|
18
|
+
export declare function gitHeaderPaths(header: string): string[];
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue row 71b: git's path quoting, decoded once for every patch-text parser (acceptance's citable changed lines,
|
|
3
|
+
* the artifact manifest's section paths). git C-quotes a path holding a byte >= 0x80 (unless core.quotePath=false),
|
|
4
|
+
* a control character, a double quote or a backslash — `"docs/caf\303\251.md"` — with the escapes \a \b \t \n \v \f
|
|
5
|
+
* \r \" \\ and \ooo octal BYTES, so a non-ASCII name arrives as its UTF-8 bytes. JSON.parse is not this grammar: it
|
|
6
|
+
* has no octal escapes and returned `caf\303\251` with literal backslashes.
|
|
7
|
+
*/
|
|
8
|
+
const ESCAPES = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, "\\": 92 };
|
|
9
|
+
/** A path exactly as git printed it, quoted or not, decoded to the name on disk. Unquoted input is returned as is. */
|
|
10
|
+
export function unquoteGitPath(raw) {
|
|
11
|
+
if (raw.length < 2 || !raw.startsWith('"') || !raw.endsWith('"'))
|
|
12
|
+
return raw;
|
|
13
|
+
const chars = Array.from(raw.slice(1, -1)); // code points, so a non-ASCII character is pushed whole
|
|
14
|
+
const bytes = [];
|
|
15
|
+
for (let i = 0; i < chars.length; i++) {
|
|
16
|
+
const ch = chars[i];
|
|
17
|
+
if (ch !== "\\") {
|
|
18
|
+
bytes.push(...Buffer.from(ch, "utf8"));
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const octal = /^[0-7]{1,3}/.exec(chars.slice(i + 1, i + 4).join(""))?.[0];
|
|
22
|
+
if (octal) {
|
|
23
|
+
bytes.push(parseInt(octal, 8) & 0xff);
|
|
24
|
+
i += octal.length;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const escape = chars[i + 1];
|
|
28
|
+
if (escape !== undefined && escape in ESCAPES) {
|
|
29
|
+
bytes.push(ESCAPES[escape]);
|
|
30
|
+
i++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
bytes.push(92); // a backslash git never writes alone: kept literally
|
|
34
|
+
}
|
|
35
|
+
return Buffer.from(bytes).toString("utf8");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* One side of a `--- ` / `+++ ` line, after its four-character marker: null for /dev/null, else the path without its
|
|
39
|
+
* a/ or b/ prefix. git ends a side holding a space with ONE tab (quoted or not, measured on git 2.54) — exactly that tab
|
|
40
|
+
* is dropped, never trimmed, so a name that begins or ends with a space survives.
|
|
41
|
+
*/
|
|
42
|
+
export function diffSidePath(raw) {
|
|
43
|
+
const value = unquoteGitPath(raw.endsWith("\t") ? raw.slice(0, -1) : raw);
|
|
44
|
+
if (value === "/dev/null")
|
|
45
|
+
return null;
|
|
46
|
+
return value.replace(/^[ab]\//, "");
|
|
47
|
+
}
|
|
48
|
+
/** The one path a header side pair names — `a/<X>` and `b/<X>`, the same non-empty X — or none (D-1608: one rule). */
|
|
49
|
+
function samePath(left, right) {
|
|
50
|
+
return left.startsWith("a/") && right.startsWith("b/") && left.length > 2 && left.slice(2) === right.slice(2) ? [left.slice(2)] : [];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The paths a `diff --git <a> <b>` header names, for a section with no ---/+++ or rename lines (a mode-only change, a
|
|
54
|
+
* binary or an empty file). git writes such a header for ONE path, twice: `a/<X> b/<X>`. Each grammar only splits the
|
|
55
|
+
* header into its two sides, and both answer through the same samePath rule (D-1608), so they cannot disagree: an
|
|
56
|
+
* unquoted header splits where git apply's git_header_name does — the ` ` before `b/` at the midpoint, whitespace kept, so
|
|
57
|
+
* a name that ends in (or is) a space survives (D-1604); a quoted one must be two complete quoted strings joined by
|
|
58
|
+
* exactly one space, matched end to end, each unquoted with its prefix kept (D-1607). Anything else is unresolvable: []
|
|
59
|
+
* (the artifact manifest then keeps the section as logic, rendered and charged — over-charged, never hidden).
|
|
60
|
+
*/
|
|
61
|
+
export function gitHeaderPaths(header) {
|
|
62
|
+
if (!header.includes('"')) {
|
|
63
|
+
const half = (header.length - 5) / 2;
|
|
64
|
+
return Number.isInteger(half) && half > 0 && header[2 + half] === " "
|
|
65
|
+
? samePath(header.slice(0, 2 + half), header.slice(3 + half)) : [];
|
|
66
|
+
}
|
|
67
|
+
const quoted = /^("(?:\\.|[^"\\])*") ("(?:\\.|[^"\\])*")$/.exec(header);
|
|
68
|
+
return quoted ? samePath(unquoteGitPath(quoted[1]), unquoteGitPath(quoted[2])) : [];
|
|
69
|
+
}
|
package/dist/gates/scope.js
CHANGED
|
@@ -32,7 +32,9 @@ export async function scopeGate(worktree, integrationTip, files, result, allowDe
|
|
|
32
32
|
if (!files.length)
|
|
33
33
|
return { gate: "scope", pass: true, details: "no file scope declared — unrestricted" };
|
|
34
34
|
const baseRef = await scopeDiffBase(worktree, integrationTip);
|
|
35
|
-
|
|
35
|
+
// Queue row 71: without -z git C-quotes a non-ASCII path (`"docs/caf\303\251.md"`), which no files[] entry can ever
|
|
36
|
+
// match; -z prints every path verbatim, NUL-terminated (no trim: a name may begin or end with a space).
|
|
37
|
+
const changed = (await shGitOk(`git diff --name-only -z '${baseRef}..HEAD'`, worktree)).split("\0").filter(Boolean);
|
|
36
38
|
const inScope = filesGlob(files); // the ONE files[] matcher — src/graph/files-glob.ts (Q120s)
|
|
37
39
|
const offenders = changed.filter((f) => !inScope(f));
|
|
38
40
|
if (!offenders.length)
|
package/dist/run/daemon.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export interface DispatchObservation {
|
|
|
18
18
|
outcome: "accepted" | "not-accepted";
|
|
19
19
|
authoritative: true;
|
|
20
20
|
}
|
|
21
|
-
export declare function heldWorkerTransport(driver: ExecutorDriver, slot: Slot, dispatchId: string, held: (data: Record<string, unknown>) => void, sleep?: (ms: number) => Promise<void>): Pick<ExecutorDriver, "run" | "read" | "status" | "waitOutput" | "waitAgentStatus">;
|
|
21
|
+
export declare function heldWorkerTransport(driver: ExecutorDriver, slot: Slot, dispatchId: string, held: (data: Record<string, unknown>) => void, sleep?: (ms: number) => Promise<void>): Pick<ExecutorDriver, "run" | "read" | "status" | "waitOutput" | "waitAgentStatus" | "transportState">;
|
|
22
22
|
export declare function setAttemptHardTimeoutMsForTests(ms: number): void;
|
|
23
23
|
export declare function resetAttemptHardTimeoutMsForTests(): void;
|
|
24
24
|
export { harvestCpuFlatWindowMs, resetHarvestCpuFlatMsForTests, setHarvestCpuFlatMsForTests, workerTreeCpuMs, } from "./stall.js";
|
package/dist/run/daemon.js
CHANGED
|
@@ -13,7 +13,7 @@ import { batchedProcessProbes, runnerPids } from "./suite-census.js";
|
|
|
13
13
|
import { stringify } from "yaml";
|
|
14
14
|
import { BOOTSTRAP_FAILURE_RE, classifyDeadChannel, classifyTransientCapacity, trailerPattern, writePrompt } from "../adapters/prompt.js";
|
|
15
15
|
import { allAdapters, getAdapter, probeAll, readDoctor, rolePools } from "../adapters/registry.js";
|
|
16
|
-
import { SettledTrailerTracker, addUsage, CAPACITY_RE, channelKey, matchesInputBox, matchesTrustDialog,
|
|
16
|
+
import { SettledTrailerTracker, addUsage, CAPACITY_RE, channelKey, matchesInputBox, matchesTrustDialog, quotaSignal } from "../adapters/types.js";
|
|
17
17
|
import { bannerShell, paneDispatchCommand } from "../brand.js";
|
|
18
18
|
import { collateralHits } from "../compile/collateral.js";
|
|
19
19
|
import { ExecutionPolicySchema, DEFAULT_DIFF_CAP, globalConfigDir, loadConfigWithMode, readOverlayFile, repoOverlayPath, } from "../config/config.js";
|
|
@@ -103,6 +103,8 @@ export function heldWorkerTransport(driver, slot, dispatchId, held, sleep = (ms)
|
|
|
103
103
|
status: (s) => probe("status", () => driver.status(s)),
|
|
104
104
|
waitOutput: (s, p, ms, o) => probe("waitOutput", () => driver.waitOutput(s, p, ms, o)),
|
|
105
105
|
waitAgentStatus: (s, st, ms) => probe("waitAgentStatus", () => driver.waitAgentStatus(s, st, ms)),
|
|
106
|
+
// row 108: forwarded only when the driver answers; absent stays absent, so the park reads it as unknown
|
|
107
|
+
...(driver.transportState ? { transportState: (s) => probe("transportState", () => driver.transportState(s)) } : {}),
|
|
106
108
|
run: async (s, command) => {
|
|
107
109
|
const observer = driver;
|
|
108
110
|
// A second delivery needs a receipt for THIS mutation, not an empty or idle terminal.
|
|
@@ -683,7 +685,7 @@ export function resetNudgeTimingForTests() {
|
|
|
683
685
|
workerNudgeGraceMs = WORKER_NUDGE_GRACE_MS;
|
|
684
686
|
}
|
|
685
687
|
// T1 (OBS-263): in-loop quota-banner classification — the banner IS output, so the empty-output
|
|
686
|
-
// rules can never catch it and the post-loop
|
|
688
|
+
// rules can never catch it and the post-loop quota check only runs after the full window. Two
|
|
687
689
|
// consecutive matching slices plus this much monotonic-tracker silence classify (a worker whose
|
|
688
690
|
// diff merely quotes "rate limit" keeps working undisturbed).
|
|
689
691
|
const QUOTA_BANNER_SILENT_MS = 3 * 60_000;
|
|
@@ -5361,6 +5363,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
5361
5363
|
let rowSaturationHeld = false; // journaled once per attempt when the kill stands down
|
|
5362
5364
|
let cpuHeld = false; // likewise for the CPU leg's stand-down (OBS-548)
|
|
5363
5365
|
let paneReadHeld = false;
|
|
5366
|
+
let transportHeldNoted = false; // row 108: an open transport is journaled once per attempt
|
|
5364
5367
|
let paneStatusHeld = false;
|
|
5365
5368
|
const hardDeadline = attemptStart + (attemptHardTimeoutMs ?? stallWindowMs * 4);
|
|
5366
5369
|
await armCpuLeg(true);
|
|
@@ -5512,7 +5515,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
5512
5515
|
// and concludes the attempt the same way — only its post-loop outcome differs (bounded
|
|
5513
5516
|
// requeue on this seat, then same-floor failover, never demotion). Quota wins a tie.
|
|
5514
5517
|
const bannerRows = stallSnapshotBannerRows(paneText);
|
|
5515
|
-
const quotaBanner =
|
|
5518
|
+
const quotaBanner = quotaSignal(bannerRows);
|
|
5516
5519
|
const bannerMatch = quotaBanner ?? CAPACITY_RE.exec(bannerRows);
|
|
5517
5520
|
if (bannerMatch)
|
|
5518
5521
|
quotaStreak++;
|
|
@@ -5533,7 +5536,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
5533
5536
|
// no `output =` here: the post-loop no-trailer tail re-reads the pane anyway, so an
|
|
5534
5537
|
// assignment would only split the classification read from the verdict read.
|
|
5535
5538
|
quotaBannerKilled = true;
|
|
5536
|
-
journal.append("quota-banner", t.id, { slot: slot.name, attempt, silentMs: sliceNow - lastProgressAt, matched: quotaBanner[0], excerpt: quotaBanner.input, regex:
|
|
5539
|
+
journal.append("quota-banner", t.id, { slot: slot.name, attempt, silentMs: sliceNow - lastProgressAt, matched: quotaBanner[0], excerpt: quotaBanner.input, regex: quotaBanner.source });
|
|
5537
5540
|
break;
|
|
5538
5541
|
}
|
|
5539
5542
|
// T1 (OBS-262): the `paged` latch is deleted — status is sampled EVERY slice (and
|
|
@@ -5675,10 +5678,42 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
5675
5678
|
const confirmedProcessTree = worktreeDelta === "unchanged"
|
|
5676
5679
|
? await observeWorkerProcessTree(dispatchScript, wt)
|
|
5677
5680
|
: "unmeasurable";
|
|
5681
|
+
// Queue row 108 (D-1619): the transport's own CLOSED fact. A subprocess 'exit' precedes its stdio 'close', so a
|
|
5682
|
+
// just-finished worker can look gone — no pane bytes, no process, an unchanged worktree — with its trailer still
|
|
5683
|
+
// in flight. Not closed, or a driver with no answer, never parks: the rolling timeout continues, as for every
|
|
5684
|
+
// other unproven leg. Pane drivers answer closed (their closure is the pane gone, already required above).
|
|
5685
|
+
// ORDER MATTERS: the closed fact is read FIRST, then the pane once more. Closed means every byte is already in
|
|
5686
|
+
// the read surface, so an empty read AFTER it proves the worker wrote nothing; the earlier empty reads may
|
|
5687
|
+
// predate the close and are not that proof (the stale-empty race this row's own test caught).
|
|
5688
|
+
const transport = confirmedProcessTree === "empty" ? await workerTransport.transportState?.(slot) : undefined;
|
|
5689
|
+
let drainedEmpty = false;
|
|
5690
|
+
if (transport?.closed === true) {
|
|
5691
|
+
try {
|
|
5692
|
+
const drained = await workerTransport.read(slot, PANE_READ_ROWS);
|
|
5693
|
+
drainedEmpty = drained.trim().length === 0;
|
|
5694
|
+
if (!drainedEmpty) {
|
|
5695
|
+
everHadOutput = true;
|
|
5696
|
+
if (stallProgress.observe({ paneText: drained, contextTokens }))
|
|
5697
|
+
lastProgressAt = Date.now();
|
|
5698
|
+
}
|
|
5699
|
+
}
|
|
5700
|
+
catch (error) {
|
|
5701
|
+
if (error instanceof HeldProbeExhausted)
|
|
5702
|
+
throw error;
|
|
5703
|
+
}
|
|
5704
|
+
}
|
|
5678
5705
|
const deathCertain = paneAbsent
|
|
5679
5706
|
&& processTree === "empty"
|
|
5680
5707
|
&& confirmedProcessTree === "empty"
|
|
5681
|
-
&& worktreeDelta === "unchanged"
|
|
5708
|
+
&& worktreeDelta === "unchanged"
|
|
5709
|
+
&& transport?.closed === true
|
|
5710
|
+
&& drainedEmpty;
|
|
5711
|
+
if (confirmedProcessTree === "empty" && transport?.closed !== true && !transportHeldNoted) {
|
|
5712
|
+
transportHeldNoted = true;
|
|
5713
|
+
noteWorkerLiveness("worker-dead-held", {
|
|
5714
|
+
slot: slot.name, attempt, reason: transport ? "transport-open" : "transport-unknown", ...(transport ? { transport } : {}),
|
|
5715
|
+
});
|
|
5716
|
+
}
|
|
5682
5717
|
if (deathCertain) {
|
|
5683
5718
|
const preservation = await preserveDeadWorker(wt, taskBase);
|
|
5684
5719
|
if (!preservation.ref) {
|
|
@@ -5692,7 +5727,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
5692
5727
|
deadWorkerPark = { ref, reason };
|
|
5693
5728
|
journal.append("worktree-preserved", t.id, { ref, ...producerFields(producerNow()) });
|
|
5694
5729
|
noteWorkerLiveness("worker-dead-held", {
|
|
5695
|
-
|
|
5730
|
+
// row 108 legibility: the transport's closed state, byte count and exit code/signal (a kill shows its signal)
|
|
5731
|
+
slot: slot.name, attempt, reason: "unambiguous-worker-death", ref, transport,
|
|
5696
5732
|
});
|
|
5697
5733
|
break;
|
|
5698
5734
|
}
|
|
@@ -6256,7 +6292,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
6256
6292
|
// this repository's own fixture prose. The matched bytes and their stream offset ride the
|
|
6257
6293
|
// row so a false positive is legible from the record alone.
|
|
6258
6294
|
const quotaMatch = (interactive ? !workerFinished : exitCode !== 0)
|
|
6259
|
-
?
|
|
6295
|
+
? quotaSignal(stallSnapshotBannerRows(output))
|
|
6260
6296
|
: null;
|
|
6261
6297
|
// OBS-1161: transient capacity reads the SAME tail under the same guards — a live idle banner
|
|
6262
6298
|
// and a no-trailer capacity exit classify identically — through the parse-boundary rule that a
|
package/dist/run/git-trust.d.ts
CHANGED
|
@@ -74,6 +74,24 @@ export interface DetachedAddCapability {
|
|
|
74
74
|
export interface TrustedStoreCapability {
|
|
75
75
|
store: string;
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Whether, before the child spawns, the git its PATH resolves honours GIT_REFERENCE_BACKEND (git >= 2.54): a fresh
|
|
79
|
+
* probe repository whose own refs lack PROBE_REF, pinned to a fresh store that holds it as a symbolic ref. A git that
|
|
80
|
+
* ignores the pin reads the probe repository's own refs. Nothing is cached: each attempt probes again.
|
|
81
|
+
* Queue row 21: only a git that RAN TO COMPLETION and answered something else has shown it ignores the pin. A spawn
|
|
82
|
+
* error (EAGAIN, EMFILE, ENOENT), a timeout or a kill is no answer at all — "unknown", never "git < 2.54".
|
|
83
|
+
*/
|
|
84
|
+
export type RefStorePinProbe = {
|
|
85
|
+
pin: "honoured" | "ignored";
|
|
86
|
+
} | {
|
|
87
|
+
pin: "unknown";
|
|
88
|
+
cause: string;
|
|
89
|
+
};
|
|
90
|
+
/** A probe git that threw: "ignored" only when it ran to completion and exited non-zero (a numeric exit status, no
|
|
91
|
+
* signal, no error code); anything else never gave an answer that was read — a timeout, a kill, a spawn failure, or
|
|
92
|
+
* ENOBUFS (status 0 with its output over the buffer cap) — and the cause is named for the refusal (queue row 21's
|
|
93
|
+
* closed table, D-1563). */
|
|
94
|
+
export declare function refStorePinProbeFailure(error: unknown): RefStorePinProbe;
|
|
77
95
|
/**
|
|
78
96
|
* v2.6.8 T2 (P3): the same uncached check, returning the child of tickmarkr's OWN git in a linked checkout — `env`
|
|
79
97
|
* (the caller's) with GIT_DIR, GIT_COMMON_DIR and GIT_WORK_TREE set to the canonical gitdir, trusted common directory
|
|
@@ -83,7 +101,8 @@ export interface TrustedStoreCapability {
|
|
|
83
101
|
* descendant) is followed for neither config, hooks, info/, objects nor refs: HEAD, branches, status and diffs read
|
|
84
102
|
* the real repository and every ref write lands in the trusted store, whatever an inherited GIT_CONFIG_* or ref backend
|
|
85
103
|
* says; a commondir REMOVED after it cannot move the main checkout's branch. Before such a child spawns, the git it
|
|
86
|
-
* would run must prove it honours that pin (
|
|
104
|
+
* would run must prove it honours that pin (refStorePinProbe); one that cannot (git < 2.54) runs without it, below, and
|
|
105
|
+
* one whose probe never answers twice is refused (queue row 21).
|
|
87
106
|
* Nothing here reads the command, and no caller leaves any pin. `recheck` (after the child exits) fails closed on ANY change to commondir since the check: the result
|
|
88
107
|
* of that child is never returned. An ordinary checkout or a non-repository gets no pin.
|
|
89
108
|
* NAMED RESIDUAL: git still reads $GIT_DIR/config.worktree when the trusted common config already enables
|
package/dist/run/git-trust.js
CHANGED
|
@@ -235,12 +235,21 @@ function assertFilesRefStore(common) {
|
|
|
235
235
|
/** A detached auto gc or maintenance would outlive its child and read refs through a view already released. */
|
|
236
236
|
const NO_BACKGROUND_GIT = "'gc.auto=0' 'maintenance.auto=false'";
|
|
237
237
|
const PROBE_REF = "refs/tickmarkr/ref-pin-probe", PROBE_TARGET = "refs/tickmarkr/ref-pin-honoured";
|
|
238
|
-
/**
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
|
|
243
|
-
|
|
238
|
+
/** A probe git that threw: "ignored" only when it ran to completion and exited non-zero (a numeric exit status, no
|
|
239
|
+
* signal, no error code); anything else never gave an answer that was read — a timeout, a kill, a spawn failure, or
|
|
240
|
+
* ENOBUFS (status 0 with its output over the buffer cap) — and the cause is named for the refusal (queue row 21's
|
|
241
|
+
* closed table, D-1563). */
|
|
242
|
+
export function refStorePinProbeFailure(error) {
|
|
243
|
+
const ran = error;
|
|
244
|
+
if (typeof ran?.status === "number" && !ran.signal && !ran.code)
|
|
245
|
+
return { pin: "ignored" };
|
|
246
|
+
// Queue row 107: the code names the cause before the signal — Node kills an overflowing child with SIGTERM and sets
|
|
247
|
+
// ENOBUFS, and "killed by SIGTERM" would hide why; a self-inflicted kill carries no code, so it still reads "killed by".
|
|
248
|
+
const cause = ran?.code === "ETIMEDOUT" ? "timed out after 15 s" : ran?.code ? `failed with ${ran.code}`
|
|
249
|
+
: ran?.signal ? `killed by ${ran.signal}` : "failed with an unknown error";
|
|
250
|
+
return { pin: "unknown", cause };
|
|
251
|
+
}
|
|
252
|
+
function refStorePinProbe(env) {
|
|
244
253
|
const dir = mkdtempSync(join(tmpdir(), "tickmarkr-ref-pin-"));
|
|
245
254
|
try {
|
|
246
255
|
const repo = join(dir, "repo"), store = join(dir, "store");
|
|
@@ -252,12 +261,14 @@ function refStorePinHonoured(env) {
|
|
|
252
261
|
const probe = { ...env, GIT_DIR: repo, GIT_REFERENCE_BACKEND: `files://${store}` };
|
|
253
262
|
for (const k of ["GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"])
|
|
254
263
|
delete probe[k];
|
|
255
|
-
let read
|
|
264
|
+
let read;
|
|
256
265
|
try {
|
|
257
266
|
read = execFileSync("git", ["symbolic-ref", PROBE_REF], { cwd: dir, env: probe, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15_000 }).trim();
|
|
258
267
|
}
|
|
259
|
-
catch {
|
|
260
|
-
|
|
268
|
+
catch (error) {
|
|
269
|
+
return refStorePinProbeFailure(error);
|
|
270
|
+
}
|
|
271
|
+
return { pin: read === PROBE_TARGET ? "honoured" : "ignored" };
|
|
261
272
|
}
|
|
262
273
|
finally {
|
|
263
274
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -280,7 +291,8 @@ function warnRefStorePinUnavailable() {
|
|
|
280
291
|
* descendant) is followed for neither config, hooks, info/, objects nor refs: HEAD, branches, status and diffs read
|
|
281
292
|
* the real repository and every ref write lands in the trusted store, whatever an inherited GIT_CONFIG_* or ref backend
|
|
282
293
|
* says; a commondir REMOVED after it cannot move the main checkout's branch. Before such a child spawns, the git it
|
|
283
|
-
* would run must prove it honours that pin (
|
|
294
|
+
* would run must prove it honours that pin (refStorePinProbe); one that cannot (git < 2.54) runs without it, below, and
|
|
295
|
+
* one whose probe never answers twice is refused (queue row 21).
|
|
284
296
|
* Nothing here reads the command, and no caller leaves any pin. `recheck` (after the child exits) fails closed on ANY change to commondir since the check: the result
|
|
285
297
|
* of that child is never returned. An ordinary checkout or a non-repository gets no pin.
|
|
286
298
|
* NAMED RESIDUAL: git still reads $GIT_DIR/config.worktree when the trusted common config already enables
|
|
@@ -309,7 +321,16 @@ export function ownGitPin(cwd, env, capability = {}) {
|
|
|
309
321
|
// linked checkout on most installed gits, and a hostile git binary ignores every pin anyway; so it runs with the
|
|
310
322
|
// path pins and the post-exit recheck (the 2.6.7 guard). NAMED RESIDUAL there: refs still resolve through the
|
|
311
323
|
// commondir file, so a rewrite between check and exit can reach the hostile store before the recheck refuses.
|
|
312
|
-
|
|
324
|
+
// Queue row 21: a probe that never answered is asked once more; a second non-answer refuses, because dropping to
|
|
325
|
+
// the weaker guard on a spawn error or timeout under load would silently weaken own git and blame the git version.
|
|
326
|
+
let pin = refStorePinProbe(child);
|
|
327
|
+
if (pin.pin === "unknown")
|
|
328
|
+
pin = refStorePinProbe(child);
|
|
329
|
+
if (pin.pin === "unknown") {
|
|
330
|
+
// infra, not a verdict: the ref-store pin probe never answered — name the probe and its cause
|
|
331
|
+
throw new GitTrustRefusal(common, `could not tell whether this git honours the ref-store pin: the probe (git symbolic-ref ${PROBE_REF}) did not complete twice (${pin.cause}), so tickmarkr's own git does not run here without it`);
|
|
332
|
+
}
|
|
333
|
+
if (pin.pin === "ignored") {
|
|
313
334
|
delete child.GIT_REFERENCE_BACKEND;
|
|
314
335
|
warnRefStorePinUnavailable();
|
|
315
336
|
return { env: child, store: common, release: () => { }, unview: (text) => text, recheck };
|
package/dist/run/stall.js
CHANGED
|
@@ -559,7 +559,7 @@ export function stallSnapshotTail(text, rows = QUOTA_BANNER_TAIL_ROWS) {
|
|
|
559
559
|
}
|
|
560
560
|
// T1 review (chrome-blind-matcher class, OBS-152/155): the tail of a RENDERED TUI frame is not
|
|
561
561
|
// "what the pane printed last" — its bottom rows are fixed composer/welcome chrome. Codex pins
|
|
562
|
-
// "• You have 3 usage limit resets available." there, so a raw-tail
|
|
562
|
+
// "• You have 3 usage limit resets available." there, so a raw-tail quota match fires on every
|
|
563
563
|
// frame of a wedged pane (verified against all 8 frames of tests/fixtures/codex-mcp-spinner) and
|
|
564
564
|
// would fail a live worker over mid-work. Filter the KNOWN chrome instead of everything on screen
|
|
565
565
|
// at some anchor: a novelty baseline cannot distinguish "chrome that was already there" from "a
|