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
3 changes: 2 additions & 1 deletion .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,8 @@ Before killing and respawning a live OMP agent for a wedged PROVIDER STREAM, fol
**Primary-session integration fact (verified 2026-07-31, OMP 17.1.8).**
Plain OMP started from the Firstmate root discovers `.omp/extensions/fm-primary-omp.ts` natively, including in a fresh checkout before canonical `state/` exists; `omp -e .omp/extensions/fm-primary-omp.ts` remains the explicit recovery fallback.
The adapter publishes the OMP marker shape owned by [configuration](../../../docs/configuration.md#harness-support), delivers the session-start instruction on native `session_start` and `session_switch` events, and owns watcher generations through `fm_watch_arm_omp` plus `/new` and `/resume` continuity.
It routes watcher follow-ups through OMP's `sendUserMessage(content)` with no explicit `deliverAs`, so OMP starts a turn when idle and steers while streaming instead of queueing the notification; it runs the shared turn-end predicate through native `session_stop`, and applies the shared watcher-arm, persistent-directory, and delegation-shaped tool safety checks before tool execution.
It routes watcher wakes through OMP's `sendMessage` as a hidden custom next-turn message with `triggerTurn`, which starts a handling turn from idle and, when the current turn is still unwinding, schedules the continuation that consumes every wake queued during it; [watcher continuity](../../../docs/watcher-continuity.md#actionable-wake-ordering) owns the durable-row acknowledgement and session-event re-notification contract.
It runs the shared turn-end predicate through native `session_stop`, and applies the shared watcher-arm, persistent-directory, and delegation-shaped tool safety checks before tool execution.
`bin/fm-session-start.sh` rejects a missing, stale, foreign-PID, or version-mismatched loaded marker and prints both native-discovery and explicit `-e` recovery commands.
The authoritative operating procedure is `docs/supervision-protocols/omp.md`.

Expand Down
65 changes: 51 additions & 14 deletions .omp/extensions/fm-primary-omp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// OMP-native session, stop, tool-call, and shutdown events stay in this adapter.
import { spawn, spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type {
Expand Down Expand Up @@ -135,6 +135,18 @@ function runSessionstartNudge(forceForNativeSwitch = false): string {
return result.stdout.trim();
}

function durableWakeQueueHasRows(): boolean {
let queue = "";
try {
queue = readFileSync(`${state}/.wake-queue`, "utf8");
} catch {
return false;
}
return queue.split("\n").some((line) =>
/^(?:[^\t]*)\t[0-9]+\t(?:signal|stale|check|heartbeat)\t[^\t]*\t[^\t]*$/.test(line),
);
}

function runChecker(script: string, flag: "--command" | "--tool", value: string): Promise<ProcessResult> {
return new Promise((resolveResult) => {
const child = spawn(`${fmRoot}/bin/${script}`, [flag, value], {
Expand Down Expand Up @@ -203,6 +215,37 @@ export default function (omp: ExtensionAPI) {
const taskInboxDoorbell = installTaskInboxDoorbell(omp);
let pendingStartupNudge = "";

// Hidden next-turn delivery with triggerTurn. OMP schedules an internal
// continuation bound to the current prompt generation, so a wake that lands
// while a turn is still unwinding still starts its handling turn instead of
// stranding an idle session, and every notification queued during that turn
// is consumed by the one continuation. The message never enters the editable
// pending-message UI, so the captain's draft is untouched.
const sendWakeNotification = (content: string): void => {
omp.sendMessage(
{
customType: "firstmate-watcher-wake",
content,
display: false,
attribution: "agent",
details: { kind: "watcher", runtime: "omp" },
},
{ deliverAs: "nextTurn", triggerTurn: true },
);
};

// Durable rows are the whole persistence: only fm-wake-drain acknowledgement
// removes them, so an interruption leaves the next session event able to
// re-notify. At most one notification is sent per session event, and the
// core remains the sole speaker while it owns an undelivered close.
const notifyQueuedWake = (coreOwnsDelivery: boolean): void => {
if (coreOwnsDelivery || !durableWakeQueueHasRows()) return;
sendWakeNotification(encodeOperationalInput(
"watcher",
"Durable watcher wakes are queued. Run `bin/fm-wake-drain.sh` first to present and acknowledge them.",
));
};

// Supervision-branch dispatch handshake (docs/omp-supervision-branch.md).
// Build one offer per ordinary actionable wake and emit it on the shared
// event bus; a live, enabled branch extension calls accept() synchronously
Expand Down Expand Up @@ -234,19 +277,7 @@ export default function (omp: ExtensionAPI) {
armReadyTimeoutEnv: "FM_OMP_ARM_READY_TIMEOUT_MS",
repairToolName: "fm_watch_arm_omp",
encodeOperationalInput,
sendFollowUp: async (content) => {
// Deliver a custom steer so OMP wakes idle sessions without touching the editable draft.
omp.sendMessage(
{
customType: "firstmate-watcher-wake",
content,
display: false,
attribution: "agent",
details: { kind: "watcher", runtime: "omp" },
},
{ deliverAs: "steer", triggerTurn: true },
);
},
sendFollowUp: async (content) => sendWakeNotification(content),
offerWakeToBranch,
});

Expand Down Expand Up @@ -281,9 +312,13 @@ export default function (omp: ExtensionAPI) {

omp.on("session_start", (_event, ctx) => {
taskInboxDoorbell.activate();
// Read before sessionStart: activating the watch consumes the core's own
// handoff, so asking afterwards could not tell who owns the redelivery.
const coreOwnsDelivery = watch.hasPendingActionableHandoff();
watch.sessionStart();
publishSecondmateSession(ctx);
deliverSessionstartNudge();
notifyQueuedWake(coreOwnsDelivery);
});

omp.on("turn_start", () => {
Expand All @@ -294,7 +329,9 @@ export default function (omp: ExtensionAPI) {
await watch.sessionShutdown(true);
publishSecondmateSession(ctx);
deliverSessionstartNudge(event.reason === "new" || event.reason === "resume");
const coreOwnsDelivery = watch.hasPendingActionableHandoff();
watch.sessionStart();
notifyQueuedWake(coreOwnsDelivery);
});

omp.on("before_agent_start", (event): BeforeAgentStartEventResult | undefined => {
Expand Down
15 changes: 15 additions & 0 deletions bin/fm-primary-watch-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import {
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
Expand Down Expand Up @@ -110,6 +111,7 @@ export type PrimaryWatchCore = {
arm: () => ArmResult;
armAndWait: () => Promise<ArmResult>;
acknowledgeWake: (content: string) => void;
hasPendingActionableHandoff: () => boolean;
markLoaded: () => void;
sessionShutdown: (replacement?: boolean) => Promise<void>;
sessionStart: () => void;
Expand Down Expand Up @@ -1130,6 +1132,18 @@ export function createPrimaryWatchCore(options: PrimaryWatchCoreOptions): Primar
return result;
}

// True while the core still owns an actionable wake it has not delivered.
function hasPendingActionableHandoff(): boolean {
if (replacementCoordinator.pending.length > 0) return true;
if (generation.pendingActionables.some((pending) => !pending.delivered)) return true;
try {
return validateReplacementHandoff(JSON.parse(readFileSync(actionableHandoff, "utf8")))
.some((pending) => !pending.delivered);
} catch {
return false;
}
}

function acknowledgeWake(content: string): void {
for (const [token, acknowledgement] of generation.wakeAcknowledgements) {
if (acknowledgement.content !== content) continue;
Expand Down Expand Up @@ -1164,6 +1178,7 @@ export function createPrimaryWatchCore(options: PrimaryWatchCoreOptions): Primar
arm: () => activateOwnedWatch(generation),
armAndWait,
acknowledgeWake,
hasPendingActionableHandoff,
markLoaded,
sessionShutdown,
sessionStart,
Expand Down
4 changes: 2 additions & 2 deletions bin/fm-wake-drain.sh
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ decide_scoped_locked() {
}

# The highest sequence this actor has already been presented: the branch's
# grant is exactly its current prompt's rows, and main's claim file is what its
# last drain printed. Read BEFORE an ack re-claims, so a row that arrived since
# grant is exactly its current prompt's rows, and main's eligible-row snapshot
# is what its last drain printed. Read BEFORE an ack re-claims, so a row that arrived since
# presentation is never named as "the current wake" the caller may acknowledge
# unseen. 0 when nothing is on record.
presented_max_row() { # <rows-file>
Expand Down
4 changes: 2 additions & 2 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ FM_TEST_SUMMARY_FAMILY family=watcher-wake-lock count=2 duration_ms=97033 failed
Every listed script ran at that head with no gate skip.
The isolated Herdr role matrix emitted no queued-wake warning.
The final run retained the fresh-beacon, pending-notification, queue-drain, and bounded-delivery assertions in the Herdr fixture.
The focused OMP adapter contract now delivers watcher wakes as a custom steer with `triggerTurn`, preserving the editable draft while retaining idle wake and streaming delivery.
At this 2026-08-01 head, the focused OMP adapter contract delivered watcher wakes as a custom steer with `triggerTurn`, preserving the editable draft while retaining idle wake and streaming delivery.
The tmux role fixtures emitted their expected task-copy worktree and missing-fixture-watcher notices.
The Herdr exit fixture refused an unlocked presentation close after proving normal process exit, then completed its named guarded teardown.

Expand Down Expand Up @@ -618,7 +618,7 @@ ok - a captain-worthy wake opens exactly one follow-up turn on MAIN (real SDK)
ok - OMP supervision branch live guard passed against @oh-my-pi/pi-coding-agent 17.3.4
```

The guard proves a broken branch (an unresolvable model pin) falls the wake back to main through the primary adapter's watcher-wake steer with triggerTurn, not sendUserMessage, leaving the wake queue durable.
The guard proves a broken branch (an unresolvable model pin) falls the wake back to main through the primary adapter's custom steer with triggerTurn, not sendUserMessage, leaving the wake queue durable.
It proves a resident second AgentSession is created and remains re-promptable on a later wake without its turn output reaching main or replacing main's terminal resume breadcrumb, and that a routine verdict opens no new main turn while a captain verdict opens exactly one follow-up turn.
The captain sub-check is skipped, not passed, on a run where the model judges the captain-worthy fixture routine.

Expand Down
18 changes: 18 additions & 0 deletions docs/verification/supervision.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,24 @@ ok - OMP omp/18.1.5 primary E2E proved watcher delivery with an intact editable

Before that change the same guard failed on OMP 18.1.5 at the first `/new`: the restored watcher's first wake started an agent-initiated turn, and OMP emits `before_agent_start` neither for that turn nor for a captain prompt queued into it, so an instruction staged for `before_agent_start` never reached the replacement session.

The OMP 18.1.5 idle-wake guard ran on 2026-09-06 after the adapter moved its watcher wake from a custom steer to a hidden `nextTurn` message with `triggerTurn`.
Delivery alone was already covered; this run adds the guarantee that the idle session runs the handling turn for that wake itself.

```sh
omp --version
FM_OMP_PRIMARY_LIVE_E2E=1 tests/fm-omp-primary-live-e2e.test.sh
```

```text
omp/18.1.5
ok - OMP omp/18.1.5 primary E2E proved fresh no-state and ordinary native discovery, exact ownership, once-only startup, guarded watcher startup, repeated /new continuity, shutdown, resume, and away-mode delivery
ok - OMP omp/18.1.5 primary E2E proved an idle session runs the watcher wake turn itself with an intact editable draft
```

The session was idle with an unsent draft when the watcher fired and nobody typed anything: it started the handling turn on its own and reached a terminal assistant record with the exact draft unchanged.
The same command refreshes this result.
A live leg that fires the wake mid-turn was attempted and left out: driving a reliably long real turn from the composer was not dependable enough for a guard, so the mid-turn continuation rests on OMP's own `sendCustomMessage` contract - `nextTurn` with `triggerTurn` schedules a post-prompt continuation bound to that prompt generation - plus the deterministic delivery-mode assertions in `tests/fm-omp-primary.test.sh`.

Standalone OMP executable compatibility is recorded in [runtime backend verification](runtime-backends.md#omp-lifecycle).

Current deterministic and live entry points:
Expand Down
12 changes: 9 additions & 3 deletions docs/watcher-continuity.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ While supervision is still needed and away mode remains inactive, an actionable
## Actionable wake ordering

After an actionable Pi, OMP, or OpenCode child close, the adapter starts and verifies one singleton successor before it delivers the original wake.
OMP delivers that follow-up through its host API as a hidden custom `steer` with `triggerTurn`, which starts an idle handling turn without touching an editable TUI draft.
OMP delivers that follow-up through its host API as a hidden custom `nextTurn` message with `triggerTurn`, which starts an idle handling turn without touching an editable TUI draft.
That mode is what makes an idle session reachable from a turn boundary: a wake that lands while the current turn is still unwinding schedules a continuation bound to that prompt generation instead of relying on a steer the finishing turn may never poll, and every wake queued during that turn is consumed by the one continuation.
The durable wake rows are the whole persistence: only `bin/fm-wake-drain.sh` acknowledgement removes them, so an interruption before acknowledgement leaves the rows queued for the next session event to re-notify.
On `session_start` and `session_switch`, OMP sends at most one hidden next-turn notification when structurally valid durable rows remain and the core has no undelivered actionable handoff.
The core remains the sole speaker while it owns an undelivered close, and OMP's hidden next-turn transport coalesces notifications queued during one turn into a single continuation.
A delivered follow-up is acknowledged when the runtime starts a turn whose prompt is that wake; a wake the runtime queues into an already-running turn never starts one, so the core waits at most `FM_WATCH_WAKE_CONSUME_TIMEOUT_MS` for that acknowledgement, then treats the wake as consumed and continues the successor chain instead of parking every later actionable close behind it.
It confirms the handling handoff against that successor before scheduling the follow-up, retries once against the current generation and successor, and treats a failed confirmation as a restoration failure: it classifies the error, retires a successor that is no longer alive, and surfaces exactly one typed message.
A failed confirmation is never swallowed.
Expand Down Expand Up @@ -102,8 +106,10 @@ The same suite covers Pi same-process replacement for `/new`, `/resume`, `/fork`
`tests/fm-omp-primary.test.sh` drives OMP `/new`, `/resume`, `/fork`, and reload through real watcher child processes and the adapter's public `session_switch` event, proving automatic re-arm plus the same in-flight actionable handoff without using `session_shutdown` as a replacement signal.
Both suites also prove the bounded acknowledgement wait: two consecutive actionable closes whose deliveries never receive `before_agent_start` still start a third arm and deliver exactly one wake per close.
It also covers a fresh factory bind without a prior shutdown: the superseded binding retires its arm child, cannot arm or reclaim ownership through retained session callbacks, the live binding keeps exactly one arm child, and repeated binds never accumulate more than one process-exit fallback.
`tests/fm-omp-primary.test.sh` covers OMP's binding of the same core to its native session, watcher, and shutdown surfaces, pins the input-preserving custom-steer delivery contract, pins the recovery handling handshake OMP performs before delivering that steer, and pins the single typed wake a refused handshake produces.
The opt-in `tests/fm-omp-primary-live-e2e.test.sh` proves a real OMP watcher wake reaches the session while its exact pending draft remains intact.
`tests/fm-omp-primary.test.sh` covers OMP's binding of the same core to its native session, watcher, and shutdown surfaces, pins the input-preserving hidden next-turn delivery contract, pins the recovery handling handshake OMP performs before delivering that notification, and pins the single typed wake a refused handshake produces.
It also covers durable-row re-notification on session events, acknowledgement gating, queue-empty suppression, and the silent handover while the core still owns an undelivered close.
The opt-in `tests/fm-omp-primary-live-e2e.test.sh` proves a real OMP watcher wake reaches the session while its exact pending draft remains intact, and that an idle OMP primary runs a handling turn for that wake with no manual intervention.
`tests/fm-wake-queue.test.sh` covers durable-row acknowledgement and interrupted handling replay.
`tests/fm-watch-arm.test.sh` covers durable queue replay, real remote parent-replies ingestion into the authoritative status log, decision-only OPEN DECISIONS recovery, interrupted handling replay, generation-bound acknowledgement, a persistent live successor after recovery, a watcher close inside the handling window that must leave the printed acknowledgement valid, and the self-healing moved-generation acknowledgement that consumes its handled rows and names its remedy.
`tests/fm-watch-recovery-loop.test.sh` covers the once-per-generation announcement bound with the real Pi extension against a refused handling handshake, and a handling successor that must surface a real crew event instead of going blind.
Pi and OMP share `bin/fm-primary-watch-core.ts` here, so that Pi-driven coverage exercises the same confirmation, retry, and typed-failure code OMP runs.
Expand Down
14 changes: 7 additions & 7 deletions tests/fm-omp-branch-live-e2e.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,17 +101,17 @@ const sessionFile = () => { try { return readFileSync(`${HOME}/state/.branch-ses

if (MODE === "degrade") {
// The broken branch falls the wake back to main through the primary adapter's
// exact main-wake mechanism: a firstmate-watcher-wake steer with triggerTurn,
// captured in the sendMessage stream (not sendUserMessage).
// exact main-wake mechanism: a hidden firstmate-watcher-wake nextTurn message
// with triggerTurn, captured in the sendMessage stream (not sendUserMessage).
const fallback = sent.filter((s) => s.m.customType === "firstmate-watcher-wake");
if (fallback.length < 1) fail("a broken branch did not fall the wake back to main via a watcher-wake steer");
if (fallback.some((s) => s.o.triggerTurn !== true || s.o.deliverAs !== "steer")) {
fail("fallback did not use the steer+triggerTurn main-wake mechanism");
if (fallback.length < 1) fail("a broken branch did not fall the wake back to main via a watcher-wake notification");
if (fallback.some((s) => s.o.triggerTurn !== true || s.o.deliverAs !== "nextTurn")) {
fail("fallback did not use the hidden nextTurn+triggerTurn main-wake mechanism");
}
if (sent.some((s) => s.m.customType === "fm-branch-merge")) fail("a broken branch merged into main instead of falling back");
if (userMsgs.length !== 0) fail("fallback used sendUserMessage instead of the watcher-wake steer");
if (userMsgs.length !== 0) fail("fallback used sendUserMessage instead of the watcher-wake notification");
if (!(readFileSync(`${HOME}/state/.wake-queue`, "utf8").trim().length > 0)) fail("the wake queue was lost on degrade");
console.log("DRIVER_OK degrade: broken branch fell back to main via a watcher-wake steer with the wake queue intact");
console.log("DRIVER_OK degrade: broken branch fell back to main via a hidden watcher-wake nextTurn with the wake queue intact");
process.exit(0);
}

Expand Down
Loading
Loading