tickmarkr 2.5.3 → 2.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/doctor.d.ts +4 -0
- package/dist/cli/commands/plan.js +144 -3
- package/dist/cli/commands/resume.js +2 -0
- package/dist/cli/commands/run.js +10 -0
- package/dist/compile/collateral.d.ts +2 -0
- package/dist/compile/collateral.js +50 -9
- package/dist/compile/ownership.d.ts +7 -0
- package/dist/compile/ownership.js +59 -22
- package/dist/config/config.d.ts +9 -0
- package/dist/config/config.js +13 -2
- package/dist/drivers/herdr.d.ts +5 -0
- package/dist/drivers/herdr.js +14 -3
- package/dist/drivers/types.d.ts +1 -0
- package/dist/gates/review.d.ts +35 -2
- package/dist/gates/review.js +64 -16
- package/dist/gates/run-gates.d.ts +2 -0
- package/dist/gates/run-gates.js +17 -8
- package/dist/route/router.d.ts +13 -0
- package/dist/route/router.js +64 -11
- package/dist/run/daemon.js +241 -29
- package/dist/run/git.d.ts +25 -0
- package/dist/run/git.js +68 -1
- package/dist/tui/cockpit/live-runtime.js +12 -10
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +21 -0
package/dist/run/daemon.js
CHANGED
|
@@ -31,8 +31,9 @@ import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferre
|
|
|
31
31
|
import { isDiffCapPark, pickReviewer } from "../gates/review.js";
|
|
32
32
|
import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
|
|
33
33
|
import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
|
|
34
|
-
import { nextChannel, route } from "../route/router.js";
|
|
34
|
+
import { climbChannel, marginalCostRank, nextChannel, route } from "../route/router.js";
|
|
35
35
|
import { desiredPanes } from "./reconcile.js";
|
|
36
|
+
import { readTierLiveness, readWatchBoard, supervisionBeatPath } from "./supervision.js";
|
|
36
37
|
import { harvestCpuFlatWindowMs, NUDGEABLE_ADAPTERS, PANE_READ_ROWS, StallProgressTracker, stallSnapshotBannerRows, WorkerTreeCpuAccountant, } from "./stall.js";
|
|
37
38
|
// The live set is also the ownership claim shared by task cleanup and termination.
|
|
38
39
|
export async function closeLiveSlot(liveSlots, driver, slot) {
|
|
@@ -1358,7 +1359,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1358
1359
|
return await runWithVerificationBudget(occupancyCapacity, async () => {
|
|
1359
1360
|
// v1.51 T4: every dispatch provenance line begins with the mode and its source; when a pin won
|
|
1360
1361
|
// the route (the final "→ " segment is a pin, not a degraded-to-auto tail) it names the mode it bypassed.
|
|
1361
|
-
const dispatchProvenance = (p) => `mode ${rm.mode.mode} (${rm.source})${p.split("→ ").pop().startsWith("pin ") ? ` — pin bypasses mode ${rm.mode.mode}` : ""} · ${p}`;
|
|
1362
|
+
const dispatchProvenance = (p) => `mode ${rm.mode.mode} (${rm.source})${p.split("→ ").pop().startsWith("pin ") && !p.includes("not re-tried") ? ` — pin bypasses mode ${rm.mode.mode}` : ""} · ${p}`;
|
|
1362
1363
|
// v1.87 T2: one pool per seat role, built once. `channels` stays the WORKER pool — every routing
|
|
1363
1364
|
// call below reads it exactly as before — while the judge, review and consult seats each receive
|
|
1364
1365
|
// the pool their own deny scope allows, so routing.deny.workers benches a channel for dispatch
|
|
@@ -1395,6 +1396,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1395
1396
|
liveSlots.add(s);
|
|
1396
1397
|
return s;
|
|
1397
1398
|
} } : {}),
|
|
1399
|
+
...(driver.retireLostWatch ? { retireLostWatch: driver.retireLostWatch.bind(driver) } : {}),
|
|
1398
1400
|
...(driver.project ? { project: driver.project.bind(driver) } : {}),
|
|
1399
1401
|
...(driver.reconcile ? { reconcile: driver.reconcile.bind(driver) } : {}),
|
|
1400
1402
|
notify: (m, o) => driver.notify(m, o),
|
|
@@ -1468,6 +1470,15 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1468
1470
|
const reviewHistory = journal.read()
|
|
1469
1471
|
.filter((event) => event.event === "gate-result" && event.data.gate === "review" && typeof event.data.reviewer === "string")
|
|
1470
1472
|
.map((event) => event.data.reviewer);
|
|
1473
|
+
// RF-1 (OBS-922 add.3): the seats THIS task's review rows name — the prior reviewers a later round's
|
|
1474
|
+
// floor holds. Task-scoped on purpose: reviewHistory rotates seats run-wide and is never floor evidence.
|
|
1475
|
+
// Each row's journaled dispatch tier travels with it, so a seat that left the pool on resume still counts.
|
|
1476
|
+
// Leg-2 T4 M2: a `review-no-verdict` note is dispatch evidence too — it lands BEFORE the in-gate retry's
|
|
1477
|
+
// gate-result, so a daemon killed between the two leaves it as the task's only record of that seat.
|
|
1478
|
+
const taskReviewers = (taskId) => journal.read()
|
|
1479
|
+
.filter((event) => event.taskId === taskId && typeof event.data.reviewer === "string"
|
|
1480
|
+
&& ((event.event === "gate-result" && event.data.gate === "review") || event.event === "review-no-verdict"))
|
|
1481
|
+
.map((event) => ({ reviewer: event.data.reviewer, tier: event.data.reviewerTier }));
|
|
1471
1482
|
// Capture this before this observer appends run-resume. A prior run-end belongs to a completed
|
|
1472
1483
|
// lifecycle and must use the ordinary resume/redispatch rules; only an interrupted live attempt
|
|
1473
1484
|
// owns reusable measurements or unclean-death residue.
|
|
@@ -1499,19 +1510,99 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1499
1510
|
const commands = detectGateCommands(repoRoot, cfg);
|
|
1500
1511
|
const watchName = formatOwnedName({ role: "watch", taskId: "run", attempt: 0, runId });
|
|
1501
1512
|
let watchSlot;
|
|
1502
|
-
|
|
1513
|
+
let boardOpened = false; // WB-1: only a board this daemon placed is watched below
|
|
1514
|
+
// Leg-2 T7 M1 (OBS-988): a reopen first retires the ghost through the driver's seam so the narrator
|
|
1515
|
+
// launches a NEW pane; a driver that answers with the lost pane anyway (no seam, or a cache it
|
|
1516
|
+
// would not drop) has not reopened anything, and that answer is a FAILED reopen, never a success.
|
|
1517
|
+
const openBoard = async (ghost) => {
|
|
1518
|
+
try {
|
|
1519
|
+
if (ghost?.slot)
|
|
1520
|
+
await trackedDriver.retireLostWatch?.(ghost.slot);
|
|
1521
|
+
const slot = await trackedDriver.narrator(repoRoot, watchCommand(runId), runId);
|
|
1522
|
+
if (ghost && slot.id === ghost.pane) {
|
|
1523
|
+
liveSlots.delete(slot); // the ghost again: no close is ever aimed at it
|
|
1524
|
+
return { ok: false, error: `narrator answered with the lost pane ${ghost.pane}` };
|
|
1525
|
+
}
|
|
1526
|
+
watchSlot = slot;
|
|
1527
|
+
boardOpened = true;
|
|
1528
|
+
return { ok: true, slot };
|
|
1529
|
+
}
|
|
1530
|
+
catch (error) {
|
|
1531
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1532
|
+
}
|
|
1533
|
+
};
|
|
1534
|
+
const placeBoard = async () => {
|
|
1503
1535
|
if (!trackedDriver.narrator) {
|
|
1504
1536
|
noteCapabilityAbsent("narrator");
|
|
1537
|
+
return undefined;
|
|
1538
|
+
}
|
|
1539
|
+
const placed = await openBoard();
|
|
1540
|
+
if (placed.ok)
|
|
1541
|
+
return placed.slot;
|
|
1542
|
+
journal.append("watch-placement-failed", undefined, { error: placed.error });
|
|
1543
|
+
console.error(`tickmarkr: narrator not opened: ${placed.error}`);
|
|
1544
|
+
return undefined;
|
|
1545
|
+
};
|
|
1546
|
+
// WB-1 (OBS-988): the daemon watches its own cockpit. A board is lost when the watch tier's beat
|
|
1547
|
+
// aged past the supervision stale bound, the recorded arm has no presence, or the owner pid is dead.
|
|
1548
|
+
// The beat and presence checks wait for the UI to have armed (armId recorded) — a freshly reopened
|
|
1549
|
+
// board that has not armed yet is not a second loss. Reopens are bounded per run; past the bound
|
|
1550
|
+
// the loss is journaled `boardless` and the narrator is never called again.
|
|
1551
|
+
// Leg-2 T7 M2: one `watch-board-lost` row per LOSS. A loss is identified by the pane and pid the
|
|
1552
|
+
// owner record names; a reopen that fails leaves that record in place, so the next poll sees the
|
|
1553
|
+
// same loss, journals only its own reopen attempt, and the bound retires the run boardless from the
|
|
1554
|
+
// last failed attempt rather than from a repeated lost row.
|
|
1555
|
+
const MAX_BOARD_REOPENS = 3;
|
|
1556
|
+
let boardReopens = journal.read().filter((e) => e.event === "watch-board-reopened" || e.event === "watch-board-reopen-failed").length;
|
|
1557
|
+
let boardless = false;
|
|
1558
|
+
let journaledLoss;
|
|
1559
|
+
const boardLoss = () => {
|
|
1560
|
+
const owner = readWatchBoard(repoRoot, runId);
|
|
1561
|
+
if (!owner)
|
|
1562
|
+
return undefined;
|
|
1563
|
+
const lost = { pane: owner.pane, ...(owner.pid !== undefined ? { pid: owner.pid } : {}) };
|
|
1564
|
+
if (owner.armId !== undefined) {
|
|
1565
|
+
const beat = readTierLiveness(repoRoot, "watch");
|
|
1566
|
+
if (beat.state === "STALE")
|
|
1567
|
+
return { ...lost, beatAgeMs: beat.beatAgeMs };
|
|
1568
|
+
// ponytail: presence path math mirrors supervision.ts's private helper (`<tier>.live.<armId>`)
|
|
1569
|
+
if (!existsSync(join(dirname(supervisionBeatPath(repoRoot, "watch")), `watch.live.${owner.armId}`)))
|
|
1570
|
+
return lost;
|
|
1571
|
+
}
|
|
1572
|
+
if (owner.pid !== undefined && !isPidLive(owner.pid))
|
|
1573
|
+
return lost;
|
|
1574
|
+
return undefined;
|
|
1575
|
+
};
|
|
1576
|
+
const watchBoard = async () => {
|
|
1577
|
+
if (!boardOpened || boardless || !trackedDriver.narrator)
|
|
1578
|
+
return;
|
|
1579
|
+
const loss = boardLoss();
|
|
1580
|
+
if (!loss) {
|
|
1581
|
+
journaledLoss = undefined;
|
|
1505
1582
|
return;
|
|
1506
1583
|
}
|
|
1507
|
-
|
|
1508
|
-
|
|
1584
|
+
const identity = `${loss.pane}:${loss.pid ?? ""}`;
|
|
1585
|
+
if (identity !== journaledLoss) {
|
|
1586
|
+
journaledLoss = identity;
|
|
1587
|
+
boardless = boardReopens >= MAX_BOARD_REOPENS;
|
|
1588
|
+
journal.append("watch-board-lost", undefined, { ...loss, ...(boardless ? { boardless: true } : {}) });
|
|
1589
|
+
if (boardless)
|
|
1590
|
+
return;
|
|
1509
1591
|
}
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1592
|
+
const attempt = ++boardReopens;
|
|
1593
|
+
// The ghost pane is gone: drop it from this run's live set so no close is ever aimed at it.
|
|
1594
|
+
const ghost = watchSlot;
|
|
1595
|
+
if (ghost)
|
|
1596
|
+
liveSlots.delete(ghost);
|
|
1597
|
+
watchSlot = undefined;
|
|
1598
|
+
const reopened = await openBoard({ slot: ghost, pane: loss.pane });
|
|
1599
|
+
if (reopened.ok) {
|
|
1600
|
+
journal.append("watch-board-reopened", undefined, { pane: readWatchBoard(repoRoot, runId)?.pane ?? reopened.slot.id, attempt });
|
|
1601
|
+
return;
|
|
1514
1602
|
}
|
|
1603
|
+
boardless = attempt >= MAX_BOARD_REOPENS;
|
|
1604
|
+
journal.append("watch-board-reopen-failed", undefined, { pane: loss.pane, attempt, error: reopened.error, ...(boardless ? { boardless: true } : {}) });
|
|
1605
|
+
console.error(`tickmarkr: board not reopened (attempt ${attempt}): ${reopened.error}`);
|
|
1515
1606
|
};
|
|
1516
1607
|
// OBS-829/OBS-854: one full-suite verdict round at a time in this run, and do not begin beside an
|
|
1517
1608
|
// externally live suite attributable to this repository. The process scan catches nested scratch
|
|
@@ -1632,12 +1723,12 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1632
1723
|
...(replayedExclusions.size > 0 ? { excludedChannels: [...replayedExclusions].sort() } : {}),
|
|
1633
1724
|
...(opts.retryFailed ? { retryFailed: true } : {}),
|
|
1634
1725
|
});
|
|
1635
|
-
await
|
|
1726
|
+
await placeBoard();
|
|
1636
1727
|
}
|
|
1637
1728
|
else {
|
|
1638
1729
|
baseRef = await gitHead(repoRoot);
|
|
1639
1730
|
journal.append("baseline-start", undefined, { baseRef, commands, capacity: resolvedCapacity() });
|
|
1640
|
-
await
|
|
1731
|
+
await placeBoard();
|
|
1641
1732
|
baselinePending = true;
|
|
1642
1733
|
// Workers can run beside capture, but no gate may observe an absent or partial baseline.
|
|
1643
1734
|
// Keep publication and warnings inside the same barrier as the suite's final verdict.
|
|
@@ -1773,15 +1864,17 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1773
1864
|
];
|
|
1774
1865
|
return `${identity} — release with ${commands.map((command) => `\`${command}\``).join(" or ")}`;
|
|
1775
1866
|
};
|
|
1867
|
+
const namedPaths = (text) => [...text.matchAll(/(?:^|[\s`'"(])((?:[A-Za-z0-9_@.()[\]-]+\/)+[A-Za-z0-9_@.[\]-]+|[A-Za-z0-9_@-]+(?:\.[A-Za-z0-9_-]+)+)(?=$|[\s`'"),:;.!?])/g)]
|
|
1868
|
+
.map((match) => match[1].replace(/^\.\//, "").replace(/\.$/, ""))
|
|
1869
|
+
.filter((path) => !path.split("/").includes("..")
|
|
1870
|
+
&& (/\.[A-Za-z][A-Za-z0-9_-]*$/.test(basename(path)) || existsSync(join(repoRoot, path))));
|
|
1776
1871
|
const park = async (t, reason, kind, assignment, attempts, startMs, gateFails = 0, consults = 0, tokens, metered = 0, retryMode = "fresh", details = {}) => {
|
|
1777
1872
|
// OBS-979: a worker refusal can identify the missing authoring scope even when gates
|
|
1778
1873
|
// subsequently supply the park's disposition. Keep that actionable path on the park itself.
|
|
1779
1874
|
const worker = journal.read().reverse().find((e) => e.taskId === t.id && e.event === "worker-result");
|
|
1780
1875
|
if (t.files.length > 0 && worker?.data.ok === false && typeof worker.data.summary === "string") {
|
|
1781
1876
|
const allowed = filesGlob(t.files);
|
|
1782
|
-
const paths =
|
|
1783
|
-
.map((match) => match[1].replace(/^\.\//, "").replace(/\.$/, ""))
|
|
1784
|
-
.filter((path) => !path.split("/").includes("..") && !allowed(path));
|
|
1877
|
+
const paths = namedPaths(worker.data.summary).filter((path) => !allowed(path));
|
|
1785
1878
|
if (paths.length)
|
|
1786
1879
|
reason += ` — files[] repair hint: ${[...new Set(paths)].join(", ")}`;
|
|
1787
1880
|
}
|
|
@@ -1814,6 +1907,30 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1814
1907
|
const dispositionScopeRed = async (t, results, assignment, attempts, startMs, gateFails, consults, tokens, metered, retryMode) => {
|
|
1815
1908
|
const scopeRed = results.find((g) => g.gate === "scope" && gateFailed(g));
|
|
1816
1909
|
const verdict = scopeRed?.meta?.collateral;
|
|
1910
|
+
if (!verdict && t.files.length > 0) {
|
|
1911
|
+
const allowed = filesGlob(t.files);
|
|
1912
|
+
const worker = journal.read().reverse().find((e) => e.taskId === t.id && e.event === "worker-result");
|
|
1913
|
+
const refusal = worker?.data.ok === false && typeof worker.data.summary === "string"
|
|
1914
|
+
&& /outside|out.of.scope|allowlist|scope expansion|not (?:in|own)|unowned/i.test(worker.data.summary)
|
|
1915
|
+
? namedPaths(worker.data.summary).filter((path) => !allowed(path)) : [];
|
|
1916
|
+
const reds = results.filter(gateFailed);
|
|
1917
|
+
// A scope verdict already attributes actual out-of-scope EDITS using the collateral map.
|
|
1918
|
+
// Path-only diagnostics from the other gates name code that needs fixing, not an edit the
|
|
1919
|
+
// worker made. Do not replace the scope gate's stronger attribution with that inference.
|
|
1920
|
+
const paths = reds.filter((g) => g.gate !== "scope" || !g.meta?.collateral).flatMap((g) => namedPaths(g.details));
|
|
1921
|
+
if (refusal.length || (paths.length > 0 && paths.every((path) => !allowed(path)))) {
|
|
1922
|
+
journal.append("scope-authoring", t.id, {
|
|
1923
|
+
gate: refusal.length ? "worker" : reds[0]?.gate,
|
|
1924
|
+
paths: [...new Set(refusal.length ? refusal : paths)],
|
|
1925
|
+
repair: `files[] repair hint: ${[...new Set(refusal.length ? refusal : paths)].join(", ")}`,
|
|
1926
|
+
attempt: attempts + 1, chargeable: false, source: "diagnostic",
|
|
1927
|
+
});
|
|
1928
|
+
// OBS-547 (Leg-2 T3 material): unchargeable ⇒ metered 0, exactly as the predicted path below —
|
|
1929
|
+
// passing the physical count would write `meteredAttempts: 1` beside `attempts: 0`.
|
|
1930
|
+
await park(t, `authoring defect — files[] repair hint: ${[...new Set(refusal.length ? refusal : paths)].join(", ")}; current files[]: ${t.files.join(", ")}`, "authoring", assignment, attempts, startMs, gateFails, consults, tokens, 0, retryMode);
|
|
1931
|
+
return true;
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1817
1934
|
if (!verdict)
|
|
1818
1935
|
return false;
|
|
1819
1936
|
if (verdict.authoring) {
|
|
@@ -1932,12 +2049,28 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1932
2049
|
// not re-tried first (consult bans / prior failovers survive the release).
|
|
1933
2050
|
const rs = resume.get(t.id);
|
|
1934
2051
|
const contentDigest = taskContentDigest(t);
|
|
1935
|
-
|
|
2052
|
+
const previousDispatch = journal.read().reverse().find((e) => e.taskId === t.id && e.event === "task-dispatch");
|
|
2053
|
+
const recordedGraphPath = join(journal.dir, "graph.json");
|
|
2054
|
+
const recordedTask = existsSync(recordedGraphPath)
|
|
2055
|
+
? JSON.parse(readFileSync(recordedGraphPath, "utf8")).tasks.find((task) => task.id === t.id)
|
|
2056
|
+
: undefined;
|
|
2057
|
+
const previousHints = previousDispatch && "routingHints" in previousDispatch.data
|
|
2058
|
+
? previousDispatch.data.routingHints : recordedTask?.routingHints;
|
|
2059
|
+
const hintsChanged = !!rs && (!!recordedTask || !!previousDispatch && "routingHints" in previousDispatch.data)
|
|
2060
|
+
&& (JSON.stringify(previousHints?.pin) !== JSON.stringify(t.routingHints?.pin)
|
|
2061
|
+
|| previousHints?.floor !== t.routingHints?.floor);
|
|
2062
|
+
if (hintsChanged)
|
|
2063
|
+
journal.append("restore-rerouted", t.id, {
|
|
2064
|
+
from: rs.lastAssignment ? channelKey(rs.lastAssignment) : rs.tried.at(-1) ?? null,
|
|
2065
|
+
to: channelKey(assignment),
|
|
2066
|
+
reason: JSON.stringify(previousHints?.pin) !== JSON.stringify(t.routingHints?.pin) ? "pin changed" : "floor changed",
|
|
2067
|
+
});
|
|
2068
|
+
if (!hintsChanged && rs?.lastAssignment && rs.attempts > 0
|
|
1936
2069
|
&& channels.some((c) => channelKey(c) === channelKey(rs.lastAssignment))
|
|
1937
2070
|
&& !demotedChannels.has(channelKey(rs.lastAssignment))) {
|
|
1938
2071
|
assignment = rs.lastAssignment; // restore the consult-chosen assignment (bypasses route()'s static re-pick)
|
|
1939
2072
|
}
|
|
1940
|
-
else if (rs && rs.tried.length) {
|
|
2073
|
+
else if (!hintsChanged && rs && rs.tried.length) {
|
|
1941
2074
|
// trailing-reroute edge (kill between verdict and dispatch), a stale fleet, OR a fresh-budget
|
|
1942
2075
|
// release (attempts 0 + non-empty tried): pick a failover over the replayed exclusions via the
|
|
1943
2076
|
// EXISTING nextChannel `tried` parameter — zero router changes (D-03).
|
|
@@ -1948,6 +2081,14 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1948
2081
|
// assignment and proceed. Dispatching on a previously-tried channel beats deadlocking a resumed
|
|
1949
2082
|
// run; a park-instead policy can come later if it ever bites.
|
|
1950
2083
|
}
|
|
2084
|
+
const taskHistory = journal.read().filter((e) => e.taskId === t.id);
|
|
2085
|
+
const lastApproval = taskHistory.map((e) => e.event).lastIndexOf("task-approved");
|
|
2086
|
+
const priorClimb = taskHistory.slice(lastApproval + 1).reverse().find((e) => e.event === "tier-escalated");
|
|
2087
|
+
if (!hintsChanged && priorClimb && taskHistory.indexOf(priorClimb) > taskHistory.map((e) => e.event).lastIndexOf("task-dispatch")) {
|
|
2088
|
+
const target = channels.find((c) => channelKey(c) === priorClimb.data.to && !demotedChannels.has(channelKey(c)));
|
|
2089
|
+
if (target)
|
|
2090
|
+
assignment = { adapter: target.adapter, model: target.model, channel: target.channel, tier: target.tier };
|
|
2091
|
+
}
|
|
1951
2092
|
// pre-kill invariant: tried always contains the current assignment. Spread, never alias the
|
|
1952
2093
|
// journal-derived array (no hidden mutation of replayed state).
|
|
1953
2094
|
const tried = rs?.tried.length ? [...rs.tried] : [channelKey(assignment)];
|
|
@@ -2070,7 +2211,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2070
2211
|
...(g.meta?.reapedGroup === true ? { reapedGroup: true } : {}),
|
|
2071
2212
|
...(g.gate === "review" && typeof g.meta?.reviewer === "string" ? {
|
|
2072
2213
|
reviewer: g.meta.reviewer,
|
|
2073
|
-
...Object.fromEntries(["cause", "seatAuthoredBytes", "bytes", "rawPath", "briefPath", "timeoutMs", "unparseable", "noVerdict", "resolved", "reraised"]
|
|
2214
|
+
...Object.fromEntries(["cause", "seatAuthoredBytes", "bytes", "rawPath", "briefPath", "timeoutMs", "unparseable", "noVerdict", "resolved", "reraised", "reviewerFloor", "reviewerFloorCause", "reviewerTier"]
|
|
2074
2215
|
.filter((key) => g.meta?.[key] !== undefined).map((key) => [key, g.meta[key]])),
|
|
2075
2216
|
...(typeof g.meta.vendor === "string" ? { vendor: g.meta.vendor } : {}),
|
|
2076
2217
|
...(typeof g.meta.provider === "string" ? { provider: g.meta.provider } : {}),
|
|
@@ -2170,6 +2311,43 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2170
2311
|
? `The operator UPHELD the reviewer's findings — address them without discarding landed work.\nreview: ${upheldFeedback}`
|
|
2171
2312
|
: "");
|
|
2172
2313
|
let ladderIdx = 0;
|
|
2314
|
+
const engagementRows = () => {
|
|
2315
|
+
const rows = journal.read().filter((e) => e.taskId === t.id);
|
|
2316
|
+
const approval = rows.map((e) => e.event).lastIndexOf("task-approved");
|
|
2317
|
+
return rows.slice(approval + 1);
|
|
2318
|
+
};
|
|
2319
|
+
let ownedGateFailures = engagementRows().filter((e) => e.event === "escalation" && e.data.workerAttributed === true).length;
|
|
2320
|
+
let climbProvenance = !hintsChanged && priorClimb
|
|
2321
|
+
? `tier-escalated ${priorClimb.data.from} → ${priorClimb.data.to} (${priorClimb.data.cause})` : "";
|
|
2322
|
+
const climbSkips = new Set(!hintsChanged && priorClimb
|
|
2323
|
+
? channels.filter((c) => c.tier === priorClimb.data.fromTier).map(channelKey) : []);
|
|
2324
|
+
const climb = async (cause, gate, attempt) => {
|
|
2325
|
+
if (cfg.routing.escalateTier === "off" || engagementRows().some((e) => e.event === "tier-escalated"))
|
|
2326
|
+
return;
|
|
2327
|
+
const pin = t.routingHints?.pin;
|
|
2328
|
+
if (pin) {
|
|
2329
|
+
await park(t, `pin ${pin.via}:${pin.model} held: ${cause}`, "gate-fail", assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode);
|
|
2330
|
+
return "parked";
|
|
2331
|
+
}
|
|
2332
|
+
const next = climbChannel(assignment, t, cfg, channels, tried, profile, demotedChannels);
|
|
2333
|
+
if (!next?.climbed)
|
|
2334
|
+
return;
|
|
2335
|
+
const from = channelKey(assignment);
|
|
2336
|
+
const to = channelKey(next);
|
|
2337
|
+
const poolBefore = channels.filter((c) => !tried.includes(channelKey(c)) && !demotedChannels.has(channelKey(c))).map(channelKey);
|
|
2338
|
+
journal.append("tier-escalated", t.id, {
|
|
2339
|
+
attempt, cause, gate: gate.gate, fingerprint: normalizeGateFailure(gate.details).slice(0, 500),
|
|
2340
|
+
from, to, fromTier: assignment.tier, toTier: next.tier, poolBefore,
|
|
2341
|
+
costDelta: marginalCostRank(channels.find((c) => channelKey(c) === to)) - marginalCostRank(channels.find((c) => channelKey(c) === from)),
|
|
2342
|
+
});
|
|
2343
|
+
for (const c of channels)
|
|
2344
|
+
if (c.tier === assignment.tier && channelKey(c) !== to)
|
|
2345
|
+
climbSkips.add(channelKey(c));
|
|
2346
|
+
climbProvenance = `tier-escalated ${from} → ${to} (${cause})`;
|
|
2347
|
+
assignment = { adapter: next.adapter, model: next.model, channel: next.channel, tier: next.tier };
|
|
2348
|
+
tried.push(to);
|
|
2349
|
+
return "climbed";
|
|
2350
|
+
};
|
|
2173
2351
|
let modeFallbackNoted = false; // v1.2: journal the interactive→print fallback once per task, not per attempt
|
|
2174
2352
|
let gateFails = 0; // TEL-02: incremented ONLY where feedback is built from failing gates — never derived from attempts (quota failovers bump attempts too, Pitfall 6)
|
|
2175
2353
|
let consults = 0; // TEL-02: bumped in the runConsult wrapper so one counter covers all three trigger sites, across the attempt loop
|
|
@@ -2465,7 +2643,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2465
2643
|
}
|
|
2466
2644
|
: undefined,
|
|
2467
2645
|
excludeReviewers: badReviewers,
|
|
2468
|
-
reviewHistory, demotedReviewers,
|
|
2646
|
+
reviewHistory, demotedReviewers, priorReviewers: taskReviewers(t.id),
|
|
2469
2647
|
onGate: async (e) => {
|
|
2470
2648
|
if (e.phase === "start") {
|
|
2471
2649
|
notePhaseStart(e);
|
|
@@ -2741,8 +2919,17 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2741
2919
|
// ledger cannot tell a carried dispatch from an amnesiac one — the exact question a run that
|
|
2742
2920
|
// spends two frontier attempts re-deriving a known defect has to be able to answer afterwards.
|
|
2743
2921
|
journal.append("task-dispatch", t.id, {
|
|
2744
|
-
assignment, attempt, provenance: dispatchProvenance(
|
|
2745
|
-
|
|
2922
|
+
assignment, attempt, provenance: dispatchProvenance([
|
|
2923
|
+
channelKey(assignment) === channelKey(r.assignment) ? r.provenance
|
|
2924
|
+
: t.routingHints?.pin ? `pin ${t.routingHints.pin.via}:${t.routingHints.pin.model} not re-tried` : "ladder assignment",
|
|
2925
|
+
`dispatch ${channelKey(assignment)}`, climbProvenance,
|
|
2926
|
+
].filter(Boolean).join(" · ")), retryMode,
|
|
2927
|
+
routingHints: t.routingHints ?? {},
|
|
2928
|
+
excludedChannels: [...new Set([...demotedChannels, ...tried, ...climbSkips])]
|
|
2929
|
+
.filter((key) => key !== channelKey(assignment)).sort(),
|
|
2930
|
+
exclusionReasons: Object.fromEntries([...new Set([...demotedChannels, ...tried, ...climbSkips])]
|
|
2931
|
+
.filter((key) => key !== channelKey(assignment))
|
|
2932
|
+
.map((key) => [key, demotedChannels.has(key) ? "demoted" : tried.includes(key) ? "already tried" : "tier climb skipped"])),
|
|
2746
2933
|
...(outstandingFindings.length > 0 ? { carriedFindings: outstandingFindings } : {}),
|
|
2747
2934
|
...(carriedConsultGuidance ? { carriedConsultGuidance } : {}),
|
|
2748
2935
|
});
|
|
@@ -3896,6 +4083,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3896
4083
|
// (provider outage, quota banner, dead CLI) must still route; the harvest synthesis only
|
|
3897
4084
|
// decides whether THIS attempt's worktree goes to gates, and when routing wins instead, the
|
|
3898
4085
|
// commits survive via the existing commitsToCarry/cherryPickCommits carry-forward.
|
|
4086
|
+
if (workerFinished && !result.ok && await dispositionScopeRed(t, [], assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode))
|
|
4087
|
+
return;
|
|
3899
4088
|
const preHarvestResult = result;
|
|
3900
4089
|
// T2 (OBS-264): recognize committed no-trailer work BEFORE any no-trailer streak, provider,
|
|
3901
4090
|
// quota or dead-channel routing. Gates never trusted the trailer, so this successful synthesis
|
|
@@ -4208,7 +4397,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4208
4397
|
}
|
|
4209
4398
|
: undefined,
|
|
4210
4399
|
excludeReviewers: badReviewers,
|
|
4211
|
-
reviewHistory, demotedReviewers,
|
|
4400
|
+
reviewHistory, demotedReviewers, priorReviewers: taskReviewers(t.id),
|
|
4212
4401
|
onGate,
|
|
4213
4402
|
})));
|
|
4214
4403
|
}
|
|
@@ -4300,6 +4489,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4300
4489
|
&& !isDiffCapPark(reviewFail)
|
|
4301
4490
|
&& results.every((g) => gateSatisfied(g) || g.gate === "review");
|
|
4302
4491
|
const failing = results.filter(gateFailed);
|
|
4492
|
+
const deterministic = failing.find(isDeterministicFailure);
|
|
4493
|
+
if (deterministic)
|
|
4494
|
+
ownedGateFailures++;
|
|
4303
4495
|
// Decided before the cap, because the cap's question is whether the NEXT move would re-buy a
|
|
4304
4496
|
// measurement already made — and a funded repair is one of the moves that would.
|
|
4305
4497
|
const landed = await commitsAheadOf(taskBase, wt);
|
|
@@ -4341,13 +4533,18 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4341
4533
|
channel: channelKey(assignment), // the ban is bound to the channel that produced the repeat
|
|
4342
4534
|
attempt: attempt + 1,
|
|
4343
4535
|
});
|
|
4344
|
-
await driver.notify(`tickmarkr ${runId}: ${t.id} ${repeated.gate} failed identically twice —
|
|
4536
|
+
await driver.notify(`tickmarkr ${runId}: ${t.id} ${repeated.gate} failed identically twice — identical retry banned`, { tier: "attention" });
|
|
4345
4537
|
// The cap takes the ladder's MOVE, never its accounting: this failure still spends the rung it
|
|
4346
4538
|
// would have spent, so a task that cannot converge still reaches ladder exhaustion on exactly
|
|
4347
4539
|
// the budget it always had and the cap can never hand a stuck task extra rounds.
|
|
4348
4540
|
capStep = r.ladder[Math.min(ladderIdx++, r.ladder.length - 1)];
|
|
4349
|
-
journal.append("escalation", t.id, { step: capStep, attempt: attempt + 1, fingerprintCap: true });
|
|
4541
|
+
journal.append("escalation", t.id, { step: capStep, attempt: attempt + 1, fingerprintCap: true, workerAttributed: !!deterministic });
|
|
4350
4542
|
await driver.notify(`tickmarkr ${runId}: ${t.id} escalation: ${capStep}`, { tier: "attention" });
|
|
4543
|
+
const climbed = await climb("gate-fingerprint-cap", repeated, attempt + 1);
|
|
4544
|
+
if (climbed === "parked")
|
|
4545
|
+
return;
|
|
4546
|
+
if (climbed === "climbed")
|
|
4547
|
+
continue;
|
|
4351
4548
|
const v = await runConsult("gate-fail-repeat", output, feedback, results);
|
|
4352
4549
|
// Rule: a terminal cap consult vetoes a same-channel retry, but cannot veto an `escalate`
|
|
4353
4550
|
// rung that already satisfies retry-same-banned by changing channel. Thus terminal+retry
|
|
@@ -4397,12 +4594,21 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4397
4594
|
: r.ladder[Math.min(ladderIdx++, r.ladder.length - 1)]);
|
|
4398
4595
|
if (!capStep) { // a capped failure already journaled and announced the rung it spent
|
|
4399
4596
|
journal.append("escalation", t.id, {
|
|
4400
|
-
step, attempt: attempt + 1,
|
|
4597
|
+
step, attempt: attempt + 1, workerAttributed: !!deterministic,
|
|
4401
4598
|
...(reviewFixRetry && !repairExhausted ? { reviewFix: true } : {}),
|
|
4402
4599
|
...(repair ? { repair: repairHistory.length + 1, repairCharge: repairsDrawn + 1 } : {}),
|
|
4403
4600
|
});
|
|
4404
4601
|
await driver.notify(`tickmarkr ${runId}: ${t.id} escalation: ${step}`, { tier: "attention" });
|
|
4405
4602
|
}
|
|
4603
|
+
const climbCause = repairExhausted ? "repair-exhausted"
|
|
4604
|
+
: deterministic && ownedGateFailures === 2 ? "gate-fail" : undefined;
|
|
4605
|
+
if (climbCause) {
|
|
4606
|
+
const climbed = await climb(climbCause, deterministic ?? failing[0], attempt + 1);
|
|
4607
|
+
if (climbed === "parked")
|
|
4608
|
+
return;
|
|
4609
|
+
if (climbed === "climbed")
|
|
4610
|
+
continue;
|
|
4611
|
+
}
|
|
4406
4612
|
if (step === "retry")
|
|
4407
4613
|
continue;
|
|
4408
4614
|
if (step === "escalate") {
|
|
@@ -4428,14 +4634,18 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4428
4634
|
const inflight = new Map();
|
|
4429
4635
|
// A settled worker can release a dependency or an approval in the same poll tick.
|
|
4430
4636
|
// Audit the current graph at every empty-flight boundary, never a prior ready snapshot.
|
|
4431
|
-
|
|
4637
|
+
// WB-1 rider: `end-condition-held` names a CLOSE the daemon refused, so only a close attempt journals
|
|
4638
|
+
// it — the post-race sweep still dispatches what it finds but says nothing.
|
|
4639
|
+
const holdEndCondition = (closing = true) => {
|
|
4432
4640
|
sweepLiveApprovals();
|
|
4433
4641
|
const freeSlots = Math.max(0, concurrency - inflight.size);
|
|
4434
4642
|
const dispatchable = readyTasks(graph).filter((t) => !inflight.has(t.id));
|
|
4435
4643
|
if (freeSlots === 0 || dispatchable.length === 0)
|
|
4436
4644
|
return false;
|
|
4437
|
-
|
|
4438
|
-
|
|
4645
|
+
if (closing) {
|
|
4646
|
+
for (const task of dispatchable) {
|
|
4647
|
+
journal.append("end-condition-held", task.id, { deps: task.deps, freeSlots });
|
|
4648
|
+
}
|
|
4439
4649
|
}
|
|
4440
4650
|
return true;
|
|
4441
4651
|
};
|
|
@@ -4446,6 +4656,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4446
4656
|
// must still stop the run before it can dispatch more work or write run-end.
|
|
4447
4657
|
if (termSignal)
|
|
4448
4658
|
throw new Error(`terminated by ${termSignal}`);
|
|
4659
|
+
await watchBoard();
|
|
4449
4660
|
sweepLiveApprovals();
|
|
4450
4661
|
const ready = readyTasks(graph)
|
|
4451
4662
|
.filter((t) => !inflight.has(t.id))
|
|
@@ -4500,12 +4711,13 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4500
4711
|
const waiters = [...inflight.values(), aborted];
|
|
4501
4712
|
// A free slot is itself a scheduling boundary: poll the append-only approval stream instead of
|
|
4502
4713
|
// sleeping until an unrelated long-running task settles.
|
|
4503
|
-
|
|
4714
|
+
// An open board is polled on the same cadence, so a dead cockpit is noticed mid-task.
|
|
4715
|
+
if (inflight.size < concurrency || boardOpened) {
|
|
4504
4716
|
waiters.push(new Promise((wake) => setTimeout(wake, APPROVAL_POLL_MS)));
|
|
4505
4717
|
}
|
|
4506
4718
|
await Promise.race(waiters); // aborted rejects on termination — unwinds the run
|
|
4507
4719
|
if (inflight.size === 0)
|
|
4508
|
-
holdEndCondition();
|
|
4720
|
+
holdEndCondition(false);
|
|
4509
4721
|
}
|
|
4510
4722
|
// D-07: the sweep now closes only what's LEFT in keptSlots — done-closed worker slots were removed
|
|
4511
4723
|
// (no double-close) and self-cleaned LLM/consult panes were never added under keepLlm:false. This
|
package/dist/run/git.d.ts
CHANGED
|
@@ -179,3 +179,28 @@ export type BaseContainment = {
|
|
|
179
179
|
*/
|
|
180
180
|
export declare function declaredBaseContainment(cwd: string, declaredRef: string, targetRef?: string): Promise<BaseContainment>;
|
|
181
181
|
export declare function removeWorktree(repo: string, dir: string): Promise<void>;
|
|
182
|
+
export declare const REFS_PREFLIGHT_PREFIX = "refs/tickmarkr/preflight";
|
|
183
|
+
export declare const REFS_PROBE_REMEDY = "run from the main repository or a full clone; a sandbox that denies writes under that path cannot host a run";
|
|
184
|
+
export interface RefsProbeOk {
|
|
185
|
+
ok: true;
|
|
186
|
+
path: string;
|
|
187
|
+
refsDir: string;
|
|
188
|
+
}
|
|
189
|
+
export interface RefsProbeRefusal {
|
|
190
|
+
ok: false;
|
|
191
|
+
path: string;
|
|
192
|
+
refsDir: string;
|
|
193
|
+
error: string;
|
|
194
|
+
}
|
|
195
|
+
export type RefsProbeResult = RefsProbeOk | RefsProbeRefusal;
|
|
196
|
+
export declare function refsRefusalMessage(action: "run" | "resume", path: string, error: string): string;
|
|
197
|
+
/**
|
|
198
|
+
* OBS-983/984: prove the repository's common git directory accepts a ref write before starting
|
|
199
|
+
* the daemon. In a linked worktree, git resolves refs to the main repository's git directory.
|
|
200
|
+
* Creates and deletes a real ref under refs/tickmarkr/preflight/ through git itself, leaving
|
|
201
|
+
* nothing behind on success.
|
|
202
|
+
*/
|
|
203
|
+
export declare function probeRefsWritable(cwd?: string): Promise<RefsProbeResult>;
|
|
204
|
+
export declare const probeRefs: typeof probeRefsWritable;
|
|
205
|
+
export declare const refsProbe: typeof probeRefsWritable;
|
|
206
|
+
export declare function assertRefsWritable(cwd?: string, action?: "run" | "resume"): Promise<RefsProbeResult>;
|
package/dist/run/git.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { availableParallelism, tmpdir } from "node:os";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { StringDecoder } from "node:string_decoder";
|
|
@@ -565,3 +565,70 @@ export async function removeWorktree(repo, dir) {
|
|
|
565
565
|
await shGit(`rm -rf ${shq(dir)}`, repo);
|
|
566
566
|
await shGit("git worktree prune", repo);
|
|
567
567
|
}
|
|
568
|
+
export const REFS_PREFLIGHT_PREFIX = "refs/tickmarkr/preflight";
|
|
569
|
+
export const REFS_PROBE_REMEDY = "run from the main repository or a full clone; a sandbox that denies writes under that path cannot host a run";
|
|
570
|
+
export function refsRefusalMessage(action, path, error) {
|
|
571
|
+
return `refusing to ${action}: repository refs directory ${path} is not writable (${error}). `
|
|
572
|
+
+ `Remedy: ${REFS_PROBE_REMEDY}.`;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* OBS-983/984: prove the repository's common git directory accepts a ref write before starting
|
|
576
|
+
* the daemon. In a linked worktree, git resolves refs to the main repository's git directory.
|
|
577
|
+
* Creates and deletes a real ref under refs/tickmarkr/preflight/ through git itself, leaving
|
|
578
|
+
* nothing behind on success.
|
|
579
|
+
*/
|
|
580
|
+
export async function probeRefsWritable(cwd = process.cwd()) {
|
|
581
|
+
const refsPathRes = await shGit("git rev-parse --git-path refs", cwd);
|
|
582
|
+
if (refsPathRes.code !== 0) {
|
|
583
|
+
const error = (refsPathRes.stderr || refsPathRes.stdout).trim() || "git rev-parse --git-path refs failed";
|
|
584
|
+
return { ok: false, path: "", refsDir: "", error };
|
|
585
|
+
}
|
|
586
|
+
const raw = refsPathRes.stdout.trim();
|
|
587
|
+
let refsDir = resolve(cwd, raw);
|
|
588
|
+
try {
|
|
589
|
+
refsDir = realpathSync(refsDir);
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
// keep resolved path if realpath fails
|
|
593
|
+
}
|
|
594
|
+
const headRes = await shGit("git rev-parse HEAD", cwd);
|
|
595
|
+
const target = headRes.code === 0 && headRes.stdout.trim()
|
|
596
|
+
? headRes.stdout.trim()
|
|
597
|
+
: "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
598
|
+
const probeId = `probe-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
599
|
+
const ref = `${REFS_PREFLIGHT_PREFIX}/${probeId}`;
|
|
600
|
+
const createRes = await shGit(`git update-ref ${shq(ref)} ${shq(target)}`, cwd);
|
|
601
|
+
if (createRes.code !== 0) {
|
|
602
|
+
const error = (createRes.stderr || createRes.stdout).trim() || `git update-ref failed (${createRes.code})`;
|
|
603
|
+
return { ok: false, path: refsDir, refsDir, error };
|
|
604
|
+
}
|
|
605
|
+
const delRes = await shGit(`git update-ref -d ${shq(ref)}`, cwd);
|
|
606
|
+
if (delRes.code !== 0) {
|
|
607
|
+
const error = (delRes.stderr || delRes.stdout).trim() || `git update-ref -d failed (${delRes.code})`;
|
|
608
|
+
return { ok: false, path: refsDir, refsDir, error };
|
|
609
|
+
}
|
|
610
|
+
// Clean up empty directories left behind by git
|
|
611
|
+
try {
|
|
612
|
+
const preflightDir = join(refsDir, "tickmarkr", "preflight");
|
|
613
|
+
if (existsSync(preflightDir) && readdirSync(preflightDir).length === 0) {
|
|
614
|
+
rmSync(preflightDir, { recursive: true, force: true });
|
|
615
|
+
}
|
|
616
|
+
const tmDir = join(refsDir, "tickmarkr");
|
|
617
|
+
if (existsSync(tmDir) && readdirSync(tmDir).length === 0) {
|
|
618
|
+
rmSync(tmDir, { recursive: true, force: true });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
// best-effort cleanup of empty dirs
|
|
623
|
+
}
|
|
624
|
+
return { ok: true, path: refsDir, refsDir };
|
|
625
|
+
}
|
|
626
|
+
export const probeRefs = probeRefsWritable;
|
|
627
|
+
export const refsProbe = probeRefsWritable;
|
|
628
|
+
export async function assertRefsWritable(cwd = process.cwd(), action = "run") {
|
|
629
|
+
const probe = await probeRefsWritable(cwd);
|
|
630
|
+
if (!probe.ok) {
|
|
631
|
+
throw new Error(refsRefusalMessage(action, probe.path, probe.error));
|
|
632
|
+
}
|
|
633
|
+
return probe;
|
|
634
|
+
}
|
|
@@ -467,18 +467,20 @@ export async function runConsolidatedCockpit(options) {
|
|
|
467
467
|
const reader = createPointerReportReader();
|
|
468
468
|
const stdin = new Proxy(input, { get(target, property) {
|
|
469
469
|
if (property === "read")
|
|
470
|
-
return (
|
|
470
|
+
return () => {
|
|
471
471
|
try {
|
|
472
|
-
const chunk = target.read(...args);
|
|
473
|
-
if (chunk == null)
|
|
474
|
-
return null;
|
|
475
|
-
const result = reader(String(chunk));
|
|
476
472
|
let keys = "";
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
473
|
+
// OBS-965: drain until the stream itself says empty. A real tty (highWaterMark 0) stops its
|
|
474
|
+
// handle after every chunk and only a read() that finds the buffer EMPTY restarts it; Ink's
|
|
475
|
+
// loop stops at the first null, so returning null after ONE pointer-only chunk left that
|
|
476
|
+
// empty read unmade and every later key queued in the kernel — the deaf board.
|
|
477
|
+
for (let chunk = target.read(); chunk != null; chunk = target.read()) {
|
|
478
|
+
for (const token of reader(String(chunk)).tokens) {
|
|
479
|
+
if (token.type === "pointer")
|
|
480
|
+
pointer(token.report);
|
|
481
|
+
else
|
|
482
|
+
keys += token.bytes;
|
|
483
|
+
}
|
|
482
484
|
}
|
|
483
485
|
// A standalone Escape is unambiguous after Ink's own input grace.
|
|
484
486
|
if (reader.pending() === "\x1b")
|