diff --git a/CHANGELOG.md b/CHANGELOG.md index a06257f..fa35eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- **Liveness follows the harness when the mailbox has gone quiet (T583).** `mbx_agents` and `agentmbx agents` timed a live holder by its last mbx call, so a session that was busy in its harness looked stale. A Codex lease now also uses that thread's rollout mtime (file metadata only, the same one-file rule as T677). A Grok lease uses `active_sessions.json`: a live row is a heartbeat, and a dead or reused pid is not. When that stamp is the newer one the reason says `last activity N min ago (codex rollout)` or `(grok session)`; otherwise it still says `last mbx activity`. A shared conversation becomes claimable only once both are quiet. + ## 0.5.47 (2026-10-16) - **Service-hosted OpenCode sessions stay wakeable after MCP eviction (T688).** OpenCode stops an idle session's MCP about hourly, which releases its lease. T659 kept standalone sessions wakeable through their plugin's poll; a session hosted by the shared `opencode serve --service` is now a wake target too while that service process (same pid and birth) lives, and the wake posts to the service with `resume: true` and a verified receipt. A lease held by another session of the agent still blocks it. docs/OPENCODE.md recommends plain `opencode` (one service per machine, every project) for long-lived agents. diff --git a/dist/identity-availability.js b/dist/identity-availability.js index c152df4..ceddf3b 100644 --- a/dist/identity-availability.js +++ b/dist/identity-availability.js @@ -1,6 +1,10 @@ // One answer to "can this identity be claimed now?", used by both mbx_identity list and the claim itself (T204, R4.3), so // a name the list calls available is never refused by the claim for a reason the list didn't show. +import { globSync, lstatSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { identityLeaseStatus } from "./identity-leases.js"; +import { procStartEpochMs } from "./proc.js"; /** * A conversation of a shared provider process (Codex or OpenCode transport, hosted Kimi) that made no mbx call and fired * no hook for this long can be taken over by an explicit claim: closing such a conversation does not end its process, so @@ -20,6 +24,77 @@ export function parseActivity(raw) { } } const minutes = (ms) => `${Math.max(1, Math.round(ms / 60_000))} min`; +const CODEX_THREAD = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i; +/** A stamp counts only when it is a real past moment from this holder's lease, never a future or pre-lease mtime. */ +function usableStamp(at, now, claimedAt) { + return Number.isSafeInteger(at) && at >= 0 && at <= now && at > claimedAt ? at : null; +} +function fileMtime(path) { + try { + const stat = lstatSync(path); + return stat.isFile() ? Math.floor(stat.mtimeMs) : null; + } + catch { + return null; + } +} +function pidAlive(pid) { + if (!Number.isSafeInteger(pid) || pid <= 0) + return false; + try { + process.kill(pid, 0); + return true; + } + catch (e) { + return e.code === "EPERM"; + } +} +/** Exact-thread rollout mtime. Metadata only: the same one-file rule as CodexActivity, and the body is never opened. */ +function codexRolloutActivity(sessionId, home, now, claimedAt) { + if (!CODEX_THREAD.test(sessionId)) + return null; + try { + const found = globSync(`sessions/*/*/*/rollout-*-${sessionId.toLowerCase()}.jsonl`, { cwd: home }); + if (found.length !== 1) + return null; + const at = usableStamp(fileMtime(join(home, found[0])) ?? -1, now, claimedAt); + return at === null ? null : { at, source: "codex rollout" }; + } + catch { + return null; + } +} +/** A live row in Grok's active_sessions.json is a heartbeat at decision time. A dead or reused pid is not activity. */ +function grokSessionActivity(sessionId, file, now, claimedAt) { + try { + const rows = JSON.parse(readFileSync(file, "utf8")); + if (!Array.isArray(rows)) + return null; + const matches = rows.filter((r) => !!r && typeof r === "object" && r.session_id === sessionId + && Number.isSafeInteger(r.pid) && (r.pid) > 0); + if (matches.length !== 1 || !pidAlive(matches[0].pid)) + return null; + const opened = typeof matches[0].opened_at === "string" ? Date.parse(matches[0].opened_at) : NaN; + const startMs = procStartEpochMs(matches[0].pid); + if (Number.isFinite(opened) && startMs !== null && opened < startMs) + return null; + const at = usableStamp(now, now, claimedAt); + return at === null ? null : { at, source: "grok session" }; + } + catch { + return null; + } +} +function harnessActivity(lease, now, homes) { + if (lease.cli === "codex") + return codexRolloutActivity(lease.session_id, homes?.codexHome ?? (process.env.CODEX_HOME || join(homedir(), ".codex")), now, lease.claimed_at); + if (lease.cli === "grok") { + const file = homes?.grokSessionsFile ?? (process.env.MBX_GROK_SESSIONS_FILE + || join(process.env.GROK_HOME || join(homedir(), ".grok"), "active_sessions.json")); + return grokSessionActivity(lease.session_id, file, now, lease.claimed_at); + } + return null; +} export const providerRecordKey = (mcpPid) => `mcp-provider:${mcpPid}`; /** A kv value is a provider record only when both fields are present and bounded. Anything else is "no record". */ export function parseProviderRecord(raw) { @@ -105,6 +180,9 @@ export function identityAvailability(o) { const holder = `${lease.cli} session ${lease.session_id}`; if (status.state === "unknown") return { claimable: false, state: "unknown", reason: `held by ${holder}, whose process could not be verified right now; retry shortly` }; + const harness = harnessActivity(lease, o.now, o.homes); + const mbxAt = o.activity?.at ?? null; + const effectiveAt = Math.max(mbxAt ?? 0, harness?.at ?? 0); if (o.caller && lease.cli === o.caller.cli && lease.session_id === o.caller.sessionId && !o.caller.sessionId.startsWith("mcp-")) { // T383: a provider may start a second, short-lived MCP server for a session whose own server is still live and serving // (Codex did). T439: a server under the caller's own provider co-uses the lease. T440: a different parent is takeover @@ -114,7 +192,7 @@ export function identityAvailability(o) { // T608: the same conversation's other server, quiet for the stale threshold (D039), is stuck, not serving: this // session takes its own identity back without the owner. A holder still in use keeps it, so two live servers do not // trade the lease. - const sessionQuiet = o.now - (o.activity?.at ?? lease.claimed_at); + const sessionQuiet = o.now - Math.max(o.activity?.at ?? lease.claimed_at, harness?.at ?? 0); if (holderProvider !== providerPid && sessionQuiet >= SHARED_IDLE_MS) return { claimable: true, state: "idle", reason: `held by an idle MCP server of this same ${lease.cli} session (pid ${lease.holder_pid}, no mbx activity for ${minutes(sessionQuiet)})`, takeover: "same-session" }; if (providerPid !== undefined && holderProvider != null && holderProvider !== providerPid) { @@ -128,8 +206,11 @@ export function identityAvailability(o) { + "a sibling server of this session co-uses it: it acts as that identity under the holder's lease, without taking or releasing it" }; return { claimable: false, state: "live", reason: `held by a live process of this same ${lease.cli} session (pid ${lease.holder_pid}) whose provider process could not be verified; retry shortly` }; } - const quiet = o.activity ? o.now - o.activity.at : null; - if (o.activity?.shared && quiet !== null && quiet >= SHARED_IDLE_MS) + const quiet = mbxAt !== null ? o.now - mbxAt : null; + const effectiveQuiet = mbxAt !== null || harness ? o.now - effectiveAt : null; + if (o.activity?.shared && quiet !== null && quiet >= SHARED_IDLE_MS && (effectiveQuiet ?? quiet) >= SHARED_IDLE_MS) return { claimable: true, state: "idle", reason: `held by ${holder} in a shared ${lease.cli} process with no mbx activity for ${minutes(quiet)} (that conversation has probably ended)`, takeover: "idle-conversation" }; - return { claimable: false, state: "live", reason: `held by live ${holder}${quiet !== null ? `, last mbx activity ${minutes(quiet)} ago` : ""}` }; + const showHarness = harness !== null && (mbxAt === null || harness.at >= mbxAt); + const suffix = showHarness ? `, last activity ${minutes(o.now - harness.at)} ago (${harness.source})` : quiet !== null ? `, last mbx activity ${minutes(quiet)} ago` : ""; + return { claimable: false, state: "live", reason: `held by live ${holder}${suffix}` }; } diff --git a/src/identity-availability.ts b/src/identity-availability.ts index d0da9ad..7c024fb 100644 --- a/src/identity-availability.ts +++ b/src/identity-availability.ts @@ -1,6 +1,10 @@ // One answer to "can this identity be claimed now?", used by both mbx_identity list and the claim itself (T204, R4.3), so // a name the list calls available is never refused by the claim for a reason the list didn't show. +import { globSync, lstatSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { identityLeaseStatus, type IdentityLease, type ProcessEvidence } from "./identity-leases.ts"; +import { procStartEpochMs } from "./proc.ts"; /** * A conversation of a shared provider process (Codex or OpenCode transport, hosted Kimi) that made no mbx call and fired @@ -35,6 +39,66 @@ export interface Availability { } const minutes = (ms: number) => `${Math.max(1, Math.round(ms / 60_000))} min`; +const CODEX_THREAD = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i; + +interface HarnessHomes { codexHome?: string; grokSessionsFile?: string } +interface HarnessStamp { at: number; source: string } + +/** A stamp counts only when it is a real past moment from this holder's lease, never a future or pre-lease mtime. */ +function usableStamp(at: number, now: number, claimedAt: number): number | null { + return Number.isSafeInteger(at) && at >= 0 && at <= now && at > claimedAt ? at : null; +} + +function fileMtime(path: string): number | null { + try { + const stat = lstatSync(path); + return stat.isFile() ? Math.floor(stat.mtimeMs) : null; + } catch { return null; } +} + +function pidAlive(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) return false; + try { process.kill(pid, 0); return true; } + catch (e) { return (e as NodeJS.ErrnoException).code === "EPERM"; } +} + +/** Exact-thread rollout mtime. Metadata only: the same one-file rule as CodexActivity, and the body is never opened. */ +function codexRolloutActivity(sessionId: string, home: string, now: number, claimedAt: number): HarnessStamp | null { + if (!CODEX_THREAD.test(sessionId)) return null; + try { + const found = globSync(`sessions/*/*/*/rollout-*-${sessionId.toLowerCase()}.jsonl`, { cwd: home }); + if (found.length !== 1) return null; + const at = usableStamp(fileMtime(join(home, found[0])) ?? -1, now, claimedAt); + return at === null ? null : { at, source: "codex rollout" }; + } catch { return null; } +} + +/** A live row in Grok's active_sessions.json is a heartbeat at decision time. A dead or reused pid is not activity. */ +function grokSessionActivity(sessionId: string, file: string, now: number, claimedAt: number): HarnessStamp | null { + try { + const rows = JSON.parse(readFileSync(file, "utf8")) as unknown; + if (!Array.isArray(rows)) return null; + const matches = rows.filter((r): r is { pid: number; opened_at?: unknown } => + !!r && typeof r === "object" && (r as { session_id?: unknown }).session_id === sessionId + && Number.isSafeInteger((r as { pid?: unknown }).pid) && ((r as { pid: number }).pid) > 0); + if (matches.length !== 1 || !pidAlive(matches[0].pid)) return null; + const opened = typeof matches[0].opened_at === "string" ? Date.parse(matches[0].opened_at) : NaN; + const startMs = procStartEpochMs(matches[0].pid); + if (Number.isFinite(opened) && startMs !== null && opened < startMs) return null; + const at = usableStamp(now, now, claimedAt); + return at === null ? null : { at, source: "grok session" }; + } catch { return null; } +} + +function harnessActivity(lease: IdentityLease, now: number, homes?: HarnessHomes): HarnessStamp | null { + if (lease.cli === "codex") return codexRolloutActivity(lease.session_id, homes?.codexHome ?? (process.env.CODEX_HOME || join(homedir(), ".codex")), now, lease.claimed_at); + if (lease.cli === "grok") { + const file = homes?.grokSessionsFile ?? (process.env.MBX_GROK_SESSIONS_FILE + || join(process.env.GROK_HOME || join(homedir(), ".grok"), "active_sessions.json")); + return grokSessionActivity(lease.session_id, file, now, lease.claimed_at); + } + return null; +} /** The provider a holder recorded at claim (`mcp-provider:`). Start is the lease-layer birth time, not a proc-table field. */ export interface RecordedProvider { pid: number; start: string; harness?: true } @@ -113,7 +177,10 @@ export function identityAvailability(o: { lease?: IdentityLease; evidence: Proce * `providerPid`: the caller's provider process. `holderProviderPid`: the provider the live holder serves under. * `holderProviderAlive`: false only when that provider was confirmed dead. A pid mismatch without that proof is not takeover (T440). */ - caller?: { cli: string; sessionId: string; providerPid?: number; holderProviderPid?: number | null; holderProviderAlive?: boolean | null } }): Availability { + caller?: { cli: string; sessionId: string; providerPid?: number; holderProviderPid?: number | null; holderProviderAlive?: boolean | null }; + /** Isolated Codex home or Grok session file. Omitted, the real harness locations are read. */ + homes?: HarnessHomes; +}): Availability { if (o.conflict) return { claimable: false, state: "conflict", reason: "historical ownership requires explicit owner recovery" }; const lease = o.lease; if (!lease) return { claimable: true, state: "free", reason: "no session has held it under a lease" }; @@ -121,6 +188,9 @@ export function identityAvailability(o: { lease?: IdentityLease; evidence: Proce if (status.state === "expired") return { claimable: true, state: "offline", reason: lease.released_at !== null ? lease.release_reason ?? "released" : status.reason ?? "expired" }; const holder = `${lease.cli} session ${lease.session_id}`; if (status.state === "unknown") return { claimable: false, state: "unknown", reason: `held by ${holder}, whose process could not be verified right now; retry shortly` }; + const harness = harnessActivity(lease, o.now, o.homes); + const mbxAt = o.activity?.at ?? null; + const effectiveAt = Math.max(mbxAt ?? 0, harness?.at ?? 0); if (o.caller && lease.cli === o.caller.cli && lease.session_id === o.caller.sessionId && !o.caller.sessionId.startsWith("mcp-")) { // T383: a provider may start a second, short-lived MCP server for a session whose own server is still live and serving // (Codex did). T439: a server under the caller's own provider co-uses the lease. T440: a different parent is takeover @@ -130,7 +200,7 @@ export function identityAvailability(o: { lease?: IdentityLease; evidence: Proce // T608: the same conversation's other server, quiet for the stale threshold (D039), is stuck, not serving: this // session takes its own identity back without the owner. A holder still in use keeps it, so two live servers do not // trade the lease. - const sessionQuiet = o.now - (o.activity?.at ?? lease.claimed_at); + const sessionQuiet = o.now - Math.max(o.activity?.at ?? lease.claimed_at, harness?.at ?? 0); if (holderProvider !== providerPid && sessionQuiet >= SHARED_IDLE_MS) return { claimable: true, state: "idle", reason: `held by an idle MCP server of this same ${lease.cli} session (pid ${lease.holder_pid}, no mbx activity for ${minutes(sessionQuiet)})`, takeover: "same-session" }; if (providerPid !== undefined && holderProvider != null && holderProvider !== providerPid) { @@ -144,8 +214,11 @@ export function identityAvailability(o: { lease?: IdentityLease; evidence: Proce + "a sibling server of this session co-uses it: it acts as that identity under the holder's lease, without taking or releasing it" }; return { claimable: false, state: "live", reason: `held by a live process of this same ${lease.cli} session (pid ${lease.holder_pid}) whose provider process could not be verified; retry shortly` }; } - const quiet = o.activity ? o.now - o.activity.at : null; - if (o.activity?.shared && quiet !== null && quiet >= SHARED_IDLE_MS) + const quiet = mbxAt !== null ? o.now - mbxAt : null; + const effectiveQuiet = mbxAt !== null || harness ? o.now - effectiveAt : null; + if (o.activity?.shared && quiet !== null && quiet >= SHARED_IDLE_MS && (effectiveQuiet ?? quiet) >= SHARED_IDLE_MS) return { claimable: true, state: "idle", reason: `held by ${holder} in a shared ${lease.cli} process with no mbx activity for ${minutes(quiet)} (that conversation has probably ended)`, takeover: "idle-conversation" }; - return { claimable: false, state: "live", reason: `held by live ${holder}${quiet !== null ? `, last mbx activity ${minutes(quiet)} ago` : ""}` }; + const showHarness = harness !== null && (mbxAt === null || harness.at >= mbxAt); + const suffix = showHarness ? `, last activity ${minutes(o.now - harness.at)} ago (${harness.source})` : quiet !== null ? `, last mbx activity ${minutes(quiet)} ago` : ""; + return { claimable: false, state: "live", reason: `held by live ${holder}${suffix}` }; } diff --git a/test/liveness-activity.test.ts b/test/liveness-activity.test.ts new file mode 100644 index 0000000..1904c54 --- /dev/null +++ b/test/liveness-activity.test.ts @@ -0,0 +1,85 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { identityAvailability, SHARED_IDLE_MS } from "../src/identity-availability.ts"; +import type { IdentityLease } from "../src/identity-leases.ts"; + +const THREAD = "44444444-4444-4444-8444-444444444444"; +const live = { alive: true as const, start: "birth" }; + +function lease(cli: string, sessionId: string, now: number): IdentityLease { + return { + name: "reader", token: "t", holder_pid: 10, holder_start: "birth", key_fp: "aaaa-aaaa-aaaa-aaaa", + cli, session_id: sessionId, claimed_at: now - SHARED_IDLE_MS - 60_000, heartbeat_at: now, + idle_ttl: 30 * 60_000, released_at: null, release_reason: null, + }; +} + +function dir(): string { + const home = mkdtempSync(join(tmpdir(), "mbx-liveness-")); + return home; +} + +test("a writing Codex thread with a stale mbx call reads as active", () => { + const now = Date.now(), home = dir(), at = now - 1000; + const day = join(home, "sessions", "2026", "10", "17"); + mkdirSync(day, { recursive: true }); + const path = join(day, `rollout-2026-10-17T12-00-00-${THREAD}.jsonl`); + writeFileSync(path, "metadata only", { mode: 0o000 }); + utimesSync(path, at / 1000, at / 1000); + try { + const got = identityAvailability({ + lease: lease("codex", THREAD, now), evidence: live, now, homes: { codexHome: home }, + activity: { at: now - SHARED_IDLE_MS - 60_000, shared: true }, + }); + assert.equal(got.claimable, false); + assert.equal(got.state, "live"); + assert.match(got.reason, /last activity 1 min ago \(codex rollout\)/); + } finally { rmSync(home, { recursive: true, force: true }); } +}); + +test("a live Grok session with a stale mbx call reads as active", () => { + const now = Date.now(), home = dir(); + const file = join(home, "active_sessions.json"); + writeFileSync(file, JSON.stringify([{ session_id: "grok-t583", pid: process.pid, cwd: home, opened_at: new Date(now).toISOString() }])); + try { + const got = identityAvailability({ + lease: lease("grok", "grok-t583", now), evidence: live, now, homes: { grokSessionsFile: file }, + activity: { at: now - SHARED_IDLE_MS - 60_000, shared: true }, + }); + assert.equal(got.claimable, false); + assert.equal(got.state, "live"); + assert.match(got.reason, /last activity 1 min ago \(grok session\)/); + } finally { rmSync(home, { recursive: true, force: true }); } +}); + +test("a dead harness still reads as stale", () => { + const now = Date.now(), home = dir(); + const file = join(home, "active_sessions.json"); + writeFileSync(file, JSON.stringify([{ session_id: "grok-t583", pid: 2_147_483_646, cwd: home, opened_at: new Date(now).toISOString() }])); + const day = join(home, "sessions", "2026", "10", "17"); + mkdirSync(day, { recursive: true }); + const path = join(day, `rollout-2026-10-17T12-00-00-${THREAD}.jsonl`); + writeFileSync(path, "old"); + const quietAt = now - SHARED_IDLE_MS - 1000; + utimesSync(path, quietAt / 1000, quietAt / 1000); + const stale = { at: now - SHARED_IDLE_MS - 60_000, shared: true }; + try { + const grok = identityAvailability({ + lease: lease("grok", "grok-t583", now), evidence: live, now, homes: { grokSessionsFile: file }, activity: stale, + }); + assert.equal(grok.claimable, true); + assert.equal(grok.takeover, "idle-conversation"); + assert.match(grok.reason, /no mbx activity/); + assert.doesNotMatch(grok.reason, /\(grok session\)/); + const codex = identityAvailability({ + lease: lease("codex", THREAD, now), evidence: live, now, homes: { codexHome: home }, activity: stale, + }); + assert.equal(codex.claimable, true); + assert.equal(codex.takeover, "idle-conversation"); + assert.match(codex.reason, /no mbx activity/); + assert.doesNotMatch(codex.reason, /\(codex rollout\)/); + } finally { rmSync(home, { recursive: true, force: true }); } +});