omp-conductor 0.20.1 → 0.20.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/schema/config.schema.json +24 -0
- package/src/commands/companion.ts +52 -16
- package/src/commands/daemon.ts +14 -25
- package/src/commands/drain.ts +12 -5
- package/src/commands/worker.ts +5 -0
- package/src/config-schema.ts +8 -0
- package/src/config.ts +17 -2
- package/src/daemon/drain.ts +33 -0
- package/src/daemon/http.ts +28 -0
- package/src/daemon/review.ts +11 -11
- package/src/daemon/supervision.ts +144 -1
- package/src/daemon/tick.ts +46 -15
- package/src/daemon/views.ts +17 -0
- package/src/daemon.ts +2 -1
- package/src/decisions.ts +20 -9
- package/src/diff-flags.ts +186 -31
- package/src/doctor.ts +320 -9
- package/src/escalate.ts +187 -15
- package/src/failure-class.ts +176 -4
- package/src/fleet.ts +444 -50
- package/src/graph-health.ts +17 -1
- package/src/groom.ts +11 -0
- package/src/knowledge.ts +75 -21
- package/src/lifecycle.ts +267 -5
- package/src/orchestrator-tick.ts +8 -2
- package/src/ready-gate.ts +100 -5
- package/src/reports.ts +4 -1
- package/src/settlement.ts +98 -7
- package/src/status-render.ts +27 -2
- package/src/store.ts +50 -13
- package/src/to-spec.ts +52 -0
- package/src/tracker/github.ts +132 -10
- package/src/types.ts +31 -0
- package/src/upgrade.ts +12 -5
- package/src/verbs/server.ts +16 -12
package/src/ready-gate.ts
CHANGED
|
@@ -95,6 +95,65 @@ export function acceptanceCriteriaSection(text: string): { heading: string; crit
|
|
|
95
95
|
return { heading: lines[start]!.trim(), criteria };
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** The leading run of backticked tokens in a bullet: `` `a.ts`, `b/c.ts` ``
|
|
99
|
+
* reads as two paths, while a backticked token after prose is prose, never a
|
|
100
|
+
* declaration. Mirrors admission's private helper of the same shape — the
|
|
101
|
+
* gate cannot import it, and a second grammar beside the first would make
|
|
102
|
+
* issues that render correctly for one parse wrongly for the other. */
|
|
103
|
+
function leadingBacktickedRun(text: string): string[] {
|
|
104
|
+
const trimmed = text.trim();
|
|
105
|
+
if (!trimmed.startsWith("`")) return [];
|
|
106
|
+
const run: string[] = [];
|
|
107
|
+
let pos = 0;
|
|
108
|
+
while (true) {
|
|
109
|
+
if (trimmed[pos] !== "`") break;
|
|
110
|
+
const close = trimmed.indexOf("`", pos + 1);
|
|
111
|
+
if (close < 0) break;
|
|
112
|
+
const token = trimmed.slice(pos + 1, close).trim();
|
|
113
|
+
if (token === "") break;
|
|
114
|
+
run.push(token);
|
|
115
|
+
pos = close + 1;
|
|
116
|
+
const sep = /^[,\s]+/.exec(trimmed.slice(pos));
|
|
117
|
+
if (sep === null) break;
|
|
118
|
+
pos += sep[0].length;
|
|
119
|
+
}
|
|
120
|
+
return run;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The read-only entry points an issue declares: files its worker must read —
|
|
125
|
+
* the caller to understand, the file that proves the convention — but never
|
|
126
|
+
* write (#1108). The grammar mirrors the write-lane section: a `#`-to-`######`
|
|
127
|
+
* heading spelling `Read-only` optionally followed by `entry points`, an
|
|
128
|
+
* optional trailing colon, then the contiguous bullet run any non-bullet line
|
|
129
|
+
* ends; each bullet contributes its leading backticked run, so an issue that
|
|
130
|
+
* renders correctly for the lane sections renders correctly here too. Unlike
|
|
131
|
+
* the lane there is no inline form and no bare-token fallback: marking a file
|
|
132
|
+
* read-only is a deliberate act, and the backticked bullet is the whole shape.
|
|
133
|
+
*
|
|
134
|
+
* Every occurrence in the text counts, where a lane declaration replaces:
|
|
135
|
+
* read-only lists accumulate, so a correction comment adds references without
|
|
136
|
+
* restating the body's.
|
|
137
|
+
*/
|
|
138
|
+
export function readOnlyEntryPoints(text: string): string[] {
|
|
139
|
+
const files: string[] = [];
|
|
140
|
+
let inSection = false;
|
|
141
|
+
for (const line of text.split("\n")) {
|
|
142
|
+
if (/^[ \t]*#{1,6}[ \t]+read[-\s]only(?:[ \t]+entry[ \t]+points)?[ \t]*[:.]?[ \t]*$/i.test(line)) {
|
|
143
|
+
inSection = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!inSection || line.trim() === "") continue;
|
|
147
|
+
const marker = line.match(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/);
|
|
148
|
+
if (marker === null) {
|
|
149
|
+
inSection = false;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
files.push(...leadingBacktickedRun(line.slice(marker[0].length)));
|
|
153
|
+
}
|
|
154
|
+
return files;
|
|
155
|
+
}
|
|
156
|
+
|
|
98
157
|
/** Trimmed non-empty strings out of a value the type system claims is a string
|
|
99
158
|
* array. Defensive because the gate is the last thing between a hand-edited
|
|
100
159
|
* store row and a dispatched worker: a malformed field is a refusal, never a
|
|
@@ -114,7 +173,7 @@ function strings(value: unknown): string[] {
|
|
|
114
173
|
* May this verdict be promoted mechanically?
|
|
115
174
|
*
|
|
116
175
|
* Every check is one named miss. The order is fixed — verdict, readability,
|
|
117
|
-
* acceptance criteria, write lane, proof commands, sizing evidence,
|
|
176
|
+
* acceptance criteria, write lane, entry points, proof commands, sizing evidence,
|
|
118
177
|
* dependencies, premise, routing, state labels — so the same input always
|
|
119
178
|
* produces the same list and a digest line is stable across ticks.
|
|
120
179
|
*
|
|
@@ -181,6 +240,7 @@ export function readyGate(input: ReadyGateInput): ReadyGateVerdict {
|
|
|
181
240
|
? undefined
|
|
182
241
|
: (writeLaneSectionHeading(body) ??
|
|
183
242
|
comments.map((comment) => writeLaneSectionHeading(comment.body)).find((found) => found !== undefined));
|
|
243
|
+
const declared = lane === undefined ? [] : [...new Set(strings(lane.files))].sort();
|
|
184
244
|
if (verdictLane.length === 0) {
|
|
185
245
|
// The schema requires at least one path, so this is a hand-edited or
|
|
186
246
|
// corrupt row. An empty lane on both sides would otherwise *match*, and
|
|
@@ -190,11 +250,46 @@ export function readyGate(input: ReadyGateInput): ReadyGateVerdict {
|
|
|
190
250
|
missing.push(
|
|
191
251
|
`the write-lane section (${heading}) parsed no path-like files — name them as backticked bullets directly under the heading`,
|
|
192
252
|
);
|
|
193
|
-
} else {
|
|
194
|
-
|
|
195
|
-
|
|
253
|
+
} else if (declared.length !== verdictLane.length || declared.some((path, index) => path !== verdictLane[index])) {
|
|
254
|
+
missing.push(
|
|
255
|
+
`the issue's write lane [${declared.join(", ")}] disagrees with the verdict's file lane [${verdictLane.join(", ")}]`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// An entry point outside the lane is usually legitimate — the reference
|
|
260
|
+
// file, the caller to understand first — so the gate refuses the *unstated*
|
|
261
|
+
// one, not the idea (#1108): eight settlement flags in one day were lane
|
|
262
|
+
// escapes, several into files the issue itself had already named as an
|
|
263
|
+
// entry point. Putting the file in the lane it is meant to be written in,
|
|
264
|
+
// or listing it under a `## Read-only entry points` heading, both satisfy
|
|
265
|
+
// the check; a hard refusal for every reference would push promoters into
|
|
266
|
+
// stopping the one field workers most need.
|
|
267
|
+
//
|
|
268
|
+
// Judged only when the two lane lists agree: a disagreement is refused
|
|
269
|
+
// anyway, and entry points computed against a lane about to be rewritten
|
|
270
|
+
// would be noise stacked on the actionable fact. No parsed lane means
|
|
271
|
+
// nothing to stand outside of.
|
|
272
|
+
const entryPoints = [...new Set(strings(result.entryPoints))];
|
|
273
|
+
const lanesAgree =
|
|
274
|
+
verdictLane.length > 0 &&
|
|
275
|
+
declared.length === verdictLane.length &&
|
|
276
|
+
declared.every((path, index) => path === verdictLane[index]);
|
|
277
|
+
if (entryPoints.length > 0 && lanesAgree) {
|
|
278
|
+
const readOnly = [
|
|
279
|
+
...new Set([body, ...comments.map((comment) => comment.body)].flatMap((text) => readOnlyEntryPoints(text))),
|
|
280
|
+
];
|
|
281
|
+
// Exact member or anything under a trailing-slash directory entry — the
|
|
282
|
+
// containment the lane enforcement itself applies to written files
|
|
283
|
+
// (diff-flags.ts:withinLane).
|
|
284
|
+
const escaped = entryPoints.filter(
|
|
285
|
+
(path) =>
|
|
286
|
+
!declared.includes(path) &&
|
|
287
|
+
!declared.some((entry) => entry.endsWith("/") && path.startsWith(entry)) &&
|
|
288
|
+
!readOnly.includes(path),
|
|
289
|
+
);
|
|
290
|
+
if (escaped.length > 0) {
|
|
196
291
|
missing.push(
|
|
197
|
-
`
|
|
292
|
+
`entry points ${escaped.join(", ")} are neither in the issue's write lane [${declared.join(", ")}] nor marked read-only — put each in the \`## Exact write lane\` bullets meant for writing, or declare it under \`## Read-only entry points\``,
|
|
198
293
|
);
|
|
199
294
|
}
|
|
200
295
|
}
|
package/src/reports.ts
CHANGED
|
@@ -361,7 +361,10 @@ export function telegramReportSend(p: ProjectConfig): ReportSend {
|
|
|
361
361
|
// limit would still be delivered whole (never truncated) — the seam would
|
|
362
362
|
// simply under-report the extra ids, which is why the split lives at the
|
|
363
363
|
// caller, not here.
|
|
364
|
-
const ids = await sendTelegram(token, chatId, text, {
|
|
364
|
+
const ids = await sendTelegram(token, chatId, text, {
|
|
365
|
+
topicId: resolveProjectTopicId(p),
|
|
366
|
+
project: { name: p.name, workspaceRoot: p.workspaceRoot },
|
|
367
|
+
});
|
|
365
368
|
return ids[0];
|
|
366
369
|
};
|
|
367
370
|
}
|
package/src/settlement.ts
CHANGED
|
@@ -26,10 +26,12 @@ import {
|
|
|
26
26
|
deriveChangedLine,
|
|
27
27
|
} from "./diff-flags.ts";
|
|
28
28
|
import {
|
|
29
|
+
EFFECTIVE_BUDGET_SAMPLE_RUNS,
|
|
29
30
|
SPINNING_CAP_CLASSES,
|
|
30
31
|
COMPOSE_DEPENDENCY_STARTUP_SIGNATURE,
|
|
31
32
|
classifyRun,
|
|
32
33
|
normalise,
|
|
34
|
+
observeTurnBudget,
|
|
33
35
|
type ClassifyFacts,
|
|
34
36
|
} from "./failure-class.ts";
|
|
35
37
|
import { GhPrMissingError } from "./tracker/github.ts";
|
|
@@ -1516,6 +1518,13 @@ export async function classifyAndRecover(d: SettlementDeps): Promise<number> {
|
|
|
1516
1518
|
// merged is settled here when the settle sweep could not establish identity
|
|
1517
1519
|
// (#497). Every other exit returns 0.
|
|
1518
1520
|
let settled = 0;
|
|
1521
|
+
// One read for the whole sweep (#1063): the completed-run sample the
|
|
1522
|
+
// effective turn budget is derived from. Each row is then judged against the
|
|
1523
|
+
// budget *excluding itself*, so a just-killed run's own extreme pace cannot
|
|
1524
|
+
// drag the average it is being explained against.
|
|
1525
|
+
const latencySamples = store.recentLatencySamples(project.name, EFFECTIVE_BUDGET_SAMPLE_RUNS);
|
|
1526
|
+
const observedBudgetFor = (runId: string) =>
|
|
1527
|
+
observeTurnBudget(latencySamples, caps, project.workerModel, runId);
|
|
1519
1528
|
for (const run of store.runsNeedingClassification(project.name, CLASSIFY_BATCH)) {
|
|
1520
1529
|
const facts: ClassifyFacts = {};
|
|
1521
1530
|
let classifiedRun = run;
|
|
@@ -1605,8 +1614,12 @@ export async function classifyAndRecover(d: SettlementDeps): Promise<number> {
|
|
|
1605
1614
|
continue;
|
|
1606
1615
|
}
|
|
1607
1616
|
}
|
|
1608
|
-
|
|
1609
|
-
|
|
1617
|
+
const { cls, recovery, evidence } = classifyRun(
|
|
1618
|
+
classifiedRun,
|
|
1619
|
+
facts,
|
|
1620
|
+
caps,
|
|
1621
|
+
observedBudgetFor(run.id),
|
|
1622
|
+
);
|
|
1610
1623
|
|
|
1611
1624
|
// A healthy green PR is not a failure of any class. Leaving the row
|
|
1612
1625
|
// unclassified is what keeps it eligible for the sweep on the tick where its
|
|
@@ -1695,9 +1708,27 @@ async function recoverRun(
|
|
|
1695
1708
|
// candidate admission can never accept — only the failed label comes off,
|
|
1696
1709
|
// and the exhaustion reaches a human (#490, #348).
|
|
1697
1710
|
if (cls === "wall-clock-cap-progress") {
|
|
1711
|
+
// Same split the requeue branch applies (#1080): an issue the tracker
|
|
1712
|
+
// reports closed was resolved by another route — a duplicate, a
|
|
1713
|
+
// supersede, a decomposition — so continuing it would dispatch work
|
|
1714
|
+
// nobody asked for. That verdict is terminal: release the lifecycle
|
|
1715
|
+
// label(s) the row could be holding (the kill path swaps in-progress
|
|
1716
|
+
// for failed; a crash between those two writes leaves either) and
|
|
1717
|
+
// stamp `recoveredAt`, or `selectUnclassified` re-offers this row on
|
|
1718
|
+
// every pass forever (#1095). An unreadable answer is the tracker
|
|
1719
|
+
// failing to answer, not answering no — it stays transient and retries.
|
|
1698
1720
|
const state = await tracker.issueState(run.issue).catch(() => undefined);
|
|
1721
|
+
if (state === "closed") {
|
|
1722
|
+
store.enqueueLabelOps(project.name, [
|
|
1723
|
+
{ issue: run.issue, op: "remove", label: project.stateLabels.failed },
|
|
1724
|
+
{ issue: run.issue, op: "remove", label: inProgress },
|
|
1725
|
+
]);
|
|
1726
|
+
store.updateRun(run.id, { recoveredAt: Date.now() });
|
|
1727
|
+
log(`#${run.issue} not continued from ${cls}: issue is closed`);
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1699
1730
|
if (state !== "open") {
|
|
1700
|
-
log(`#${run.issue} not continued from ${cls}: issue is
|
|
1731
|
+
log(`#${run.issue} not continued from ${cls}: issue is unreadable (retrying)`);
|
|
1701
1732
|
return;
|
|
1702
1733
|
}
|
|
1703
1734
|
const continuation = store.continuationsFor(project.name, run.issue);
|
|
@@ -1732,6 +1763,42 @@ async function recoverRun(
|
|
|
1732
1763
|
return;
|
|
1733
1764
|
}
|
|
1734
1765
|
|
|
1766
|
+
// `progress-stall`: the watch settled a hung session (#1086), which keeps
|
|
1767
|
+
// the in-progress label held the way an orphan row does. Work to continue
|
|
1768
|
+
// from hands that label back for a continuation brief; a budget already
|
|
1769
|
+
// spent releases the issue to a human instead of re-offering a candidate
|
|
1770
|
+
// admission can never accept — the same ceiling the orphan path honours.
|
|
1771
|
+
if (cls === "progress-stall") {
|
|
1772
|
+
const state = await tracker.issueState(run.issue).catch(() => undefined);
|
|
1773
|
+
if (state !== "open") {
|
|
1774
|
+
log(`#${run.issue} not continued from ${cls}: issue is ${state ?? "unreadable"}`);
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
const continuation = store.continuationsFor(project.name, run.issue);
|
|
1778
|
+
if (hasContinuationBudget(continuation, caps.maxContinuationsPerIssue)) {
|
|
1779
|
+
await swapToQueue(d, run.issue, inProgress);
|
|
1780
|
+
store.updateRun(run.id, { recoveredAt: Date.now() });
|
|
1781
|
+
log(`#${run.issue} requeued after a progress stall: ${evidence}`);
|
|
1782
|
+
} else {
|
|
1783
|
+
store.enqueueLabelOps(project.name, [{ issue: run.issue, op: "remove", label: inProgress }]);
|
|
1784
|
+
store.updateRun(run.id, { recoveredAt: Date.now() });
|
|
1785
|
+
await safeEscalate(d, {
|
|
1786
|
+
tier: 1,
|
|
1787
|
+
project: project.name,
|
|
1788
|
+
issue: run.issue,
|
|
1789
|
+
summary: `#${run.issue} exhausted its continuation budget on progress stalls`,
|
|
1790
|
+
detail: [
|
|
1791
|
+
`Attempt ${run.attempt} was settled silent with work to continue from, but ${continuation} continuation(s) are already charged against a cap of ${caps.maxContinuationsPerIssue}.`,
|
|
1792
|
+
evidence,
|
|
1793
|
+
`Work to continue: branch ${run.branch} at ${run.headSha ?? run.salvageSha}${run.prUrl === undefined ? "" : ` — ${run.prUrl}`}.`,
|
|
1794
|
+
"Sessions on this issue keep hanging, so another run would burn a worker slot for the same outcome. Re-scope it, raise maxContinuationsPerIssue for it, or finish the remaining work by hand.",
|
|
1795
|
+
].join("\n"),
|
|
1796
|
+
});
|
|
1797
|
+
log(`#${run.issue} not continued from ${cls}: continuation budget exhausted`);
|
|
1798
|
+
}
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1735
1802
|
// `model-empty-stop`: the provider answered with empty turns until the
|
|
1736
1803
|
// harness ended the session. The work is not at fault and the row is not a
|
|
1737
1804
|
// failed attempt (the counters exclude the class), so this hands the queue
|
|
@@ -1740,9 +1807,22 @@ async function recoverRun(
|
|
|
1740
1807
|
// wall-clock path is: a provider stuck in that state must reach a human
|
|
1741
1808
|
// instead of consuming the issue forever, one free retry at a time.
|
|
1742
1809
|
if (cls === "model-empty-stop") {
|
|
1810
|
+
// Same split as the wall-clock branch above (#1080, #1095): a closed
|
|
1811
|
+
// issue is terminal — release the lifecycle label(s), stamp
|
|
1812
|
+
// `recoveredAt`, stop being offered — while an unreadable tracker keeps
|
|
1813
|
+
// the row unstamped and retrying on a later pass.
|
|
1743
1814
|
const state = await tracker.issueState(run.issue).catch(() => undefined);
|
|
1815
|
+
if (state === "closed") {
|
|
1816
|
+
store.enqueueLabelOps(project.name, [
|
|
1817
|
+
{ issue: run.issue, op: "remove", label: project.stateLabels.failed },
|
|
1818
|
+
{ issue: run.issue, op: "remove", label: inProgress },
|
|
1819
|
+
]);
|
|
1820
|
+
store.updateRun(run.id, { recoveredAt: Date.now() });
|
|
1821
|
+
log(`#${run.issue} not continued from ${cls}: issue is closed`);
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1744
1824
|
if (state !== "open") {
|
|
1745
|
-
log(`#${run.issue} not continued from ${cls}: issue is
|
|
1825
|
+
log(`#${run.issue} not continued from ${cls}: issue is unreadable (retrying)`);
|
|
1746
1826
|
return;
|
|
1747
1827
|
}
|
|
1748
1828
|
const continuation = store.continuationsFor(project.name, run.issue);
|
|
@@ -1871,11 +1951,22 @@ async function recoverRun(
|
|
|
1871
1951
|
return;
|
|
1872
1952
|
}
|
|
1873
1953
|
// Only when the tracker still shows this issue as ours to hand back. An
|
|
1874
|
-
// issue that is closed
|
|
1875
|
-
//
|
|
1954
|
+
// issue that is closed was resolved by another route and requeueing it
|
|
1955
|
+
// would dispatch work nobody asked for — so that verdict is terminal
|
|
1956
|
+
// (#1080): release the dispatcher-owned in-progress label and stamp
|
|
1957
|
+
// `recoveredAt`, exactly like the operator-withdrawal branch below, or the
|
|
1958
|
+
// sweep re-offers this row on every pass forever. An unreadable answer is
|
|
1959
|
+
// a different thing in kind — the tracker failing to answer, not answering
|
|
1960
|
+
// no — so it stays transient and keeps retrying.
|
|
1876
1961
|
const state = await tracker.issueState(run.issue).catch(() => undefined);
|
|
1962
|
+
if (state === "closed") {
|
|
1963
|
+
store.enqueueLabelOps(project.name, [{ issue: run.issue, op: "remove", label: inProgress }]);
|
|
1964
|
+
store.updateRun(run.id, { recoveredAt: Date.now() });
|
|
1965
|
+
log(`#${run.issue} not requeued from ${cls}: issue is closed`);
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1877
1968
|
if (state !== "open") {
|
|
1878
|
-
log(`#${run.issue} not requeued from ${cls}: issue is
|
|
1969
|
+
log(`#${run.issue} not requeued from ${cls}: issue is unreadable (retrying)`);
|
|
1879
1970
|
return;
|
|
1880
1971
|
}
|
|
1881
1972
|
// A clean orphan whose queue label is already absent is a deliberate
|
package/src/status-render.ts
CHANGED
|
@@ -490,6 +490,9 @@ export function formatFleetStatus(
|
|
|
490
490
|
lastStop: DaemonStop | undefined = undefined,
|
|
491
491
|
siblings: { project: string; live: number }[] = [],
|
|
492
492
|
grooming: string | undefined = undefined,
|
|
493
|
+
/** One line naming the flat-chat deliveries this project's stale pin caused
|
|
494
|
+
* (#1094), or undefined when there is nothing outstanding. */
|
|
495
|
+
misrouteNote: string | undefined = undefined,
|
|
493
496
|
): string {
|
|
494
497
|
const tickLine =
|
|
495
498
|
layers.ticksDetail === undefined
|
|
@@ -595,6 +598,7 @@ export function formatFleetStatus(
|
|
|
595
598
|
recoveryLine,
|
|
596
599
|
herdrLine,
|
|
597
600
|
telegramLine,
|
|
601
|
+
...(misrouteNote === undefined ? [] : [misrouteNote]),
|
|
598
602
|
...(brief === undefined ? [] : [brief]),
|
|
599
603
|
...(decisions === undefined ? [] : [decisions]),
|
|
600
604
|
...(failureClasses === undefined ? [] : [failureClasses]),
|
|
@@ -811,6 +815,18 @@ export function formatReviewCorrections(rounds: readonly ReviewCorrectionRound[]
|
|
|
811
815
|
});
|
|
812
816
|
}
|
|
813
817
|
|
|
818
|
+
/**
|
|
819
|
+
* The #1063 effective-turn-budget annotation for the `new worker turns` row:
|
|
820
|
+
* shown whenever observed per-turn latency makes the configured ceiling
|
|
821
|
+
* unreachable at all — the break-even pace is exactly wall clock ÷ max turns,
|
|
822
|
+
* so anything slower is material, anything faster is noise.
|
|
823
|
+
*/
|
|
824
|
+
function effectiveTurnBudgetSuffix(s: StatusSnapshot): string {
|
|
825
|
+
const observed = s.workerTurnBudgetObserved;
|
|
826
|
+
if (observed === undefined || observed.effectiveTurns >= s.caps.workerMaxTurns) return "";
|
|
827
|
+
return ` (effective ~${observed.effectiveTurns} at ${observed.minutesPerTurn.toFixed(2)} min/turn, last ${observed.sampleSize} runs)`;
|
|
828
|
+
}
|
|
829
|
+
|
|
814
830
|
|
|
815
831
|
function formatProjectBody(
|
|
816
832
|
s: StatusSnapshot,
|
|
@@ -916,7 +932,7 @@ function formatProjectBody(
|
|
|
916
932
|
: `${Math.max(1, Math.round(s.labelOps.oldestAgeMs / 1000))}s`
|
|
917
933
|
})`,
|
|
918
934
|
]),
|
|
919
|
-
` new worker turns ${s.caps.workerMaxTurns}`,
|
|
935
|
+
` new worker turns ${s.caps.workerMaxTurns}${effectiveTurnBudgetSuffix(s)}`,
|
|
920
936
|
...s.turnOverrides.map(
|
|
921
937
|
({ issue, maxTurns }) => ` turn override #${issue} → ${maxTurns} (next attempt)`,
|
|
922
938
|
),
|
|
@@ -1082,6 +1098,15 @@ function formatProjectBody(
|
|
|
1082
1098
|
// the phase the line is naming, and a number attributed to the wrong phase
|
|
1083
1099
|
// is worse than no number. The attempt's own cap remains visible as
|
|
1084
1100
|
// `N/M turns cumulative`.
|
|
1101
|
+
// Transcript silence (#1086), from the progress watch's recorded write
|
|
1102
|
+
// instant — read off the row, never probed at render time (#919). This
|
|
1103
|
+
// is where a stall is visible forming, before the pass settles it.
|
|
1104
|
+
// Skipped under pause, whose silence is the pause working (#938), and
|
|
1105
|
+
// for rows no longer live, whose transcripts have stopped forever.
|
|
1106
|
+
const silent =
|
|
1107
|
+
paused || r.state !== "running" || r.lastProgressAt === undefined
|
|
1108
|
+
? ""
|
|
1109
|
+
: ` silent ${formatDownDuration(Math.max(0, now - r.lastProgressAt))}`;
|
|
1085
1110
|
const progress = paused
|
|
1086
1111
|
? ""
|
|
1087
1112
|
: round !== undefined
|
|
@@ -1093,7 +1118,7 @@ function formatProjectBody(
|
|
|
1093
1118
|
).toFixed(1)} turns/min avg` +
|
|
1094
1119
|
` projects ${Math.round(
|
|
1095
1120
|
(r.turns * s.caps.workerWallClockMs) / Math.max(elapsedMs, 1_000),
|
|
1096
|
-
)} turns at cap
|
|
1121
|
+
)} turns at cap` + silent;
|
|
1097
1122
|
lines.push(
|
|
1098
1123
|
` #${r.issue} ${r.repo} ${state} attempt ${r.attempt} ` +
|
|
1099
1124
|
`${r.turns}/${r.maxTurns} turns${progress} ${r.spendUsd.toFixed(2)} ${r.branch}` +
|
package/src/store.ts
CHANGED
|
@@ -221,6 +221,7 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
221
221
|
failureCharges: true,
|
|
222
222
|
continuationCharges: true,
|
|
223
223
|
terminalEvidence: true,
|
|
224
|
+
lastProgressAt: true,
|
|
224
225
|
};
|
|
225
226
|
|
|
226
227
|
/** Everything SQLite will accept from us. */
|
|
@@ -281,6 +282,8 @@ interface RunRow {
|
|
|
281
282
|
graphTools: string | null;
|
|
282
283
|
failureCharges: number | null;
|
|
283
284
|
continuationCharges: number | null;
|
|
285
|
+
/** NULL for a row the progress pass has not observed, or one with no transcript yet (#1086). */
|
|
286
|
+
lastProgressAt: number | null;
|
|
284
287
|
}
|
|
285
288
|
|
|
286
289
|
/** The `base_health` table exactly as SQLite hands it back. */
|
|
@@ -887,7 +890,11 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
887
890
|
model TEXT,
|
|
888
891
|
graphTools TEXT,
|
|
889
892
|
failureCharges INTEGER,
|
|
890
|
-
continuationCharges INTEGER
|
|
893
|
+
continuationCharges INTEGER,
|
|
894
|
+
-- When the run's transcript was last observed to grow, as epoch ms (#1086).
|
|
895
|
+
-- Written by the daemon's progress watch from the file's own mtime; NULL
|
|
896
|
+
-- reads as "not observed yet", never "no progress".
|
|
897
|
+
lastProgressAt INTEGER
|
|
891
898
|
);
|
|
892
899
|
CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
|
|
893
900
|
CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
|
|
@@ -1752,6 +1759,7 @@ function toRecord(row: RunRow): RunRecord {
|
|
|
1752
1759
|
};
|
|
1753
1760
|
if (row.spendReservedUsd !== null) record.spendReservedUsd = row.spendReservedUsd;
|
|
1754
1761
|
if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
|
|
1762
|
+
if (row.lastProgressAt !== null) record.lastProgressAt = row.lastProgressAt;
|
|
1755
1763
|
if (row.workerPid !== null) record.workerPid = row.workerPid;
|
|
1756
1764
|
if (row.paneId !== null) record.paneId = row.paneId;
|
|
1757
1765
|
if (row.paneLabel !== null) record.paneLabel = row.paneLabel;
|
|
@@ -2321,6 +2329,12 @@ export function openStore(dbPath: string): Store {
|
|
|
2321
2329
|
if (!columns.some((column) => column.name === "terminalEvidence")) {
|
|
2322
2330
|
db.exec("ALTER TABLE runs ADD COLUMN terminalEvidence TEXT");
|
|
2323
2331
|
}
|
|
2332
|
+
// The progress watch's last-observed transcript write (#1086). Rows written
|
|
2333
|
+
// before the column were never observed, so NULL is the honest reading — no
|
|
2334
|
+
// backfill, because the instant only the file's own mtime holds.
|
|
2335
|
+
if (!columns.some((column) => column.name === "lastProgressAt")) {
|
|
2336
|
+
db.exec("ALTER TABLE runs ADD COLUMN lastProgressAt INTEGER");
|
|
2337
|
+
}
|
|
2324
2338
|
// Every row written before #132 is unclassified, and NULL is the honest
|
|
2325
2339
|
// reading of that: the budget counters below deliberately still count an
|
|
2326
2340
|
// unclassified terminal row exactly as this release's predecessor did, so an
|
|
@@ -2686,8 +2700,8 @@ export function openStore(dbPath: string): Store {
|
|
|
2686
2700
|
id, project, issue, repo, branch, worktree, state, attempt, turns,
|
|
2687
2701
|
maxTurns, spendUsd, spendReservedUsd, sessionFile, resumedFromRunId, lane, prUrl, headSha, mergeSha, baseRef,
|
|
2688
2702
|
baseCheck, baseCheckAt, salvageSha, salvageError, salvageAckAt, startedAt,
|
|
2689
|
-
endedAt, lastError, settlementFlags, report, graphTools
|
|
2690
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
2703
|
+
endedAt, lastError, settlementFlags, report, graphTools, lastProgressAt
|
|
2704
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
2691
2705
|
);
|
|
2692
2706
|
const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
|
|
2693
2707
|
const selectGraphToolsObs = db.query<{ graphTools: string }, [string]>(
|
|
@@ -3070,6 +3084,16 @@ export function openStore(dbPath: string): Store {
|
|
|
3070
3084
|
ORDER BY endedAt DESC, rowid DESC
|
|
3071
3085
|
LIMIT ?`,
|
|
3072
3086
|
);
|
|
3087
|
+
// Newest runs that can yield a per-turn pace (#1063): rows with turns and
|
|
3088
|
+
// elapsed time recorded. Same over-sample discipline as the spend query
|
|
3089
|
+
// above — the caller filters by model attribution, so this stays unfiltered
|
|
3090
|
+
// beyond what no row can answer without.
|
|
3091
|
+
const selectLatencySamples = db.query<RunRow, [string, number]>(
|
|
3092
|
+
`SELECT * FROM runs
|
|
3093
|
+
WHERE project = ? AND turns > 0 AND endedAt IS NOT NULL
|
|
3094
|
+
ORDER BY endedAt DESC, rowid DESC
|
|
3095
|
+
LIMIT ?`,
|
|
3096
|
+
);
|
|
3073
3097
|
const selectFailureClassCounts = db.query<{ cls: string; n: number }, [string]>(
|
|
3074
3098
|
`SELECT failureClass AS cls, COUNT(*) AS n FROM runs
|
|
3075
3099
|
WHERE project = ? AND failureClass IS NOT NULL AND recoveredAt IS NULL
|
|
@@ -4481,11 +4505,12 @@ export function openStore(dbPath: string): Store {
|
|
|
4481
4505
|
},
|
|
4482
4506
|
);
|
|
4483
4507
|
// The states the review verb admits and the dispatch pass may therefore
|
|
4484
|
-
// claim (#795): a settled green run,
|
|
4485
|
-
// (`failed` / `killed`)
|
|
4486
|
-
//
|
|
4487
|
-
//
|
|
4488
|
-
//
|
|
4508
|
+
// claim (#795, #1101): a settled green run, a capped/failed run that pushed
|
|
4509
|
+
// one (`failed` / `killed`), and — since #1101 — a `stopped` run that had
|
|
4510
|
+
// already pushed when the operator stopped it. The set is closed on purpose
|
|
4511
|
+
// — a live row (`running` / `claimed`) is already doing its own work, a
|
|
4512
|
+
// `pushed-pending` PR is not green yet, and a `blocked` / `orphaned` /
|
|
4513
|
+
// `merged` row is not work the orchestrator returned for revision.
|
|
4489
4514
|
//
|
|
4490
4515
|
// The terminal leg does two things in the same atomic statement (#795 review
|
|
4491
4516
|
// rounds 1-2): it PRESERVES the budget charge the row's terminal event was
|
|
@@ -4522,13 +4547,21 @@ export function openStore(dbPath: string): Store {
|
|
|
4522
4547
|
const claimRunForReviewGreen = db.query<unknown, [string]>(
|
|
4523
4548
|
`UPDATE runs SET state = 'running', endedAt = NULL WHERE id = ? AND state = 'pushed-green'`,
|
|
4524
4549
|
);
|
|
4525
|
-
//
|
|
4526
|
-
//
|
|
4527
|
-
//
|
|
4528
|
-
//
|
|
4550
|
+
// The stopped leg (#1101): reclaiming an operator-stopped run spends no
|
|
4551
|
+
// budget — an operator stop never charged one — and `prUrl IS NOT NULL`
|
|
4552
|
+
// keeps a stop before any push unclaimable: only a run whose row records
|
|
4553
|
+
// the reviewed PR may be woken for its revision.
|
|
4554
|
+
const claimRunForReviewStopped = db.query<unknown, [string]>(
|
|
4555
|
+
`UPDATE runs SET state = 'running', endedAt = NULL WHERE id = ? AND state = 'stopped' AND prUrl IS NOT NULL`,
|
|
4556
|
+
);
|
|
4557
|
+
// Exactly one leg can match (the row is in exactly one state at the moment
|
|
4558
|
+
// of the claim), a repeated claim matches none, and the three statements
|
|
4559
|
+
// plus the read of `changes` are one transaction — the dispatch pass can
|
|
4560
|
+
// never see a half-claimed row.
|
|
4529
4561
|
const claimRunForReviewTx = db.transaction((runId: string): boolean => {
|
|
4530
4562
|
if (claimRunForReviewTerminal.run(runId).changes > 0) return true;
|
|
4531
|
-
|
|
4563
|
+
if (claimRunForReviewGreen.run(runId).changes > 0) return true;
|
|
4564
|
+
return claimRunForReviewStopped.run(runId).changes > 0;
|
|
4532
4565
|
});
|
|
4533
4566
|
|
|
4534
4567
|
// Appending is a single-statement concatenation so even a write from a
|
|
@@ -4772,6 +4805,7 @@ export function openStore(dbPath: string): Store {
|
|
|
4772
4805
|
toSql(record.settlementFlags),
|
|
4773
4806
|
toSql(record.report),
|
|
4774
4807
|
toSql(record.graphTools),
|
|
4808
|
+
toSql(record.lastProgressAt),
|
|
4775
4809
|
);
|
|
4776
4810
|
return record;
|
|
4777
4811
|
};
|
|
@@ -5889,6 +5923,9 @@ export function openStore(dbPath: string): Store {
|
|
|
5889
5923
|
recentSpendSamples(project: string, limit: number): { turns: number; spendUsd: number }[] {
|
|
5890
5924
|
return selectSpendSamples.all(project, limit).map((row) => ({ ...row }));
|
|
5891
5925
|
},
|
|
5926
|
+
recentLatencySamples(project: string, limit: number): RunRecord[] {
|
|
5927
|
+
return selectLatencySamples.all(project, limit).map(toRecord);
|
|
5928
|
+
},
|
|
5892
5929
|
|
|
5893
5930
|
failureClassCounts(project: string): { cls: FailureClass; n: number }[] {
|
|
5894
5931
|
return selectFailureClassCounts
|
package/src/to-spec.ts
CHANGED
|
@@ -99,6 +99,44 @@ export const TO_SPEC_MAX_SOURCE_AGE_MS = 24 * 60 * 60 * 1000;
|
|
|
99
99
|
/** `routing: "MULTI"` requires `routingSplit` naming one repo per slice. */
|
|
100
100
|
const MULTI_ROUTING = "MULTI" as const;
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* One write-lane entry (#1060): a plausible repository-relative path.
|
|
104
|
+
*
|
|
105
|
+
* A lane entry that is prose — veltro#731's
|
|
106
|
+
* `"docker-compose.dev.yml (only if …)"` parsed as one non-empty string —
|
|
107
|
+
* used to harden into the durable verdict and brick its issue: the ready
|
|
108
|
+
* gate then refuses every promotion whose issue-side lane disagrees, a
|
|
109
|
+
* gate-rejected candidate is deliberately never re-groomed, and the
|
|
110
|
+
* refusal's own remedy (edit the issue to match) would have written the
|
|
111
|
+
* parenthetical into the issue. The grammar is therefore enforced here, at
|
|
112
|
+
* parse time, where a refusal is `malformed`, the retry cooldown can act on
|
|
113
|
+
* it, and nothing unusable becomes an admission contract. Deliberately
|
|
114
|
+
* narrow so no valid lane is newly rejected: dots, hyphens, nested
|
|
115
|
+
* directories, globs and trailing slashes all pass; only a leading `/`
|
|
116
|
+
* (absolute) or whitespace/parentheses inside the entry (prose) is refused.
|
|
117
|
+
*/
|
|
118
|
+
const LANE_ENTRY_PROSE = /[\s()]/;
|
|
119
|
+
|
|
120
|
+
/** Every problem with one write lane's entries, each naming the offending
|
|
121
|
+
* entry verbatim (`field[index] "…"`), in array order — the detail a repair
|
|
122
|
+
* round hands back and a refusal records must be readable against the next
|
|
123
|
+
* pass. Empty for a usable lane. */
|
|
124
|
+
function laneEntryProblems(field: string, entries: readonly string[]): string[] {
|
|
125
|
+
const problems: string[] = [];
|
|
126
|
+
for (const [index, entry] of entries.entries()) {
|
|
127
|
+
if (entry.startsWith("/")) {
|
|
128
|
+
problems.push(
|
|
129
|
+
`${field}[${index}] ${JSON.stringify(entry)} is absolute — a write lane names repository-relative paths`,
|
|
130
|
+
);
|
|
131
|
+
} else if (LANE_ENTRY_PROSE.test(entry)) {
|
|
132
|
+
problems.push(
|
|
133
|
+
`${field}[${index}] ${JSON.stringify(entry)} is not a path — write-lane entries carry no whitespace or parentheses`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return problems;
|
|
138
|
+
}
|
|
139
|
+
|
|
102
140
|
const ToSpecSourceSchema = z
|
|
103
141
|
.object({
|
|
104
142
|
name: z.string().trim().min(1).describe("The authoritative source that was read: repo or tracker, e.g. `TerrifiedBug/conductor`."),
|
|
@@ -146,6 +184,14 @@ const ToSpecDecompositionChildSchema = z
|
|
|
146
184
|
.describe("The focused commands that prove this child, each with its cwd when it matters."),
|
|
147
185
|
})
|
|
148
186
|
.strict()
|
|
187
|
+
.superRefine((child, ctx) => {
|
|
188
|
+
// #1060: a decomposition child carries the identical admission contract,
|
|
189
|
+
// so its write lane obeys the identical grammar — prose here would
|
|
190
|
+
// harden into a child slice nothing can file.
|
|
191
|
+
for (const problem of laneEntryProblems("writeLane", child.writeLane)) {
|
|
192
|
+
ctx.addIssue({ code: "custom", message: problem });
|
|
193
|
+
}
|
|
194
|
+
})
|
|
149
195
|
.describe("One ordered child slice a decomposition proposal names (#1041).");
|
|
150
196
|
|
|
151
197
|
/**
|
|
@@ -309,6 +355,12 @@ const ToSpecResultSchema = z
|
|
|
309
355
|
if (value.routing !== MULTI_ROUTING && value.routingSplit !== undefined) {
|
|
310
356
|
ctx.addIssue({ code: "custom", message: "routingSplit is only valid with routing `MULTI`" });
|
|
311
357
|
}
|
|
358
|
+
// #1060: the lane is the admission contract, so its entries must be
|
|
359
|
+
// plausible repository-relative paths — refused here, at parse time,
|
|
360
|
+
// rather than hardening into a durable verdict no promotion can use.
|
|
361
|
+
for (const problem of laneEntryProblems("fileLane", value.fileLane)) {
|
|
362
|
+
ctx.addIssue({ code: "custom", message: problem });
|
|
363
|
+
}
|
|
312
364
|
})
|
|
313
365
|
.describe("A complete, to-spec grooming result; nothing outside this shape is accepted.");
|
|
314
366
|
|