tickmarkr 1.66.0 → 1.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/run.js +9 -0
- package/dist/cli/commands/status.d.ts +20 -0
- package/dist/cli/commands/status.js +41 -27
- package/dist/compile/collateral.d.ts +1 -0
- package/dist/compile/collateral.js +52 -3
- package/dist/gates/run-gates.js +13 -6
- package/dist/run/daemon.js +26 -2
- package/dist/run/git.d.ts +2 -0
- package/dist/run/git.js +9 -0
- package/dist/tui/app.d.ts +32 -0
- package/dist/tui/app.js +223 -19
- package/dist/tui/save.d.ts +38 -0
- package/dist/tui/save.js +96 -0
- package/dist/tui/staging.d.ts +29 -0
- package/dist/tui/staging.js +78 -0
- package/dist/tui/views/consult-dossier.d.ts +49 -0
- package/dist/tui/views/consult-dossier.js +169 -0
- package/dist/tui/views/diff-modal.d.ts +9 -0
- package/dist/tui/views/diff-modal.js +19 -0
- package/dist/tui/views/fleet-view.d.ts +23 -1
- package/dist/tui/views/fleet-view.js +108 -14
- package/dist/tui/views/routing-view.d.ts +22 -3
- package/dist/tui/views/routing-view.js +152 -16
- package/dist/tui/views/runs-view.d.ts +70 -0
- package/dist/tui/views/runs-view.js +387 -0
- package/package.json +1 -1
package/dist/cli/commands/run.js
CHANGED
|
@@ -4,6 +4,7 @@ import { ROUTING_MODES } from "../../config/config.js";
|
|
|
4
4
|
import { pickDriver } from "../../drivers/index.js";
|
|
5
5
|
import { loadGraph } from "../../graph/graph.js";
|
|
6
6
|
import { formatSummary, resolveRunMode, runDaemon } from "../../run/daemon.js";
|
|
7
|
+
import { isRunLockLive } from "../../run/lock.js";
|
|
7
8
|
import { route, NO_EXPLORE_ENV } from "../../route/router.js";
|
|
8
9
|
import { formatJournalNarration, loadRoutingProfile } from "../../run/journal.js";
|
|
9
10
|
import { ttyVisual } from "../../adapters/model-lints.js";
|
|
@@ -65,6 +66,14 @@ export async function run(argv, cwd = process.cwd()) {
|
|
|
65
66
|
throw new Error(`--route-strict: refusing to dispatch — ${conflict}`);
|
|
66
67
|
console.warn(`tickmarkr: !! ${conflict}`);
|
|
67
68
|
}
|
|
69
|
+
// OBS-107 (v1.67 T5): advisory only — a stale compiled graph with prior terminal statuses and no
|
|
70
|
+
// live daemon will otherwise finish with zero work and confuse the operator. The recompile remedy
|
|
71
|
+
// is named; dispatch proceeds unchanged.
|
|
72
|
+
const TERMINAL_STATUSES = ["done", "failed", "human"];
|
|
73
|
+
if (graph.tasks.some((t) => TERMINAL_STATUSES.includes(t.status)) && !isRunLockLive(cwd)) {
|
|
74
|
+
console.warn("tickmarkr: compiled graph carries terminal statuses from a prior run and no daemon is active — "
|
|
75
|
+
+ "run `tickmarkr compile <src>` to recompile; proceeding with stale graph");
|
|
76
|
+
}
|
|
68
77
|
const noExplore = !!values["no-explore"];
|
|
69
78
|
const exploreCtx = noExplore ? { noExplore } : undefined;
|
|
70
79
|
if (noExplore)
|
|
@@ -1,5 +1,25 @@
|
|
|
1
|
+
import { type Task, type TaskStatus } from "../../graph/schema.js";
|
|
2
|
+
import { type JournalEvent } from "../../run/journal.js";
|
|
1
3
|
export type StatusOpts = {
|
|
2
4
|
iterations?: number;
|
|
3
5
|
sleep?: (ms: number) => Promise<void>;
|
|
4
6
|
};
|
|
7
|
+
export declare const GATE_KEYS: {
|
|
8
|
+
readonly build: "B";
|
|
9
|
+
readonly test: "T";
|
|
10
|
+
readonly lint: "L";
|
|
11
|
+
readonly evidence: "E";
|
|
12
|
+
readonly scope: "S";
|
|
13
|
+
readonly acceptance: "A";
|
|
14
|
+
readonly review: "R";
|
|
15
|
+
};
|
|
16
|
+
export declare const taskBox: (status: TaskStatus) => string;
|
|
17
|
+
export declare const gateBox: (state: "open" | "pass" | "fail" | "skip", unicode: boolean) => string;
|
|
18
|
+
export type GateState = "open" | "pass" | "fail" | "skip";
|
|
19
|
+
export declare const defaultGateStates: (task: Task) => GateState[];
|
|
20
|
+
export declare const gateStates: (task: Task, events: JournalEvent[]) => GateState[];
|
|
21
|
+
export declare const gateChain: (states: GateState[], unicode: boolean) => string;
|
|
22
|
+
export declare const failedGates: (states: GateState[]) => string[];
|
|
23
|
+
export declare const humanGateSuffix: (t: Task, st: TaskStatus, states: GateState[]) => string;
|
|
24
|
+
export declare const shortGoal: (goal: string, max: number) => string;
|
|
5
25
|
export declare function status(argv: string[], cwd?: string, opts?: StatusOpts): Promise<string>;
|
|
@@ -9,7 +9,7 @@ const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states n
|
|
|
9
9
|
// The timer must keep the process ALIVE: an unref'd timer here let the event loop drain after the
|
|
10
10
|
// first frame, so a live `--watch` printed once and exited 0 (OBS-11). Never unref this.
|
|
11
11
|
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
12
|
-
const GATE_KEYS = { build: "B", test: "T", lint: "L", evidence: "E", scope: "S", acceptance: "A", review: "R" };
|
|
12
|
+
export const GATE_KEYS = { build: "B", test: "T", lint: "L", evidence: "E", scope: "S", acceptance: "A", review: "R" };
|
|
13
13
|
const attemptStartIdx = (events, taskId) => {
|
|
14
14
|
let idx = -1;
|
|
15
15
|
for (let i = 0; i < events.length; i++) {
|
|
@@ -21,22 +21,22 @@ const attemptStartIdx = (events, taskId) => {
|
|
|
21
21
|
};
|
|
22
22
|
const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
|
23
23
|
// non-TTY machine surface only — the TTY frame draws task verdicts via statusRow
|
|
24
|
-
const taskBox = (status) => {
|
|
24
|
+
export const taskBox = (status) => {
|
|
25
25
|
if (status === "done")
|
|
26
26
|
return "[x]";
|
|
27
27
|
if (status === "failed" || status === "human")
|
|
28
28
|
return "[!]";
|
|
29
29
|
return "[ ]";
|
|
30
30
|
};
|
|
31
|
-
const gateBox = (state, unicode) => {
|
|
31
|
+
export const gateBox = (state, unicode) => {
|
|
32
32
|
if (unicode) {
|
|
33
33
|
// shared glyph vocabulary: pass/fail verdicts, dash for skip, dim circle for not-yet-run
|
|
34
34
|
return state === "pass" ? GLYPHS.pass : state === "fail" ? GLYPHS.fail : state === "skip" ? GLYPHS.neutral : GLYPHS.toggleInactive;
|
|
35
35
|
}
|
|
36
36
|
return state === "pass" ? "[x]" : state === "fail" ? "[!]" : state === "skip" ? "." : "[ ]";
|
|
37
37
|
};
|
|
38
|
-
const defaultGateStates = (task) => GATE_NAMES.map((gate) => task.gates.includes(gate) ? "open" : "skip");
|
|
39
|
-
const gateStates = (task, events) => {
|
|
38
|
+
export const defaultGateStates = (task) => GATE_NAMES.map((gate) => task.gates.includes(gate) ? "open" : "skip");
|
|
39
|
+
export const gateStates = (task, events) => {
|
|
40
40
|
const outcomes = new Map();
|
|
41
41
|
const start = attemptStartIdx(events, task.id);
|
|
42
42
|
if (start >= 0) {
|
|
@@ -57,12 +57,10 @@ const gateStates = (task, events) => {
|
|
|
57
57
|
const GATE_STATE_TOKEN = { pass: ok, fail, skip: dim, open: dim };
|
|
58
58
|
// TTY cells are bare glyphs in fixed GATE_NAMES order — gate identity lives once in the frame
|
|
59
59
|
// legend, and in words on a failing row; non-TTY keeps the letter+box chips (byte-pinned surface)
|
|
60
|
-
const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => unicode
|
|
60
|
+
export const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => unicode
|
|
61
61
|
? GATE_STATE_TOKEN[states[i]](gateBox(states[i], true))
|
|
62
62
|
: `${GATE_KEYS[gate]}${gateBox(states[i], false)}`).join(" ");
|
|
63
|
-
|
|
64
|
-
const gateChainWidth = (unicode) => GATE_NAMES.reduce((w, gate) => w + (unicode ? 1 : GATE_KEYS[gate].length + 3) + 1, -1);
|
|
65
|
-
const failedGates = (states) => GATE_NAMES.filter((_, i) => states[i] === "fail");
|
|
63
|
+
export const failedGates = (states) => GATE_NAMES.filter((_, i) => states[i] === "fail");
|
|
66
64
|
// plain-text form of the failed-gate words for column math; rendered with a dim dot + red names
|
|
67
65
|
const failedSuffix = (states) => {
|
|
68
66
|
const f = failedGates(states);
|
|
@@ -72,8 +70,8 @@ const failedSuffix = (states) => {
|
|
|
72
70
|
// awaited approval is named from task state alone — never from gate results. Failed gates win the
|
|
73
71
|
// cell when present: a post-approval park is not awaiting the designed gate. Plain text for column
|
|
74
72
|
// math; rendered with a dim dot + warn words. TTY-only — the non-TTY surface stays byte-pinned.
|
|
75
|
-
const humanGateSuffix = (t, st, states) => st === "human" && t.humanGate && failedGates(states).length === 0 ? " · awaiting approval" : "";
|
|
76
|
-
const shortGoal = (goal, max) => {
|
|
73
|
+
export const humanGateSuffix = (t, st, states) => st === "human" && t.humanGate && failedGates(states).length === 0 ? " · awaiting approval" : "";
|
|
74
|
+
export const shortGoal = (goal, max) => {
|
|
77
75
|
const clause = goal.split(/[,;.?!]/, 1)[0].trim();
|
|
78
76
|
if (clause.length <= max)
|
|
79
77
|
return clause;
|
|
@@ -182,10 +180,13 @@ const renderFrame = (cwd) => {
|
|
|
182
180
|
const done = effective.tasks.filter((t) => t.status === "done").length;
|
|
183
181
|
const cells = g.tasks.map((t) => {
|
|
184
182
|
const st = replayed?.get(t.id) ?? t.status;
|
|
185
|
-
const
|
|
183
|
+
const isStarved = starved.has(t.id);
|
|
184
|
+
const phrase = isStarved ? undefined : activity.cells.get(t.id);
|
|
185
|
+
const label = isStarved ? " starved" : phrase ? ` ${phrase}` : "";
|
|
186
186
|
const channel = assignments.get(t.id) ?? "-";
|
|
187
|
-
const
|
|
188
|
-
|
|
187
|
+
const ctx = contexts.get(t.id);
|
|
188
|
+
const assignCol = ctx !== undefined ? `${channel}${divider}ctx ${ctx}` : channel;
|
|
189
|
+
return { t, st, label, assignCol, isStarved, phrase, channel, ctx, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
|
|
189
190
|
});
|
|
190
191
|
if (!unicode) {
|
|
191
192
|
// machine/CI surface — layout unchanged (pipes, greps, and the golden pins depend on it)
|
|
@@ -227,26 +228,39 @@ const renderFrame = (cwd) => {
|
|
|
227
228
|
// rows: the line names task ids, and rows must stay the first id-bearing lines for grep consumers.
|
|
228
229
|
const nowLine = activity.now ? [legend(` now: ${activity.now}`)] : [];
|
|
229
230
|
const gatesLegend = legend(` gates: ${GATE_NAMES.join(" · ")}`);
|
|
231
|
+
// Two-line card per task (operator request, v1.67): line 1 carries identity + verdict — glyph,
|
|
232
|
+
// id, goal at full width, and only SHORT status words. Line 2 carries the machinery, dim and
|
|
233
|
+
// aligned under the goal: gate chain, live activity phrase (or channel), ctx. Long channel names
|
|
234
|
+
// and activity phrases live on line 2 only, so they can never squeeze the goal or wrap line 1.
|
|
230
235
|
const taskVerdict = (st) => st === "done" ? "pass" : st === "failed" ? "fail" : st === "human" ? "warn" : "neutral";
|
|
231
236
|
const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
const
|
|
236
|
-
const
|
|
237
|
-
|
|
237
|
+
// plain-text status suffixes for width math only — starved / failed gate names / approval hint
|
|
238
|
+
const suffixPlain = (c) => (c.isStarved ? " · starved" : "") + failedSuffix(c.states) + humanGateSuffix(c.t, c.st, c.states);
|
|
239
|
+
const stW = Math.max(...cells.map((c) => String(c.st).length + suffixPlain(c).length));
|
|
240
|
+
const avail = Math.max(8, width - (5 + idW) - 2 - stW);
|
|
241
|
+
const goals = cells.map((c) => shortGoal(c.t.goal, avail));
|
|
242
|
+
const goalW = Math.max(8, ...goals.map((s) => s.length));
|
|
243
|
+
const indent = " ".repeat(idW + 5); // line 2 starts under the goal column
|
|
244
|
+
const rows = cells.map(({ t, st, states, isStarved, phrase, channel, ctx }, i) => {
|
|
238
245
|
const stWord = st === "done" ? ok(String(st)) : st === "failed" ? fail(String(st)) : st === "human" ? warn(String(st)) : String(st);
|
|
239
246
|
// a fail names its gate in words right here — the one moment gate identity is needed on a row
|
|
240
247
|
const f = failedGates(states);
|
|
241
248
|
const human = humanGateSuffix(t, st, states);
|
|
242
249
|
const statusCell = stWord +
|
|
243
|
-
(
|
|
244
|
-
(f.length ?
|
|
245
|
-
(human ?
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
+
(isStarved ? dot + fail("starved") : "") +
|
|
251
|
+
(f.length ? dot + fail(f.join(", ")) : "") +
|
|
252
|
+
(human ? dot + warn("awaiting approval") : "");
|
|
253
|
+
const line1 = ` ${statusRow(taskVerdict(st), `${t.id.padEnd(idW)} ${goals[i].padEnd(goalW)} ${statusCell}`)}`;
|
|
254
|
+
// activity already names its channel for in-flight attempts — never repeat it
|
|
255
|
+
const detail = [
|
|
256
|
+
...(phrase ? [phrase, ...(phrase.includes(channel) || channel === "-" ? [] : [channel])] : [channel]),
|
|
257
|
+
...(ctx !== undefined ? [`ctx ${ctx}`] : []),
|
|
258
|
+
].join(" · ");
|
|
259
|
+
return [line1, `${indent}${gateChain(states, true)} ${dim(detail)}`, ""];
|
|
260
|
+
}).flat();
|
|
261
|
+
if (!nowLine.length)
|
|
262
|
+
rows.pop(); // cards end blank-separated; drop the dangling one
|
|
263
|
+
return [header, hr, gatesLegend, "", ...rows, ...nowLine].join("\n");
|
|
250
264
|
};
|
|
251
265
|
export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
252
266
|
// cockpit surface: banner + frame on a TTY (doctor's pattern); pipes get the bare frame
|
|
@@ -4,6 +4,7 @@ import { type Task } from "../graph/schema.js";
|
|
|
4
4
|
* Each line names the task id and at least one missing collateral test path.
|
|
5
5
|
*/
|
|
6
6
|
export declare function collateralLints(tasks: ReadonlyArray<Pick<Task, "id" | "files">>, repoRoot: string): string[];
|
|
7
|
+
export declare function newDirectoryLints(tasks: ReadonlyArray<Pick<Task, "id" | "files">>, repoRoot: string): string[];
|
|
7
8
|
/**
|
|
8
9
|
* OBS-76 class: sweep src/ for out-of-scope source files that reference a symbol the acceptance
|
|
9
10
|
* criteria name — the v1.52 router.ts omission, named at plan time instead of one judge round in.
|
|
@@ -145,20 +145,69 @@ function criteriaSymbols(acceptance) {
|
|
|
145
145
|
}
|
|
146
146
|
return [...out].sort();
|
|
147
147
|
}
|
|
148
|
+
const ARCH_PAGES = ["docs/codebase/ARCHITECTURE.md", "docs/codebase/STRUCTURE.md"];
|
|
149
|
+
function topLevelSrcDir(file) {
|
|
150
|
+
const parts = file.replace(/^\.\//, "").replace(/\/+$/, "").split("/");
|
|
151
|
+
if (parts[0] !== "src" || parts.length < 2 || !parts[1])
|
|
152
|
+
return undefined;
|
|
153
|
+
return parts[1];
|
|
154
|
+
}
|
|
155
|
+
function existingSrcTopLevels(repoRoot) {
|
|
156
|
+
const out = new Set();
|
|
157
|
+
let entries;
|
|
158
|
+
try {
|
|
159
|
+
entries = readdirSync(join(repoRoot, "src"), { withFileTypes: true });
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
for (const e of entries) {
|
|
165
|
+
if (e.isDirectory())
|
|
166
|
+
out.add(e.name);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
// OBS-108 (v1.67 T5): a task whose files[] creates a new top-level src/ directory must also
|
|
171
|
+
// include the architecture pages the docs-truth suite pins. Advisory only — never blocks compile.
|
|
172
|
+
export function newDirectoryLints(tasks, repoRoot) {
|
|
173
|
+
const existing = existingSrcTopLevels(repoRoot);
|
|
174
|
+
const lines = [];
|
|
175
|
+
for (const t of tasks) {
|
|
176
|
+
// OBS-22: scopeGate accepts picomatch globs; advisory warnings must agree.
|
|
177
|
+
const scoped = picomatch(t.files.map((f) => f.replace(/^\.\//, "")), { dot: true });
|
|
178
|
+
const newDirs = new Set();
|
|
179
|
+
for (const f of t.files) {
|
|
180
|
+
const dir = topLevelSrcDir(f);
|
|
181
|
+
if (!dir || existing.has(dir))
|
|
182
|
+
continue;
|
|
183
|
+
newDirs.add(`src/${dir}/`);
|
|
184
|
+
}
|
|
185
|
+
if (!newDirs.size)
|
|
186
|
+
continue;
|
|
187
|
+
const missing = ARCH_PAGES.filter((p) => !scoped(p));
|
|
188
|
+
if (!missing.length)
|
|
189
|
+
continue;
|
|
190
|
+
const dirList = [...newDirs].sort().join(", ");
|
|
191
|
+
const dirLabel = newDirs.size > 1 ? "directories" : "directory";
|
|
192
|
+
lines.push(`${t.id}: new top-level source ${dirLabel} ${dirList} must include ${missing.join(" and ")} in files[]`);
|
|
193
|
+
}
|
|
194
|
+
return lines;
|
|
195
|
+
}
|
|
148
196
|
/**
|
|
149
197
|
* OBS-76 class: sweep src/ for out-of-scope source files that reference a symbol the acceptance
|
|
150
198
|
* criteria name — the v1.52 router.ts omission, named at plan time instead of one judge round in.
|
|
151
199
|
* Advisory plan output only, same contract as collateralLints.
|
|
152
200
|
*/
|
|
153
201
|
export function sourceScopeLints(tasks, repoRoot) {
|
|
202
|
+
const newDirLints = newDirectoryLints(tasks, repoRoot);
|
|
154
203
|
const perTask = tasks
|
|
155
204
|
.map((t) => ({ t, needles: criteriaSymbols(t.acceptance) }))
|
|
156
205
|
.filter((x) => x.needles.length);
|
|
157
206
|
if (!perTask.length)
|
|
158
|
-
return
|
|
207
|
+
return newDirLints;
|
|
159
208
|
const srcFiles = walkCode(repoRoot, "src");
|
|
160
209
|
if (!srcFiles.length)
|
|
161
|
-
return
|
|
210
|
+
return newDirLints;
|
|
162
211
|
const read = makeReader(repoRoot);
|
|
163
212
|
const lines = [];
|
|
164
213
|
for (const { t, needles } of perTask) {
|
|
@@ -183,5 +232,5 @@ export function sourceScopeLints(tasks, repoRoot) {
|
|
|
183
232
|
const tail = hits.length > MAX_HITS_PER_TASK ? " (capped)" : "";
|
|
184
233
|
lines.push(`${t.id}: criteria implicate out-of-scope source not in files[]: ${listed}${tail}`);
|
|
185
234
|
}
|
|
186
|
-
return lines;
|
|
235
|
+
return [...newDirLints, ...lines];
|
|
187
236
|
}
|
package/dist/gates/run-gates.js
CHANGED
|
@@ -72,16 +72,23 @@ export async function runGates(task, ctx) {
|
|
|
72
72
|
// worker attempt — exactly what this fix forbids; only its meta-carries-channel pattern is mirrored.
|
|
73
73
|
// Straight-line single `if` — NO loop/counter/knob: exactly-once by construction (a knob is a
|
|
74
74
|
// fail-closed weakening vector); a second garbage verdict fails the gate closed exactly as today.
|
|
75
|
+
// T6: exclusion mirrors consult reroute semantics — the flaked channel's whole adapter is banned, not
|
|
76
|
+
// just its exact channel key, so an outage window cannot re-select the vendor being routed around.
|
|
77
|
+
// If no other adapter is live, the exclusion degrades to a channel-level reroute within the same
|
|
78
|
+
// adapter so a single-adapter fleet still retries (matching the daemon's unknown-excludeAdapter
|
|
79
|
+
// degradation path).
|
|
75
80
|
if (a.meta?.unparseable === true && typeof a.meta.judge === "string") {
|
|
76
81
|
const flakedKey = a.meta.judge;
|
|
77
|
-
const
|
|
78
|
-
|
|
82
|
+
const flakedAdapter = flakedKey.slice(0, flakedKey.indexOf(":"));
|
|
83
|
+
const pick = (pool) => pool
|
|
79
84
|
// pickReviewer's sort (review.ts:37): TIER_RANK desc, marginalCostRank asc — proven ordering; both
|
|
80
|
-
// symbols already imported by a sibling gate file.
|
|
85
|
+
// symbols already imported by a sibling gate file.
|
|
81
86
|
.sort((x, y) => TIER_RANK[y.tier] - TIER_RANK[x.tier] || marginalCostRank(x) - marginalCostRank(y))[0];
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
const crossAdapter = pick(ctx.channels.filter((c) => c.adapter !== flakedAdapter));
|
|
88
|
+
const sameAdapter = pick(ctx.channels.filter((c) => c.adapter === flakedAdapter && channelKey(c) !== flakedKey));
|
|
89
|
+
// Prefer a different adapter; if the fleet only has one adapter, retry on a different channel of
|
|
90
|
+
// that adapter; if the fleet has only one channel, fall back to the original judge config.
|
|
91
|
+
const retry = crossAdapter ?? sameAdapter ?? { adapter: ctx.cfg.judge.adapter, model: ctx.cfg.judge.model };
|
|
85
92
|
const retryAdapter = getAdapter(retry.adapter, ctx.adapters);
|
|
86
93
|
const retryJvia = ctx.via
|
|
87
94
|
// unconditional -r1 suffix: under keepPanes:forever a same-channel retry cannot collide with the
|
package/dist/run/daemon.js
CHANGED
|
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { stringify } from "yaml";
|
|
7
|
-
import { classifyDeadChannel, trailerPattern, writePrompt } from "../adapters/prompt.js";
|
|
7
|
+
import { classifyDeadChannel, NO_TRAILER_SUMMARY, trailerPattern, UNPARSEABLE_TRAILER_SUMMARY, writePrompt } from "../adapters/prompt.js";
|
|
8
8
|
import { allAdapters, discoverChannels, getAdapter, probeAll, readDoctor } from "../adapters/registry.js";
|
|
9
9
|
import { addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
|
|
10
10
|
import { bannerShell, paneDispatchCommand } from "../brand.js";
|
|
@@ -658,6 +658,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
658
658
|
let output;
|
|
659
659
|
let exitCode;
|
|
660
660
|
let timedOut = false;
|
|
661
|
+
let settleParsed;
|
|
661
662
|
if (interactive) {
|
|
662
663
|
// v1.2 interactive: the TUI doesn't exit on completion — the trailer is the finish line.
|
|
663
664
|
// The exit wrapper still fires if the TUI dies (crash/quit): fast-fail instead of burning the timeout.
|
|
@@ -748,6 +749,29 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
748
749
|
await driver.waitAgentStatus(slot, "idle", 5_000); // settle, then re-harvest the final render
|
|
749
750
|
output = await driver.read(slot, 1000);
|
|
750
751
|
}
|
|
752
|
+
// T5 / OBS-111: an interactive harvest can race the TUI's final paint. When the pane
|
|
753
|
+
// contains the nonce token but the JSON hasn't balanced yet, settle and re-read through
|
|
754
|
+
// the existing pane-read seam once or twice before recording a malformed-trailer cause.
|
|
755
|
+
if (interactive) {
|
|
756
|
+
const stallWindowMs = taskTimeoutMinutes * 60_000;
|
|
757
|
+
const settleDeadline = attemptStart + stallWindowMs;
|
|
758
|
+
const settleDelayMs = 1_000;
|
|
759
|
+
const maxSettleRetries = 2;
|
|
760
|
+
let settleTries = 0;
|
|
761
|
+
settleParsed = adapter.parse(output, nonce);
|
|
762
|
+
while (settleParsed.summary === UNPARSEABLE_TRAILER_SUMMARY && settleTries < maxSettleRetries) {
|
|
763
|
+
const remaining = settleDeadline - Date.now();
|
|
764
|
+
if (remaining <= 0)
|
|
765
|
+
break;
|
|
766
|
+
await new Promise((r) => setTimeout(r, Math.min(settleDelayMs, remaining)));
|
|
767
|
+
output = await driver.read(slot, 1000);
|
|
768
|
+
settleParsed = adapter.parse(output, nonce);
|
|
769
|
+
settleTries++;
|
|
770
|
+
}
|
|
771
|
+
if (settleParsed.summary !== UNPARSEABLE_TRAILER_SUMMARY) {
|
|
772
|
+
finished = settleParsed.summary !== NO_TRAILER_SUMMARY;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
751
775
|
}
|
|
752
776
|
else {
|
|
753
777
|
await driver.run(slot, paneDispatchCommand(dispatchScript));
|
|
@@ -800,7 +824,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
800
824
|
tokens = addUsage(tokens, attemptUsage);
|
|
801
825
|
metered++;
|
|
802
826
|
}
|
|
803
|
-
const result = adapter.parse(output, nonce);
|
|
827
|
+
const result = settleParsed ?? adapter.parse(output, nonce);
|
|
804
828
|
const cause = classifyWorkerResultCause({ output, ok: result.ok, finished, exitCode, summary: result.summary, timedOut });
|
|
805
829
|
journal.append("worker-result", t.id, {
|
|
806
830
|
ok: result.ok, summary: result.summary, deviations: result.deviations, finished, exitCode,
|
package/dist/run/git.d.ts
CHANGED
package/dist/run/git.js
CHANGED
|
@@ -5,6 +5,12 @@ import { shq } from "../adapters/types.js";
|
|
|
5
5
|
import { tickmarkrDir } from "../graph/graph.js";
|
|
6
6
|
import { ROUTING_ENV_SEAMS } from "../route/router.js";
|
|
7
7
|
export { ROUTING_ENV_SEAMS };
|
|
8
|
+
// OBS-110: gate/baseline/tip-verify children are vitest suites; without a fork cap, concurrent
|
|
9
|
+
// full-suite gate runs fork a worker-per-core pool per worktree and saturate the operator box.
|
|
10
|
+
// Default to a modest cap through the environment so vitest honors it natively; never pass it
|
|
11
|
+
// as argv (OBS-55) so child test oracles stay intact. The operator's own export wins.
|
|
12
|
+
export const FORK_CAP_ENV = "VITEST_MAX_FORKS";
|
|
13
|
+
export const DEFAULT_FORK_CAP = "6";
|
|
8
14
|
// stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
|
|
9
15
|
// (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
|
|
10
16
|
function shell(cmd, cwd, timeoutMs, login) {
|
|
@@ -15,6 +21,9 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
15
21
|
const env = { ...process.env };
|
|
16
22
|
for (const k of ROUTING_ENV_SEAMS)
|
|
17
23
|
delete env[k];
|
|
24
|
+
// OBS-110: apply the default fork cap only when the operator has not already set one.
|
|
25
|
+
if (!(FORK_CAP_ENV in env))
|
|
26
|
+
env[FORK_CAP_ENV] = DEFAULT_FORK_CAP;
|
|
18
27
|
return new Promise((resolve) => {
|
|
19
28
|
// detached: bash gets its own process group so a timeout can kill the whole tree —
|
|
20
29
|
// SIGKILLing bash alone orphans grandchildren (codex/pi) that hold the stdio pipes
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type OutputStream } from "../tui/engine.js";
|
|
2
2
|
import type { InputStream } from "../tui/input.js";
|
|
3
|
+
import type { FleetEditable } from "../config/config.js";
|
|
4
|
+
import { type FleetEdit } from "./staging.js";
|
|
3
5
|
export type View = {
|
|
4
6
|
id: string;
|
|
5
7
|
label: string;
|
|
@@ -7,17 +9,32 @@ export type View = {
|
|
|
7
9
|
cols: number;
|
|
8
10
|
rows: number;
|
|
9
11
|
}): string[];
|
|
12
|
+
/** Optional per-view key handler (e.g. up/down cursor movement). */
|
|
13
|
+
key?(name: string): void;
|
|
10
14
|
};
|
|
11
15
|
export type StudioOptions = {
|
|
12
16
|
input: InputStream;
|
|
13
17
|
output: OutputStream;
|
|
18
|
+
/** Loaded fleet state the buffer wraps. Defaults to an empty editable for the no-arg shell path. */
|
|
19
|
+
loaded?: FleetEditable;
|
|
20
|
+
/** Repository root used for overlay paths and live-run detection. Defaults to cwd. */
|
|
21
|
+
repoRoot?: string;
|
|
22
|
+
/** Global config directory override. */
|
|
23
|
+
globalDir?: string;
|
|
14
24
|
};
|
|
15
25
|
export declare class StudioApp {
|
|
16
26
|
private engine;
|
|
17
27
|
private views;
|
|
18
28
|
private active;
|
|
19
29
|
private showingHelp;
|
|
30
|
+
private confirmingQuit;
|
|
31
|
+
private showingSave;
|
|
32
|
+
private saveTarget;
|
|
33
|
+
private saveProposal;
|
|
20
34
|
private notice;
|
|
35
|
+
private staging;
|
|
36
|
+
private repoRoot;
|
|
37
|
+
private globalDir;
|
|
21
38
|
private _lines;
|
|
22
39
|
private resolveExit;
|
|
23
40
|
readonly exited: Promise<void>;
|
|
@@ -30,11 +47,26 @@ export declare class StudioApp {
|
|
|
30
47
|
start(): void;
|
|
31
48
|
/** Restore the terminal and release the input stream. */
|
|
32
49
|
stop(): void;
|
|
50
|
+
/** Apply an edit to the staging buffer and repaint. Intended as the test seam and future edit hook. */
|
|
51
|
+
stageEdit(edit: FleetEdit): void;
|
|
52
|
+
private get isModalOpen();
|
|
33
53
|
private bindKeys;
|
|
54
|
+
private handleEscape;
|
|
55
|
+
private handleQuit;
|
|
56
|
+
private handleSaveOpen;
|
|
57
|
+
private handleSaveToggle;
|
|
58
|
+
private handleConfirm;
|
|
59
|
+
private handleCancel;
|
|
60
|
+
private executeSave;
|
|
61
|
+
private closeSave;
|
|
62
|
+
private handleRevert;
|
|
34
63
|
private setView;
|
|
64
|
+
private forwardViewKey;
|
|
35
65
|
private showNotice;
|
|
36
66
|
private paint;
|
|
37
67
|
private buildFrame;
|
|
38
68
|
private renderTabBar;
|
|
69
|
+
private renderStatusBar;
|
|
39
70
|
private renderHelp;
|
|
71
|
+
private renderQuitConfirm;
|
|
40
72
|
}
|