tickmarkr 2.5.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/fake.d.ts +1 -0
- package/dist/adapters/fake.js +2 -1
- package/dist/adapters/opencode.d.ts +1 -0
- package/dist/adapters/opencode.js +4 -1
- package/dist/adapters/types.d.ts +14 -0
- package/dist/adapters/types.js +25 -0
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.js +45 -5
- package/dist/cli/commands/plan.js +58 -6
- package/dist/cli/commands/report.js +3 -1
- package/dist/cli/commands/run.js +3 -0
- package/dist/gates/llm.d.ts +5 -0
- package/dist/gates/llm.js +188 -3
- package/dist/gates/review.d.ts +6 -3
- package/dist/gates/review.js +79 -33
- package/dist/gates/run-gates.d.ts +3 -0
- package/dist/gates/run-gates.js +14 -4
- package/dist/run/daemon.d.ts +2 -1
- package/dist/run/daemon.js +150 -36
- package/dist/tui/ink/init-app.d.ts +4 -0
- package/dist/tui/ink/init-app.js +18 -7
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +13 -2
package/dist/run/daemon.js
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { stringify } from "yaml";
|
|
9
9
|
import { classifyDeadChannel, NO_TRAILER_SUMMARY, trailerPattern, UNPARSEABLE_TRAILER_SUMMARY, writePrompt } from "../adapters/prompt.js";
|
|
10
10
|
import { allAdapters, getAdapter, probeAll, readDoctor, rolePools } from "../adapters/registry.js";
|
|
11
|
-
import { addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
|
|
11
|
+
import { SettledTrailerTracker, addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
|
|
12
12
|
import { bannerShell, paneDispatchCommand } from "../brand.js";
|
|
13
13
|
import { collateralHits } from "../compile/collateral.js";
|
|
14
14
|
import { DEFAULT_DIFF_CAP, globalConfigDir, loadConfigWithMode, readOverlayFile, repoOverlayPath, } from "../config/config.js";
|
|
@@ -27,14 +27,25 @@ import { runEnvironment } from "./environment.js";
|
|
|
27
27
|
import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, resolvedCapacity, runWithForkBudget, sameCapacity, sh, shGit, SUITE_PARENT_ENV, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
|
|
28
28
|
import { runInteractiveSeed } from "./interactive-seed.js";
|
|
29
29
|
import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, outstandingReviewFindings, pendingRechecks, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, RECHECK_RELEASE, renderStructuredReviewFinding, repairReachSinceApproval, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
|
|
30
|
-
import { isDiffCapPark } from "../gates/review.js";
|
|
30
|
+
import { isDiffCapPark, pickReviewer } from "../gates/review.js";
|
|
31
31
|
import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
|
|
32
32
|
import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
|
|
33
33
|
import { nextChannel, route } from "../route/router.js";
|
|
34
34
|
import { desiredPanes } from "./reconcile.js";
|
|
35
35
|
import { harvestCpuFlatWindowMs, NUDGEABLE_ADAPTERS, PANE_READ_ROWS, StallProgressTracker, stallSnapshotBannerRows, WorkerTreeCpuAccountant, } from "./stall.js";
|
|
36
|
-
//
|
|
37
|
-
|
|
36
|
+
// The live set is also the ownership claim shared by task cleanup and termination.
|
|
37
|
+
export async function closeLiveSlot(liveSlots, driver, slot) {
|
|
38
|
+
if (!liveSlots.delete(slot))
|
|
39
|
+
return;
|
|
40
|
+
try {
|
|
41
|
+
await driver.close(slot);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
liveSlots.add(slot);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Compatibility exports: gate dispatch can import stall.ts without importing the daemon.
|
|
38
49
|
export { harvestCpuFlatWindowMs, resetHarvestCpuFlatMsForTests, setHarvestCpuFlatMsForTests, workerTreeCpuMs, } from "./stall.js";
|
|
39
50
|
const MODE_RANK = { "staff-led": 0, "risk-based": 1, "partner-led": 2 };
|
|
40
51
|
// An override (flag/spec) re-resolves through loadConfigWithMode itself, via a synthesized repo overlay
|
|
@@ -1264,12 +1275,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1264
1275
|
// opens/closes keep the ledger exact. Termination then closes exactly what is still live — and a
|
|
1265
1276
|
// slot closed once (task-done, quota reroute, gate self-clean) can never be closed twice.
|
|
1266
1277
|
const liveSlots = new Set();
|
|
1267
|
-
const closeSlot =
|
|
1268
|
-
if (!liveSlots.has(s))
|
|
1269
|
-
return; // already closed — never twice
|
|
1270
|
-
await driver.close(s);
|
|
1271
|
-
liveSlots.delete(s);
|
|
1272
|
-
};
|
|
1278
|
+
const closeSlot = (s) => closeLiveSlot(liveSlots, driver, s);
|
|
1273
1279
|
const trackedDriver = {
|
|
1274
1280
|
id: driver.id,
|
|
1275
1281
|
interactive: driver.interactive,
|
|
@@ -1331,7 +1337,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1331
1337
|
// new attempt while this reaper is still closing the old ones.
|
|
1332
1338
|
abortRun(new Error(`terminated by ${sig}`));
|
|
1333
1339
|
if (cfg.visibility.keepPanes !== "forever") {
|
|
1334
|
-
for (const s of liveSlots) { //
|
|
1340
|
+
for (const s of Array.from(liveSlots)) { // snapshot: failed closes restore membership for the next sweep
|
|
1335
1341
|
try {
|
|
1336
1342
|
await closeSlot(s);
|
|
1337
1343
|
}
|
|
@@ -1350,6 +1356,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1350
1356
|
process.on("SIGINT", onTermination);
|
|
1351
1357
|
process.on("SIGTERM", onTermination);
|
|
1352
1358
|
journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
|
|
1359
|
+
const demotedReviewers = new Set(journal.read()
|
|
1360
|
+
.filter((event) => event.event === "review-pool-demotion" && typeof event.data.reviewer === "string")
|
|
1361
|
+
.map((event) => event.data.reviewer));
|
|
1353
1362
|
const reviewHistory = journal.read()
|
|
1354
1363
|
.filter((event) => event.event === "gate-result" && event.data.gate === "review" && typeof event.data.reviewer === "string")
|
|
1355
1364
|
.map((event) => event.data.reviewer);
|
|
@@ -1816,6 +1825,35 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1816
1825
|
if (rs)
|
|
1817
1826
|
journal.append("resume-restore", t.id, { attempts: rs.attempts, tried: [...tried], assignment });
|
|
1818
1827
|
const badReviewers = []; // v1.1: reviewer channels that produced unparseable output for this task
|
|
1828
|
+
const noteReviewEvent = (e) => {
|
|
1829
|
+
journal.append(e.name, t.id, e.payload);
|
|
1830
|
+
if (e.name === "review-no-verdict" && typeof e.payload.reviewer === "string") {
|
|
1831
|
+
if (!badReviewers.includes(e.payload.reviewer))
|
|
1832
|
+
badReviewers.push(e.payload.reviewer);
|
|
1833
|
+
}
|
|
1834
|
+
};
|
|
1835
|
+
// Keep the measured siblings, including a red judge, while replacing only a missing review.
|
|
1836
|
+
// Every dispatched seat is excluded for this task, so the loop is bounded by the eligible pool.
|
|
1837
|
+
const runReviewRecovery = async (task, ctx) => {
|
|
1838
|
+
const round = await runGates(task, ctx);
|
|
1839
|
+
let review = round.results.find((g) => g.gate === "review");
|
|
1840
|
+
while (review?.meta?.noVerdict === true) {
|
|
1841
|
+
const next = pickReviewer(ctx.author, ctx.channels, badReviewers, cfg.review.prefer ?? [], task.routingHints?.floor, reviewHistory, undefined, demotedReviewers);
|
|
1842
|
+
if (!next)
|
|
1843
|
+
break;
|
|
1844
|
+
journal.append("review-infra-retry", t.id, { reviewer: channelKey(next), cause: review.meta.cause });
|
|
1845
|
+
const retried = await runGates({ ...task, gates: ["review"] }, ctx);
|
|
1846
|
+
const replacement = retried.results.find((g) => g.gate === "review");
|
|
1847
|
+
// A dirty tree or another pre-dispatch refusal must still fail closed.
|
|
1848
|
+
if (!replacement) {
|
|
1849
|
+
round.results.push(...retried.results.filter((g) => !gateSatisfied(g)));
|
|
1850
|
+
break;
|
|
1851
|
+
}
|
|
1852
|
+
round.results = round.results.map((g) => g.gate === "review" ? replacement : g);
|
|
1853
|
+
review = replacement;
|
|
1854
|
+
}
|
|
1855
|
+
return round;
|
|
1856
|
+
};
|
|
1819
1857
|
// v1.70 T5 (review-convergence): failed review rounds this task has drawn, counted from the per-task
|
|
1820
1858
|
// review history already in the journal — the SAME review gate-result stream onGate reads to grow the
|
|
1821
1859
|
// reviewer-exclusion list (badReviewers), never a second parallel counter. OBS-189: scoped to the
|
|
@@ -1840,7 +1878,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1840
1878
|
// One helper, both onGate sites (satisfied-gate resume + main attempt loop).
|
|
1841
1879
|
let gateSubject;
|
|
1842
1880
|
const journalGateResult = (g) => {
|
|
1843
|
-
const blocking = gateFailed(g) && (g.gate === "review" || g.gate === "acceptance");
|
|
1881
|
+
const blocking = g.meta?.infra !== true && gateFailed(g) && (g.gate === "review" || g.gate === "acceptance");
|
|
1844
1882
|
// T2: a review that PASSED while DEFERRING a concern still recorded a defect — the prompt
|
|
1845
1883
|
// promises the deferral is recorded and never dropped, and a details string is not a record a
|
|
1846
1884
|
// later round can match. The blocking projection above is the only writer of structured
|
|
@@ -1861,12 +1899,17 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1861
1899
|
// The legacy baseline declines (a build command the repo never configured) have always written
|
|
1862
1900
|
// `pass: true` beside `skipped: true` and every consumer already reads them right, so their row
|
|
1863
1901
|
// is untouched: only a decline that would otherwise be recorded RED changes shape here.
|
|
1864
|
-
|
|
1902
|
+
// A no-verdict review is journaled `infra` + `skipped` with NO `pass`: the canonical reader
|
|
1903
|
+
// (outcome.ts) files it as infra regardless, the `pass === false` readers (rounds, briefs,
|
|
1904
|
+
// park kind) never charge it, and outstandingReviewFindings (journal.ts:816) skips a `skipped`
|
|
1905
|
+
// row instead of reading a missing `pass` as a PASS that settles every open blocking finding.
|
|
1906
|
+
const noVerdictReview = g.gate === "review" && g.meta?.noVerdict === true;
|
|
1907
|
+
const unverdicted = (g.meta?.skipped === true && !g.pass) || noVerdictReview;
|
|
1865
1908
|
journal.append("gate-result", t.id, {
|
|
1866
1909
|
gate: g.gate, ...(unverdicted ? {} : { pass: g.pass }), details: g.details,
|
|
1867
1910
|
...(gateSubject ? { commit: gateSubject.commit, attempt: gateSubject.attempt } : {}),
|
|
1868
1911
|
...(gateSubject?.replayMeasurement ? { replayMeasurement: true } : {}),
|
|
1869
|
-
...(g.meta?.skipped === true ? { skipped: true } : {}),
|
|
1912
|
+
...(g.meta?.skipped === true || noVerdictReview ? { skipped: true } : {}),
|
|
1870
1913
|
// T9: an infra-only exit is journaled AS one. The operator reading a red `test` row has to
|
|
1871
1914
|
// be able to tell "the suite found a defect" from "the runner never ran", and the merge
|
|
1872
1915
|
// predicate's reason for refusing has to be legible in the ledger it refused from.
|
|
@@ -1892,6 +1935,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1892
1935
|
...(g.meta?.reapedGroup === true ? { reapedGroup: true } : {}),
|
|
1893
1936
|
...(g.gate === "review" && typeof g.meta?.reviewer === "string" ? {
|
|
1894
1937
|
reviewer: g.meta.reviewer,
|
|
1938
|
+
...Object.fromEntries(["cause", "seatAuthoredBytes", "bytes", "rawPath", "briefPath", "timeoutMs", "unparseable", "noVerdict", "resolved", "reraised"]
|
|
1939
|
+
.filter((key) => g.meta?.[key] !== undefined).map((key) => [key, g.meta[key]])),
|
|
1895
1940
|
...(typeof g.meta.vendor === "string" ? { vendor: g.meta.vendor } : {}),
|
|
1896
1941
|
...(typeof g.meta.provider === "string" ? { provider: g.meta.provider } : {}),
|
|
1897
1942
|
...(typeof g.meta.rotationSeat === "number" ? { rotationSeat: g.meta.rotationSeat } : {}),
|
|
@@ -1901,7 +1946,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1901
1946
|
// where work is allowed, not where this verdict found the defect.
|
|
1902
1947
|
...(blocking ? {
|
|
1903
1948
|
taskContentDigest: contentDigest,
|
|
1904
|
-
findings: structuredFindings(g.gate, g.details),
|
|
1949
|
+
findings: Array.isArray(g.meta?.findings) ? g.meta.findings : structuredFindings(g.gate, g.details),
|
|
1905
1950
|
} : deferred.length > 0 ? {
|
|
1906
1951
|
taskContentDigest: contentDigest,
|
|
1907
1952
|
findings: deferred,
|
|
@@ -2267,7 +2312,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2267
2312
|
};
|
|
2268
2313
|
await trackedDriver.project?.(t.id, "in-review");
|
|
2269
2314
|
journal.phaseStart(t.id, "gates");
|
|
2270
|
-
const { results } = await withSuiteWindow(t.id, resumedTask.gates.includes("test") && commands.test !== undefined, () =>
|
|
2315
|
+
const { results } = await withSuiteWindow(t.id, resumedTask.gates.includes("test") && commands.test !== undefined, () => runReviewRecovery(resumedTask, {
|
|
2316
|
+
carriedFindings: outstandingReviewFindings(journal.read(), t.id),
|
|
2271
2317
|
worktree: wt, baseRef: taskBase, result: priorResult, author: gateAuthor,
|
|
2272
2318
|
commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
|
|
2273
2319
|
collateral: collateral.get(t.id) ?? [],
|
|
@@ -2283,7 +2329,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2283
2329
|
}
|
|
2284
2330
|
: undefined,
|
|
2285
2331
|
excludeReviewers: badReviewers,
|
|
2286
|
-
reviewHistory,
|
|
2332
|
+
reviewHistory, demotedReviewers,
|
|
2287
2333
|
onGate: async (e) => {
|
|
2288
2334
|
if (e.phase === "start") {
|
|
2289
2335
|
notePhaseStart(e);
|
|
@@ -2291,7 +2337,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2291
2337
|
return;
|
|
2292
2338
|
}
|
|
2293
2339
|
if (e.phase === "note") {
|
|
2294
|
-
|
|
2340
|
+
noteReviewEvent(e);
|
|
2295
2341
|
return;
|
|
2296
2342
|
}
|
|
2297
2343
|
const g = e.result;
|
|
@@ -2318,6 +2364,11 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2318
2364
|
graph = addEvidence(graph, t.id, { commits: approvedCommits, gateResults: results });
|
|
2319
2365
|
saveGraph(repoRoot, graph);
|
|
2320
2366
|
if (!results.every(gateSatisfied)) {
|
|
2367
|
+
const infra = results.find((g) => g.meta?.infra === true);
|
|
2368
|
+
if (infra) {
|
|
2369
|
+
await park(t, `${infra.gate}: ${infra.details}`, "infra", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2321
2372
|
// OBS-547: disposition FIRST, and through the same helper the ordinary attempt path uses. A
|
|
2322
2373
|
// crash between a journaled scope red and its classification must not turn a predicted red
|
|
2323
2374
|
// into a charged one just because a resume is what observed it.
|
|
@@ -2871,6 +2922,12 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2871
2922
|
let startupFailure = false;
|
|
2872
2923
|
let deadWorkerPark;
|
|
2873
2924
|
let settleParsed;
|
|
2925
|
+
const trailerFrames = interactive && adapter.busyFrameMarkers
|
|
2926
|
+
? new SettledTrailerTracker(adapter.busyFrameMarkers) : undefined;
|
|
2927
|
+
const sampleTrailer = (frame) => {
|
|
2928
|
+
const trailer = new RegExp(trailerPattern(nonce)).test(frame);
|
|
2929
|
+
return trailerFrames ? trailerFrames.sample(frame, trailer) : trailer;
|
|
2930
|
+
};
|
|
2874
2931
|
let seedResult;
|
|
2875
2932
|
// v1.22 T5 / OBS-19: auto-answer a fingerprint-matched trust dialog exactly once per slot.
|
|
2876
2933
|
// Any other blocked/idle dialog pages the operator (unlatched since T1 — see below).
|
|
@@ -3036,14 +3093,22 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3036
3093
|
// own source, where "TICKMARKR_EXIT:" is a string literal) must not end the wait. Only a
|
|
3037
3094
|
// parseable trailer or a digit-suffixed exit marker in the harvest is completion.
|
|
3038
3095
|
output = await driver.read(slot, PANE_READ_ROWS); // TUI transcripts carry chrome — read deeper than print's 500
|
|
3039
|
-
finished =
|
|
3096
|
+
finished = sampleTrailer(output);
|
|
3040
3097
|
const exit = exitRe.exec(output);
|
|
3041
|
-
if (finished || exit) {
|
|
3098
|
+
if (finished || (exit && !(trailerFrames && new RegExp(trailerPattern(nonce)).test(output)))) {
|
|
3042
3099
|
exitCode = exit ? Number(exit[1]) : null; // null ⇔ the TUI is still alive
|
|
3043
3100
|
processExited = exit !== null;
|
|
3044
3101
|
await sampleContext(); // final poll-seam sample before leaving the wait
|
|
3045
3102
|
break;
|
|
3046
3103
|
}
|
|
3104
|
+
if (trailerFrames && new RegExp(trailerPattern(nonce)).test(output)) {
|
|
3105
|
+
// A matching busy frame is still a live turn. Preserve the rolling progress budget.
|
|
3106
|
+
await sampleContext();
|
|
3107
|
+
if (stallProgress.observe({ paneText: output, contextTokens }))
|
|
3108
|
+
lastProgressAt = Date.now();
|
|
3109
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(250, remaining)));
|
|
3110
|
+
continue;
|
|
3111
|
+
}
|
|
3047
3112
|
}
|
|
3048
3113
|
// A failed pane read is absence of evidence, never evidence of an absent pane. Keep the
|
|
3049
3114
|
// rolling taskTimeoutMinutes window as the backstop and name the held probe once.
|
|
@@ -3078,6 +3143,16 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3078
3143
|
await new Promise((r) => setTimeout(r, Math.min(slice - spent, 1_000)));
|
|
3079
3144
|
continue;
|
|
3080
3145
|
}
|
|
3146
|
+
// waitOutput is only a wake hint; a driver may miss a trailer already painted.
|
|
3147
|
+
if (trailerFrames && sampleTrailer(paneText)) {
|
|
3148
|
+
output = paneText;
|
|
3149
|
+
finished = true;
|
|
3150
|
+
const exit = exitRe.exec(output);
|
|
3151
|
+
exitCode = exit ? Number(exit[1]) : null;
|
|
3152
|
+
processExited = exit !== null;
|
|
3153
|
+
await sampleContext();
|
|
3154
|
+
break;
|
|
3155
|
+
}
|
|
3081
3156
|
if (paneText.length > 0)
|
|
3082
3157
|
everHadOutput = true;
|
|
3083
3158
|
if (startupFailureInWindow(paneText, workerLaunchedAt)) {
|
|
@@ -3429,9 +3504,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3429
3504
|
// un-answered nudge instead of the remainder of the window.
|
|
3430
3505
|
nudgeDeadline = undefined;
|
|
3431
3506
|
output = await driver.read(slot, PANE_READ_ROWS);
|
|
3432
|
-
finished =
|
|
3507
|
+
finished = sampleTrailer(output);
|
|
3433
3508
|
const exit = exitRe.exec(output);
|
|
3434
|
-
if (finished || exit) {
|
|
3509
|
+
if (finished || (exit && !(trailerFrames && new RegExp(trailerPattern(nonce)).test(output)))) {
|
|
3435
3510
|
exitCode = exit ? Number(exit[1]) : null;
|
|
3436
3511
|
processExited = exit !== null;
|
|
3437
3512
|
await sampleContext();
|
|
@@ -3476,12 +3551,12 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3476
3551
|
// The poll loop already recorded the unreadable pane. Retain the last readable bytes
|
|
3477
3552
|
// so this ambiguous path still reaches the ordinary timeout/consult backstop.
|
|
3478
3553
|
}
|
|
3479
|
-
finished = new RegExp(trailerPattern(nonce)).test(output);
|
|
3554
|
+
finished = trailerFrames ? trailerFrames.settled : new RegExp(trailerPattern(nonce)).test(output);
|
|
3480
3555
|
const exit = exitRe.exec(output);
|
|
3481
3556
|
exitCode = exit ? Number(exit[1]) : null;
|
|
3482
3557
|
processExited = exit !== null;
|
|
3483
3558
|
}
|
|
3484
|
-
if (finished) {
|
|
3559
|
+
if (finished && !trailerFrames) {
|
|
3485
3560
|
await driver.waitAgentStatus(slot, "idle", 5_000); // settle, then re-harvest the final render
|
|
3486
3561
|
output = await driver.read(slot, PANE_READ_ROWS);
|
|
3487
3562
|
}
|
|
@@ -3501,11 +3576,12 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3501
3576
|
break;
|
|
3502
3577
|
await new Promise((r) => setTimeout(r, Math.min(settleDelayMs, remaining)));
|
|
3503
3578
|
output = await driver.read(slot, PANE_READ_ROWS);
|
|
3579
|
+
trailerFrames?.sample(output, new RegExp(trailerPattern(nonce)).test(output));
|
|
3504
3580
|
settleParsed = adapter.parse(output, nonce);
|
|
3505
3581
|
settleTries++;
|
|
3506
3582
|
}
|
|
3507
3583
|
if (settleParsed.summary !== UNPARSEABLE_TRAILER_SUMMARY) {
|
|
3508
|
-
finished = settleParsed.summary !== NO_TRAILER_SUMMARY;
|
|
3584
|
+
finished = settleParsed.summary !== NO_TRAILER_SUMMARY && (!trailerFrames || trailerFrames.settled);
|
|
3509
3585
|
}
|
|
3510
3586
|
}
|
|
3511
3587
|
}
|
|
@@ -3613,13 +3689,18 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3613
3689
|
// encoded and therefore invisible to the raw wait regex. Keep `output` bounded: every text
|
|
3614
3690
|
// classifier below retains its pre-artifact tail surface.
|
|
3615
3691
|
let result = settleParsed ?? adapter.parse(capturedStream.output, nonce);
|
|
3692
|
+
const unsettledTrailer = trailerFrames && !trailerFrames.settled
|
|
3693
|
+
&& (trailerFrames.busyMarkersSeen.size > 0 || new RegExp(trailerPattern(nonce)).test(capturedStream.output));
|
|
3694
|
+
if (unsettledTrailer) {
|
|
3695
|
+
result = { ok: false, summary: `premature-idle: trailer did not settle; busy markers: ${[...trailerFrames.busyMarkersSeen].join(", ") || "awaiting two idle samples"}`, deviations: [], raw: output };
|
|
3696
|
+
}
|
|
3616
3697
|
const adapterCause = result.cause;
|
|
3617
3698
|
// Law 46: there is one completion fact. A classified adapter result with no parse-failure cause
|
|
3618
3699
|
// means its decoder found the nonce-bound trailer even when the raw JSON envelope escaped it.
|
|
3619
|
-
const workerFinished = finished || adapterCause === undefined;
|
|
3700
|
+
const workerFinished = !unsettledTrailer && (finished || adapterCause === undefined);
|
|
3620
3701
|
finished = workerFinished;
|
|
3621
3702
|
const adapterStartupFailure = adapterCause === "startup-failure";
|
|
3622
|
-
const workerCause = adapterStartupFailure
|
|
3703
|
+
const workerCause = unsettledTrailer ? "premature-idle" : adapterStartupFailure
|
|
3623
3704
|
? "startup-failure"
|
|
3624
3705
|
: startupFailure
|
|
3625
3706
|
? "startup-failure"
|
|
@@ -3708,9 +3789,24 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3708
3789
|
// never certifies the channel. Reading the synthesized `finished`/`ok` here reset the streak
|
|
3709
3790
|
// on every harvest, so a CLI that produces commits and swallows every trailer was immune to
|
|
3710
3791
|
// the two-window demotion and stayed first pick for the rest of the run.
|
|
3792
|
+
// Q-1 (OBS-926): BOTH branches read the same tail — a print stream tested whole failed a
|
|
3793
|
+
// pinned codex worker over for a diff line number (`429:`) 41 985 bytes before EOF and for
|
|
3794
|
+
// this repository's own fixture prose. The matched bytes and their stream offset ride the
|
|
3795
|
+
// row so a false positive is legible from the record alone.
|
|
3796
|
+
const quotaMatch = (interactive ? !workerFinished : exitCode !== 0)
|
|
3797
|
+
? QUOTA_RE.exec(stallSnapshotBannerRows(output))
|
|
3798
|
+
: null;
|
|
3799
|
+
// Q-1: a graph pin is an operator instruction — a quota match ALONE never overrides it; only a
|
|
3800
|
+
// channel-attributed error (auth/setup/outage/timeout, the typed dead-channel classes) may.
|
|
3801
|
+
const pin = t.routingHints?.pin;
|
|
3802
|
+
const onPin = !!pin && assignment.adapter === pin.via && assignment.model === pin.model;
|
|
3803
|
+
const channelError = quotaMatch && onPin ? classifyDeadChannel({ ...preHarvestResult, raw: output }) : undefined;
|
|
3804
|
+
const pinRefusesQuota = !!quotaMatch && onPin && !channelError;
|
|
3805
|
+
// a refused quota window is not a no-trailer verdict on the pinned channel: counting it would
|
|
3806
|
+
// demote the pin two tails in and the demotion re-dispatch would move the task off it.
|
|
3711
3807
|
if (preHarvestResult.ok && workerFinished)
|
|
3712
3808
|
noTrailerStreak.set(channelKey(assignment), 0);
|
|
3713
|
-
else if (!workerFinished && cause !== "provider-death") {
|
|
3809
|
+
else if (!workerFinished && cause !== "provider-death" && !pinRefusesQuota) {
|
|
3714
3810
|
const ck = channelKey(assignment);
|
|
3715
3811
|
const streak = (noTrailerStreak.get(ck) ?? 0) + 1;
|
|
3716
3812
|
noTrailerStreak.set(ck, streak);
|
|
@@ -3740,12 +3836,29 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3740
3836
|
// T2 review: the gate is `workerFinished`, not the harvest-synthesized `finished` — a
|
|
3741
3837
|
// committed-but-quota-walled attempt routes here FIRST (its commits ride the carry-forward
|
|
3742
3838
|
// into the next attempt's recreated worktree), it never buys a gate run on throttled work.
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
if (
|
|
3839
|
+
// Q-1 review: NO cap and NO fall-through to the consult ladder (failoverOrRecycle ignores the
|
|
3840
|
+
// pin) — the attempt is counted, so a pin that never clears parks at MAX_ATTEMPTS on its own
|
|
3841
|
+
// channel; only a channel-attributed error below may leave it.
|
|
3842
|
+
if (pinRefusesQuota) {
|
|
3843
|
+
journal.append("quota-failover-refused", t.id, {
|
|
3844
|
+
pin: `${pin.via}:${pin.model}`, channel: channelKey(assignment), matched: quotaMatch[0],
|
|
3845
|
+
reason: "pinned channel; no channel-attributed error", attempt,
|
|
3846
|
+
});
|
|
3847
|
+
await new Promise((r) => setTimeout(r, PROVIDER_DEATH_BACKOFF_MS));
|
|
3848
|
+
continue;
|
|
3849
|
+
}
|
|
3850
|
+
if (quotaMatch && (!onPin || channelError)) {
|
|
3747
3851
|
const next = failover("quota-failover");
|
|
3748
|
-
|
|
3852
|
+
// byte offset inside the PERSISTED stream (the journaled `stream` file) — the capture keeps
|
|
3853
|
+
// only its last MAX_BUF bytes, so the file's origin may sit past the buffer's.
|
|
3854
|
+
const streamBytes = Buffer.byteLength(capturedStream.output);
|
|
3855
|
+
const idx = capturedStream.output.lastIndexOf(quotaMatch[0]);
|
|
3856
|
+
const offset = idx >= 0 ? Buffer.byteLength(capturedStream.output.slice(0, idx)) - Math.max(0, streamBytes - MAX_BUF) : null;
|
|
3857
|
+
journal.append("quota-failover", t.id, {
|
|
3858
|
+
from: channelKey(assignment), to: next ? channelKey(next) : null,
|
|
3859
|
+
matched: quotaMatch[0], offset, stream: capturedStream.path, streamBytes: Math.min(streamBytes, MAX_BUF),
|
|
3860
|
+
...(onPin ? { pin: `${pin.via}:${pin.model}`, channelError } : {}),
|
|
3861
|
+
});
|
|
3749
3862
|
if (next) {
|
|
3750
3863
|
await driver.notify(`tickmarkr ${runId}: ${t.id} quota failover`, { tier: "attention" });
|
|
3751
3864
|
// OBS-17 T2: the superseded slot's pane closes AT REROUTE TIME — it holds a throttled
|
|
@@ -3866,7 +3979,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3866
3979
|
return;
|
|
3867
3980
|
}
|
|
3868
3981
|
if (e.phase === "note") {
|
|
3869
|
-
|
|
3982
|
+
noteReviewEvent(e);
|
|
3870
3983
|
return;
|
|
3871
3984
|
}
|
|
3872
3985
|
const g = e.result;
|
|
@@ -3904,7 +4017,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3904
4017
|
gateSubject = { commit: await gateCommitSubject(taskBase, gated, wt), attempt };
|
|
3905
4018
|
await trackedDriver.project?.(t.id, "in-review");
|
|
3906
4019
|
journal.phaseStart(t.id, "gates");
|
|
3907
|
-
({ results, commits } = await withSuiteWindow(t.id, t.gates.includes("test") && commands.test !== undefined, () =>
|
|
4020
|
+
({ results, commits } = await withSuiteWindow(t.id, t.gates.includes("test") && commands.test !== undefined, () => runReviewRecovery(t, {
|
|
4021
|
+
carriedFindings: outstandingFindings,
|
|
3908
4022
|
worktree: wt, baseRef: taskBase, result, author: assignment,
|
|
3909
4023
|
commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
|
|
3910
4024
|
collateral: collateral.get(t.id) ?? [],
|
|
@@ -3926,7 +4040,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3926
4040
|
}
|
|
3927
4041
|
: undefined,
|
|
3928
4042
|
excludeReviewers: badReviewers,
|
|
3929
|
-
reviewHistory,
|
|
4043
|
+
reviewHistory, demotedReviewers,
|
|
3930
4044
|
onGate,
|
|
3931
4045
|
})));
|
|
3932
4046
|
results.forEach(classifySignalOnlyTest);
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { InitConfigOverlay } from "../../config/config.js";
|
|
2
2
|
type Driver = NonNullable<InitConfigOverlay["driver"]>;
|
|
3
3
|
type VisibilityLlm = NonNullable<NonNullable<InitConfigOverlay["visibility"]>["llm"]>;
|
|
4
|
+
export type DetectedHost = "herdr" | "orca" | "subprocess";
|
|
5
|
+
export declare function detectedHost(env?: NodeJS.ProcessEnv): DetectedHost;
|
|
6
|
+
export declare function driverDescription(driver: Driver, host?: DetectedHost): string;
|
|
4
7
|
export type InitWizardFields = {
|
|
5
8
|
driver: Driver;
|
|
6
9
|
concurrency: number;
|
|
7
10
|
visibilityLlm: VisibilityLlm;
|
|
8
11
|
offerSkills: boolean;
|
|
9
12
|
skillsDefault: boolean;
|
|
13
|
+
detectedHost?: DetectedHost;
|
|
10
14
|
};
|
|
11
15
|
export type InitWizardResult = {
|
|
12
16
|
kind: "continue";
|
package/dist/tui/ink/init-app.js
CHANGED
|
@@ -1,23 +1,33 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, render, Text, useApp, useInput } from "ink";
|
|
3
3
|
import { useRef, useState } from "react";
|
|
4
|
+
import { orcaHostDetected } from "../../drivers/index.js";
|
|
4
5
|
import { ToggleMark } from "./components.js";
|
|
5
6
|
import { clip, INK, inkInput, inkOutput, KeyBar, padCell, Pointer } from "./frame.js";
|
|
6
7
|
const DRIVERS = ["auto", "herdr", "subprocess", "orca"];
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
export function detectedHost(env = process.env) {
|
|
9
|
+
if (env.HERDR_ENV === "1")
|
|
10
|
+
return "herdr";
|
|
11
|
+
if (orcaHostDetected(env))
|
|
12
|
+
return "orca";
|
|
13
|
+
return "subprocess";
|
|
14
|
+
}
|
|
10
15
|
const DRIVER_DESC = {
|
|
11
|
-
auto: "auto
|
|
16
|
+
auto: "auto — resolves to subprocess here",
|
|
12
17
|
herdr: "herdr: every worker runs in a visible pane you can watch and unblock",
|
|
13
18
|
subprocess: "subprocess: headless child processes — no cockpit, same fail-closed gates",
|
|
14
19
|
orca: "orca: visible terminals in the Orca app — picked by auto inside one; name it elsewhere",
|
|
15
20
|
};
|
|
21
|
+
export function driverDescription(driver, host = detectedHost()) {
|
|
22
|
+
if (driver === "auto")
|
|
23
|
+
return `auto — resolves to ${host} here`;
|
|
24
|
+
return DRIVER_DESC[driver];
|
|
25
|
+
}
|
|
16
26
|
const VISIBILITY = ["pane", "headless"];
|
|
17
27
|
// Three fields, one toggle, one action — a descriptor array, deliberately not a forms framework.
|
|
18
|
-
function buildRows(offerSkills, driver) {
|
|
28
|
+
function buildRows(offerSkills, driver, host) {
|
|
19
29
|
const rows = [
|
|
20
|
-
{ id: "driver", section: "Run", label: "Driver", desc:
|
|
30
|
+
{ id: "driver", section: "Run", label: "Driver", desc: driverDescription(driver, host) },
|
|
21
31
|
{ id: "concurrency", section: "Run", label: "Concurrency", desc: "parallel task batteries per run — min 1; an empty or zero entry reverts on leave" },
|
|
22
32
|
{
|
|
23
33
|
id: "visibility",
|
|
@@ -45,6 +55,7 @@ function buildRows(offerSkills, driver) {
|
|
|
45
55
|
}
|
|
46
56
|
export function InitWizardApp({ fields, frameColumns = 74 }) {
|
|
47
57
|
const { exit } = useApp();
|
|
58
|
+
const host = fields.detectedHost ?? detectedHost();
|
|
48
59
|
const [cursor, setCursor] = useState(0);
|
|
49
60
|
const cursorRef = useRef(0);
|
|
50
61
|
const driverRef = useRef(fields.driver);
|
|
@@ -56,7 +67,7 @@ export function InitWizardApp({ fields, frameColumns = 74 }) {
|
|
|
56
67
|
const typedRef = useRef(false);
|
|
57
68
|
const doneRef = useRef(false);
|
|
58
69
|
// Built per render so the description row follows the driver the cursor last cycled to.
|
|
59
|
-
const rows = buildRows(fields.offerSkills, driverRef.current);
|
|
70
|
+
const rows = buildRows(fields.offerSkills, driverRef.current, host);
|
|
60
71
|
const [, setRevision] = useState(0);
|
|
61
72
|
const bump = () => setRevision((n) => n + 1);
|
|
62
73
|
// Leaving the row (or pressing Enter on it) is the commit point: a parse ≥ 1 becomes the new
|
package/package.json
CHANGED
|
@@ -212,19 +212,30 @@ banner_pct() {
|
|
|
212
212
|
# The rule glyph is passed IN, never written as an escape: this host's awk (BWK 20200816) does not
|
|
213
213
|
# honour \xNN, and an unsupported escape matches nothing — the selector would find no rule line and
|
|
214
214
|
# report UNREADABLE on every real pane while staying green against any fixture that skipped awk.
|
|
215
|
+
# OBS-964 (2): a codex footer has NO rule line above it, so the rule selector is UNREADABLE for the whole
|
|
216
|
+
# life of a codex seat. Fall back to the self-labelled `Context N% used` row — the label IS the selector.
|
|
215
217
|
banner=$(printf '%s\n' "$screen" | awk -v rule='─' '
|
|
216
218
|
{ line[NR] = $0
|
|
217
219
|
bare = $0; gsub(/[[:space:]]/, "", bare)
|
|
218
220
|
stripped = bare; gsub(rule, "", stripped)
|
|
219
221
|
if (bare != "" && stripped == "") last = NR }
|
|
220
|
-
END {
|
|
222
|
+
END { labelled = 0
|
|
223
|
+
for (i = 1; i <= NR; i++) if (line[i] ~ /Context [0-9]+% used/) { print line[i]; labelled = 1 }
|
|
224
|
+
if (!labelled && last) for (i = last + 1; i <= NR; i++) if (line[i] ~ /[0-9]+%/) print line[i] }
|
|
221
225
|
' | tail -1)
|
|
222
226
|
# No rule line in the window, or no percentage below it: say so out loud rather than guess.
|
|
223
227
|
[ -n "$banner" ] || { printf 'UNREADABLE\n'; return 0; }
|
|
224
228
|
printf '%s\n' "$banner" |
|
|
225
229
|
grep -Eqi '(^|[^[:alnum:]])(claude|opus|sonnet|haiku|fable|gpt|gemini|glm|kimi|grok|composer|openai|zai)[[:alnum:]_./-]*([[:space:]]|$)' ||
|
|
226
230
|
{ printf 'UNREADABLE\n'; return 0; }
|
|
227
|
-
|
|
231
|
+
# OBS-964: a codex banner carries TWO percentages ("Context 16% used · weekly 96% left"); the LAST one is the
|
|
232
|
+
# quota, not the fill. Prefer the labelled context figure; fall back to the last bare % (claude-code banner).
|
|
233
|
+
pct=$(printf '%s\n' "$banner" | grep -oE 'Context [0-9]+% used' | grep -oE '[0-9]+' | head -1)
|
|
234
|
+
# OBS-964 add.3: a codex footer while WORKING drops the labelled figure but keeps `weekly N% left`; strip the
|
|
235
|
+
# quota phrase before the bare-% fallback so a quota can never be read as fill.
|
|
236
|
+
[ -n "$pct" ] || pct=$(printf '%s\n' "$banner" | sed -E 's/weekly [0-9]+% left//g' | grep -oE '[0-9]+%' | tail -1 | tr -d '%')
|
|
237
|
+
# OBS-964 add.4: keep the row the number came from, so a false reading can be diagnosed from bytes.
|
|
238
|
+
printf '%s\t%s\n' "$(date -u +%H:%M:%SZ)" "$banner" >> "${TMPDIR:-/tmp}/tkr-watch-context-banner-$(printf '%s' "$TARGET" | tr -c 'A-Za-z0-9' _).log" 2>/dev/null || true
|
|
228
239
|
[ -n "$pct" ] && printf '%s\n' "$pct" || printf 'UNREADABLE\n'
|
|
229
240
|
}
|
|
230
241
|
|