Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
098b309
fix: safely split supervision wake handling by actor (#2953)
kunchenguid Aug 25, 2026
318c27c
fix(pi): hide branch outcomes tool rows in Calm (#3024)
kunchenguid Aug 25, 2026
db1b547
Add fleet-wide PR merge conflict watcher with routed wakes.
bingb0t5 Aug 25, 2026
8a91403
no-mistakes(review): fix PR conflict watch cap suppression, budget ov…
bingb0t5 Aug 25, 2026
546287c
no-mistakes(document): document PR conflict watch routing, silence, a…
bingb0t5 Aug 25, 2026
daec851
no-mistakes: apply CI fixes
bingb0t5 Aug 25, 2026
c40c783
no-mistakes: apply CI fixes
bingb0t5 Aug 25, 2026
e949422
no-mistakes: apply CI fixes
bingb0t5 Aug 25, 2026
335a7a8
no-mistakes: apply CI fixes
bingb0t5 Aug 25, 2026
f9e5729
no-mistakes(review): fix PR conflict watch gh-axi reads and disclose …
bingb0t5 Aug 25, 2026
6061b6e
no-mistakes(review): read PRs via one GraphQL call and disclose unswe…
bingb0t5 Aug 25, 2026
a96965b
Rebuild PR conflict coverage as a ledger of unobserved targets.
bingb0t5 Aug 25, 2026
edf059d
no-mistakes(review): Fix conflict delivery, reread metadata, and repo…
bingb0t5 Aug 25, 2026
6b7eaf3
no-mistakes(review): Distinguish origin failures and correct coverage…
bingb0t5 Aug 25, 2026
270404f
no-mistakes(review): Preserve typed project origin failure causes
bingb0t5 Aug 25, 2026
8727a54
no-mistakes(review): Accept port-qualified GitHub SSH origins
bingb0t5 Aug 25, 2026
1a8332d
no-mistakes(review): Track missing runtime dependencies as coverage gaps
bingb0t5 Aug 25, 2026
4d99916
no-mistakes(review): Persist independent coverage recovery lifecycle
bingb0t5 Aug 25, 2026
f322eb7
no-mistakes(review): Build structural GitHub origin parser
bingb0t5 Aug 25, 2026
6bd0cce
no-mistakes(document): Correct stale conflict-watch documentation
bingb0t5 Aug 25, 2026
1f1156f
no-mistakes(lint): Fix ShellCheck declarations and intentional lint a…
bingb0t5 Aug 25, 2026
7c8ed63
no-mistakes(review): Handle truncated GraphQL PR sets as coverage gaps
bingb0t5 Aug 25, 2026
848e687
no-mistakes(document): Document bounded PR conflict coverage
bingb0t5 Aug 25, 2026
a3b0c92
no-mistakes: apply CI fixes
bingb0t5 Aug 25, 2026
4404c05
no-mistakes: apply CI fixes
bingb0t5 Aug 25, 2026
395bfce
no-mistakes: apply CI fixes
bingb0t5 Aug 25, 2026
7e13a75
no-mistakes: apply CI fixes
bingb0t5 Aug 25, 2026
dfdd933
no-mistakes: apply CI fixes
bingb0t5 Aug 25, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -385,8 +385,8 @@ jobs:
bearings_output=$(/bin/bash tests/fm-bearings-snapshot.test.sh)
printf '%s\n' "$bearings_output"
bearings_count=$(printf '%s\n' "$bearings_output" | grep -c '^ok - ')
[ "$bearings_count" -eq 42 ] || {
echo "::error::expected 42 Bearings tests, got $bearings_count"
[ "$bearings_count" -eq 43 ] || {
echo "::error::expected 43 Bearings tests, got $bearings_count"
exit 1
}

Expand Down
130 changes: 124 additions & 6 deletions .pi/extensions/fm-branch-supervision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,20 @@ import {
type ExtensionAPI,
type ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { Box, Container, Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import {
type CalmPresentationState,
calmTranscriptClassIsVisible,
FIRSTMATE_CALM_PRESENTATION_EVENT,
} from "./lib/fm-calm-visibility.ts";
import {
activateEligibleRowsOwner,
deactivateEligibleRowsOwner,
FM_BRANCH_DISPATCH_EVENT,
releaseEligibleRowsSnapshot,
scopeForUnreadWake,
writeEligibleRowsSnapshot,
type BranchDispatchOffer,
} from "./lib/fm-branch-dispatch.ts";
import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.ts";
Expand All @@ -78,6 +87,7 @@ const mirrorCursorFile = join(state, ".branch-mirror-cursor");
const promptScript = join(fmRoot, "bin", "fm-branch-prompt.sh");
const outcomeScript = join(fmRoot, "bin", "fm-branch-outcome.sh");
const leaseScript = join(fmRoot, "bin", "fm-lease.sh");
const wakeGrantScript = join(fmRoot, "bin", "fm-wake-grant.sh");
const loadedMarker = join(state, ".pi-branch-extension-loaded");

// Same tool set in the same order on every request (part of the cached
Expand Down Expand Up @@ -292,6 +302,11 @@ export default function (pi: ExtensionAPI) {
if (activatedGeneration !== expectedGeneration) {
if (!releaseBranchLeases(expectedGeneration)) return false;
if (!generationOwnsLock(expectedGeneration)) return false;
if (!activateEligibleRowsOwner(state, wakeGrantScript, process.pid, String(expectedGeneration))) return false;
if (!generationOwnsLock(expectedGeneration)) {
deactivateEligibleRowsOwner(state, wakeGrantScript, process.pid, String(expectedGeneration));
return false;
}
markLoaded();
activatedGeneration = expectedGeneration;
}
Expand Down Expand Up @@ -592,19 +607,41 @@ ${context.command}
if (!actingAsOwner(acceptedGeneration)) throw new Error("supervision session no longer owns the fleet lock");
const heartbeat = /^heartbeat($|:)/.test(message);
const scope = scopeForUnreadWake(state, heartbeat);
if (scope.status === "empty") return;
if (scope.status === "unsafe") {
throw new Error("unread wake queue now contains a main-owned row or could not be read safely");
// A newly-arrived main-owned (check-kind) row never bounces this
// whole recheck back to main any more - scopeForUnreadWake already
// excludes it from eligibleSeqs rather than vetoing the scan, so it
// stays queued for main while whatever else is eligible right now
// still reaches the branch. A genuinely empty queue, or a queue that
// simply has nothing (or nothing further) eligible for the branch
// right now, is an ordinary quiet no-op - not a fault, so it is
// never reported back to main. Only a scan scopeForUnreadWake itself
// marks corrupted (the queue or its metadata could not be read
// safely, or - for a heartbeat review - a main-owned row anywhere in
// the unread queue, since a heartbeat needs full-fleet context)
// still falls back to main.
if (scope.status === "empty" || (!scope.corrupted && scope.eligibleSeqs.length === 0)) return;
if (scope.corrupted) {
throw new Error("the unread wake queue could not be read safely");
}
const grant = writeEligibleRowsSnapshot(
state,
scope.eligibleSeqs,
wakeGrantScript,
String(acceptedGeneration),
);
if (grant === "main-owned") throw new Error("the wake rows are already claimed by main");
if (grant !== "published") throw new Error("could not record the branch's eligible row snapshot");
// A row can still arrive between this re-check and the model starting
// the drain; that residual is accepted by the confused-agent-grade boundary.
await session.prompt(
`FIRSTMATE SUPERVISION WAKE: ${message}\n\nHandle this per your operating procedure and finish with fm_branch_report.`,
);
if (!releaseEligibleRowsSnapshot(state, wakeGrantScript, String(acceptedGeneration))) {
throw new Error("could not release the branch's settled wake-row grant");
}
})
.catch(async (error: unknown) => {
// Return the wake to main rather than losing it; the durable wake
// queue additionally re-presents anything never acknowledged.
releaseEligibleRowsSnapshot(state, wakeGrantScript, String(acceptedGeneration));
try {
await fallbackToMain(message, error instanceof Error ? error.message : String(error));
} catch {}
Expand Down Expand Up @@ -679,6 +716,7 @@ ${context.command}
});

pi.on?.("session_shutdown", () => {
deactivateEligibleRowsOwner(state, wakeGrantScript, process.pid, String(generation));
shuttingDown = true;
generation += 1;
pendingMirror.length = 0;
Expand All @@ -694,6 +732,66 @@ ${context.command}
}
});

let calmPresentation: CalmPresentationState = {
active: false,
stockExportRendering: false,
};
pi.events?.on?.(FIRSTMATE_CALM_PRESENTATION_EVENT, (data) => {
const next = data as Partial<CalmPresentationState>;
calmPresentation = {
active: next.active === true,
stockExportRendering: next.stockExportRendering === true,
};
});
const calmHides = (itemClass: Parameters<typeof calmTranscriptClassIsVisible>[0]): boolean =>
calmPresentation.active &&
!calmPresentation.stockExportRendering &&
!calmTranscriptClassIsVisible(itemClass);

const outcomesToolAnsiPattern = new RegExp(
"(?:\\u001B\\][\\s\\S]*?(?:\\u0007|\\u001B\\u005C|\\u009C))|[\\u001B\\u009B][[\\]\\()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]",
"g",
);
const normalizeOutcomesToolOutput = (value: string): string => {
const withoutAnsi = value.includes("\u001B") || value.includes("\u009B")
? value.replace(outcomesToolAnsiPattern, "")
: value;
return Array.from(withoutAnsi)
.filter((char) => {
const code = char.codePointAt(0);
if (code === undefined) return false;
if (code === 0x09 || code === 0x0a || code === 0x0d) return true;
if (code <= 0x1f) return false;
return code < 0xfff9 || code > 0xfffb;
})
.join("")
.replace(/\r/g, "");
};

type OutcomesToolShellState = {
shell?: Box;
call?: Text;
result?: Text | Container;
};
const refreshOutcomesToolShell = (
shellState: OutcomesToolShellState,
theme: Parameters<NonNullable<ToolDefinition["renderCall"]>>[1],
context: Parameters<NonNullable<ToolDefinition["renderCall"]>>[2],
): Box => {
const background = context.isPartial
? (text: string) => theme.bg("toolPendingBg", text)
: context.isError
? (text: string) => theme.bg("toolErrorBg", text)
: (text: string) => theme.bg("toolSuccessBg", text);
const shell = shellState.shell ?? new Box(1, 1, background);
shellState.shell = shell;
shell.setBgFn(background);
shell.clear();
if (shellState.call) shell.addChild(shellState.call);
if (shellState.result) shell.addChild(shellState.result);
return shell;
};

pi.registerTool?.({
name: "fm_branch_outcomes",
label: "Read supervision branch outcomes",
Expand All @@ -703,6 +801,26 @@ ${context.command}
parameters: Type.Object({
recent: Type.Optional(Type.Number({ description: "How many most-recent outcomes to read (default 20)" })),
}),
renderShell: "self",
renderCall: (_args, theme, context) => {
if (calmPresentation.stockExportRendering) throw new Error("Use Pi stock export rendering");
if (calmHides("assistant-tool-call")) return new Container();
const shellState = context.state as OutcomesToolShellState;
shellState.call = new Text(theme.fg("toolTitle", theme.bold("fm_branch_outcomes")), 0, 0);
return refreshOutcomesToolShell(shellState, theme, context);
},
renderResult: (result, _options, theme, context) => {
if (calmPresentation.stockExportRendering) throw new Error("Use Pi stock export rendering");
if (calmHides("tool-result")) return new Container();
const output = result.content
.filter((item) => item.type === "text")
.map((item) => normalizeOutcomesToolOutput(item.text))
.join("\n");
const shellState = context.state as OutcomesToolShellState;
shellState.result = output ? new Text(theme.fg("toolOutput", output), 0, 0) : new Container();
refreshOutcomesToolShell(shellState, theme, context);
return new Container();
},
execute: async (_toolCallId, params) => {
const recentRaw = (params as { recent?: unknown }).recent;
const recent = typeof recentRaw === "number" && recentRaw >= 1 ? String(Math.floor(recentRaw)) : "20";
Expand Down
13 changes: 12 additions & 1 deletion .pi/extensions/fm-primary-pi-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,8 +299,19 @@ export default function (pi: ExtensionAPI) {

function offerWakeToBranch(message: string): boolean {
const heartbeat = /^heartbeat($|:)/.test(message);
// A check-kind close (merge-confirmation polls, Relay mentions,
// credential/auth failures, and every other legitimately main-only
// class - docs/pi-supervision-branch.md) is never routed to the branch
// even when other currently-unread rows are individually eligible: this
// watcher cycle's own triggering event stays on main, exactly as before
// scopeForUnreadWake stopped letting a co-present check row veto the
// whole scan. That relaxation is what lets an UNRELATED eligible
// signal/stale row still reach the branch on this cycle; it must never
// also let a check-kind trigger itself slip past main's delivery.
const isCheckTrigger = /^check:/.test(message);
const scope = scopeForUnreadWake(state, heartbeat);
const offer = createBranchDispatchOffer(message, scope.projects, heartbeat, scope.eligible);
const eligible = !isCheckTrigger && scope.eligible;
const offer = createBranchDispatchOffer(message, scope.projects, heartbeat, eligible);
pi.events?.emit?.(FM_BRANCH_DISPATCH_EVENT, offer);
return offer.accepted;
}
Expand Down
Loading
Loading