tickmarkr 2.5.4 → 2.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/adapters/registry.js +6 -1
- package/dist/adapters/types.d.ts +3 -0
- package/dist/cli/commands/approve.d.ts +1 -0
- package/dist/cli/commands/approve.js +118 -9
- package/dist/cli/commands/doctor.d.ts +10 -0
- package/dist/cli/commands/doctor.js +44 -29
- package/dist/cli/commands/fleet.js +199 -33
- package/dist/cli/commands/init.js +196 -6
- package/dist/cli/commands/plan.js +20 -4
- package/dist/cli/commands/resume.js +4 -2
- package/dist/cli/commands/run.js +11 -2
- package/dist/cli/commands/verify.js +102 -81
- package/dist/cli/help.d.ts +2 -0
- package/dist/cli/help.js +3 -1
- package/dist/config/config.d.ts +25 -8
- package/dist/config/config.js +41 -29
- package/dist/config/fleet-overlay.d.ts +3 -9
- package/dist/config/fleet-overlay.js +114 -19
- package/dist/config/fleet-why.d.ts +7 -0
- package/dist/config/fleet-why.js +5 -0
- package/dist/drivers/index.d.ts +15 -1
- package/dist/drivers/index.js +39 -10
- package/dist/drivers/orca.d.ts +119 -10
- package/dist/drivers/orca.js +781 -110
- package/dist/gates/baseline.d.ts +17 -3
- package/dist/gates/baseline.js +63 -15
- package/dist/gates/cache.d.ts +101 -0
- package/dist/gates/cache.js +401 -0
- package/dist/gates/llm.d.ts +3 -0
- package/dist/gates/llm.js +11 -0
- package/dist/gates/review.d.ts +28 -3
- package/dist/gates/review.js +106 -14
- package/dist/gates/run-gates.d.ts +9 -1
- package/dist/gates/run-gates.js +407 -102
- package/dist/gates/test-manifest.d.ts +128 -0
- package/dist/gates/test-manifest.js +463 -0
- package/dist/gates/test-reporter.d.ts +4 -0
- package/dist/gates/test-reporter.js +57 -0
- package/dist/graph/graph.d.ts +2 -0
- package/dist/graph/graph.js +45 -1
- package/dist/route/preference.d.ts +22 -1
- package/dist/route/preference.js +123 -25
- package/dist/route/router.js +31 -6
- package/dist/run/consult.js +5 -4
- package/dist/run/daemon.d.ts +15 -0
- package/dist/run/daemon.js +638 -167
- package/dist/run/execution-budget.d.ts +25 -0
- package/dist/run/execution-budget.js +142 -0
- package/dist/run/git.d.ts +50 -1
- package/dist/run/git.js +131 -12
- package/dist/run/journal.d.ts +16 -2
- package/dist/run/journal.js +115 -25
- package/dist/run/lease.d.ts +58 -0
- package/dist/run/lease.js +310 -0
- package/dist/run/merge.d.ts +2 -0
- package/dist/run/merge.js +91 -3
- package/dist/run/operator-state.d.ts +11 -0
- package/dist/run/operator-state.js +17 -3
- package/dist/run/recovery.d.ts +8 -0
- package/dist/run/recovery.js +25 -0
- package/dist/run/repair-selection.d.ts +12 -0
- package/dist/run/repair-selection.js +56 -0
- package/dist/run/stall.d.ts +6 -1
- package/dist/run/stall.js +60 -3
- package/dist/tui/cockpit/board.d.ts +96 -0
- package/dist/tui/cockpit/board.js +346 -0
- package/dist/tui/cockpit/decision-actions.js +2 -0
- package/dist/tui/cockpit/layout.d.ts +5 -1
- package/dist/tui/cockpit/layout.js +8 -3
- package/dist/tui/cockpit/live-runtime.js +71 -21
- package/dist/tui/cockpit/run-view.d.ts +7 -5
- package/dist/tui/cockpit/run-view.js +12 -11
- package/dist/tui/ink/fleet-app.d.ts +49 -27
- package/dist/tui/ink/fleet-app.js +229 -38
- package/package.json +2 -2
- package/skills/tickmarkr-overseer/SKILL.md +173 -113
- package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
package/dist/run/merge.js
CHANGED
|
@@ -2,8 +2,10 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { shq } from "../adapters/types.js";
|
|
4
4
|
import { ceilingKillResult, classifyFreshRunnerOutput, classifyRunnerOutput, fileCountDeficit, waitForCalmWindow, effectiveCeilingMs, fingerprint, freshFailures, } from "../gates/baseline.js";
|
|
5
|
+
import { evaluateManifestedTest, isVitestTestCommand } from "../gates/test-manifest.js";
|
|
6
|
+
import { computeVerificationIdentity, formatReusedDetails, getVerdictStore, getWorktreeTree, resolveStateDir, isInfraResult, } from "../gates/cache.js";
|
|
5
7
|
import { tickmarkrDir } from "../graph/graph.js";
|
|
6
|
-
import { describeCapacity, gitHead, linkNodeModules, resolveIntegrationBranch, sameCapacity, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
|
|
8
|
+
import { describeCapacity, gitHead, linkNodeModules, resolveIntegrationBranch, resolvedCapacity, sameCapacity, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
|
|
7
9
|
export function integrationBranch(cfg, runId) {
|
|
8
10
|
return `${cfg.integrationBranchPrefix}${runId}`;
|
|
9
11
|
}
|
|
@@ -120,6 +122,11 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
120
122
|
}
|
|
121
123
|
return results;
|
|
122
124
|
}
|
|
125
|
+
const stateDir = resolveStateDir(intWt, runDir);
|
|
126
|
+
const store = getVerdictStore(stateDir);
|
|
127
|
+
// Publish only after the entire verification completes. The daemon kills a cancelled
|
|
128
|
+
// verifier process, so completed early gates must remain in memory until then.
|
|
129
|
+
const pending = new Map();
|
|
123
130
|
for (const [gate, cmd] of gatesToRun) {
|
|
124
131
|
if (runStartCommands !== undefined) {
|
|
125
132
|
const expectedCmd = gate === "test"
|
|
@@ -150,6 +157,73 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
150
157
|
const entry = gate === "test" && (hasTipCommand || (!identity.test && identity.tipTest))
|
|
151
158
|
? baseline?.commands.tipTest
|
|
152
159
|
: baseline?.commands[gate];
|
|
160
|
+
// A distinct tipTest selects a different forgiveness entry even when the command
|
|
161
|
+
// converges with the task command. Include that effective entry in the identity.
|
|
162
|
+
const verificationBaseline = baseline ? { commands: { ...baseline.commands } } : undefined;
|
|
163
|
+
if (verificationBaseline) {
|
|
164
|
+
if (entry)
|
|
165
|
+
verificationBaseline.commands[gate] = entry;
|
|
166
|
+
else
|
|
167
|
+
delete verificationBaseline.commands[gate];
|
|
168
|
+
}
|
|
169
|
+
// A tip verifier may reuse only a completed green from its own scope.
|
|
170
|
+
const id = await computeVerificationIdentity({
|
|
171
|
+
worktree: intWt,
|
|
172
|
+
gate,
|
|
173
|
+
scope: "tip",
|
|
174
|
+
command: cmd,
|
|
175
|
+
baseline: verificationBaseline,
|
|
176
|
+
selectedSet: undefined,
|
|
177
|
+
capacity: resolvedCapacity(),
|
|
178
|
+
});
|
|
179
|
+
// Persistent reuse requires the run's durable journal. Without it there is no
|
|
180
|
+
// recorded cycle provenance to reconcile with the caller's capacity checks.
|
|
181
|
+
const hit = journalFound ? store.get(id) : undefined;
|
|
182
|
+
if (hit && id && hit.pass && !isInfraResult(hit)) {
|
|
183
|
+
results.push({
|
|
184
|
+
gate,
|
|
185
|
+
cmd,
|
|
186
|
+
pass: hit.pass,
|
|
187
|
+
exitCode: hit.exitCode ?? 0,
|
|
188
|
+
fingerprints: hit.meta?.fingerprints ?? [],
|
|
189
|
+
...(hit.meta?.forgiven ? { forgiven: true } : {}),
|
|
190
|
+
details: formatReusedDetails(hit.details, id),
|
|
191
|
+
...(hit.meta?.reportPath ? { reportPath: hit.meta.reportPath } : {}),
|
|
192
|
+
...(hit.meta?.spawnedCommand ? { spawnedCommand: hit.meta.spawnedCommand } : {}),
|
|
193
|
+
...(hit.meta?.artifact ? { artifact: hit.meta.artifact } : {}),
|
|
194
|
+
});
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (id && journalFound)
|
|
198
|
+
pending.set(gate, id);
|
|
199
|
+
// VL-1: tip verify and standalone verify read the SAME invocation-bound report the gate would —
|
|
200
|
+
// a detected vitest test command never reaches the stdout-count/file-count path below (this
|
|
201
|
+
// gate's own manifest is always the FULL suite; verify has no selected screen to hold). A
|
|
202
|
+
// scripted test command that is not the detected runner falls through unchanged.
|
|
203
|
+
if (gate === "test" && isVitestTestCommand(cmd, intWt)) {
|
|
204
|
+
const outcome = await evaluateManifestedTest(cmd, intWt, {
|
|
205
|
+
baselineDurations: entry?.fileDurations,
|
|
206
|
+
longestFile: entry?.longestFile,
|
|
207
|
+
overallCeilingMs: effectiveCeilingMs(entry),
|
|
208
|
+
artifactDir: runDir,
|
|
209
|
+
});
|
|
210
|
+
const artifact = join(runDir, `tip-verify-${gate}.log`);
|
|
211
|
+
if (!outcome.pass)
|
|
212
|
+
writeFileSync(artifact, outcome.details);
|
|
213
|
+
results.push({
|
|
214
|
+
gate,
|
|
215
|
+
cmd,
|
|
216
|
+
pass: outcome.pass,
|
|
217
|
+
exitCode: outcome.exitCode,
|
|
218
|
+
reportPath: outcome.reportPath,
|
|
219
|
+
spawnedCommand: outcome.meta.spawnedCommand,
|
|
220
|
+
fingerprints: outcome.meta.failingTests ?? [],
|
|
221
|
+
details: outcome.details,
|
|
222
|
+
...(outcome.classification ? { cause: outcome.classification } : {}),
|
|
223
|
+
...(outcome.pass ? {} : { artifact }),
|
|
224
|
+
});
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
153
227
|
// OBS-534: the ceiling is the BATTERY's, derived by effectiveCeilingMs from the same baseline entry
|
|
154
228
|
// this loop already reads for forgiveness two lines down — never the flat DEFAULT_SHELL_TIMEOUT_MS
|
|
155
229
|
// `sh` defaults to. A suite whose capture measured 600007ms carries a recorded 1800021ms ceiling;
|
|
@@ -216,7 +290,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
216
290
|
const pass = !deficit && (r.code === 0 || greenTeardown || forgiven);
|
|
217
291
|
if (!pass)
|
|
218
292
|
writeFileSync(artifact, raw);
|
|
219
|
-
|
|
293
|
+
const tipResult = {
|
|
220
294
|
gate,
|
|
221
295
|
cmd,
|
|
222
296
|
pass,
|
|
@@ -233,7 +307,21 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
233
307
|
...(forgiven && !deficit ? { forgiven: true } : {}),
|
|
234
308
|
...(cause ? { cause } : {}),
|
|
235
309
|
...(pass ? {} : { artifact }),
|
|
236
|
-
}
|
|
310
|
+
};
|
|
311
|
+
results.push(tipResult);
|
|
312
|
+
}
|
|
313
|
+
// Recheck through the Git runner before publishing: cancellation disables further
|
|
314
|
+
// spawns in the daemon child, and a command that changed the tree invalidates reuse.
|
|
315
|
+
const completedTree = pending.size ? await getWorktreeTree(intWt) : undefined;
|
|
316
|
+
for (const result of results) {
|
|
317
|
+
const id = pending.get(result.gate);
|
|
318
|
+
if (id && id.tree === completedTree && !isInfraResult(result)) {
|
|
319
|
+
store.set(id, { ...result, meta: {
|
|
320
|
+
fingerprints: result.fingerprints, forgiven: result.forgiven,
|
|
321
|
+
reportPath: result.reportPath, spawnedCommand: result.spawnedCommand,
|
|
322
|
+
artifact: result.artifact,
|
|
323
|
+
} });
|
|
324
|
+
}
|
|
237
325
|
}
|
|
238
326
|
return results;
|
|
239
327
|
}
|
|
@@ -30,11 +30,19 @@ export interface OperatorTask {
|
|
|
30
30
|
evidence?: EvidenceIdentity;
|
|
31
31
|
mergeEvidence?: EvidenceIdentity;
|
|
32
32
|
gates: Record<string, OperatorGate>;
|
|
33
|
+
/** BD-1: the board's per-task counters — latest dispatch channel, review verdicts drawn, human parks. */
|
|
34
|
+
channel?: string;
|
|
35
|
+
reviewRounds: number;
|
|
36
|
+
parks: number;
|
|
33
37
|
}
|
|
34
38
|
export interface OperatorSnapshot {
|
|
35
39
|
sequence: number;
|
|
36
40
|
observedAt: number;
|
|
37
41
|
lastEventAt?: string;
|
|
42
|
+
firstEventAt?: string;
|
|
43
|
+
/** BD-1 header counts: run-resume and escalation rows seen. */
|
|
44
|
+
resumes: number;
|
|
45
|
+
escalations: number;
|
|
38
46
|
lifecycle: "PENDING" | "RUNNING" | "PARTIAL" | "APPROVED" | "COMPLETE" | "UNKNOWN";
|
|
39
47
|
label: string;
|
|
40
48
|
green: boolean;
|
|
@@ -63,6 +71,9 @@ export declare class OperatorStateFold {
|
|
|
63
71
|
private approved;
|
|
64
72
|
private tipFailed;
|
|
65
73
|
private lastEventAt?;
|
|
74
|
+
private firstEventAt?;
|
|
75
|
+
private resumes;
|
|
76
|
+
private escalations;
|
|
66
77
|
private passed;
|
|
67
78
|
private total;
|
|
68
79
|
apply(record: OperatorRecord): void;
|
|
@@ -16,11 +16,19 @@ export class OperatorStateFold {
|
|
|
16
16
|
approved = false;
|
|
17
17
|
tipFailed = false;
|
|
18
18
|
lastEventAt;
|
|
19
|
+
firstEventAt;
|
|
20
|
+
resumes = 0;
|
|
21
|
+
escalations = 0;
|
|
19
22
|
passed = 0;
|
|
20
23
|
total = 0;
|
|
21
24
|
apply(record) {
|
|
22
25
|
const { event: e } = record;
|
|
23
26
|
this.lastEventAt = e.ts;
|
|
27
|
+
this.firstEventAt ??= e.ts;
|
|
28
|
+
if (e.event === "run-resume")
|
|
29
|
+
this.resumes++;
|
|
30
|
+
if (e.event === "escalation")
|
|
31
|
+
this.escalations++;
|
|
24
32
|
const ref = identity(record);
|
|
25
33
|
if (e.event === "run-start" || e.event === "run-resume") {
|
|
26
34
|
if (e.event === "run-start" && !this.start) {
|
|
@@ -72,6 +80,9 @@ export class OperatorStateFold {
|
|
|
72
80
|
t.path = typeof e.data.worktree === "string" ? e.data.worktree : typeof e.data.cwd === "string" ? e.data.cwd : undefined;
|
|
73
81
|
t.pane = typeof e.data.pane === "string" ? e.data.pane : undefined;
|
|
74
82
|
t.alarmMs = typeof e.data.alarmMs === "number" ? e.data.alarmMs : undefined;
|
|
83
|
+
const a = e.data.assignment;
|
|
84
|
+
if (a && typeof a.adapter === "string" && typeof a.model === "string")
|
|
85
|
+
t.channel = `${a.adapter}:${a.model}`;
|
|
75
86
|
}
|
|
76
87
|
if (e.event === "task-done")
|
|
77
88
|
t.state = "completed";
|
|
@@ -85,6 +96,7 @@ export class OperatorStateFold {
|
|
|
85
96
|
t.state = "failed";
|
|
86
97
|
if (e.event === "task-human") {
|
|
87
98
|
t.state = "human";
|
|
99
|
+
t.parks++;
|
|
88
100
|
t.parkKind = typeof e.data.kind === "string" ? e.data.kind : undefined;
|
|
89
101
|
}
|
|
90
102
|
if (e.event === "task-blocked")
|
|
@@ -98,6 +110,8 @@ export class OperatorStateFold {
|
|
|
98
110
|
if (typeof gate === "string" && GATE_NAMES.includes(gate)) {
|
|
99
111
|
if (e.event === "gate-start")
|
|
100
112
|
t.gates[gate] = { state: "running", evidence: ref };
|
|
113
|
+
if (e.event === "gate-result" && gate === "review")
|
|
114
|
+
t.reviewRounds++;
|
|
101
115
|
if (e.event === "gate-result")
|
|
102
116
|
t.gates[gate] = {
|
|
103
117
|
state: e.data.disabled === true ? "disabled" : e.data.skipped === true ? "not-run" : e.data.pass === true ? "passed" : e.data.pass === false ? "failed" : "unknown", evidence: ref,
|
|
@@ -107,7 +121,7 @@ export class OperatorStateFold {
|
|
|
107
121
|
task(id) {
|
|
108
122
|
let t = this.tasks.get(id);
|
|
109
123
|
if (!t) {
|
|
110
|
-
t = { id, state: "unknown", merged: false, dispatches: 0, gates: emptyGates() };
|
|
124
|
+
t = { id, state: "unknown", merged: false, dispatches: 0, reviewRounds: 0, parks: 0, gates: emptyGates() };
|
|
111
125
|
this.tasks.set(id, t);
|
|
112
126
|
}
|
|
113
127
|
return t;
|
|
@@ -117,7 +131,7 @@ export class OperatorStateFold {
|
|
|
117
131
|
const comparable = this.comparableTo(hash);
|
|
118
132
|
const ids = comparable ? [...graph.tasks.map(t => t.id), ...[...this.tasks.keys()].filter(id => !graph.tasks.some(t => t.id === id))] : [...this.tasks.keys()];
|
|
119
133
|
const tasks = ids.map(id => {
|
|
120
|
-
const t = this.tasks.get(id) ?? { id, state: "unknown", merged: false, dispatches: 0, gates: emptyGates() };
|
|
134
|
+
const t = this.tasks.get(id) ?? { id, state: "unknown", merged: false, dispatches: 0, reviewRounds: 0, parks: 0, gates: emptyGates() };
|
|
121
135
|
return { ...t, title: comparable ? graph.tasks.find(g => g.id === id)?.title : undefined, gates: Object.fromEntries(Object.entries(t.gates).map(([g, cell]) => [g, { ...cell }])) };
|
|
122
136
|
});
|
|
123
137
|
const buckets = Object.fromEntries(bucketNames.map(k => [k, this.end?.buckets[k] === undefined ? undefined : [...this.end.buckets[k]]]));
|
|
@@ -134,7 +148,7 @@ export class OperatorStateFold {
|
|
|
134
148
|
&& bucketNames.every(k => buckets[k]?.length === 0);
|
|
135
149
|
const lifecycle = !readable || !this.start ? "UNKNOWN" : this.active ? "RUNNING" : this.approved ? "APPROVED" : green ? "COMPLETE" : this.end ? "PARTIAL" : "PENDING";
|
|
136
150
|
return {
|
|
137
|
-
sequence, observedAt, lastEventAt: this.lastEventAt, lifecycle,
|
|
151
|
+
sequence, observedAt, lastEventAt: this.lastEventAt, firstEventAt: this.firstEventAt, resumes: this.resumes, escalations: this.escalations, lifecycle,
|
|
138
152
|
label: this.approved ? "approved; resume required" : lifecycle,
|
|
139
153
|
green, currentTip, comparable, comparison: comparable ? "matching graph" : "not comparable",
|
|
140
154
|
merged,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { GateResult } from "../gates/types.js";
|
|
2
|
+
import type { JournalEvent } from "./journal.js";
|
|
3
|
+
export type FailureDisposition = "infrastructure" | "behavioral" | "unknown";
|
|
4
|
+
export type VerificationRetryCause = "infrastructure" | "host-starved";
|
|
5
|
+
/** Structured verifier evidence wins; neither an exit code nor prose proves infrastructure. */
|
|
6
|
+
export declare function failureDisposition(result: Pick<GateResult, "pass" | "meta">): FailureDisposition;
|
|
7
|
+
/** Reserve before re-execution. Interrupted reservations and new subjects never refill the task cap. */
|
|
8
|
+
export declare function reserveInfrastructureRetry(events: readonly JournalEvent[], taskId: string, subject: string, append: (event: string, taskId: string, data: Record<string, unknown>) => void, cause?: VerificationRetryCause): boolean;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Structured verifier evidence wins; neither an exit code nor prose proves infrastructure. */
|
|
2
|
+
export function failureDisposition(result) {
|
|
3
|
+
if (result.pass)
|
|
4
|
+
return "unknown";
|
|
5
|
+
if (result.meta?.classification === "regression")
|
|
6
|
+
return "behavioral";
|
|
7
|
+
if (result.meta?.infra === true && result.meta?.classification === "infra")
|
|
8
|
+
return "infrastructure";
|
|
9
|
+
return "unknown";
|
|
10
|
+
}
|
|
11
|
+
/** Reserve before re-execution. Interrupted reservations and new subjects never refill the task cap. */
|
|
12
|
+
export function reserveInfrastructureRetry(events, taskId, subject, append, cause = "infrastructure") {
|
|
13
|
+
const used = events.filter((e) => e.taskId === taskId && e.event === "infra-retry-reserved");
|
|
14
|
+
const malformed = used.some((e) => typeof e.data.subject !== "string" || !e.data.subject
|
|
15
|
+
|| (e.data.cause !== "infrastructure" && e.data.cause !== "host-starved"));
|
|
16
|
+
const reason = !subject ? "unknown-subject" : malformed ? "invalid-accounting"
|
|
17
|
+
: used.some((e) => e.data.subject === subject) ? "subject-allowance-exhausted"
|
|
18
|
+
: used.length >= 2 ? "task-allowance-exhausted" : undefined;
|
|
19
|
+
if (reason) {
|
|
20
|
+
append("infra-retry-denied", taskId, { subject, cause, reason, consumed: used.length });
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
append("infra-retry-reserved", taskId, { subject, cause, consumed: used.length + 1 });
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { JournalEvent } from "./journal.js";
|
|
2
|
+
export interface RepairSelectionDecision {
|
|
3
|
+
selectTests: boolean;
|
|
4
|
+
requiredFiles: string[];
|
|
5
|
+
reason: "no-test-failure" | "known-failing-files" | "legacy-test-failure" | "unattributed-test-failure" | "full-suite-failure";
|
|
6
|
+
}
|
|
7
|
+
type SelectionEvent = Pick<JournalEvent, "event" | "taskId" | "data">;
|
|
8
|
+
/** Diagnostic selection only: neither a narrow pass nor this decision can authorize integration.
|
|
9
|
+
* Distrust and known failing files survive later green results, approvals and resume. A historical
|
|
10
|
+
* ambiguous failure is never reinterpreted using a newly enabled policy. */
|
|
11
|
+
export declare function repairSelectionDecision(events: readonly SelectionEvent[], taskId: string, enabled: boolean): RepairSelectionDecision;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** These are literal repository-relative identities. Existence and selection support are checked
|
|
2
|
+
* against the current worktree by the gate; this journal fold cannot establish either. */
|
|
3
|
+
function fileIdentities(value) {
|
|
4
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((file) => typeof file !== "string" || !file || /[\\\x00-\x1f\x7f]/.test(file)
|
|
5
|
+
|| /^[A-Za-z]:/.test(file) || file.split("/").some((part) => !part || part === "." || part === ".."))) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
return [...new Set(value)].sort();
|
|
9
|
+
}
|
|
10
|
+
/** Diagnostic selection only: neither a narrow pass nor this decision can authorize integration.
|
|
11
|
+
* Distrust and known failing files survive later green results, approvals and resume. A historical
|
|
12
|
+
* ambiguous failure is never reinterpreted using a newly enabled policy. */
|
|
13
|
+
export function repairSelectionDecision(events, taskId, enabled) {
|
|
14
|
+
const required = new Set();
|
|
15
|
+
let distrust;
|
|
16
|
+
for (const event of events) {
|
|
17
|
+
if (event.taskId !== taskId || event.event !== "gate-result"
|
|
18
|
+
|| event.data.gate !== "test" || event.data.pass !== false)
|
|
19
|
+
continue;
|
|
20
|
+
const data = event.data;
|
|
21
|
+
if (!enabled) {
|
|
22
|
+
distrust ??= "legacy-test-failure";
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
// Only the recorded closed disposition can exempt infrastructure. Contradictory structured
|
|
26
|
+
// failure evidence cannot turn a behavioral failure into a harmless infrastructure event.
|
|
27
|
+
if (data.disposition === "infrastructure" && data.classification !== "regression"
|
|
28
|
+
&& (data.failingFiles === undefined || (Array.isArray(data.failingFiles) && data.failingFiles.length === 0)))
|
|
29
|
+
continue;
|
|
30
|
+
if (data.disposition !== "behavioral" || typeof data.commit !== "string"
|
|
31
|
+
|| !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(data.commit)) {
|
|
32
|
+
distrust ??= "unattributed-test-failure";
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
// A replacement full-suite failure follows a passing screen in the current pipeline. No
|
|
36
|
+
// ordinary full-suite failure establishes that a selector safely covers the failed behavior.
|
|
37
|
+
if (data.fullSuite === true) {
|
|
38
|
+
distrust ??= "full-suite-failure";
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const selected = fileIdentities(data.selectedTests);
|
|
42
|
+
const failing = fileIdentities(data.failingFiles);
|
|
43
|
+
if (!selected || !failing || failing.some((file) => !selected.includes(file))
|
|
44
|
+
|| (data.fullSuite !== undefined && data.fullSuite !== false)) {
|
|
45
|
+
distrust ??= "unattributed-test-failure";
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
for (const file of failing)
|
|
49
|
+
required.add(file);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
selectTests: distrust === undefined,
|
|
53
|
+
requiredFiles: [...required].sort(),
|
|
54
|
+
reason: distrust ?? (required.size ? "known-failing-files" : "no-test-failure"),
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/run/stall.d.ts
CHANGED
|
@@ -10,6 +10,10 @@ export declare function harvestCpuFlatWindowMs(resolutionMs: number): number;
|
|
|
10
10
|
/** Test seam — pin the quantization-aware flat window without changing production policy. */
|
|
11
11
|
export declare function setHarvestCpuFlatMsForTests(ms: number): void;
|
|
12
12
|
export declare function resetHarvestCpuFlatMsForTests(): void;
|
|
13
|
+
/** The dispatch shell records its foreground group in an attempt-unique artifact. */
|
|
14
|
+
export declare function readOwnedProcessGroup(path: string): number | undefined;
|
|
15
|
+
/** Never infer ownership from a task name or scan another attempt's marker when reaping. */
|
|
16
|
+
export declare function reapOwnedProcessGroup(group: number | undefined, cwd: string): Promise<number[] | null>;
|
|
13
17
|
export declare function workerTreeCpuMs(marker: string, cwd: string): Promise<{
|
|
14
18
|
ms: number;
|
|
15
19
|
resolutionMs: number;
|
|
@@ -17,6 +21,7 @@ export declare function workerTreeCpuMs(marker: string, cwd: string): Promise<{
|
|
|
17
21
|
export declare class WorkerTreeCpuAccountant {
|
|
18
22
|
private marker;
|
|
19
23
|
private cwd;
|
|
24
|
+
private group?;
|
|
20
25
|
private active;
|
|
21
26
|
private loop;
|
|
22
27
|
private live;
|
|
@@ -24,7 +29,7 @@ export declare class WorkerTreeCpuAccountant {
|
|
|
24
29
|
private gaps;
|
|
25
30
|
private consecutiveGaps;
|
|
26
31
|
private latest;
|
|
27
|
-
constructor(marker: string, cwd: string);
|
|
32
|
+
constructor(marker: string, cwd: string, group?: (() => number | undefined) | undefined);
|
|
28
33
|
private sample;
|
|
29
34
|
start(): Promise<void>;
|
|
30
35
|
read(): {
|
package/dist/run/stall.js
CHANGED
|
@@ -52,6 +52,51 @@ function parsePsCpu(raw) {
|
|
|
52
52
|
+ Math.round(Number(m[4]) * 1000);
|
|
53
53
|
return { ms, frac: m[4].includes(".") };
|
|
54
54
|
}
|
|
55
|
+
/** The dispatch shell records its foreground group in an attempt-unique artifact. */
|
|
56
|
+
export function readOwnedProcessGroup(path) {
|
|
57
|
+
try {
|
|
58
|
+
const raw = readFileSync(path, "utf8").trim();
|
|
59
|
+
const group = /^\d+$/.test(raw) ? Number(raw) : 0;
|
|
60
|
+
return Number.isSafeInteger(group) && group > 1 ? group : undefined;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Never infer ownership from a task name or scan another attempt's marker when reaping. */
|
|
67
|
+
export async function reapOwnedProcessGroup(group, cwd) {
|
|
68
|
+
if (group === undefined)
|
|
69
|
+
return null;
|
|
70
|
+
const own = await shGit(`ps -o pgid= -p ${process.pid}`, cwd, 5_000);
|
|
71
|
+
// An in-process driver fixture (or a non-isolating host) can share the daemon's
|
|
72
|
+
// group. It is not an owned worker group: let the driver's close retire that slot.
|
|
73
|
+
if (own.code !== 0 || !/^\d+$/.test(own.stdout.trim()) || Number(own.stdout.trim()) === group)
|
|
74
|
+
return null;
|
|
75
|
+
try {
|
|
76
|
+
process.kill(-group, "SIGKILL");
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.code !== "ESRCH")
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
let survivors = [];
|
|
83
|
+
// SIGKILL delivery and process retirement are asynchronous. Bound the confirmation
|
|
84
|
+
// grace, retaining names if a process is still present after it.
|
|
85
|
+
for (let probe = 0; probe < 5; probe++) {
|
|
86
|
+
const snapshot = await shGit("ps -Awwo pid=,pgid=,stat=", cwd, 5_000);
|
|
87
|
+
if (snapshot.code !== 0)
|
|
88
|
+
return null;
|
|
89
|
+
survivors = snapshot.stdout.split("\n").flatMap((line) => {
|
|
90
|
+
const row = /^\s*(\d+)\s+(\d+)\s+(\S+)/.exec(line);
|
|
91
|
+
return row && Number(row[2]) === group && !row[3].startsWith("Z") ? [Number(row[1])] : [];
|
|
92
|
+
});
|
|
93
|
+
if (survivors.length === 0)
|
|
94
|
+
break;
|
|
95
|
+
if (probe < 4)
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
97
|
+
}
|
|
98
|
+
return survivors;
|
|
99
|
+
}
|
|
55
100
|
let linuxClockTickMs;
|
|
56
101
|
function linuxProcessCpuMs(pid, cwd) {
|
|
57
102
|
if (!existsSync("/proc/self/stat"))
|
|
@@ -81,7 +126,7 @@ function linuxProcessCpuMs(pid, cwd) {
|
|
|
81
126
|
// Every non-seeded worker process descends from its attempt-unique dispatch script. One ps snapshot
|
|
82
127
|
// finds that root and its descendants. An empty tree is measurable zero; a failed or unparseable
|
|
83
128
|
// snapshot is undefined because missing evidence can never prove inactivity.
|
|
84
|
-
async function workerTreeCpuSnapshot(marker, cwd) {
|
|
129
|
+
async function workerTreeCpuSnapshot(marker, cwd, group) {
|
|
85
130
|
const snapshot = await shGit("ps -Awwo pid=,ppid=,time=,command=", cwd, 15_000);
|
|
86
131
|
if (snapshot.code !== 0)
|
|
87
132
|
return undefined;
|
|
@@ -97,6 +142,16 @@ async function workerTreeCpuSnapshot(marker, cwd) {
|
|
|
97
142
|
if (rows.length === 0)
|
|
98
143
|
return undefined;
|
|
99
144
|
const tree = new Set(rows.filter((p) => p.cmd.includes(marker)).map((p) => p.pid));
|
|
145
|
+
if (group !== undefined) {
|
|
146
|
+
const groups = await shGit("ps -Awwo pid=,pgid=", cwd, 15_000);
|
|
147
|
+
if (groups.code !== 0)
|
|
148
|
+
return undefined;
|
|
149
|
+
for (const line of groups.stdout.split("\n")) {
|
|
150
|
+
const row = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
|
|
151
|
+
if (row && Number(row[2]) === group)
|
|
152
|
+
tree.add(row[1]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
100
155
|
// ps output is not topologically ordered; relax the parent -> child closure until stable.
|
|
101
156
|
for (let grew = true; grew;) {
|
|
102
157
|
grew = false;
|
|
@@ -141,6 +196,7 @@ export async function workerTreeCpuMs(marker, cwd) {
|
|
|
141
196
|
export class WorkerTreeCpuAccountant {
|
|
142
197
|
marker;
|
|
143
198
|
cwd;
|
|
199
|
+
group;
|
|
144
200
|
active = false;
|
|
145
201
|
loop;
|
|
146
202
|
live = new Map();
|
|
@@ -148,12 +204,13 @@ export class WorkerTreeCpuAccountant {
|
|
|
148
204
|
gaps = 0;
|
|
149
205
|
consecutiveGaps = 0;
|
|
150
206
|
latest;
|
|
151
|
-
constructor(marker, cwd) {
|
|
207
|
+
constructor(marker, cwd, group) {
|
|
152
208
|
this.marker = marker;
|
|
153
209
|
this.cwd = cwd;
|
|
210
|
+
this.group = group;
|
|
154
211
|
}
|
|
155
212
|
async sample() {
|
|
156
|
-
const snapshot = await workerTreeCpuSnapshot(this.marker, this.cwd);
|
|
213
|
+
const snapshot = await workerTreeCpuSnapshot(this.marker, this.cwd, this.group?.());
|
|
157
214
|
if (snapshot === undefined) {
|
|
158
215
|
this.gaps++;
|
|
159
216
|
this.live.clear();
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type RunGraph } from "../../graph/schema.js";
|
|
2
|
+
import type { OperatorSnapshot } from "../../run/operator-state.js";
|
|
3
|
+
/** The prototype's palette (tasks-redesign.mjs:24-30): xterm-256 indices, stated once. */
|
|
4
|
+
export declare const BOARD_PALETTE: {
|
|
5
|
+
readonly chrome: 238;
|
|
6
|
+
readonly mute: 243;
|
|
7
|
+
readonly body: 252;
|
|
8
|
+
readonly faint: 240;
|
|
9
|
+
readonly accent: 75;
|
|
10
|
+
readonly pass: 71;
|
|
11
|
+
readonly fail: 174;
|
|
12
|
+
readonly warn: 179;
|
|
13
|
+
readonly park: 140;
|
|
14
|
+
};
|
|
15
|
+
/** The seven gates, declaration order; every cell is exactly CELL columns wide. */
|
|
16
|
+
export declare const BOARD_GATES: readonly ["build", "test", "lint", "evidence", "scope", "acceptance", "review"];
|
|
17
|
+
export declare const BOARD_CELL = 3;
|
|
18
|
+
export declare const BOARD_AREA_W = 16;
|
|
19
|
+
export declare const BOARD_DEPS_W = 14;
|
|
20
|
+
export declare const BOARD_NOTE_RESERVE = 28;
|
|
21
|
+
export declare const BOARD_FIXED: number;
|
|
22
|
+
export declare const BOARD_EFFORT_BAR_MAX = 46;
|
|
23
|
+
/** The board's own key hints — only the ones the Run view handles; nothing advertised is inert. */
|
|
24
|
+
export declare const BOARD_KEYS: readonly ["↑↓ move", "↵ task detail", "q quit"];
|
|
25
|
+
/** Width band the Run view draws in (RULING-231-19 §2: ≥150 full · 110–149 note wraps · <110 stack). */
|
|
26
|
+
export type BoardBand = "full" | "wrap" | "stacked";
|
|
27
|
+
export declare const boardBand: (columns: number) => BoardBand;
|
|
28
|
+
export declare const stripBoardAnsi: (s: string) => string;
|
|
29
|
+
/** ANSI-aware clip at `n` visible cells (prototype `clip`), cut on a grapheme boundary so a
|
|
30
|
+
* double-cell glyph never crosses the limit and an emoji is never emitted as a lone surrogate;
|
|
31
|
+
* a clipped line closes its own colour. */
|
|
32
|
+
export declare function clipBoard(s: string, n: number): string;
|
|
33
|
+
export declare const BOARD_AREA_ORDER: string[];
|
|
34
|
+
/** {codes, unmapped} — abstentions applied, set semantics, misses reported rather than dropped. */
|
|
35
|
+
export declare function areasFor(files: readonly string[]): {
|
|
36
|
+
codes: string[];
|
|
37
|
+
unmapped: string[];
|
|
38
|
+
};
|
|
39
|
+
export interface BoardInput {
|
|
40
|
+
readonly runId: string;
|
|
41
|
+
readonly snapshot: OperatorSnapshot;
|
|
42
|
+
readonly graph?: Pick<RunGraph, "tasks">;
|
|
43
|
+
/** The clock the header's elapsed/ago fields read. */
|
|
44
|
+
readonly now: number;
|
|
45
|
+
/** A live daemon holds this run's lock. */
|
|
46
|
+
readonly live?: boolean;
|
|
47
|
+
/** The task the focus marker sits on; absent draws the prototype's plain indent. */
|
|
48
|
+
readonly selection?: string;
|
|
49
|
+
/** Footer key hints, joined the prototype's way; `false` leaves the footer to the caller (see boardFooter). */
|
|
50
|
+
readonly keys?: readonly string[] | false;
|
|
51
|
+
readonly colour?: boolean;
|
|
52
|
+
}
|
|
53
|
+
/** One task row, in the parts the Run view recomposes per width band. */
|
|
54
|
+
export interface BoardRow {
|
|
55
|
+
readonly id: string;
|
|
56
|
+
/** indent (or the focus marker) + id + area + deps + title */
|
|
57
|
+
readonly head: string;
|
|
58
|
+
/** the seven gate cells */
|
|
59
|
+
readonly strip: string;
|
|
60
|
+
/** channel + attempts */
|
|
61
|
+
readonly tail: string;
|
|
62
|
+
/** `◍ parked ×n · ↻ n review rounds · ✖ gate` / `waiting on …` / `in flight`, joined by ` · ` */
|
|
63
|
+
readonly note: string;
|
|
64
|
+
/** The note's segments, so a band can wrap it across continuation rows without cutting one. */
|
|
65
|
+
readonly noteParts: readonly string[];
|
|
66
|
+
}
|
|
67
|
+
export interface BoardFrame {
|
|
68
|
+
/** Header through the gate header rule, in the prototype's order. */
|
|
69
|
+
readonly top: readonly string[];
|
|
70
|
+
readonly rows: readonly BoardRow[];
|
|
71
|
+
/** Header row parts, aligned to the row parts. */
|
|
72
|
+
readonly gateHeader: {
|
|
73
|
+
readonly head: string;
|
|
74
|
+
readonly strip: string;
|
|
75
|
+
readonly tail: string;
|
|
76
|
+
};
|
|
77
|
+
/** Legend, retired ids, rule, effort fold, footer. */
|
|
78
|
+
readonly bottom: readonly string[];
|
|
79
|
+
readonly width: number;
|
|
80
|
+
readonly lbl: number;
|
|
81
|
+
readonly chan: number;
|
|
82
|
+
}
|
|
83
|
+
/** The prototype's frame, sectioned. `renderBoard` joins it row for row. */
|
|
84
|
+
export declare function boardFrame(input: BoardInput, width: number): BoardFrame;
|
|
85
|
+
/** The footer row: the key hints in chrome, the prototype's leading space kept. */
|
|
86
|
+
export declare const boardFooter: (keys: readonly string[], colour?: boolean) => string;
|
|
87
|
+
/** The prototype's frame as lines, one row per task, unclipped — exactly what the design signed off. */
|
|
88
|
+
export declare function renderBoard(input: BoardInput, width: number): string[];
|
|
89
|
+
/**
|
|
90
|
+
* The Run view's three width bands over the same frame: full draws the prototype's rows; wrap
|
|
91
|
+
* puts a note that does not fit on its own second row; stacked draws the gate cells, channel and
|
|
92
|
+
* attempts under the row so nothing is truncated away.
|
|
93
|
+
*/
|
|
94
|
+
export declare function renderBoardLines(input: BoardInput, width: number): string[];
|
|
95
|
+
/** Pack note segments greedily into rows of `avail` cells; a segment wider than a row splits at the cell. */
|
|
96
|
+
export declare function wrapNote(parts: readonly string[], sep: string, firstAvail: number, avail: number): string[];
|