Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
91 changes: 91 additions & 0 deletions tools/orchestrator-checks/cron-sentinel-mutex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env bun
import { describe, it, expect } from "bun:test";
import { checkPeerSessions, formatResult } from "./cron-sentinel-mutex";
import type { spawnSync } from "node:child_process";

type SpawnSync = typeof spawnSync;

function fakeSpawn(stdoutLines: string[], status = 0): SpawnSync {
return (() => ({
pid: 1,
output: [],
stdout: stdoutLines.join("\n"),
stderr: "",
status,
signal: null,
})) as unknown as SpawnSync;
}

describe("checkPeerSessions", () => {
it("returns peerDetected=false when no peers found", () => {
const r = checkPeerSessions(12345, fakeSpawn([], 1));
expect(r.peerDetected).toBe(false);
expect(r.peerPids).toEqual([]);
expect(r.myPid).toBe(12345);
});

it("excludes the self-PID from peers", () => {
const lines = [
"12345 /path/to/claude-code --output-format stream-json --verbose --input-format stream-json",
"67890 /path/to/claude-code --output-format stream-json --input-format stream-json",
];
const r = checkPeerSessions(12345, fakeSpawn(lines, 0));
expect(r.peerDetected).toBe(true);
expect(r.peerPids).toEqual([67890]);
});

it("excludes parent/disclaimer/finder helpers lacking the stdio flags", () => {
const lines = [
"100 /Applications/Claude.app/Contents/MacOS/Claude",
"200 /Applications/Claude.app/Contents/Helpers/disclaimer",
"300 /path/to/claude-code --output-format stream-json --input-format stream-json",
];
const r = checkPeerSessions(999, fakeSpawn(lines, 0));
// Only 300 has the stdio-mode flags; 100 and 200 are ancestors.
expect(r.peerPids).toEqual([300]);
expect(r.peerDetected).toBe(true);
});

it("returns no peers when pgrep stdout is empty even if status=0", () => {
const r = checkPeerSessions(1, fakeSpawn([], 0));
expect(r.peerDetected).toBe(false);
});

it("ignores malformed pgrep lines", () => {
const lines = [
"not-a-pid /path/to/claude-code --output-format ...",
" ",
"999 /path/to/claude-code --output-format stream-json --input-format stream-json",
];
const r = checkPeerSessions(1, fakeSpawn(lines, 0));
expect(r.peerPids).toEqual([999]);
});

it("excludes the self-PID even when its line matches the stdio pattern", () => {
const lines = [
"555 /path/to/claude-code --output-format stream-json --input-format stream-json",
];
const r = checkPeerSessions(555, fakeSpawn(lines, 0));
expect(r.peerDetected).toBe(false);
});
});

describe("formatResult", () => {
it("formats no-peers case as a single line", () => {
const out = formatResult({ myPid: 42, peerPids: [], peerLines: [], peerDetected: false });
expect(out).toContain("no peer");
expect(out).toContain("self PID 42");
});

it("formats peer-detected case with multi-line summary", () => {
const out = formatResult({
myPid: 42,
peerPids: [100, 200],
peerLines: ["100 cmd a", "200 cmd b"],
peerDetected: true,
});
expect(out).toContain("2 peer");
expect(out).toContain("100 cmd a");
expect(out).toContain("200 cmd b");
});
});
103 changes: 103 additions & 0 deletions tools/orchestrator-checks/cron-sentinel-mutex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env bun
// cron-sentinel-mutex.ts -- detect concurrent Otto-CLI claude-code sessions
// so the <<autonomous-loop>> tick can defer when a peer is mid-flight.
//
// Per docs/backlog/P3/B-0530-cron-sentinel-mutex-prevent-otto-cli-self-contention-2026-05-15.md
// + the worktree-prune-race root-cause analysis in PR #3370 (Pattern 8 of
// docs/backlog/P3/B-0519-multi-otto-branch-state-contamination-rca-2026-05-14.md).
//
// Composes with the sibling check at tools/orchestrator-checks/verify-branch.ts
// which uses the same spawnSync pattern (no shell, args as array).

import { spawnSync } from "node:child_process";

export interface MutexResult {
readonly myPid: number;
readonly peerPids: readonly number[];
readonly peerLines: readonly string[];
readonly peerDetected: boolean;
}

const PROCESS_NAME_PATTERN = "claude-code";

/**
* Find concurrent claude-code processes other than this process.
* Uses pgrep -afl (BSD-compatible). The pgrep exit code is non-zero
* when zero processes match, which is a legitimate result and not an
* error. The function returns a structured result; the caller (the
* autonomous-loop tick body) decides whether to defer.
*/
export function checkPeerSessions(
myPid: number = process.pid,
spawnFn: typeof spawnSync = spawnSync,
): MutexResult {
// spawnSync with args array — no shell interpolation, no injection risk.
Comment thread
AceHack marked this conversation as resolved.
const result = spawnFn("pgrep", ["-afl", PROCESS_NAME_PATTERN], {
encoding: "utf8",
});

const stdout = typeof result.stdout === "string" ? result.stdout : "";
const lines = stdout.split("\n").filter((line) => line.trim().length > 0);
Comment thread
AceHack marked this conversation as resolved.

const peerLines: string[] = [];
const peerPids: number[] = [];

for (const line of lines) {
const match = /^(\d+)\s+(.*)$/.exec(line);
if (!match) continue;
const pidStr = match[1];
if (!pidStr) continue;
const pid = Number.parseInt(pidStr, 10);
if (!Number.isFinite(pid)) continue;
if (pid === myPid) continue;
// Skip ancestors / disclaimer-helper / finder-service matches:
// require the claude-code stdio-mode flags to mark a real peer.
const cmdline = match[2] ?? "";
if (!cmdline.includes("--output-format") && !cmdline.includes("--input-format")) {
continue;
}
peerPids.push(pid);
peerLines.push(line);
}

return {
myPid,
peerPids,
peerLines,
peerDetected: peerPids.length > 0,
};
}

export function formatResult(r: MutexResult): string {
if (!r.peerDetected) {
return `cron-sentinel-mutex: no peer claude-code sessions detected (self PID ${r.myPid})`;
}
const peerSummary = r.peerLines.length > 0 ? "\n " + r.peerLines.join("\n ") : "";
return (
`cron-sentinel-mutex: ${r.peerPids.length} peer claude-code session(s) detected (self PID ${r.myPid})` +
peerSummary
);
}

function main(): number {
const r = checkPeerSessions();
console.error(formatResult(r));
// Diagnostic exit codes:
// 0 = no peers
// 1+ = peer count (clamped to 250 for shell composition)
// Most callers should use the JSON output via --json instead.
if (r.peerDetected) {
return Math.min(1 + r.peerPids.length, 250);
}
return 0;
Comment thread
AceHack marked this conversation as resolved.
}

if (import.meta.main) {
const args = process.argv.slice(2);
if (args.includes("--json")) {
const r = checkPeerSessions();
console.log(JSON.stringify(r, null, 2));
process.exit(0);
Comment thread
AceHack marked this conversation as resolved.
Outdated
}
process.exit(main());
}
Loading