diff --git a/docs/reference/community-harness-examples/dsh/README.md b/docs/reference/community-harness-examples/dsh/README.md index 1802186f2..548140459 100644 --- a/docs/reference/community-harness-examples/dsh/README.md +++ b/docs/reference/community-harness-examples/dsh/README.md @@ -22,13 +22,19 @@ own. [bpamiri/dsh-seat](https://github.com/bpamiri/dsh-seat) is a fuller dsh run `$OPENRIG_HOME/community-harnesses/`. Then declare a member with `runtime: dsh`. 3. **Give the seat credentials.** Each seat gets its own dsh home, at `$OPENRIG_HOME/state/dsh//home`, because dsh rewrites its profile there on every start. Put a `.credentials.yaml` there, or put a provider key in the seat's - `env:`. To use a home you prepared yourself, set `OPENRIG_DSH_HOME`. `OPENRIG_DSH` names another `dsh` binary. + `env:`. To use a home you prepared yourself, set `DSH_SEAT_HOME` in the member's `env:`; `DSH_SEAT_BIN` there names + another `dsh` binary. The runner doesn't take dsh's own `DSH_HOME` from its environment, because your shell's startup + files run after the seat's `env:` and could set it. A member's `env:` can't set `OPENRIG_*` names, which OpenRig + reserves, but the runner still reads `OPENRIG_DSH_HOME` and `OPENRIG_DSH` when something exports them to it, such as + a preset `command` wrapper. The runner's own defaults for dsh: - **Uploads:** the seat home gets a `cordis.patch.yml` that turns off dsh's session-log and plugin-list uploads to DeepSeek, and the runner starts dsh with `DSH_TELEMETRY_DISABLED=1`. - **Permission mode:** `DSH_PERMISSION_MODE` is `workspace-write` unless you set it. - **The hook token:** OpenRig's activity token isn't passed to dsh, whose shell tool inherits the runner's environment. +- **Its version:** the launch record names dsh's version from `dsh --version`. If that fails or takes over 5 seconds, it + names the version dsh's ACP handshake reports, marked as such: dsh 0.2.1-rc.2 reports 0.0.1 there. ## Permissions diff --git a/docs/reference/community-harness-examples/dsh/dsh.yaml b/docs/reference/community-harness-examples/dsh/dsh.yaml index ac2cc2c94..566d33054 100644 --- a/docs/reference/community-harness-examples/dsh/dsh.yaml +++ b/docs/reference/community-harness-examples/dsh/dsh.yaml @@ -8,7 +8,7 @@ launch_result: true first_input: true limits: - written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh) over ACP protocol 1, with Node 22 or later, and checked against a stand-in agent only - - each seat gets its own dsh home under $OPENRIG_HOME/state/dsh//home, holding a patch that turns off dsh's session-log and plugin-list uploads to DeepSeek; give it credentials (.credentials.yaml there) or a provider key in the seat's env + - each seat gets its own dsh home under $OPENRIG_HOME/state/dsh//home, holding a patch that turns off dsh's session-log and plugin-list uploads to DeepSeek; give it credentials (.credentials.yaml there) or a provider key in the seat's env; set DSH_SEAT_HOME in the member's env to use a home you prepared, and DSH_SEAT_BIN for another dsh binary (a member's env can't set OPENRIG_* names, and the runner doesn't read dsh's own DSH_HOME) - DSH_PERMISSION_MODE defaults to workspace-write; a permission request goes to the default person or the seat's override (rig seat set-permissions-to), is permitted with dsh's allow_once (else allow_always) when no one can be asked or OpenRig can't answer, and is refused and held as needs input only after --deny - allow_always creates a standing grant in dsh, which OpenRig doesn't record or revoke; the runner prefers allow_once, and where dsh keeps the grant and for how long isn't checked - resume isn't declared until a real dsh has reopened a conversation and recalled it; the runner reopens by the method dsh advertises and reports missing only on dsh's own not-found error, not when dsh's session list names the conversation; dsh answers -32602 for a missing conversation (and for one active elsewhere), so a missing dsh conversation reads unconfirmed diff --git a/docs/reference/community-harness-examples/dsh/openrig-dsh-runner.mjs b/docs/reference/community-harness-examples/dsh/openrig-dsh-runner.mjs index f02cd38b4..9625c158f 100755 --- a/docs/reference/community-harness-examples/dsh/openrig-dsh-runner.mjs +++ b/docs/reference/community-harness-examples/dsh/openrig-dsh-runner.mjs @@ -9,7 +9,7 @@ // can't answer (refusing and holding needs input only when the seat opted in to refusal). Copy it and maintain it as your // own. -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -207,10 +207,12 @@ const UPLOADS_OFF = [ "", ].join("\n"); -/** Each seat gets its own writable dsh home (dsh rewrites its profile there on every start). OPENRIG_DSH_HOME names - * one you prepared yourself, which is used as it is. */ +/** Each seat gets its own writable dsh home (dsh rewrites its profile there on every start). DSH_SEAT_HOME, which a rig + * spec's env: can set, or OPENRIG_DSH_HOME names one you prepared yourself, which is used as it is. Not dsh's own + * DSH_HOME: a person's shell startup files can export it, and they run after the seat's env:. */ function seatHome() { - if (process.env.OPENRIG_DSH_HOME) return process.env.OPENRIG_DSH_HOME; + const prepared = process.env.OPENRIG_DSH_HOME || process.env.DSH_SEAT_HOME; + if (prepared) return prepared; const seat = process.env.OPENRIG_NODE_ID || process.env.OPENRIG_SESSION_NAME || "default"; const home = join(openrigHome(), "state", "dsh", seat, "home"); mkdirSync(home, { recursive: true, mode: 0o700 }); @@ -219,6 +221,17 @@ function seatHome() { return home; } +/** dsh's version as its CLI reports it, or null when it can't say. dsh 0.2.1-rc.2's ACP handshake reports 0.0.1. + * SIGKILL at the timeout, because a synchronous call waits for the child to exit, and a child can ignore SIGTERM. */ +function cliVersion(bin, env) { + try { + const out = execFileSync(bin, ["--version"], { encoding: "utf8", env, timeout: 5_000, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "ignore"] }); + return out.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?/)?.[0] ?? null; + } catch { + return null; + } +} + function writeLaunchRecord(fields) { const path = process.env.OPENRIG_LAUNCH_RESULT; if (!path) return; @@ -254,7 +267,10 @@ async function main() { agentEnv.DSH_PERMISSION_MODE ??= "workspace-write"; if (agentEnv.DSH_TELEMETRY_DISABLED === undefined && agentEnv.DSH_TELEMETRY_MODE === undefined) agentEnv.DSH_TELEMETRY_DISABLED = "1"; - const child = spawn(env.OPENRIG_DSH || "dsh", ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] }); + // DSH_SEAT_BIN, which a rig spec's env: can set, or OPENRIG_DSH names another dsh binary. + const bin = env.OPENRIG_DSH || env.DSH_SEAT_BIN || "dsh"; + const version = cliVersion(bin, agentEnv); + const child = spawn(bin, ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] }); child.stdin.on("error", () => {}); const peer = new AgentPeer(child, (line) => say(`[dsh] ${line}`)); const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0, asking: 0, waiting: 0, resolvedAsk: null }; @@ -510,7 +526,8 @@ async function main() { return fatal(`dsh didn't complete the ACP handshake (${error.message})`, 1); } const capabilities = init?.agentCapabilities ?? {}; - const producer = `${RUNNER}; dsh ${init?.agentInfo?.version ?? "(version not reported)"}; ACP ${init?.protocolVersion ?? "?"}`; + const reported = init?.agentInfo?.version ? `${init.agentInfo.version} (as its ACP handshake reports it)` : "(version not reported)"; + const producer = `${RUNNER}; dsh ${version ?? reported}; ACP ${init?.protocolVersion ?? "?"}`; const resumeRef = env.OPENRIG_RESUME_REF; // Whether dsh's own session list names a conversation, following its pages: true, false when every page was read and // none names it, or undefined when the list fails, answers in another shape, or runs past LIST_PAGES pages. An ACP diff --git a/package-lock.json b/package-lock.json index 11cafa4d7..b2205da39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -473,11 +473,13 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -489,11 +491,13 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -505,11 +509,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -521,11 +527,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -537,11 +545,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -553,11 +563,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -569,11 +581,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -585,11 +599,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -601,11 +617,13 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -617,11 +635,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -633,11 +653,13 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -649,11 +671,13 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -665,11 +689,13 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -681,11 +707,13 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -697,11 +725,13 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -713,11 +743,13 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -729,11 +761,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -745,11 +779,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -761,11 +797,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -777,11 +815,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -793,11 +833,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -809,11 +851,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -825,11 +869,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -841,11 +887,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -857,11 +905,13 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -873,11 +923,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } diff --git a/packages/cli/src/commands/environment.ts b/packages/cli/src/commands/environment.ts index 2c0b8866e..040c9f9be 100644 --- a/packages/cli/src/commands/environment.ts +++ b/packages/cli/src/commands/environment.ts @@ -128,8 +128,10 @@ export function environmentCommand(override?: EnvironmentCommandDeps): Command { .action(action(async (binding: string, opts: { live?: boolean }) => report(checked(await (await client()).get>(`/api/environments/${encodeURIComponent(binding)}${opts.live ? "?live=true" : ""}`))))); cmd.command("connect").argument("").requiredOption("--generation ").description("Open only the bound execution's viewer; closing it does not prove native or compute stopped") .action(action(async (id: string, opts: { generation: string }) => { - const c = await client(), b = checked(await c.get>(`/api/environments/${encodeURIComponent(id)}?live=true`)); - checked(await c.post>(`/api/environments/${encodeURIComponent(id)}/validate`, { generation: opts.generation })); + // Launch and the guest latch already verify all projected files. Opening a viewer needs only one live + // resource lookup (the driver bounds it at 30s), not another serial validation of every startup file. + const c = await client(), b = checked(await c.get>(`/api/environments/${encodeURIComponent(id)}?live=true`, { timeoutMs: 35_000 })); + if (b.generation !== opts.generation) throw new Error("The selected environment generation changed; no viewer was opened"); const args = ["--gateway", b.placement.gateway, "--workspace", b.placement.workspace, "sandbox", "connect", b.placement.name]; await new Promise((resolveExit, reject) => { const child = spawn("openshell", args, { stdio: "inherit" }); diff --git a/packages/cli/src/commands/gateway.ts b/packages/cli/src/commands/gateway.ts index db9543224..7f6ce73fa 100644 --- a/packages/cli/src/commands/gateway.ts +++ b/packages/cli/src/commands/gateway.ts @@ -173,9 +173,9 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command { .command("add ") .description("Add a human fragment (verb-add-only; the fragment is truth, the registry is a GENERATED projection)") .requiredOption("--display-name ", "Human-readable display name") - .requiredOption( + .option( "--binding ]>", - "Connector binding (repeatable); role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file::primary:handle=; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle= makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.", + "Connector binding (repeatable); leave it out for a person with no connector, who's reached in the Feed and Steering only. With bindings, role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file::primary:handle=; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle= makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.", (v: string, acc: string[] = []) => { acc.push(v); return acc; }, ) .requiredOption("--delivery-class ", "Notification loudness class (the notifications register selection)") @@ -188,7 +188,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command { .option("--policy ", "Readable policy for agents working with this person (text, not enforced)") .option("--policy-file ", "Read the policy text from a file") .option("--default", "Make this person the default: the one escalations, the notice digest and other unaddressed needs go to") - .action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => { + .action(async (entityId: string, opts: { displayName: string; binding?: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => { // LAZY import the narrow daemon surface at invocation (dep rail 2). const registry = await import("@openrig/daemon/gateway-human-registry"); const { addHumanFragment, parseBindingSpec } = registry as unknown as { @@ -209,7 +209,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command { if (unknownGroup) { console.error(`refused: no group "${unknownGroup}" — create it with rig gateway group set ${unknownGroup} --organization … --name …`); process.exitCode = 1; return; } } const bindings: Record[] = []; - for (const spec of opts.binding) { + for (const spec of opts.binding ?? []) { const b = parseBindingSpec(spec); if (!b.ok) { console.error(`--binding: ${b.error}`); process.exitCode = 1; return; } bindings.push(b.binding); @@ -284,8 +284,9 @@ Example: if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); return; } if (res.humans.length === 0) { console.log("no human configured yet — register one: rig gateway human add --display-name … --binding … --delivery-class …"); return; } for (const h of humans) { - const inbound = h.bindings.inboundResolvable ? "" : " [outbound-only]"; - console.log(`${h.entityId} "${h.displayName}" class=${h.deliveryClass} ${h.availability} bindings=${h.bindings.count} (primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}) delivery=${h.deliveryReadiness.state}${inbound}`); + const inbound = h.bindings.inboundResolvable || !h.bindings.primary ? "" : " [outbound-only]"; + const primary = h.bindings.primary ? `primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}` : "no connector: Feed and Steering only"; + console.log(`${h.entityId} "${h.displayName}" class=${h.deliveryClass} ${h.availability} bindings=${h.bindings.count} (${primary}) delivery=${h.deliveryReadiness.state}${inbound}`); if (!h.deliveryReadiness.ready) console.log(` delivery: ${h.deliveryReadiness.reason}${h.deliveryReadiness.nextAction ? `; next: ${h.deliveryReadiness.nextAction}` : ""}`); console.log(` ${"unknown" in h.quiet ? `quiet: unknown (${h.quiet.unknown})` : describeQuiet(h.quiet)}`); } diff --git a/packages/cli/src/commands/seat.ts b/packages/cli/src/commands/seat.ts index acd8be52b..09308169d 100644 --- a/packages/cli/src/commands/seat.ts +++ b/packages/cli/src/commands/seat.ts @@ -167,11 +167,19 @@ interface SeatHandoverMutationResult { restorePacket?: { status: "unconfirmed"; reason: string }; sideEffects: { departingSessionKilled: false; + /** Absent from an older daemon, whose receipt reported both side effects as a fixed false. */ + departingProcess?: "ended-graceful" | "ended-forced" | "not-touched"; startupContextDelivered: boolean; - provenanceRecordWritten: false; + provenanceRecordWritten: boolean; }; } +const DEPARTING_PROCESS_LINES = { + "ended-graceful": "The previous occupant's process exited on TERM; its tmux session was kept, and the successor runs in the same pane.", + "ended-forced": "The previous occupant's process didn't exit on TERM and was killed; its tmux session was kept, and the successor runs in the same pane.", + "not-touched": "The previous occupant's process wasn't touched: the successor was an existing session.", +} as const; + interface SeatSwitchClientResponse { seat_ref: string; session: string; @@ -325,6 +333,13 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void { console.log(`Current occupant: ${result.currentOccupant}`); console.log(`Handover result: ${display(result.currentStatus.handoverResult)}`); console.log("Seat binding and inventory provenance were updated."); + // An older daemon reports both side effects as a fixed false: say nothing rather than misreport them. + if (result.sideEffects.departingProcess) { + console.log(result.sideEffects.provenanceRecordWritten + ? "The handover's lineage record (occupant tenure) was written." + : "The handover's lineage record (occupant tenure) was NOT written; check the daemon log, which names the first such failure since the daemon started. The seat is already bound to the successor, so don't run the handover again."); + console.log(DEPARTING_PROCESS_LINES[result.sideEffects.departingProcess]); + } // OPR.0.5.5.5 — the per-source execution record: what actually carried context. if (result.sourceOutcome?.mode === "fork") { console.log(`Native fork of ${result.sourceOutcome.forkedFrom}: the successor carries the incumbent conversation from its first byte.`); @@ -349,18 +364,18 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void { // live successor agent. console.log("The captured startup context (restore packet) was delivered to the successor."); } - console.log("No conversation continuity, provenance markdown, or session stop was performed."); + console.log("No conversation continuity was carried: the successor started a fresh conversation."); } else if (result.restorePacket?.status === "unconfirmed") { // The packet was pasted and Enter pressed: the successor may already be working on it, so don't send it again. console.log(`The captured startup context (restore packet) was sent to the successor, but its submission is unconfirmed: ${result.restorePacket.reason}.`); console.log("Check the successor's screen before sending the context again; it may already be working on it."); - console.log("No conversation continuity, provenance markdown, or session stop was performed."); + console.log("No conversation continuity was carried: the successor started a fresh conversation."); } else if (result.source.mode === "fork") { console.log("No packet delivery: fork context rides the native conversation itself."); } else { // discovered handover: the operator-prepared successor is already live, so no // separate context delivery is performed. - console.log("No conversation continuity, startup context delivery, provenance markdown, or session stop was performed."); + console.log("OpenRig delivered no startup context and carried no conversation: the discovered successor was already running."); } } diff --git a/packages/cli/test/environment-connect.test.ts b/packages/cli/test/environment-connect.test.ts new file mode 100644 index 000000000..aa250a175 --- /dev/null +++ b/packages/cli/test/environment-connect.test.ts @@ -0,0 +1,33 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { spawn } from "node:child_process"; +import { environmentCommand } from "../src/commands/environment.js"; +import { DaemonClient } from "../src/client.js"; +import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js"; +vi.mock("node:child_process", async original => ({ ...await original(), spawn: vi.fn() })); +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); process.exitCode = undefined; }); +it.each(["slow-get", "slow-validate", "changed-generation"])("the viewer bounds its live lookup without the redundant validation deadline: %s", async mode => { + vi.useFakeTimers(); const generation = mode === "changed-generation" ? "changed-generation" : "generation-one"; + const paths: string[] = []; + const client = new DaemonClient("http://127.0.0.1:1", { sessionName: "worker@test", ownerProbe: async () => ({ state: "unknown" }) as never, + fetchImpl: (async (url: string | URL | Request, init?: RequestInit) => { + paths.push(String(url)); const validation = init?.method === "POST"; + if (validation && mode === "changed-generation") return new Response(JSON.stringify({ error: "The prepared environment belongs to another generation" }), { status: 409 }); + if ((mode === "slow-get" && !validation) || (mode === "slow-validate" && validation)) { + await new Promise((resolve, reject) => { const timer = setTimeout(resolve, 6000); + init?.signal?.addEventListener("abort", () => { clearTimeout(timer); reject(init.signal?.reason); }, { once: true }); }); + } + return new Response(JSON.stringify({ generation, placement: { gateway: "gateway", workspace: "workspace", name: "worker" } }), { status: 200 }); + }) as typeof fetch }); + const lifecycleDeps: LifecycleDeps = { spawn: vi.fn(), fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(() => true), + readFile: vi.fn((path: string) => path === STATE_FILE ? JSON.stringify({ pid: 123, port: 7433, db: "test.sqlite", startedAt: "2026-04-20T00:00:00Z" }) : null), + writeFile: vi.fn(), removeFile: vi.fn(), exists: vi.fn((path: string) => path === STATE_FILE), mkdirp: vi.fn(), openForAppend: vi.fn(() => 3), isProcessAlive: vi.fn(() => true) }; + vi.mocked(spawn).mockImplementation(() => { const child = new EventEmitter(); queueMicrotask(() => child.emit("exit", 0)); return child as ReturnType; }); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + const run = environmentCommand({ lifecycleDeps, clientFactory: () => client }).parseAsync(["node", "rig", "connect", "binding-one", "--generation", "generation-one"]); + await vi.advanceTimersByTimeAsync(6500); await run; + if (mode !== "changed-generation") { + expect(paths).toEqual(["http://127.0.0.1:1/api/environments/binding-one?live=true"]); + expect(errors).not.toHaveBeenCalled(); expect(spawn).toHaveBeenCalledTimes(1); + } else { expect(spawn).not.toHaveBeenCalled(); expect(errors).toHaveBeenCalledWith(expect.stringContaining("generation")); } +}); diff --git a/packages/cli/test/seat.test.ts b/packages/cli/test/seat.test.ts index 19143a868..e380cba6c 100644 --- a/packages/cli/test/seat.test.ts +++ b/packages/cli/test/seat.test.ts @@ -260,8 +260,9 @@ const HANDOVER_RESULT = { eventSeq: 42, sideEffects: { departingSessionKilled: false, + departingProcess: "not-touched", startupContextDelivered: false, - provenanceRecordWritten: false, + provenanceRecordWritten: true, }, }; @@ -272,8 +273,9 @@ const HANDOVER_RESULT_FRESH = { discovery: { id: "disc-fresh", status: "claimed", tmuxSession: "dev-impl@seat-rig-h1SUCCID0", tmuxPane: "%9" }, sideEffects: { departingSessionKilled: false, + departingProcess: "ended-graceful", startupContextDelivered: true, - provenanceRecordWritten: false, + provenanceRecordWritten: true, }, }; @@ -677,8 +679,9 @@ describe("rig seat status", () => { currentStatus: { handoverResult: "complete" }, sideEffects: { departingSessionKilled: false, + departingProcess: "not-touched", startupContextDelivered: false, - provenanceRecordWritten: false, + provenanceRecordWritten: true, }, }); }); @@ -715,12 +718,39 @@ describe("rig seat status", () => { expect(output).toContain("Previous occupant: dev-impl@seat-rig"); expect(output).toContain("Current occupant: successor-session"); expect(output).toContain("Seat binding and inventory provenance were updated."); - // startupContextDelivered:false — the message must include "startup context delivery" in the NOT-performed list. - expect(output).toContain("No conversation continuity, startup context delivery, provenance markdown, or session stop was performed."); + // The receipt's side effects, as they happened: the lineage record written, the old process left alone. + expect(output).toContain("The handover's lineage record (occupant tenure) was written."); + expect(output).toContain("The previous occupant's process wasn't touched: the successor was an existing session."); + // startupContextDelivered:false — the output says nothing was delivered or carried... + expect(output).toContain("OpenRig delivered no startup context and carried no conversation: the discovered successor was already running."); // ...and must NOT falsely claim a delivery happened. expect(output).not.toContain("restore packet) was delivered"); }); + it("handover human output says when the lineage record wasn't written, and that the old process was killed (rebuild)", async () => { + const deps = makeDeps({ status: 200, data: { ...HANDOVER_RESULT, source: { mode: "rebuild", raw: "rebuild" }, + sourceOutcome: { mode: "rebuild", primedArtifacts: [], gaps: [] }, + sideEffects: { departingSessionKilled: false, departingProcess: "ended-forced", startupContextDelivered: true, provenanceRecordWritten: false } } }, []); + const { logs, exitCode } = await captureLogs(async () => { + await makeCommand(deps).parseAsync(["node", "rig", "seat", "handover", "dev-impl@seat-rig", "--source", "rebuild", "--reason", "context wall"]); + }); + const output = logs.join("\n"); + expect(exitCode).toBeUndefined(); // the handover itself completed + expect(output).toContain("The handover's lineage record (occupant tenure) was NOT written; check the daemon log, which names the first such failure since the daemon started. The seat is already bound to the successor, so don't run the handover again."); + expect(output).toContain("The previous occupant's process didn't exit on TERM and was killed; its tmux session was kept, and the successor runs in the same pane."); + expect(output).toContain("No conversation continuity was carried: the successor started a fresh conversation."); + expect(output).not.toContain("session stop"); + }); + + it("handover human output from an older daemon, whose receipt has no departingProcess, claims neither side effect", async () => { + const older = { departingSessionKilled: false, startupContextDelivered: false, provenanceRecordWritten: false }; + const deps = makeDeps({ status: 200, data: { ...HANDOVER_RESULT, sideEffects: older } }, []); + const { logs } = await captureLogs(async () => { + await makeCommand(deps).parseAsync(["node", "rig", "seat", "handover", "dev-impl@seat-rig", "--source", "discovered:disc-1", "--reason", "mvp-proof"]); + }); + expect(logs.join("\n")).not.toMatch(/lineage record|previous occupant's process/); + }); + it("handover fresh human output HONESTLY reports the delivered startup context (B2)", async () => { const deps = makeDeps({ status: 200, data: HANDOVER_RESULT_FRESH }, []); @@ -739,7 +769,7 @@ describe("rig seat status", () => { expect(output).toContain("Current occupant: dev-impl@seat-rig-h1SUCCID0"); // The fresh path DID deliver context — the surface must say so, not deny it. expect(output).toContain("The captured startup context (restore packet) was delivered to the successor."); - expect(output).not.toContain("No conversation continuity, startup context delivery,"); + expect(output).not.toContain("OpenRig delivered no startup context"); }); it("handover fresh human output says an unconfirmed restore packet was sent, not that nothing was delivered", async () => { @@ -759,7 +789,7 @@ describe("rig seat status", () => { expect(output).toContain("Seat handover complete: dev-impl@seat-rig"); expect(output).toContain(`its submission is unconfirmed: ${reason}.`); expect(output).toContain("Check the successor's screen before sending the context again"); - expect(output).not.toContain("No conversation continuity, startup context delivery,"); + expect(output).not.toContain("OpenRig delivered no startup context"); expect(output).not.toContain("restore packet) was delivered"); }); diff --git a/packages/daemon/assets/openshell/README.md b/packages/daemon/assets/openshell/README.md index 8098362f6..ad00b4ba0 100644 --- a/packages/daemon/assets/openshell/README.md +++ b/packages/daemon/assets/openshell/README.md @@ -122,6 +122,8 @@ The first source tests use fake provider transport, the actual guest helper's fi Driver protocol 1 is documented by `driver-v1.schema.json` and the daemon's `EnvironmentDriver` interface. Core owns placement provenance, operation intent, resolved-file selection, selected task completion and receipts; the driver owns fixed OpenShell CLI transport and guest path mapping. There is no generic plugin loader or alternative task store. Projection is bounded to 4096 files, 1 MiB per file and 32 MiB total. Unsupported runtime resources/subagents refuse the selected launch explicitly; ordinary host runtimes keep their existing behavior. Cross-host migration, RAM snapshots and arbitrary native-store portability remain outside this reference. +Startup uses a private, hash-checked `control/first-input.txt` file and a bounded initial prompt pointing Claude to it. Core prepares the guest with `--prepare --first-input-required`; an older helper rejects that option before publishing the launch latch. Rebuild the maintained image with this helper when updating the worker. Startup identity, projected role/context references and applicable text actions never pass through the host viewer's input. Native ingestion remains separately observed; a passed input is not proof that Claude read it. Viewer readiness checks the recorded guest execution, and opening the viewer checks live resource identity and generation without repeating full projection validation. + ## Lifecycle and restore limits The placement name is supplied by the caller. Create refuses an already-listed name, and each binding retains its one selected name; OpenRig does not reserve names globally across bindings. Immediately before stop/delete, OpenRig checks the saved immutable identity, then the pinned vendor call addresses the name and workspace. A deliberate reuse of that name between inspection and the vendor call could affect the replacement. The pinned CLI has no expected-id condition for these calls. Later reconciliation cannot undo that mutation. diff --git a/packages/daemon/assets/openshell/guest.py b/packages/daemon/assets/openshell/guest.py index 1be0ab7aa..139678c98 100644 --- a/packages/daemon/assets/openshell/guest.py +++ b/packages/daemon/assets/openshell/guest.py @@ -111,6 +111,11 @@ def descriptor(root, pending=False): data = regular(root, file["path"]) if len(data) != file["bytes"] or digest(data) != file["sha256"]: raise Refused("environment_projection_changed") + first_input = value.get("firstInput") + if first_input: + data = regular(root, first_input["path"]) + if digest(data) != first_input["sha256"] or len(data) != first_input["bytes"]: + raise Refused("environment_projection_changed") # P1-P3 never silently replace a requested native conversation with a fresh one. if value.get("resumeToken"): if value.get("nativeType") != "community:openshell-claude" or value["resumeToken"] != value["nativeId"]: @@ -177,6 +182,11 @@ def launch(root): argv.extend(["--effort", value["effort"]]) if value.get("permissionMode"): argv.extend(["--permission-mode", value["permissionMode"]]) + if value.get("firstInput"): + # A bounded file reference is the initial user turn; no startup bytes are typed through a host viewer. + argv.append("Read " + str(safe(root, value["firstInput"]["path"])) + " as your selected OpenRig startup input. " + "It contains your identity, role/context references and instructions. Use the authenticated OpenRig MCP tools " + "inside this guest; host-only rig CLI commands in context require their MCP equivalents.") work = safe(root, "work") work.mkdir(exist_ok=True) os.chdir(work) @@ -222,6 +232,7 @@ def main(): p.add_argument("--previous") p.add_argument("--mode", choices=("0600", "0700"), default="0600") p.add_argument("--prepare", action="store_true") + p.add_argument("--first-input-required", action="store_true") p.add_argument("--identity", action="store_true") p.add_argument("--stop-native") p.add_argument("--generation") @@ -255,6 +266,8 @@ def main(): return {"ok": True, "sha256": digest(data), "bytes": len(data)} if args.prepare: value = descriptor(root, pending=True) + if args.first_input_required and not value.get("firstInput"): + raise Refused("environment_descriptor_invalid") encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() published = safe(root, "control/launch.json") if published.exists(): diff --git a/packages/daemon/assets/openshell/preset.yaml b/packages/daemon/assets/openshell/preset.yaml index 4e1321a76..16e7ac9de 100644 --- a/packages/daemon/assets/openshell/preset.yaml +++ b/packages/daemon/assets/openshell/preset.yaml @@ -10,3 +10,4 @@ limits: launch_result: true resume: true passes_model: true +first_input: true diff --git a/packages/daemon/assets/plugins/openrig-core/skills/seat-continuity-and-handover/SKILL.md b/packages/daemon/assets/plugins/openrig-core/skills/seat-continuity-and-handover/SKILL.md index 2d7a2df0b..df1332877 100644 --- a/packages/daemon/assets/plugins/openrig-core/skills/seat-continuity-and-handover/SKILL.md +++ b/packages/daemon/assets/plugins/openrig-core/skills/seat-continuity-and-handover/SKILL.md @@ -73,6 +73,15 @@ This is the system's truth-source for "how did the current occupant get there." Without it, the control plane shows the current occupant but not the legitimacy of the transition. +`rig seat handover`'s receipt reports it under `sideEffects`: +`provenanceRecordWritten` says whether this handover's lineage record (its +occupant tenure) exists, and `departingProcess` says how the old process +ended (`ended-graceful`, `ended-forced`, or `not-touched` for a discovered +successor). If `provenanceRecordWritten` is false, the seat is already bound +to the successor and only its lineage record is missing: check the daemon +log, which names the first such failure since the daemon started, and don't +run the handover again, which would replace the occupant a second time. + ## State models — independent ### Occupant-creation state (per candidate) @@ -85,7 +94,7 @@ not the legitimacy of the transition. 1. **Stable** — current occupant attached, no in-flight binding 2. **Binding** — handover in progress -3. **Bound** — handover succeeded; provenance record written +3. **Bound** — handover succeeded; provenance record written (or missing, when the receipt says so: see failure mode 3) 4. **Unchanged** — bind failed before completion; seat retains old occupant A seat stays `Stable` even if multiple candidate-occupants were produced and discarded. @@ -94,7 +103,7 @@ A seat stays `Stable` even if multiple candidate-occupants were produced and dis 1. **Candidate creation failed** — `rebuild` couldn't synthesize from artifacts; `fork` couldn't resolve `session_source`; `fresh` couldn't launch. **Action**: bind operation does not begin; seat unchanged; provenance records the failed candidate-creation step. 2. **Old occupant cannot be detached cleanly** — runtime hung, tmux locked, etc. **Action**: bind halts mid-flight; seat enters `Binding` state with explicit "halted" sub-status; operator alerted. **Do NOT auto-rollback by reattaching old-occupant if detach didn't complete cleanly.** -3. **Bind succeeded but provenance write failed** — disk/db error. **Action**: not durable until provenance writes; treat as `Binding` halted, not `Bound`. +3. **Bind succeeded but the lineage record didn't write** — disk/db error; the receipt says `provenanceRecordWritten: false`. **Action**: the binding is committed and the successor is the occupant; only its lineage record is missing. Report it as bound with the record missing, check the daemon log (it names the first such failure since the daemon started), and don't run the handover again. 4. **Old occupant disposition unfulfillable** — operator requested `advise` (keep alive as advisor) but runtime can't keep old alive. **Action**: degrade to `retire` with explicit notification, OR fail if operator passed strict-disposition flag. 5. **Concurrent handover attempts** — two operations target the same seat. **Action**: serialize by seat-id lock; second attempt refuses with clear error. @@ -104,7 +113,7 @@ A seat stays `Stable` even if multiple candidate-occupants were produced and dis - **Do NOT introduce successor-suffix seat names** (`lead2`/`lead3`). Stable seat identity is the architectural goal. The live address stays stable. A retired tenure is distinguished by its ledger generation and exact history token; preserving it does not require a renamed live pane. -- **Do NOT report `seatBindingOutcome: handed_over`** if the provenance record didn't write durably. +- **Do NOT report a handover's provenance record as written** unless its receipt says `provenanceRecordWritten: true`; a bound seat whose record is missing is reported as exactly that (failure mode 3). - **Do NOT auto-rollback a half-completed handover** by re-attaching the old occupant unless detach completed cleanly first. ## Composition and current command surface diff --git a/packages/daemon/src/adapters/community-harness-runtime-adapter.ts b/packages/daemon/src/adapters/community-harness-runtime-adapter.ts index 01f853845..335ce76d9 100644 --- a/packages/daemon/src/adapters/community-harness-runtime-adapter.ts +++ b/packages/daemon/src/adapters/community-harness-runtime-adapter.ts @@ -217,6 +217,8 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter { if (!session || !(await this.deps.tmux.hasSession(session))) { return { ready: false, reason: "The seat's tmux session isn't there.", code: "session_missing" }; } + const contained = this.contained(); + if (contained) return contained.checkReady(binding); const command = await this.deps.tmux.getPaneCommand(session); if (!command || isShellForeground(command)) { return { ready: false, reason: `Community harness "${this.runtime}" isn't running: the pane is at a shell.`, code: "harness_not_running" }; diff --git a/packages/daemon/src/adapters/openshell-driver.ts b/packages/daemon/src/adapters/openshell-driver.ts index f60f32007..934ca70ed 100644 --- a/packages/daemon/src/adapters/openshell-driver.ts +++ b/packages/daemon/src/adapters/openshell-driver.ts @@ -15,7 +15,15 @@ export class OpenShellDriver implements EnvironmentDriver { readonly version = 1 as const; constructor(private readonly command: OpenShellCommand = async args => { try { return (await runFile("openshell", args, { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 })).stdout; } - catch { throw new EnvironmentError("openshell_transport_unconfirmed", "OpenShell did not return a confirmed result; reconcile the saved operation", 503); } + catch (error) { + const failure = error as { code?: unknown; stderr?: unknown }; + const oldHelper = args.includes("/opt/openrig/guest.py") && args.includes("--prepare") && args.includes("--first-input-required") + && typeof failure?.code === "number" && failure.code !== 0 && typeof failure.stderr === "string" + && /^guest\.py: error: unrecognized arguments: --first-input-required[ \t\r]*$/m.test(failure.stderr); + throw new EnvironmentError("openshell_transport_unconfirmed", oldHelper + ? "The guest helper rejected --first-input-required and likely predates first input. Rebuild the maintained image with the guest.py shipped by this OpenRig version; see the OpenShell worker README. The saved launch remains unconfirmed; reconcile it before another launch." + : "OpenShell did not return a confirmed result; reconcile the saved operation", 503); + } }) {} private prefix(place: Placement): string[] { return ["--gateway", place.gateway, "--workspace", place.workspace, "sandbox"]; } private resource(value: unknown): EnvironmentResource { diff --git a/packages/daemon/src/domain/claude-compaction-enforcer.ts b/packages/daemon/src/domain/claude-compaction-enforcer.ts index bfdcf377b..0ee0ba1d9 100644 --- a/packages/daemon/src/domain/claude-compaction-enforcer.ts +++ b/packages/daemon/src/domain/claude-compaction-enforcer.ts @@ -1185,7 +1185,11 @@ export class ClaudeCompactionEnforcer { this.reconcilePreparations(); } while (attempt.status === "waiting"); if (!idle.ok || attempt.status !== "waiting") { - const reason = attempt.reason ?? (!idle.ok ? idle.reason : "preparation_incomplete"); + // The transport was given this attempt's remaining budget; name its timeout like preparation expiry. + const reason = attempt.reason ?? (idle.ok ? "preparation_incomplete" + : idle.reason === "wait_for_idle_timeout" + ? this.mapReady(attempt) ? "map_complete_seat_never_idle" : "preparation_incomplete" + : idle.reason); return failed(reason); } const compact = await this.sendPreparedCompact(input, attempt, opts.skipMap === true); diff --git a/packages/daemon/src/domain/environment-lifecycle.ts b/packages/daemon/src/domain/environment-lifecycle.ts index 00aed2fe7..be2e4a6d2 100644 --- a/packages/daemon/src/domain/environment-lifecycle.ts +++ b/packages/daemon/src/domain/environment-lifecycle.ts @@ -23,7 +23,7 @@ export type EnvironmentCheckpoint = { destinationCwd: string; nativeState: string; artifacts: Array<{ path: string; source: string; sha256: string; bytes: number; taskId: string }>; files: Array<{ path: string; source: string; sha256: string; bytes: number }>; }; export type EffectReceipt = { version: 1; operationId: string; bindingId: string; generation: string | null; resourceId: string; - effect: EnvironmentEffect; state: "confirmed" | "pending" | "unconfirmed"; remaining?: DependentObservation; detail?: string }; + effect: EnvironmentEffect; state: "confirmed" | "pending" | "unconfirmed"; remaining?: DependentObservation; detail?: string; stage?: string; dispatched?: false | "unknown"; error?: { name: string; code?: string; message: string } }; type Operation = { binding_id: string; kind: string; request_json: string; state: string; receipt_json: string | null; effect_id: string | null }; const stable = (v: unknown): string => JSON.stringify(v, (_key, x) => x && typeof x === "object" && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => a.localeCompare(b))) : x); @@ -92,6 +92,7 @@ export class EnvironmentLifecycle { const base = { version: 1 as const, operationId: input.operationId, bindingId: id, generation: input.generation, resourceId: binding.resource!.id, effect: input.effect, ...(input.requestedBy ? { requestedBy: input.requestedBy } : {}) }; const result = (state: EffectReceipt["state"], extra: Partial = {}): EffectReceipt => ({ ...base, state, ...extra }); const confirm = (extra: Partial = {}) => { const receipt = result("confirmed", extra); this.save(input.operationId, receipt); return receipt; }; + let stage = "selection"; // Re-check at Core's call boundary AND inside the driver's final identity await. // Only a known pre-dispatch refusal releases the claim; transport uncertainty never does. const dispatch = async (run: (allowed: () => boolean) => Promise): Promise => { @@ -104,7 +105,7 @@ export class EnvironmentLifecycle { return eligible; }; try { if (!allowed()) throw new Error("selection changed"); await run(allowed); } - catch { if (refused) { this.platform.db.prepare("UPDATE environment_operations SET state='intent' WHERE operation_id=? AND state='requested'").run(input.operationId); return false; } } + catch (error) { if (refused) { this.platform.db.prepare("UPDATE environment_operations SET state='intent' WHERE operation_id=? AND state='requested'").run(input.operationId); return false; } if (input.effect === "delete-owned") throw error; } return true; }; try { @@ -115,6 +116,7 @@ export class EnvironmentLifecycle { } if (input.effect === "delete-owned") { if (!this.driver.deleteEnvironment || !this.driver.cleanupState) refuse("environment_effect_unsupported", "This driver cannot verify workload/supervisor/volume cleanup", 501); + stage = "provider-list"; let listed = await this.driver.list(binding.placement); if (!listed.complete) return result("unconfirmed", { detail: "Provider listing is incomplete" }); if (listed.resources.some(row => row.name === binding.resource!.name && row.id !== binding.resource!.id)) refuse("environment_identity_changed", "The resource name was reused; nothing was deleted"); @@ -122,6 +124,7 @@ export class EnvironmentLifecycle { let inventory = this.operation(inventoryId)?.receipt_json; if (this.driver.captureCleanup && !inventory) { if (!input.engineSocket) refuse("environment_engine_required", "Select the local provider's rootless Podman socket before delete", 400); + stage = "cleanup-inventory"; const captured = await this.driver.captureCleanup(binding, input.engineSocket); this.intent(binding, inventoryId, "cleanup-inventory", { resourceId: binding.resource!.id, engineSocket: input.engineSocket }); this.save(inventoryId, captured); inventory = JSON.stringify(captured); @@ -130,11 +133,14 @@ export class EnvironmentLifecycle { if (found) { this.platform.assertResource(binding, found); select(); + stage = "delete-dispatch"; if (!await dispatch(allowed => this.driver.deleteEnvironment!(binding, op.effect_id!, allowed))) return result("pending", { detail: "The selection changed before dispatch; no delete was sent" }); + stage = "provider-absence"; listed = await this.driver.list(binding.placement); } if (!listed.complete || listed.resources.some(row => row.id === binding.resource!.id || row.name === binding.resource!.name)) return result("pending", { detail: "Exact provider absence is not confirmed" }); + stage = "dependent-cleanup"; const remaining = await this.driver.cleanupState(binding, inventory ? JSON.parse(inventory) : undefined); if (Object.values(remaining).some(value => value !== false)) return result("unconfirmed", { remaining, detail: "Provider absence does not establish dependent cleanup" }); return confirm({ remaining }); @@ -159,7 +165,11 @@ export class EnvironmentLifecycle { return !after.running ? confirm() : result("pending", { detail: "Native TERM requested; execution is still present" }); } catch (error) { if (error instanceof EnvironmentError) throw error; - return result("unconfirmed", { detail: "The original effect could not be observed; its intent remains, and it was not retried" }); + if (input.effect !== "delete-owned") return result("unconfirmed", { detail: "The original effect could not be observed; its intent remains, and it was not retried" }); + const dispatched = this.operation(input.operationId)?.state === "intent" ? false : "unknown" as const; + const cause = error as Error & { code?: string }; + return result("unconfirmed", { stage, dispatched, error: { name: cause.name ?? "Error", ...(typeof cause.code === "string" ? { code: cause.code } : {}), message: cause.message ?? String(error) }, + detail: dispatched === false ? "Stopped before effect dispatch; the saved intent remains for reconciliation" : "The original effect could not be observed; its intent remains, and it was not retried" }); } } checkpointRecord(id: string): EnvironmentCheckpoint | null { diff --git a/packages/daemon/src/domain/environment-startup.ts b/packages/daemon/src/domain/environment-startup.ts index ba55dd183..32f389f07 100644 --- a/packages/daemon/src/domain/environment-startup.ts +++ b/packages/daemon/src/domain/environment-startup.ts @@ -2,7 +2,7 @@ import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathS import { join, posix } from "node:path"; import { randomUUID } from "node:crypto"; import type { ProjectionPlan } from "./projection-planner.js"; -import type { NodeBinding, ProjectionResult, ResolvedStartupFile, StartupDeliveryResult, HarnessLaunchResult } from "./runtime-adapter.js"; +import type { NodeBinding, ProjectionResult, ResolvedStartupFile, StartupDeliveryResult, HarnessLaunchResult, ReadinessResult } from "./runtime-adapter.js"; import type { TmuxAdapter } from "../adapters/tmux.js"; import { shellQuote } from "../adapters/shell-quote.js"; import { EnvironmentPlatform, EnvironmentError, regularBytes, sha256, type ProjectionFile, type ProjectionReceipt } from "./environment-platform.js"; @@ -137,7 +137,18 @@ export class EnvironmentStartup { return { delivered: files.length, failed: [] }; } finally { rmSync(stage, { recursive: true }); } } - async launch(binding: NodeBinding, opts: { resumeToken?: string; resumeType?: string; forkSource?: unknown; beforeNativeDispatch?: () => boolean }, tmux: Pick): Promise { + async checkReady(binding: NodeBinding): Promise { + try { + const selected = this.selected(binding); + if (selected.generation !== binding.launchGeneration) throw new Error("The selected guest generation changed"); + const native = new EnvironmentLifecycle(this.platform, undefined, this.stageRoot).native(selected); + const observed = await (this.platform.driver as OpenShellDriver).guest(selected, ["--identity"]); + if (observed.nativeId !== native.nativeId || observed.generation !== native.generation || observed.running !== true) + throw new Error("The selected guest execution is not running"); + return { ready: true }; + } catch (error) { return { ready: false, code: "harness_not_running", reason: `Environment worker is not ready: ${(error as Error).message}` }; } + } + async launch(binding: NodeBinding, opts: { resumeToken?: string; resumeType?: string; forkSource?: unknown; firstInputText?: string; beforeNativeDispatch?: () => boolean }, tmux: Pick): Promise { if (opts.forkSource) return { ok: false, recovery: "attention_required", error: "Environment fork is unsupported; no fresh conversation was started." }; const checkpoint = this.retainedResume(binding, opts); const selected = this.selected(binding), generation = binding.launchGeneration!; @@ -155,7 +166,9 @@ export class EnvironmentStartup { throw new EnvironmentError("environment_native_unconfirmed", "The original native identity is unconfirmed; no grant or execution was replaced"); const receipt = { ...request, nativeType: "community:openshell-claude", destinationCwd: posix.join(selected.guestRoot, "work"), nativeState: posix.join(selected.guestRoot, "native"), checkpoint: checkpoint?.checkpointId ?? null, predecessorResourceId: checkpoint?.predecessorResourceId ?? null }; this.platform.db.prepare("UPDATE environment_operations SET state = 'confirmed', receipt_json = ? WHERE operation_id = ?").run(JSON.stringify(receipt), operationId); - return { ok: true, resumeToken: request.nativeId, resumeType: "community:openshell-claude", resumeProvenance: "launcher", warnings: ["The original native identity was reconciled; viewer readiness still requires its existing observation and no viewer input was replayed."] }; + return { ok: true, resumeToken: request.nativeId, resumeType: "community:openshell-claude", resumeProvenance: "launcher", + ...(request.firstInput ? { firstInput: { path: posix.join(selected.guestRoot, request.firstInput.path), submission: "unconfirmed" as const } } : {}), + warnings: ["The original native identity was reconciled; viewer readiness still requires its existing observation and no viewer input was replayed."] }; } const older = this.platform.db.prepare("SELECT operation_id FROM environment_operations WHERE binding_id = ? AND kind = 'launch' AND operation_id <> ? AND state IN ('requested','confirmed')").get(selected.bindingId, operationId); if (older && !checkpoint) throw new EnvironmentError("environment_prior_execution_unconfirmed", "A prior native execution must be reconciled through lifecycle/restore before another launch"); @@ -198,7 +211,13 @@ export class EnvironmentStartup { this.platform.db.prepare("UPDATE environment_operations SET request_json = ? WHERE operation_id = ? AND state = 'intent'").run(JSON.stringify({ ...request, activityGrantId: activity.grantId }), operationId); await putPrivate("activity.json", JSON.stringify(activity.env)); await putPrivate("coordination.json", coordination.toString("utf8")); - const descriptor = { ...request, files, ...(checkpoint ? { resumeToken: checkpoint.nativeId, nativeType: checkpoint.nativeType, checkpoint: { nativeId: checkpoint.nativeId, nativeType: checkpoint.nativeType, files: [...checkpoint.files, ...(checkpoint.artifacts ?? [])].map(({ path, sha256, bytes }) => ({ path, sha256, bytes })) } } : {}), model: binding.model, effort: binding.effort, permissionMode: binding.permissionMode, + const firstInput = opts.firstInputText === undefined ? undefined : { path: "control/first-input.txt", sha256: sha256(opts.firstInputText), bytes: Buffer.byteLength(opts.firstInputText) }; + if (firstInput) { + await putPrivate("first-input.txt", opts.firstInputText!); + this.platform.db.prepare("UPDATE environment_operations SET request_json = ? WHERE operation_id = ? AND state = 'intent'") + .run(JSON.stringify({ ...request, activityGrantId: activity.grantId, firstInput }), operationId); + } + const descriptor = { ...request, files, ...(firstInput ? { firstInput } : {}), ...(checkpoint ? { resumeToken: checkpoint.nativeId, nativeType: checkpoint.nativeType, checkpoint: { nativeId: checkpoint.nativeId, nativeType: checkpoint.nativeType, files: [...checkpoint.files, ...(checkpoint.artifacts ?? [])].map(({ path, sha256, bytes }) => ({ path, sha256, bytes })) } } : {}), model: binding.model, effort: binding.effort, permissionMode: binding.permissionMode, plugins: files.filter(file => file.path.includes("/.claude/plugins/") && file.path.endsWith("/.claude-plugin/plugin.json")) .map(file => posix.join(selected.guestRoot, file.path.slice(0, -"/.claude-plugin/plugin.json".length))) }; await putPrivate("launch.pending.json", JSON.stringify(descriptor)); @@ -207,9 +226,11 @@ export class EnvironmentStartup { if (!(this.platform.driver instanceof OpenShellDriver)) throw new EnvironmentError("environment_launch_unsupported", "This driver does not implement native launch preparation"); const claimed = this.platform.db.prepare("UPDATE environment_operations SET state = 'requested' WHERE operation_id = ? AND state = 'intent'").run(operationId); if (!claimed.changes) throw new EnvironmentError("environment_native_unconfirmed", "The saved native launch intent needs reconciliation; no second launch was attempted"); - await this.platform.driver.guest(selected, ["--prepare"]); + await this.platform.driver.guest(selected, ["--prepare", ...(firstInput ? ["--first-input-required"] : [])]); const command = ["rig", "environment", "connect", selected.bindingId, "--generation", generation].map(shellQuote).join(" "); - const sent = await tmux.sendShellCommand(binding.tmuxSession!, command, undefined, { stageIfLong: true, execInScript: true }); + // Binding and generation identifiers keep this command below the short-input bound. Replace the host + // shell itself: a viewer failure must close this pane, never leave a shell for later startup input. + const sent = await tmux.sendShellCommand(binding.tmuxSession!, `exec ${command}`, undefined, { stageIfLong: true }); if (!sent.ok) throw new EnvironmentError("environment_viewer_unconfirmed", "The native execution may have started; its viewer dispatch is unconfirmed"); let observed: Record | undefined; const deadline = Date.now() + 30_000; @@ -220,7 +241,8 @@ export class EnvironmentStartup { if (observed?.nativeId !== nativeId || observed.generation !== generation || observed.running !== true) throw new EnvironmentError("environment_native_unconfirmed", "The native process identity is not confirmed; nothing was restarted"); const receipt = { ...request, activityGrantId: activity.grantId, nativeType: "community:openshell-claude", destinationCwd: posix.join(selected.guestRoot, "work"), nativeState: posix.join(selected.guestRoot, "native"), checkpoint: checkpoint?.checkpointId ?? null, predecessorResourceId: checkpoint?.predecessorResourceId ?? null }; this.platform.db.prepare("UPDATE environment_operations SET state = 'confirmed', receipt_json = ? WHERE operation_id = ?").run(JSON.stringify(receipt), operationId); - return { ok: true, resumeToken: nativeId, resumeType: "community:openshell-claude", resumeProvenance: "launcher" }; + return { ok: true, resumeToken: nativeId, resumeType: "community:openshell-claude", resumeProvenance: "launcher", + ...(firstInput ? { firstInput: { path: posix.join(selected.guestRoot, firstInput.path), submission: "unconfirmed" as const } } : {}) }; } finally { rmSync(stage, { recursive: true }); } } } diff --git a/packages/daemon/src/domain/gateway/human-readiness.ts b/packages/daemon/src/domain/gateway/human-readiness.ts index 5391abe8c..5e20fbec4 100644 --- a/packages/daemon/src/domain/gateway/human-readiness.ts +++ b/packages/daemon/src/domain/gateway/human-readiness.ts @@ -11,7 +11,8 @@ export interface HumanDeliveryReadiness { enabled: boolean; active: boolean; ready: boolean; - connector: { kind: string; ref: string }; + /** Absent for a person with no connector binding. */ + connector?: { kind: string; ref: string }; reason: string; nextAction: string | null; checkedAt: string; @@ -36,8 +37,14 @@ export async function resolveHumanDeliveryReadiness( input: HumanDeliveryReadinessInput, deps: HumanDeliveryReadinessDeps = {}, ): Promise { - const primary = input.human.connectorBindings.find((binding) => binding.role === "primary")!; + const primary = input.human.connectorBindings.find((binding) => binding.role === "primary"); const checkedAt = (deps.now?.() ?? new Date()).toISOString(); + // A person with no connector binding is reached in the Feed and Steering only: never ready for Slack, and no connector. + if (!primary) { + const enabled = input.config.enabled; + return { state: "not-ready", configured: false, enabled, active: enabled && input.gatewayState === "active", ready: false, checkedAt, + reason: "this person has no connector binding, so they're reached in the Feed and Steering only, not Slack", nextAction: null }; + } const base = { connector: { kind: primary.kind, ref: primary.connectorRef }, checkedAt, diff --git a/packages/daemon/src/domain/gateway/human-registry.ts b/packages/daemon/src/domain/gateway/human-registry.ts index 5f4a03295..092d63daa 100644 --- a/packages/daemon/src/domain/gateway/human-registry.ts +++ b/packages/daemon/src/domain/gateway/human-registry.ts @@ -181,14 +181,16 @@ export function validateHumanFragment(raw: unknown): ValidateResult { if (address !== `${entityId}@${ADDRESS_DOMAIN}`) { return { ok: false, error: `address "${address}" must be the registered ref "${entityId}@${ADDRESS_DOMAIN}" (the @external convention)` }; } - if (!Array.isArray(connectorBindings) || connectorBindings.length < 1) { - return { ok: false, error: "connectorBindings must be a non-empty list (>=1)" }; + // A person may have no connector binding (left out, or an empty list): they're reached in the Feed and Steering only. + if (connectorBindings !== undefined && !Array.isArray(connectorBindings)) { + return { ok: false, error: "connectorBindings must be a list (empty, or left out, for a person with no connector)" }; } + const listed: unknown[] = Array.isArray(connectorBindings) ? connectorBindings : []; const bindings: HumanConnectorBinding[] = []; let primaryCount = 0; - for (let i = 0; i < connectorBindings.length; i++) { - const b = connectorBindings[i]; + for (let i = 0; i < listed.length; i++) { + const b = listed[i]; if (!isObj(b)) return { ok: false, error: `connectorBindings[${i}] must be a mapping` }; const ubk = unknownKey(b, ALLOWED_BINDING_KEYS); if (ubk) return { ok: false, error: `connectorBindings[${i}] has unknown key "${ubk}" — allowed: ${[...ALLOWED_BINDING_KEYS].join(", ")}` }; @@ -216,8 +218,8 @@ export function validateHumanFragment(raw: unknown): ValidateResult { if (b.handle !== undefined) { handle = b.handle as string; binding.handle = handle; } bindings.push(binding); } - // EXACTLY ONE primary binding per entity = the default delivery channel. - if (primaryCount !== 1) { + // EXACTLY ONE primary binding per entity with any binding = the default delivery channel. + if (bindings.length > 0 && primaryCount !== 1) { return { ok: false, error: `exactly one connectorBinding must have role "primary" (found ${primaryCount}) — it is the default delivery channel` }; } // A6 v3 pin-1 (within-fragment): a handle is UNIQUE per kind across this human's bindings — @@ -489,7 +491,8 @@ export interface InflightItem { export interface HumanBindingsSummary { count: number; - primary: { kind: string; connectorRef: string }; + /** Null for a person with no connector binding: they're reached in the Feed and Steering only. */ + primary: { kind: string; connectorRef: string } | null; /** true iff any binding carries a handle (outbound-only otherwise). */ inboundResolvable: boolean; } @@ -592,7 +595,7 @@ export function listHumans(home: string = getOpenRigHome()): ListHumansResult { const proj = projectHumans(home); if (!proj.ok) return { ok: false, error: proj.error }; const humans: HumanSummary[] = proj.entities.map((e) => { - const primary = e.connectorBindings.find((b) => b.role === "primary")!; + const primary = e.connectorBindings.find((b) => b.role === "primary"); return { entityId: e.entityId, displayName: e.displayName, @@ -602,7 +605,7 @@ export function listHumans(home: string = getOpenRigHome()): ListHumansResult { availability: e.prefs.availability ?? (e.prefs.away === true ? "away" : "available"), bindings: { count: e.connectorBindings.length, - primary: { kind: primary.kind, connectorRef: primary.connectorRef }, + primary: primary ? { kind: primary.kind, connectorRef: primary.connectorRef } : null, inboundResolvable: e.connectorBindings.some((b) => b.handle !== undefined), }, fragmentPath: fragmentPathFor(e.entityId, home), diff --git a/packages/daemon/src/domain/gateway/slack/inbound.ts b/packages/daemon/src/domain/gateway/slack/inbound.ts index 09c1c1eed..4404fd99a 100644 --- a/packages/daemon/src/domain/gateway/slack/inbound.ts +++ b/packages/daemon/src/domain/gateway/slack/inbound.ts @@ -375,7 +375,10 @@ export class InboundRouter { const resumedPark = correlated?.state === "in-progress"; const askOpen = correlated?.state === "pending" || parkOpen; const closed = correlated?.state === "done" || correlated?.state === "handed-off"; - const answerable = !ev.recoveredThreadReply && !asChannel && route.correlationQitemId !== undefined; + // A reply routed to another person (a root recorded with a person as its seat) isn't an answer to the asker: it stays + // words for whoever it reached. + const settles = route.correlationQitemId === undefined || this.deps.queue.routesToAsker?.(route.correlationQitemId, route.destination) !== false; + const answerable = !ev.recoveredThreadReply && !asChannel && route.correlationQitemId !== undefined && settles; // An absorbed event has its own identity even when the first answer came from a button or Steering. const consumedKey = (answerId: string) => `answer-event:${JSON.stringify([eventId, route.correlationQitemId, who.source, answerId])}`; // A closed ask can hold several returned answers (one per park). A replay finds the one this event returned, or the @@ -416,14 +419,14 @@ export class InboundRouter { summary, body, ...(route.correlationQitemId ? { inReplyTo: route.correlationQitemId } : {}), - ...(askOpen && !ev.recoveredThreadReply && !asChannel ? { humanAnswer: true, typedReply: true } : {}), + ...(askOpen && answerable ? { humanAnswer: true } : {}), }); } catch (e) { this.deps.log?.(`qitem create failed ts=${ts}: ${(e as Error).message}`); return { landed: false, reason: "create_failed" }; } let replyResolution: "resolved" | "resolved-uncertain" | "already-resolved" | "not-applicable" | undefined; - if (!closed && route.correlationQitemId && this.deps.resolveHumanReply && !ev.recoveredThreadReply && !asChannel) { + if (!closed && route.correlationQitemId && this.deps.resolveHumanReply && answerable) { try { // Reusing the durable answer consumed this event's words. Record that before close, but // keep the raw event unmarked until completion so an interrupted close can still retry. @@ -560,7 +563,10 @@ export class InboundRouter { if (live) await acknowledge(CLICK_REPLIES.unmapped); return { status: "ignored", reason: "unmapped-message" }; } - const existing = this.deps.queue.getHumanAnswer?.(qitemId, who.source); + // A decision parked on this person takes the current question's answer, as a typed reply does: a re-park gets its own. + const parkOpen = this.deps.queue.getQitem?.(qitemId)?.state === "blocked" + && this.deps.queue.answersPark?.(qitemId, who.source, action?.action_ts) === true; + const existing = parkOpen ? this.deps.queue.getEpisodeAnswer?.(qitemId, who.source) : this.deps.queue.getHumanAnswer?.(qitemId, who.source); if (existing) { try { const resolution = await this.deps.resolveHumanReply?.({ qitemId, actorSession: who.source, decision: humanAnswerText(existing.body), messageTs: action?.action_ts }); @@ -579,10 +585,16 @@ export class InboundRouter { const recorded = this.deps.recordHumanAnswer?.({ qitemId, actorSession: who.source, ...picked, clickedTs: action?.action_ts }); if (!recorded || recorded.status !== "recorded") { const reason = recorded?.reason ?? "answers-unavailable"; + // Another person's committed answer is the answer: a park on the person resumes with it through the resolver. + if (recorded?.answeredBy) { + try { await this.deps.resolveHumanReply?.({ qitemId, actorSession: who.source, decision: "", messageTs: action?.action_ts }); } + catch (e) { this.deps.log?.(`answer continuation failed qitem=${qitemId}: ${(e as Error).message}`); } + } // A closed decision holding this person's clicked answer: typically Slack's redelivery of the click that closed // it, after that click's own confirmation failed to post. It was recorded, so say that. if (live) { await acknowledge(recorded?.answerOnRecord ? CLICK_REPLIES.onRecord + : recorded?.answeredBy ? escapeSlackText(`${recorded.answeredBy} already answered this, so your choice wasn't recorded.`) : recorded?.decidedForThem ? escapeSlackText(redactSecrets(decidedForThemReply(recorded.decidedForThem))) : clickNotRecordedReply(reason)); } return { status: "ignored", reason }; diff --git a/packages/daemon/src/domain/gateway/slack/queue-access.ts b/packages/daemon/src/domain/gateway/slack/queue-access.ts index abb967fba..0705b9b46 100644 --- a/packages/daemon/src/domain/gateway/slack/queue-access.ts +++ b/packages/daemon/src/domain/gateway/slack/queue-access.ts @@ -66,8 +66,6 @@ export interface CreateQitemInput { inReplyTo?: string; /** A current typed/button answer, never a reaction or a recovered late message. */ humanAnswer?: boolean; - /** The answer is a typed thread reply: on a decision parked on its person, its row belongs to the current question. */ - typedReply?: boolean; } /** What the inbound router needs: land a durable qitem, get its id (or throw). */ @@ -81,6 +79,9 @@ export interface InboundQueuePort { getEpisodeAnswer?(qitemId: string, actorSession: string): { qitemId: string; body: string } | null; /** Every answer this person returned to the ask, oldest first, so a replay after close can find its own. */ listHumanAnswers?(qitemId: string, actorSession: string): Array<{ qitemId: string; body: string }>; + /** Whether a reply routed to `destination` can answer the row: routed to a person, only when that person is the row's + * asking seat (the sender of an ask to a person). A root recorded with a person as its seat routes to someone else. */ + routesToAsker?(qitemId: string, destination: string): boolean; } /** What the outbound driver needs: the current human-alert set, full items included. */ @@ -224,11 +225,13 @@ export function makeQueuePorts( const people = registeredPeople((opts.loadHumanRegistry ?? (() => loadHumanRegistry()))()); if (!people) return null; const channels = (opts.sharedChannels ?? (() => sharedChannelAddresses()))(); + // A person with no connector binding is reached in the Feed and Steering only, so nothing posts for them. + const slackPeople = people.filter((person) => person.connectorBindings.length > 0); const episodes = queueRepo.transitionLog.activeOwnerNotificationEpisodes(includePosted); return episodes.flatMap(({ qitemId, transition, posted }) => { const row = queueRepo.getById(qitemId); if (!row) return []; - const item = project(row, transition, people, channels); + const item = project(row, transition, slackPeople, channels); return item ? [{ item, posted }] : []; }); }; @@ -241,10 +244,15 @@ export function makeQueuePorts( }, getEpisodeAnswer: (qitemId, actorSession) => queueRepo.currentEpisodeAnswer(qitemId, actorSession), listHumanAnswers: (qitemId, actorSession) => queueRepo.listHumanAnswers(qitemId, actorSession), + routesToAsker: (qitemId, destination) => { + const item = queueRepo.getById(qitemId); + return !queueRepo.isPersonAddress(destination) || (item !== null && destination === item.sourceSession); + }, async createQitem(input: CreateQitemInput): Promise { const ask = input.inReplyTo ? queueRepo.getById(input.inReplyTo) : null; - const directAnswer = input.humanAnswer && ask && ask.destinationSession === input.source && ask.humanIntent !== "update" - && queueRepo.isPersonAddress(input.source); + // The asked person's answer, or another registered person's: either takes the ask's one shared answer identity. + const directAnswer = input.humanAnswer && ask && ask.humanIntent !== "update" && queueRepo.isPersonAddress(ask.destinationSession) + && queueRepo.answersAsk(ask, input.source) && queueRepo.isPersonAddress(input.source); const createInput = { qitemId: input.qitemId, sourceSession: input.source, @@ -256,11 +264,15 @@ export function makeQueuePorts( inboundReplyTo: input.inReplyTo ?? null, gatewayInbound: true, }; - const created = directAnswer && input.inReplyTo + let created = directAnswer && input.inReplyTo ? await queueRepo.createHumanAnswer({ ...createInput, inboundReplyTo: input.inReplyTo, - // A parked decision's answer belongs to its current question, so a re-park gets its own. - ...(input.typedReply && ask?.state === "blocked" ? { episodeKey: queueRepo.currentHumanRequiredEpisode(input.inReplyTo)?.key } : {}) }) + // A parked decision's answer, typed or clicked, belongs to its current question, so a re-park gets its own. + ...(ask?.state === "blocked" ? { episodeKey: queueRepo.currentHumanRequiredEpisode(input.inReplyTo)?.key } : {}) }) : await queueRepo.create(createInput); + // Another registered person's answer already settled the ask: these words still reach the agent, as correspondence. + if (directAnswer && created.qitemId !== input.inReplyTo && created.sourceSession !== input.source) { + created = await queueRepo.create({ ...createInput, tags: createInput.tags.filter((tag) => tag !== "human-answer") }); + } return (created as unknown as { qitemId: string }).qitemId; }, async listHumanAlerts(filter: AlertFilterOpts): Promise { diff --git a/packages/daemon/src/domain/gateway/slack/slack-subsystem.ts b/packages/daemon/src/domain/gateway/slack/slack-subsystem.ts index d927d0c78..a51f08b9c 100644 --- a/packages/daemon/src/domain/gateway/slack/slack-subsystem.ts +++ b/packages/daemon/src/domain/gateway/slack/slack-subsystem.ts @@ -41,6 +41,7 @@ import { startSocketInbound, type SocketInboundHandle, type WsLike } from "./soc import { HUMAN_ALERT_TAG, loadHumanRegistry, resolveSlackHandle } from "../human-registry.js"; import { sharedChannelAddresses, sharedChannelFor } from "../participants.js"; import { hasLiveHumanGate, isBlockerLive, type QueueRepository } from "../../queue-repository.js"; +import { humanAnswerText } from "../../human-questions.js"; import { formatReplyToChoice, formatReplyToThreadMissing, REPLY_TO_CHOICE_ACTOR, REPLY_TO_CHAIN_LIMIT, type ReplyToChoice } from "../../reply-to-choice.js"; import { parseSessionName } from "../../session-name.js"; import type { FetchImpl } from "./slack-api.js"; @@ -99,25 +100,29 @@ export function makeHumanReplyResolver( const source = queueRepo.getById(input.qitemId); if (source?.humanIntent === "update") return "not-applicable"; if (!contract) return "not-applicable"; - // A different person's reply to a question parked on someone else already reached the agent as their own message - // (the inbound row, linked to the question). It doesn't resume the question, which stays parked on its person. - if (source?.state === "blocked" && queueRepo.askedSomeoneElse(input.actorSession, source.blockedOn)) return "not-applicable"; + // Any registered person's reply resumes a question parked on another: one trusted workspace, recorded as them on that + // person's ask (the write contract names both). const uncertainDecision = `${input.decision}\n\n---\nOpenRig timing context (Slack reply ${JSON.stringify([input.actorSession, input.messageTs ?? null])}): This reply resumed the question, but its ordering against the posted question could not be confirmed. Resuming is not approval; read the person's words in context.`; // An acknowledgement retry must not resolve again. Match this reply's durable // context, not an older episode's generic resolved marker. if (slackMicros(input.messageTs) !== null && queueRepo.transitionLog.listForQitem(input.qitemId) - .some((transition) => transition.ownerNotificationKind === "human-decision-resolved" && transition.transitionNote === uncertainDecision)) { + // A different person's resume carries their attribution after the timing context, so match that too. + .some((transition) => transition.ownerNotificationKind === "human-decision-resolved" + && (transition.transitionNote === uncertainDecision || transition.transitionNote?.startsWith(`${uncertainDecision}; by `) === true))) { return "resolved-uncertain"; } let uncertain = false; let parkedOnAsked = false; + let episodeKey: string | undefined; + let digestReceipt = false; // A park on a person by any address (a registered person's own address, as their Slack rows carry it, or a // legacy human seat) answers only from a reply written after its current question posted. if (source?.state === "blocked" && source.blockedOn && queueRepo.isPersonAddress(source.blockedOn)) { - parkedOnAsked = source.destinationSession === input.actorSession; + parkedOnAsked = queueRepo.isPersonAddress(source.destinationSession) && queueRepo.answersAsk(source, input.actorSession); const episode = queueRepo.currentHumanRequiredEpisode(input.qitemId); if (!episode) return "not-applicable"; const { key, receipt } = episode; + episodeKey = key; // A final receipt attests delivery even when reconciliation lost its timestamp. // Without one, ONLY a unique exact current-episode primary proves anything. const primary = !receipt && home ? pendingPrimaryReceipt(home, input.qitemId, key) : null; @@ -126,9 +131,22 @@ export function makeHumanReplyResolver( const replied = slackMicros(input.messageTs); if (posted !== null && replied !== null && replied <= posted) return "not-applicable"; uncertain = Boolean(receipt?.digest) || posted === null || replied === null; + digestReceipt = Boolean(receipt?.digest); } try { + // One answer per question: another person's committed return for this question is the answer. Resume with it, as + // theirs; this reply stays the conversation it already became. + const winner = episodeKey ? queueRepo.committedAnswerFromAnother(input.qitemId, input.actorSession, episodeKey) : null; + if (winner) { + // Its typed words are the question's answer, stored as its own resume would: it was taken as the answer only after + // the question posted, so only a digest receipt leaves its order uncertain. + const words = humanAnswerText(winner.body); + const stores = queueRepo.isPersonAddress(source!.destinationSession) && queueRepo.answersAsk(source!, winner.sourceSession) && !digestReceipt; + await contract.act({ verb: "resolve", qitemId: input.qitemId, actorSession: winner.sourceSession, decision: words, + ...(stores ? { answerText: words } : {}) }); + return "not-applicable"; + } // A question parked on the person it asked takes their typed reply as its answer, stored in the resume's own // transaction. A click's return stores nothing here, and a reply whose order is uncertain stays words in context. await contract.act({ verb: "resolve", ...input, decision: uncertain ? uncertainDecision : input.decision, diff --git a/packages/daemon/src/domain/human-questions.ts b/packages/daemon/src/domain/human-questions.ts index 74fc8b2f3..244d2ec53 100644 --- a/packages/daemon/src/domain/human-questions.ts +++ b/packages/daemon/src/domain/human-questions.ts @@ -31,9 +31,10 @@ export type HumanAnswers = Record; export type RecordHumanAnswerResult = | { status: "recorded"; answers: HumanAnswers; complete: boolean; questions: HumanQuestion[] } /** answerOnRecord: the decision is no longer pending, and this asked human's clicked answer is the one it holds. - * decidedForThem: it was decided on this asked human's behalf, inside their mode's decision room. */ + * decidedForThem: it was decided on this asked human's behalf, inside their mode's decision room. + * answeredBy: another person's committed answer is the ask's answer; nothing of this person's was recorded. */ | { status: "not-applicable"; reason: string; answerOnRecord?: true; - decidedForThem?: { decidedBy: string; modeName: string; chose: string; reversible: boolean } }; + decidedForThem?: { decidedBy: string; modeName: string; chose: string; reversible: boolean }; answeredBy?: string }; export const MAX_HUMAN_QUESTIONS = 4; export const MIN_OPTIONS = 2; diff --git a/packages/daemon/src/domain/kernel-hook-review.ts b/packages/daemon/src/domain/kernel-hook-review.ts index 4ebf7bdb0..90a923eee 100644 --- a/packages/daemon/src/domain/kernel-hook-review.ts +++ b/packages/daemon/src/domain/kernel-hook-review.ts @@ -2,6 +2,7 @@ import type Database from "better-sqlite3"; import type { TmuxAdapter } from "../adapters/tmux.js"; import { resolveCurrentSessionAddress } from "./session-registry.js"; import { assessNativeResumeProbe } from "./native-resume-probe.js"; +import { verifyCodexPaneProcess, type NativeProcessLister } from "./native-process-lineage.js"; export interface HookReviewObservation { destination: string; @@ -14,7 +15,8 @@ export interface HookReviewObservation { * startup error, generic attention flag or scrollback can start an alert. */ export function makeKernelHookReviewReader(deps: { db: Database.Database; - tmux: Pick; + tmux: Pick; + listProcesses?: NativeProcessLister; now?: () => Date; }): () => Promise { return async () => { @@ -39,17 +41,24 @@ export function makeKernelHookReviewReader(deps: { if (before.some(p => p.id === row.pane)) { const process = (await deps.tmux.readAllPaneProcesses())?.get(row.pane); pid = process?.pid ?? null; - if (!pid || process?.dead !== false || process.command !== "codex") throw new Error("native pane is not positively live Codex"); + if (!pid || process?.dead !== false) throw new Error("native pane is not positively live Codex"); + const proofInput = { target: row.pane, tmux: deps.tmux, listProcesses: deps.listProcesses }; + const native = process.command === "codex" ? null : await verifyCodexPaneProcess(proofInput); + if (process.command !== "codex" && native?.panePid !== pid) throw new Error("native pane is not positively live Codex"); const screen = await deps.tmux.capturePaneScreen(row.pane); + // A stable launcher PID alone cannot exclude a replaced Codex child during capture. + const currentNative = native ? await verifyCodexPaneProcess(proofInput) : null; const after = await deps.tmux.listPanes(row.destination, true); const current = deps.db.prepare("SELECT id, tmux_session, tmux_pane FROM bindings WHERE node_id = ?").get(row.nodeId) as { id: string; tmux_session: string; tmux_pane: string } | undefined; const session = resolveCurrentSessionAddress(deps.db, row.destination); const currentProcess = (await deps.tmux.readAllPaneProcesses())?.get(row.pane); - if (currentProcess?.pid === pid && currentProcess.dead === false && currentProcess.command === "codex" && after.some(p => p.id === row.pane) + if (currentProcess?.pid === pid && currentProcess.dead === false && currentProcess.command === process.command + && (!native || (currentNative?.panePid === pid && currentNative.fingerprint === native.fingerprint)) && after.some(p => p.id === row.pane) && current?.id === row.bindingId && current.tmux_session === row.destination && current.tmux_pane === row.pane && session?.sessionId === row.sessionId && session.nodeId === row.nodeId && screen !== null) { - const probe = assessNativeResumeProbe({ runtime: "codex", paneCommand: currentProcess.command, paneContent: screen }); + const probe = assessNativeResumeProbe({ runtime: "codex", paneCommand: currentProcess.command, paneContent: screen, + codexProcessVerified: native !== null }); state = probe.code === "hook_trust_gate" ? "blocked" : probe.status === "resumed" ? "clear" : "unknown"; } } diff --git a/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts b/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts index d5c57733c..37f9eb8cc 100644 --- a/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts +++ b/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts @@ -41,7 +41,7 @@ import type Database from "better-sqlite3"; import type { EventBus } from "../event-bus.js"; -import { isTypedGateBlocker, type QueueRepository, type QueueItem } from "../queue-repository.js"; +import { isTypedGateBlocker, UNATTRIBUTED_ACTOR, type QueueRepository, type QueueItem } from "../queue-repository.js"; import type { PersistedEvent } from "../types.js"; import { MissionControlActionLog, @@ -50,12 +50,11 @@ import { } from "./mission-control-action-log.js"; import { isHumanSeatSessionRef } from "../session-name.js"; import { humanAnswerText, formatHumanAnswers, unansweredQuestions, type HumanAnswers } from "../human-questions.js"; -import { resolveDefaultPerson } from "../gateway/participants.js"; import { runnerPermissionOrigin } from "../runner-permissions.js"; -/** The primary user when several people are registered and none is the default, or when no one is registered and the - * claimed actor is a shared channel: the generic human seat the web UI also records. */ -export const UNNAMED_PRIMARY_USER = "human@host"; +/** The terminal's act, which names no responder: the generic human seat the web UI also records, read as explicitly + * unattributed, never a person. */ +export const UNNAMED_PRIMARY_USER = UNATTRIBUTED_ACTOR; /** Verbs that settle what a person was asked. */ const SETTLING_VERBS = new Set(["approve", "deny", "resolve", "answer"]); @@ -96,9 +95,10 @@ export interface MissionControlActionInput { optionId?: string; /** Operator-supplied audit context. */ auditNotes?: Record; - /** `primary-user`: the person at the terminal acts (the TUI), whoever was asked. The actor recorded is the primary user - * (the default person), never the claimed actor, and the audit notes say whose ask it was. Only a request with no - * transport identity carries it. */ + /** `primary-user`: the person at the terminal acts (the TUI), whoever was asked. The terminal's claimed actor is the + * Feed's recipient-derived value, not who typed, so the act is recorded as human@host, explicitly unattributed, never + * the default person or the claim; the audit notes say whose ask it was. Only a request with no transport identity + * carries it. A surface that knows its person sends them without it. */ authority?: "primary-user"; /** * For handoff: opt-in best-effort wake. Default true (PL-004 Phase A R1 @@ -432,8 +432,8 @@ export class MissionControlWriteContract { { qitemId: input.qitemId, state: source.state, blockedOn: source.blockedOn }, ); } - // Only a person was asked; a typed gate has no asked person. asActor refuses a registered person settling another - // person's park (answer_not_recorded), except the primary user at the terminal. + // Only a person was asked; a typed gate has no asked person. Another registered person settles it as themselves, and + // the record names the person asked. const acting = this.asActor(input, humanPark ? source.blockedOn : null); input = acting.input; @@ -538,9 +538,9 @@ export class MissionControlWriteContract { throw new MissionControlWriteContractError("qitem_not_found", `qitem ${input.qitemId} not found`, { qitemId: input.qitemId }); } const primaryUser = input.authority === "primary-user"; - // A runner's permission request takes any registered person's answer, so it isn't "someone else's" to settle. + // Any registered person answers any ask, recorded as themselves; a runner's request keeps its own notes. input = this.asActor(input, runnerPermissionOrigin(ask) && this.queueRepo.answersAsk(ask, input.actorSession) ? null : ask.destinationSession).input; - // The asked person answers, or the primary user does on anyone's ask (and any registered person on a runner's request). + // The asked person or another registered person answers, or the person at the terminal does on anyone's ask. if ((!this.queueRepo.answersAsk(ask, input.actorSession) && !primaryUser) || ask.humanIntent === "update" || !this.queueRepo.isPersonAddress(ask.destinationSession)) { throw new MissionControlWriteContractError("answer_not_recorded", `qitem ${input.qitemId} isn't an ask to ${input.actorSession}, so ${input.actorSession} can't answer it.`, { qitemId: input.qitemId }); @@ -557,6 +557,10 @@ export class MissionControlWriteContract { return this.recordAnswer(input, before, decision, { outcome: "answered", unanswered: [], message: `Already answered; the original answer went to ${existing.destinationSession} as ${existing.qitemId}. No new reply was sent.` }, existing.qitemId); } + // One answer per ask: another person's committed return is the answer. Before anything of this person's is recorded, + // finish settling the ask with it and say who answered; nothing of theirs is recorded or sent. + const other = this.queueRepo.committedAnswerFromAnother(input.qitemId, input.actorSession); + if (other) this.answeredByAnother(input, other, primaryUser); if (ask.state !== "pending") { throw new MissionControlWriteContractError("qitem_already_terminal", `qitem ${input.qitemId} is ${ask.state}, not waiting on an answer (it may already be answered), so nothing was recorded or sent.`, @@ -594,12 +598,16 @@ export class MissionControlWriteContract { } // The asker reads who answered, and whose ask it was: never the asked person when someone else answered. const forAsked = ask.destinationSession !== input.actorSession ? ask.destinationSession : null; + const unattributed = primaryUser && input.actorSession === UNNAMED_PRIMARY_USER; + const answeredBy = !forAsked ? `Answered by ${input.actorSession}` + : unattributed ? `Answered at the terminal by someone OpenRig can't name (${UNNAMED_PRIMARY_USER}), unattributed, on ${forAsked}'s ask` + : `Answered by ${input.actorSession}, ${primaryUser ? "the primary user at the terminal" : "a registered person"}, on ${forAsked}'s ask`; let returned: QueueItem; try { returned = await this.queueRepo.createHumanAnswer({ sourceSession: input.actorSession, destinationSession: ask.sourceSession, - body: `${reply}\nAnswered by ${input.actorSession}${forAsked ? `, the primary user at the terminal, on ${forAsked}'s ask` : ""} in OpenRig.`, - summary: `${input.actorSession} answered${forAsked ? ` ${forAsked}'s ask` : ""}: ${ask.summary ?? input.qitemId}`, + body: `${reply}\n${answeredBy} in OpenRig.`, + summary: `${forAsked && unattributed ? `Unattributed (${UNNAMED_PRIMARY_USER})` : input.actorSession} answered${forAsked ? ` ${forAsked}'s ask` : ""}: ${ask.summary ?? input.qitemId}`, tags: ["human-answer", ...(ask.tags ?? []).filter((t) => /^(project|mission|slice):/.test(t))], inboundReplyTo: input.qitemId, ...(input.notify === false ? { nudge: false } : {}), }); @@ -610,6 +618,8 @@ export class MissionControlWriteContract { { qitemId: input.qitemId }); } const replyId = returned.qitemId; + // Two people answering at once: one answer returns, and the other is told whose it was. + if (replyId !== input.qitemId && returned.sourceSession !== input.actorSession) this.answeredByAnother(input, returned, primaryUser); // A runner's permission ask is its own answer record (no reply row comes back), so typed words are the person's own. const decision = chose || resume ? formatHumanAnswers(questions, this.queueRepo.getById(input.qitemId)?.humanAnswers ?? {}).join("; ") @@ -623,6 +633,16 @@ export class MissionControlWriteContract { return this.recordAnswer(input, before, decision, { outcome: "answered", unanswered: [] }, replyId); } + /** Another person's committed return already answers the ask: settle the ask with it if it's still pending (the + * repository closes it as theirs), then tell this person who answered and where it went. */ + private answeredByAnother(input: MissionControlActionInput, other: QueueItem, primaryUser: boolean): never { + const ask = this.queueRepo.getById(input.qitemId); + if (ask?.state === "pending") this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision: "", primaryUser }); + throw new MissionControlWriteContractError("qitem_already_terminal", + `${other.sourceSession} already answered qitem ${input.qitemId}; their answer went to ${other.destinationSession} as ${other.qitemId}. ${input.actorSession}'s answer wasn't recorded or sent.`, + { qitemId: input.qitemId, answeredBy: other.sourceSession, replyQitemId: other.qitemId }); + } + /** The audit row and event for one answer (its queue writes are the repository's own). */ private recordAnswer(input: MissionControlActionInput, before: Record, reason: string, answer: NonNullable, createdQitemId: string | null = null): MissionControlActionResult { @@ -643,11 +663,11 @@ export class MissionControlWriteContract { } /** - * Who acts, and on whose ask. With primary-user authority the actor is the primary user: the default person, or, with - * several people and none chosen, the generic human seat. With no one registered it's the claimed actor, as before, - * unless that's a shared channel: then it's the generic human seat. The audit notes record the authority and the person - * asked, and the transition says so when they differ. Without it, a registered person can't settle what was asked of - * another registered person: that's someone else's answer. A shared channel never acts on a row, for any verb: its + * Who acts, and on whose ask. One trusted workspace, equal authority: any registered person settles an ask that went to + * another, recorded as themselves, and the transition and audit notes name the person asked. With primary-user + * authority the terminal names no responder (its claimed actor is the Feed's recipient-derived value), so the act is + * human@host, explicitly unattributed, never the default person or the claim, whether or not anyone is registered. A + * shared channel never acts on a row, for any verb: its * messages are correspondence, so the asking seat or the person at the terminal acts on a channel's row. */ private asActor(input: MissionControlActionInput, asked: string | null): { input: MissionControlActionInput; note: string } { @@ -659,19 +679,16 @@ export class MissionControlWriteContract { { qitemId: input.qitemId, channel: input.actorSession }); } const someoneElse = SETTLING_VERBS.has(input.verb) ? this.queueRepo.askedSomeoneElse(input.actorSession, asked) : null; - if (someoneElse) { - throw new MissionControlWriteContractError("answer_not_recorded", - `qitem ${input.qitemId} was asked of ${someoneElse}, not ${input.actorSession}, so ${input.actorSession} can't settle it.`, - { qitemId: input.qitemId, asked: someoneElse }); - } - return { input, note: "" }; + if (!someoneElse) return { input, note: "" }; + return { input: { ...input, auditNotes: { ...input.auditNotes, asked: someoneElse } }, + note: `; by ${input.actorSession}, a registered person, on ${someoneElse}'s ask` }; } - const registry = this.queueRepo.humanRegistry(); - const people = registry.ok ? registry.entities : []; - const actor = people.length ? resolveDefaultPerson(people).person?.address ?? UNNAMED_PRIMARY_USER - : channel ? UNNAMED_PRIMARY_USER : input.actorSession; + // The terminal names no responder, whatever it claims and whoever is registered: the act is unattributed. + const actor = UNNAMED_PRIMARY_USER; const auditNotes = { ...input.auditNotes, authority: "primary-user", asked }; - const note = asked && asked !== actor ? `; by ${actor}, the primary user at the terminal, on ${asked}'s ask` : ""; + const note = !asked || asked === actor ? "" + : actor === UNNAMED_PRIMARY_USER ? `; by someone at the terminal OpenRig can't name (${UNNAMED_PRIMARY_USER}), unattributed, on ${asked}'s ask` + : `; by ${actor}, the primary user at the terminal, on ${asked}'s ask`; return { input: { ...input, actorSession: actor, auditNotes }, note }; } diff --git a/packages/daemon/src/domain/queue-repository.ts b/packages/daemon/src/domain/queue-repository.ts index 86dfdf981..6fa5a1160 100644 --- a/packages/daemon/src/domain/queue-repository.ts +++ b/packages/daemon/src/domain/queue-repository.ts @@ -39,7 +39,7 @@ import { } from "./queue-wake-repository.js"; import { WatchdogJobsRepository } from "./watchdog-jobs-repository.js"; import { armQueueWait, backOffQueueWait, refreshQueueWaits, evaluateQueueWait, retargetQueueWait, isQueueWait } from "./queue-wait-backoff.js"; -import { parseHumanQuestions, unansweredQuestions, describeTypedReplyPlacement, type HumanQuestion, type HumanAnswers, type TypedHumanReply, type RecordHumanAnswerResult } from "./human-questions.js"; +import { parseHumanQuestions, unansweredQuestions, describeTypedReplyPlacement, formatHumanAnswers, humanAnswerText, type HumanQuestion, type HumanAnswers, type TypedHumanReply, type RecordHumanAnswerResult } from "./human-questions.js"; import { slackMicros } from "./gateway/slack/state-store.js"; /** A direct ask's answer row ID: from the ask, or from the ask and the park episode its answer belongs to. */ @@ -117,6 +117,9 @@ const FALLBACK_ACTOR = "system:queue-fallback"; * external condition that is NOT a qitem and NOT a human seat; these prefixes make such a gate a * first-class, compact-visible, downstream-classifiable blocker (the ruling detail rides a transition). */ export const TYPED_GATE_BLOCKER_PREFIXES = ["fold:", "auth:", "external:"] as const; + +/** The actor a terminal act is recorded as when it names no responder: explicitly unattributed, never a person. */ +export const UNATTRIBUTED_ACTOR = "human@host"; export function typedGateBlockerPrefix(value: string): string | null { return TYPED_GATE_BLOCKER_PREFIXES.find((p) => value.startsWith(p)) ?? null; } @@ -1850,6 +1853,9 @@ export class QueueRepository { const qitemId = humanAnswerId(input.inboundReplyTo, episodeKey); const existing = episodeKey ? this.getById(qitemId) : this.getHumanAnswer(input.inboundReplyTo, input.sourceSession); if (existing) return existing; + // One reply per ask: another person's answer already returned is the answer, so it comes back, and the caller says so. + const answered = this.getById(qitemId); + if (answered) return answered; return this.create({ ...create, qitemId, tags: [...new Set([...(input.tags ?? []), "human-answer"])] }); } @@ -2171,11 +2177,11 @@ export class QueueRepository { return registry.ok && resolveRegisteredHumanAddress(destination, registry.entities) !== null; } - /** Who may answer an ask: the asked person; and on a runner's permission request, any registered person, since a - * person's yes counts however it arrives there. A shared channel never answers. */ + /** Who may answer an ask: the asked person, and any other registered person in this workspace, who's recorded as + * themselves with the asked person kept (one trusted workspace, equal authority). A shared channel never answers. */ answersAsk(item: QueueItem, actorSession: string): boolean { if (item.destinationSession === actorSession) return true; - if (!runnerPermissionOrigin(item) || this.isSharedChannel(actorSession)) return false; + if (this.isSharedChannel(actorSession)) return false; const registry = this.loadHumanRegistryFn(); return registry.ok && resolveRegisteredHumanAddress(actorSession, registry.entities) !== null; } @@ -2190,8 +2196,8 @@ export class QueueRepository { return this.loadHumanRegistryFn(); } - /** The asked person, when a different registered person is acting on what was asked of them; else null. An agent, the - * asked person under another spelling, and an ask to no registered person aren't someone else. */ + /** The asked person, when a different registered person is acting on what was asked of them, so the record names both; + * else null. An agent, the asked person under another spelling, and an ask to no registered person aren't someone else. */ askedSomeoneElse(actorSession: string, askedSession: string | null | undefined): string | null { const registry = this.loadHumanRegistryFn(); if (!registry.ok) return null; @@ -3796,29 +3802,56 @@ export class QueueRepository { * button set is already final; its resolve continuation must not overwrite those answers. */ resolveDirectHumanReply(input: { qitemId: string; actorSession: string; decision: string; primaryUser?: boolean }): boolean { + let mine = true; const result = this.db.transaction(() => { const item = this.getById(input.qitemId); if (item?.state !== "pending" || item.humanIntent === "update" || (!this.answersAsk(item, input.actorSession) && !input.primaryUser) || !this.isPersonAddress(item.destinationSession)) return null; - - let transitionNote = item.destinationSession === input.actorSession ? "direct human reply received" - : input.primaryUser ? `reply from ${input.actorSession}, the primary user at the terminal, on ${item.destinationSession}'s ask; not answered by ${item.destinationSession}` - : `reply from ${input.actorSession}, a registered person, on the runner permission request asked of ${item.destinationSession}`; - const placed = this.placeTypedReply(item, input.decision); - if (placed) transitionNote += `; ${placed}`; - return this.updateInTransactionalContext({ - qitemId: input.qitemId, - actorSession: input.actorSession, - state: "done", - closureReason: "no-follow-on", - transitionNote, - ownerNotificationKind: "human-decision-resolved", - }); + // One answer per ask: another person's committed return is the answer, so the ask closes as theirs, with their words, + // and this caller's don't settle it (false tells the caller so). + const winner = this.committedAnswerFromAnother(item.qitemId, input.actorSession); + if (winner) { + mine = false; + const questions = item.humanQuestions ?? []; + const decision = questions.length && unansweredQuestions(questions, item.humanAnswers ?? {}).length === 0 + ? formatHumanAnswers(questions, item.humanAnswers ?? {}).join("; ") : humanAnswerText(winner.body); + return this.closeDirectAsk(item, winner.sourceSession, decision, !this.answersAsk(item, winner.sourceSession)); + } + return this.closeDirectAsk(item, input.actorSession, input.decision, input.primaryUser === true); })(); if (!result) return false; for (const event of result.persistedEvents) this.eventBus.notifySubscribers(event); - return true; + return mine; + } + + /** Another person's committed return to this ask: its shared reply (for a park's question, `episodeKey`'s). It's the + * answer, so a different person's later attempt settles with it rather than their own. Null when there's none, or it's + * this actor's. */ + committedAnswerFromAnother(qitemId: string, actorSession: string, episodeKey?: string): QueueItem | null { + const row = this.getById(humanAnswerId(qitemId, episodeKey)); + return row && row.replyTo === qitemId && row.sourceSession !== actorSession ? row : null; + } + + /** Close a pending direct ask as `actor`, its typed words under the first unanswered question. Inside a transaction. */ + private closeDirectAsk(item: QueueItem, actor: string, decision: string, primaryUser: boolean) { + const asked = item.destinationSession; + let transitionNote = asked === actor ? "direct human reply received" + : primaryUser && actor === UNATTRIBUTED_ACTOR + ? `reply from someone at the terminal OpenRig can't name (${UNATTRIBUTED_ACTOR}), unattributed, on ${asked}'s ask; not answered by ${asked}` + : primaryUser ? `reply from ${actor}, the primary user at the terminal, on ${asked}'s ask; not answered by ${asked}` + : runnerPermissionOrigin(item) ? `reply from ${actor}, a registered person, on the runner permission request asked of ${asked}` + : `reply from ${actor}, a registered person, on ${asked}'s ask; not answered by ${asked}`; + const placed = this.placeTypedReply(item, decision); + if (placed) transitionNote += `; ${placed}`; + return this.updateInTransactionalContext({ + qitemId: item.qitemId, + actorSession: actor, + state: "done", + closureReason: "no-follow-on", + transitionNote, + ownerNotificationKind: "human-decision-resolved", + }); } /** Store a typed reply under the first unanswered question, keeping earlier clicks; the placement note, or null. */ @@ -3857,7 +3890,7 @@ export class QueueRepository { clickedTs?: string }): RecordHumanAnswerResult { if (!this.hasHumanQuestionsColumn) return { status: "not-applicable", reason: "schema" }; if (this.closeStaleRunnerPermission(input.qitemId)) return { status: "not-applicable", reason: "state-canceled" }; - return this.db.transaction(() => { + const result = this.db.transaction((): RecordHumanAnswerResult => { const item = this.getById(input.qitemId); if (!item?.humanQuestions?.length) return { status: "not-applicable" as const, reason: "no-questions" }; if (item.state !== "pending" && !this.answersPark(item, input.actorSession, input.clickedTs)) { @@ -3870,10 +3903,18 @@ export class QueueRepository { ...(decidedForThem ? { decidedForThem } : {}) }; } if (!this.answersAsk(item, input.actorSession) && !input.primaryUser) return { status: "not-applicable" as const, reason: "not-the-asked-human" }; + // One answer per question: another person's committed return is the answer (a park's, for its current question), so + // nothing of this person's is recorded. + const episodeKey = item.state === "pending" ? undefined : this.currentHumanRequiredEpisode(item.qitemId)?.key; + const winner = item.state === "pending" || episodeKey ? this.committedAnswerFromAnother(item.qitemId, input.actorSession, episodeKey) : null; + if (winner) return { status: "not-applicable" as const, reason: "answered", answeredBy: winner.sourceSession }; const question = item.humanQuestions.find((q) => q.id === input.questionId); if (!question?.options.some((o) => o.id === input.optionId)) return { status: "not-applicable" as const, reason: "unknown-option" }; const recorded = item.humanAnswers ?? {}; - if (unansweredQuestions(item.humanQuestions, recorded).length === 0) { + // A park's set is final once this park has its answer: until then a complete set is an earlier park's, and a click + // on the re-park records over it. + if (unansweredQuestions(item.humanQuestions, recorded).length === 0 + && (item.state === "pending" || this.currentEpisodeAnswer(item.qitemId, input.actorSession))) { return { status: "recorded" as const, answers: recorded, complete: true, questions: item.humanQuestions }; } const answers: HumanAnswers = { ...(item.humanAnswers ?? {}), [input.questionId]: input.optionId }; @@ -3881,13 +3922,19 @@ export class QueueRepository { const complete = unansweredQuestions(item.humanQuestions, answers).length === 0; return { status: "recorded" as const, answers, complete, questions: item.humanQuestions }; })(); + // A pending ask's interrupted close finishes here, as theirs; a park resumes with it through the reply resolver. + if (result.status === "not-applicable" && result.answeredBy) { + this.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision: "", primaryUser: input.primaryUser }); + } + return result; } - /** A decision parked on the person it asked takes that person's click or typed reply as its answer (makeHumanReplyResolver - * resumes it): the park is on them by any of their addresses, and the answer came after its current question posted. */ + /** A decision parked on the person it asked takes their click or typed reply, or another registered person's, as its + * answer (makeHumanReplyResolver resumes it): the park is on a person, and the answer came after its current question + * posted. */ answersPark(item: QueueItem, actorSession: string, answeredTs: string | undefined): boolean { if (item.state !== "blocked" || !item.blockedOn || !this.isPersonAddress(item.blockedOn)) return false; - if (item.destinationSession !== actorSession || this.askedSomeoneElse(actorSession, item.blockedOn)) return false; + if (!this.isPersonAddress(item.destinationSession) || !this.answersAsk(item, actorSession)) return false; const posted = slackMicros(this.currentHumanRequiredEpisode(item.qitemId)?.receipt?.messageTs); const answered = slackMicros(answeredTs); return posted !== null && answered !== null && answered > posted; @@ -4580,6 +4627,8 @@ export class QueueRepository { : item.state === "blocked" ? resolveRegisteredHumanAddress(item.blockedOn, registry.entities) : resolveRegisteredHumanAddress(item.destinationSession, registry.entities); + // A person with no connector is reached in the Feed and Steering only: nothing is posted to them, so nothing is owed. + if (humanAddress && registry.entities.find((entity) => entity.address === humanAddress)?.connectorBindings.length === 0) return "inactive"; // An ask placed on Steering without an alert (RECORD) owes no post: its ledger reports a real receipt (a posting // dial set to RECORD posts it) and never a missing one. return humanAddress diff --git a/packages/daemon/src/domain/runtime-adapter.ts b/packages/daemon/src/domain/runtime-adapter.ts index bfd9ebdc0..a888a56c6 100644 --- a/packages/daemon/src/domain/runtime-adapter.ts +++ b/packages/daemon/src/domain/runtime-adapter.ts @@ -224,7 +224,7 @@ export interface ForkSource { */ export interface RuntimeAdapter { /** Optional environment-driver v1 operations. Absence means unsupported; the host launch path is unchanged. */ - readonly environment?: Pick; + readonly environment?: Pick; preflightEnvironmentStartup?(binding: NodeBinding, opts: { resumeToken?: string; resumeType?: string; forkSource?: unknown }): Promise; prepareEnvironmentStartup?(files: ResolvedStartupFile[], binding: NodeBinding): Promise; /** Claude's managed capability/launch seam, shared with seat selection. */ diff --git a/packages/daemon/src/domain/seat-handover-service.ts b/packages/daemon/src/domain/seat-handover-service.ts index 6e3e3308a..215797be2 100644 --- a/packages/daemon/src/domain/seat-handover-service.ts +++ b/packages/daemon/src/domain/seat-handover-service.ts @@ -78,9 +78,14 @@ export interface SeatHandoverMutationResult { handoverAt: string; eventSeq: number; sideEffects: { + /** The departing tmux session is kept: a successor respawns into its pane, and a discovered one runs in its own. */ departingSessionKilled: false; + /** How the previous occupant's process ended: it exited on TERM, or was killed after the TERM window, before the + * successor started in its pane. A discovered successor leaves it alone. */ + departingProcess: "ended-graceful" | "ended-forced" | "not-touched"; startupContextDelivered: boolean; - provenanceRecordWritten: false; + /** This handover's lineage record (its occupant tenure) exists. Its write is best-effort, so it's read back. */ + provenanceRecordWritten: boolean; }; restorePacket?: { status: "unconfirmed"; reason: string }; /** OPR.0.5.5.5 — per-source execution outcome. fork: the resolved fork origin; @@ -430,6 +435,7 @@ export class SeatHandoverService { reason, operator, contextDelivered: false, + departingProcess: "not-touched", launchToken: null, occupantGeneration: null, appliedLaunch: null, @@ -693,6 +699,7 @@ export class SeatHandoverService { reason, operator, contextDelivered, + departingProcess: launch.retireeExit === "forced" ? "ended-forced" : "ended-graceful", restorePacket, // B2 (launched/fresh): the launch-scraped resume token captured by the // successor launcher is persisted atomically at commit (provenance scrape). @@ -722,6 +729,7 @@ export class SeatHandoverService { reason: string; operator: string | null; contextDelivered: boolean; + departingProcess: SeatHandoverMutationResult["sideEffects"]["departingProcess"]; restorePacket?: SeatHandoverMutationResult["restorePacket"]; /** Launch-scraped resume token for a fresh successor (persisted at commit). */ launchToken: { token: string; resumeType?: string } | null; @@ -831,6 +839,7 @@ export class SeatHandoverService { operator: input.operator, discovered, contextDelivered: input.contextDelivered, + departingProcess: input.departingProcess, restorePacket: input.restorePacket, launchToken: input.launchToken, occupantGeneration: input.occupantGeneration, @@ -1140,6 +1149,7 @@ export class SeatHandoverService { operator: string | null; discovered: ReturnType & NonNullable; contextDelivered: boolean; + departingProcess: SeatHandoverMutationResult["sideEffects"]["departingProcess"]; restorePacket?: SeatHandoverMutationResult["restorePacket"]; launchToken: { token: string; resumeType?: string } | null; occupantGeneration: string | null; @@ -1171,6 +1181,7 @@ export class SeatHandoverService { // (the name is reused), so this is the only point the retiree's generation is resolvable. const retiringGeneration = this.sessionRegistry.currentOccupantGenerationForSession(input.latestSession.session_name) ?? undefined; + const tenureBefore = this.readOccupantTenure(input.node.id); // atom-B: a seat handover mints a HANDOVER-kind occupant generation (not the default 'adopt'). const newSession = this.sessionRegistry.registerClaimedSession( input.node.id, @@ -1178,6 +1189,10 @@ export class SeatHandoverService { "handover", input.occupantGeneration, ); + // The receipt's lineage record: that mint is best-effort (a failure is logged and swallowed), so read it back. + const tenure = this.readOccupantTenure(input.node.id); + const provenanceRecordWritten = tenure !== null && tenure.kind === "handover" && tenure.id !== tenureBefore?.id + && (input.occupantGeneration === null || tenure.generationUuid === input.occupantGeneration); // W3: registerClaimedSession mints the successor generation. Only now may // the exact adapter-returned launch effect be attached to that tenure. // The store is best-effort, so observation persistence can never make an @@ -1248,10 +1263,10 @@ export class SeatHandoverService { ...(input.sourceOutcome ? { sourceOutcome: input.sourceOutcome } : {}), ...(input.launchAdditionNotes?.length ? { launchAdditionNotes: input.launchAdditionNotes } : {}), }); - return { newSessionId: newSession.id, previousSessionIdsSuperseded, event }; + return { newSessionId: newSession.id, previousSessionIdsSuperseded, event, provenanceRecordWritten }; }); - let committed: { newSessionId: string; previousSessionIdsSuperseded: string[]; event: PersistedEvent }; + let committed: { newSessionId: string; previousSessionIdsSuperseded: string[]; event: PersistedEvent; provenanceRecordWritten: boolean }; try { committed = tx(); // S19 ruling 01530 — the SOLE narrow call: after the commit lands, the activity @@ -1328,13 +1343,19 @@ export class SeatHandoverService { ...(input.restorePacket ? { restorePacket: input.restorePacket } : {}), sideEffects: { departingSessionKilled: false, + departingProcess: input.departingProcess, startupContextDelivered: input.contextDelivered, - provenanceRecordWritten: false, + provenanceRecordWritten: committed.provenanceRecordWritten, }, }, }; } + /** The node's current occupant tenure, or null when the ledger can't be read (then nothing proves a write). */ + private readOccupantTenure(nodeId: string): ReturnType { + try { return this.sessionRegistry.currentOccupantTenure(nodeId); } catch { return null; } + } + private upsertBinding(nodeId: string, fields: { tmuxSession: string; tmuxWindow: string | null; tmuxPane: string | null }): void { const existing = this.db.prepare("SELECT id FROM bindings WHERE node_id = ?").get(nodeId) as { id: string } | undefined; if (existing) { diff --git a/packages/daemon/src/domain/startup-orchestrator.ts b/packages/daemon/src/domain/startup-orchestrator.ts index f23c4baa5..a2c1c36f0 100644 --- a/packages/daemon/src/domain/startup-orchestrator.ts +++ b/packages/daemon/src/domain/startup-orchestrator.ts @@ -596,7 +596,24 @@ export class StartupOrchestrator { this.sessionRegistry.recordResumeLaunch(input.sessionId, launchResumeToken ?? null); } catch { /* best-effort: without it a rotation stays unproved, as before */ } } - if (input.adapter instanceof CommunityHarnessRuntimeAdapter && input.adapter.firstInput() + if (input.adapter.environment) { + const selected = input.adapter.environment.forNode(input.nodeId, input.binding.tmuxSession ?? undefined); + if (!selected) throw new Error("The selected environment destination is unavailable"); + launchChallenge = continuityOutcome === "fresh" && startupProof.mode === "authenticated" ? issueStartupChallenge(this.eventBus, { + rigId: input.rigId, nodeId: input.nodeId, contractSource: JSON.stringify(input.resolvedStartupFiles), + }) : null; + const actions = input.startupActions.filter(a => a.type === "send_text" && !isSessionIdentityAction(a) + && a.appliesOn.includes(context) && !(input.isRestore && !a.idempotent)); + const guestText = (text: string) => input.binding.cwd && input.binding.cwd !== "/" + ? text.split(input.binding.cwd).join(`${selected.guestRoot}/work`) : text; + const parts = [ + ...(identityAction ? [`OpenRig-generated identity context (not a project task):\n${guestText(identityAction.value)}\nEnd of generated identity context.`] : []), + ...actions.filter(a => a.phase === "after_ready").map(a => guestText(a.value)), + "Read the projected CLAUDE.md and its selected startup files in this guest.", + ...postLaunchFiles.map(file => `Read ${selected.guestRoot}/work/.openrig/startup/${encodeURIComponent(file.path)}.`), + ...actions.filter(a => a.phase === "after_files").map(a => guestText(a.value)), ...(launchChallenge ? [launchChallenge.promptBlock] : [])]; + launchPrompt = { prompt: parts.join("\n\n"), remainingFiles: [] }; + } else if (input.adapter instanceof CommunityHarnessRuntimeAdapter && input.adapter.firstInput() && !launchResumeToken && continuityOutcome === "fresh" && identityAction) { launchChallenge = startupProof.mode === "authenticated" ? issueStartupChallenge(this.eventBus, { rigId: input.rigId, nodeId: input.nodeId, contractSource: JSON.stringify(input.resolvedStartupFiles), @@ -827,6 +844,11 @@ export class StartupOrchestrator { // A selected proof still works without a session_identity action: deliver // its standalone prompt after the post-launch contract files below. const consumedActions = new Set(); + const guestInputPassed = !!input.adapter.environment && !!input.firstInputPassed; + if (guestInputPassed) { + postLaunchFiles = []; // All resolved files were projected before the guest's initial input. + for (const action of input.startupActions) if (action.type === "send_text") consumedActions.add(action); + } let challengeOnlyPrompt: string | null = null; if (continuityOutcome === "fresh" && identityAction) { const initialPrompt = input.firstInputPassed && launchPrompt @@ -838,7 +860,7 @@ export class StartupOrchestrator { } postLaunchFiles = initialPrompt.remainingFiles; if (challenge) await this.sendProofInstruction(deliveryInput); - } else if (challenge) { + } else if (challenge && !guestInputPassed) { challengeOnlyPrompt = challenge.promptBlock; } @@ -857,7 +879,7 @@ export class StartupOrchestrator { if (continuityOutcome !== "fresh") { const preloadActions = input.startupActions.filter( (a) => - !isSessionIdentityAction(a) && + !isSessionIdentityAction(a) && !consumedActions.has(a) && a.type === "send_text" && a.phase === "after_ready" && a.appliesOn.includes(context) && @@ -905,7 +927,7 @@ export class StartupOrchestrator { } // 8. Execute after_files actions - const afterFilesResult = await this.executeActions(deliveryInput, "after_files"); + const afterFilesResult = await this.executeActions(deliveryInput, "after_files", consumedActions); if (!afterFilesResult.ok) { return owed(afterFilesResult.errors) ?? this.fail(deliveryInput, "failed", afterFilesResult.errors, undefined, false, undefined, false, "after_files", true); } @@ -1301,6 +1323,9 @@ export class StartupOrchestrator { } private async sendInteractiveText(input: StartupDeliveryInput, text: string, source: StartupSubmissionDiagnostic["source"], actionIndex?: number): Promise { + // Contained startup is passed at launch through the verified guest input file. A viewer can exit + // between any observation and input; never use its host terminal as a fallback destination. + if (input.adapter.environment) return { error: "Environment startup input was not passed through the guest launch; no host input was sent" }; // After a refused turn nothing more is typed into the seat. The held input is counted, and startNode reports it as // owed (ahead of any later error) before it would mark the seat ready, so returning here never reads as delivered. if (input.adapter.runtime === "claude-code" && input.sendOrder > 0 && (input.refusedTurn ??= await this.refusedTurn(input))) { diff --git a/packages/daemon/src/domain/successor-session-launcher.ts b/packages/daemon/src/domain/successor-session-launcher.ts index e2ede7d68..4fa50d38c 100644 --- a/packages/daemon/src/domain/successor-session-launcher.ts +++ b/packages/daemon/src/domain/successor-session-launcher.ts @@ -98,6 +98,8 @@ export type SuccessorLaunchResult = resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; + /** How the departing occupant's process ended at the cutover: it exited on TERM, or was killed after the TERM window. */ + retireeExit: "graceful" | "forced"; /** * OPR.0.4.6.02 S1 — non-fatal tmux option-default warnings from the * fresh successor's launch (mouse/status/clipboard). Present only when @@ -315,6 +317,7 @@ export class SuccessorSessionLauncher { resumeToken: started.resumeToken, resumeType: started.resumeType, appliedLaunch: started.appliedLaunch, + retireeExit: terminated.path, ...(respawned.warnings ? { warnings: respawned.warnings } : {}), }; } diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 330623aca..0f702488d 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -195,7 +195,7 @@ export function startWakeLadderScheduler(deps: { usageLimitJitterSeconds?: number; seatActivityService?: Pick; agentActivityStore?: Pick; - tmuxAdapter?: Pick; + tmuxAdapter?: Pick; gatewaySubsystem?: { status?: () => { state?: unknown }; dispatch: (op: string, entityBindingRef: string, payload: unknown, opts?: { decisionId?: string }) => import("./domain/gateway/dispatcher.js").DispatchResult }; }): WakeLadderScheduler | null { const queueRepo = deps.queueRepo; diff --git a/packages/daemon/src/routes/attention.ts b/packages/daemon/src/routes/attention.ts index ad3ebcbc3..48948eb16 100644 --- a/packages/daemon/src/routes/attention.ts +++ b/packages/daemon/src/routes/attention.ts @@ -12,6 +12,7 @@ import { readMissionReadiness } from "../domain/proof/judgments.js"; import { WorkflowInstanceStore } from "../domain/workflow-instance-store.js"; import { WorkflowStepTrailLog } from "../domain/workflow-step-trail-log.js"; import { feedPerspective } from "../domain/feed-perspective.js"; +import { resolveRegisteredHumanAddress } from "../domain/gateway/human-registry.js"; import { UNNAMED_PRIMARY_USER } from "../domain/mission-control/mission-control-write-contract.js"; export function attentionRoutes(): Hono { @@ -46,11 +47,19 @@ export function attentionRoutes(): Hono { const asked = new Set(rows.slice(0, 1000).filter(q => queue.isCurrentHumanAsk(q.qitemId)).map(q => q.qitemId)); const quiet = new Set(rows.slice(0, 1000).filter(q => asked.has(q.qitemId) && queue.humanAskWithoutAlert(q.qitemId)).map(q => q.qitemId)); const current = rows.slice(0, 1000).filter(q => isHumanSeatSessionRef(q.destinationSession) || asked.has(q.qitemId) || q.state === "blocked" && isHumanSeatSessionRef(q.blockedOn ?? "")); + // A person with no connector binding is asked on Steering and in the Feed only: nothing is posted to them in Slack. + const people = queue.humanRegistry(); + const noConnector = (address: string | null | undefined): boolean => { + if (!people.ok) return false; + const person = resolveRegisteredHumanAddress(address, people.entities); + return people.entities.some((entity) => entity.address === person && entity.connectorBindings.length === 0); + }; // Whether the request went to Slack: its own receipt first, then whether it was ever to be sent. - const slackLine = (qitemId: string): string => { + const slackLine = (qitemId: string, unbound: boolean): string => { const delivery = queue.deliveryOutcomeFor(qitemId); if (delivery) return delivery.outcome === "posted" ? "posted (posted isn't read)" : `${delivery.outcome}: ${delivery.detail}`; - return quiet.has(qitemId) ? "not sent: created without --alert, and this person's Slack subscription is urgent-only" : "no receipt yet"; + return unbound ? "not sent: this person has no connector binding, so they're asked on Steering and in the Feed only" + : quiet.has(qitemId) ? "not sent: created without --alert, and this person's Slack subscription is urgent-only" : "no receipt yet"; }; // Keep an opened request inspectable after its source is resolved, without // retaining it in the current Action required feed or inventing an inbox. @@ -76,13 +85,15 @@ export function attentionRoutes(): Hono { ...(toPerson ? { asker: q.sourceSession } : {}), ...(questions ? { humanQuestions: questions, humanAnswers: q.humanAnswers ?? {} } : {}), summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || "Request summary unavailable", urgency: q.priority, unblocks, at: q.tsUpdated, scope: p ? `project ${p.id}` : "instance · project unknown", project: p ? { id: p.id, root: p.root } : null, source: `/api/queue/${encodeURIComponent(q.qitemId)}` }; + const unbound = noConnector(toPerson ? q.destinationSession : q.blockedOn); + const notSent = unbound || quiet.has(q.qitemId); const route = unanswered - ? `Decision route: answer its questions here, choosing an option or typing a reply${quiet.has(q.qitemId) ? "; it wasn't sent to Slack" : ", or in the correlated Slack message"}.` + ? `Decision route: answer its questions here, choosing an option or typing a reply${notSent ? "; it wasn't sent to Slack" : ", or in the correlated Slack message"}.` : questions && q.state === "pending" ? "Decision route: its questions are all answered, but the answers haven't gone back to the asker yet: send them from here." - : quiet.has(q.qitemId) ? "Decision route: answer on Steering; it wasn't sent to Slack." : "Decision route: follow the correlated human request in Slack."; + : notSent ? "Decision route: answer on Steering; it wasn't sent to Slack." : "Decision route: follow the correlated human request in Slack."; add(item, () => { const mode = posture?.resolve({ qitemId: q.qitemId }); - return ["Request:", q.body, ...(q.humanDetail ? ["Supplemental detail:", q.humanDetail] : []), `State: ${q.state}`, `To: ${q.destinationSession}`, `From: ${q.sourceSession}`, `Blocked on: ${q.blockedOn ?? "none"}`, `Posture: ${mode?.posture ?? "unknown"} · ${mode?.reason ?? "posture source unavailable"}`, `Evidence reference: ${q.evidenceRef ?? "none recorded"}`, `Slack: ${slackLine(q.qitemId)}`, `${route} Inspect: rig queue show ${q.qitemId} --full`, "Queue history (read/delivery is not approval):", ...queue.listTransitions(q.qitemId).map(t => JSON.stringify(t))]; + return ["Request:", q.body, ...(q.humanDetail ? ["Supplemental detail:", q.humanDetail] : []), `State: ${q.state}`, `To: ${q.destinationSession}`, `From: ${q.sourceSession}`, `Blocked on: ${q.blockedOn ?? "none"}`, `Posture: ${mode?.posture ?? "unknown"} · ${mode?.reason ?? "posture source unavailable"}`, `Evidence reference: ${q.evidenceRef ?? "none recorded"}`, `Slack: ${slackLine(q.qitemId, unbound)}`, `${route} Inspect: rig queue show ${q.qitemId} --full`, "Queue history (read/delivery is not approval):", ...queue.listTransitions(q.qitemId).map(t => JSON.stringify(t))]; }, () => { if (!q.evidenceRef || !path.isAbsolute(q.evidenceRef) && (!p || /^[a-z][a-z\d+.-]*:/i.test(q.evidenceRef))) return []; const hash = q.evidenceRef.indexOf("#"), anchor = hash < 0 ? "" : q.evidenceRef.slice(hash); diff --git a/packages/daemon/test/community-harness-dsh-example.test.ts b/packages/daemon/test/community-harness-dsh-example.test.ts index cd287e5c1..99fcb6cdc 100644 --- a/packages/daemon/test/community-harness-dsh-example.test.ts +++ b/packages/daemon/test/community-harness-dsh-example.test.ts @@ -18,6 +18,12 @@ import { appendFileSync } from "node:fs"; import { createInterface } from "node:readline"; const log = (entry) => appendFileSync(process.env.FAKE_LOG, JSON.stringify(entry) + "\\n"); const send = (message) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); +// Like dsh 0.2.1-rc.2: its CLI reports its real version, and its ACP handshake reports 0.0.1. +if (process.argv.includes("--version")) { + // FAKE_VERSION=stuck: a version lookup that ignores SIGTERM and doesn't exit for 30 seconds. + if (process.env.FAKE_VERSION === "stuck") { process.on("SIGTERM", () => {}); setTimeout(() => process.exit(0), 30_000); await new Promise(() => {}); } + process.stdout.write("dsh 0.2.1-rc.2\\n"); process.exit(0); +} log({ start: { argv: process.argv.slice(2), DSH_HOME: process.env.DSH_HOME ?? null, DSH_PERMISSION_MODE: process.env.DSH_PERMISSION_MODE ?? null, DSH_TELEMETRY_DISABLED: process.env.DSH_TELEMETRY_DISABLED ?? null, token: "OPENRIG_ACTIVITY_HOOK_TOKEN" in process.env } }); process.stdout.write("dsh: a log line that isn't JSON\\n"); @@ -30,7 +36,7 @@ createInterface({ input: process.stdin }).on("line", (line) => { const reply = (result) => send({ id: message.id, result }); const fail = (code, text) => send({ id: message.id, error: { code, message: text } }); switch (message.method) { - case "initialize": return reply({ protocolVersion: 1, agentCapabilities: JSON.parse(process.env.FAKE_CAPS ?? "{}"), agentInfo: { name: "dsh", version: "0.2.1-rc.2" } }); + case "initialize": return reply({ protocolVersion: 1, agentCapabilities: JSON.parse(process.env.FAKE_CAPS ?? "{}"), agentInfo: { name: "dsh", version: "0.0.1" } }); case "session/new": session = "sess-new-1"; return reply({ sessionId: session }); case "session/resume": case "session/load": { if (process.env.FAKE_RESUME === "missing") return fail(-32002, "Resource not found"); @@ -178,7 +184,7 @@ async function fakeOpenRig(permissions: Permissions) { return { url: `http://127.0.0.1:${address.port}`, hooks, submits, withdraws }; } -function start(openrig: { url: string }, env: Record = {}) { +function start(openrig: { url: string }, env: Record = {}, binVar = "OPENRIG_DSH") { const dir = mkdtempSync(join(tmpdir(), "dsh-example-")); const fake = join(dir, "fake-dsh.mjs"); writeFileSync(fake, FAKE_DSH); @@ -187,7 +193,7 @@ function start(openrig: { url: string }, env: Record = {}) { const launchResult = join(dir, "launch", "result.json"); mkdirSync(join(dir, "launch")); const child: ChildProcess = spawn(process.execPath, [RUNNER], { cwd: dir, stdio: "pipe", env: { - PATH: process.env["PATH"]!, OPENRIG_DSH: fake, FAKE_LOG: logFile, OPENRIG_HOME: join(dir, "home"), + PATH: process.env["PATH"]!, [binVar]: fake, FAKE_LOG: logFile, OPENRIG_HOME: join(dir, "home"), OPENRIG_URL: openrig.url, OPENRIG_ACTIVITY_HOOK_TOKEN: "test-token", OPENRIG_SESSION_NAME: "dev-dsh@test-rig", OPENRIG_NODE_ID: "node-1", OPENRIG_OCCUPANT_GENERATION: "gen-1", OPENRIG_RUNTIME: "dsh", OPENRIG_LAUNCH_RESULT: launchResult, OPENRIG_LAUNCH_ATTEMPT: "attempt-1", ...env, @@ -225,7 +231,8 @@ const responseTo = (log: Array>, id: string) => log.find describe("the documented dsh example runner", () => { it("opens a conversation, writes the launch record, and only then reads ready, with uploads off and no hook token for dsh", async () => { const openrig = await fakeOpenRig("absent"); - const seat = start(openrig); + // A DSH_HOME the person's shell exports (its startup files run after the seat's env:) isn't the seat's home. + const seat = start(openrig, { DSH_HOME: join(tmpdir(), "a-persons-own-dsh-home") }); const record = await waitFor(seat.launch, "the launch record"); expect(record).toMatchObject({ format: 1, attempt: "attempt-1", harness: "dsh", nativeRef: "sess-new-1" }); expect(String(record.producer)).toMatch(/openrig-dsh-runner .*dsh 0\.2\.1-rc\.2.*ACP 1/); @@ -243,6 +250,23 @@ describe("the documented dsh example runner", () => { expect(idle.auth).toBe("Bearer test-token"); }); + it("takes its dsh binary and home from DSH_SEAT_BIN and DSH_SEAT_HOME, which a rig spec's env: can set, over an exported DSH_HOME", async () => { + const openrig = await fakeOpenRig("absent"); + const home = mkdtempSync(join(tmpdir(), "dsh-prepared-home-")); + cleanups.push(() => rmSync(home, { recursive: true, force: true })); + const seat = start(openrig, { DSH_SEAT_HOME: home, DSH_HOME: join(tmpdir(), "a-persons-own-dsh-home") }, "DSH_SEAT_BIN"); + await waitFor(seat.launch, "the launch record"); + expect((seat.log()[0] as { start: Record }).start).toMatchObject({ argv: ["--profile", "acp"], DSH_HOME: home }); + }); + + it("starts without waiting on a version lookup that ignores SIGTERM, and names the handshake's version", async () => { + const openrig = await fakeOpenRig("absent"); + const seat = start(openrig, { FAKE_VERSION: "stuck" }); + const record = await waitFor(seat.launch, "the launch record", 15_000); + expect(record).toMatchObject({ nativeRef: "sess-new-1" }); + expect(String(record.producer)).toMatch(/dsh 0\.0\.1 \(as its ACP handshake reports it\); ACP 1/); + }, 30_000); + it("sends one submitted block as one prompt and reports working, then idle, in order", async () => { const openrig = await fakeOpenRig("absent"); const seat = start(openrig); diff --git a/packages/daemon/test/compaction-preparation.test.ts b/packages/daemon/test/compaction-preparation.test.ts index 437eeba74..c02070e85 100644 --- a/packages/daemon/test/compaction-preparation.test.ts +++ b/packages/daemon/test/compaction-preparation.test.ts @@ -341,6 +341,34 @@ function routeFixture(f:ReturnType, cwd?:string){ return ()=>app.request('/api/compaction/trigger',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session:seat})}); } +for(const map of ['complete','removed'])it(`manual route classifies an idle-wait timeout with a ${map} map`,async()=>{ + const f=fixture();f.onSleep(async()=>{stageMap(f);f.activity('busy');}); + // The transport reports its own elapsed wait before the enforcer's clock observes the deadline. + const idle=vi.spyOn(f.transport,'waitUntilIdle').mockImplementationOnce(async(_session,remaining)=>{ + expect(remaining).toBeGreaterThan(0); + const a=f.e.getPreparationState(seat)!; + if(map==='removed')for(const path of [a.mapPath,a.mapPath+'.tmp']){ + rmSync(path,{force:true});expect(existsSync(path)).toBe(false); + } + return {ok:false,reason:'wait_for_idle_timeout',waitedMs:remaining,attempts:1} as any; + }); + const response=await routeFixture(f)();const body=await response.json(); + expect(response.status).toBe(409); + expect(body.reason).toBe(map==='complete'?'map_complete_seat_never_idle':'preparation_incomplete'); + expect(body.error).toContain(map==='complete'?'map complete, seat never idle':'restore map was not completed'); + expect(body.preparation).toMatchObject({delivery:'delivered',compact:'not_sent'}); + expect(f.e.getPreparationState(seat)).toMatchObject({status:'stopped',reason:body.reason}); + expect(idle).toHaveBeenCalledTimes(1);expect(compacts(f)).toHaveLength(0);expect(f.writes).toHaveLength(1); +}); + +it('manual route preserves a non-timeout idle-wait refusal after map completion',async()=>{ + const f=fixture();f.onSleep(async()=>{stageMap(f);f.activity('busy');}); + vi.spyOn(f.transport,'waitUntilIdle').mockResolvedValueOnce({ok:false,reason:'transport_unavailable'} as any); + const response=await routeFixture(f)();const body=await response.json(); + expect(response.status).toBe(409);expect(body.reason).toBe('transport_unavailable'); + expect(compacts(f)).toHaveLength(0);expect(f.writes).toHaveLength(1); +}); + it('preparation receipt: unresolved permission at deadline discloses the delivered prep and exact attempt',async()=>{ const f=fixture();f.onSleep(async()=>{stageMap(f);f.activity('needs_input');}); const res=await routeFixture(f)();const body=await res.json(); diff --git a/packages/daemon/test/delegated-decisions.test.ts b/packages/daemon/test/delegated-decisions.test.ts index f38583180..99139c9cd 100644 --- a/packages/daemon/test/delegated-decisions.test.ts +++ b/packages/daemon/test/delegated-decisions.test.ts @@ -196,7 +196,7 @@ describe("refused: nothing is decided for the person", () => { }); describe("the person's own answers stay theirs", () => { - it("recordHumanAnswer still refuses anyone but the asked person", async () => { + it("recordHumanAnswer still refuses an agent seat, and takes the asked person's choice", async () => { const f = fixture(); const row = await f.ask(); expect(f.repo.recordHumanAnswer({ qitemId: row.qitemId, actorSession: "designer@rig", questionId: "design", optionId: "a" })) diff --git a/packages/daemon/test/environment-lifecycle.test.ts b/packages/daemon/test/environment-lifecycle.test.ts index 0dc7e42dc..f7e0eac38 100644 --- a/packages/daemon/test/environment-lifecycle.test.ts +++ b/packages/daemon/test/environment-lifecycle.test.ts @@ -267,3 +267,27 @@ describe("finite completion is selected work plus a verified actual export", () }); }); + +it.each(["provider-list", "cleanup-inventory"])("reports the actual pre-dispatch stage and error without claiming a delete: %s", async stage => { + const f = await fixture(); const error = Object.assign(new Error("fixture engine connection refused"), { code: "ECONNREFUSED" }); + if (stage === "provider-list") vi.spyOn(f.driver, "list").mockRejectedValue(error); + else Object.assign(f.driver, { captureCleanup: async () => { throw error; } }); + const request = { effect: "delete-owned", operationId: "delete-before-dispatch", generation: "generation-one", engineSocket: "/fixture/engine.sock" }; + for (let observation = 0; observation < 2; observation++) { + const result = await f.request("/binding-one/effect", request); + expect(result.body).toMatchObject({ state: "unconfirmed", stage, dispatched: false, error: { code: "ECONNREFUSED", message: "fixture engine connection refused" } }); + expect(f.counts().deleteCalls).toBe(0); + const row = f.db.prepare("SELECT state FROM environment_operations WHERE operation_id=?").get(request.operationId) as { state: string }; + expect(row.state).toBe("intent"); + } +}); + +it("keeps an uncertain delete requested and reconciles the same operation without another mutation", async () => { + const f = await fixture(); const deletion = vi.spyOn(f.driver, "deleteEnvironment").mockRejectedValue(new Error("fixture reply lost at delete boundary")); + const request = { effect: "delete-owned", operationId: "delete-uncertain", generation: "generation-one" }; + const first = await f.request("/binding-one/effect", request); + expect(first.body).toMatchObject({ state: "unconfirmed", stage: "delete-dispatch", dispatched: "unknown", error: { message: "fixture reply lost at delete boundary" } }); + expect((f.db.prepare("SELECT state FROM environment_operations WHERE operation_id=?").get(request.operationId) as { state: string }).state).toBe("requested"); + await f.request("/binding-one/effect", request); + expect(deletion).toHaveBeenCalledTimes(1); +}); diff --git a/packages/daemon/test/environment-startup.test.ts b/packages/daemon/test/environment-startup.test.ts index ae8d9b7f5..17678e4e3 100644 --- a/packages/daemon/test/environment-startup.test.ts +++ b/packages/daemon/test/environment-startup.test.ts @@ -1,3 +1,5 @@ +import { execFileSync } from "node:child_process"; +import { TmuxAdapter } from "../src/adapters/tmux.js"; import { afterEach, expect, it, vi } from "vitest"; import { mkdtempSync, realpathSync, readFileSync, writeFileSync, rmSync, mkdirSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -72,6 +74,41 @@ it("reconciles a lost latch reply without issuing another grant or rewriting an expect(f.activity).toHaveBeenCalledTimes(1); expect(f.guest.mock.calls.filter(call => call[1][0] === "--prepare")).toHaveLength(1); expect(sha256(f.files.get("/sandbox/openrig/control/launch.pending.json")!)).toBe(before); }); +it.each(["old-helper", "transport"])("preserves launch custody and distinguishes %s prepare failure guidance", async failure => { + const f = await fixture(), bin = join(f.root, "bin"), calls = join(f.root, "vendor-calls.jsonl"); + mkdirSync(bin); + const stderr = failure === "old-helper" + ? "usage: guest.py [-h] [--prepare]\nguest.py: error: unrecognized arguments: --first-input-required\n" + : "connection reset by peer\n"; + // A local stand-in exercises the real driver's execFile error boundary; no vendor/provider runs. + writeFileSync(join(bin, "openshell"), `#!${process.execPath} +const fs = require("node:fs"), args = process.argv.slice(2); +fs.appendFileSync(${JSON.stringify(calls)}, JSON.stringify(args) + "\\n"); +if (args[5] === "get") process.stdout.write(${JSON.stringify(JSON.stringify(f.resource))}); +else if (args.includes("--prepare")) { process.stderr.write(${JSON.stringify(stderr)}); process.exitCode = 2; } +else { process.stderr.write("unexpected stand-in command"); process.exitCode = 9; } +`, { mode: 0o700 }); + const realDriver = new OpenShellDriver(); + Object.assign(f.platform.driver, { guest: realDriver.guest.bind(realDriver) }); + const previousPath = process.env.PATH; + process.env.PATH = `${bin}:${previousPath ?? ""}`; + try { + const error = await f.startup.launch(f.binding, { firstInputText: "Selected startup input" }, f.tmux).catch(error => error); + expect(error).toMatchObject({ code: "openshell_transport_unconfirmed" }); + expect(f.db.prepare("SELECT state FROM environment_operations WHERE kind='launch'").all()).toEqual([{ state: "requested" }]); + expect(f.tmux.sendShellCommand).not.toHaveBeenCalled(); + const dispatched = readFileSync(calls, "utf8").trim().split("\n").map(line => JSON.parse(line) as string[]); + expect(dispatched.filter(args => args.includes("--prepare"))).toHaveLength(1); + expect(dispatched.at(-1)).toContain("--first-input-required"); + if (failure === "old-helper") { + expect(error.message).toContain("predates first input"); + expect(error.message).toContain("Rebuild"); + expect(error.message).toContain("README"); + } else { + expect(error.message).toBe("OpenShell did not return a confirmed result; reconcile the saved operation"); + } + } finally { if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; } +}); it("blocks a changed required source before issuing a grant or preparing native", async () => { const f = await fixture(), role = join(f.root, "role.md"); writeFileSync(role, "selected source"); await f.startup.deliverStartup([{ path: "role.md", absolutePath: role, ownerRoot: f.root, deliveryHint: "send_text", required: true, appliesOn: ["fresh_start"] }], f.binding); @@ -232,3 +269,51 @@ it.each(["unchanged", "missing-source", "changed", "added", "removed", "stale-de else { expect(restored).toBe(false); expect(restoredNode?.status).not.toBe("resumed"); } expect(textInput).not.toHaveBeenCalled(); }); + +// No provider or native binary: use the real contained adapter and launch journal. +it.each(["unavailable", "different-generation", "stopped", "ready-at-shell"])("contained readiness observes the selected guest, not the connect process: %s", async kind => { + const f = await fixture(); await f.startup.launch(f.binding, {}, f.tmux); + const row = f.db.prepare("SELECT receipt_json FROM environment_operations WHERE kind='launch'").get() as { receipt_json: string }; + const native = JSON.parse(row.receipt_json); + f.guest.mockImplementation(async () => { + if (kind === "unavailable") throw new Error("guest identity unavailable"); + return { ...native, generation: kind === "different-generation" ? "other" : native.generation, running: kind !== "stopped", ok: true }; + }); + const adapter = new CommunityHarnessRuntimeAdapter("openshell-claude", { environmentStartup: f.startup, + tmux: { ...f.tmux, hasSession: async () => true, getPaneCommand: async () => kind === "ready-at-shell" ? "bash" : "node" } }); + expect((await adapter.checkReady(f.binding)).ready).toBe(kind === "ready-at-shell"); +}); +it("passes generated first input through verified guest files and execs the short viewer command", async () => { + const f = await fixture(); + const opts = { firstInputText: "Selected identity; read projected role.md. Literal $(not-a-host-command)." }; + const result = await f.startup.launch(f.binding, opts, f.tmux); + expect(result.firstInput).toMatchObject({ submission: "unconfirmed" }); + const descriptor = JSON.parse(f.files.get("/sandbox/openrig/control/launch.pending.json")!.toString()); + expect(descriptor.firstInput).toMatchObject({ path: "control/first-input.txt", sha256: sha256(opts.firstInputText) }); + expect(f.files.get("/sandbox/openrig/control/first-input.txt")!.toString()).toBe(opts.firstInputText); + const command = f.tmux.sendShellCommand.mock.calls[0] as unknown as [string, string]; + expect(command[1]).toMatch(/^exec /); expect(command[1]).not.toContain("Selected identity"); +}); + +it("the real short-command transport leaves no host shell when the environment viewer exits", async () => { + const f = await fixture(), bin = join(f.root, "bin"); mkdirSync(bin); + writeFileSync(join(bin, "rig"), "#!/bin/sh\nexit 7\n", { mode: 0o700 }); + const transport = new TmuxAdapter(async () => ""); let invocation = ""; + vi.spyOn(transport, "sendText").mockImplementation(async (_target, text) => { invocation = text; return { ok: true }; }); + vi.spyOn(transport, "sendKeys").mockResolvedValue({ ok: true }); + await f.startup.launch(f.binding, {}, { sendShellCommand: transport.sendShellCommand.bind(transport) }); + const marker = join(f.root, "host-fell-through"); + expect(invocation).toContain("environment"); expect(Buffer.byteLength(invocation)).toBeLessThanOrEqual(512); + try { execFileSync("/bin/sh", ["-c", `${invocation}\ntouch '${marker}'`], { env: { ...process.env, PATH: `${bin}:/usr/bin:/bin` }, stdio: "ignore" }); } catch {} + expect(existsSync(marker)).toBe(false); +}); +it("a lost prepare reply reconciles the original first input instead of typing it again", async () => { + const f = await fixture(); f.losePrepare(); const opts = { firstInputText: "selected immutable input" }; + await expect(f.startup.launch(f.binding, opts, f.tmux)).rejects.toThrow("Lost preparation reply"); + const before = sha256(f.files.get("/sandbox/openrig/control/first-input.txt")!); + const result = await f.startup.launch(f.binding, opts, f.tmux); + expect(result.firstInput).toMatchObject({ path: "/sandbox/openrig/control/first-input.txt", submission: "unconfirmed" }); + expect(sha256(f.files.get("/sandbox/openrig/control/first-input.txt")!)).toBe(before); + expect(f.guest.mock.calls.filter(call => call[1][0] === "--prepare")).toHaveLength(1); + expect(f.tmux.sendShellCommand).not.toHaveBeenCalled(); +}); diff --git a/packages/daemon/test/gateway-human-registry.test.ts b/packages/daemon/test/gateway-human-registry.test.ts index 62877f79a..d02a4b5bf 100644 --- a/packages/daemon/test/gateway-human-registry.test.ts +++ b/packages/daemon/test/gateway-human-registry.test.ts @@ -50,9 +50,11 @@ describe("A3 human-fragment validation (add-time == load-time)", () => { expect(r.ok).toBe(false); }); - it("rejects zero connectorBindings (>=1)", () => { - const r = validateHumanFragment({ ...fragment(), connectorBindings: [] }); - expect(r.ok).toBe(false); + it("accepts a person with no connector binding, as an empty list or left out, and rejects a value that isn't a list", () => { + expect(validateHumanFragment({ ...fragment(), connectorBindings: [] })).toMatchObject({ ok: true, fragment: { connectorBindings: [] } }); + const { connectorBindings: _none, ...withoutBindings } = fragment(); + expect(validateHumanFragment(withoutBindings)).toMatchObject({ ok: true, fragment: { connectorBindings: [] } }); + expect(validateHumanFragment({ ...fragment(), connectorBindings: "slack" as never }).ok).toBe(false); }); it("rejects an entity with NO primary binding (exactly-one-primary)", () => { diff --git a/packages/daemon/test/mission-control-parked-person.test.ts b/packages/daemon/test/mission-control-parked-person.test.ts index eba583a67..42c85c505 100644 --- a/packages/daemon/test/mission-control-parked-person.test.ts +++ b/packages/daemon/test/mission-control-parked-person.test.ts @@ -90,16 +90,15 @@ describe("Steering resolves a registered person's park without changing its owne expect(repo.getById(sibling)).toEqual(siblingBefore); }); - it("refuses a different registered person before any transition, audit or nudge, even if the body claims the asked person", async () => { + it("a different registered person settles it as themselves, on the asked person's park, even if the body claims the asked person", async () => { const id = await park(); - const before = repo.getById(id); - const history = repo.transitionLog.listForQitem(id); - expect(await act(id, "other-person@external", "resolve", "No", "fixture-person@external")) - .toMatchObject({ status: 400, body: { error: "answer_not_recorded", asked: "fixture-person@external" } }); - expect(repo.getById(id)).toEqual(before); - expect(repo.transitionLog.listForQitem(id)).toEqual(history); - expect(log.listForQitem(id)).toHaveLength(0); - expect(nudges).toHaveLength(0); + expect((await act(id, "other-person@external", "resolve", "No", "fixture-person@external")).status).toBe(200); + expect(repo.getById(id)).toMatchObject({ state: "in-progress", blockedOn: null }); + expect(repo.transitionLog.listForQitem(id).at(-1)).toMatchObject({ actorSession: "other-person@external", + transitionNote: "No; by other-person@external, a registered person, on fixture-person@external's ask" }); + expect(log.listForQitem(id)).toHaveLength(1); + expect(nudges).toHaveLength(1); + expect(nudges[0]).toMatchObject({ session: "current-owner@fixture" }); }); it.each(["human@host", "human-review@kernel"])("keeps the legacy park %s settleable by an agent", async (blockedOn) => { @@ -120,7 +119,6 @@ describe("Steering resolves a registered person's park without changing its owne it("keeps direct answers repeat-safe and addressed to the asker", async () => { const ask = await repo.create({ sourceSession: "asker@fixture", destinationSession: "fixture-person@external", body: "Ship?", summary: "Ship?", humanIntent: "decision", evidenceRef: "fixture/ship", nudge: false }); - expect(await act(ask.qitemId, "other-person@external", "answer")).toMatchObject({ status: 400 }); const first = await act(ask.qitemId, "fixture-person@external", "answer", "Ship it"); expect(first.status).toBe(200); const replay = await act(ask.qitemId, "fixture-person@external", "answer", "Different words"); diff --git a/packages/daemon/test/openshell-guest.test.ts b/packages/daemon/test/openshell-guest.test.ts index 5f94ded4d..319e99239 100644 --- a/packages/daemon/test/openshell-guest.test.ts +++ b/packages/daemon/test/openshell-guest.test.ts @@ -88,3 +88,43 @@ it("preserves rejection of a symlink in the selected root's ancestors", async () expect(await command(join(root, "link", "workspace"), "--read", "role.md")) .toMatchObject({ ok: false, code: "environment_root_invalid" }); }); + +it.each([false, true])("the real guest latch binds its initial input without a host viewer (changed=%s)", async changed => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "openshell-first-input-"))); roots.push(root); + for (const name of ["control", "work"]) mkdirSync(join(root, name)); + const input = "Selected identity and role; $(literal-not-a-host-command)"; + writeFileSync(join(root, "control", "first-input.txt"), input); + const script = `import importlib.util,json,sys,hashlib +spec=importlib.util.spec_from_file_location("guest",sys.argv[1]); g=importlib.util.module_from_spec(spec); spec.loader.exec_module(g) +root=sys.argv[2]; payload=sys.argv[3].encode() +value={"version":1,"bindingId":"binding-one","generation":"generation-one","nativeId":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee","files":[],"firstInput":{"path":"control/first-input.txt","bytes":len(payload),"sha256":hashlib.sha256(payload).hexdigest()}} +g.atomic(root,"control/launch.json",json.dumps(value).encode()) +g.atomic(root,"control/activity.json",json.dumps({"OPENRIG_URL":"http://guest.test:7782","OPENRIG_ACTIVITY_HOOK_TOKEN":"fixture-token","OPENRIG_NODE_ID":"node-one","OPENRIG_SESSION_NAME":"worker@test","OPENRIG_OCCUPANT_GENERATION":"generation-one"}).encode()) +if sys.argv[4]=="true": g.atomic(root,"control/first-input.txt",b"changed") +class Captured(Exception): pass +class Child: + pid=123 + def wait(self): raise Captured() +def spawn(argv,**kwargs): + print(json.dumps({"argv":argv,"input":g.regular(root,"control/first-input.txt").decode()})); return Child() +g.subprocess.Popen=spawn; g.process_start=lambda pid:"start" +try: g.launch(root) +except Captured: pass +except g.Refused as error: print(json.dumps({"error":str(error)})) +`; + const result = JSON.parse((await run("python3", ["-c", script, helper, root, input, String(changed)], { timeout: 5000 })).stdout); + if (changed) expect(result).toEqual({ error: "environment_projection_changed" }); + else { + expect(result.input).toBe(input); + expect(result.argv[0]).toBe("/usr/local/bin/claude"); + expect(result.argv.at(-1)).toContain(join(root, "control", "first-input.txt")); + expect(result.argv.at(-1)).not.toContain("$(literal"); + } +}); + +it("requires a verified first input before publishing a launch that selected it", async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "openshell-input-required-"))); roots.push(root); mkdirSync(join(root, "control")); + writeFileSync(join(root, "control", "launch.pending.json"), JSON.stringify({ version: 1, bindingId: "binding-one", generation: "generation-one", nativeId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", files: [] })); + expect(await command(root, "--prepare", "--first-input-required")).toEqual({ ok: false, code: "environment_descriptor_invalid" }); + expect(() => readFileSync(join(root, "control", "launch.json"))).toThrow(); +}); diff --git a/packages/daemon/test/primary-user-actions.test.ts b/packages/daemon/test/primary-user-actions.test.ts index 1773100dc..658f7df94 100644 --- a/packages/daemon/test/primary-user-actions.test.ts +++ b/packages/daemon/test/primary-user-actions.test.ts @@ -1,5 +1,7 @@ -// Primary-user actions: the person at the terminal acts on anyone's ask, and the record says both who acted and who was -// asked. A registered person without that authority can't settle another person's ask. Real SQLite (all migrations), +// Primary-user actions: the person at the terminal acts on anyone's ask. The terminal names no responder (the actor the +// Feed serves is the asked person's address), so with anyone registered the act is unattributed, human@host, and the +// record says whose ask it was. Any registered person settles another's ask on the plain path, recorded as themselves. +// Real SQLite (all migrations), // people registered in a temporary home, and the real Mission Control route and write contract, called with no seat // header, as the TUI calls it. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -21,7 +23,7 @@ import { setDefaultPerson, setSharedChannel } from "../src/domain/gateway/partic const ASKER = "writer@rig"; -describe("the primary user acts on anyone's ask, recorded as themselves on that person's ask", () => { +describe("the primary user acts on anyone's ask, unattributed, on that person's ask", () => { let db: Database.Database; let repo: QueueRepository; let home: string; @@ -62,19 +64,19 @@ describe("the primary user acts on anyone's ask, recorded as themselves on that return { verb: row.action_verb, actor: row.actor_session, notes: row.audit_notes_json ? JSON.parse(row.audit_notes_json) : null }; }; - it("answers Sarah's ask in the primary user's words: Mike answered, on Sarah's ask, and the asker reads exactly that", async () => { + it("answers Sarah's ask from the terminal: unattributed, on Sarah's ask, and the asker reads exactly that", async () => { register("mike", "sarah"); expect(setDefaultPerson("mike", home).ok).toBe(true); const sarahs = await ask("sarah@external"); - // The TUI claims the actor it shows; the daemon records the primary user whatever the claim. + // The TUI claims the actor the Feed serves, the asked person; that's whose ask it is, not who typed. const result = await act({ verb: "answer", qitemId: sarahs.qitemId, actorSession: "sarah@external", authority: "primary-user", decision: "Ship it" }); expect(result.status).toBe(200); expect(repo.getById(sarahs.qitemId)?.state).toBe("done"); - expect(lastTransition(sarahs.qitemId)).toMatchObject({ state: "done", actor_session: "mike@external" }); - expect(lastTransition(sarahs.qitemId).transition_note).toContain("mike@external, the primary user at the terminal, on sarah@external's ask; not answered by sarah@external"); - expect(audit(sarahs.qitemId)).toEqual({ verb: "answer", actor: "mike@external", notes: { authority: "primary-user", asked: "sarah@external" } }); + expect(lastTransition(sarahs.qitemId)).toMatchObject({ state: "done", actor_session: "human@host" }); + expect(lastTransition(sarahs.qitemId).transition_note).toContain("reply from someone at the terminal OpenRig can't name (human@host), unattributed, on sarah@external's ask; not answered by sarah@external"); + expect(audit(sarahs.qitemId)).toEqual({ verb: "answer", actor: "human@host", notes: { authority: "primary-user", asked: "sarah@external" } }); const reply = repo.list({ limit: 50 }).find((item) => item.replyTo === sarahs.qitemId)!; - expect(reply).toMatchObject({ sourceSession: "mike@external", destinationSession: ASKER, summary: "mike@external answered sarah@external's ask: Ship the draft?" }); - expect(reply.body).toContain("Answered by mike@external, the primary user at the terminal, on sarah@external's ask in OpenRig."); + expect(reply).toMatchObject({ sourceSession: "human@host", destinationSession: ASKER, summary: "Unattributed (human@host) answered sarah@external's ask: Ship the draft?" }); + expect(reply.body).toContain("Answered at the terminal by someone OpenRig can't name (human@host), unattributed, on sarah@external's ask in OpenRig."); }); it("records a choice on a question ask the same way", async () => { @@ -84,33 +86,40 @@ describe("the primary user acts on anyone's ask, recorded as themselves on that expect(result.status).toBe(200); expect(repo.getById(sarahs.qitemId)).toMatchObject({ state: "done", humanAnswers: { go: "yes" } }); expect(audit(sarahs.qitemId).notes).toEqual({ authority: "primary-user", asked: "sarah@external" }); - expect(repo.list({ limit: 50 }).find((item) => item.replyTo === sarahs.qitemId)?.sourceSession).toBe("mike@external"); + expect(repo.list({ limit: 50 }).find((item) => item.replyTo === sarahs.qitemId)?.sourceSession).toBe("human@host"); }); - it("approves Sarah's pending ask and resolves a row parked on her, recorded as Mike on her ask", async () => { + it("approves Sarah's pending ask and resolves a row parked on her from the terminal, unattributed, on her ask", async () => { register("mike", "sarah"); expect(setDefaultPerson("mike", home).ok).toBe(true); const pending = await ask("sarah@external"); expect((await act({ verb: "approve", qitemId: pending.qitemId, actorSession: "mike@external", authority: "primary-user" })).status).toBe(200); - expect(lastTransition(pending.qitemId)).toMatchObject({ state: "done", actor_session: "mike@external" }); - expect(lastTransition(pending.qitemId).transition_note).toBe("mission-control:approve; by mike@external, the primary user at the terminal, on sarah@external's ask"); - expect(audit(pending.qitemId)).toEqual({ verb: "approve", actor: "mike@external", notes: { authority: "primary-user", asked: "sarah@external" } }); + expect(lastTransition(pending.qitemId)).toMatchObject({ state: "done", actor_session: "human@host" }); + expect(lastTransition(pending.qitemId).transition_note).toBe("mission-control:approve; by someone at the terminal OpenRig can't name (human@host), unattributed, on sarah@external's ask"); + expect(audit(pending.qitemId)).toEqual({ verb: "approve", actor: "human@host", notes: { authority: "primary-user", asked: "sarah@external" } }); const work = await repo.create({ sourceSession: ASKER, destinationSession: "designer@rig", body: "Layout", summary: "Layout", evidenceRef: "/fixture/layout", nudge: false }); await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: "sarah@external" } as never); expect((await act({ verb: "resolve", qitemId: work.qitemId, actorSession: "mike@external", authority: "primary-user", decision: "Use the wide layout" })).status).toBe(200); - expect(lastTransition(work.qitemId)).toMatchObject({ state: "in-progress", actor_session: "mike@external", - transition_note: "Use the wide layout; by mike@external, the primary user at the terminal, on sarah@external's ask" }); + expect(lastTransition(work.qitemId)).toMatchObject({ state: "in-progress", actor_session: "human@host", + transition_note: "Use the wide layout; by someone at the terminal OpenRig can't name (human@host), unattributed, on sarah@external's ask" }); expect(audit(work.qitemId).notes).toEqual({ authority: "primary-user", asked: "sarah@external" }); }); - it("resolves a row parked on a registered person's address, which the Feed lists, and only with the authority", async () => { + it("resolves a row parked on a registered person's address, which the Feed lists, with or without the authority", async () => { register("mike", "sarah"); expect(setDefaultPerson("mike", home).ok).toBe(true); - const work = await repo.create({ sourceSession: ASKER, destinationSession: "designer@rig", body: "Layout", summary: "Layout", evidenceRef: "/fixture/layout", nudge: false }); - await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: "sarah@external" } as never); - expect(await act({ verb: "resolve", qitemId: work.qitemId, actorSession: "mike@external", decision: "Wide" })).toMatchObject({ status: 400, body: { error: "answer_not_recorded" } }); - expect((await act({ verb: "resolve", qitemId: work.qitemId, actorSession: "mike@external", authority: "primary-user", decision: "Wide" })).status).toBe(200); - expect(lastTransition(work.qitemId)).toMatchObject({ state: "in-progress", actor_session: "mike@external", - transition_note: "Wide; by mike@external, the primary user at the terminal, on sarah@external's ask" }); + const parked = async () => { + const work = await repo.create({ sourceSession: ASKER, destinationSession: "designer@rig", body: "Layout", summary: "Layout", evidenceRef: "/fixture/layout", nudge: false }); + await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: "sarah@external" } as never); + return work; + }; + const mikes = await parked(); + expect((await act({ verb: "resolve", qitemId: mikes.qitemId, actorSession: "mike@external", decision: "Wide" })).status).toBe(200); + expect(lastTransition(mikes.qitemId)).toMatchObject({ state: "in-progress", actor_session: "mike@external", + transition_note: "Wide; by mike@external, a registered person, on sarah@external's ask" }); + const terminals = await parked(); + expect((await act({ verb: "resolve", qitemId: terminals.qitemId, actorSession: "mike@external", authority: "primary-user", decision: "Wide" })).status).toBe(200); + expect(lastTransition(terminals.qitemId)).toMatchObject({ state: "in-progress", actor_session: "human@host", + transition_note: "Wide; by someone at the terminal OpenRig can't name (human@host), unattributed, on sarah@external's ask" }); }); it("resolves a typed gate as the primary user with the gate's own note: no person was asked", async () => { @@ -119,18 +128,18 @@ describe("the primary user acts on anyone's ask, recorded as themselves on that await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: "external:qa-pass" } as never); expect((await act({ verb: "resolve", qitemId: work.qitemId, actorSession: "sarah@external", authority: "primary-user", decision: "QA passed" })).status).toBe(200); const transition = repo.transitionLog.listForQitem(work.qitemId).find((t) => t.transitionNote?.includes("QA passed")); - expect(transition).toMatchObject({ state: "in-progress", actorSession: "mike@external", transitionNote: "Blocker external:qa-pass resolved: QA passed" }); + expect(transition).toMatchObject({ state: "in-progress", actorSession: "human@host", transitionNote: "Blocker external:qa-pass resolved: QA passed" }); expect(transition!.ownerNotificationKind).not.toBe("human-decision-resolved"); - expect(audit(work.qitemId)).toEqual({ verb: "resolve", actor: "mike@external", notes: { authority: "primary-user", asked: null } }); + expect(audit(work.qitemId)).toEqual({ verb: "resolve", actor: "human@host", notes: { authority: "primary-user", asked: null } }); }); - it("approves a row parked on Sarah as the primary user, recorded on her ask", async () => { + it("approves a row parked on Sarah as the primary user, unattributed, on her ask", async () => { register("mike", "sarah"); expect(setDefaultPerson("mike", home).ok).toBe(true); const work = await repo.create({ sourceSession: ASKER, destinationSession: "designer@rig", body: "Layout", summary: "Layout", evidenceRef: "/fixture/layout", nudge: false }); await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: "sarah@external" } as never); expect((await act({ verb: "approve", qitemId: work.qitemId, actorSession: "mike@external", authority: "primary-user" })).status).toBe(200); - expect(audit(work.qitemId)).toEqual({ verb: "approve", actor: "mike@external", notes: { authority: "primary-user", asked: "sarah@external" } }); - expect(lastTransition(work.qitemId).transition_note).toBe("mission-control:approve; by mike@external, the primary user at the terminal, on sarah@external's ask"); + expect(audit(work.qitemId)).toEqual({ verb: "approve", actor: "human@host", notes: { authority: "primary-user", asked: "sarah@external" } }); + expect(lastTransition(work.qitemId).transition_note).toBe("mission-control:approve; by someone at the terminal OpenRig can't name (human@host), unattributed, on sarah@external's ask"); }); it("with several people and no default, the primary user is unnamed and recorded as the generic human seat", async () => { @@ -140,30 +149,31 @@ describe("the primary user acts on anyone's ask, recorded as themselves on that expect(audit(sarahs.qitemId)).toEqual({ verb: "approve", actor: "human@host", notes: { authority: "primary-user", asked: "sarah@external" } }); }); - it("a registered person without that authority can't settle another person's ask, pending or parked", async () => { + it("a registered person settles another person's ask, pending or parked, recorded as themselves on that person's ask", async () => { // A legacy seat address is a registered person's when its whole local part is their id. register("mike", "sarah", "human-ana"); - const pending = await ask("sarah@external"); for (const body of [{ verb: "approve" }, { verb: "deny", reason: "no" }, { verb: "answer", decision: "Ship it" }]) { - const refused = await act({ qitemId: pending.qitemId, actorSession: "mike@external", ...body }); - expect(refused).toMatchObject({ status: 400, body: { error: "answer_not_recorded" } }); + const pending = await ask("sarah@external"); + expect((await act({ qitemId: pending.qitemId, actorSession: "mike@external", ...body })).status, body.verb).toBe(200); + expect(repo.getById(pending.qitemId)?.state, body.verb).toBe("done"); + expect(audit(pending.qitemId), body.verb).toEqual({ verb: body.verb, actor: "mike@external", notes: { asked: "sarah@external" } }); } - expect(repo.getById(pending.qitemId)?.state).toBe("pending"); - // A row parked on a person, whoever owns it: resolve, approve and deny are all refused. - // The refusal names the person by their registered address, however the park spells it. + // A row parked on a person, whoever owns it: resolve, approve and deny each settle it as Mike. + // The record names the person by their registered address, however the park spells it. for (const [parkedOn, person] of [["human-ana@host", "human-ana@external"], ["sarah@external", "sarah@external"]]) { - const work = await repo.create({ sourceSession: ASKER, destinationSession: "designer@rig", body: "Layout", summary: "Layout", evidenceRef: "/fixture/layout", nudge: false }); - await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: parkedOn } as never); for (const body of [{ verb: "resolve", decision: "Wide" }, { verb: "approve" }, { verb: "deny", reason: "no" }]) { - expect(await act({ qitemId: work.qitemId, actorSession: "mike@external", ...body }), `${body.verb} on a park on ${parkedOn}`) - .toMatchObject({ status: 400, body: { error: "answer_not_recorded", asked: person } }); + const work = await repo.create({ sourceSession: ASKER, destinationSession: "designer@rig", body: "Layout", summary: "Layout", evidenceRef: "/fixture/layout", nudge: false }); + await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: parkedOn } as never); + expect((await act({ qitemId: work.qitemId, actorSession: "mike@external", ...body })).status, `${body.verb} on a park on ${parkedOn}`).toBe(200); + expect(audit(work.qitemId), `${body.verb} on a park on ${parkedOn}`).toEqual({ verb: body.verb, actor: "mike@external", notes: { asked: person } }); + expect(lastTransition(work.qitemId).transition_note, `${body.verb} on a park on ${parkedOn}`).toContain(`by mike@external, a registered person, on ${person}'s ask`); } - expect(repo.getById(work.qitemId)).toMatchObject({ state: "blocked", blockedOn: parkedOn }); } - // Sarah settles her own. - expect((await act({ verb: "approve", qitemId: pending.qitemId, actorSession: "sarah@external" })).status).toBe(200); - expect(audit(pending.qitemId)).toEqual({ verb: "approve", actor: "sarah@external", notes: null }); + // Sarah settles her own, with no note about anyone else. + const own = await ask("sarah@external"); + expect((await act({ verb: "approve", qitemId: own.qitemId, actorSession: "sarah@external" })).status).toBe(200); + expect(audit(own.qitemId)).toEqual({ verb: "approve", actor: "sarah@external", notes: null }); }); it("a seat's own transport identity is never replaced by the authority", async () => { @@ -173,7 +183,7 @@ describe("the primary user acts on anyone's ask, recorded as themselves on that expect(audit(sarahs.qitemId)).toEqual({ verb: "approve", actor: "orch@rig", notes: null }); }); - it("with no one registered, acts are recorded as the claimed actor, as before", async () => { + it("with no one registered, a legacy ask's own seat is answered as human@host, unattributed, as the terminal always is", async () => { const legacy = await ask("human@host"); expect((await act({ verb: "answer", qitemId: legacy.qitemId, actorSession: "human@host", authority: "primary-user", decision: "Yes" })).status).toBe(200); expect(audit(legacy.qitemId)).toEqual({ verb: "answer", actor: "human@host", notes: { authority: "primary-user", asked: "human@host" } }); @@ -215,13 +225,13 @@ describe("the primary user acts on anyone's ask, recorded as themselves on that const channels = await ask(CHANNEL); expect((await act({ verb: "approve", qitemId: channels.qitemId, actorSession: CHANNEL, authority: "primary-user" })).status).toBe(200); expect(lastTransition(channels.qitemId)).toMatchObject({ state: "done", actor_session: "human@host", - transition_note: `mission-control:approve; by human@host, the primary user at the terminal, on ${CHANNEL}'s ask` }); + transition_note: `mission-control:approve; by someone at the terminal OpenRig can't name (human@host), unattributed, on ${CHANNEL}'s ask` }); expect(audit(channels.qitemId)).toEqual({ verb: "approve", actor: "human@host", notes: { authority: "primary-user", asked: CHANNEL } }); const work = await parkedOnChannel(); expect((await act({ verb: "resolve", qitemId: work.qitemId, actorSession: CHANNEL, authority: "primary-user", decision: "Use the plan" })).status).toBe(200); expect(lastTransition(work.qitemId)).toMatchObject({ state: "in-progress", actor_session: "human@host", - transition_note: `Use the plan; by human@host, the primary user at the terminal, on ${CHANNEL}'s ask` }); + transition_note: `Use the plan; by someone at the terminal OpenRig can't name (human@host), unattributed, on ${CHANNEL}'s ask` }); }); it("without the authority, acting as the channel is refused and nothing changes", async () => { diff --git a/packages/daemon/test/prompt-blocked-alert.test.ts b/packages/daemon/test/prompt-blocked-alert.test.ts index 475611dbe..dc28b0cd9 100644 --- a/packages/daemon/test/prompt-blocked-alert.test.ts +++ b/packages/daemon/test/prompt-blocked-alert.test.ts @@ -14,6 +14,7 @@ import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.j import { tmuxAttachCommand } from "../src/adapters/tmux-server.js"; import { shellQuote } from "../src/adapters/shell-quote.js"; import { makeKernelHookReviewReader } from "../src/domain/kernel-hook-review.js"; +import { verifyCodexPaneProcess, type NativeProcessRow } from "../src/domain/native-process-lineage.js"; import { runWakeLadderTick, queueRecoveryOwnsWake, classifyPromptAfterRefusal } from "../src/domain/queue-wake-ladder.js"; import { makeOperatorDeliveryEngine } from "../src/domain/gateway/operator-delivery-engine.js"; import { buildSlackGatewayWire } from "../src/domain/gateway/slack/slack-subsystem.js"; @@ -528,6 +529,7 @@ describe("prompt-blocked outstanding work reaches the configured route", () => { let pane = "%17"; let changeBinding = false; const tmux = { + getPanePid: vi.fn(async () => 123), listPanes: vi.fn(async () => [{ id: pane, index: 0, cwd: "/fixture", width: 80, height: 24, active: true }]), readAllPaneProcesses: vi.fn(async () => new Map([["%17", { pid: 123, command: "codex", dead }]])), capturePaneScreen: vi.fn(async () => { @@ -560,6 +562,69 @@ describe("prompt-blocked outstanding work reaches the configured route", () => { expect(posts).toHaveLength(1); }); + function shellHookReaderFixture() { + const rigs = new RigRepository(db); const sessions = new SessionRegistry(db); + const rig = rigs.createRig("kernel"); const node = rigs.addNode(rig.id, "operator", { runtime: "codex" }); + const destination = "operator@kernel"; + const session = sessions.registerSession(node.id, destination); sessions.updateStatus(session.id, "running"); + sessions.updateBinding(node.id, { attachmentType: "tmux", tmuxSession: destination, tmuxPane: "%17" }); + const hookScreen = "OpenAI Codex (v0.164.0)\nHooks need review\nTrust all and continue"; + let screen = hookScreen; + let panePid = 123, nativePid = 126; + const processes = (): NativeProcessRow[] => [ + { pid: panePid, ppid: 1, pgid: panePid, tpgid: 124, executableName: "zsh", command: "-zsh", startedAt: "start" }, + { pid: 124, ppid: panePid, pgid: 124, tpgid: 124, executableName: "sh", command: "/bin/sh /fixture/start.sh", startedAt: "start" }, + { pid: 125, ppid: 124, pgid: 124, tpgid: 124, executableName: "node", command: "node /opt/bin/codex", startedAt: "start" }, + { pid: nativePid, ppid: 125, pgid: 124, tpgid: 124, executableName: "codex", command: "/opt/native/codex", + argv: ["/opt/native/codex"], startedAt: "start" }, + ]; + const tmux = { + getPanePid: vi.fn(async () => panePid), + listPanes: vi.fn(async () => [{ id: "%17", index: 0, cwd: "/fixture", width: 80, height: 24, active: true }]), + readAllPaneProcesses: vi.fn(async () => new Map([["%17", { pid: panePid, command: "sh", dead: false }]])), + capturePaneScreen: vi.fn(async () => screen), + }; + const listProcesses = vi.fn(async () => processes()); const now = new Date("2030-01-01T00:00:00Z"); + const deps = { db, tmux, listProcesses, now: () => now }; + return { tmux, listProcesses, processes, now, destination, hookScreen, read: makeKernelHookReviewReader(deps), + screen: (value: string) => { screen = value; }, changeNative: () => { nativePid++; }, + changePanePid: () => { panePid += 1000; }, changeBinding: () => sessions.updateBinding(node.id, { tmuxPane: "%18" }) }; + } + + it("escalates a verified shell-backed kernel Codex and leaves its alert unresolved while lineage is unknown", async () => { + const f = shellHookReaderFixture(); + expect(await verifyCodexPaneProcess({ target: "%17", tmux: f.tmux, listProcesses: f.listProcesses })).not.toBeNull(); + expect((await f.read())[0]?.state).toBe("blocked"); + const posts: unknown[] = []; const port = engine(posts); + await tick(port, { now: f.now, readHookReviews: f.read }); + await vi.waitFor(() => expect(posts).toHaveLength(1)); + expect(alerts()[0]!.body).toContain(f.destination); + f.screen("OpenAI Codex (v0.164.0)\nmodel: gpt-5\n› "); + f.listProcesses.mockResolvedValue([]); + expect((await f.read())[0]?.state).toBe("unknown"); + await tick(port, { now: f.now, readHookReviews: f.read }); + expect(alerts()).toHaveLength(1); expect(posts).toHaveLength(1); + f.listProcesses.mockImplementation(async () => f.processes()); + expect((await f.read())[0]?.state).toBe("clear"); + await tick(port, { now: f.now, readHookReviews: f.read }); + expect(alerts()).toHaveLength(0); + }); + + it.each(["missing", "unavailable", "changing", "capture-change", "pane-change", "binding-change"])( + "a shell-backed kernel observation stays unknown without a stable chain: %s", async kind => { + const f = shellHookReaderFixture(); + if (kind === "missing") f.listProcesses.mockResolvedValue([]); + if (kind === "unavailable") f.listProcesses.mockRejectedValue(new Error("unavailable process table")); + if (kind === "changing") f.listProcesses.mockImplementation(async () => { f.changeNative(); return f.processes(); }); + if (kind === "capture-change") f.tmux.capturePaneScreen.mockImplementation(async () => { f.changeNative(); return f.hookScreen; }); + if (kind === "pane-change") f.tmux.capturePaneScreen.mockImplementation(async () => { f.changePanePid(); return f.hookScreen; }); + if (kind === "binding-change") f.tmux.capturePaneScreen.mockImplementation(async () => { f.changeBinding(); return f.hookScreen; }); + expect((await f.read())[0]?.state).toBe("unknown"); + const posts: unknown[] = []; + await tick(engine(posts), { now: f.now, readHookReviews: f.read }); + expect(alerts()).toHaveLength(0); expect(posts).toHaveLength(0); + }); + it("several rows share one alert, persisted acceptance and receipt across reconstructed ticks", async () => { await refusal(3); const posts: unknown[] = []; const port = engine(posts); expect((await tick(port)).outcome).not.toBe("failed"); diff --git a/packages/daemon/test/queue-stuck-sweep.test.ts b/packages/daemon/test/queue-stuck-sweep.test.ts index 8325ede2f..df22b888f 100644 --- a/packages/daemon/test/queue-stuck-sweep.test.ts +++ b/packages/daemon/test/queue-stuck-sweep.test.ts @@ -2034,8 +2034,9 @@ describe.each(["done", "handed-off"] as const)("#318 seat-target closure without describe("#514 unclaimed sweep and a pending ask posted to a registered human", () => { const OWNER = { entityId: "human-owner", class: "human", displayName: "Owner", address: "human-owner@external", connectorBindings: [{ connector: "slack", ref: "U0OWNER", primary: true }], prefs: {} } as unknown as HumanFragment; + // Each is reached in Slack, as a posted receipt needs: nothing is posted to a person with no connector binding. const human = (entityId: string) => ({ entityId, class: "human", displayName: entityId, address: `${entityId}@external`, - connectorBindings: [], prefs: {} }) as unknown as HumanFragment; + connectorBindings: [{ connector: "slack", ref: `U-${entityId}`, primary: true }], prefs: {} }) as unknown as HumanFragment; const ALPHA = human("human-alpha"); const BETA = human("human-beta"); // A registered human whose id has no human- prefix: its canonical alias (owner@kernel) cannot carry an intent. diff --git a/packages/daemon/test/seat-handover-service.test.ts b/packages/daemon/test/seat-handover-service.test.ts index 7de2a0c86..e8856762b 100644 --- a/packages/daemon/test/seat-handover-service.test.ts +++ b/packages/daemon/test/seat-handover-service.test.ts @@ -202,8 +202,9 @@ describe("SeatHandoverService", () => { }, sideEffects: { departingSessionKilled: false, + departingProcess: "not-touched", startupContextDelivered: false, - provenanceRecordWritten: false, + provenanceRecordWritten: true, }, }); expect(result.result.previousSessionIdsSuperseded).toContain(sessionId); @@ -846,6 +847,32 @@ describe("SeatHandoverService", () => { expect(binding.codexPermissions).toEqual(own); }); + it.each(["minted", "mint fails"] as const)("a rebuild handover's receipt reads its lineage record back (%s)", async (mint) => { + const { node } = seedSeat(); + // The tenure mint is best-effort: a failed one is logged and swallowed, and the handover still completes. + const failing = mint === "mint fails" + ? [vi.spyOn(sessionRegistry, "mintOccupantTenure").mockImplementation(() => { throw new Error("ledger unavailable"); }), + vi.spyOn(console, "warn").mockImplementation(() => {})] + : []; + try { + const result = await service.handover({ seatRef: "dev-impl@seat-rig", source: "rebuild", reason: "context wall" }); + expect(result).toMatchObject({ ok: true, result: { sideEffects: { provenanceRecordWritten: mint === "minted" } } }); + if (mint === "minted") expect(sessionRegistry.currentOccupantTenure(node.id)).toMatchObject({ kind: "handover" }); + } finally { + for (const spy of failing) spy.mockRestore(); + } + }); + + it.each([["graceful", "ended-graceful"], ["forced", "ended-forced"]] as const)( + "a rebuild handover's receipt says how the departing process ended (%s)", async (exit, reported) => { + seedSeat(); + // The retiree exits on TERM, or only once KILL follows the TERM window. + if (exit === "forced") isPaneDead.mockImplementation(async () => signalPaneProcess.mock.calls.some((call) => call[1] === "KILL")); + const result = await service.handover({ seatRef: "dev-impl@seat-rig", source: "rebuild", reason: "context wall" }); + expect(result).toMatchObject({ ok: true, result: { sideEffects: { departingSessionKilled: false, departingProcess: reported } } }); + expect(signalPaneProcess.mock.calls.some((call) => call[1] === "KILL")).toBe(exit === "forced"); + }); + it.each(["observed", "unverified", "changed selection", "cleared selection", "profile change", "older selection"])( "rebuild permission result: %s", async state => { const { node } = seedSeat(state === "profile change" ? { codexConfigProfile: "next-profile" } : undefined); diff --git a/packages/daemon/test/shared-workspace-humans.test.ts b/packages/daemon/test/shared-workspace-humans.test.ts new file mode 100644 index 000000000..de0325134 --- /dev/null +++ b/packages/daemon/test/shared-workspace-humans.test.ts @@ -0,0 +1,274 @@ +// One trusted workspace, distinct people with equal authority: any registered person settles an ask that went to +// another, recorded as themselves with the asked person kept, exactly once; the terminal's act, which names no +// responder, is unattributed rather than credited to the default person; and a person with no connector binding is +// registered, asked in the Feed and Steering, and never claimed as reached in Slack. Real SQLite (all migrations), +// people registered in a temporary home, and the real Mission Control route and write contract. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type Database from "better-sqlite3"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Hono } from "hono"; +import { createDb } from "../src/db/connection.js"; +import { migrate } from "../src/db/migrate.js"; +import { ALL_MIGRATIONS } from "../src/db/all-migrations.js"; +import { EventBus } from "../src/domain/event-bus.js"; +import { QueueRepository } from "../src/domain/queue-repository.js"; +import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js"; +import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js"; +import { missionControlRoutes } from "../src/routes/mission-control.js"; +import { attentionRoutes } from "../src/routes/attention.js"; +import { addHumanFragment, humansDir, loadHumanRegistry, writeProjection } from "../src/domain/gateway/human-registry.js"; +import { resolveHumanDeliveryReadiness } from "../src/domain/gateway/human-readiness.js"; +import { makeQueuePorts } from "../src/domain/gateway/slack/queue-access.js"; +import { makeHumanReplyResolver } from "../src/domain/gateway/slack/slack-subsystem.js"; + +const ASKER = "writer@rig"; + +describe("shared-workspace people", () => { + let db: Database.Database; + let repo: QueueRepository; + let home: string; + let app: Hono; + let contract: MissionControlWriteContract; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "shared-workspace-humans-")); + vi.stubEnv("OPENRIG_HOME", home); + db = createDb(); + migrate(db, ALL_MIGRATIONS); + const bus = new EventBus(db); + repo = new QueueRepository(db, bus, { validateRig: () => true }); + contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) }); + app = new Hono(); + app.use("*", async (c, next) => { c.set("queueRepo" as never, repo as never); c.set("eventBus" as never, bus as never); c.set("missionControlWriteContract" as never, contract as never); await next(); }); + app.route("/api/mission-control", missionControlRoutes()); + }); + afterEach(() => { vi.unstubAllEnvs(); db.close(); rmSync(home, { recursive: true, force: true }); }); + + const register = (...ids: string[]) => { + for (const entityId of ids) expect(addHumanFragment({ + entityId, class: "human", displayName: entityId, address: `${entityId}@external`, + connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: `vault://slack/${entityId}`, role: "primary", handle: `U${entityId.toUpperCase()}` }], + prefs: { deliveryClass: "B" }, + }, home)).toMatchObject({ ok: true }); + }; + const ask = (to: string, extra: Record = {}) => repo.create({ sourceSession: ASKER, destinationSession: to, body: "Ship the draft?", + summary: "Ship the draft?", humanIntent: "decision", evidenceRef: "/fixture/ask", nudge: false, ...extra } as never); + async function act(body: Record) { + const response = await app.request("/api/mission-control/action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + return { status: response.status, body: await response.json() as Record }; + } + const transitions = (qitemId: string) => db.prepare("SELECT state, actor_session, transition_note FROM queue_transitions WHERE qitem_id = ? ORDER BY transition_id") + .all(qitemId) as Array<{ state: string; actor_session: string; transition_note: string }>; + const audit = (qitemId: string) => { + const row = db.prepare("SELECT action_verb, actor_session, audit_notes_json FROM mission_control_actions WHERE qitem_id = ? ORDER BY rowid DESC LIMIT 1") + .get(qitemId) as { action_verb: string; actor_session: string; audit_notes_json: string | null }; + return { verb: row.action_verb, actor: row.actor_session, notes: row.audit_notes_json ? JSON.parse(row.audit_notes_json) : null }; + }; + const replies = (qitemId: string) => repo.list({ limit: 100 }).filter((item) => item.replyTo === qitemId); + + describe("any registered person settles an ask that went to another, recorded as themselves", () => { + it("Bob answers Sarah's ask in his words: recorded as Bob on Sarah's ask, and the asker reads exactly that", async () => { + register("sarah", "bob"); + const sarahs = await ask("sarah@external"); + const result = await act({ verb: "answer", qitemId: sarahs.qitemId, actorSession: "bob@external", decision: "Ship it" }); + expect(result.status).toBe(200); + expect(repo.getById(sarahs.qitemId)?.state).toBe("done"); + expect(transitions(sarahs.qitemId).at(-1)).toMatchObject({ state: "done", actor_session: "bob@external" }); + expect(transitions(sarahs.qitemId).at(-1)!.transition_note) + .toContain("reply from bob@external, a registered person, on sarah@external's ask; not answered by sarah@external"); + expect(audit(sarahs.qitemId)).toEqual({ verb: "answer", actor: "bob@external", notes: { asked: "sarah@external" } }); + const [reply] = replies(sarahs.qitemId); + expect(reply).toMatchObject({ sourceSession: "bob@external", destinationSession: ASKER, summary: "bob@external answered sarah@external's ask: Ship the draft?" }); + expect(reply!.body).toContain("Answered by bob@external, a registered person, on sarah@external's ask in OpenRig."); + }); + + it("Bob approves Sarah's pending ask and resolves a row parked on her, recorded as Bob on her ask", async () => { + register("sarah", "bob"); + const pending = await ask("sarah@external"); + expect((await act({ verb: "approve", qitemId: pending.qitemId, actorSession: "bob@external" })).status).toBe(200); + expect(transitions(pending.qitemId).at(-1)).toMatchObject({ state: "done", actor_session: "bob@external", + transition_note: "mission-control:approve; by bob@external, a registered person, on sarah@external's ask" }); + expect(audit(pending.qitemId)).toEqual({ verb: "approve", actor: "bob@external", notes: { asked: "sarah@external" } }); + + const work = await repo.create({ sourceSession: ASKER, destinationSession: "designer@rig", body: "Layout", summary: "Layout", evidenceRef: "/fixture/layout", nudge: false }); + await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: "sarah@external" } as never); + expect((await act({ verb: "resolve", qitemId: work.qitemId, actorSession: "bob@external", decision: "Use the wide layout" })).status).toBe(200); + expect(transitions(work.qitemId).at(-1)).toMatchObject({ state: "in-progress", actor_session: "bob@external", + transition_note: "Use the wide layout; by bob@external, a registered person, on sarah@external's ask" }); + }); + + it("settles once: answering at the same time, one answer returns and closes it, and the other is told who answered", async () => { + register("sarah", "bob"); + const sarahs = await ask("sarah@external"); + const results = await Promise.all([ + act({ verb: "answer", qitemId: sarahs.qitemId, actorSession: "sarah@external", decision: "Ship it" }), + act({ verb: "answer", qitemId: sarahs.qitemId, actorSession: "bob@external", decision: "Hold it" }), + ]); + expect(results.filter((r) => r.status === 200)).toHaveLength(1); + const refused = results.find((r) => r.status !== 200)!; + expect(refused).toMatchObject({ status: 409, body: { error: "qitem_already_terminal" } }); + expect(replies(sarahs.qitemId)).toHaveLength(1); + expect(transitions(sarahs.qitemId).filter((t) => t.state === "done")).toHaveLength(1); + // A later answer settles nothing and sends nothing. + expect(await act({ verb: "answer", qitemId: sarahs.qitemId, actorSession: "bob@external", decision: "Hold it" })) + .toMatchObject({ status: 409, body: { error: "qitem_already_terminal" } }); + expect(replies(sarahs.qitemId)).toHaveLength(1); + }); + + // One answer per ask on every surface, including a process stopped between a committed answer and the close. + const ports = () => makeQueuePorts(repo, { loadHumanRegistry: () => loadHumanRegistry(home), sharedChannels: () => [] }); + const slackAnswer = (askId: string, who: string, words: string) => ports().createQitem({ qitemId: `qitem-slack-inbound-${who.split("@")[0]}-${askId}`, + source: who, destination: ASKER, summary: words, body: words, inReplyTo: askId, humanAnswer: true }); + + it("Bob's Slack answer committed before Sarah answers in the Feed: Sarah is told Bob answered, and it settles once, as Bob's", async () => { + register("sarah", "bob"); + const sarahs = await ask("sarah@external"); + await slackAnswer(sarahs.qitemId, "bob@external", "Hold it"); // the process stops before Slack's close runs + expect(await act({ verb: "answer", qitemId: sarahs.qitemId, actorSession: "sarah@external", decision: "Ship it" })) + .toMatchObject({ status: 409, body: { error: "qitem_already_terminal", answeredBy: "bob@external" } }); + expect(transitions(sarahs.qitemId).at(-1)).toMatchObject({ state: "done", actor_session: "bob@external" }); + expect(replies(sarahs.qitemId).map((r) => [r.sourceSession, r.body])).toEqual([["bob@external", "Hold it"]]); + }); + + it("Sarah's Feed answer committed before Bob's Slack reply: Bob's words stay conversation, and it settles once, as Sarah's", async () => { + register("sarah", "bob"); + const sarahs = await ask("sarah@external"); + // Sarah's return committed, then the process stopped before the close. + await repo.createHumanAnswer({ sourceSession: "sarah@external", destinationSession: ASKER, body: "Ship it", summary: "Ship it", + inboundReplyTo: sarahs.qitemId, nudge: false } as never); + const bobs = await slackAnswer(sarahs.qitemId, "bob@external", "Hold it"); + expect(await makeHumanReplyResolver(repo, contract)({ qitemId: sarahs.qitemId, actorSession: "bob@external", decision: "Hold it", + messageTs: "2.2", typedReply: true } as never)).toBe("not-applicable"); + expect(transitions(sarahs.qitemId).at(-1)).toMatchObject({ state: "done", actor_session: "sarah@external" }); + const answers = replies(sarahs.qitemId).filter((r) => r.tags?.includes("human-answer")); + expect(answers.map((r) => r.sourceSession)).toEqual(["sarah@external"]); + expect(repo.getById(bobs)).toMatchObject({ sourceSession: "bob@external", body: "Hold it" }); + }); + + it("Sarah's typed answer committed before Bob picks an option: Bob's choice isn't recorded, and it settles as Sarah's", async () => { + register("sarah", "bob"); + const sarahs = await ask("sarah@external", { humanQuestions: [{ id: "go", question: "Ship?", options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] }] }); + await repo.createHumanAnswer({ sourceSession: "sarah@external", destinationSession: ASKER, body: "Do not ship", summary: "Do not ship", + inboundReplyTo: sarahs.qitemId, nudge: false } as never); + expect(await act({ verb: "answer", qitemId: sarahs.qitemId, actorSession: "bob@external", questionId: "go", optionId: "yes" })) + .toMatchObject({ status: 409, body: { error: "qitem_already_terminal", answeredBy: "sarah@external" } }); + const closed = repo.getById(sarahs.qitemId)!; + expect(closed.state).toBe("done"); + expect(closed.humanAnswers?.go).not.toBe("yes"); + expect(transitions(sarahs.qitemId).at(-1)).toMatchObject({ state: "done", actor_session: "sarah@external" }); + expect(replies(sarahs.qitemId).map((r) => r.sourceSession)).toEqual(["sarah@external"]); + }); + + it("a person answering their own ask through the same path is recorded as themselves", async () => { + register("sarah"); + const sarahs = await ask("sarah@external"); + expect((await act({ verb: "answer", qitemId: sarahs.qitemId, actorSession: "sarah@external", decision: "Ship it" })).status).toBe(200); + expect(transitions(sarahs.qitemId).at(-1)).toMatchObject({ state: "done", actor_session: "sarah@external" }); + expect(replies(sarahs.qitemId)[0]?.sourceSession).toBe("sarah@external"); + }); + }); + + describe("the terminal's act names no responder, so it's unattributed", () => { + it("is recorded as human@host, explicitly unattributed, never the default person, even with one registered person", async () => { + register("sarah"); + const sarahs = await ask("sarah@external"); + // The Feed serves the asked person's address as the actor, and the TUI sends it with the authority: that's whose ask + // it is, not who typed. + const result = await act({ verb: "answer", qitemId: sarahs.qitemId, actorSession: "sarah@external", authority: "primary-user", decision: "Ship it" }); + expect(result.status).toBe(200); + expect(transitions(sarahs.qitemId).at(-1)).toMatchObject({ state: "done", actor_session: "human@host" }); + expect(transitions(sarahs.qitemId).at(-1)!.transition_note) + .toContain("reply from someone at the terminal OpenRig can't name (human@host), unattributed, on sarah@external's ask; not answered by sarah@external"); + expect(audit(sarahs.qitemId)).toEqual({ verb: "answer", actor: "human@host", notes: { authority: "primary-user", asked: "sarah@external" } }); + const [reply] = replies(sarahs.qitemId); + expect(reply).toMatchObject({ sourceSession: "human@host", summary: "Unattributed (human@host) answered sarah@external's ask: Ship the draft?" }); + expect(reply!.body).toContain("Answered at the terminal by someone OpenRig can't name (human@host), unattributed, on sarah@external's ask in OpenRig."); + }); + }); + + it("with no one registered, the terminal's act is still unattributed, whatever address it claims", async () => { + const legacy = await ask("human-review@kernel"); + expect((await act({ verb: "approve", qitemId: legacy.qitemId, actorSession: "human-review@kernel", authority: "primary-user" })).status).toBe(200); + expect(transitions(legacy.qitemId).at(-1)).toMatchObject({ state: "done", actor_session: "human@host", + transition_note: "mission-control:approve; by someone at the terminal OpenRig can't name (human@host), unattributed, on human-review@kernel's ask" }); + }); + + describe("a person with no connector binding", () => { + const dana = { entityId: "dana", class: "human" as const, displayName: "Dana", address: "dana@external", connectorBindings: [], prefs: { deliveryClass: "B" as const } }; + + it("an open ask to them owes no Slack post, so it never reads never-posted or undelivered; one to a Slack person does", async () => { + register("sarah"); + expect(addHumanFragment(dana, home)).toMatchObject({ ok: true }); + const danas = await ask("dana@external"); + const sarahs = await ask("sarah@external"); + vi.useFakeTimers({ now: Date.now() + 10 * 60_000, toFake: ["Date"] }); + try { + expect(repo.deliveryOutcomeFor(sarahs.qitemId)?.outcome).toBe("never-posted"); + expect(repo.deliveryOutcomeFor(danas.qitemId)).toBeNull(); + expect(repo.findUndelivered().map((item) => item.qitemId)).not.toContain(danas.qitemId); + } finally { + vi.useRealTimers(); + } + }); + + it("registers and loads beside people with Slack, and a hand-written fragment with no bindings loads too", () => { + register("sarah"); + expect(addHumanFragment(dana, home)).toMatchObject({ ok: true }); + mkdirSync(humansDir(home), { recursive: true }); + writeFileSync(join(humansDir(home), "eli.yaml"), "entityId: eli\nclass: human\ndisplayName: Eli\naddress: eli@external\nprefs:\n deliveryClass: B\n"); + // A hand-written fragment joins the registry through the supported re-projection. + expect(writeProjection(home)).toMatchObject({ ok: true }); + const loaded = loadHumanRegistry(home); + expect(loaded.ok).toBe(true); + if (!loaded.ok) return; + expect(loaded.entities.map((e) => [e.entityId, e.connectorBindings.length]).sort()).toEqual([["dana", 0], ["eli", 0], ["sarah", 1]]); + }); + + it("is asked in the Feed and answers there, and nothing posts or claims Slack delivery for them", async () => { + register("sarah"); + expect(addHumanFragment(dana, home)).toMatchObject({ ok: true }); + // A decision parked on each person: Slack's open asks list Sarah's and never Dana's. + const parkOn = async (person: string) => { + const row = await repo.create({ sourceSession: ASKER, destinationSession: "designer@rig", body: `Choose for ${person}`, nudge: false }); + repo.update({ qitemId: row.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: person, summary: `Choose for ${person}`, + evidenceRef: "/fixture/choose", transitionNote: "parked" } as never); + return row; + }; + await parkOn("dana@external"); + const sarahsPark = await parkOn("sarah@external"); + const open = await makeQueuePorts(repo, { loadHumanRegistry: () => loadHumanRegistry(home), sharedChannels: () => [] }).listOpenAsks({ minimumLevel: "NOTICE" }); + expect(open.map((o) => o.item.qitemId)).toEqual([sarahsPark.qitemId]); + const danas = await ask("dana@external"); + const loaded = loadHumanRegistry(home); + const record = loaded.ok ? loaded.entities.find((e) => e.entityId === "dana")! : null; + const readiness = await resolveHumanDeliveryReadiness({ human: record!, gatewayState: "active", botToken: "xoxb-fixture", + config: { enabled: true, channel: "CFIXTURE", outboundDestinations: [], requiredScopes: [] } as never }); + expect(readiness).toMatchObject({ state: "not-ready", ready: false, configured: false }); + expect(readiness.connector).toBeUndefined(); + expect(readiness.reason).toContain("Feed and Steering"); + expect((await act({ verb: "answer", qitemId: danas.qitemId, actorSession: "dana@external", decision: "Ship it" })).status).toBe(200); + expect(repo.getById(danas.qitemId)?.state).toBe("done"); + }); + + it("isn't told in the Feed to follow an ask or a park in Slack, and a Slack person still is", async () => { + register("sarah"); + expect(addHumanFragment(dana, home)).toMatchObject({ ok: true }); + app.route("/api/attention", attentionRoutes()); + const feed = async (qitemId: string) => ((await (await app.request(`/api/attention?item=${encodeURIComponent(`queue:${qitemId}`)}`)).json()) as + { detail: { lines: string[] } }).detail.lines; + const park = await repo.create({ sourceSession: ASKER, destinationSession: "designer@rig", body: "Choose for Dana", nudge: false }); + repo.update({ qitemId: park.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: "dana@external", summary: "Choose for Dana", + evidenceRef: "/fixture/choose", transitionNote: "parked" } as never); + for (const row of [await ask("dana@external"), park]) { + const lines = await feed(row.qitemId); + expect(lines).toContain("Slack: not sent: this person has no connector binding, so they're asked on Steering and in the Feed only"); + expect(lines.some((line) => line.startsWith("Decision route: answer on Steering; it wasn't sent to Slack."))).toBe(true); + } + const sarahs = await feed((await ask("sarah@external")).qitemId); + expect(sarahs.some((line) => line.startsWith("Decision route: follow the correlated human request in Slack."))).toBe(true); + expect(sarahs.find((line) => line.startsWith("Slack: "))).not.toContain("no connector binding"); + }); + }); +}); diff --git a/packages/daemon/test/slack-reply-from-another-person.test.ts b/packages/daemon/test/slack-reply-from-another-person.test.ts index 9821f2e01..4e4d04029 100644 --- a/packages/daemon/test/slack-reply-from-another-person.test.ts +++ b/packages/daemon/test/slack-reply-from-another-person.test.ts @@ -10,8 +10,8 @@ import { MissionControlWriteContract } from "../src/domain/mission-control/missi import { makeHumanReplyResolver } from "../src/domain/gateway/slack/slack-subsystem.js"; import type { HumanFragment, LoadResult } from "../src/domain/gateway/human-registry.js"; -// A reply that comes through the gateway keeps its sender's identity: a different registered person's Slack reply to a -// question parked on someone else reaches the agent as their own message and doesn't resume the question. +// A reply that comes through the gateway keeps its sender's identity: in one trusted workspace, a different registered +// person's Slack reply to a question parked on someone else resumes it, recorded as them on that person's ask. // Resolving from a terminal keeps that same registered-person identity. const person = (entityId: string): HumanFragment => ({ @@ -54,12 +54,65 @@ describe("a Slack reply from someone other than the person a question is parked const slackReply = (qitemId: string, actorSession: string) => makeHumanReplyResolver(repo, contract)({ qitemId, actorSession, decision: "go ahead", messageTs: "2.2" }); - it("doesn't resume the question: it stays parked on its person and nothing is written to it", async () => { + it("resumes the question, recorded as Mike on Ana's ask", async () => { const parked = await parkOnAna(); - const before = repo.listTransitions(parked.qitemId).length; - expect(await slackReply(parked.qitemId, MIKE)).toBe("not-applicable"); - expect(repo.getById(parked.qitemId)).toMatchObject({ state: "blocked", blockedOn: "human-ana@host" }); - expect(repo.listTransitions(parked.qitemId)).toHaveLength(before); + expect(await slackReply(parked.qitemId, MIKE)).toBe("resolved"); + expect(repo.getById(parked.qitemId)?.state).toBe("in-progress"); + const resolved = repo.listTransitions(parked.qitemId).filter((t) => t.ownerNotificationKind === "human-decision-resolved"); + expect(resolved.map((t) => [t.actorSession, t.transitionNote])) + .toEqual([[MIKE, `go ahead; by ${MIKE}, a registered person, on ${ANA}'s ask`]]); + }); + + it("recognizes a replay of Mike's uncertain resume, so its timing notice is retried rather than dropped", async () => { + // Parked on Ana and posted to her in a digest, so a reply's order against the question can't be confirmed. + const parked = await repo.create({ sourceSession: "asker@r", destinationSession: "dev@r", body: "work", nudge: false }); + repo.update({ qitemId: parked.qitemId, actorSession: "dev@r", state: "blocked", blockedOn: "human-ana@host", summary: "Parked", + evidenceRef: "/proof/parked.md", transitionNote: "parked on Ana" }); + const episode = repo.listTransitions(parked.qitemId).filter((t) => t.ownerNotificationKind === "human-required").at(-1)!; + repo.update({ qitemId: parked.qitemId, actorSession: "openrig-slack", + transitionNote: `slack-owner-notification-posted notification_key=${parked.qitemId}:${episode.transitionId} kind=human-required thread_ts=1.1 message_ts=1.5 digest=d1` }); + expect(await slackReply(parked.qitemId, MIKE)).toBe("resolved-uncertain"); + expect(await slackReply(parked.qitemId, MIKE)).toBe("resolved-uncertain"); + expect(repo.listTransitions(parked.qitemId).filter((t) => t.ownerNotificationKind === "human-decision-resolved")).toHaveLength(1); + }); + + /** An ask to Ana with a question, parked by its asker on Ana and posted to her; Ana's typed answer is committed under + * this park's answer identity, then execution stops before the resume. */ + const interruptedPark = async () => { + const asked = await repo.create({ sourceSession: "asker@r", destinationSession: ANA, body: "Which day?", summary: "Which day?", + humanIntent: "decision", evidenceRef: "/fixture/day", nudge: false, + humanQuestions: [{ id: "day", question: "Which day?", options: [{ id: "mon", label: "Monday" }, { id: "tue", label: "Tuesday" }] }] }); + repo.update({ qitemId: asked.qitemId, actorSession: "asker@r", state: "blocked", blockedOn: ANA, transitionNote: "parked on Ana" }); + const episode = repo.listTransitions(asked.qitemId).filter((t) => t.ownerNotificationKind === "human-required").at(-1)!; + repo.update({ qitemId: asked.qitemId, actorSession: "openrig-slack", + transitionNote: `slack-owner-notification-posted notification_key=${asked.qitemId}:${episode.transitionId} kind=human-required thread_ts=1.1 message_ts=1.5` }); + await repo.createHumanAnswer({ sourceSession: ANA, destinationSession: "asker@r", body: "Wednesday works", summary: "Wednesday works", + inboundReplyTo: asked.qitemId, episodeKey: repo.currentHumanRequiredEpisode(asked.qitemId)!.key, nudge: false } as never); + return asked; + }; + const resolvedBy = (qitemId: string) => repo.listTransitions(qitemId).filter((t) => t.ownerNotificationKind === "human-decision-resolved").map((t) => t.actorSession); + + it("an interrupted park resumes with the committed answer, as its author's, and stores its typed words", async () => { + const asked = await interruptedPark(); + expect(await slackReply(asked.qitemId, MIKE)).toBe("not-applicable"); + const resumed = repo.getById(asked.qitemId)!; + expect(resumed).toMatchObject({ state: "in-progress", blockedOn: null }); + expect(resumed.humanAnswers?.day).toMatchObject({ kind: "typed-reply", text: "Wednesday works" }); + expect(resolvedBy(asked.qitemId)).toEqual([ANA]); + }); + + it("a click on an interrupted park records nothing, and the park resumes with the committed answer, as its author's", async () => { + const asked = await interruptedPark(); + // Mike clicks a valid option after the question posted: the click path records it, then resumes through the resolver. + expect(repo.recordHumanAnswer({ qitemId: asked.qitemId, actorSession: MIKE, questionId: "day", optionId: "mon", clickedTs: "2.2" })) + .toMatchObject({ status: "not-applicable", answeredBy: ANA }); + await makeHumanReplyResolver(repo, contract)({ qitemId: asked.qitemId, actorSession: MIKE, decision: "", messageTs: "2.2" }); + const resumed = repo.getById(asked.qitemId)!; + expect(resumed).toMatchObject({ state: "in-progress", blockedOn: null }); + expect(resumed.humanAnswers?.day).toMatchObject({ kind: "typed-reply", text: "Wednesday works" }); + expect(resolvedBy(asked.qitemId)).toEqual([ANA]); + expect(repo.list({ limit: 100 }).filter((item) => item.replyTo === asked.qitemId && (item.tags ?? []).includes("human-answer")) + .map((item) => item.sourceSession)).toEqual([ANA]); }); it("the person it's parked on still resumes it from Slack", async () => { @@ -70,17 +123,12 @@ describe("a Slack reply from someone other than the person a question is parked .toEqual([ANA]); }); - it("a different registered person cannot resolve the question from a terminal either", async () => { + it("a different registered person resolves the question from a terminal too, as themselves on Ana's ask", async () => { const parked = await parkOnAna(); - const before = repo.getById(parked.qitemId); - const history = repo.listTransitions(parked.qitemId); - const audit = log.listForQitem(parked.qitemId); - send.mockClear(); - await expect(contract.act({ verb: "resolve", qitemId: parked.qitemId, actorSession: MIKE, decision: "go ahead", notify: true })) - .rejects.toMatchObject({ code: "answer_not_recorded" }); - expect(repo.getById(parked.qitemId)).toEqual(before); - expect(repo.listTransitions(parked.qitemId)).toEqual(history); - expect(log.listForQitem(parked.qitemId)).toEqual(audit); - expect(send).not.toHaveBeenCalled(); + await contract.act({ verb: "resolve", qitemId: parked.qitemId, actorSession: MIKE, decision: "go ahead", notify: false }); + expect(repo.getById(parked.qitemId)?.state).toBe("in-progress"); + expect(repo.listTransitions(parked.qitemId).at(-1)).toMatchObject({ actorSession: MIKE, + transitionNote: `go ahead; by ${MIKE}, a registered person, on ${ANA}'s ask` }); + expect(log.listForQitem(parked.qitemId).at(-1)).toMatchObject({ actorSession: MIKE }); }); }); diff --git a/packages/daemon/test/slack-reply-to-thread.test.ts b/packages/daemon/test/slack-reply-to-thread.test.ts index ec326b5d6..f26e98379 100644 --- a/packages/daemon/test/slack-reply-to-thread.test.ts +++ b/packages/daemon/test/slack-reply-to-thread.test.ts @@ -346,7 +346,6 @@ describe("update --reply-to an earlier item's thread (#96)", () => { actions: [{ type: "button", block_id: `or-q:${questionId}`, action_id: `or-opt:${optionId}`, value: optionId, action_ts: actionTs }], }, }) }); - const answerRowId = (qitemId: string) => `qitem-human-answer-${createHash("sha256").update(qitemId).digest("hex").slice(0, 20)}`; const resolvedCount = (qitemId: string) => repo.transitionLog.listForQitem(qitemId) .filter((t) => t.ownerNotificationKind === "human-decision-resolved").length; // The ask posts as root 1.1; its agent then parks it on the person it asked, and that question posts too. @@ -368,7 +367,7 @@ describe("update --reply-to an earlier item's thread (#96)", () => { click("q2", "no", "991.1"); await vi.waitFor(() => expect(repo.getById(ask.qitemId)?.state).toBe("in-progress")); expect(repo.getById(ask.qitemId)?.humanAnswers).toEqual({ q1: "yes", q2: "no" }); - expect(repo.getById(answerRowId(ask.qitemId))).toMatchObject({ destinationSession: "author@rig" }); + expect(humanAnswerRows(ask.qitemId)).toHaveLength(1); expect(resolvedCount(ask.qitemId)).toBe(1); click("q2", "no", "991.1"); await new Promise((r) => setTimeout(r, 50)); @@ -485,8 +484,7 @@ describe("update --reply-to an earlier item's thread (#96)", () => { } finally { spy.mockRestore(); } }); - // The click path keeps its own rules until its follow-up: only a typed reply stores its words as the answer. - it("stores no earlier typed answer under a re-park's question when the person clicks", async () => { + it("answers a re-park with the person's click, not their typed answer to the earlier question", async () => { const ask = await parkedAsk(); typed("large", "990.1"); await vi.waitFor(() => expect(repo.getById(ask.qitemId)?.state).toBe("in-progress")); @@ -495,7 +493,26 @@ describe("update --reply-to an earlier item's thread (#96)", () => { await postPendingNotice(ask.qitemId); click("q2", "yes", "996.1"); await vi.waitFor(() => expect(resolvedCount(ask.qitemId)).toBe(2)); - expect(repo.getById(ask.qitemId)?.humanAnswers?.q2).toBeUndefined(); + expect(repo.getById(ask.qitemId)?.humanAnswers).toEqual({ q1: typedAnswer("large", 1), q2: "yes" }); + expect(humanAnswerRows(ask.qitemId)).toHaveLength(2); + expect(posts.some((post) => String(post.text ?? "").startsWith("Already answered"))).toBe(false); + }); + + it("answers a re-park's click with a new answer to the asker, over the earlier park's complete set", async () => { + const ask = await parkedAsk(); + click("q1", "yes", "990.1"); + await vi.waitFor(() => expect(repo.getById(ask.qitemId)?.humanAnswers).toEqual({ q1: "yes" })); + click("q2", "yes", "991.1"); + await vi.waitFor(() => expect(repo.getById(ask.qitemId)?.state).toBe("in-progress")); + repo.update({ qitemId: ask.qitemId, actorSession: "author@rig", state: "blocked", blockedOn: human, summary: "Tell the users after all?", + evidenceRef: "/private/proof.md", transitionNote: "parked again on the second question" }); + await postPendingNotice(ask.qitemId); + click("q2", "no", "996.1"); + await vi.waitFor(() => expect(resolvedCount(ask.qitemId)).toBe(2)); + expect(repo.getById(ask.qitemId)?.humanAnswers).toEqual({ q1: "yes", q2: "no" }); + expect(humanAnswerRows(ask.qitemId)).toHaveLength(2); + expect(humanAnswerRows(ask.qitemId).filter((row) => row.body.includes(`"q2":"no"`))).toHaveLength(1); + expect(posts.some((post) => String(post.text ?? "").startsWith("Already answered"))).toBe(false); }); it("returns nothing again when a re-park's interrupted answer is replayed after the ask closes", async () => { diff --git a/packages/daemon/test/slack-self-parked-ask.test.ts b/packages/daemon/test/slack-self-parked-ask.test.ts index 9a91146fc..4b33ecdcf 100644 --- a/packages/daemon/test/slack-self-parked-ask.test.ts +++ b/packages/daemon/test/slack-self-parked-ask.test.ts @@ -21,9 +21,9 @@ import { makeThreadRouteResolver } from "../src/domain/gateway/slack/thread-rout import { SeenStore, DeadLetterStore } from "../src/domain/gateway/slack/state-store.js"; import type { HumanFragment } from "../src/domain/gateway/human-registry.js"; -// An agent asks a person, then parks the ask on that same person. A colleague who writes in the ask's thread reaches the -// agent that asked, as themselves, exactly as on a pending ask; the park stays on the person asked until they answer; -// and the park's own post goes into the ask's thread rather than opening a second root. A person's Slack message, as it +// An agent asks a person, then parks the ask on that same person. A registered colleague who writes in the ask's thread +// answers it as themselves, once, exactly as on a pending ask, and the person asked writing afterwards is words for the +// agent; the park's own post goes into the ask's thread rather than opening a second root. A person's Slack message, as it // arrived, is never posted back to Slack as an ask, so its internal routing lines and a shared channel's policy stay in // OpenRig; an agent that later parks that message on a person makes a new ask, which posts. @@ -87,7 +87,7 @@ async function fixture() { } describe("an ask its asker parked on the person asked", () => { - it("a colleague's reply reaches the asker as theirs; the park stays on the person asked; its post stays in the ask's thread", async () => { + it("a colleague's reply answers the park as theirs; the person asked writing later doesn't answer again; its post stays in the ask's thread", async () => { const f = await fixture(); const first = f.post((await f.alertFor(f.ask.qitemId))!); expect(first).toEqual({ threadTs: "1.1", newRoot: true }); @@ -99,28 +99,38 @@ describe("an ask its asker parked on the person asked", () => { // Same row, same person and seat: the park's post goes into the ask's thread, not a second top-level root. expect(f.post(park)).toEqual({ threadTs: "1.1", newRoot: false }); - // A registered colleague types in that thread. + // A registered colleague types in that thread: their reply is the park's answer, as theirs. await f.typed("UMIKE", "afternoon", "1.1", "3.3"); - expect(f.repliesFrom(MIKE)).toEqual([expect.objectContaining({ destination: ASKER, humanAnswer: false })]); - expect(f.state()).toEqual({ state: "blocked", blockedOn: ANA, answers: null, resolvedBy: [] }); + expect(f.repliesFrom(MIKE)).toEqual([expect.objectContaining({ destination: ASKER, humanAnswer: true })]); + expect(f.state()).toEqual({ state: "in-progress", blockedOn: null, answers: null, resolvedBy: [MIKE] }); // Nothing they wrote is owed a Slack post. for (const item of await f.alerts()) expect(item.sourceSession).not.toBe(MIKE); - // The person asked still answers in the same thread, and the answer is theirs. + // The person asked writing afterwards, in the same thread, is words for the agent: the answer stays the colleague's. + await f.typed("UANA", "morning", "1.1", "4.4"); + expect(f.state()).toMatchObject({ state: "in-progress", resolvedBy: [MIKE] }); + // The resolution's notice goes to the person who resolved it, as any resolve's does. + expect(await f.alertFor(f.ask.qitemId)).toMatchObject({ destinationSession: MIKE, sourceSession: ASKER, ownerNotificationKind: "human-decision-resolved" }); + }); + + it("control: the person asked answers the park, and the resolution's notice posts in the ask's thread", async () => { + const f = await fixture(); + f.post((await f.alertFor(f.ask.qitemId))!); + f.repo.update({ qitemId: f.ask.qitemId, actorSession: ASKER, state: "blocked", blockedOn: ANA, transitionNote: "parked on Ana" }); + expect(f.post((await f.alertFor(f.ask.qitemId))!)).toEqual({ threadTs: "1.1", newRoot: false }); await f.typed("UANA", "morning", "1.1", "4.4"); expect(f.state()).toMatchObject({ state: "in-progress", resolvedBy: [ANA] }); - // The resolution's notice is the asker's, and posts in the ask's thread. const resolved = (await f.alertFor(f.ask.qitemId))!; expect(resolved).toMatchObject({ destinationSession: ANA, sourceSession: ASKER, ownerNotificationKind: "human-decision-resolved" }); expect(f.post(resolved)).toEqual({ threadTs: "1.1", newRoot: false }); }); - it("control: on a pending ask, the colleague's reply reaches the asker and the ask stays open", async () => { + it("control: on a pending ask, the colleague's reply reaches the asker as the answer, theirs", async () => { const f = await fixture(); f.post((await f.alertFor(f.ask.qitemId))!); await f.typed("UMIKE", "afternoon", "1.1", "3.3"); - expect(f.repliesFrom(MIKE)).toEqual([expect.objectContaining({ destination: ASKER, humanAnswer: false })]); - expect(f.state()).toEqual({ state: "pending", blockedOn: null, answers: null, resolvedBy: [] }); + expect(f.repliesFrom(MIKE)).toEqual([expect.objectContaining({ destination: ASKER, humanAnswer: true })]); + expect(f.state()).toEqual({ state: "done", blockedOn: null, answers: null, resolvedBy: [MIKE] }); }); it("a person's Slack message that lands on another person (a root stamped with a person as its seat) isn't posted back", async () => { @@ -133,6 +143,7 @@ describe("an ask its asker parked on the person asked", () => { // It isn't owed a Slack post (its body carries OpenRig's routing lines); it's on Steering and the TUI. expect(await f.alertFor(reply!.qitemId)).toBeNull(); expect(f.repo.listAttention({ limit: 100 }).map((item) => item.qitemId)).toContain(reply!.qitemId); + // It reached another person, not the asker, so it doesn't settle the ask. expect(f.state()).toMatchObject({ state: "pending", answers: null, resolvedBy: [] }); }); diff --git a/packages/daemon/test/slack-sweep-selection.test.ts b/packages/daemon/test/slack-sweep-selection.test.ts index d6597d972..91b8c2c44 100644 --- a/packages/daemon/test/slack-sweep-selection.test.ts +++ b/packages/daemon/test/slack-sweep-selection.test.ts @@ -14,7 +14,8 @@ import { SeenStore } from "../src/domain/gateway/slack/state-store.js"; import { DispatchBuffer } from "../src/domain/gateway/dispatch-buffer.js"; const registry = { ok: true as const, entities: [{ entityId: "human-owner", class: "human" as const, - displayName: "Owner", address: "human-owner@external", connectorBindings: [], prefs: {} }] }; + displayName: "Owner", address: "human-owner@external", + connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: "unused", role: "primary" }], prefs: {} }] }; const questions = [{ id: "continue", question: "Continue?", options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] }]; describe("Slack sweep selection", () => { diff --git a/packages/daemon/test/slack-wrong-answerer.test.ts b/packages/daemon/test/slack-wrong-answerer.test.ts index dd87771f7..5ccb2c800 100644 --- a/packages/daemon/test/slack-wrong-answerer.test.ts +++ b/packages/daemon/test/slack-wrong-answerer.test.ts @@ -17,9 +17,9 @@ import { makeHumanReplyResolver } from "../src/domain/gateway/slack/slack-subsys import { SeenStore, DeadLetterStore } from "../src/domain/gateway/slack/state-store.js"; import type { HumanFragment } from "../src/domain/gateway/human-registry.js"; -// With several registered people, a person who wasn't asked can still write in the asked person's Slack thread. Their -// words reach the agent as their own message, and never become the asked person's answer: not on a pending ask, typed -// or clicked, and not on a question parked on someone else. +// With several registered people in one trusted workspace, a person who wasn't asked can write in the asked person's +// Slack thread, and their reply settles it as their own: typed or clicked on a pending ask, and typed on a question +// parked on someone else. It settles once: the asked person writing afterwards is words for the agent. const person = (entityId: string, handle: string): HumanFragment => ({ entityId, class: "human", displayName: entityId, address: `${entityId}@external`, @@ -75,44 +75,56 @@ async function fixture(kind: "pending" | "parked") { replies: replies.map((item) => ({ source: item.sourceSession, destination: item.destinationSession, humanAnswer: (item.tags ?? []).includes("human-answer") })), }; }; - return { typed, clicked, state }; + return { repo, ask, typed, clicked, state }; } describe("a reply from a registered person who wasn't asked", () => { - it("typed in a pending ask's thread: reaches the agent as their own message; the ask stays open and unanswered", async () => { + it("typed in a pending ask's thread: answers it as their own, and the agent gets their words as the answer", async () => { const f = await fixture("pending"); await f.typed("UMIKE", "I'd say yes", "3.3"); - expect(f.state()).toEqual({ state: "pending", blockedOn: null, answers: null, resolvedBy: [], - replies: [{ source: MIKE, destination: ASKER, humanAnswer: false }] }); + expect(f.state()).toMatchObject({ state: "done", blockedOn: null, answers: { choice: { kind: "typed-reply", text: "I'd say yes" } }, + resolvedBy: [MIKE], replies: [{ source: MIKE, destination: ASKER, humanAnswer: true }] }); }); - it("clicked on a pending ask's button: records nothing as the asked person's answer", async () => { + it("clicked on a pending ask's button: records the answer as theirs, and the asked person's later click records nothing", async () => { const f = await fixture("pending"); await f.clicked("UMIKE", "2.2"); - expect(f.state()).toMatchObject({ state: "pending", answers: null, resolvedBy: [] }); - expect(f.state().replies.filter((reply) => reply.humanAnswer)).toEqual([]); - // The same click from the person asked does record their answer, so the refusal above is about who clicked. + expect(f.state()).toMatchObject({ state: "done", answers: { choice: "yes" }, resolvedBy: [MIKE] }); + expect(f.state().replies.filter((reply) => reply.humanAnswer)).toEqual([{ source: MIKE, destination: ASKER, humanAnswer: true }]); + // One answer per ask: the same click from the person asked, afterwards, changes nothing. await f.clicked("UANA", "2.3"); - expect(f.state()).toMatchObject({ state: "done", answers: { choice: "yes" }, resolvedBy: [ANA] }); + expect(f.state()).toMatchObject({ state: "done", answers: { choice: "yes" }, resolvedBy: [MIKE] }); + expect(f.state().replies.filter((reply) => reply.humanAnswer)).toEqual([{ source: MIKE, destination: ASKER, humanAnswer: true }]); }); - it("typed on a question parked on someone else: reaches the agent and doesn't resume the question", async () => { + it("clicked after the asked person's typed answer committed but before it closed: records nothing, and settles as theirs", async () => { + const f = await fixture("pending"); + // Ana's typed answer is committed under the ask's answer identity, then execution stops before the close. + await f.repo.createHumanAnswer({ sourceSession: ANA, destinationSession: ASKER, body: "Do not ship", summary: "Do not ship", + inboundReplyTo: f.ask.qitemId, nudge: false } as never); + await f.clicked("UMIKE", "2.2"); + expect(f.state()).toMatchObject({ state: "done", answers: { choice: { kind: "typed-reply", text: "Do not ship" } }, resolvedBy: [ANA] }); + expect(f.state().replies.filter((reply) => reply.humanAnswer)).toEqual([{ source: ANA, destination: ASKER, humanAnswer: true }]); + }); + + it("typed on a question parked on someone else: resumes it as theirs, and reaches the agent", async () => { const f = await fixture("parked"); await f.typed("UMIKE", "go ahead", "3.3"); - expect(f.state()).toMatchObject({ state: "blocked", blockedOn: "human-ana@host", resolvedBy: [] }); + expect(f.state()).toMatchObject({ state: "in-progress", blockedOn: null, resolvedBy: [MIKE] }); expect(f.state().replies).toEqual([{ source: MIKE, destination: ASKER, humanAnswer: false }]); }); - it("the asked person still answers, after the other person wrote, and the answer is theirs", async () => { + it("the asked person writing after the other person answered: their words reach the agent, and the answer stays the other person's", async () => { const pending = await fixture("pending"); await pending.typed("UMIKE", "I'd say yes", "3.3"); await pending.typed("UANA", "Yes, go ahead", "4.4"); - expect(pending.state()).toMatchObject({ state: "done", resolvedBy: [ANA] }); - expect(pending.state().replies.filter((reply) => reply.humanAnswer)).toEqual([{ source: ANA, destination: ASKER, humanAnswer: true }]); + expect(pending.state()).toMatchObject({ state: "done", resolvedBy: [MIKE] }); + expect(pending.state().replies.filter((reply) => reply.humanAnswer)).toEqual([{ source: MIKE, destination: ASKER, humanAnswer: true }]); + expect(pending.state().replies).toContainEqual({ source: ANA, destination: ASKER, humanAnswer: false }); const parked = await fixture("parked"); await parked.typed("UMIKE", "go ahead", "3.3"); await parked.typed("UANA", "go ahead", "4.4"); - expect(parked.state()).toMatchObject({ state: "in-progress", resolvedBy: [ANA] }); + expect(parked.state()).toMatchObject({ state: "in-progress", resolvedBy: [MIKE] }); }); }); diff --git a/packages/daemon/test/startup-orchestrator.test.ts b/packages/daemon/test/startup-orchestrator.test.ts index eb4724a10..9881ae8a2 100644 --- a/packages/daemon/test/startup-orchestrator.test.ts +++ b/packages/daemon/test/startup-orchestrator.test.ts @@ -1478,6 +1478,34 @@ describe("StartupOrchestrator", () => { } finally { nodeFs.rmSync(dir, { recursive: true, force: true }); } }); + it("delivers contained identity, role and actions without host input when the viewer exits after readiness", async () => { + const dir = nodeFs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), "contained-startup-race-")); + try { + const marker = nodePath.join(dir, "host-executed"); + const seed = seedSession(); db.prepare("UPDATE nodes SET runtime='openshell-claude' WHERE id=?").run(seed.nodeId); + let passed: string | undefined; let viewerExited = false; + const contained = { platform: { forNode: () => ({ guestRoot: "/sandbox/openrig" }) }, preflight: async () => {}, project: async () => ({ projected: [], skipped: [], failed: [] }), + deliverStartup: async () => ({ delivered: 1, failed: [] }), + launch: async (_binding: unknown, opts: { firstInputText?: string }) => { passed = opts.firstInputText; + return { ok: true, ...(passed ? { firstInput: { path: "/guest/control/first-input.txt", submission: "unconfirmed" } } : {}) }; }, + checkReady: async () => { viewerExited = true; return { ready: true }; } }; + const adapter = new CommunityHarnessRuntimeAdapter("openshell-claude", { environmentStartup: contained as never, + tmux: { hasSession: async () => true, getPaneCommand: async () => { viewerExited = true; return "node"; }, sendShellCommand: async () => ({ ok: true }) } }); + vi.mocked(tmux.sendText).mockImplementation(async (_target, text) => { + if (viewerExited) { try { execFileSync("/bin/sh", ["-c", text], { stdio: "ignore" }); } catch {} } + return { ok: true }; + }); + const role: ResolvedStartupFile = { path: "role.md", absolutePath: "/role.md", ownerRoot: "/", deliveryHint: "send_text", required: true, appliesOn: ["fresh_start"] }; + const result = await createOrchestrator({ readFile: () => `Selected role\ntouch '${marker}'` }).startNode(makeInput(seed, { adapter, + resolvedStartupFiles: [role], startupActions: [makeIdentityAction(), makeAction({ type: "send_text", value: "selected after-ready instruction" })] })); + expect(nodeFs.existsSync(marker)).toBe(false); + expect(tmux.sendText).not.toHaveBeenCalled(); expect(tmux.sendKeys).not.toHaveBeenCalled(); + expect(passed).toContain("OpenRig-generated identity context"); + expect(passed).toContain("role.md"); expect(passed).toContain("selected after-ready instruction"); + expect(result.ok).toBe(true); + } finally { nodeFs.rmSync(dir, { recursive: true, force: true }); } + }); + it.each([[false, false, false], [true, false, false], [true, true, false], [true, false, true]])("passes the composed first input at launch (%s), assistance (%s), uncertain launch (%s)", async (firstInput, assisted, uncertain) => { const dir = nodeFs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), "first-input-")); try { diff --git a/packages/tui/src/attention/attention-model.ts b/packages/tui/src/attention/attention-model.ts index 66c5f8619..b97607bdc 100644 --- a/packages/tui/src/attention/attention-model.ts +++ b/packages/tui/src/attention/attention-model.ts @@ -123,9 +123,13 @@ export function answerRequest(action: Extract { const state = createViewState({ instanceId: "perspective" }).get(); const lines = (r: AttentionRead) => attentionLines({ ...state, section: "needs" }, { ...emptySnapshot(), attentionRead: r }, 120).map(l => l.text); - expect(lines(read({ ...perspective(null, "unknown"), requested: "ana" }))[0]).toBe('FEED · Viewing no registered person "ana" · you act as mike@external'); + expect(lines(read({ ...perspective(null, "unknown"), requested: "ana" }))[0]).toBe('FEED · Viewing no registered person "ana" · you act as human@host (unattributed)'); expect(lines(read(perspective(null, "all", "none"))).slice(0, 2)).toEqual([ - "FEED · Viewing everyone · you act as the primary user", + "FEED · Viewing everyone · you act as human@host (unattributed)", "No default person is chosen: rig gateway default chooses one.", ]); expect(lines(read())[0]).toBe("FEED · Instance / All humans"); @@ -90,11 +90,11 @@ it("an act on someone else's ask says who acts and whose ask it is, and carries const state = { ...createViewState({ instanceId: "perspective" }).get(), section: "needs", attentionOpen: item.id }; const approve = (snap: FleetSnapshot) => attentionLines(state, snap, 160).find(l => l.action?.type === "act")!; const viewingSarah = approve(served(perspective("sarah@external", "chosen-here"))); - expect(viewingSarah.text).toContain("Approve as mike@external on sarah@external's ask"); + expect(viewingSarah.text).toContain("Approve as human@host (unattributed) on sarah@external's ask"); expect(answerRequest(viewingSarah.action as Extract)).toEqual( { verb: "approve", qitemId: "sarahs", actorSession: "sarah@external", authority: "primary-user" }); - // With no one registered, the label is as before. - expect(approve(served()).text).toContain("Approve as sarah@external (closes"); + // With no one registered, the terminal still names no responder: unattributed too. + expect(approve(served()).text).toContain("Approve as human@host (unattributed) (closes"); }); it("a row parked on someone is resolved with reply, in words, as the primary user", () => { @@ -104,7 +104,7 @@ it("a row parked on someone is resolved with reply, in words, as the primary use const snap: FleetSnapshot = { ...emptySnapshot(), attentionRead: { ...read(perspective("sarah@external", "chosen-here")), items: [item], detail: { item, lines: [], files: [] } } }; const state = { ...createViewState({ instanceId: "perspective" }).get(), section: "needs", attentionOpen: item.id }; const text = attentionLines(state, snap, 160).map(l => l.text).join("\n"); - expect(text).toContain("Resolve it in your own words: reply , as mike@external on sarah@external's ask"); + expect(text).toContain("Resolve it in your own words: reply , as human@host (unattributed) on sarah@external's ask"); const act = replyAct(snap.attentionRead, item.id, "Use the wide layout"); expect(act).toEqual({ type: "act", act: "answer", qitemId: "parked", verb: "resolve", actorSession: "sarah@external", decision: "Use the wide layout" }); expect(answerRequest(act as Extract)).toMatchObject({ verb: "resolve", decision: "Use the wide layout", authority: "primary-user" }); diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 836cad47d..bda209ba4 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -1,58 +1,6 @@ import { RouterProvider } from "@tanstack/react-router"; -import { useState } from "react"; import { router } from "./routes.js"; -export const UI_MAINTENANCE_NOTICE_STORAGE_KEY = "openrig.uiMaintenanceNoticeDismissed"; - -const UI_MAINTENANCE_NOTICE = - "The OpenRig UI is experimental and in maintenance mode. It is not under active development; support is best-effort. The CLI is the primary supported interface. Contributions welcome."; - -function wasMaintenanceNoticeDismissed(): boolean { - try { - return localStorage.getItem(UI_MAINTENANCE_NOTICE_STORAGE_KEY) === "1"; - } catch { - return false; - } -} - -export function UiMaintenanceNotice() { - const [dismissed, setDismissed] = useState(wasMaintenanceNoticeDismissed); - - if (dismissed) return null; - - const dismiss = () => { - try { - localStorage.setItem(UI_MAINTENANCE_NOTICE_STORAGE_KEY, "1"); - } catch { - // localStorage can be unavailable; dismissal still works for this load. - } - setDismissed(true); - }; - - return ( -
- {UI_MAINTENANCE_NOTICE} - -
- ); -} - export function App() { - return ( - <> - - - - ); + return ; } diff --git a/packages/ui/src/components/AppShell.tsx b/packages/ui/src/components/AppShell.tsx index 1b5d71624..7d1236c4d 100644 --- a/packages/ui/src/components/AppShell.tsx +++ b/packages/ui/src/components/AppShell.tsx @@ -42,6 +42,7 @@ import { TopologyOverlayProvider, useTopologyOverlay, } from "./topology/topology-overlay-context.js"; +import { UiMaintenanceNotice } from "./UiMaintenanceNotice.js"; import { ThemeSelector } from "./ThemeSelector.js"; import { useActivityFeed } from "../hooks/useActivityFeed.js"; import { useClearPlacementOnHostSwitch } from "../hooks/useHosts.js"; @@ -312,6 +313,7 @@ function AppShellInner({ children }: AppShellProps) { }} >
+ {/* Top bar — universal across viewports per universal-shell.md L40–L53. Single source of truth: same element renders at all sizes. Hamburger button keeps its own lg:hidden so it @@ -375,6 +377,7 @@ function AppShellInner({ children }: AppShellProps) { {/* Main: rail + explore + center + drawer */}
@@ -397,7 +400,7 @@ function AppShellInner({ children }: AppShellProps) { {(explorerOpen || mobileExplorerOpen) && (
{ setExplorerOpen(false); setMobileExplorerOpen(false); @@ -408,8 +411,8 @@ function AppShellInner({ children }: AppShellProps) { data-testid="mobile-rail-tray" inert={!explorerOpen} className={cn( - "fixed top-12 left-0 bottom-0 z-30 bg-background transition-transform duration-200 ease-tactical lg:hidden", - "w-72 max-w-[80vw] flex flex-col pb-[calc(3.5rem+env(safe-area-inset-bottom))]", + "absolute top-0 left-0 bottom-14 mb-[env(safe-area-inset-bottom)] z-30 bg-background transition-transform duration-200 ease-tactical lg:hidden", + "w-72 max-w-[80vw] flex flex-col overflow-y-auto", explorerOpen ? "translate-x-0" : "-translate-x-full", )} > diff --git a/packages/ui/src/components/Explorer.tsx b/packages/ui/src/components/Explorer.tsx index 9a9880880..bdfba1b15 100644 --- a/packages/ui/src/components/Explorer.tsx +++ b/packages/ui/src/components/Explorer.tsx @@ -313,7 +313,7 @@ export function Explorer({ aria-label="Expand explorer" onClick={onDesktopToggle} className={cn( - "hidden lg:flex fixed top-[5.5rem] left-[3.5rem] z-30 h-8 w-8 items-center justify-center", + "hidden lg:flex absolute top-8 left-[3.5rem] z-30 h-8 w-8 items-center justify-center", "rounded-full border border-outline-variant bg-background/90 text-on-surface", "shadow-[0_2px_8px_rgba(41,37,36,0.08)] backdrop-blur-sm transition-colors", "hover:bg-surface-low hover:text-on-surface", @@ -361,11 +361,11 @@ export function Explorer({ isOverlay ? "vellum z-30 shadow-[6px_0_14px_rgba(46,52,46,0.06)]" : "z-40 bg-[hsl(var(--background)/0.035)] supports-[backdrop-filter]:bg-[hsl(var(--background)/0.018)] backdrop-blur-[14px] backdrop-saturate-75 shadow-[6px_0_14px_rgba(46,52,46,0.04)]", - // Mobile: slide-over from left below the 48px top bar (h-12). - // Opaque on phones: the desktop glass background let the page - // underneath bleed through the panel. + // Mobile: slide-over within the shell content, below the notice and + // header. Opaque on phones: the desktop glass background let the + // page underneath bleed through the panel. !isOverlay && "max-lg:!bg-background", - "fixed top-12 bottom-0 left-0 transition-transform duration-200 ease-tactical w-72 max-w-[80vw]", + "absolute top-0 bottom-0 left-0 transition-transform duration-200 ease-tactical w-72 max-w-[80vw]", open ? "translate-x-0" : "-translate-x-full", // Desktop (>=lg): persistent column at 280px (lg:w-72) per // universal-shell.md L34. Positioned absolutely after the sidebar diff --git a/packages/ui/src/components/RigStatusCard.tsx b/packages/ui/src/components/RigStatusCard.tsx index 351cd1c54..8f536a69f 100644 --- a/packages/ui/src/components/RigStatusCard.tsx +++ b/packages/ui/src/components/RigStatusCard.tsx @@ -88,7 +88,7 @@ export function RigStatusCard({
{/* Dark header stripe — vellum grammar (matches RigCard). */}
@@ -98,7 +98,7 @@ export function RigStatusCard({
{/* Name + aggregate status badge (the badge tone CONSUMES the verdict). */} -
+
{rigName} src: {src.join(" · ")}

diff --git a/packages/ui/src/components/SharedDetailDrawer.tsx b/packages/ui/src/components/SharedDetailDrawer.tsx index a24113021..93cc71272 100644 --- a/packages/ui/src/components/SharedDetailDrawer.tsx +++ b/packages/ui/src/components/SharedDetailDrawer.tsx @@ -91,7 +91,7 @@ export function SharedDetailDrawer({ // CORRECTIVE §7.2 — the LEFT reinvention is REVERTED (founder 2026-07-05): // back to the app-wide RIGHT edge, and the FR-11.1 z-bump comes out with // it (the right edge never contended with the sidebar's opaque z-40). - className="fixed top-14 right-0 bottom-0 left-0 z-30 pointer-events-none" + className="absolute top-0 right-0 bottom-0 left-0 z-30 pointer-events-none" > +
+ ); +} + diff --git a/packages/ui/src/components/for-you/FeedCard.tsx b/packages/ui/src/components/for-you/FeedCard.tsx index 5c3bc83df..89e71e739 100644 --- a/packages/ui/src/components/for-you/FeedCard.tsx +++ b/packages/ui/src/components/for-you/FeedCard.tsx @@ -409,6 +409,15 @@ export function FeedCard({ onDismiss(card); }; const qitemViewerData = qitemViewerDataFromItem(card, queueItem); + const payload = card.source.payload ?? {}; + // Remote cards must use their origin's payload, not a same-id local queue row. + const localItem = !card.hostId || card.hostId === "local" ? queueItem : undefined; + const hasQuestions = (Array.isArray(payload.humanQuestions) && payload.humanQuestions.length > 0) + || (Array.isArray(localItem?.humanQuestions) && localItem.humanQuestions.length > 0); + // Event cards can arrive before the queue read. Do not offer a closing action until it resolves. + const questionsKnown = localItem !== undefined || Object.hasOwn(payload, "humanQuestions"); + const permissionTags = localItem ? localItem.tags : payload.tags; + const isRunnerPermission = Array.isArray(permissionTags) && permissionTags.includes("runner-permission"); const body = compactQueueBody(queueItem?.body || card.body); const tags = queueItem?.tags ?? qitemViewerData?.tags ?? []; const source = qitemViewerData?.source ?? card.authorSession; @@ -519,13 +528,12 @@ export function FeedCard({
- {/* CORRECTIVE §7.1 + founder live-review 2026-07-05: JUST the two - buttons — APPROVE (one-tap, the existing verb + write path) and - CHAT (the shared terminal, preamble per BR-12). No "Your turn" - prose, no "Choose response" chrome (VerbActions `bare`) — - buttons are self-evident. Deny/route stay retired. */} + {/* CORRECTIVE §7.1 + founder live-review 2026-07-05: bare APPROVE + and CHAT only. Questions require answers in the TUI: Approve would + close the ask without returning them. Runner permission asks + retain Approve because their runner reads that closure as allow. */}
- onOptimisticOutcome(qitemViewerData.qitemId, outcome) : undefined } - /> + /> : hasQuestions ? ( + + {card.hostId && card.hostId !== "local" + ? `This ask has questions. Answer in the TUI on host ${card.hostId} (rig tui).` + : `This ask has questions. Answer in the TUI (rig tui)${terminalSession ? `, or tell ${terminalSession} in Chat.` : "."}`} + + ) : null}
-
+
, + document.getElementById("shell-content") ?? document.body, ); } diff --git a/packages/ui/src/components/topology/TopologyTableView.tsx b/packages/ui/src/components/topology/TopologyTableView.tsx index ed7c960db..677d93188 100644 --- a/packages/ui/src/components/topology/TopologyTableView.tsx +++ b/packages/ui/src/components/topology/TopologyTableView.tsx @@ -426,6 +426,9 @@ export function TopologyTableView({ rigIdScope, podNameScope }: { rigIdScope?: s const table = useReactTable({ data: activityData, + // This table has no pagination. Resetting an unused page index on fresh + // query results feeds a render/reset loop when filtering or sorting. + autoResetPageIndex: false, columns, state: { sorting, globalFilter: search }, onSortingChange: setSorting, diff --git a/packages/ui/src/hooks/useAttentionItems.ts b/packages/ui/src/hooks/useAttentionItems.ts index becb28073..c1ccae1da 100644 --- a/packages/ui/src/hooks/useAttentionItems.ts +++ b/packages/ui/src/hooks/useAttentionItems.ts @@ -25,6 +25,8 @@ import { useQuery } from "@tanstack/react-query"; export interface AttentionQueueItem { + /** Structured questions already included in the queue API response. */ + humanQuestions?: unknown[] | null; qitemId: string; tsCreated: string; tsUpdated: string; diff --git a/packages/ui/src/hooks/useSlices.ts b/packages/ui/src/hooks/useSlices.ts index 995842672..dabb18071 100644 --- a/packages/ui/src/hooks/useSlices.ts +++ b/packages/ui/src/hooks/useSlices.ts @@ -385,6 +385,8 @@ export function proofAssetUrl(sliceName: string, relPath: string): string { } export interface QueueItemDetail { + /** Structured questions already included in the queue API response. */ + humanQuestions?: unknown[] | null; qitemId: string; tsCreated: string; tsUpdated: string; diff --git a/packages/ui/src/lib/attention-feed.ts b/packages/ui/src/lib/attention-feed.ts index d35deb2f0..4a8a253f4 100644 --- a/packages/ui/src/lib/attention-feed.ts +++ b/packages/ui/src/lib/attention-feed.ts @@ -66,6 +66,8 @@ export function attentionItemToFeedCard(item: AttentionQueueItem): FeedCard { tier: item.tier, state: item.state, body: item.body, + humanQuestions: item.humanQuestions ?? null, + tags: item.tags, }, createdAt, receivedAt, diff --git a/packages/ui/test/app-shell.test.tsx b/packages/ui/test/app-shell.test.tsx index f03ce27ed..b43b7e7c8 100644 --- a/packages/ui/test/app-shell.test.tsx +++ b/packages/ui/test/app-shell.test.tsx @@ -18,6 +18,7 @@ // remains surface=none (no Explorer). import { describe, it, expect } from "vitest"; +import { fireEvent } from "@testing-library/react"; import { readFileSync } from "node:fs"; import path from "node:path"; import { setupAppShellTestEnv, renderAppShellAt } from "./helpers/app-shell-env.js"; @@ -37,6 +38,16 @@ const renderAt = (initialPath: string, opts: { innerWidth?: number } = {}) => renderAppShellAt(AppShell, initialPath, opts); describe("AppShell — Phase 2 chrome", () => { + it("places the dismissible notice before the header inside the shell", async () => { + const view = await renderAt("/for-you", { innerWidth: 390 }); + const notice = view.getByTestId("ui-maintenance-notice"); + const header = view.getByTestId("app-topbar"); + expect(notice.nextElementSibling).toBe(header); + fireEvent.click(view.getByRole("button", { name: "Dismiss maintenance notice" })); + expect(view.queryByTestId("ui-maintenance-notice")).toBeNull(); + expect(header.parentElement?.firstElementChild).toBe(header); + }); + describe("SC-1: exactly 2 left chromes on desktop (rail + explore)", () => { it("renders exactly 2 left chromes at /topology desktop (rail + explore) — SC-1 strict count", async () => { const { container } = await renderAt("/topology"); @@ -236,6 +247,9 @@ describe("AppShell — Phase 2 chrome", () => { const tray = container.querySelector("[data-testid='mobile-rail-tray']") as HTMLElement; expect(tray).toBeTruthy(); expect(tray.className).toContain("-translate-x-full"); + // Keep every link reachable when the notice leaves a short viewport. + expect(tray.className).toContain("overflow-y-auto"); + expect(tray.className).toContain("bottom-14"); }); // Slice 20 mobile: hamburger menu items stack vertically @@ -264,7 +278,7 @@ describe("AppShell — Phase 2 chrome", () => { const rail = tray.querySelector("[data-testid='app-rail']") as HTMLElement; expect(rail.style.width).toBe(""); expect(rail.className).toMatch(/\bw-full\b/); - expect(tray.className).toContain("top-12"); + expect(tray.className).toContain("top-0"); // The rail must be allowed to shrink so its link list scrolls // inside the tray instead of running under the bottom nav. expect(rail.className).toMatch(/\bmin-h-0\b/); diff --git a/packages/ui/test/attention-feed.test.ts b/packages/ui/test/attention-feed.test.ts index 7f5933d12..4364c2c0a 100644 --- a/packages/ui/test/attention-feed.test.ts +++ b/packages/ui/test/attention-feed.test.ts @@ -60,6 +60,15 @@ function makeFeedCard(opts: { kind: FeedCard["kind"]; qitemId?: string; id?: str } describe("attentionItemToFeedCard — kind classification", () => { + it("preserves question data before card hydration, including on remote hosts", () => { + const humanQuestions = [{ id: "channel", question: "Which channel?" }]; + const card = attentionItemToFeedCard(makeAttention({ humanQuestions, hostId: "remote", tags: ["runner-permission"] })); + expect(card.source.payload.humanQuestions).toEqual(humanQuestions); + expect(card.hostId).toBe("remote"); + expect(card.source.payload.tags).toEqual(["runner-permission"]); + expect(attentionItemToFeedCard(makeAttention()).source.payload.humanQuestions).toBeNull(); + }); + it("HG-4 approval class: tier='human-gate' → kind:'approval'", () => { const card = attentionItemToFeedCard(makeAttention({ tier: "human-gate", destinationSession: "advisor@rig" })); expect(card.kind).toBe("approval"); diff --git a/packages/ui/test/foryou-bare-approve-chat.test.tsx b/packages/ui/test/foryou-bare-approve-chat.test.tsx index d3582bdd2..4d62e8466 100644 --- a/packages/ui/test/foryou-bare-approve-chat.test.tsx +++ b/packages/ui/test/foryou-bare-approve-chat.test.tsx @@ -17,6 +17,7 @@ import nodePath from "node:path"; import { fileURLToPath } from "node:url"; import { VerbActions } from "../src/components/mission-control/components/VerbActions.js"; import { buildChatPreamble } from "../src/components/review/chat.js"; +import type { QueueItemDetail } from "../src/hooks/useSlices.js"; import type { FeedCard as FeedCardModel } from "../src/lib/feed-classifier.js"; // The shared terminal is mocked to a marker so the DOM test proves the CHAT @@ -114,7 +115,7 @@ describe("For-You quick actions — bare APPROVE + CHAT only (N-1, BR-12)", () = source: { seq: 7, type: "queue.enqueued", - payload: { qitem_id: "q-chat-1", source_session: "dev-owner@rig" }, + payload: { qitem_id: "q-chat-1", source_session: "dev-owner@rig", humanQuestions: null }, } as unknown as FeedCardModel["source"], } as FeedCardModel; } @@ -139,6 +140,81 @@ describe("For-You quick actions — bare APPROVE + CHAT only (N-1, BR-12)", () = expect(view.queryByText(/approve,\s*deny,?\s*(?:and|or)\s*route/i)).toBeNull(); }); + it.each(["action-required", "approval"] as const)("%s question cards offer Chat without posting approval", async (kind) => { + const raw = stubActionFetch(); + const card = actionCard(); + card.kind = kind; + card.source.payload.humanQuestions = [{ id: "channel", question: "Which channel?", options: [{ id: "stable", label: "Stable" }, { id: "beta", label: "Beta" }] }]; + const view = withQuery(); + expect(view.queryByTestId("mc-verb-approve")).toBeNull(); + fireEvent.click(view.getByTestId("feed-card-chat-queue.enqueued-7")); + await waitFor(() => expect(view.getByTestId("shared-progressive-terminal")).toBeTruthy()); + expect(raw).toHaveLength(0); + }); + + it.each([true, false])("remote questions name the origin host without suggesting Chat (terminal: %s)", (hasTerminal) => { + stubActionFetch(); + const card = actionCard(); + card.hostId = "remote-host"; + card.source.payload.humanQuestions = [{ id: "note", question: "What should change?" }]; + if (!hasTerminal) delete card.source.payload.source_session; + const view = withQuery(); + expect(view.getByText("This ask has questions. Answer in the TUI on host remote-host (rig tui).")).toBeTruthy(); + expect(view.queryByText(/or tell .* in Chat/)).toBeNull(); + expect(view.queryByTestId("mc-verb-approve")).toBeNull(); + expect(view.getByTestId("feed-card-chat-queue.enqueued-7").hasAttribute("disabled")).toBe(!hasTerminal); + }); + + it.each([undefined, "local"])("local questions retain TUI and Chat guidance (host: %s)", (hostId) => { + stubActionFetch(); + const card = actionCard(); + card.hostId = hostId; + card.source.payload.humanQuestions = [{ id: "note", question: "What should change?" }]; + const view = withQuery(); + expect(view.getByText("This ask has questions. Answer in the TUI (rig tui), or tell dev-owner@rig in Chat.")).toBeTruthy(); + }); + + it.each([undefined, "remote"])("keeps Approve for runner permission questions on host %s", async (hostId) => { + const raw = stubActionFetch(); + const card = actionCard(); + card.hostId = hostId; + card.source.payload.humanQuestions = [{ id: "permission", question: "Allow this tool?" }]; + card.source.payload.tags = ["runner-permission"]; + const view = withQuery(); + fireEvent.click(view.getByTestId("mc-verb-approve")); + await waitFor(() => expect(raw).toHaveLength(1)); + expect(JSON.parse(raw[0]!.body).verb).toBe("approve"); + }); + + it("does not offer approval before queue details establish whether there are questions", () => { + stubActionFetch(); + const card = actionCard(); + delete card.source.payload.humanQuestions; + const view = withQuery(); + expect(view.queryByTestId("mc-verb-approve")).toBeNull(); + expect(view.queryByText(/unavailable/i)).toBeNull(); + }); + + it.each([null, []])("keeps one-click approval for hydrated asks without questions (%j)", (humanQuestions) => { + stubActionFetch(); + const card = actionCard(); + delete card.source.payload.humanQuestions; + const queueItem = { qitemId: "q-chat-1", sourceSession: "dev-owner@rig", state: "pending", humanQuestions } as QueueItemDetail; + const view = withQuery(); + expect(view.getByTestId("mc-verb-approve")).toBeTruthy(); + }); + + it("reads questions from hydration and offers only the TUI when no Chat sender resolves", () => { + stubActionFetch(); + const card = actionCard(); + delete card.source.payload.source_session; + const queueItem = { qitemId: "q-chat-1", state: "pending", humanQuestions: [{ id: "note", question: "What should change?" }] } as QueueItemDetail; + const view = withQuery(); + expect(view.queryByTestId("mc-verb-approve")).toBeNull(); + expect(view.getByText("This ask has questions. Answer in the TUI (rig tui).")).toBeTruthy(); + expect(view.getByTestId("feed-card-chat-queue.enqueued-7").hasAttribute("disabled")).toBe(true); + }); + it("CHAT opens the SHARED ProgressiveTerminal seeded with the pinned preamble (BR-12: terminal, never a chat panel)", async () => { stubActionFetch(); const view = withQuery(); diff --git a/packages/ui/test/proof-image-viewer.test.tsx b/packages/ui/test/proof-image-viewer.test.tsx new file mode 100644 index 000000000..1fd66b4fe --- /dev/null +++ b/packages/ui/test/proof-image-viewer.test.tsx @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { ProofImageViewer } from "../src/components/project/ProofImageViewer.js"; + +afterEach(() => { cleanup(); document.getElementById("shell-content")?.remove(); }); + +describe("proof preview below the shell header", () => { + it("escapes positioned cards and anchors to the shell content", () => { + const content = document.createElement("div"); + content.id = "shell-content"; + document.body.append(content); + const onClose = vi.fn(); + const view = render(
); + const dialog = screen.getByRole("dialog"); + expect(dialog.parentElement).toBe(content); + expect(view.container.contains(dialog)).toBe(false); + expect(dialog.className).toContain("absolute"); + expect(dialog.className).toContain("top-0"); + expect(dialog.className).not.toContain("top-14"); + fireEvent.click(screen.getByRole("button", { name: "Close screenshot preview" })); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/test/rig-status-launch-control.test.tsx b/packages/ui/test/rig-status-launch-control.test.tsx index 2426a0331..1132fbbfd 100644 --- a/packages/ui/test/rig-status-launch-control.test.tsx +++ b/packages/ui/test/rig-status-launch-control.test.tsx @@ -52,6 +52,15 @@ const ALL_FRESH_PLAN = { describe("RigStatusCard — consumes the backend verdict in the render (19/21 lesson)", () => { afterEach(cleanup); + it.each([true, false])("uses a theme surface for kernel=%s without changing the light surface", (isKernel) => { + renderWithClient(); + const card = screen.getByTestId("rig-status-card-theme"); + expect(card.className).toContain("bg-surface-lowest"); + expect(card.className).toContain("dark:border-outline"); + }); + it("renders a non-green verdict for a blocked rig (does not default to green)", () => { renderWithClient( ({ + ...await importActual(), + useNavigate: () => vi.fn(), +})); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +it("settles after filtering, clearing and sorting a populated table", async () => { + vi.stubGlobal("fetch", vi.fn(async (url: string) => new Response(JSON.stringify( + url.includes("/api/rigs/summary") ? [{ id: "demo", name: "demo" }] + : url.includes("/api/rigs/demo/nodes") ? ["lead", "builder"].map(name => ({ + logicalId: `main.${name}`, canonicalSessionName: `${name}@demo`, + runtime: "terminal", sessionStatus: "running", podNamespace: "main", + })) : [], + )))); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + let commits = 0; + render( { + // Bound a runaway loop so this regression fails instead of hanging the test worker. + if (++commits > 40) throw new Error("Table did not settle within 40 commits"); + }}>); + await waitFor(() => expect(screen.getAllByTestId(/^topology-table-row-/)).toHaveLength(2)); + const search = screen.getByTestId("topology-table-search"); + for (const [value, count] of [["l", 2], ["lead", 1], ["missing", 0], ["", 2]] as const) { + await act(async () => { + fireEvent.change(search, { target: { value } }); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(screen.queryAllByTestId(/^topology-table-row-/)).toHaveLength(count); + } + await act(async () => { + fireEvent.change(search, { target: { value: "lead" } }); + fireEvent.click(screen.getAllByRole("columnheader")[0]!); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(screen.getAllByTestId(/^topology-table-row-/)).toHaveLength(1); + expect(commits).toBeLessThan(40); + client.clear(); +}); diff --git a/packages/ui/test/ui-maintenance-notice.test.tsx b/packages/ui/test/ui-maintenance-notice.test.tsx index 3e22f1654..4866a2dbc 100644 --- a/packages/ui/test/ui-maintenance-notice.test.tsx +++ b/packages/ui/test/ui-maintenance-notice.test.tsx @@ -1,12 +1,10 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("../src/routes.js", () => ({ router: {} })); +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { UI_MAINTENANCE_NOTICE_STORAGE_KEY, UiMaintenanceNotice, -} from "../src/App.js"; +} from "../src/components/UiMaintenanceNotice.js"; describe("UI maintenance notice", () => { beforeEach(() => { @@ -30,7 +28,8 @@ describe("UI maintenance notice", () => { const first = render(); const notice = screen.getByTestId("ui-maintenance-notice"); - expect(notice.className).toContain("fixed"); + expect(notice.className).not.toContain("fixed"); + expect(notice.className).toContain("shrink-0"); fireEvent.click(screen.getByRole("button", { name: "Dismiss maintenance notice" })); expect(screen.queryByTestId("ui-maintenance-notice")).toBeNull(); diff --git a/scripts/skill-edge-digests.generated.json b/scripts/skill-edge-digests.generated.json index 987bc162e..779496185 100644 --- a/scripts/skill-edge-digests.generated.json +++ b/scripts/skill-edge-digests.generated.json @@ -98,7 +98,7 @@ "refocusing/scripts/trace-to-root.py": "877f80f1c84e2b9a922ea7729b77378b25129b3cd44ce999ab7356b91f62747c", "retiring-and-inheriting-a-seat/CHANGELOG.md": "1756a0a6dadc015e2744b4c39a3e7cd08813d9d421e788e953ac5ad75b064197", "retiring-and-inheriting-a-seat/SKILL.md": "c03449dd9e9ba225cd06810e9008dfc086b6d433a28d80066d0edc0d9207d733", - "seat-continuity-and-handover/SKILL.md": "7419ddb966be5e3ca26376b3307a092dcf44db21acf65b1175519cc9a8c99fd8", + "seat-continuity-and-handover/SKILL.md": "ba5a704b1999891bf694f7248c6a43a789408fc0b82b7d91da63c18f3b26c870", "seat-continuity-and-handover/references/apprentice-evidence-toolkit.md": "a50bd7a3107762dbb6accd5d2fb7e058b408b85b09067ac79a7a8af6212234fd", "seat-continuity-and-handover/references/apprentice-successor-seat-cutover.md": "0c865b76c9b0908373d3cf17439ef31b08e20a769c1cef1bea2403d963887840", "seat-continuity-and-handover/references/orchestrator-role.md": "2ddf05125e0bf22e2dc64cb328fcc4bd94fa62eb2fcfd21d75f21c488c72ecaa",