Skip to content
Merged
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
projects/
state/
data/
scratchpad/
scratchpad*
.no-mistakes/
.atelier/
.fm-secondmate-home
Expand Down
738 changes: 738 additions & 0 deletions .pi/extensions/fm-branch-supervision.ts

Large diffs are not rendered by default.

17 changes: 16 additions & 1 deletion .pi/extensions/fm-primary-pi-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import { fileURLToPath } from "node:url";
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
import { Box, Container, Text, type Component } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import {
createBranchDispatchOffer,
FM_BRANCH_DISPATCH_EVENT,
scopeForUnreadWake,
} from "./lib/fm-branch-dispatch.ts";
import {
type CalmPresentationState,
calmTranscriptClassIsVisible,
Expand Down Expand Up @@ -292,9 +297,18 @@ export default function (pi: ExtensionAPI) {
return confirmHandlingDelivery(snapshot());
}

function offerWakeToBranch(message: string): boolean {
const heartbeat = /^heartbeat($|:)/.test(message);
const scope = scopeForUnreadWake(state, heartbeat);
const offer = createBranchDispatchOffer(message, scope.projects, heartbeat, scope.eligible);
pi.events?.emit?.(FM_BRANCH_DISPATCH_EVENT, offer);
return offer.accepted;
}

async function deliverActionableWake(
owner: SessionGeneration,
message: string,
repairFailed: boolean,
recovery?: { generation: string; watcherPid: string },
): Promise<void> {
if (!generationIsLive(owner)) return;
Expand All @@ -309,6 +323,7 @@ export default function (pi: ExtensionAPI) {
return;
}
}
if (!repairFailed && offerWakeToBranch(message)) return;
await sendWake(owner, message);
}

Expand Down Expand Up @@ -506,7 +521,7 @@ export default function (pi: ExtensionAPI) {
const restoration = await restoreAfterActionableClose(owner, predecessor);
if (!generationIsLive(owner)) return;
const message = restoration.failure ? `${classification.message}\n\n${restoration.failure}` : classification.message;
await deliverActionableWake(owner, message, restoration.recovery);
await deliverActionableWake(owner, message, Boolean(restoration.failure), restoration.recovery);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
surfaceFailure(owner, `watcher: FAILED - Pi extension could not deliver an actionable wake\n${detail}`);
Expand Down
110 changes: 110 additions & 0 deletions .pi/extensions/lib/fm-branch-dispatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { readdirSync, readFileSync } from "node:fs";

// Shared wake-dispatch handshake between the Pi watcher extension (the
// dispatcher) and the supervision-branch extension (the handler), carried over
// pi.events so neither extension imports the other.
//
// Contract: the watcher builds one offer per actionable wake and emits it on
// FM_BRANCH_DISPATCH_EVENT. A live, enabled branch extension calls accept()
// SYNCHRONOUSLY inside its handler (the event bus invokes handlers
// synchronously up to their first await), so after emit returns the watcher
// reads `accepted`: true means the branch now owns delivering and handling the
// wake (including its own fallback back to main on a later failure); false
// means no branch took it and the watcher delivers to main exactly as it did
// before the branch existed. Watcher-failure alarms are never offered - only
// main can repair the watcher cycle (fm_watch_arm_pi lives on main).

export const FM_BRANCH_DISPATCH_EVENT = "fm-branch-supervision:dispatch";

export type UnreadWakeScopeStatus = "safe" | "empty" | "unsafe";

export function scopeForUnreadWake(state: string, heartbeat: boolean): {
status: UnreadWakeScopeStatus;
eligible: boolean;
projects: string[];
} {
let queue = "";
try {
queue = readFileSync(`${state}/.wake-queue`, "utf8");
} catch {
return { status: "unsafe", eligible: false, projects: [] };
}

const rows = queue.split(/\r?\n/).filter((line) => line.length > 0);
if (rows.length === 0) return { status: "empty", eligible: false, projects: [] };

const projects = new Set<string>();
const metadata = new Map<string, string>();
try {
for (const name of readdirSync(state)) {
if (!name.endsWith(".meta")) continue;
const task = name.slice(0, -5);
const fields = readFileSync(`${state}/${name}`, "utf8").split(/\r?\n/);
const project = fields.find((line) => line.startsWith("project="))?.slice(8) ?? "";
const window = fields.find((line) => line.startsWith("window="))?.slice(7) ?? "";
if (project) {
metadata.set(task, project);
if (window) metadata.set(window, project);
}
}
} catch {
return { status: "unsafe", eligible: false, projects: [] };
}

for (const line of rows) {
const fields = line.split("\t");
if (fields.length < 4 || !/^[0-9]+$/.test(fields[1])) return { status: "unsafe", eligible: false, projects: [] };
const kind = fields[2];
const key = fields[3];
if (kind === "heartbeat") continue;
let project = "";
if (kind === "signal") {
const task = key.replace(/\.(?:status|turn-ended)$/, "");
project = metadata.get(task) ?? "";
} else if (kind === "stale") {
project = metadata.get(key) ?? metadata.get(key.replace(/^fm-/, "")) ?? "";
} else {
return { status: "unsafe", eligible: false, projects: [] };
}
if (!project) return { status: "unsafe", eligible: false, projects: [] };
projects.add(project);
}
const eligible = heartbeat || projects.size > 0;
return { status: eligible ? "safe" : "unsafe", eligible, projects: [...projects] };
}

export interface BranchDispatchOffer {
/** The watcher's actionable close message (the wake reason line(s)). */
message: string;
/**
* Exact project values from the unread task metadata this wake will drain.
* Empty means the wake is fleet-wide or could not be scoped safely.
*/
projects: readonly string[];
/** True when the watcher classified this wake as a fleet-wide heartbeat scan. */
heartbeat: boolean;
/** True only when every unread queue row is safe for branch handling. */
eligible: boolean;
/** Set by accept(); read by the watcher after emit returns. */
accepted: boolean;
accept(): void;
}

export function createBranchDispatchOffer(
message: string,
projects: readonly string[] = [],
heartbeat = false,
eligible = false,
): BranchDispatchOffer {
const offer: BranchDispatchOffer = {
message,
projects: [...projects],
heartbeat,
eligible,
accepted: false,
accept() {
offer.accepted = true;
},
};
return offer;
}
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ state/ runtime records and signals; gitignored
<id>.pr-poll private validated data sidecar for the byte-static PR merge poll
<id>.pr-poll-registration private transactional provenance record binding the task, canonical metadata identity, sidecar, and static poll publication
<id>.pr-poll-retirement private identity-bound crash-recovery receipt for one exact validated merged result; removed after its poll artifacts retire
branch-outcomes.jsonl .branch-outcomes-cursor Pi supervision-branch durable outcome store and its read cursor; bin/fm-branch-outcome.sh owns the format
branch-session/ .branch-session .branch-mirror-cursor the branch's persistent conversation, its pointer, and the dialog-mirror cursor; extension-owned (docs/pi-supervision-branch.md)
.lease-<task> per-task supervision lease naming which actor (main or branch) may change that task; bin/fm-lease-lib.sh owns the contract the guarded scripts enforce
.pr-check-quarantine/ private non-runnable storage for checks neutralized by the non-executing migration
.pr-check-migration.log private per-task outcomes distinguishing rebuilt or canonically registered replacement polls, quarantined unarmed polls, and incomplete migrations
.pr-check-migration-scan-v1 private marker proving the non-executing scan disabled every unsafe legacy check; .pr-check-migration-v1 separately records completed private repairs
Expand Down Expand Up @@ -156,7 +159,7 @@ If the session lock cannot be acquired and verified, report its exact diagnostic
A lock-refused session must not spawn, steer, merge, drain the wake queue, repair supervision, repair a checkout, or perform any other fleet mutation.

The digest itself makes no external-network call and never waits for one.
Every network check a session start owes - GitHub auth, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, and project clone refresh - runs concurrently in a bounded worker owned by `bin/fm-startup-network.sh` and is reported in the digest's own `NETWORK CHECKS` section.
Every network check a session start owes - GitHub auth, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, and project clone refresh - runs off the digest's blocking path in a bounded worker owned by `bin/fm-startup-network.sh` and is reported in the digest's own `NETWORK CHECKS` section.
When that section reports its checks still in progress it names exactly what is unconfirmed; treat none of those as passed until the result lands, either from `bin/fm-startup-network.sh report` or as a `check: startup-network` wake.

1. **Lock** - acquires the per-home session lock first, before anything mutates shared state, then starts the deferred network stage above.
Expand Down
Loading
Loading