From 78d2549ae7b0b14d3764c55754a51947fd1f0252 Mon Sep 17 00:00:00 2001 From: Henry Lach Date: Wed, 9 Sep 2026 00:34:05 -0400 Subject: [PATCH 1/2] fix(hold): no stale 'Lane held' alert when a ruling is already queued; prose ruling claims require affirmative language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from the first live exercise of the held state (penster 20260909T000015, two holds, both chains intact end to end): 1. The escalation reaches the supervisor live (mail recognition), so a fast ruling is often in the inbox before the worker exits. awaitHoldResolution published the held alert on entry and consumed the ruling one poll later — a stale-looking alert after the release. Peek once: if every open hold has a valid queued ruling, log 'Ruling already queued' and skip the publication. 2. isProseRulingClaim: 'hold(TP-1919): record HARD HOLD … pending operator ruling' is bookkeeping, not a claim. Claims must be affirmative (R###, verdict token, per/apply/implement/as-ruled language); pending/awaiting/ requesting/hold language is exempt unless a verdict token is present. The TP-2037 commit still flags. Tests: runner regression (queued ruling → release with no 'Lane held' alert), 11-case claim/bookkeeping matrix. 4118 pass. --- CHANGELOG.md | 15 ++++ extensions/taskplane/lane-runner.ts | 87 +++++++++++++++------- extensions/taskplane/ruling-trailer.ts | 27 ++++++- extensions/tests/held-state-runner.test.ts | 30 ++++++++ extensions/tests/ruling-trailer.test.ts | 33 ++++++++ 5 files changed, 162 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40ec2194..53b0b56d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Stale `⏸️ Lane held` alert after a fast ruling.** The escalation reaches the + supervisor live, so a ruling is often queued before the worker exits; the hold + loop published \"Lane held\" and then consumed that ruling on its first poll. + The runner now peeks the inbox first and, when every open hold already has a + valid ruling waiting, releases without publishing the hold (STATUS logs + `Ruling already queued`). Observed on penster 20260909T000015 (TP-1919, TP-2100). +- **`Ruling citation flagged` false positive on hold bookkeeping.** A commit + recording a hold (`hold(TP-1919): … pending operator ruling`) was flagged as a + prose ruling claim. A prose mention is now a claim only when it is affirmative + (an `R###` reference, a verdict token such as `(FIX)`, or per/applied/as-ruled + language); pending/awaiting/requesting/hold language is bookkeeping. The + TP-2037 pattern (`R004 cap ruling (FIX)`) still flags. + ### New - **Unified `.DONE` completion authority + ruling commit-trailer validation diff --git a/extensions/taskplane/lane-runner.ts b/extensions/taskplane/lane-runner.ts index 00bbb243..cb84a874 100644 --- a/extensions/taskplane/lane-runner.ts +++ b/extensions/taskplane/lane-runner.ts @@ -1188,6 +1188,25 @@ export async function executeTaskV2( * Pause (any cause) unwinds with a `held` outcome; the deadline expiring * parks the batch (`hold-timeout`) with the hold still open. */ + const allHoldsHaveQueuedRuling = (open: HoldRecord[], inboxDir: string): boolean => { + if (open.length === 0) return false; + let queued: ReturnType = []; + try { + queued = readInbox(inboxDir, config.batchId); + } catch { + return false; + } + const holds = holdStore.list(); + return open.every((h) => + queued.some( + ({ message }) => + message.type === "ruling" && + message.replyTo === h.escalationId && + validateRuling(message, holds, { taskId, segmentId }).ok, + ), + ); + }; + const awaitHoldResolution = async (): Promise< | { kind: "ruled" } | { kind: "paused" } @@ -1198,34 +1217,48 @@ export async function executeTaskV2( const inboxDir = sessionInboxDir(config.stateRoot, config.batchId, workerAgentId); const openHolds = () => unitHolds().filter((h) => h.phase === "open"); - updateStatusField(statusPath, "Status", "⏸️ Held — awaiting ruling"); const initial = openHolds(); - logExecution( - statusPath, - "Held", - `no worker process; awaiting ruling on ${initial.map((h) => h.escalationId).join(", ")} (reply with send_agent_message type="ruling" replyTo=; type="info" acknowledges without releasing)`, - ); - emitSnapshot( - config, - taskId, - segmentId, - "held", - lastTelemetry, - statusPath, - reviewerStatePath, - snapshotSegmentCtx, - ); - holdAlert( - "agent-message", - `⏸️ **Lane held** — ${taskId} (lane ${config.laneNumber}) is waiting for a ruling with no worker running (zero cost).\n` + - buildHoldStatusSummary(initial) - .split("\n") - .map((l) => ` ${l}`) - .join("\n") + - `\n Rule: send_agent_message(to="${workerAgentId}", type="ruling", replyTo="${initial[0]?.escalationId ?? ""}", content=).` + - `\n Acknowledge without releasing: type="info". Ask for status: type="query". Cancel: type="abort".`, - { messageId: initial[0]?.escalationId, exitReason: "lane_held" }, - ); + // Penster 20260909T000015: the escalation reaches the supervisor LIVE + // (mail recognition), so a fast ruling is often already queued before the + // worker has exited. Publishing "Lane held" and then consuming that ruling + // on the first poll produced a stale-looking alert after the release. Peek + // once: if every open hold already has a valid ruling waiting, skip the + // held publication entirely and let the loop release on its first pass. + if (allHoldsHaveQueuedRuling(initial, inboxDir)) { + logExecution( + statusPath, + "Ruling already queued", + `ruling(s) for ${initial.map((h) => h.escalationId).join(", ")} were waiting in the inbox — releasing without publishing a hold`, + ); + } else { + updateStatusField(statusPath, "Status", "⏸️ Held — awaiting ruling"); + logExecution( + statusPath, + "Held", + `no worker process; awaiting ruling on ${initial.map((h) => h.escalationId).join(", ")} (reply with send_agent_message type="ruling" replyTo=; type="info" acknowledges without releasing)`, + ); + emitSnapshot( + config, + taskId, + segmentId, + "held", + lastTelemetry, + statusPath, + reviewerStatePath, + snapshotSegmentCtx, + ); + holdAlert( + "agent-message", + `⏸️ **Lane held** — ${taskId} (lane ${config.laneNumber}) is waiting for a ruling with no worker running (zero cost).\n` + + buildHoldStatusSummary(initial) + .split("\n") + .map((l) => ` ${l}`) + .join("\n") + + `\n Rule: send_agent_message(to="${workerAgentId}", type="ruling", replyTo="${initial[0]?.escalationId ?? ""}", content=).` + + `\n Acknowledge without releasing: type="info". Ask for status: type="query". Cancel: type="abort".`, + { messageId: initial[0]?.escalationId, exitReason: "lane_held" }, + ); + } let lastHeartbeat = Date.now(); for (;;) { diff --git a/extensions/taskplane/ruling-trailer.ts b/extensions/taskplane/ruling-trailer.ts index c8b532d5..e3384985 100644 --- a/extensions/taskplane/ruling-trailer.ts +++ b/extensions/taskplane/ruling-trailer.ts @@ -35,8 +35,29 @@ export interface RulingCitationFlag { /** `Taskplane-Ruling: id[, id…]` — case-insensitive, leading whitespace tolerated. */ const TRAILER_RE = /^[ \t]*Taskplane-Ruling:[ \t]*(.+?)[ \t]*$/i; -/** A prose mention of a (cap) ruling — the pattern the design flags outside a trailer. */ -const PROSE_RE = /\b(?:cap )?ruling\b/i; +/** A prose mention of a (cap) ruling — the candidate pattern outside a trailer. */ +const PROSE_RE = /\b(?:(?:cap )?ruling|ruled)\b/i; +/** + * A prose mention is a CLAIM only when it asserts a ruling was received/applied: + * an `R###` review reference, a verdict token, or "per / applied / implemented / + * as ruled / ruling (FIX)" language. Hold BOOKKEEPING ("pending operator + * ruling", "awaiting a ruling", "requesting a ruling", "hold … ruling") is not + * a claim — penster 20260909T000015 flagged `hold(TP-1919): record HARD HOLD on + * Step 2 pending operator ruling` as a false positive. + */ +const CLAIM_RE = + /\bR\d{3}\b|\((?:FIX|ACCEPT|REJECT|APPROVE)\)|\b(?:per|apply|applied|applies|applying|implement|implemented|implementing|follow|following|honou?r|honou?ring)\b[^.\n]{0,40}\bruling\b|\bruling\b[^.\n]{0,40}\b(?:applied|implemented|received|says|said)\b|\bas ruled\b/i; +const BOOKKEEPING_RE = + /\b(?:pending|awaiting|await|requesting|requested|request|need(?:s|ed)?|hold|held|holding|until|before|without|no)\b[^.\n]{0,40}\bruling\b/i; + +/** Is this non-trailer line an affirmative ruling claim (vs. hold bookkeeping)? */ +export function isProseRulingClaim(line: string): boolean { + if (!PROSE_RE.test(line)) return false; + if (BOOKKEEPING_RE.test(line) && !/\bR\d{3}\b|\((?:FIX|ACCEPT|REJECT|APPROVE)\)/i.test(line)) { + return false; + } + return CLAIM_RE.test(line); +} /** * Split a commit message into structured trailer citations and prose ruling @@ -58,7 +79,7 @@ export function parseRulingCitations(commitMessage: string | null | undefined): } continue; // trailer line — not a prose claim } - if (PROSE_RE.test(line)) { + if (isProseRulingClaim(line)) { proseClaims.push(line.trim()); } } diff --git a/extensions/tests/held-state-runner.test.ts b/extensions/tests/held-state-runner.test.ts index dde8be1b..178b9476 100644 --- a/extensions/tests/held-state-runner.test.ts +++ b/extensions/tests/held-state-runner.test.ts @@ -638,6 +638,36 @@ describe("#627 — held state (lane-runner behavioural)", () => { expect(holds()[0].deliveryState).toBe("acknowledged"); }); + it("penster 20260909T000015: a ruling already queued when the worker exits releases WITHOUT publishing 'Lane held' (no stale alert after the release)", async () => { + let escId = ""; + onSpawn = async (i) => { + if (i === 0) { + escId = escalate("fast ruling incoming"); + // The escalation reaches the supervisor live; it rules before the worker exits. + await sleep(200); // let the live drain open the hold + ruling(escId, "Ruling: go with option B"); + } + if (i === 1) { + expect(spawnPrompts[1].startsWith("## Ruling received")).toBe(true); + writeOutboxMessage(tmpRoot, BATCH, AGENT, { + from: AGENT, + type: "reply", + content: "ack", + replyTo: holds()[0].ruling!.id, + }); + checkBox(); + } + }; + const { unit, config } = buildUnitAndConfig(); + const r = await run(config, unit); + expect(r.outcome.status).toBe("succeeded"); + expect(holds()[0].phase).toBe("released"); + expect(status()).toContain("Ruling already queued"); + expect(status()).not.toContain("| Held |"); + expect(alerts.some((a) => a.summary.includes("Lane held"))).toBe(false); + expect(spawnPrompts.length).toBe(2); + }); + it("a worker-written .DONE while held is quarantined, never accepted; step check-off is withheld; the monitor reports `held` instead of succeeded/stalled", async () => { onSpawn = (i) => { if (i === 0) { diff --git a/extensions/tests/ruling-trailer.test.ts b/extensions/tests/ruling-trailer.test.ts index ed8dedf4..ce285ed3 100644 --- a/extensions/tests/ruling-trailer.test.ts +++ b/extensions/tests/ruling-trailer.test.ts @@ -82,6 +82,39 @@ describe("parseRulingCitations", () => { const c = parseRulingCitations("fix: thing\n\nTaskplane-Ruling: ruling-1\n"); assert.deepEqual(c.proseClaims, []); }); + + it("hold BOOKKEEPING is not a claim (penster 20260909T000015 false positive); affirmative claims still are", async () => { + const { isProseRulingClaim } = await import("../taskplane/ruling-trailer.ts"); + // the live false positive + assert.equal( + isProseRulingClaim("hold(TP-1919): record HARD HOLD on Step 2 pending operator ruling"), + false, + ); + for (const bookkeeping of [ + "awaiting a ruling on scope", + "escalated; requesting a ruling from the supervisor", + "completion held behind Step-5 gate ruling", + "no ruling needed for this change", + "hydrate: TP-1919 record ruling + narrow Step 2 to deployer identities", + ]) { + assert.equal(isProseRulingClaim(bookkeeping), false, bookkeeping); + } + for (const claim of [ + "fix(TP-2037): R004 cap ruling (FIX) — rotate token", // the TP-2037 incident + "apply supervisor ruling: narrow Step 2 to deployer identities", + "per the ruling, accept the P1 as documented risk", + "as ruled, skip the migration", + "ruling received; implementing option B", + ]) { + assert.equal(isProseRulingClaim(claim), true, claim); + } + // a verdict token overrides bookkeeping words on the same line + assert.equal(isProseRulingClaim("pending nothing: R004 ruling (FIX) applied"), true); + assert.deepEqual( + parseRulingCitations("hold(TP-1): HARD HOLD pending operator ruling\n").proseClaims, + [], + ); + }); }); // ── Part 1: validator ───────────────────────────────────────────────── From 085556af9e44fe02b6146340823b6bd1342e646a Mon Sep 17 00:00:00 2001 From: Henry Lach Date: Sat, 12 Sep 2026 13:00:48 -0400 Subject: [PATCH 2/2] test(hold): 'ruling-independent' hold-time vocabulary is not a prose ruling claim (penster 20260911T234647 F) --- extensions/tests/ruling-trailer.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extensions/tests/ruling-trailer.test.ts b/extensions/tests/ruling-trailer.test.ts index ce285ed3..0095208e 100644 --- a/extensions/tests/ruling-trailer.test.ts +++ b/extensions/tests/ruling-trailer.test.ts @@ -96,6 +96,9 @@ describe("parseRulingCitations", () => { "completion held behind Step-5 gate ruling", "no ruling needed for this change", "hydrate: TP-1919 record ruling + narrow Step 2 to deployer identities", + // penster 20260911T234647 item F: the mandated hold-time commit vocabulary + "hold(TP-2104): commit ruling-independent work; hold on Step 3", + "chore: ruling-independent cleanup", ]) { assert.equal(isProseRulingClaim(bookkeeping), false, bookkeeping); }