Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
bb2e021
fix: make the OMP task-inbox doorbell handshake observable and truthful
dnth Sep 13, 2026
73280d0
no-mistakes(review): Derive durable OMP failure journals for all acti…
dnth Sep 13, 2026
bd66663
no-mistakes(review): Apply bounded doorbell wait to every OMP launch
dnth Sep 13, 2026
0bcece4
no-mistakes(review): Journal and retire on asynchronous doorbell send…
dnth Sep 13, 2026
63acf14
no-mistakes(ci): Rebased onto f534be8 and resolved conflicts. Fixed t…
dnth Sep 13, 2026
13f86b6
no-mistakes(review): Gate doorbell handshake waits to generated OMP t…
dnth Sep 13, 2026
1c0e0a4
no-mistakes(review): Restore preserved doorbell turn-proof and ack re…
dnth Sep 13, 2026
8e8bf88
no-mistakes(review): Await initial async doorbell sends before readiness
dnth Sep 13, 2026
acd5544
no-mistakes(document): Documented truthful OMP doorbell handshake beh…
dnth Sep 13, 2026
1ab10f4
no-mistakes(ci): Fixed tests/remote-herdr-fixture.sh to normalize she…
dnth Sep 13, 2026
49699bd
no-mistakes(review): Await concurrent activation sends and cover late…
dnth Sep 13, 2026
e9c0865
no-mistakes(review): Diagnose missing OMP request directories accurately
dnth Sep 13, 2026
df9a81c
no-mistakes(review): Prevent stale async rejections poisoning success…
dnth Sep 13, 2026
2b5307a
no-mistakes(review): Restore retryable inbox records after async drai…
dnth Sep 13, 2026
774bcc4
no-mistakes(review): Restore retryable inbox records after synchronou…
dnth Sep 13, 2026
b761947
no-mistakes(review): Preserve all queued requests after async drain f…
dnth Sep 13, 2026
d51e553
no-mistakes(review): Preserve failure journals during spawn cleanup
dnth Sep 13, 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
109 changes: 100 additions & 9 deletions .omp/extensions/lib/fm-task-inbox-doorbell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ type OmpDoorbellApi = {
details: { kind: "task-inbox"; runtime: "omp" };
},
options: { deliverAs: "steer"; triggerTurn: true },
) => void;
) => void | Promise<void>;
// The downgrade recovery channel: a user prompt starts a turn on an idle
// session, where the agent-initiated sendMessage path can be deferred into
// append-only delivery by the runtime's turn policy.
Expand All @@ -49,6 +49,12 @@ const MAX_TURN_GRACE_MS = 120000;
export type TaskInboxDoorbellOptions = {
inboxDir?: string;
readyMarker?: string;
// Durable diagnosis for a refused handshake: a failure that retires the
// ready marker (activation, or a drain that takes the doorbell down) writes
// its reason here, so a missing marker is never ambiguous. Deliberately
// independent of the doorbell's own configuration - an unconfigured
// doorbell is itself an activation failure only this file can report.
failureJournal?: string;
// How long a delivered doorbell may go without a turn_start before the
// triggerTurn call is treated as downgraded to append-only and the
// instruction is re-driven through the user-prompt channel.
Expand All @@ -61,7 +67,11 @@ export type TaskInboxDoorbellOptions = {
};

export type TaskInboxDoorbell = {
activate: () => void;
// activate reports whether the doorbell is live after the call. A caller
// that publishes its own readiness marker (fm-spawn's generated extension
// touching .omp-ready) must gate that marker on this result, or readiness
// silently outlives a failed handshake.
activate: () => boolean | Promise<boolean>;
retire: () => void;
notifyTurnStart: () => void;
notifyTurnEnd: () => void;
Expand All @@ -86,6 +96,23 @@ function publishReadyMarker(marker: string): void {
renameSync(staged, marker);
}

// Best-effort durable diagnosis for a lost handshake: "<iso> <phase>: <error>".
// The write is staged and renamed like the ready marker so a concurrent reader
// never sees a partial reason. A failed journal write is swallowed - the
// journal explains failures, it must never become one.
function journalDoorbellFailure(journal: string, phase: string, error: unknown): void {
if (!journal.startsWith("/")) return;
try {
const reason = error instanceof Error ? (error.stack ?? error.message) : String(error);
mkdirSync(dirname(journal), { recursive: true });
const staged = `${journal}.staging.${process.pid}`;
writeFileSync(staged, `${new Date().toISOString()} ${phase}: ${reason}\n`, { mode: 0o600 });
renameSync(staged, journal);
} catch {
return;
}
}

function retireOwnedReadyMarker(marker: string): void {
try {
if (readFileSync(marker, "utf8") === `${process.pid}\n`) unlinkSync(marker);
Expand Down Expand Up @@ -139,13 +166,45 @@ function reconcileAwaitingTurns(requestDir: string): void {
}
}

function defaultFailureJournal(options: TaskInboxDoorbellOptions): string {
const explicit = options.failureJournal || process.env.FM_OMP_TASK_DOORBELL_FAILED || "";
if (explicit) return explicit;
const readyMarker = options.readyMarker || process.env.FM_OMP_TASK_DOORBELL_READY || "";
if (readyMarker.startsWith("/")) {
const suffix = ".omp-doorbell-ready";
const stem = readyMarker.endsWith(suffix)
? readyMarker.slice(0, -suffix.length)
: readyMarker;
return `${stem}.omp-doorbell-failed`;
}
const inboxDir = options.inboxDir || process.env.FM_OMP_TASK_INBOX_DIR || "";
const stateDir = inboxDir.startsWith("/")
? dirname(inboxDir)
: (process.env.FM_STATE_OVERRIDE?.startsWith("/")
? process.env.FM_STATE_OVERRIDE
: join(process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || process.cwd(), "state"));
return stateDir ? join(stateDir, `.omp-doorbell-failed.${process.pid}`) : "";
}

export function installTaskInboxDoorbell(
omp: OmpDoorbellApi,
options: TaskInboxDoorbellOptions = {},
): TaskInboxDoorbell {
const failureJournal = defaultFailureJournal(options);
const configured = configuredOptions(options);
if (!configured || typeof omp.sendMessage !== "function") {
return { activate: () => {}, retire: () => {}, notifyTurnStart: () => {}, notifyTurnEnd: () => {} };
const unconfiguredWhy = !configured
? "task inbox doorbell is unconfigured (inboxDir/readyMarker unresolved)"
: "OMP sendMessage is unavailable";
return {
activate: () => {
journalDoorbellFailure(failureJournal, "activate", new Error(unconfiguredWhy));
return false;
},
retire: () => {},
notifyTurnStart: () => {},
notifyTurnEnd: () => {},
};
}

const requestDir = `${configured.readyMarker}.requests`;
Expand All @@ -161,6 +220,7 @@ export function installTaskInboxDoorbell(
let dispatchingTurn = false;
let dispatchingTurnObserved = false;
const awaitingTurns = new Map<string, ReturnType<typeof setTimeout>>();
const activationSends = new Set<Promise<void>>();
let watcher: FSWatcher | undefined;
const settleAwaiting = (awaitingPath: string, outcome: "delivered" | "failed"): void => {
const timer = awaitingTurns.get(awaitingPath);
Expand Down Expand Up @@ -252,7 +312,7 @@ export function installTaskInboxDoorbell(
invoked = true;
dispatchingTurn = true;
dispatchingTurnObserved = false;
omp.sendMessage(
const sendResult = omp.sendMessage(
{
customType: "firstmate-task-inbox-doorbell",
content,
Expand All @@ -262,6 +322,18 @@ export function installTaskInboxDoorbell(
},
{ deliverAs: "steer", triggerTurn: true },
);
if (sendResult && typeof sendResult.then === "function") {
const delivery = Promise.resolve(sendResult);
activationSends.add(delivery);
void delivery.catch((error: unknown) => {
if (!active && existsSync(configured.readyMarker)) return;
bestEffortRename(`${pending}.delivered`, pending);
bestEffortRename(`${pending}.awaiting-turn`, pending);
bestEffortRename(ambiguous, pending);
journalDoorbellFailure(failureJournal, "drain", error);
retire();
}).finally(() => activationSends.delete(delivery));
}
const turnStartedDuringSend = dispatchingTurnObserved;
dispatchingTurn = false;
dispatchingTurnObserved = false;
Expand All @@ -282,9 +354,11 @@ export function installTaskInboxDoorbell(
awaitingPath,
setTimeout(() => recoverUnprovenTurn(awaitingPath), turnGraceMs),
);
} catch {
} catch (error) {
dispatchingTurn = false;
if (!invoked) bestEffortRename(ambiguous, `${pending}.failed`);
if (invoked) bestEffortRename(ambiguous, pending);
else bestEffortRename(ambiguous, `${pending}.failed`);
journalDoorbellFailure(failureJournal, "drain", error);
retire();
break;
}
Expand All @@ -293,8 +367,8 @@ export function installTaskInboxDoorbell(
draining = false;
}
};
const activate = (): void => {
if (active) return;
const activate = (): boolean | Promise<boolean> => {
if (active) return true;
try {
mkdirSync(requestDir, { recursive: true, mode: 0o700 });
reconcileAmbiguousClaims(requestDir);
Expand All @@ -308,9 +382,26 @@ export function installTaskInboxDoorbell(
}
publishReadyMarker(configured.readyMarker);
drain();
} catch {
} catch (error) {
journalDoorbellFailure(failureJournal, "activate", error);
retire();
return false;
}
// A drain failure retires the doorbell without throwing; it already
// journaled its reason, so the activation reports the failure it caused.
if (!active) return false;
if (activationSends.size > 0) {
return (async (): Promise<boolean> => {
while (activationSends.size > 0) {
await Promise.allSettled([...activationSends]);
}
if (!active) return false;
bestEffortUnlink(failureJournal);
return true;
})();
}
bestEffortUnlink(failureJournal);
return true;
};

return { activate, retire, notifyTurnStart, notifyTurnEnd };
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ state/ volatile runtime signals; gitignored
<id>.kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown
<id>.devin-turnend-token firstmate-owned Devin hook registry token for the task; removed by teardown
<id>.hermes-turnend-token <id>.hermes-session <id>.hermes-started firstmate-owned Hermes hook registry token plus the task's stable session id and per-turn start acknowledgement; removed by teardown
<id>.omp-ext.ts <id>.omp-ready <id>.omp-started firstmate-generated OMP task extension plus its session-start and first-turn acknowledgement markers; removed by teardown
<id>.omp-ext.ts <id>.omp-ready <id>.omp-started <id>.omp-doorbell-ready <id>.omp-doorbell-failed firstmate-generated OMP task extension plus its session-start and first-turn acknowledgement markers; .omp-ready publishes only after the inbox doorbell activates, and a lost handshake journals its reason to .omp-doorbell-failed (docs/architecture.md; bin/fm-task-inbox-lib.sh); removed by teardown
<id>.inbox/ durable steering inbox: sequenced firstmate instruction records the worker acknowledges by moving them into its handled/ subdirectory; written by fm-send, re-rung and escalated by the watcher, removed by teardown (bin/fm-task-inbox-lib.sh)
<id>.meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; optional grok_turnend_dir=, kimi_turnend_dir=, and devin_turnend_dir= persist harness registry ownership for teardown; optional prewalk_into= records an effective OMP Prewalk target; optional allow_project_omp_extensions=1 records explicit approval for tracked project extensions on an OMP launch (docs/configuration.md "OMP project extensions"); an optional traceparent= only when trace context is enabled (docs/configuration.md "Trace context propagation"); kind=secondmate also records home= and projects=, plus remote_host=/remote_root=/remote_backend=/remote_herdr_session=/remote_target= for a remote route; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14)
<id>.herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Presentation spaces"
Expand Down
6 changes: 5 additions & 1 deletion bin/fm-send.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,7 @@ else
INBOX_RECORD_HANDLED=0
FM_TASK_INBOX_RING_OMP_REQUEST=
FM_TASK_INBOX_RING_OMP_PID=
FM_TASK_INBOX_RING_OMP_DOORBELL=
case "$INBOX_RECORD" in
*/handled/*)
ring_rc=0
Expand All @@ -1115,7 +1116,10 @@ else
if [ "$TARGET_HARNESS" = omp ]; then
OMP_NATIVE_SESSION_PID=${FM_TASK_INBOX_RING_OMP_PID:-unreadable}
[ "$INBOX_RECORD_HANDLED" = 1 ] && OMP_NATIVE_SESSION_PID=not-a-session-receipt
OMP_NATIVE_BINDING="task=$TARGET_TASK_ID endpoint=$TARGET_BACKEND:$T session-pid=$OMP_NATIVE_SESSION_PID request=${FM_TASK_INBOX_RING_OMP_REQUEST:-none} record=$INBOX_RECORD message-bytes=$(printf '%s' "$MESSAGE" | wc -c | tr -d '[:space:]')"
OMP_NATIVE_BINDING="task=$TARGET_TASK_ID endpoint=$TARGET_BACKEND:$T session-pid=$OMP_NATIVE_SESSION_PID"
[ -z "${FM_TASK_INBOX_RING_OMP_DOORBELL:-}" ] \
|| OMP_NATIVE_BINDING="$OMP_NATIVE_BINDING $FM_TASK_INBOX_RING_OMP_DOORBELL"
OMP_NATIVE_BINDING="$OMP_NATIVE_BINDING request=${FM_TASK_INBOX_RING_OMP_REQUEST:-none} record=$INBOX_RECORD message-bytes=$(printf '%s' "$MESSAGE" | wc -c | tr -d '[:space:]')"
fi
case "$ring_rc" in
0) ;;
Expand Down
48 changes: 37 additions & 11 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2212,7 +2212,8 @@ if [ "$HARNESS" = omp ]; then
fi
for artifact in \
"$STATE/$ID.status" "$STATE/$ID.omp-ext.ts" "$STATE/$ID.omp-ready" \
"$STATE/$ID.omp-started" "$STATE/$ID.omp-doorbell-ready"; do
"$STATE/$ID.omp-started" "$STATE/$ID.omp-doorbell-ready" \
"$STATE/$ID.omp-doorbell-failed"; do
if [ -L "$artifact" ] || { [ -e "$artifact" ] && [ ! -f "$artifact" ]; }; then
echo "error: refusing OMP relaunch through unsafe artifact path: $artifact" >&2
exit 1
Expand Down Expand Up @@ -2271,7 +2272,7 @@ if [ "$HARNESS" = omp ]; then
for artifact in \
"$STATE/$ID.meta" "$STATE/$ID.status" "$STATE/$ID.omp-ext.ts" \
"$STATE/$ID.omp-ready" "$STATE/$ID.omp-started" "$STATE/$ID.omp-doorbell-ready" \
"$STATE/$ID.omp-doorbell-ready.requests" "/tmp/fm-$ID"; do
"$STATE/$ID.omp-doorbell-failed" "$STATE/$ID.omp-doorbell-ready.requests" "/tmp/fm-$ID"; do
if [ -e "$artifact" ] || [ -L "$artifact" ]; then
echo "error: refusing OMP spawn because task $ID already has artifacts at $artifact; reconcile or clean the prior task before retrying" >&2
exit 1
Expand Down Expand Up @@ -4023,7 +4024,11 @@ TURNEND="$STATE_REAL/$ID.turn-ended"
TURNEND_SIGNAL="$FM_ROOT/bin/fm-turnend-signal.sh"
SPAWN_GEN="s$(date +%s).${BASHPID:-$$}.$RANDOM"
if [ "$HARNESS" = omp ]; then
rm -f "$STATE/$ID.omp-doorbell-ready"
OMP_READY="$STATE_REAL/$ID.omp-ready"
OMP_STARTED="$STATE_REAL/$ID.omp-started"
OMP_DOORBELL_READY="$STATE_REAL/$ID.omp-doorbell-ready"
OMP_DOORBELL_FAILED="$STATE_REAL/$ID.omp-doorbell-failed"
rm -f "$STATE/$ID.omp-doorbell-ready" "$STATE/$ID.omp-doorbell-failed"
fi
exclude_path() {
local rel=$1 EXCL
Expand Down Expand Up @@ -4186,26 +4191,28 @@ export default function (pi: any) {
EOF
;;
omp)
OMP_READY="$STATE_REAL/$ID.omp-ready"
OMP_STARTED="$STATE_REAL/$ID.omp-started"
OMP_DOORBELL_READY="$STATE_REAL/$ID.omp-doorbell-ready"
rm -f "$OMP_READY" "$OMP_STARTED" "$OMP_DOORBELL_READY"
rm -f "$OMP_READY" "$OMP_STARTED" "$OMP_DOORBELL_READY" "$OMP_DOORBELL_FAILED"
cat > "$STATE/$ID.omp-ext.ts" <<EOF
// Firstmate OMP launch acknowledgement, inbox doorbell, and turn-end signal; written by fm-spawn.
// .omp-ready publishes only after the inbox doorbell activates; a failed
// activation journals its reason to .omp-doorbell-failed instead, so a missing
// marker is always attributable.
import { execFile } from "node:child_process";
import { installTaskInboxDoorbell } from "$FM_ROOT/.omp/extensions/lib/fm-task-inbox-doorbell.ts";
export default function (omp: any) {
const taskInboxDoorbell = installTaskInboxDoorbell(omp, {
inboxDir: "$STATE_REAL/$ID.inbox",
readyMarker: "$OMP_DOORBELL_READY",
failureJournal: "$OMP_DOORBELL_FAILED",
// Turn proof rides this extension's own turn_start/turn_end handlers
// through notifyTurnStart/notifyTurnEnd, so the doorbell holds no omp.on
// subscription of its own.
observeTurns: false,
});
omp.on("session_start", () => {
taskInboxDoorbell.activate();
execFile("touch", ["$OMP_READY"]);
Promise.resolve(taskInboxDoorbell.activate()).then((active) => {
if (active) execFile("touch", ["$OMP_READY"]);
});
});
omp.on("turn_start", () => {
taskInboxDoorbell.notifyTurnStart();
Expand Down Expand Up @@ -4535,7 +4542,7 @@ if [ "$OMP_LAUNCH_TEMPLATE" -eq 1 ] && [ -n "$OMP_BUN_LAUNCH_DIR" ]; then
OMP_LAUNCH_PATH_GUARD="PATH=$(shell_quote "$OMP_BUN_LAUNCH_DIR${PATH:+:$PATH}"); export PATH; FM_OMP_BUN_LOOKUP=\$(command -v bun) || exit 1; FM_OMP_BUN_RESOLVED=\$(readlink -f \"\$FM_OMP_BUN_LOOKUP\" 2>/dev/null || node -e 'const { realpathSync } = require(\"node:fs\"); process.stdout.write(realpathSync(process.argv[1]));' \"\$FM_OMP_BUN_LOOKUP\") || exit 1; [ \"\$FM_OMP_BUN_RESOLVED\" = $(shell_quote "$OMP_BUN_CANON") ] || exit 1; "
fi
if [ "$OMP_LAUNCH_TEMPLATE" -eq 1 ] && [ "$HARNESS" = omp ] && [ -n "$OMP_BIN_CANON" ]; then
LAUNCH="FM_OMP_TASK_INBOX_DIR=$(shell_quote "$STATE_REAL/$ID.inbox") FM_OMP_TASK_DOORBELL_READY=$(shell_quote "$STATE_REAL/$ID.omp-doorbell-ready") FM_OMP_BUN=$(shell_quote "$OMP_BUN_CANON") FM_OMP_BIN=$(shell_quote "$OMP_BIN_CANON") $LAUNCH"
LAUNCH="FM_OMP_TASK_INBOX_DIR=$(shell_quote "$STATE_REAL/$ID.inbox") FM_OMP_TASK_DOORBELL_READY=$(shell_quote "$STATE_REAL/$ID.omp-doorbell-ready") FM_OMP_TASK_DOORBELL_FAILED=$(shell_quote "$STATE_REAL/$ID.omp-doorbell-failed") FM_OMP_BUN=$(shell_quote "$OMP_BUN_CANON") FM_OMP_BIN=$(shell_quote "$OMP_BIN_CANON") $LAUNCH"
fi
OMPRESUMEFLAG=
[ -z "$OMP_RESUME_FILE" ] || OMPRESUMEFLAG="--resume $(shell_quote "$OMP_RESUME_FILE") "
Expand Down Expand Up @@ -4716,9 +4723,28 @@ if [ "${HERDR_PROJECTED:-0}" -eq 1 ]; then
HERDR_PROJECTION_ABORT_CLEANUP=0
spawn_herdr_presentation_order_lock_release
fi
if [ "$HARNESS" = omp ]; then
if [ "$HARNESS" = omp ] && [ "$OMP_LAUNCH_TEMPLATE" -eq 1 ]; then
OMP_ACK_INTERVAL=${FM_OMP_LAUNCH_ACK_INTERVAL:-0.5}
OMP_ACKED=0
OMP_DOORBELL_ACK_POLLS=${FM_OMP_DOORBELL_ACK_POLLS:-40}
OMP_DOORBELL_ACKED=0
for _ in $(seq 1 "$OMP_DOORBELL_ACK_POLLS"); do
if [ -f "$OMP_DOORBELL_READY" ]; then
OMP_DOORBELL_ACKED=1
break
fi
[ -f "$OMP_DOORBELL_FAILED" ] && break
sleep "$OMP_ACK_INTERVAL"
done
if [ "$OMP_DOORBELL_ACKED" -ne 1 ]; then
OMP_DOORBELL_DETAIL="the worker extension did not activate its inbox doorbell"
if [ -f "$OMP_DOORBELL_FAILED" ]; then
OMP_DOORBELL_DETAIL="doorbell activation failed: $(head -n 1 "$OMP_DOORBELL_FAILED" 2>/dev/null || printf 'unreadable journal') (journal: $OMP_DOORBELL_FAILED)"
fi
printf 'failed: OMP inbox doorbell marker %s never appeared; %s\n' "$OMP_DOORBELL_READY" "$OMP_DOORBELL_DETAIL" >> "$STATE/$ID.status"
echo "error: OMP inbox doorbell marker $OMP_DOORBELL_READY never appeared; $OMP_DOORBELL_DETAIL; cleaning the owned launch" >&2
exit 1
fi
if [ "$KIND" = secondmate ]; then
OMP_ACK_POLLS=${FM_OMP_SECONDMATE_ACK_POLLS:-120}
OMP_PRIMARY_VERSION=$(fm_primary_watch_version "$OMP_PRIMARY_EXTENSION" "$PROJ_ABS" 2>/dev/null || true)
Expand Down
Loading
Loading