Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 60 additions & 27 deletions extensions/taskplane/lane-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof readInbox> = [];
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" }
Expand All @@ -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=<escalation id>; 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 ?? "<escalation id>"}", content=<instructions>).` +
`\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=<escalation id>; 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 ?? "<escalation id>"}", content=<instructions>).` +
`\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 (;;) {
Expand Down
27 changes: 24 additions & 3 deletions extensions/taskplane/ruling-trailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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());
}
}
Expand Down
30 changes: 30 additions & 0 deletions extensions/tests/held-state-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
36 changes: 36 additions & 0 deletions extensions/tests/ruling-trailer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,42 @@ 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",
// 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);
}
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 ─────────────────────────────────────────────────
Expand Down