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
6 changes: 6 additions & 0 deletions .opencode/plugins/fm-primary-turnend-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ function runProcess(command, args, input = "") {
});
child.on("error", () => resolve({ code: 0, stdout: "", stderr: "" }));
child.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr }));
// A child that exits before this write lands makes the write fail with
// EPIPE, which node raises on the stdin stream rather than on the child.
// Unhandled, that stream error throws and takes the whole process down.
// The child's own close/error handlers already settle this promise, so a
// child that no longer wants its input needs nothing further here.
child.stdin.on("error", () => {});
child.stdin.end(input);
});
}
Expand Down
40 changes: 35 additions & 5 deletions .opencode/plugins/fm-primary-watch-arm.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm";
const ARM_READY_TIMEOUT_DEFAULT_MS = process.platform === "win32" ? 35000 : 12000;
const ARM_READY_TIMEOUT_MS = positiveInteger("FM_OPENCODE_ARM_READY_TIMEOUT_MS", ARM_READY_TIMEOUT_DEFAULT_MS);
const ARM_RETIRE_TIMEOUT_MS = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000);
// The arm child runs under a login shell so it inherits PATH additions the
// account's profile provides (an nvm-managed node, for instance), which
// bin/fm-watch-arm.sh and its descendants may need. FM_WATCH_ARM_NO_LOGIN_SHELL=1
// drops to a plain shell for tests that must not pay an unbounded, machine-
// dependent profile-sourcing cost inside a readiness window.
const ARM_SHELL_FLAG = process.env.FM_WATCH_ARM_NO_LOGIN_SHELL === "1" ? "-c" : "-lc";
const REARM_RETRY_BASE_MS = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250);
const REARM_RETRY_MAX_MS = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000);
const REARM_RETRY_LIMIT = positiveInteger("FM_WATCH_REARM_RETRY_LIMIT", 5);
Expand All @@ -19,6 +25,7 @@ let armStatus = "idle";
let retryTimer = null;
let retryFailures = 0;
let launchInFlight = null;
let launchInFlightLock = null;
let restorationInFlight = null;
let armClose = new WeakMap();
let armReadiness = new WeakMap();
Expand Down Expand Up @@ -302,7 +309,7 @@ function spawnArm(paths, sessionID, client, predecessorArmPid = "") {
FM_CONFIG_OVERRIDE: paths.config,
FM_WATCH_PREDECESSOR_ARM_PID: predecessorArmPid,
};
const armChild = spawn("bash", ["-lc", 'config_dir="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"; [ -f "$config_dir/x-mode.env" ] && . "$config_dir/x-mode.env"; exec "$FM_ROOT_OVERRIDE/bin/fm-watch-arm.sh" --restart'], {
const armChild = spawn("bash", [ARM_SHELL_FLAG, 'config_dir="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"; [ -f "$config_dir/x-mode.env" ] && . "$config_dir/x-mode.env"; exec "$FM_ROOT_OVERRIDE/bin/fm-watch-arm.sh" --restart'], {
cwd: paths.root,
env,
stdio: ["ignore", "pipe", "pipe"],
Expand Down Expand Up @@ -409,18 +416,41 @@ function armAttempt(status, armChild, includeArmChild) {
return includeArmChild ? { status, armChild } : status;
}

function lockSnapshot(paths) {
try {
return readFileSync(`${paths.state}/.lock`, "utf8");
} catch {
return null;
}
}

async function ensureArm(paths, sessionID, client, predecessorArmPid = "", includeArmChild = false) {
// Every ordinary idle turn produces two callers (this plugin's own
// session.idle handler and the turn-end guard's coordinator call), so they
// share one in-flight beginArm rather than each paying its own git/ps
// subprocess walk. That share is only sound while the premise the in-flight
// attempt is evaluating still holds: an attempt that read a foreign lock is
// computing a "read-only" verdict, and a caller arriving after the lock was
// reacquired underneath it must not inherit that answer. Comparing the lock
// file's content at call time against what the in-flight attempt captured
// keeps the common case coalesced and forces a fresh evaluation exactly when
// the state it depends on changed.
const currentLock = lockSnapshot(paths);
let launchResult = null;
if (!launchInFlight) {
if (launchInFlight && launchInFlightLock === currentLock) {
launchResult = await launchInFlight;
} else {
const launch = beginArm(paths, sessionID, client, predecessorArmPid);
launchInFlight = launch;
launchInFlightLock = currentLock;
try {
launchResult = await launch;
} finally {
if (launchInFlight === launch) launchInFlight = null;
if (launchInFlight === launch) {
launchInFlight = null;
launchInFlightLock = null;
}
}
} else {
launchResult = await launchInFlight;
}
const armChild = launchResult.armChild;
if (!armChild) {
Expand Down
6 changes: 6 additions & 0 deletions .opencode/plugins/lib/fm-operational-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ export function encodeFirstmateOperationalInput(root, kind, content) {
}
reject(new Error(stderr.trim() || `operational-input encoder exited ${code ?? "unknown"}`));
});
// A child that exits before this write lands makes the write fail with
// EPIPE, which node raises on the stdin stream rather than on the child.
// Unhandled, that stream error throws and takes the whole process down.
// The child's own error/close handlers already settle this promise, so a
// child that no longer wants its input needs nothing further here.
child.stdin.on("error", () => {});
child.stdin.end(content);
});
}
8 changes: 7 additions & 1 deletion .pi/extensions/fm-primary-pi-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ const armReadyTimeoutMs = positiveInteger(
process.platform === "win32" ? 35000 : 12000,
);
const armRetireTimeoutMs = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000);
// The arm child runs under a login shell so it inherits PATH additions the
// account's profile provides (an nvm-managed node, for instance), which
// bin/fm-watch-arm.sh and its descendants may need. FM_WATCH_ARM_NO_LOGIN_SHELL=1
// drops to a plain shell for tests that must not pay an unbounded, machine-
// dependent profile-sourcing cost inside a readiness window.
const armShellFlag = process.env.FM_WATCH_ARM_NO_LOGIN_SHELL === "1" ? "-c" : "-lc";
const repairOnlyHint = "call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy";
const shuttingDownMessage = "watcher: not armed - Pi session is shutting down";

Expand Down Expand Up @@ -394,7 +400,7 @@ export default function (pi: ExtensionAPI) {
FM_WATCH_ARM_SCRIPT: armScript,
FM_WATCH_PREDECESSOR_ARM_PID: predecessorArmPid,
};
const armChild = spawn("bash", ["-lc", "config_dir=\"${FM_CONFIG_OVERRIDE:-$FM_HOME/config}\"; [ -f \"$config_dir/x-mode.env\" ] && . \"$config_dir/x-mode.env\"; exec \"$FM_WATCH_ARM_SCRIPT\" --restart"], {
const armChild = spawn("bash", [armShellFlag, "config_dir=\"${FM_CONFIG_OVERRIDE:-$FM_HOME/config}\"; [ -f \"$config_dir/x-mode.env\" ] && . \"$config_dir/x-mode.env\"; exec \"$FM_WATCH_ARM_SCRIPT\" --restart"], {
cwd: fmRoot,
env,
stdio: ["ignore", "pipe", "pipe"],
Expand Down
6 changes: 6 additions & 0 deletions .pi/extensions/fm-primary-turnend-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ function runGuard(): Promise<{ code: number; stderr: string }> {
});
child.on("error", () => resolveResult({ code: 0, stderr: "" }));
child.on("close", (code) => resolveResult({ code: code ?? 0, stderr }));
// A child that exits before this write lands makes the write fail with
// EPIPE, which node raises on the stdin stream rather than on the child.
// Unhandled, that stream error throws and takes the whole process down.
// The child's own close/error handlers already settle this promise, so a
// child that no longer wants its input needs nothing further here.
child.stdin.on("error", () => {});
child.stdin.end('{"stop_hook_active":false}');
});
}
Expand Down
Loading
Loading