mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-13 05:01:30 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75a56e530c | ||
|
|
1a2b1d5b75 | ||
|
|
2ae1df25a4 | ||
|
|
3d3eb9f09f | ||
|
|
9efe42b257 | ||
|
|
835526b565 | ||
|
|
3bb7a410d5 | ||
|
|
602ba44ff1 | ||
|
|
2d0280dc63 | ||
|
|
5dbc554b76 |
No files matched your search
@@ -119,3 +119,4 @@ When a regression reaches a release, add one line: date, issue, artery, and whic
|
||||
- 2026-11-10, F4 (0.7.11, #2662 leg (a)), delivery (draft-aware, under Claude's update banner): beneath Claude's bare update banner, a draft-aware delivery pasted the message and withheld Enter, leaving it in the composer, where 0.7.10 held it. Caught at the cut by a native delivery run and accepted with disclosure, so it reached 0.7.11. Missed by footer-grammar fixtures that had no paste hint beside the update notice. Fixed in #2786 for 0.7.12.
|
||||
- 2026-11-10, Pi case 1c (0.7.11, #2630), launch (Pi account home): a Pi seat with its own Pi login and a declared absolute `PI_CODING_AGENT_DIR` stopped at launch even with an `auth.json` there, with a misleading missing-account-file error, where 0.7.10 started it. Found at the cut by source and function-level tests, not natively, and accepted with disclosure, so it reached 0.7.11. Not fixed: #2803 (0.7.12) makes the launch error name the real cause, and the stop stands. Scenario owed: a Pi seat with its own login and a declared account home.
|
||||
- 2026-11-12, a tracked `AGENTS.md` (0.7.12, repaired by #3039), restore and relaunch (Codex guidance blocks): on a Codex seat, a snapshot `rig restore`, and `rig down` followed by `rig up --existing`, each wrote OpenRig's managed guidance blocks back into the person's tracked `AGENTS.md` (249 B to 17,316 B), leaving the worktree modified. Seen at the cut by a native Codex run through restore and `up --existing`, and accepted with disclosure, so it reached 0.7.12: its release text says to check `git status` after either. Snapshot restore was already a disclosed 0.7.11 limit; `up --existing` was newly observed, and whether it regressed isn't established. The check that would tell, the same `up --existing` run on the previous release, hasn't run. #3039 merged after 0.7.13's cut, so the repair is on main for 0.7.14, not in 0.7.13. Scenario owed: a Codex seat in a repository with a tracked `AGENTS.md`, checked with `git status` after a restore and after `up --existing`.
|
||||
- 2026-11-12, `rig queue create` latency (0.7.13), queue writes (the CLI-to-daemon create path): on one performance host, under a before/after rule frozen ahead of the run (six alternating arms of 100 creates each, at the same offered load), whole `rig queue create` invocations had a median of 304.6 ms against 0.7.12's 276.0 ms, +10.39% (about +29 ms), and the two versions' per-arm medians didn't overlap. The p95 moved from 320.1 to 349.3 ms. Send latency, and memory and CPU under load, stayed within the comparison's unchanged range; the idle-memory comparison was inconclusive. Split at the daemon's insert timestamp, both the part before the insert (CLI start, transport, the daemon's handling before the insert) and the part after it moved, so no single segment carries the change, and the cause is unassigned. Caught at the cut by the release's representative-load performance comparison and accepted with disclosure, so it reached 0.7.13. Missed by the package suites and stub scenarios, which don't time whole invocations against the previous release. Not fixed: 0.7.14's stability comparison times the same path with CLI startup, transport, daemon handling and exit recorded separately. Scenario owed: the same before/after create timing on retained history, on one host at a fixed offered load, with those boundaries recorded.
|
||||
@@ -10,7 +10,9 @@
|
||||
> runner is qualified beyond that.
|
||||
> - **Existing interfaces with author obligations:** launch-result identity, exact resume, first input and model
|
||||
> handoff. These interfaces do not establish that a particular runner implements them correctly.
|
||||
> - **Unchanged:** plain community presets and the built-in runtimes (Claude Code, Codex, Pi, OMP, terminal).
|
||||
> - **Current source:** per-launch credentials for hosted Pi, Pi-RPC presets and declared community runners (section 4).
|
||||
> This credential does not qualify new native input or cancellation behavior.
|
||||
> - **Unchanged:** plain community presets and the other built-in runtimes (Claude Code, Codex, OMP, terminal).
|
||||
>
|
||||
> **What has run natively, in 0.7.8's release testing, against a Claude Haiku model:**
|
||||
> - **dsh-seat 0.3.0**, an outside runner for dsh 0.2.1-rc.2 over ACP: launch and first input, useful queue work, a
|
||||
@@ -127,6 +129,29 @@ immediately before the preset's native command, after OpenRig's launcher assignm
|
||||
|
||||
## 4. Activity: report turn boundaries
|
||||
|
||||
Managed hosted Pi, Pi-RPC presets and declared community runners can receive an actual-launch credential in
|
||||
`OPENRIG_ACTIVITY_HOOK_TOKEN`, with their attempt in `OPENRIG_RUN_ID`. Keep this credential in the runner's own
|
||||
process; remove it from the native agent and tool environment. It is delivered through tmux's private environment,
|
||||
never as a literal launch argument or launch-result field. A report that supplies `runId` requires this bound
|
||||
credential; knowing a generation or the install token does not supply run authority.
|
||||
|
||||
Issuing or delivering this optional credential never blocks an otherwise supported launch or restore. If the launch
|
||||
anchor, PID, generation or private credential channel is unavailable, the existing unbound install-token path remains;
|
||||
that path adds no new run-authority claim and is not attributed to a selected run. A refused bound report stays
|
||||
refused: it is never silently retried with the install token. If native dispatch already occurred before activation
|
||||
failed, Core preserves the dispatched outcome and does not replay input; cleanup addresses only that launch's grant.
|
||||
Unreadable observations cannot revoke a grant. Confirmed exit/replacement or intentional lifecycle cleanup still can.
|
||||
|
||||
Core activates a pending credential after dispatch against its selected session, pane and recorded runner. A staged
|
||||
successor waits for the existing binding cutover; pending posts receive `activity_grant_pending` and must retry.
|
||||
An unreadable binding is `activity_grant_unverified` (409, retryable): it could not be verified now, not proven stale.
|
||||
Nothing is recorded and the grant is retained; retry when its binding can be checked again.
|
||||
Failed launches revoke their own credential. Relaunch replaces the active run; an explicit no-launch adoption can
|
||||
retain it only with the original concrete pane/process evidence and an adoption-only tenure continuation. These
|
||||
checks bind a runner's reports to its launch; they do not independently verify its claimed native identity. Existing
|
||||
host hooks, contained guest grants and install-token Pi usage keep their legacy behavior. Pi usage also accepts a
|
||||
current bound Pi credential. No native input channel or cancellation capability is implied by this credential.
|
||||
|
||||
Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer <token>`.
|
||||
- **Finding the base and token:** take them from `OPENRIG_URL` (or `OPENRIG_PORT`, with `OPENRIG_HOST` defaulting to
|
||||
`127.0.0.1`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else from `$OPENRIG_HOME/activity-endpoint.json`.
|
||||
@@ -401,6 +426,7 @@ answer. Only the seat's opt-in refuses and holds attention.
|
||||
| `stale_generation` | 409 | With the activity token: the post isn't from the seat's current occupant. | answers `cancelled` |
|
||||
| `activity_grant_wrong_seat` | 403 | The grant speaks for another seat. | answers `cancelled` |
|
||||
| `activity_grant_stale` | 409 | The grant's launch generation no longer occupies the seat. | answers `cancelled` |
|
||||
| `activity_grant_unverified` | 409 | The binding could not be verified now; this is retryable, not evidence of replacement or cancellation. | permits under the existing unavailable-answer policy |
|
||||
| `activity_grant_revoked` | 401 | The grant was revoked while its launch generation still occupies the seat, or before it has registered, so this may still be the live request; a replaced occupant's grant reads `activity_grant_stale` instead, even once revoked. | permits |
|
||||
| `activity_grant_pending` | 409 | The grant's launch hasn't registered its occupant yet. | permits |
|
||||
| `duplicate_request` | 409 | This request id was already submitted in another turn; an agent may reuse an id. | permits |
|
||||
|
||||
@@ -412,9 +412,12 @@ export function resolveWorkPosition(opts: {
|
||||
let sliceName: string | null = null;
|
||||
let frontier: WorkInstallAltitude = "project";
|
||||
if (opts.mission !== undefined) {
|
||||
missionRoot = join(missionsRoot, opts.mission);
|
||||
if (existsSync(missionRoot)) {
|
||||
const canonicalMissionRoot = canonicalExisting(missionRoot);
|
||||
const nominalMissionRoot = join(missionsRoot, opts.mission);
|
||||
if (!existsSync(nominalMissionRoot)) {
|
||||
// Not a refusal: the project's context still serves. Say so, and never report a folder that doesn't exist.
|
||||
warnings.push(`mission '${opts.mission}' not found under ${missionsRoot}${opts.slice !== undefined ? `, so slice '${opts.slice}' wasn't looked up` : ""}; using the project's context`);
|
||||
} else {
|
||||
const canonicalMissionRoot = canonicalExisting(nominalMissionRoot);
|
||||
if (!canonicalMissionRoot || !inside(projectRoot, canonicalMissionRoot)) {
|
||||
return failure("mission_root_escape", `mission '${opts.mission}' resolves outside the selected project root`);
|
||||
}
|
||||
|
||||
@@ -632,12 +632,23 @@ export const MEMBER_WORKFLOW = [
|
||||
|
||||
// Short guidance a client may show the model (the server's MCP instructions). An external caller's connection asks the
|
||||
// team for work and isn't one of its members, so it isn't given the member workflow.
|
||||
const SHARED_INSTRUCTIONS = [
|
||||
"This server reaches an OpenRig team as one seat: the one named by OPENRIG_SESSION_NAME where the server was configured, or, over authenticated HTTP, the seat of the caller's grant (rig_whoami shows which, and whether the caller is authenticated; rig_team shows a rig's seats and its lead). The daemon attributes this server's writes to that seat.",
|
||||
"To hand work to a seat, use rig_queue_create and keep its qitemId; read the answer later with rig_queue_show, which names the next item when the work was handed on. To pass on work this seat holds, use rig_queue_handoff rather than closing it and creating another, so the work stays one chain. Queue items are durable: they outlive this conversation and this server.",
|
||||
"Tools marked read-only change nothing. A seat whose activity is unknown is not idle, and an accepted request (a restore's attemptId) is not a finished one.",
|
||||
"A question reserved for a person is theirs to answer: don't answer it on their behalf. rig_queue_decide records a decision only inside the decision room of the mode they have on, beside their answers and never as one.",
|
||||
];
|
||||
/** The paragraphs every seat's connection shares, naming a tool only where `has` says the connection offers it. A local or
|
||||
* authenticated member is offered every tool named here; an external grant is offered only the ones its grant names. */
|
||||
function sharedInstructions(has: (tool: string) => boolean): string[] {
|
||||
const finding = [has("rig_whoami") ? "rig_whoami shows which, and whether the caller is authenticated" : "",
|
||||
has("rig_team") ? "rig_team shows a rig's seats and its lead" : ""].filter(Boolean).join("; ");
|
||||
const showing = "rig_queue_show, which names the next item when the work was handed on";
|
||||
const handing = has("rig_queue_create")
|
||||
? `To hand work to a seat, use rig_queue_create and keep its qitemId${has("rig_queue_show") ? `; read the answer later with ${showing}` : ""}. `
|
||||
: has("rig_queue_show") ? `Read an item's answer with ${showing}. ` : "";
|
||||
return [
|
||||
`This server reaches an OpenRig team as one seat: the one named by OPENRIG_SESSION_NAME where the server was configured, or, over authenticated HTTP, the seat of the caller's grant${finding ? ` (${finding})` : ""}. The daemon attributes this server's writes to that seat.`,
|
||||
`${handing}${has("rig_queue_handoff") ? "To pass on work this seat holds, use rig_queue_handoff rather than closing it and creating another, so the work stays one chain. " : ""}Queue items are durable: they outlive this conversation and this server.`,
|
||||
"Tools marked read-only change nothing. A seat whose activity is unknown is not idle, and an accepted request (a restore's attemptId) is not a finished one.",
|
||||
`A question reserved for a person is theirs to answer: don't answer it on their behalf.${has("rig_queue_decide") ? " rig_queue_decide records a decision only inside the decision room of the mode they have on, beside their answers and never as one." : ""}`,
|
||||
];
|
||||
}
|
||||
const SHARED_INSTRUCTIONS = sharedInstructions(() => true);
|
||||
const INSTRUCTIONS = [...SHARED_INSTRUCTIONS, MEMBER_WORKFLOW].join("\n");
|
||||
|
||||
/** rig mcp serve --front-door: the seat is a person's agent in their own app, the front door to their team, not a member
|
||||
@@ -671,15 +682,18 @@ const EXTERNAL_PERSON_INSTRUCTIONS = [
|
||||
"To answer one, show the person the question and its options as rig_question_list gives them. Then pass rig_question_answer its qitemId, the revision you showed, and either the options they chose or exactly what they typed, with no label or words of your own. If the question changed, show them the new one; if it was already answered or closed, say so. It's recorded as their answer, given through this connection. A question reserved for a person is theirs to answer, never yours.",
|
||||
].join("\n");
|
||||
|
||||
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. */
|
||||
const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS,
|
||||
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it and close it done, with your reply in resolution when it needs one; its sender reads that reply on the same item. Nothing promises to wake you, so check your items again.",
|
||||
].join("\n");
|
||||
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. It names
|
||||
* only the tools that grant offers. */
|
||||
function externalInboxInstructions(tools: ReadonlySet<string>): string {
|
||||
return [...sharedInstructions((tool) => tools.has(tool)),
|
||||
`Items addressed to your seat are yours${tools.has("rig_queue_list") ? ": rig_queue_list with owned set lists them" : ""}. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it and close it done, with your reply in resolution when it needs one; its sender reads that reply on the same item. Nothing promises to wake you, so check your items again.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function instructionsFor(access: McpAccess | undefined, frontDoor = false): string {
|
||||
if (!access?.external) return frontDoor ? FRONT_DOOR_INSTRUCTIONS : INSTRUCTIONS;
|
||||
const tools = externalTools(access.external);
|
||||
const instructions = tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? EXTERNAL_INBOX_INSTRUCTIONS
|
||||
const instructions = tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? externalInboxInstructions(tools)
|
||||
: tools.has("rig_question_list") || tools.has("rig_question_answer") ? EXTERNAL_PERSON_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
|
||||
return tools.has("rig_team") ? `Discover the team this grant connects you to by calling rig_team without rigId. A sole granted rig is selected automatically; with several, it returns the granted names to choose from.\n${instructions}` : instructions;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ it.each(["0", "person-terminal"])("real attach -> returned env -> actual From ->
|
||||
const db = createDb(); migrate(db, ALL_MIGRATIONS);
|
||||
const rigs = new RigRepository(db), pods = new PodRepository(db), sessions = new SessionRegistry(db), bus = new EventBus(db);
|
||||
const rig = rigs.createRig("sample-team"), person = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
|
||||
const operator = rigs.addNode(rig.id, "operator.agent", { runtime: "terminal" });
|
||||
const operator = rigs.addNode(rig.id, "operator.agent", { runtime: "claude-code" });
|
||||
sessions.updateBinding(operator.id, { tmuxSession: "operator-physical", tmuxPane: "%43" }); sessions.registerClaimedSession(operator.id, "operator-physical");
|
||||
const writes: Array<{ target: string; pane: string; text: string }> = [];
|
||||
const tmux = { hasSession: async () => true, probeSession: async () => ({ state: "present" }),
|
||||
@@ -92,7 +92,7 @@ it.each(["0", "person-terminal"])("real attach -> returned env -> actual From ->
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "operator-agent@sample-team"); vi.stubEnv("OPENRIG_NODE_ID", operator.id);
|
||||
await run(sendCommand(deps), ["send", from, "A reply to the actual From address.", "--json"]);
|
||||
expect(writes[1]).toMatchObject({ target: physical, pane: "%42" });
|
||||
expect(writes[1]?.text).toContain("A reply to the actual From address.");
|
||||
expect(writes[1]?.text).toBe("A reply to the actual From address.");
|
||||
expect(sessions.getBindingForNode(person.id)?.tmuxSession).toBe(physical);
|
||||
expect(sessions.getSessionsForRig(rig.id).filter(s => s.nodeId === person.id).map(s => s.sessionName)).toEqual([physical]);
|
||||
expect(rigs.getRig(rig.id)?.nodes.find(n => n.id === person.id)?.label).toBe("Taylor");
|
||||
|
||||
@@ -718,6 +718,32 @@ id: OPR.0.5.8.13
|
||||
});
|
||||
});
|
||||
|
||||
it("warns and keeps the project's context when the selected mission doesn't exist, with or without --slice", async () => {
|
||||
const missionsRoot = join(alphaRoot, "missions");
|
||||
for (const slice of [undefined, "01-live-work"]) {
|
||||
const result = await captureLogs(async () => {
|
||||
await makeCommand().parseAsync([
|
||||
"node", "rig", "context", "work-install",
|
||||
"--project", "alpha", "--mission", "release-0.7.13", ...(slice ? ["--slice", slice] : []), "--json",
|
||||
]);
|
||||
});
|
||||
expect(result.exitCode).toBeUndefined();
|
||||
const plan = JSON.parse(result.logs.join("")) as {
|
||||
position: { mission: string; missionRoot: string | null; sliceRoot: string | null; frontier: string };
|
||||
pieces: Array<{ altitude: string }>;
|
||||
warnings: string[];
|
||||
};
|
||||
expect(plan.position).toMatchObject({ mission: "release-0.7.13", missionRoot: null, sliceRoot: null, frontier: "project" });
|
||||
expect(plan.pieces.every((piece) => piece.altitude === "project")).toBe(true);
|
||||
expect(plan.warnings).toContain(`mission 'release-0.7.13' not found under ${missionsRoot}${slice ? `, so slice '${slice}' wasn't looked up` : ""}; using the project's context`);
|
||||
}
|
||||
const text = await captureLogs(async () => {
|
||||
await makeCommand().parseAsync(["node", "rig", "context", "work-install", "--project", "alpha", "--mission", "release-0.7.13"]);
|
||||
});
|
||||
expect(text.exitCode).toBeUndefined();
|
||||
expect(text.errLogs).toContain(`Warning: mission 'release-0.7.13' not found under ${missionsRoot}; using the project's context`);
|
||||
});
|
||||
|
||||
it("uses explicit mission composition as the strict slice-selection boundary", async () => {
|
||||
const missionRoot = join(alphaRoot, "missions", "alpha-active");
|
||||
writeFileSync(join(missionRoot, "slices", "01-live-work", "slice.yaml"), `schema: openrig.slice/v0alpha1
|
||||
|
||||
@@ -882,6 +882,37 @@ describe("MCP Server", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a full inbox grant", { queue: ["create", "show", "list", "claim", "update"], team: ["list"] }],
|
||||
["a narrow inbox grant", { queue: ["claim", "update"] }],
|
||||
])("%s's instructions name only tools its connection offers", async (_label, objects) => {
|
||||
const server = createMcpServer(mockClient(), { seat: "cus-1-caller@team",
|
||||
access: { principal: "cus-1", owner: false, rigs: ["team"], roots: [], workspace: false, external: { objects } } });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const external = new Client({ name: "external-client", version: "1.0.0" });
|
||||
await server.connect(serverTransport);
|
||||
await external.connect(clientTransport);
|
||||
const instructions = external.getInstructions() ?? "";
|
||||
const offered = new Set((await external.listTools()).tools.map((tool) => tool.name));
|
||||
const named = [...new Set(instructions.match(/\brig_[a-z_]+/g) ?? [])];
|
||||
expect(instructions).toContain("claim it (rig_queue_claim)");
|
||||
expect(named.filter((tool) => !offered.has(tool))).toEqual([]);
|
||||
expect(instructions).not.toMatch(/rig_whoami|rig_queue_handoff|rig_queue_decide/);
|
||||
expect(instructions.includes("rig_team")).toBe(offered.has("rig_team"));
|
||||
await external.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a member's instructions keep every shared paragraph word for word", async () => {
|
||||
const instructions = mcpClient.getInstructions() ?? "";
|
||||
for (const paragraph of [
|
||||
"This server reaches an OpenRig team as one seat: the one named by OPENRIG_SESSION_NAME where the server was configured, or, over authenticated HTTP, the seat of the caller's grant (rig_whoami shows which, and whether the caller is authenticated; rig_team shows a rig's seats and its lead). The daemon attributes this server's writes to that seat.",
|
||||
"To hand work to a seat, use rig_queue_create and keep its qitemId; read the answer later with rig_queue_show, which names the next item when the work was handed on. To pass on work this seat holds, use rig_queue_handoff rather than closing it and creating another, so the work stays one chain. Queue items are durable: they outlive this conversation and this server.",
|
||||
"Tools marked read-only change nothing. A seat whose activity is unknown is not idle, and an accepted request (a restore's attemptId) is not a finished one.",
|
||||
"A question reserved for a person is theirs to answer: don't answer it on their behalf. rig_queue_decide records a decision only inside the decision room of the mode they have on, beside their answers and never as one.",
|
||||
]) expect(instructions).toContain(paragraph);
|
||||
});
|
||||
|
||||
it("shows a connected assistant's person plain titles and openings, keeping each tool's rules and hints", async () => {
|
||||
// What a person sees: each tool's title and the first sentence of its description.
|
||||
const plain: Record<string, [title: string, sentence: string]> = {
|
||||
|
||||
@@ -30,6 +30,8 @@ on an OpenRig team load `openrig-core` instead.
|
||||
1. OpenRig is installed and runs a team on this machine: `rig ps` lists it.
|
||||
2. Pick an address for this app on that team, such as `cursor@my-team`. The queue takes work for it; nothing is
|
||||
launched. To tell the team what it's for, add it to a roster as a hosted member (`docs/reference/hosted-members.md`).
|
||||
3. **Claude Code 2.1.119 or later.** On earlier versions, while the plugin's OpenRig seat is blank, Claude Code silently
|
||||
drops its MCP server: the skill loads, but your agent has no OpenRig tools.
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ already does it, or two verbs crossed already answer it. **You cannot look up a
|
||||
not know exists.** So the point of what follows is not skill. It is recognition: that when a task
|
||||
lands, something rhymes, and you go check instead of building.
|
||||
|
||||
Ninety-two top-level verbs ship (capability canon refreshed through
|
||||
`capability-delta-v0.7.12`). This marker describes the pack's teaching, not publication or live
|
||||
Ninety-three top-level verbs ship (capability canon refreshed through
|
||||
`capability-delta-v0.7.13`). This marker describes the pack's teaching, not publication or live
|
||||
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
|
||||
What changed in the installed version, and what to do about it, is one read away:
|
||||
`rig context get reference/whats-new.md`.
|
||||
@@ -749,7 +749,10 @@ not have to tear one down to change it, and you rarely have to start from nothin
|
||||
conversation continuity stays unverified. Both refuse while typing protection is on.
|
||||
- **`rig unclaim` / `rig release`** — stop managing one adopted session, or all of them,
|
||||
**without killing the process or the agent inside it**. Handing something back to its human is a
|
||||
first-class move, not an abandonment.
|
||||
first-class move, not an abandonment. To stop an adopted session after unclaiming it, end its
|
||||
process and its tmux session; closing only the viewing window detaches it. Since 0.7.13 a fresh
|
||||
`rig launch` after `rig unclaim` ended the server's last session reopens the server (a released
|
||||
session that's still running blocks it, as before).
|
||||
|
||||
> **Two words that mean different things at different altitudes.** `unclaim` and `release` each
|
||||
> exist both here — *stop managing a live session* — and in the queue — *put a work row back down*.
|
||||
@@ -866,7 +869,9 @@ version, from checking the environment to a useful report to the OpenRig team.
|
||||
effectful handover path.** Both forms perform the operation by default; pass `--dry-run`
|
||||
to plan without changing the seat. Check the selected source and continuity evidence first. A
|
||||
seat can be handed over again into its own pane, so a seat left half-handed-over recovers by
|
||||
running the handover again. A Claude seat started through a `#!` wrapper that runs Claude without
|
||||
running the handover again. A handover whose successor was launched but not ready says it may be
|
||||
running: look at the pane before retrying. A handover cleans up the retiring occupant's own claims
|
||||
and jobs. A Claude seat started through a `#!` wrapper that runs Claude without
|
||||
`exec`, or through the installer's `~/.local/bin/claude` symlink, rebuilds with `--source
|
||||
rebuild`; a failed rebuild leaves the resume target on the earlier conversation, so rebuild again
|
||||
rather than relaunching it fresh.
|
||||
@@ -1231,6 +1236,9 @@ reaching them is ordinary work rather than an escalation.
|
||||
see which host you are acting on, and check a registered host is *actually* usable end to end.
|
||||
`rig host edit <id>` corrects an entry (its id and transport stay fixed), and `rig host remove <id>` deletes
|
||||
only the registry entry: credential files, the learned identity and the selection stay as they are.
|
||||
- **`rig network`** — **experimental, unsupported and at your own risk.** It connects one local seat
|
||||
to a hub you already have. The daemon starts the connector, sweeps leftover credential folders and
|
||||
schedules local rounds even with no binding, so it isn't inert, and its migrations apply on upgrade.
|
||||
- **A cross-host queue write that times out reads INDETERMINATE, not failed.** The CLI waits 15
|
||||
seconds for the daemon's answer, and `rig queue create` with no answer reads the row back once by
|
||||
its ID. Reconcile by that ID before any retry; don't send the write twice.
|
||||
@@ -1275,6 +1283,11 @@ reaching them is ordinary work rather than an escalation.
|
||||
a person can decide directly in the TUI's pairing settings. A relay connector that can't accept
|
||||
incoming connections can pull instead (`OPENRIG_RELAY_MODE=pull` in the relay secrets file); push
|
||||
stays the default, and a person's connector binding can be unset.
|
||||
**Since 0.7.13 a person's runner answer gets a note back:** who decides and in what state, or that
|
||||
the answer is kept and the tool hasn't run, and a runner waiting on it wakes on the decision. Read
|
||||
that note before asking the person to answer again. The answer a receiving agent reads says what an
|
||||
answer typed at the machine's terminal means, to act on a clear one with the request's own decision
|
||||
command, and how to ask the person back, with a new ask to them, when it's unclear.
|
||||
- **The `relay` gateway connector** carries a registered person's questions, updates and notices to a
|
||||
bridge process you run, over loopback HTTP, and takes their replies back with the sender resolved
|
||||
against their bindings. It's off until a person has a relay binding (`rig gateway human add <id> …
|
||||
@@ -1413,7 +1426,8 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
conversation it is in the middle of. **`provider signals` reports anomalies rather than a
|
||||
listing** — unbound seats, accounts shared across several seats. A Codex seat showing its
|
||||
usage-limit banner reads activity `unknown` with reason `usage_limit`, and keeps it while its
|
||||
screen redraws; `provider signals` can show a provider-event signal for that seat's account that
|
||||
screen redraws (a banner with a curly apostrophe included); a Claude seat whose usage-limit message
|
||||
states a reset time is parked until then, instead of being retried every few minutes; `provider signals` can show a provider-event signal for that seat's account that
|
||||
allows a switch decision. With `provider.usage_windows` on (off by default), `rig provider status`
|
||||
shows Codex's windows and Claude's 5-hour and weekly windows from the seats' status lines, each
|
||||
seat's configured account, and with `--all-hosts` or `--hosts <ids>` every registered machine's
|
||||
@@ -1484,7 +1498,11 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
(empty, the default, means every signed-in one). `--router-line yes|no` decides whether OpenRig
|
||||
adds its one line to shared guidance files (`skills.router_line.enabled`).
|
||||
Setup offers to register the person when none is registered, and a team started from the
|
||||
person's own assistant fits their own logins, with a review before anything is committed.
|
||||
person's own assistant fits their own logins, with a review before anything is committed. Since
|
||||
0.7.13, `rig daemon start` ends with that offer when the person registry can be read and holds
|
||||
nobody. The kernel operator's startup is also taught to run `rig gateway participants` before its
|
||||
first greeting and offer registration when nobody is registered. The agent asks first and
|
||||
registers only on the person's yes.
|
||||
- **`rig usage series`** — what a seat's token curve has looked like over time: climbing steadily,
|
||||
reset, or stopped reporting entirely. The last one is a signal, not a gap.
|
||||
- **`rig usage top`** — which of this machine's seats used the most tokens over a window (`--window
|
||||
@@ -1581,6 +1599,9 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
with secret names, never values): OpenRig then POSTs a pointer to the row when work lands, and a
|
||||
2xx records only that the ring was accepted, not that anything woke. When work waits for a
|
||||
member nothing can wake, or a rung member doesn't claim it in time, its person is told.
|
||||
A hosted member's roster entry shows whether it's checking in: its expected cadence and its last
|
||||
check-in, read as checking in, overdue, not yet checked in or checking in on request, and unknown
|
||||
when that can't be read. It's a status, never a gate on delivery.
|
||||
**Internet-facing hosted members are experimental, unsupported, off by default and at your
|
||||
own risk.** Local assistants can connect locally. Internet access needs your own TLS tunnel or
|
||||
proxy and a narrowly scoped, revocable token; see `reference/hosted-members.md`. Task terminal
|
||||
@@ -1676,6 +1697,6 @@ deliberate writing.
|
||||
|
||||
**`rig --help`**, then `rig <verb> --help`.
|
||||
|
||||
Ninety-two verbs ship, and this page named most of them once. **You will not remember which — you
|
||||
Ninety-three verbs ship, and this page named most of them once. **You will not remember which — you
|
||||
are meant to remember only that the list is long enough to be worth reading before you build
|
||||
anything.** The question is never *how do I write this*; it is **does this already exist**.
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RunnerGrantIssuer, RunnerGrantLaunch } from "../domain/runner-grant-launch.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { EnvironmentStartup } from "../domain/environment-startup.js";
|
||||
import type {
|
||||
RuntimeAdapter,
|
||||
@@ -28,6 +30,7 @@ import { PiCommunityRuntimeAdapter } from "./pi-community-runtime-adapter.js";
|
||||
|
||||
export interface CommunityHarnessAdapterDeps {
|
||||
environmentStartup?: EnvironmentStartup;
|
||||
runnerGrants?: RunnerGrantIssuer;
|
||||
tmux: Pick<TmuxAdapter, "hasSession" | "getPaneCommand" | "sendShellCommand"> & Partial<Pick<TmuxAdapter, "capturePaneContent" | "listPanes">>;
|
||||
seatLaunchEnvironment?: Pick<SeatLaunchEnvironment, "command" | "resolveLaunchWrapper">;
|
||||
presetsDir?: () => string;
|
||||
@@ -173,6 +176,8 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
|
||||
onSent?: () => void, launcherPrefix = ""): Promise<HarnessLaunchResult> {
|
||||
if (preset.namesConversation) return this.launchNamed(binding, session, preset.command, resumeRef, beforeInput, onSent, launcherPrefix);
|
||||
let attempt: LaunchAttempt | undefined;
|
||||
let authority: RunnerGrantLaunch | undefined;
|
||||
let launchSucceeded = false;
|
||||
// The record is optional: a folder that can't be made leaves the identity unavailable, and the harness still launches.
|
||||
let storageNote: string | undefined;
|
||||
if (preset.launchResult) {
|
||||
@@ -188,41 +193,72 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
|
||||
storageNote = `community harness "${this.runtime}": its launch identity is unavailable: its launch-result folder couldn't be made (${(error as Error).message})`;
|
||||
}
|
||||
}
|
||||
let command: string;
|
||||
const runId = attempt?.attempt ?? randomUUID();
|
||||
let authorityUnavailable = false;
|
||||
let dispatchedWithoutAuthority = false;
|
||||
if (preset.runner) {
|
||||
try { authority = await this.deps.runnerGrants?.(binding, this.runtime, runId, 1); }
|
||||
catch { authorityUnavailable = true; }
|
||||
}
|
||||
try {
|
||||
command = this.deps.seatLaunchEnvironment
|
||||
? await this.deps.seatLaunchEnvironment.command(session, preset.command, { runtime: this.runtime, nodeId: binding.nodeId, launcherPrefix,
|
||||
...(binding.launchWrapper?.length ? { failClosed: true, ...(!preset.runner ? { launchWrapper: binding.launchWrapper } : {}) } : {}) })
|
||||
: launcherPrefix + preset.command;
|
||||
} catch (error) {
|
||||
if (!binding.launchWrapper?.length) throw error;
|
||||
return { ok: false, error: "launch_wrapper unavailable; no agent started." };
|
||||
let command: string;
|
||||
try {
|
||||
command = this.deps.seatLaunchEnvironment
|
||||
? await this.deps.seatLaunchEnvironment.command(session, preset.command, { runtime: this.runtime, nodeId: binding.nodeId, launcherPrefix, ...(authority ? { generation: authority.generation } : {}),
|
||||
...(binding.launchWrapper?.length ? { failClosed: true, ...(!preset.runner ? { launchWrapper: binding.launchWrapper } : {}) } : {}) })
|
||||
: launcherPrefix + preset.command;
|
||||
} catch (error) {
|
||||
if (!binding.launchWrapper?.length) throw error;
|
||||
return { ok: false, error: "launch_wrapper unavailable; no agent started." };
|
||||
}
|
||||
const fallback = command;
|
||||
if (authority) command = authority.wrap(command);
|
||||
let sent = await this.deps.tmux.sendShellCommand(session, command, beforeInput, { stageIfLong: true, execInScript: true });
|
||||
if (!sent.ok && sent.code === "launch_path_too_long" && authority && command !== fallback) {
|
||||
// This result proves no input was dispatched. Retry only the same pre-grant command.
|
||||
try { await authority.finish(false); } catch { /* optional cleanup cannot block the original launch */ }
|
||||
authority = undefined; authorityUnavailable = true;
|
||||
sent = await this.deps.tmux.sendShellCommand(session, fallback, beforeInput, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (sent.ok) {
|
||||
try { authority?.dispatched(); }
|
||||
catch {
|
||||
try { await authority?.finish(false); } catch { /* preserve the already dispatched effect */ }
|
||||
authority = undefined; authorityUnavailable = true; dispatchedWithoutAuthority = true;
|
||||
}
|
||||
}
|
||||
if (!sent.ok) return { ok: false, error: `Couldn't start community harness "${this.runtime}": ${sent.message}` };
|
||||
onSent?.();
|
||||
// A runner reports its conversation in this record; without one, its seat needs a person rather than reading ready.
|
||||
if (!attempt && storageNote && preset.runner) return { ok: false, recovery: "attention_required", error: storageNote };
|
||||
if (!attempt) { launchSucceeded = true; return storageNote ? { ok: true, warnings: [storageNote] } : { ok: true }; }
|
||||
const read = await waitForLaunchResult(attempt, this.runtime, { ...this.deps.launchResultWait, root: this.deps.launchResultRoot?.() ?? launchResultRoot() });
|
||||
if (resumeRef) {
|
||||
// A conversation that's gone is never replaced by a new one: nothing started, and the person decides.
|
||||
if (!read.ok && "missing" in read) {
|
||||
return { ok: false, missingConversation: true, error: `Couldn't resume community harness "${this.runtime}": conversation ${resumeRef}: ${read.reason}.` };
|
||||
}
|
||||
// Unconfirmed or different: the seat keeps the conversation it asked for, and the person decides. No retry.
|
||||
if (!read.ok) {
|
||||
return { ok: false, recovery: "attention_required", resumeUnproven: true,
|
||||
error: `Community harness "${this.runtime}": resuming conversation ${resumeRef} isn't confirmed: ${read.reason}. The seat keeps conversation ${resumeRef}; nothing was restarted.` };
|
||||
}
|
||||
if (read.nativeRef !== resumeRef) {
|
||||
return { ok: false, recovery: "attention_required", resumeMismatch: { opened: read.nativeRef },
|
||||
error: `Community harness "${this.runtime}": asked to resume conversation ${resumeRef}, its launcher reports conversation ${read.nativeRef} instead. The seat keeps conversation ${resumeRef}, and OpenRig doesn't switch it to ${read.nativeRef}. Nothing was restarted.` };
|
||||
}
|
||||
}
|
||||
if (read.ok) { launchSucceeded = true; return { ok: true, resumeToken: read.nativeRef, resumeType: `community:${this.runtime}`, resumeProvenance: "launcher",
|
||||
...(authority ? { activityGrantId: authority.grantId } : {}),
|
||||
...(authorityUnavailable ? { warnings: [dispatchedWithoutAuthority
|
||||
? "Native command dispatched; run-bound reporting could not be confirmed. Cleanup of its grant was attempted; nothing was replayed."
|
||||
: "Run-bound reporting unavailable; using existing unbound install-token reporting."] } : {}) }; }
|
||||
const unavailable = `community harness "${this.runtime}": its launch identity is unavailable: ${read.reason}`;
|
||||
return preset.runner ? { ok: false, recovery: "attention_required", error: unavailable } : { ok: true, warnings: [unavailable] };
|
||||
|
||||
} finally {
|
||||
try { await authority?.finish(launchSucceeded); } catch { /* optional reporting cannot change a native launch outcome */ }
|
||||
}
|
||||
const sent = await this.deps.tmux.sendShellCommand(session, command, beforeInput, { stageIfLong: true, execInScript: true });
|
||||
if (!sent.ok) return { ok: false, error: `Couldn't start community harness "${this.runtime}": ${sent.message}` };
|
||||
onSent?.();
|
||||
// A runner reports its conversation in this record; without one, its seat needs a person rather than reading ready.
|
||||
if (!attempt && storageNote && preset.runner) return { ok: false, recovery: "attention_required", error: storageNote };
|
||||
if (!attempt) return storageNote ? { ok: true, warnings: [storageNote] } : { ok: true };
|
||||
const read = await waitForLaunchResult(attempt, this.runtime, { ...this.deps.launchResultWait, root: this.deps.launchResultRoot?.() ?? launchResultRoot() });
|
||||
if (resumeRef) {
|
||||
// A conversation that's gone is never replaced by a new one: nothing started, and the person decides.
|
||||
if (!read.ok && "missing" in read) {
|
||||
return { ok: false, missingConversation: true, error: `Couldn't resume community harness "${this.runtime}": conversation ${resumeRef}: ${read.reason}.` };
|
||||
}
|
||||
// Unconfirmed or different: the seat keeps the conversation it asked for, and the person decides. No retry.
|
||||
if (!read.ok) {
|
||||
return { ok: false, recovery: "attention_required", resumeUnproven: true,
|
||||
error: `Community harness "${this.runtime}": resuming conversation ${resumeRef} isn't confirmed: ${read.reason}. The seat keeps conversation ${resumeRef}; nothing was restarted.` };
|
||||
}
|
||||
if (read.nativeRef !== resumeRef) {
|
||||
return { ok: false, recovery: "attention_required", resumeMismatch: { opened: read.nativeRef },
|
||||
error: `Community harness "${this.runtime}": asked to resume conversation ${resumeRef}, its launcher reports conversation ${read.nativeRef} instead. The seat keeps conversation ${resumeRef}, and OpenRig doesn't switch it to ${read.nativeRef}. Nothing was restarted.` };
|
||||
}
|
||||
}
|
||||
if (read.ok) return { ok: true, resumeToken: read.nativeRef, resumeType: `community:${this.runtime}`, resumeProvenance: "launcher" };
|
||||
const unavailable = `community harness "${this.runtime}": its launch identity is unavailable: ${read.reason}`;
|
||||
return preset.runner ? { ok: false, recovery: "attention_required", error: unavailable } : { ok: true, warnings: [unavailable] };
|
||||
}
|
||||
|
||||
/** OpenRig names the conversation: a new one gets a fresh name in OPENRIG_CONVERSATION, and a resume passes the saved name
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RunnerGrantLaunch } from "../domain/runner-grant-launch.js";
|
||||
// OPR.0.4.6.PI1 FR-6 — Pi seat resume (mirrors codex-resume.ts).
|
||||
//
|
||||
// Resume is HONEST session-file continuation: relaunch the pi-runner with
|
||||
@@ -29,6 +30,8 @@ export interface PiResumeFsOps {
|
||||
}
|
||||
|
||||
interface PiResumeOptions {
|
||||
/** Core resolves the registered restore binding; the runner never supplies it. */
|
||||
runnerGrant?: (session: string, runId: string, cwd: string) => Promise<RunnerGrantLaunch>;
|
||||
/** Resolve the persisted/configured account without relying on live tmux metadata. */
|
||||
accountHomeForSession?: (session: string) => string | undefined;
|
||||
stateRootForSession?: (session: string) => string;
|
||||
@@ -105,64 +108,92 @@ export class PiResumeAdapter {
|
||||
// only states stamped with THIS attempt's launchId. The prior record is
|
||||
// read FIRST so the durable catch-up cursor (lastEntryId, FR-5) survives.
|
||||
const launchId = (this.options.newLaunchId ?? (() => randomUUID()))();
|
||||
const prior = this.fs.exists(seat.runnerStatePath) ? parsePiRunnerState(this.readSafe(seat.runnerStatePath)) : null;
|
||||
this.fs.writeFile(
|
||||
seat.runnerStatePath,
|
||||
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
|
||||
);
|
||||
let authority: RunnerGrantLaunch | undefined;
|
||||
let authorityUnavailable = false;
|
||||
let dispatchedWithoutAuthority = false;
|
||||
if (this.runtime === "pi") {
|
||||
try { authority = await this.options.runnerGrant?.(tmuxSessionName, launchId, cwd); }
|
||||
catch { authorityUnavailable = true; }
|
||||
}
|
||||
let launchSucceeded = false;
|
||||
try {
|
||||
const prior = this.fs.exists(seat.runnerStatePath) ? parsePiRunnerState(this.readSafe(seat.runnerStatePath)) : null;
|
||||
this.fs.writeFile(
|
||||
seat.runnerStatePath,
|
||||
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
|
||||
);
|
||||
|
||||
const trust = this.runtime === "omp"
|
||||
? (yoloEnabled(process.env, resolvedPosture) ? "approve" : "no-approve")
|
||||
: piTrust(this.options.trustPosture, process.env, resolvedPosture);
|
||||
const appliedLaunch = this.runtime === "omp"
|
||||
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
|
||||
: observePiResourceTrust(trust);
|
||||
let nodeId: string | undefined;
|
||||
if (this.runtime === "pi" && this.options.seatLaunchEnvironment) {
|
||||
try { nodeId = await this.tmux.getSessionEnv(tmuxSessionName, "OPENRIG_NODE_ID") ?? undefined; }
|
||||
catch { /* Account-free resume retains best-effort identity correction. */ }
|
||||
}
|
||||
const accountHome = this.runtime === "pi" ? this.options.accountHomeForSession?.(tmuxSessionName)
|
||||
?? (nodeId ? this.options.seatLaunchEnvironment?.accountHome(nodeId, this.runtime) : undefined) : undefined;
|
||||
if (!nodeId && this.runtime === "pi" && (accountHome || this.fs.exists(`${seat.agentDir}/.openrig-account`))) {
|
||||
return { ok: false, code: "resume_failed", message: "Pi resume needs seat identity to preserve its selected account; restore identity and retry resume." };
|
||||
}
|
||||
if (!nodeId && this.runtime === "pi" && this.options.seatLaunchEnvironment) console.warn("[pi-resume] Seat identity unavailable; account-free resume continues with best-effort routing.");
|
||||
const cmd = buildPiRunnerCommand({
|
||||
accountHome,
|
||||
runtime: this.runtime,
|
||||
runnerEntryPath: this.paths.runnerEntryPath,
|
||||
sessionName: tmuxSessionName,
|
||||
stateRoot: this.options.stateRootForSession?.(tmuxSessionName) ?? this.paths.stateRoot,
|
||||
cwd,
|
||||
// The model declaration must survive resume: the runner's provider-key
|
||||
// allowlist keys off the declared provider (VM leg finding).
|
||||
model: model ?? undefined,
|
||||
thinking: this.runtime === "pi" ? effort ?? undefined : undefined,
|
||||
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). Same decision as the launch path:
|
||||
// YOLO forces `approve` on every restored seat; otherwise the configured posture.
|
||||
trust,
|
||||
sessionFile,
|
||||
launchId,
|
||||
usageFacts: this.runtime === "pi" && (this.options.usageFactsEnabled?.() ?? false),
|
||||
openRouterCharge: this.runtime === "pi" && (this.options.openRouterChargeEnabled?.() ?? false),
|
||||
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
|
||||
});
|
||||
const trust = this.runtime === "omp"
|
||||
? (yoloEnabled(process.env, resolvedPosture) ? "approve" : "no-approve")
|
||||
: piTrust(this.options.trustPosture, process.env, resolvedPosture);
|
||||
const appliedLaunch = this.runtime === "omp"
|
||||
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
|
||||
: observePiResourceTrust(trust);
|
||||
let nodeId: string | undefined;
|
||||
if (this.runtime === "pi" && this.options.seatLaunchEnvironment) {
|
||||
try { nodeId = await this.tmux.getSessionEnv(tmuxSessionName, "OPENRIG_NODE_ID") ?? undefined; }
|
||||
catch { /* Account-free resume retains best-effort identity correction. */ }
|
||||
}
|
||||
const accountHome = this.runtime === "pi" ? this.options.accountHomeForSession?.(tmuxSessionName)
|
||||
?? (nodeId ? this.options.seatLaunchEnvironment?.accountHome(nodeId, this.runtime) : undefined) : undefined;
|
||||
if (!nodeId && this.runtime === "pi" && (accountHome || this.fs.exists(`${seat.agentDir}/.openrig-account`))) {
|
||||
return { ok: false, code: "resume_failed", message: "Pi resume needs seat identity to preserve its selected account; restore identity and retry resume." };
|
||||
}
|
||||
if (!nodeId && this.runtime === "pi" && this.options.seatLaunchEnvironment) console.warn("[pi-resume] Seat identity unavailable; account-free resume continues with best-effort routing.");
|
||||
const cmd = buildPiRunnerCommand({
|
||||
accountHome,
|
||||
runtime: this.runtime,
|
||||
runnerEntryPath: this.paths.runnerEntryPath,
|
||||
sessionName: tmuxSessionName,
|
||||
stateRoot: this.options.stateRootForSession?.(tmuxSessionName) ?? this.paths.stateRoot,
|
||||
cwd,
|
||||
// The model declaration must survive resume: the runner's provider-key
|
||||
// allowlist keys off the declared provider (VM leg finding).
|
||||
model: model ?? undefined,
|
||||
thinking: this.runtime === "pi" ? effort ?? undefined : undefined,
|
||||
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). Same decision as the launch path:
|
||||
// YOLO forces `approve` on every restored seat; otherwise the configured posture.
|
||||
trust,
|
||||
sessionFile,
|
||||
launchId,
|
||||
usageFacts: this.runtime === "pi" && (this.options.usageFactsEnabled?.() ?? false),
|
||||
openRouterCharge: this.runtime === "pi" && (this.options.openRouterChargeEnabled?.() ?? false),
|
||||
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
|
||||
});
|
||||
|
||||
const fallback = cmd;
|
||||
const command = this.runtime === "pi" && this.options.seatLaunchEnvironment
|
||||
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: this.runtime, nodeId, accountHome })
|
||||
: fallback;
|
||||
// Short commands retain the direct path; long commands exec from a private script.
|
||||
let textResult = await this.tmux.sendShellCommand(tmuxSessionName, command, undefined, { stageIfLong: true, execInScript: true });
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== fallback && accountHome === undefined) {
|
||||
textResult = await this.tmux.sendShellCommand(tmuxSessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
|
||||
const fallback = cmd;
|
||||
let command = this.runtime === "pi" && this.options.seatLaunchEnvironment
|
||||
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: this.runtime, nodeId, accountHome, ...(authority ? { generation: authority.generation } : {}) })
|
||||
: fallback;
|
||||
const originalCommand = command;
|
||||
if (authority) command = authority.wrap(command);
|
||||
// Short commands retain the direct path; long commands exec from a private script.
|
||||
let textResult = await this.tmux.sendShellCommand(tmuxSessionName, command, undefined, { stageIfLong: true, execInScript: true });
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && authority && command !== originalCommand) {
|
||||
try { await authority.finish(false); } catch { /* optional authority cleanup cannot prevent the original resume */ }
|
||||
authority = undefined; authorityUnavailable = true;
|
||||
textResult = await this.tmux.sendShellCommand(tmuxSessionName, originalCommand, undefined, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && originalCommand !== fallback && accountHome === undefined) {
|
||||
textResult = await this.tmux.sendShellCommand(tmuxSessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (!textResult.ok) {
|
||||
return { ok: false, code: "resume_failed", message: textResult.message };
|
||||
}
|
||||
try { authority?.dispatched(); }
|
||||
catch {
|
||||
try { await authority?.finish(false); } catch { /* native dispatch already happened; never replay */ }
|
||||
authority = undefined; authorityUnavailable = true; dispatchedWithoutAuthority = true;
|
||||
}
|
||||
const result = await this.verifyResume(tmuxSessionName, sessionFile, launchId);
|
||||
launchSucceeded = result.ok;
|
||||
return result.ok ? { ...result, appliedLaunch,
|
||||
...(authorityUnavailable ? { warnings: [dispatchedWithoutAuthority
|
||||
? "Native command dispatched; run-bound reporting could not be confirmed. Cleanup of its grant was attempted; nothing was replayed."
|
||||
: "Run-bound reporting unavailable; using existing unbound install-token reporting."] } : {}) } : result;
|
||||
} finally {
|
||||
try { await authority?.finish(launchSucceeded); } catch { /* optional reporting cannot turn a completed resume into failure */ }
|
||||
}
|
||||
if (!textResult.ok) {
|
||||
return { ok: false, code: "resume_failed", message: textResult.message };
|
||||
}
|
||||
const result = await this.verifyResume(tmuxSessionName, sessionFile, launchId);
|
||||
return result.ok ? { ...result, appliedLaunch } : result;
|
||||
}
|
||||
|
||||
// Poll the runner's launch-scoped sidecar ONLY — never stale pane
|
||||
|
||||
@@ -827,7 +827,7 @@ export class RunnerCore {
|
||||
|
||||
constructor(
|
||||
private io: RunnerIo,
|
||||
private identity: { sessionName: string; nodeId?: string; launchId?: string; generation?: string },
|
||||
private identity: { sessionName: string; nodeId?: string; launchId?: string; generation?: string; runId?: string },
|
||||
private opts: { catchUpSince?: string; runtime?: RunnerRuntime | string; usageFacts?: boolean; openRouterCharge?: boolean;
|
||||
/** K4: the credential observation taken before Pi was spawned. */
|
||||
launchCredentialEvidence?: PiCredentialEvidence;
|
||||
@@ -1314,11 +1314,11 @@ export class RunnerCore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-deliver OMP identity until the daemon confirms the resume token.
|
||||
/** Re-deliver OMP or managed run identity until the daemon confirms the resume token.
|
||||
* Called from a timer so a daemon restart during the first turn cannot
|
||||
* leave a seat with history but no restorable token. */
|
||||
retrySessionIdentity(): void {
|
||||
if (this.runtime === "omp" && this.ready) this.postSessionIdentity();
|
||||
if ((this.runtime === "omp" || this.identity.runId) && this.ready) this.postSessionIdentity();
|
||||
}
|
||||
|
||||
/** A resume that came up on another session file started a different session: the requested one stays the seat's. */
|
||||
@@ -1330,9 +1330,10 @@ export class RunnerCore {
|
||||
// Never the identity of a session other than the one this launch resumes: it would replace the seat's resume token.
|
||||
if (this.resumedElsewhere()) return;
|
||||
if (this.runtime === "omp" && (!this.sessionFile || !this.io.sessionFileExists?.(this.sessionFile))) return;
|
||||
if (this.runtime === "omp" && (this.identityPersisted || this.identityInFlight)) return;
|
||||
if ((this.runtime === "omp" || this.identity.runId) && (this.identityPersisted || this.identityInFlight)) return;
|
||||
const result = this.io.postActivity({
|
||||
eventFamily: "session_identity",
|
||||
...(this.identity.runId ? { runId: this.identity.runId } : {}),
|
||||
sessionName: this.identity.sessionName,
|
||||
nodeId: this.identity.nodeId ?? null,
|
||||
runtime: this.runtime,
|
||||
@@ -1342,7 +1343,7 @@ export class RunnerCore {
|
||||
sessionFile: this.sessionFile ?? null,
|
||||
occurredAt: this.io.now(),
|
||||
});
|
||||
if (this.runtime !== "omp" || !result) return;
|
||||
if ((this.runtime !== "omp" && !this.identity.runId) || !result) return;
|
||||
this.identityInFlight = true;
|
||||
void result.then(
|
||||
(body) => { if (body?.tokenPersisted === true) this.identityPersisted = true; },
|
||||
@@ -1360,6 +1361,7 @@ export class RunnerCore {
|
||||
if (hookEvent === "Stop") this.lastHeartbeatMs = Date.parse(this.io.now());
|
||||
return {
|
||||
sequence: ++this.activitySequence,
|
||||
...(this.identity.runId ? { runId: this.identity.runId } : {}),
|
||||
sessionName: this.identity.sessionName,
|
||||
nodeId: this.identity.nodeId ?? null,
|
||||
generation: this.identity.generation ?? null,
|
||||
@@ -1689,6 +1691,9 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
);
|
||||
|
||||
const endpoint = resolveActivityEndpoint(process.env);
|
||||
// A legacy install-token launch does not acquire run authority from inherited environment.
|
||||
// Preserve a bound credential's supplied claim so Core can refuse a stale or mismatched report.
|
||||
const runId = endpoint?.token.startsWith("oag_") ? process.env.OPENRIG_RUN_ID : undefined;
|
||||
const childEnv = buildPiChildEnv(process.env as Record<string, string | undefined>, {
|
||||
agentDir: paths.agentDir,
|
||||
sessionsDir: paths.sessionsDir,
|
||||
@@ -1822,7 +1827,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => response.ok && runtime === "omp" ? await response.json() as Record<string, unknown> : null)
|
||||
.then(async (response) => response.ok && (runtime === "omp" || runId) ? await response.json() as Record<string, unknown> : null)
|
||||
.catch(() => null)
|
||||
.finally(() => clearTimeout(timeout));
|
||||
});
|
||||
@@ -1838,9 +1843,9 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
postActivity: (payload) => {
|
||||
if (!endpoint || typeof fetch !== "function") return;
|
||||
const result = postInOrder(payload).catch(() => null);
|
||||
// OMP reads the acknowledgement so identity delivery can retry; null
|
||||
// Managed runs and OMP read the acknowledgement so identity delivery can retry; null
|
||||
// (transport failure or a non-OK reply) means "not yet persisted".
|
||||
if (runtime === "omp") return result;
|
||||
if (runtime === "omp" || runId) return result;
|
||||
},
|
||||
writeSidecar: (state) => {
|
||||
try {
|
||||
@@ -1889,6 +1894,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
sessionName: args.sessionName, nodeId: process.env.OPENRIG_NODE_ID, launchId: args.launchId,
|
||||
// Carry the emitting tenure; never infer it from a later daemon read or Pi event.
|
||||
generation: process.env.OPENRIG_OCCUPANT_GENERATION,
|
||||
runId: runId,
|
||||
}, { catchUpSince, runtime, usageFacts: args.usageFacts === true, openRouterCharge: args.openRouterCharge === true, launchCredentialEvidence,
|
||||
requestedSessionFile: args.sessionFile });
|
||||
|
||||
@@ -1905,8 +1911,8 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
}
|
||||
core.handlePiLine(line);
|
||||
});
|
||||
// Re-deliver OMP identity until the daemon confirms the resume token.
|
||||
const identityRetry = runtime === "omp" ? setInterval(() => core.retrySessionIdentity(), IDENTITY_RETRY_MS) : undefined;
|
||||
// Re-deliver managed run identity after pending activation/cutover, and legacy OMP identity as before.
|
||||
const identityRetry = runtime === "omp" || runId ? setInterval(() => core.retrySessionIdentity(), IDENTITY_RETRY_MS) : undefined;
|
||||
identityRetry?.unref();
|
||||
const heartbeatTimer = startHeartbeatTimer(core, runtime);
|
||||
// The last few stderr lines, so an OMP that exits during startup is
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RunnerGrantLaunch, RunnerGrantIssuer } from "../domain/runner-grant-launch.js";
|
||||
import { piAccountSwitchStateRoot } from "../domain/pi-account-state.js";
|
||||
// OPR.0.4.6.PI1 — the Pi runtime adapter (RPC-first, runner-in-a-pane).
|
||||
//
|
||||
@@ -49,6 +50,7 @@ export interface PiAdapterFsOps {
|
||||
|
||||
export interface PiRuntimeAdapterDeps {
|
||||
tmux: TmuxAdapter;
|
||||
runnerGrants?: RunnerGrantIssuer;
|
||||
seatLaunchEnvironment?: SeatLaunchEnvironment;
|
||||
fsOps: PiAdapterFsOps;
|
||||
/** Root under which every Pi seat gets its isolated state dir (FR-7).
|
||||
@@ -77,6 +79,7 @@ export interface PiRuntimeAdapterDeps {
|
||||
export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
readonly runtime: string = "pi";
|
||||
protected tmux: TmuxAdapter;
|
||||
protected runnerGrants?: RunnerGrantIssuer;
|
||||
protected seatLaunchEnvironment?: SeatLaunchEnvironment;
|
||||
protected fs: PiAdapterFsOps;
|
||||
protected stateRoot: string;
|
||||
@@ -94,6 +97,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
this.usageFactsEnabled = deps.usageFactsEnabled ?? (() => false);
|
||||
this.openRouterChargeEnabled = deps.openRouterChargeEnabled ?? (() => false);
|
||||
this.tmux = deps.tmux;
|
||||
this.runnerGrants = deps.runnerGrants;
|
||||
this.seatLaunchEnvironment = deps.seatLaunchEnvironment;
|
||||
this.fs = deps.fsOps;
|
||||
this.stateRoot = deps.stateRoot;
|
||||
@@ -107,6 +111,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
getCommunityDeps(): PiRuntimeAdapterDeps {
|
||||
return {
|
||||
tmux: this.tmux,
|
||||
runnerGrants: this.runnerGrants,
|
||||
seatLaunchEnvironment: this.seatLaunchEnvironment,
|
||||
fsOps: this.fs,
|
||||
stateRoot: this.stateRoot,
|
||||
@@ -280,7 +285,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
|
||||
async launchHarness(
|
||||
binding: NodeBinding,
|
||||
opts: { name: string; resumeToken?: string; forkSource?: ForkSource },
|
||||
opts: { name: string; resumeToken?: string; forkSource?: ForkSource; beforeNativeDispatch?: () => boolean },
|
||||
): Promise<HarnessLaunchResult> {
|
||||
const wrapped = binding.launchWrapper?.length ? binding.launchWrapper : undefined;
|
||||
if (wrapped && (this.runtime !== "pi" || !this.seatLaunchEnvironment)) {
|
||||
@@ -341,90 +346,123 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
// The prior record is read FIRST so the durable catch-up cursor
|
||||
// (lastEntryId, FR-5) survives the reset.
|
||||
const launchId = this.newLaunchId();
|
||||
const prior = this.readRunnerState(location.sessionName, location.stateRoot);
|
||||
this.fs.writeFile(
|
||||
paths.runnerStatePath,
|
||||
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
|
||||
);
|
||||
let authority: RunnerGrantLaunch | undefined;
|
||||
let authorityUnavailable = false;
|
||||
let dispatchedWithoutAuthority = false;
|
||||
if (this.runtime !== "omp") {
|
||||
try { authority = await this.runnerGrants?.(binding, this.runtime, launchId, this.runtime === "pi" ? 0 : 2); }
|
||||
catch { authorityUnavailable = true; }
|
||||
}
|
||||
let authorizedLaunchSucceeded = false;
|
||||
try {
|
||||
const prior = this.readRunnerState(location.sessionName, location.stateRoot);
|
||||
this.fs.writeFile(
|
||||
paths.runnerStatePath,
|
||||
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
|
||||
);
|
||||
|
||||
const trust = this.runtime === "omp"
|
||||
? (yoloEnabled(process.env, binding.launchPosture) ? "approve" : "no-approve")
|
||||
: piTrust(this.trustPosture, process.env, binding.launchPosture);
|
||||
const appliedLaunch = this.runtime === "omp"
|
||||
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
|
||||
: observePiResourceTrust(trust);
|
||||
// An account home is Pi's own, as on main: OMP and a pi-rpc fork take none, and the runner refuses --account-home
|
||||
// for any runtime but pi.
|
||||
const accountHome = this.runtime !== "pi" ? undefined
|
||||
: binding.accountHome !== undefined ? binding.accountHome
|
||||
: binding.nodeId ? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime) : undefined;
|
||||
const preparedHome = accountHome === null && binding.nodeId
|
||||
? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime, true) : accountHome;
|
||||
const cmd = buildPiRunnerCommand({
|
||||
accountHome: preparedHome ?? undefined,
|
||||
runtime: this.runtime,
|
||||
command: this.commandName,
|
||||
runnerEntryPath: this.runnerEntryPath,
|
||||
sessionName: location.sessionName,
|
||||
stateRoot: location.stateRoot,
|
||||
cwd: binding.cwd,
|
||||
model: binding.model,
|
||||
// The seat's resolved effort, sent as Pi's --thinking; OMP unchanged.
|
||||
thinking: this.runtime === "omp" ? undefined : binding.effort,
|
||||
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). YOLO forces `approve` on every
|
||||
// seat; otherwise the configured posture. Same decision used on the restore path (pi-resume).
|
||||
trust,
|
||||
sessionFile: opts.resumeToken,
|
||||
forkRef,
|
||||
launchId,
|
||||
usageFacts: this.runtime === "pi" && this.usageFactsEnabled(),
|
||||
openRouterCharge: this.runtime === "pi" && this.openRouterChargeEnabled(),
|
||||
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
|
||||
});
|
||||
const trust = this.runtime === "omp"
|
||||
? (yoloEnabled(process.env, binding.launchPosture) ? "approve" : "no-approve")
|
||||
: piTrust(this.trustPosture, process.env, binding.launchPosture);
|
||||
const appliedLaunch = this.runtime === "omp"
|
||||
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
|
||||
: observePiResourceTrust(trust);
|
||||
// An account home is Pi's own, as on main: OMP and a pi-rpc fork take none, and the runner refuses --account-home
|
||||
// for any runtime but pi.
|
||||
const accountHome = this.runtime !== "pi" ? undefined
|
||||
: binding.accountHome !== undefined ? binding.accountHome
|
||||
: binding.nodeId ? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime) : undefined;
|
||||
const preparedHome = accountHome === null && binding.nodeId
|
||||
? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime, true) : accountHome;
|
||||
const cmd = buildPiRunnerCommand({
|
||||
accountHome: preparedHome ?? undefined,
|
||||
runtime: this.runtime,
|
||||
command: this.commandName,
|
||||
runnerEntryPath: this.runnerEntryPath,
|
||||
sessionName: location.sessionName,
|
||||
stateRoot: location.stateRoot,
|
||||
cwd: binding.cwd,
|
||||
model: binding.model,
|
||||
// The seat's resolved effort, sent as Pi's --thinking; OMP unchanged.
|
||||
thinking: this.runtime === "omp" ? undefined : binding.effort,
|
||||
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). YOLO forces `approve` on every
|
||||
// seat; otherwise the configured posture. Same decision used on the restore path (pi-resume).
|
||||
trust,
|
||||
sessionFile: opts.resumeToken,
|
||||
forkRef,
|
||||
launchId,
|
||||
usageFacts: this.runtime === "pi" && this.usageFactsEnabled(),
|
||||
openRouterCharge: this.runtime === "pi" && this.openRouterChargeEnabled(),
|
||||
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
|
||||
});
|
||||
|
||||
const fallback = cmd;
|
||||
// Every Pi-protocol runtime (Pi and its pi-rpc forks) takes the seat launch environment; OMP unchanged.
|
||||
const command = this.runtime !== "omp" && this.seatLaunchEnvironment
|
||||
? await this.seatLaunchEnvironment.command(sessionName, cmd, { nodeId: binding.nodeId, generation: binding.launchGeneration, runtime: this.runtime, accountHome })
|
||||
: fallback;
|
||||
// Stage long commands without leaving a shell above the live runner.
|
||||
let textResult = await this.tmux.sendShellCommand(sessionName, command, undefined, { stageIfLong: true, execInScript: true });
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== fallback && accountHome === undefined) {
|
||||
textResult = await this.tmux.sendShellCommand(sessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (!textResult.ok) {
|
||||
return { ok: false, error: `Failed to send launch command: ${textResult.message}` };
|
||||
}
|
||||
const fallback = cmd;
|
||||
// Every Pi-protocol runtime (Pi and its pi-rpc forks) takes the seat launch environment; OMP unchanged.
|
||||
let command = this.runtime !== "omp" && this.seatLaunchEnvironment
|
||||
? await this.seatLaunchEnvironment.command(sessionName, cmd, { nodeId: binding.nodeId, generation: authority?.generation ?? binding.launchGeneration, runtime: this.runtime, accountHome })
|
||||
: fallback;
|
||||
const originalCommand = command;
|
||||
if (authority) command = authority.wrap(command);
|
||||
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Runner launch selection changed before dispatch." };
|
||||
// Stage long commands without leaving a shell above the live runner.
|
||||
let textResult = await this.tmux.sendShellCommand(sessionName, command, undefined, { stageIfLong: true, execInScript: true });
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && authority && command !== originalCommand) {
|
||||
try { await authority.finish(false); } catch { /* optional authority cleanup cannot prevent the original launch */ }
|
||||
authority = undefined; authorityUnavailable = true;
|
||||
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Runner launch selection changed before dispatch." };
|
||||
textResult = await this.tmux.sendShellCommand(sessionName, originalCommand, undefined, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && originalCommand !== fallback && accountHome === undefined) {
|
||||
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Runner launch selection changed before dispatch." };
|
||||
textResult = await this.tmux.sendShellCommand(sessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (!textResult.ok) {
|
||||
return { ok: false, error: `Failed to send launch command: ${textResult.message}` };
|
||||
}
|
||||
|
||||
// The runner writes the sidecar after its first successful get_state;
|
||||
// that sidecar (not pane content) is the token source of truth (FR-5).
|
||||
const state = await this.waitForRunnerReady(location.sessionName, launchId, location.stateRoot);
|
||||
if (!state.ok) return state.failure;
|
||||
try { authority?.dispatched(); }
|
||||
catch {
|
||||
try { await authority?.finish(false); } catch { /* dispatch already happened; never retry native input */ }
|
||||
authority = undefined; authorityUnavailable = true; dispatchedWithoutAuthority = true;
|
||||
}
|
||||
|
||||
const sessionFile = state.value.sessionFile;
|
||||
if (!sessionFile) {
|
||||
return { ok: false, error: `${this.runtime} launch: the runner became ready but reported no session file` };
|
||||
}
|
||||
if (opts.resumeToken && sessionFile !== opts.resumeToken) {
|
||||
// An exact resume must continue the requested history; a runner
|
||||
// that came up on any other file started a different session. Same
|
||||
// contract as the separate resume adapter's verifyResume.
|
||||
return { ok: false, error: `${this.runtime} resume: the runner is ready but does not report the requested session file` };
|
||||
}
|
||||
if (forkRef && sessionFile === forkRef) {
|
||||
// The adapter contract requires the NEW post-fork token, never the
|
||||
// parent's (runtime-adapter.ts fork rule).
|
||||
return { ok: false, error: `${this.runtime} fork: the runner reported the parent session file instead of the post-fork child` };
|
||||
}
|
||||
const validation = this.validateToken(sessionFile);
|
||||
if (!validation.ok) {
|
||||
return { ok: false, error: `${this.runtime} launch: the runner reported a malformed session file (${validation.error})` };
|
||||
}
|
||||
// The runner writes the sidecar after its first successful get_state;
|
||||
// that sidecar (not pane content) is the token source of truth (FR-5).
|
||||
const state = await this.waitForRunnerReady(location.sessionName, launchId, location.stateRoot);
|
||||
if (!state.ok) return state.failure;
|
||||
|
||||
// OMP lazily creates its JSONL on the first persisted turn. Readiness
|
||||
// is real, but until then there is no history to resume after a reboot.
|
||||
if (this.runtime === "omp" && !this.fs.exists(validation.token)) return { ok: true, appliedLaunch };
|
||||
return { ok: true, resumeToken: validation.token, resumeType: this.resumeType, appliedLaunch };
|
||||
const sessionFile = state.value.sessionFile;
|
||||
if (!sessionFile) {
|
||||
return { ok: false, error: `${this.runtime} launch: the runner became ready but reported no session file` };
|
||||
}
|
||||
if (opts.resumeToken && sessionFile !== opts.resumeToken) {
|
||||
// An exact resume must continue the requested history; a runner
|
||||
// that came up on any other file started a different session. Same
|
||||
// contract as the separate resume adapter's verifyResume.
|
||||
return { ok: false, error: `${this.runtime} resume: the runner is ready but does not report the requested session file` };
|
||||
}
|
||||
if (forkRef && sessionFile === forkRef) {
|
||||
// The adapter contract requires the NEW post-fork token, never the
|
||||
// parent's (runtime-adapter.ts fork rule).
|
||||
return { ok: false, error: `${this.runtime} fork: the runner reported the parent session file instead of the post-fork child` };
|
||||
}
|
||||
const validation = this.validateToken(sessionFile);
|
||||
if (!validation.ok) {
|
||||
return { ok: false, error: `${this.runtime} launch: the runner reported a malformed session file (${validation.error})` };
|
||||
}
|
||||
|
||||
// OMP lazily creates its JSONL on the first persisted turn. Readiness
|
||||
// is real, but until then there is no history to resume after a reboot.
|
||||
if (this.runtime === "omp" && !this.fs.exists(validation.token)) return { ok: true, appliedLaunch };
|
||||
authorizedLaunchSucceeded = true;
|
||||
return { ok: true, resumeToken: validation.token, resumeType: this.resumeType, appliedLaunch,
|
||||
...(authority ? { activityGrantId: authority.grantId } : {}),
|
||||
...(authorityUnavailable ? { warnings: [dispatchedWithoutAuthority
|
||||
? "Native command dispatched; run-bound reporting could not be confirmed. Cleanup of its grant was attempted; nothing was replayed."
|
||||
: "Run-bound reporting unavailable; using existing unbound install-token reporting."] } : {}) };
|
||||
} finally {
|
||||
try { await authority?.finish(authorizedLaunchSucceeded); } catch { /* optional reporting cannot change the native launch outcome */ }
|
||||
}
|
||||
}
|
||||
|
||||
async checkReady(binding: NodeBinding): Promise<ReadinessResult> {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runBoundActivityGrantsSchema } from "./migrations/165_run_bound_activity_grants.js";
|
||||
import { piAccountSwitchesSchema } from "./migrations/151_pi_account_switches.js";
|
||||
import { workflowInstanceSpecSchema } from "./migrations/152_workflow_instance_spec.js";
|
||||
import { eventsRetentionMarksSchema } from "./migrations/153_events_retention_marks.js";
|
||||
@@ -332,5 +333,6 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
sessionAddressReadIndexesSchema,
|
||||
networkInstallationsSchema,
|
||||
queueItemsReplyToIndexSchema,
|
||||
runBoundActivityGrantsSchema,
|
||||
queueListReadIndexesSchema,
|
||||
];
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** Durable actual-run binding; legacy grants remain explicitly unbound. */
|
||||
export const runBoundActivityGrantsSchema: Migration = {
|
||||
name: "165_run_bound_activity_grants.sql",
|
||||
sql: `
|
||||
ALTER TABLE activity_grants ADD COLUMN run_id TEXT;
|
||||
ALTER TABLE activity_grants ADD COLUMN session_id TEXT;
|
||||
ALTER TABLE activity_grants ADD COLUMN activated_at TEXT;
|
||||
ALTER TABLE activity_grants ADD COLUMN binding_json TEXT;
|
||||
CREATE INDEX idx_activity_grants_run ON activity_grants(node_id, run_id);
|
||||
CREATE UNIQUE INDEX idx_activity_grants_active_run ON activity_grants(node_id)
|
||||
WHERE run_id IS NOT NULL AND activated_at IS NOT NULL AND revoked_at IS NULL;
|
||||
`,
|
||||
};
|
||||
@@ -1,17 +1,25 @@
|
||||
// A seat activity grant: the authority a workload outside the host's own hook setup, such as
|
||||
// a guest in another environment or a runner inside one, uses to post activity for ONE seat and ONE launch generation.
|
||||
// a guest in another environment or a runner inside one, uses to post activity for ONE seat. Managed hosted runners
|
||||
// additionally bind that authority to one actual launch, its concrete Core pane and originating session.
|
||||
// The install-wide hook token (activity-endpoint.ts) speaks for every seat, so it never goes into a guest; a grant goes
|
||||
// there instead, under the same variable name, so runners need no change. The ingress (routes/activity.ts) resolves
|
||||
// the grant first and only then accepts the seat and generation a post claims.
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { SessionRegistry } from "./session-registry.js";
|
||||
import { SeatIdentityStore } from "./seat-identity-store.js";
|
||||
import { monotonicFactory } from "ulid";
|
||||
|
||||
const ulid = monotonicFactory();
|
||||
const TOKEN_PREFIX = "oag_";
|
||||
|
||||
export type RunGrantBinding = { runtime: string; runner: number; pane: string; panePid: number };
|
||||
export type ActivityGrant = {
|
||||
runId: string | null;
|
||||
sessionId: string | null;
|
||||
activatedAt: string | null;
|
||||
binding: RunGrantBinding | null;
|
||||
grantId: string;
|
||||
nodeId: string;
|
||||
generation: string;
|
||||
@@ -28,12 +36,15 @@ export class ActivityGrantError extends Error {
|
||||
}
|
||||
|
||||
type Row = { grant_id: string; node_id: string; generation: string; issued_at: string; issued_by: string;
|
||||
revoked_at: string | null; revoke_reason: string | null };
|
||||
revoked_at: string | null; revoke_reason: string | null; run_id?: string | null; session_id?: string | null;
|
||||
activated_at?: string | null; binding_json?: string | null };
|
||||
|
||||
const sha256 = (token: string): string => createHash("sha256").update(token).digest("hex");
|
||||
|
||||
function toGrant(row: Row): ActivityGrant {
|
||||
return { grantId: row.grant_id, nodeId: row.node_id, generation: row.generation, issuedAt: row.issued_at,
|
||||
let binding: RunGrantBinding | null = null;
|
||||
try { binding = row.binding_json ? JSON.parse(row.binding_json) : null; } catch { /* unavailable */ }
|
||||
return { runId: row.run_id ?? null, sessionId: row.session_id ?? null, activatedAt: row.activated_at ?? null, binding, grantId: row.grant_id, nodeId: row.node_id, generation: row.generation, issuedAt: row.issued_at,
|
||||
issuedBy: row.issued_by, revokedAt: row.revoked_at, revokeReason: row.revoke_reason };
|
||||
}
|
||||
|
||||
@@ -45,13 +56,17 @@ export function hasActivityGrantsTable(db: Database.Database): boolean {
|
||||
export class ActivityGrantStore {
|
||||
constructor(private readonly db: Database.Database, private readonly now: () => Date = () => new Date()) {}
|
||||
|
||||
/** Issue the node's grant for `generation`: the occupant generation its launch reserved, which may not be registered
|
||||
* yet. One active grant per node, so this revokes any earlier one ("reissued"): a lost response is reconciled by
|
||||
* issuing again. The token is returned once; only its sha256 is stored. */
|
||||
issue(input: { nodeId: string; generation: string; issuedBy: string }): ActivityGrant & { token: string } {
|
||||
/** Legacy issuance revokes earlier grants as before. An actual-run issuance stays pending and preserves
|
||||
* the active predecessor; only another pending credential for this same run is superseded. Activation owns cutover.
|
||||
* The token is returned once; only its sha256 is stored. */
|
||||
issue(input: { nodeId: string; generation: string; issuedBy: string; run?: { runId: string; sessionId?: string; binding: RunGrantBinding } }): ActivityGrant & { token: string } {
|
||||
if (!input.nodeId.trim() || !input.generation.trim() || !input.issuedBy.trim()) {
|
||||
throw new ActivityGrantError("invalid_grant_input", "An activity grant needs a node, a launch generation and who issued it.");
|
||||
}
|
||||
if (input.run && (!input.run.runId.trim() || !input.run.binding.runtime || !input.run.binding.pane
|
||||
|| !Number.isSafeInteger(input.run.binding.panePid) || input.run.binding.panePid <= 0)) {
|
||||
throw new ActivityGrantError("invalid_grant_input", "A run grant needs a concrete Core launch binding.");
|
||||
}
|
||||
const token = `${TOKEN_PREFIX}${randomBytes(32).toString("hex")}`;
|
||||
const grantId = `agrant-${ulid()}`;
|
||||
const issuedAt = this.now().toISOString();
|
||||
@@ -59,9 +74,14 @@ export class ActivityGrantStore {
|
||||
if (!this.db.prepare("SELECT 1 FROM nodes WHERE id = ?").get(input.nodeId)) {
|
||||
throw new ActivityGrantError("node_not_found", `No seat with node ${input.nodeId}.`);
|
||||
}
|
||||
this.revokeActive(input.nodeId, "reissued", issuedAt);
|
||||
if (input.run) {
|
||||
this.db.prepare("UPDATE activity_grants SET revoked_at = ?, revoke_reason = 'reissued' WHERE node_id = ? AND run_id = ? AND activated_at IS NULL AND revoked_at IS NULL")
|
||||
.run(issuedAt, input.nodeId, input.run.runId);
|
||||
} else this.revokeActive(input.nodeId, "reissued", issuedAt);
|
||||
this.db.prepare(`INSERT INTO activity_grants (grant_id, node_id, generation, token_sha256, issued_by, issued_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`).run(grantId, input.nodeId, input.generation, sha256(token), input.issuedBy, issuedAt);
|
||||
if (input.run) this.db.prepare("UPDATE activity_grants SET run_id = ?, session_id = ?, binding_json = ? WHERE grant_id = ?")
|
||||
.run(input.run.runId, input.run.sessionId ?? null, JSON.stringify(input.run.binding), grantId);
|
||||
})();
|
||||
return { ...this.get(grantId)!, token };
|
||||
}
|
||||
@@ -80,10 +100,154 @@ export class ActivityGrantStore {
|
||||
}
|
||||
|
||||
activeForNode(nodeId: string): ActivityGrant | null {
|
||||
const row = this.db.prepare("SELECT * FROM activity_grants WHERE node_id = ? AND revoked_at IS NULL").get(nodeId) as Row | undefined;
|
||||
const row = this.db.prepare(this.hasRunColumns()
|
||||
? "SELECT * FROM activity_grants WHERE node_id = ? AND revoked_at IS NULL AND (run_id IS NULL OR activated_at IS NOT NULL) ORDER BY activated_at DESC LIMIT 1"
|
||||
: "SELECT * FROM activity_grants WHERE node_id = ? AND revoked_at IS NULL").get(nodeId) as Row | undefined;
|
||||
return row ? toGrant(row) : null;
|
||||
}
|
||||
|
||||
/** Activate only against the committed Core binding. Staged launches remain pending until cutover. */
|
||||
activate(grantId: string): boolean {
|
||||
return this.db.transaction(() => {
|
||||
const grant = this.get(grantId);
|
||||
if (!grant?.runId || grant.revokedAt || !this.matchesBinding(grant, false, !grant.activatedAt)) return false;
|
||||
if (grant.activatedAt) return true;
|
||||
const active = this.activeForNode(grant.nodeId);
|
||||
if (active?.runId && active.grantId > grant.grantId && this.bindingMatch(active, true) !== false) return false;
|
||||
const session = this.currentSession(grant.nodeId)!;
|
||||
// Only this pending, Core-selected launch may fill an empty declaration; never overwrite a conflicting record.
|
||||
if (grant.binding!.runtime !== "pi" && session.agent_app == null && session.agent_runner == null) {
|
||||
this.db.prepare("UPDATE sessions SET agent_app = ?, agent_runner = ? WHERE id = ?")
|
||||
.run(grant.binding!.runtime, grant.binding!.runner, session.id);
|
||||
}
|
||||
const at = this.now().toISOString();
|
||||
this.db.prepare("UPDATE activity_grants SET revoked_at = ?, revoke_reason = 'run_replaced' WHERE node_id = ? AND grant_id <> ? AND revoked_at IS NULL AND (run_id IS NULL OR activated_at IS NOT NULL)")
|
||||
.run(at, grant.nodeId, grantId);
|
||||
this.db.prepare("UPDATE activity_grants SET activated_at = ?, session_id = ? WHERE grant_id = ? AND revoked_at IS NULL")
|
||||
.run(at, session.id, grantId);
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
|
||||
/** Core's existing claimed-session commit is the staged activation point. Only an unambiguous
|
||||
* matching launch may restore its recorded runner declaration onto that new session row. */
|
||||
reconcileBinding(nodeId: string): void {
|
||||
if (!hasActivityGrantsTable(this.db) || !this.hasRunColumns()) return;
|
||||
try { this.db.transaction(() => {
|
||||
const grants = (this.db.prepare("SELECT * FROM activity_grants WHERE node_id = ? AND run_id IS NOT NULL AND revoked_at IS NULL")
|
||||
.all(nodeId) as Row[]).map(toGrant);
|
||||
if (grants.length === 0) return;
|
||||
const session = this.currentSession(nodeId);
|
||||
if (!session) return;
|
||||
const registry = new SessionRegistry(this.db);
|
||||
const generation = registry.currentOccupantTenure(nodeId)?.generationUuid;
|
||||
const candidates = grants.filter(grant => grant.binding?.pane === session.tmux_pane && grant.binding.runtime === session.runtime
|
||||
&& (grant.generation === generation || (grant.activatedAt && registry.isOccupantContinuedByAdoption(nodeId, grant.generation)
|
||||
&& this.adoptionAnchorMatches(grant, session.session_name))));
|
||||
if (candidates.length === 1) {
|
||||
const grant = candidates[0]!;
|
||||
if (grant.binding!.runtime !== "pi" && session.agent_app == null && session.agent_runner == null) {
|
||||
this.db.prepare("UPDATE sessions SET agent_app = ?, agent_runner = ? WHERE id = ?")
|
||||
.run(grant.binding!.runtime, grant.binding!.runner, session.id);
|
||||
}
|
||||
if (!grant.activatedAt) this.activate(grant.grantId);
|
||||
}
|
||||
for (const grant of grants) {
|
||||
if (grant.activatedAt && this.bindingMatch(grant, true) === false) this.revoke({ grantId: grant.grantId }, "binding_replaced");
|
||||
}
|
||||
})(); } catch { /* Optional reconciliation cannot turn an unreadable binding into revocation or fail registration. */ }
|
||||
}
|
||||
|
||||
/** Pending issuance IDs for a Core-reserved operation, captured before its recovery awaits.
|
||||
* This selects cleanup work; generation alone never authenticates a report or activates a grant. */
|
||||
pendingForGeneration(nodeId: string, generation: string): ActivityGrant[] {
|
||||
if (!hasActivityGrantsTable(this.db) || !this.hasRunColumns()) return [];
|
||||
return (this.db.prepare(`SELECT * FROM activity_grants WHERE node_id = ? AND generation = ?
|
||||
AND run_id IS NOT NULL AND activated_at IS NULL AND revoked_at IS NULL`).all(nodeId, generation) as Row[]).map(toGrant);
|
||||
}
|
||||
|
||||
/** A status observation for an old row cannot revoke the replacement's run. */
|
||||
revokeForSession(sessionId: string, reason: string): void {
|
||||
if (!hasActivityGrantsTable(this.db) || !this.hasRunColumns()) return;
|
||||
const row = this.db.prepare("SELECT node_id FROM sessions WHERE id = ?").get(sessionId) as { node_id: string } | undefined;
|
||||
if (!row) return;
|
||||
const current = this.currentSession(row.node_id);
|
||||
const grants = (this.db.prepare("SELECT * FROM activity_grants WHERE node_id = ? AND run_id IS NOT NULL AND revoked_at IS NULL")
|
||||
.all(row.node_id) as Row[]).map(toGrant);
|
||||
for (const grant of grants) {
|
||||
if ((grant.sessionId === sessionId && (current?.id === sessionId || this.bindingMatch(grant, true) === false))
|
||||
|| (grant.activatedAt && current?.id === sessionId)) {
|
||||
this.revoke({ grantId: grant.grantId }, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
standing(grantId: string): "current" | "pending" | "stale" | "unverified" | "revoked" {
|
||||
const grant = this.get(grantId);
|
||||
if (!grant || grant.revokedAt) return "revoked";
|
||||
if (!grant.runId || !this.hasRunColumns()) return "stale";
|
||||
if (!grant.activatedAt) return "pending";
|
||||
const binding = this.bindingMatch(grant, true);
|
||||
return binding === null ? "unverified" : binding ? "current" : "stale";
|
||||
}
|
||||
|
||||
private hasRunColumns(): boolean {
|
||||
return (this.db.prepare("PRAGMA table_info(activity_grants)").all() as Array<{ name: string }>).some(row => row.name === "run_id");
|
||||
}
|
||||
|
||||
private currentSession(nodeId: string): { id: string; session_name: string; agent_app: string | null; agent_runner: number | null; tmux_pane: string | null; runtime: string | null } | undefined {
|
||||
return this.db.prepare(`SELECT s.id, s.session_name, s.agent_app, s.agent_runner, b.tmux_pane, n.runtime
|
||||
FROM sessions s JOIN nodes n ON n.id=s.node_id JOIN bindings b ON b.node_id=n.id AND b.tmux_session=s.session_name
|
||||
WHERE s.node_id=? AND s.id=(SELECT id FROM sessions WHERE node_id=? ORDER BY id DESC LIMIT 1)
|
||||
AND s.status NOT IN ('exited','detached','superseded')`).get(nodeId, nodeId) as ReturnType<ActivityGrantStore["currentSession"]>;
|
||||
}
|
||||
|
||||
private matchesBinding(grant: ActivityGrant, allowAdoption: boolean, allowUnrecorded = false): boolean {
|
||||
return this.bindingMatch(grant, allowAdoption, allowUnrecorded) === true;
|
||||
}
|
||||
|
||||
/** Unknown evidence refuses reports, but cannot authorize destructive lifecycle cleanup. */
|
||||
private bindingMatch(grant: ActivityGrant, allowAdoption: boolean, allowUnrecorded = false): boolean | null {
|
||||
try {
|
||||
const anchor = grant.binding, session = this.currentSession(grant.nodeId);
|
||||
if (!anchor || !session || session.tmux_pane !== anchor.pane || session.runtime !== anchor.runtime) return false;
|
||||
if (anchor.runtime !== "pi" && ((session.agent_app != null && session.agent_app !== anchor.runtime)
|
||||
|| (session.agent_runner != null && session.agent_runner !== anchor.runner))) return false;
|
||||
const declarationMissing = anchor.runtime !== "pi" && (session.agent_app == null || session.agent_runner == null)
|
||||
&& !(allowUnrecorded && session.agent_app == null && session.agent_runner == null && [1, 2].includes(anchor.runner));
|
||||
const registry = new SessionRegistry(this.db);
|
||||
const current = registry.currentOccupantTenure(grant.nodeId)?.generationUuid;
|
||||
if (!current) return null;
|
||||
const adoption = current !== grant.generation;
|
||||
if (adoption && (!allowAdoption || !registry.isOccupantContinuedByAdoption(grant.nodeId, grant.generation))) return false;
|
||||
if (!adoption && grant.sessionId && grant.sessionId !== session.id) return false;
|
||||
const evidence = new SeatIdentityStore(this.db).getForNode(grant.nodeId);
|
||||
if (adoption) {
|
||||
const match = this.adoptionAnchorMatch(grant, session.session_name);
|
||||
if (match !== true) return match;
|
||||
}
|
||||
if (evidence?.sessionName === session.session_name && evidence.evidence.registeredPane === anchor.pane
|
||||
&& Date.parse(evidence.observedAt) >= Date.parse(grant.issuedAt)
|
||||
&& evidence.evidence.observedPanePid != null && evidence.evidence.observedPanePid !== anchor.panePid) return false;
|
||||
// Missing declaration is unknown only after ruling out positive binding/tenure/process replacement.
|
||||
return declarationMissing ? null : true;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
private adoptionAnchorMatches(grant: ActivityGrant, sessionName: string): boolean {
|
||||
return this.adoptionAnchorMatch(grant, sessionName) === true;
|
||||
}
|
||||
|
||||
private adoptionAnchorMatch(grant: ActivityGrant, sessionName: string): boolean | null {
|
||||
const store = new SeatIdentityStore(this.db), evidence = store.getForNode(grant.nodeId);
|
||||
const process = store.getProcessEvidence(grant.nodeId);
|
||||
if (!evidence || !process || evidence.sessionName !== sessionName || process.sessionName !== sessionName
|
||||
|| evidence.evidence.observedPanePid == null || process.panePid == null) return null;
|
||||
return !!grant.binding && evidence.sessionName === sessionName && process.sessionName === sessionName
|
||||
&& evidence.evidence.registeredPane === grant.binding.pane
|
||||
&& evidence.evidence.observedPanePid === grant.binding.panePid && process.panePid === grant.binding.panePid;
|
||||
}
|
||||
|
||||
/** The grant a presented token belongs to, revoked or not, or null for any other token. */
|
||||
authenticate(token: string | null | undefined): ActivityGrant | null {
|
||||
if (!token?.startsWith(TOKEN_PREFIX) || !hasActivityGrantsTable(this.db)) return null;
|
||||
|
||||
@@ -199,10 +199,13 @@ export interface InboundDeps {
|
||||
/** S10 thread routing (deterministic, zero inference): resolve the destination + tags for an
|
||||
* admitted event. Absent → every event lands on the static `destination` (the pre-routing
|
||||
* shape, and the fallback the tests pin). */
|
||||
resolveRoute?: (ev: SlackEvent) => { destination: string; tags?: string[]; correlationQitemId?: string; routeClass?: string };
|
||||
resolveRoute?: (ev: SlackEvent) => { destination: string; tags?: string[]; correlationQitemId?: string; arrivalThread?: { threadTs: string; channel: string }; routeClass?: string };
|
||||
/** Open a thread root for a person's top-level message (its own ts), so the seat's
|
||||
* `--reply-to` reply and later updates land under it. Absent: nothing is minted. */
|
||||
mintThread?: (m: { threadTs: string; channel: string; human: string; seat: string; conversationId: string }) => void;
|
||||
/** Record the thread a reply arrived in without correlating it to a human gate, so the seat's
|
||||
* `--reply-to` answer returns there. Absent: nothing is recorded. */
|
||||
recordArrivalThread?: (input: { qitemId: string; threadTs: string; channel: string }) => void;
|
||||
/** Continue an exact human gate through the existing Mission Control resolve primitive. */
|
||||
/** `typedReply`: a typed thread reply, whose words a decision parked on its person stores as its answer; a click's return never does. */
|
||||
resolveHumanReply?: (input: { qitemId: string; actorSession: string; decision: string; messageTs?: string; typedReply?: boolean }) => Promise<"resolved" | "resolved-uncertain" | "already-resolved" | "not-applicable">;
|
||||
@@ -481,6 +484,9 @@ export class InboundRouter {
|
||||
this.deps.log?.(`thread root not recorded for ${qitemId} (ts=${ts}): ${(e as Error).message}; its reply will post top-level`);
|
||||
}
|
||||
}
|
||||
if (!replayedAnswer && route.arrivalThread) {
|
||||
this.deps.recordArrivalThread?.({ qitemId, threadTs: route.arrivalThread.threadTs, channel: route.arrivalThread.channel });
|
||||
}
|
||||
this.deps.seen.mark(eventId, "landed"); // durable qitem exists → safe to mark
|
||||
this.deps.log?.(`qitem ${qitemId} -> ${route.destination} (ts=${ts})`);
|
||||
return { landed: true, qitemId, correlationQitemId: route.correlationQitemId, replyResolution };
|
||||
|
||||
@@ -44,7 +44,7 @@ import { HUMAN_ALERT_TAG, loadHumanRegistry, resolveSlackHandle } from "../human
|
||||
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 { formatReplyToChoice, formatArrivalThread, 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";
|
||||
import {
|
||||
@@ -544,7 +544,21 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
if (root.channel !== channel) return { kind: "fallback", reason: "root-other-channel", threadTs: root.threadTs };
|
||||
return { kind: "thread", threadTs: root.threadTs };
|
||||
}
|
||||
if (!item.replyTo) return { kind: "fallback", reason: "root-missing", qitemId: item.qitemId };
|
||||
if (!item.replyTo) {
|
||||
// #2990: this reply has no gate correlation, but its arrival thread was recorded when
|
||||
// it landed. Its answer returns there; the conversation's gate still recognizes only
|
||||
// the newest root. Consult this record only at the end of the replyTo chain.
|
||||
const arrival = opts.queueRepo.arrivalThreadFor(item.qitemId);
|
||||
const root = arrival ? threadMap.resolveByThread(arrival.threadTs, arrival.channel) : null;
|
||||
if (root) {
|
||||
if (root.state === "closed") return { kind: "fallback", reason: "root-closed", threadTs: root.threadTs };
|
||||
if (root.human !== (p.destinationSession ?? "")) return { kind: "fallback", reason: "root-other-human", threadTs: root.threadTs };
|
||||
if (root.seat !== (p.sourceSession ?? "")) return { kind: "fallback", reason: "root-other-seat", threadTs: root.threadTs };
|
||||
if (root.channel !== channel) return { kind: "fallback", reason: "root-other-channel", threadTs: root.threadTs };
|
||||
return { kind: "thread", threadTs: root.threadTs };
|
||||
}
|
||||
return { kind: "fallback", reason: "root-missing", qitemId: item.qitemId };
|
||||
}
|
||||
item = opts.queueRepo.getById(item.replyTo);
|
||||
}
|
||||
return item
|
||||
@@ -558,14 +572,21 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
// person, a closed root, a live gate) posts top-level in the replying seat's own channel, as before.
|
||||
const personRootChannel = (p: OutboundPostPayload): string | null => {
|
||||
let item = p.replyTo ? opts.queueRepo.getById(p.replyTo) : null;
|
||||
let lastItem = item;
|
||||
for (let depth = 0; item && depth < MAX_REPLY_TO_CHAIN; depth++) {
|
||||
const root = threadMap.resolveByConversation(item.qitemId);
|
||||
if (root) {
|
||||
if (!item.tags?.includes(SLACK_THREAD_ROOT_TAG)) return null;
|
||||
return deriveReplyToChoice(p, root.channel).kind === "thread" ? root.channel : null;
|
||||
}
|
||||
lastItem = item;
|
||||
item = item.replyTo ? opts.queueRepo.getById(item.replyTo) : null;
|
||||
}
|
||||
// #2990: A reply in a superseded person-owned root uses that root's channel when a channel map would otherwise move it.
|
||||
const arrival = !item && lastItem ? opts.queueRepo.arrivalThreadFor(lastItem.qitemId) : null;
|
||||
const root = arrival ? threadMap.resolveByThread(arrival.threadTs, arrival.channel) : null;
|
||||
const owner = root ? opts.queueRepo.getById(root.conversationId) : null;
|
||||
if (root && owner?.tags?.includes(SLACK_THREAD_ROOT_TAG) && deriveReplyToChoice(p, root.channel).kind === "thread") return root.channel;
|
||||
return null;
|
||||
};
|
||||
// Decided ONCE and recorded on the row before the first post. Every later attempt reuses
|
||||
@@ -1392,6 +1413,13 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
log(`thread stamp failed for ${m.conversationId}: ${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
recordArrivalThread: (input) => {
|
||||
try {
|
||||
opts.queueRepo.update({ qitemId: input.qitemId, actorSession: REPLY_TO_CHOICE_ACTOR, transitionNote: formatArrivalThread(input) });
|
||||
} catch (e) {
|
||||
log(`arrival thread record failed for ${input.qitemId}: ${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
resolveHumanReply: opts.resolveHumanReply,
|
||||
// #193 — a button click records its answer on the decision the clicked root belongs to;
|
||||
// a failed hand-back is retried with the event dead-letters, and each click is confirmed
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface InboundRoute {
|
||||
tags: string[];
|
||||
/** Exact human-gate qitem this thread was opened for. Absent on unmapped traffic. */
|
||||
correlationQitemId?: string;
|
||||
/** The thread a reply arrived in when its superseded root no longer correlates to the gate. */
|
||||
arrivalThread?: { threadTs: string; channel: string };
|
||||
/** The routing class that fired — receipts per class ride the row tags + logs. */
|
||||
routeClass: "existing-thread" | "closed-thread" | "unmapped-thread" | "human-initiated";
|
||||
}
|
||||
@@ -77,7 +79,7 @@ export function makeThreadRouteResolver(opts: {
|
||||
return {
|
||||
destination: mapping.seat,
|
||||
tags: [...BASE_TAGS, "thread", `reply-to:${mapping.conversationId}`],
|
||||
...(current ? { correlationQitemId: mapping.conversationId } : {}),
|
||||
...(current ? { correlationQitemId: mapping.conversationId } : { arrivalThread: { threadTs, channel: mapping.channel } }),
|
||||
routeClass,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ import { getSelfHostId } from "./hosts/fanout-contract.js";
|
||||
import { parseSessionName, isHumanSeatSessionRef } from "./session-name.js";
|
||||
import { runnerPermissionOrigin } from "./runner-permissions.js";
|
||||
import { isHostPairingAsk } from "./hosts/pairing-ask.js";
|
||||
import { parseReplyToChoice, parseReplyToThreadMissing, formatReplyToChoice, describeReplyToFallback, REPLY_TO_CHOICE_ACTOR, type ReplyToChoice } from "./reply-to-choice.js";
|
||||
import { parseReplyToChoice, parseArrivalThread, parseReplyToThreadMissing, formatReplyToChoice, describeReplyToFallback, REPLY_TO_CHOICE_ACTOR, type ReplyToChoice } from "./reply-to-choice.js";
|
||||
import { classifyDestination, type DestinationClass } from "./gateway/destination-resolver.js";
|
||||
import { join } from "node:path";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
@@ -2113,7 +2113,7 @@ export class QueueRepository {
|
||||
// command line (it carries the row's summary), and nothing there can claim the row. So nothing
|
||||
// is typed; the row stays queued and visible. `failed:` keeps it on the wake ladder, whose
|
||||
// retries come back here and type nothing before it escalates.
|
||||
if (isBoundTerminalSeat(this.db, destinationSession)) {
|
||||
if (isBoundTerminalSeat(this.db, destinationSession) || isBoundTerminalSeat(this.db, physicalSessionName(this.db, destinationSession))) {
|
||||
return {
|
||||
classified: "failed",
|
||||
nudgeResult: `failed:terminal-seat: '${destinationSession}' is a terminal seat (runtime: terminal), not an agent, so no wake was typed into it and nothing there can claim this row. The row stays queued; route it to an agent seat.`,
|
||||
@@ -4825,6 +4825,17 @@ export class QueueRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** #2990 — the thread an inbound reply actually arrived in, without gate correlation.
|
||||
* Only an in-process daemon write counts, with the same trust rule as replyToChoiceFor. */
|
||||
arrivalThreadFor(qitemId: string): { threadTs: string; channel: string } | null {
|
||||
for (const t of this.transitionLog.listForQitem(qitemId).reverse()) {
|
||||
if (t.actorSession !== REPLY_TO_CHOICE_ACTOR || t.identityProvenance !== null) continue;
|
||||
const arrival = parseArrivalThread(t.transitionNote ?? "");
|
||||
if (arrival) return arrival;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** #96 — whether some update recorded this Slack root as the thread it posts into. Such a
|
||||
* root is shared: its owner's next decision must not reuse it (see slack-subsystem). */
|
||||
isReplyToThread(threadTs: string, where?: { channel: string }): boolean {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// #96 — the durable record of where a --reply-to update posts. It is written on the row
|
||||
// ONCE, before the first post, so every retry, reconcile scan and --verify read the same
|
||||
// choice instead of re-deriving it after the referenced item or its root may have changed.
|
||||
//
|
||||
// #2990 — a reply arriving in a superseded Slack root records that thread separately
|
||||
// from gate correlation. An update answering the reply returns there, while only the
|
||||
// conversation's newest root can answer its human gate. The same actor/provenance rule
|
||||
// as the reply-to choice protects this in-process record.
|
||||
|
||||
const REPLY_TO_CHOICE_PREFIX = "slack-reply-to-choice";
|
||||
export const SLACK_ARRIVAL_THREAD_PREFIX = "slack-arrival-thread";
|
||||
|
||||
/** The only actor whose choice note is trusted: an agent can append any transition note, so
|
||||
* a note from anyone else must never steer where the update posts. */
|
||||
@@ -67,6 +73,23 @@ export function parseReplyToChoice(note: string): ReplyToChoice | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatArrivalThread(arrival: { threadTs: string; channel: string }): string {
|
||||
return `${SLACK_ARRIVAL_THREAD_PREFIX} thread_ts=${arrival.threadTs} channel=${arrival.channel}`;
|
||||
}
|
||||
|
||||
/** Parse an arrival-thread note (null when the note is not one). Callers check the actor. */
|
||||
export function parseArrivalThread(note: string): { threadTs: string; channel: string } | null {
|
||||
if (!note.startsWith(SLACK_ARRIVAL_THREAD_PREFIX + " ")) return null;
|
||||
const fields = new Map<string, string>();
|
||||
for (const token of note.slice(SLACK_ARRIVAL_THREAD_PREFIX.length + 1).split(/\s+/)) {
|
||||
const eq = token.indexOf("=");
|
||||
if (eq > 0) fields.set(token.slice(0, eq), token.slice(eq + 1));
|
||||
}
|
||||
const threadTs = fields.get("thread_ts");
|
||||
const channel = fields.get("channel");
|
||||
return threadTs && channel ? { threadTs, channel } : null;
|
||||
}
|
||||
|
||||
/** #2234: where Slack actually put a --reply-to update that its choice threaded. The thread's root was gone, so Slack posted
|
||||
* the update as a new top-level message. Recorded after the post by the same actor, and read only for the receipt: retries and
|
||||
* reconciliation keep the recorded choice. */
|
||||
|
||||
@@ -2638,6 +2638,7 @@ export class RestoreOrchestrator {
|
||||
...(launchWrapper?.length ? ([launchWrapper] as const) : []),
|
||||
);
|
||||
if (result.ok) {
|
||||
for (const note of result.warnings ?? []) warnings?.push(`${sessionName}: ${note}`);
|
||||
if (result.appliedLaunch && launchGeneration) this.appliedLaunchStore.recordGeneration(launchGeneration, result.appliedLaunch);
|
||||
return { kind: "resumed" };
|
||||
}
|
||||
|
||||
@@ -2696,7 +2696,8 @@ export class PodRigInstantiator {
|
||||
nodeId: input.nodeId,
|
||||
tmuxSession: launchResult.binding.tmuxSession,
|
||||
tmuxWindow: null,
|
||||
tmuxPane: null,
|
||||
// Only the run-grant launch owners need this anchor; unrelated builtins keep their existing session target.
|
||||
tmuxPane: input.member.runtime === "pi" || isCommunityHarnessRuntime(input.member.runtime) ? launchResult.binding.tmuxPane : null,
|
||||
cmuxWorkspace: null,
|
||||
cmuxSurface: null,
|
||||
updatedAt: "",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import type { NodeBinding } from "./runtime-adapter.js";
|
||||
import { ActivityGrantStore } from "./activity-grants.js";
|
||||
import { SessionRegistry } from "./session-registry.js";
|
||||
|
||||
export interface RunnerGrantLaunch {
|
||||
readonly grantId: string;
|
||||
readonly generation: string;
|
||||
wrap(command: string): string;
|
||||
dispatched(): void;
|
||||
finish(ok: boolean): Promise<void>;
|
||||
}
|
||||
export type RunnerGrantIssuer = (binding: NodeBinding, runtime: string, runId: string, runner: number) => Promise<RunnerGrantLaunch>;
|
||||
|
||||
/** Shared by actual Pi/community launch owners. The token crosses only tmux's private environment channel;
|
||||
* the typed command carries an unpredictable environment KEY, never its value. The runner's shell consumes and
|
||||
* removes that key before exec. Neither a failed await nor a later readiness result may revoke a successor. */
|
||||
export function runnerGrantIssuer(db: Database.Database, tmux: Pick<TmuxAdapter, "getPanePid" | "setSessionEnv" | "unsetSessionEnv">): RunnerGrantIssuer {
|
||||
return async (binding, runtime, runId, runner) => {
|
||||
const session = binding.tmuxSession, pane = binding.tmuxPane;
|
||||
if (!session || !pane) throw new Error("Runner authority needs a concrete launch pane.");
|
||||
// sendShellCommand stages the payload through /bin/sh for non-POSIX pane shells too.
|
||||
// The private bootstrap itself names /bin/sh; it must not narrow the existing parent-shell support.
|
||||
const panePid = await tmux.getPanePid(pane);
|
||||
if (!panePid) throw new Error("Runner authority needs a concrete launch process.");
|
||||
const registry = new SessionRegistry(db), grants = new ActivityGrantStore(db);
|
||||
const predecessorGeneration = registry.currentOccupantTenure(binding.nodeId)?.generationUuid;
|
||||
const generation = binding.launchGeneration ?? predecessorGeneration;
|
||||
if (!generation) throw new Error("Runner authority needs the reserved launch generation.");
|
||||
const row = db.prepare("SELECT id FROM sessions WHERE node_id=? AND session_name=? ORDER BY id DESC LIMIT 1")
|
||||
.get(binding.nodeId, session) as { id: string } | undefined;
|
||||
// An explicitly staged successor has no managed row until cutover.
|
||||
const grant = grants.issue({ nodeId: binding.nodeId, generation, issuedBy: "runner-launch",
|
||||
run: { runId, ...(registry.currentOccupantTenure(binding.nodeId)?.generationUuid === generation && row ? { sessionId: row.id } : {}),
|
||||
binding: { runtime, runner, pane, panePid } } });
|
||||
const key = `OPENRIG_RUN_GRANT_${grant.grantId.replace(/[^A-Za-z0-9]/g, "_").toUpperCase()}`;
|
||||
try { await tmux.setSessionEnv(session, key, grant.token); }
|
||||
catch {
|
||||
try { grants.revoke({ grantId: grant.grantId }, "credential_distribution_failed"); }
|
||||
finally {
|
||||
try { await tmux.unsetSessionEnv(session, key); } catch { /* private write may have succeeded before failing */ }
|
||||
}
|
||||
throw new Error("Runner credential distribution failed; no runner started.");
|
||||
}
|
||||
return {
|
||||
grantId: grant.grantId,
|
||||
generation: grant.generation,
|
||||
wrap(command) {
|
||||
const target = shellQuote(`=${session}`);
|
||||
// No eval: token text is treated only as data. Disable tracing before reading the private value.
|
||||
const script = `set +x; unset OPENRIG_RUN_ID; if _openrig_grant=$(tmux show-environment -t ${target} ${key} 2>/dev/null) && `
|
||||
+ `case "$_openrig_grant" in ${key}=oag_*) true;; *) false;; esac && tmux set-environment -t ${target} -u ${key} 2>/dev/null; then `
|
||||
+ `OPENRIG_ACTIVITY_HOOK_TOKEN=\${_openrig_grant#*=}; OPENRIG_RUN_ID=${shellQuote(runId)}; OPENRIG_OCCUPANT_GENERATION=${shellQuote(grant.generation)}; `
|
||||
+ `export OPENRIG_ACTIVITY_HOOK_TOKEN OPENRIG_RUN_ID OPENRIG_OCCUPANT_GENERATION; `
|
||||
+ `else tmux set-environment -t ${target} -u ${key} 2>/dev/null || :; printf '%s\\n' 'Run-bound reporting unavailable; using existing install-token reporting.' >&2; fi; `
|
||||
+ `unset _openrig_grant; exec ${command}`;
|
||||
return `/bin/sh -c ${shellQuote(script)}`;
|
||||
},
|
||||
dispatched() {
|
||||
const currentGeneration = registry.currentOccupantTenure(binding.nodeId)?.generationUuid;
|
||||
if (currentGeneration !== predecessorGeneration && currentGeneration !== generation) {
|
||||
throw new Error("Runner launch selection changed during dispatch; authority was refused.");
|
||||
}
|
||||
if (!grants.activate(grant.grantId) && registry.isOccupantGenerationRegistered(binding.nodeId, generation)) {
|
||||
throw new Error("Runner launch binding changed during dispatch; authority was refused.");
|
||||
}
|
||||
},
|
||||
async finish(ok) {
|
||||
// A staged readiness result can arrive after another cutover. It owns only this issued grant.
|
||||
try {
|
||||
const currentGeneration = ok ? registry.currentOccupantTenure(binding.nodeId)?.generationUuid : undefined;
|
||||
if (!ok || (currentGeneration !== predecessorGeneration && currentGeneration !== generation)) {
|
||||
grants.revoke({ grantId: grant.grantId }, "launch_failed_or_uncertain");
|
||||
}
|
||||
} finally {
|
||||
// Also retry cleanup when the launch shell continued without the private value.
|
||||
try { await tmux.unsetSessionEnv(session, key); } catch { /* optional cleanup cannot undo dispatch */ }
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -147,7 +147,10 @@ export type HarnessLaunchRecovery = "retry_fresh" | "attention_required";
|
||||
|
||||
// `launchedProcess` (Claude resume launches): the first process the launch's own readiness check
|
||||
// pinned as the one it started, kept even when a later sample saw it replaced (#1077).
|
||||
export type HarnessLaunchResult = { firstInput?: import("./community-harness-first-input.js").FirstInputPassed;
|
||||
export type HarnessLaunchResult = {
|
||||
/** Non-secret issuance ID for exact cleanup by later Core readiness owners. Never the token. */
|
||||
activityGrantId?: string;
|
||||
firstInput?: import("./community-harness-first-input.js").FirstInputPassed;
|
||||
/** A community launcher that opts in was handed the seat's declared model as OPENRIG_MODEL: "confirmed" when the launch
|
||||
* command's send succeeded, "uncertain" when typing began and the send then failed. Never proof of the model that runs. */
|
||||
modelInput?: { model: string; dispatch: "confirmed" | "uncertain" } } & (
|
||||
|
||||
@@ -657,7 +657,7 @@ export class SeatHandoverService {
|
||||
// Partial: the successor is live in the preserved pane but the context packet never landed —
|
||||
// unwind the discovery candidate (cleanup marks it vanished; it NEVER kills the preserved seat)
|
||||
// and leave the binding unchanged (no false-green). The seat is re-wakeable from its session file.
|
||||
await this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId);
|
||||
await this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId, launch.activityGrantId);
|
||||
return {
|
||||
ok: false,
|
||||
code: "context_delivery_failed",
|
||||
@@ -742,7 +742,7 @@ export class SeatHandoverService {
|
||||
if (!delivered.ok) {
|
||||
// Same partial-state contract as the fresh packet: unwind the candidate,
|
||||
// binding unchanged, seat re-wakeable — never a false complete.
|
||||
await this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId);
|
||||
await this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId, launch.activityGrantId);
|
||||
return {
|
||||
ok: false,
|
||||
code: "context_delivery_failed",
|
||||
@@ -780,7 +780,7 @@ export class SeatHandoverService {
|
||||
launchAdditionNotes,
|
||||
startedWithoutRequired,
|
||||
launchWarnings: coreCopyNotes,
|
||||
cleanup: () => this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId),
|
||||
cleanup: () => this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId, launch.activityGrantId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1458,10 +1458,6 @@ export class SeatHandoverService {
|
||||
};
|
||||
}
|
||||
committed = outcome;
|
||||
// S19 ruling 01530 — the SOLE narrow call: after the commit lands, the activity
|
||||
// oracle sees the swap as its own event, keyed by the durable node id, identified
|
||||
// by the successor tenure (never the retiree's). In-memory, post-commit, optional.
|
||||
this.activityOracle?.declareOccupantSwap(input.node.id, committed.newSessionId);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1471,6 +1467,16 @@ export class SeatHandoverService {
|
||||
};
|
||||
}
|
||||
|
||||
// S19 ruling 01530 — the SOLE narrow call: after the commit lands, the activity
|
||||
// oracle sees the swap as its own event, keyed by the durable node id, identified
|
||||
// by the successor tenure (never the retiree's). In-memory, post-commit, optional.
|
||||
try {
|
||||
this.activityOracle?.declareOccupantSwap(input.node.id, committed.newSessionId);
|
||||
} catch (err) {
|
||||
// A notification failure cannot roll back the committed binding or abandon its grant.
|
||||
console.warn(`Seat activity swap notification failed: ${err instanceof Error ? err.name : typeof err}`);
|
||||
}
|
||||
|
||||
this.eventBus.notifySubscribers(committed.event);
|
||||
const postStatus = this.statusService.getStatus(`${input.status.logical_id}@${input.status.rig_name}`);
|
||||
const currentStatus = postStatus.ok
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PiRunnerState } from "../adapters/pi-runner-protocol.js";
|
||||
import { ActivityGrantStore, hasActivityGrantsTable } from "./activity-grants.js";
|
||||
import { observeClaudePaneProcess, observeClaudePaneRuntime, observeCodexPaneProcess, listNativeProcesses, type NativeProcessLister, type NativeProcessObservation } from "./native-process-lineage.js";
|
||||
import { isShellForeground } from "./shell-classifier.js";
|
||||
import { claudeResumeRotation } from "./session-registry.js";
|
||||
@@ -91,6 +93,7 @@ export interface SeatIdentityReconcilerDeps {
|
||||
tmux: Pick<TmuxAdapter, "listSessions" | "getPanePid" | "getPaneCommand"> & Partial<Pick<TmuxAdapter, "readAllPaneProcesses" | "listPanes">>;
|
||||
now?: () => Date;
|
||||
listProcesses?: NativeProcessLister;
|
||||
readPiRunnerState?: (sessionName: string) => PiRunnerState | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,6 +113,7 @@ export class SeatIdentityReconciler {
|
||||
private readonly now: () => Date;
|
||||
private readonly store: SeatIdentityStore;
|
||||
private readonly listProcesses: NativeProcessLister;
|
||||
private readonly readPiRunnerState?: SeatIdentityReconcilerDeps["readPiRunnerState"];
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private reconciling = false;
|
||||
private generation = 0;
|
||||
@@ -120,6 +124,7 @@ export class SeatIdentityReconciler {
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
this.store = new SeatIdentityStore(deps.db);
|
||||
this.listProcesses = deps.listProcesses ?? listNativeProcesses;
|
||||
this.readPiRunnerState = deps.readPiRunnerState;
|
||||
}
|
||||
|
||||
private runningSeats(): RunningSeatRow[] {
|
||||
@@ -172,6 +177,23 @@ export class SeatIdentityReconciler {
|
||||
|
||||
private async reconcileSweep(generation: number): Promise<void> {
|
||||
const seats = this.runningSeats();
|
||||
// Capture exact issuance before any observation awaits. A delayed verdict cannot revoke a successor.
|
||||
const grants = hasActivityGrantsTable(this.db) ? new ActivityGrantStore(this.db) : null;
|
||||
const observedGrants = new Map(seats.map(seat => {
|
||||
const grant = grants?.activeForNode(seat.node_id);
|
||||
let bootstrapComplete = false;
|
||||
if (grant?.binding?.runtime === "pi" && grant.binding.runner === 0) {
|
||||
// A shell can be the launch bootstrap. Capture current-attempt readiness BEFORE observing the pane;
|
||||
// a later ready result cannot turn an earlier bootstrap-shell sample into a definitive exit.
|
||||
try {
|
||||
const state = this.readPiRunnerState?.(seat.session_name);
|
||||
bootstrapComplete = !!state && state.launchId === grant.runId && (state.ready || !!state.exited);
|
||||
} catch { /* Missing launch evidence is unknown, not proof that the runner exited. */ }
|
||||
}
|
||||
return [seat.node_id, grant?.runId && grant.binding?.pane === seat.tmux_pane
|
||||
? { grantId: grant.grantId, shellExitConfirmed: grant.binding.runtime !== "pi" || grant.binding.runner !== 0 || bootstrapComplete }
|
||||
: null];
|
||||
}));
|
||||
// Prune verdicts for nodes no longer running (keep the table bounded), except a released seat's.
|
||||
this.store.pruneExcept([...seats.map((s) => s.node_id), ...this.releasedSeats()]);
|
||||
if (seats.length === 0) return;
|
||||
@@ -272,6 +294,11 @@ export class SeatIdentityReconciler {
|
||||
const verdict = await this.computeVerdict(seat, liveSessions, observedAt, nativeProofs.get(seat.node_id) ?? null, panes);
|
||||
if (generation !== this.generation) return;
|
||||
this.store.upsert(verdict);
|
||||
const observedGrant = observedGrants.get(seat.node_id);
|
||||
if (observedGrant && (verdict.verdict === "pane_missing"
|
||||
|| (verdict.verdict === "mismatch" && verdict.reason === "process_identity_mismatch" && observedGrant.shellExitConfirmed))) {
|
||||
grants!.revoke({ grantId: observedGrant.grantId }, "runner_exit_observed");
|
||||
}
|
||||
} catch {
|
||||
if (generation !== this.generation) return;
|
||||
// A single seat's tmux failure must not crash the loop; record it as
|
||||
@@ -342,9 +369,15 @@ export class SeatIdentityReconciler {
|
||||
}
|
||||
const pid = batched ? batched.pid : await this.tmux.getPanePid(seat.tmux_pane);
|
||||
if (pid === null) {
|
||||
// The registered pane no longer resolves. Distinguish "the whole tmux
|
||||
// session is gone" from "the pane within a live session is gone".
|
||||
// getPanePid collapses transport/read failures into null. Only a readable census
|
||||
// showing another pane, or a successful session snapshot proving absence, establishes loss.
|
||||
const sessionAlive = liveSessions.has(seat.session_name);
|
||||
if (sessionAlive) {
|
||||
const currentPanes = await this.tmux.listPanes?.(seat.session_name, true).catch(() => null);
|
||||
if (!currentPanes?.length || currentPanes.some(pane => pane.id === seat.tmux_pane)) {
|
||||
return this.tmuxUnavailableVerdict(seat, observedAt);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
verdict: "pane_missing",
|
||||
|
||||
@@ -1940,7 +1940,11 @@ export class SeatLifecycleService {
|
||||
const probed = await this.probeLiveness(session.session_name, "in-place restart refuses rather than act on an unknown session");
|
||||
if ("code" in probed) return probed;
|
||||
if (probed.state === "absent") {
|
||||
return { ok: false, code: "session_not_live", message: `Session "${session.session_name}" is absent in tmux; there is nothing to restart in place.` };
|
||||
return { ok: false, code: "session_not_live", message: `Session "${session.session_name}" is absent in tmux; there is nothing to restart in place.`,
|
||||
// A recoverable seat (#3131) comes back in its saved conversation through the ordinary launch, leaving the rest of the rig alone.
|
||||
...(resolved.entry.lifecycleState === "recoverable"
|
||||
? { guidance: `The seat is recoverable from its saved conversation: rig launch ${shellQuote(seat.rigName)} ${shellQuote(seat.logicalId)} (add --plan to preview).` }
|
||||
: {}) };
|
||||
}
|
||||
const binding = this.sessionRegistry.getBindingForNode(node.id);
|
||||
if (!binding?.tmuxSession || binding.tmuxSession !== session.session_name || !binding.tmuxPane) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ActivityGrantStore } from "./activity-grants.js";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { piAccountStateRoot, piAccountSwitchStateRoot } from "./pi-account-state.js";
|
||||
import { piSeatPaths } from "../adapters/pi-runner-protocol.js";
|
||||
@@ -416,7 +417,9 @@ export class SeatRuntimeSwitchService {
|
||||
.run(id, node.id, sessionName, row.staging_session, ctx.predecessorPane, ctx.predecessorGeneration, successorGeneration, node.runtime, target,
|
||||
ctx.input.reason, ctx.input.requestedBy ?? null, JSON.stringify(ctx.plan), deadlineAt, stamp(), stamp());
|
||||
let successorPane: string | null = null;
|
||||
let activityGrantId: string | undefined;
|
||||
const abandon = async (why: string): Promise<RuntimeSwitchRefusal> => {
|
||||
if (activityGrantId) new ActivityGrantStore(db).revoke({ grantId: activityGrantId }, "cutover_abandoned");
|
||||
const retired = await this.retireStaging(row.staging_session, node.id, "Uncommitted successor");
|
||||
db.prepare(`UPDATE seat_runtime_switches SET phase = 'abandoned', outcome = ?, attention = ?, updated_at = ? WHERE id = ? AND committed_at IS NULL`)
|
||||
.run(why, retired.ok ? null : `The staged successor couldn't be retired: ${retired.message}`, stamp(), id);
|
||||
@@ -486,6 +489,7 @@ export class SeatRuntimeSwitchService {
|
||||
const attention = launch.code === "successor_attention_required" ? " It is waiting at a login, trust or approval prompt." : "";
|
||||
return abandon(`The ${target} successor didn't start: ${launch.message}.${attention}`);
|
||||
}
|
||||
activityGrantId = launch.activityGrantId;
|
||||
const warnings = [...ctx.plan.warnings, ...(launch.notes ?? [])];
|
||||
|
||||
// 2. The handover, as the successor's first turn; the confirmation it asks for is the evidence it can carry on.
|
||||
@@ -546,6 +550,7 @@ export class SeatRuntimeSwitchService {
|
||||
committed = this.commit({ ...ctx, id, successorGeneration, successorPane: successorPane!, appliedLaunch: launch.appliedLaunch,
|
||||
launchResume: launch.resumeToken ? { type: launch.resumeType, token: launch.resumeToken } : null });
|
||||
} catch (error) {
|
||||
if (activityGrantId) new ActivityGrantStore(db).revoke({ grantId: activityGrantId }, "cutover_failed");
|
||||
const back = await tmux.swapRuntimeSwitchPanes({ nodeId: node.id, stagingSession: row.staging_session, canonicalSession: sessionName,
|
||||
canonicalPane: successorPane!, stagedPane: ctx.predecessorPane });
|
||||
if (!back.ok) {
|
||||
@@ -728,6 +733,13 @@ export class SeatRuntimeSwitchService {
|
||||
const row = this.openSwitch(nodeId);
|
||||
if (!row) return { ok: true, outcome: "No runtime switch needs recovery.", switchId: null };
|
||||
const { db, tmuxAdapter: tmux } = this.deps;
|
||||
if (!row.committed_at) {
|
||||
const grants = new ActivityGrantStore(db);
|
||||
// The durable operation owns this unique reservation. Capture/revoke its exact pending IDs before awaits;
|
||||
// active predecessor authority and other reservations are outside this recovery.
|
||||
const pending = grants.pendingForGeneration(nodeId, row.successor_generation);
|
||||
for (const grant of pending) grants.revoke({ grantId: grant.grantId }, "interrupted_cutover_abandoned");
|
||||
}
|
||||
const at = () => this.now().toISOString();
|
||||
const stuck = (message: string) => {
|
||||
db.prepare("UPDATE seat_runtime_switches SET attention = ?, updated_at = ? WHERE id = ?").run(message, at(), row.id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ActivityGrantStore } from "./activity-grants.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { monotonicFactory } from "ulid";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -327,6 +328,8 @@ export class SessionRegistry {
|
||||
.run(id, nodeId, sessionName);
|
||||
this.mintOccupantTenureBestEffort(nodeId, kind, reservedGeneration); // atom-B: mint this occupant generation
|
||||
this.observeWatchdogRegistration(nodeId, sessionName, kind);
|
||||
try { new ActivityGrantStore(this.db).reconcileBinding(nodeId); }
|
||||
catch { /* Optional run attribution must not fail an already registered claimed session. */ }
|
||||
|
||||
return this.rowToSession(
|
||||
this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id) as SessionRow
|
||||
@@ -492,6 +495,15 @@ export class SessionRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/** Name existence is independent of best-effort tenure recording. */
|
||||
carriesSessionName(sessionName: string): boolean {
|
||||
return !!this.db.prepare(`SELECT 1 FROM nodes n JOIN rigs r ON r.id = n.rig_id
|
||||
WHERE r.archived_at IS NULL AND (
|
||||
EXISTS (SELECT 1 FROM sessions s WHERE s.node_id = n.id AND s.session_name = ?)
|
||||
OR EXISTS (SELECT 1 FROM bindings b WHERE b.node_id = n.id AND (b.tmux_session = ? OR b.external_session_name = ?))
|
||||
) LIMIT 1`).get(sessionName, sessionName, sessionName);
|
||||
}
|
||||
|
||||
private rowToTenure(row: OccupantTenureRow): OccupantTenure {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -505,6 +517,7 @@ export class SessionRegistry {
|
||||
}
|
||||
|
||||
updateStatus(sessionId: string, status: string): void {
|
||||
if (["exited", "detached", "superseded"].includes(status)) new ActivityGrantStore(this.db).revokeForSession(sessionId, status);
|
||||
this.db
|
||||
.prepare("UPDATE sessions SET status = ?, intentionally_stopped = 0, last_seen_at = datetime('now') WHERE id = ?")
|
||||
.run(status, sessionId);
|
||||
@@ -513,6 +526,7 @@ export class SessionRegistry {
|
||||
/** A supported stop is distinct from an observed exit. Any later status write
|
||||
* clears the intent, including a resume of this same session row. */
|
||||
markIntentionallyStopped(sessionId: string): void {
|
||||
new ActivityGrantStore(this.db).revokeForSession(sessionId, "stopped");
|
||||
this.db.prepare(
|
||||
"UPDATE sessions SET status = 'exited', intentionally_stopped = 1, last_seen_at = datetime('now') WHERE id = ?",
|
||||
).run(sessionId);
|
||||
|
||||
@@ -2686,7 +2686,7 @@ export class SessionTransport {
|
||||
// segment must never edit a line of the exact text.
|
||||
if (opts?.exactText !== undefined && !opts.dangerouslyInteract) {
|
||||
const { exactText, ...rest } = opts;
|
||||
if (!isBoundTerminalSeat(this.db, sessionName)) return this.send(sessionName, text, rest);
|
||||
if (!isBoundTerminalSeat(this.db, sessionName) && !isBoundTerminalSeat(this.db, physicalSessionName(this.db, sessionName))) return this.send(sessionName, text, rest);
|
||||
return { ...(await this.send(sessionName, exactText, { ...rest, stampISO: undefined, typedExactly: true })), envelopeOmitted: true };
|
||||
}
|
||||
const observe = () => { try { return this.compactionObserver?.(sessionName) ?? null; } catch { return null; } };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ActivityGrantStore } from "./activity-grants.js";
|
||||
import { nonInterruptiveNotice } from "../adapters/non-interruptive.js";
|
||||
import { planModelAliasNotice } from "../adapters/yolo-mode.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -414,6 +415,7 @@ export class StartupOrchestrator {
|
||||
? "rebuilt"
|
||||
: "fresh";
|
||||
let appliedLaunch: AppliedLaunchObservation | undefined;
|
||||
let activityGrantId: string | undefined;
|
||||
let resumedProcess: { pid: number; startedAt: string } | undefined;
|
||||
const forkContext: PendingForkContext | undefined = input.adapter.runtime === "codex"
|
||||
&& input.forkSource?.kind === "native_id" && input.forkSource.value?.trim()
|
||||
@@ -691,6 +693,7 @@ export class StartupOrchestrator {
|
||||
warnings.push(`Model "${launchResult.modelInput.model}": the launch command's send is uncertain, so it isn't recorded as passed.`);
|
||||
}
|
||||
if (launchResult.ok) {
|
||||
activityGrantId = launchResult.activityGrantId;
|
||||
appliedLaunch = launchResult.appliedLaunch;
|
||||
resumedProcess = launchResult.resumedProcess;
|
||||
if (input.adapter.runtime === "claude-code" && launchResumeToken?.trim()) {
|
||||
@@ -820,6 +823,7 @@ export class StartupOrchestrator {
|
||||
const readinessTimeoutMs = resolveReadinessTimeoutMs(input.readinessTimeoutMs, this.readinessSettings);
|
||||
const readiness = await this.waitForReady(input.adapter, input.binding, readinessTimeoutMs);
|
||||
if (!readiness.ready) {
|
||||
if (activityGrantId) new ActivityGrantStore(this.db).revoke({ grantId: activityGrantId }, "readiness_failed");
|
||||
const hasPendingReadinessWork = getPendingReadinessWork();
|
||||
if (isAttentionRequiredReadinessCode(readiness.code)) {
|
||||
errors.push(`Startup requires attention: ${readiness.reason ?? "unknown"}`);
|
||||
@@ -829,6 +833,7 @@ export class StartupOrchestrator {
|
||||
return this.fail(input, forkContext ? "attention_required" : "failed", errors, undefined, forkContext ?? false, undefined, false, "readiness_check", hasPendingReadinessWork);
|
||||
}
|
||||
} catch (err) {
|
||||
if (activityGrantId) new ActivityGrantStore(this.db).revoke({ grantId: activityGrantId }, "readiness_failed");
|
||||
errors.push(`Readiness check error: ${(err as Error).message}`);
|
||||
return this.fail(input, "failed", errors, undefined, false, undefined, false, "readiness_check", getPendingReadinessWork());
|
||||
}
|
||||
@@ -994,6 +999,7 @@ export class StartupOrchestrator {
|
||||
try {
|
||||
const readiness = await input.adapter.checkReady(input.binding);
|
||||
if (!readiness.ready && isAttentionRequiredReadinessCode(readiness.code)) {
|
||||
if (activityGrantId) new ActivityGrantStore(this.db).revoke({ grantId: activityGrantId }, "post_delivery_readiness_failed");
|
||||
// A fresh Claude start whose last startup turn was refused hasn't read what that turn sent: it's owed too.
|
||||
const refusedLast = input.adapter.runtime === "claude-code" && isFreshLaunch && deliveryInput.sendOrder > 0
|
||||
? owed([], readiness) : null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ActivityGrantStore } from "./activity-grants.js";
|
||||
import { ulid } from "ulid";
|
||||
import { seatEnvLaunchVars, type SeatEnv } from "./seat-env.js";
|
||||
import { SEAT_NODE_ENV, SEAT_PROJECT_DIR_ENV } from "./claude-core-plugin-env.js";
|
||||
@@ -94,6 +95,7 @@ export type SuccessorLaunchResult =
|
||||
| {
|
||||
ok: true;
|
||||
discoveredId: string;
|
||||
activityGrantId?: string;
|
||||
tmuxSession: string;
|
||||
tmuxPane: string;
|
||||
resumeToken?: string;
|
||||
@@ -326,6 +328,7 @@ export class SuccessorSessionLauncher {
|
||||
resumeToken: started.resumeToken,
|
||||
resumeType: started.resumeType,
|
||||
appliedLaunch: started.appliedLaunch,
|
||||
...(started.activityGrantId ? { activityGrantId: started.activityGrantId } : {}),
|
||||
retireeExit: terminated.path,
|
||||
...(respawned.warnings ? { warnings: respawned.warnings } : {}),
|
||||
};
|
||||
@@ -345,7 +348,7 @@ export class SuccessorSessionLauncher {
|
||||
occupantGeneration: string;
|
||||
onPaneCreated: (pane: string) => void;
|
||||
staged: Omit<StagedLaunch, "canonicalSession">;
|
||||
}): Promise<{ ok: true; tmuxPane: string; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] }
|
||||
}): Promise<{ ok: true; activityGrantId?: string; tmuxPane: string; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] }
|
||||
| { ok: false; code: string; message: string }> {
|
||||
const env = compactEnv({
|
||||
OPENRIG_NODE_ID: input.node.id,
|
||||
@@ -394,7 +397,7 @@ export class SuccessorSessionLauncher {
|
||||
forkSource?: ForkSource,
|
||||
launchGeneration?: string | null,
|
||||
staged?: StagedLaunch,
|
||||
): Promise<{ ok: true; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] } | { ok: false; code: string; message: string; launched?: true }> {
|
||||
): Promise<{ ok: true; activityGrantId?: string; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] } | { ok: false; code: string; message: string; launched?: true }> {
|
||||
const adapter = node.runtime ? this.runtimeAdapters[node.runtime] : undefined;
|
||||
if (!adapter) {
|
||||
return {
|
||||
@@ -483,10 +486,14 @@ export class SuccessorSessionLauncher {
|
||||
// Catch it here and return a STRUCTURED ok:false so the caller fails loudly at
|
||||
// step=start_agent, exactly like a returned readiness failure. Nothing kills the
|
||||
// pane (unwind invariant above): the launched successor may still be running there.
|
||||
const revokeRun = () => {
|
||||
if (launch.activityGrantId) new ActivityGrantStore(this.discoveryRepo.db).revoke({ grantId: launch.activityGrantId }, "successor_readiness_failed");
|
||||
};
|
||||
let readiness: ReadinessResult;
|
||||
try {
|
||||
readiness = await this.waitForReady(adapter, binding);
|
||||
} catch (err) {
|
||||
revokeRun();
|
||||
return {
|
||||
ok: false,
|
||||
code: "successor_readiness_failed",
|
||||
@@ -495,6 +502,7 @@ export class SuccessorSessionLauncher {
|
||||
};
|
||||
}
|
||||
if (!readiness.ready) {
|
||||
revokeRun();
|
||||
return {
|
||||
ok: false,
|
||||
code: isAttentionRequiredReadinessCode(readiness.code) ? "successor_attention_required" : "successor_not_ready",
|
||||
@@ -503,7 +511,7 @@ export class SuccessorSessionLauncher {
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, resumeToken, resumeType, appliedLaunch: launch.appliedLaunch, ...(notes?.length ? { notes } : {}) };
|
||||
return { ok: true, resumeToken, resumeType, ...(launch.activityGrantId ? { activityGrantId: launch.activityGrantId } : {}), appliedLaunch: launch.appliedLaunch, ...(notes?.length ? { notes } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -564,7 +572,8 @@ export class SuccessorSessionLauncher {
|
||||
* candidate vanished; the re-wakeable shell stays in the pane and the seat is recoverable from its
|
||||
* provider session file. (`tmuxSession` is retained for signature stability + call-site logging.)
|
||||
*/
|
||||
async cleanup(_tmuxSession: string, discoveredId: string | null): Promise<void> {
|
||||
async cleanup(_tmuxSession: string, discoveredId: string | null, activityGrantId?: string): Promise<void> {
|
||||
if (activityGrantId) new ActivityGrantStore(this.discoveryRepo.db).revoke({ grantId: activityGrantId }, "cutover_abandoned");
|
||||
if (discoveredId) this.discoveryRepo.markVanished([discoveredId]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,8 +142,8 @@ interface WatchdogPolicyEngineDeps {
|
||||
* SKIPPING, so an unknown target-generation must never skip a legitimate wake (note-2 inversion).
|
||||
*/
|
||||
resolveTargetGeneration?: (sessionName: string) => string | null;
|
||||
/** Whether any node and occupant generation still carry this name. When resolveTargetGeneration is
|
||||
* unknown, a carried name is ambiguous (a context watch skips and measures again); one carried by
|
||||
/** Whether an unarchived node still carries this name, even without a recorded tenure. When resolveTargetGeneration is
|
||||
* unknown, a carried name is unresolved (a context watch skips and measures again); one carried by
|
||||
* nothing is a seat that's gone (the watch ends). Absent → gone, as before. */
|
||||
targetNameCarried?: (sessionName: string) => boolean;
|
||||
/**
|
||||
|
||||
@@ -158,6 +158,14 @@ function bindToGrant(c: Context, store: AgentActivityStore, grant: ActivityGrant
|
||||
if (revoked && !permissionRoute) {
|
||||
return refuse(401, "activity_grant_revoked", "This activity grant was revoked; nothing was recorded.");
|
||||
}
|
||||
if (grant.runId && body.runId !== undefined && body.runId !== grant.runId) {
|
||||
return refuse(409, "activity_grant_stale", "This post names another run; nothing was recorded.");
|
||||
}
|
||||
const claimedRuntime = stringOrNull(body.runtime);
|
||||
if (grant.binding && ((claimedRuntime && claimedRuntime !== grant.binding.runtime)
|
||||
|| (body.eventFamily === "session_identity" && !claimedRuntime))) {
|
||||
return refuse(403, "activity_grant_runtime_mismatch", "This runner grant speaks only for its bound runtime; nothing was recorded.");
|
||||
}
|
||||
const claimedNode = stringOrNull(body.nodeId);
|
||||
const claimedSession = stringOrNull(body.sessionName);
|
||||
if ((claimedNode && claimedNode !== grant.nodeId)
|
||||
@@ -178,6 +186,15 @@ function bindToGrant(c: Context, store: AgentActivityStore, grant: ActivityGrant
|
||||
* hasn't registered yet (pending). A `revoked` grant whose occupant wasn't replaced reads revoked: it was reissued for
|
||||
* the same launch. `outcome` says what the refusal left undone. */
|
||||
function grantStandingRefusal(c: Context, store: AgentActivityStore, grant: ActivityGrant, outcome: string, revoked = false): Response | null {
|
||||
if (grant.runId) {
|
||||
const standing = new ActivityGrantStore(store.db).standing(grant.grantId);
|
||||
if (standing === "current") return null;
|
||||
if (standing === "unverified") {
|
||||
return c.json({ ok: false, code: "activity_grant_unverified", retryable: true,
|
||||
error: `This runner grant couldn't be verified now; retry verification; ${outcome}` }, 409);
|
||||
}
|
||||
return c.json({ ok: false, code: `activity_grant_${standing}`, error: `This runner grant is ${standing}; ${outcome}` }, standing === "revoked" ? 401 : 409);
|
||||
}
|
||||
const registry = (c.get("sessionRegistry" as never) as SessionRegistry | undefined) ?? new SessionRegistry(store.db);
|
||||
let standing: ReturnType<typeof grantGeneration>;
|
||||
try {
|
||||
@@ -211,7 +228,10 @@ async function piUsageRequest(c: Context, what: string, needs: (db: SessionRegis
|
||||
}
|
||||
const authHeader = c.req.header("authorization");
|
||||
const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice("Bearer ".length).trim() : null;
|
||||
if (bearerToken !== expectedToken && (c.req.header("x-openrig-activity-token") ?? null) !== expectedToken) {
|
||||
const headerToken = c.req.header("x-openrig-activity-token") ?? null;
|
||||
const grantStore = new ActivityGrantStore(sessionRegistry.db);
|
||||
const grant = grantStore.authenticate(bearerToken) ?? grantStore.authenticate(headerToken);
|
||||
if (bearerToken !== expectedToken && headerToken !== expectedToken && !grant?.runId) {
|
||||
return { error: c.json({ ok: false, code: "activity_hook_unauthorized", error: `${what} require the configured local hook token.` }, 401) };
|
||||
}
|
||||
let body: Record<string, unknown>;
|
||||
@@ -223,6 +243,12 @@ async function piUsageRequest(c: Context, what: string, needs: (db: SessionRegis
|
||||
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
||||
return { error: c.json({ ok: false, code: "invalid_pi_usage", error: `${what} require a JSON object` }, 400) };
|
||||
}
|
||||
if (grant?.runId) {
|
||||
const bound = bindToGrant(c, store, grant, body);
|
||||
if ("refused" in bound) return { error: bound.refused };
|
||||
if (grant.binding?.runtime !== "pi") return { error: c.json({ ok: false, code: "activity_grant_wrong_seat", error: "Pi usage requires a Pi run." }, 403) };
|
||||
body = bound.body;
|
||||
}
|
||||
const sessionName = stringOrNull(body.sessionName);
|
||||
const piSessionId = stringOrNull(body.piSessionId);
|
||||
if (!sessionName || !piSessionId) {
|
||||
@@ -326,6 +352,9 @@ async function postHook(c: Context, installTokenAccepted: boolean): Promise<Resp
|
||||
return c.json({ ok: false, code: "invalid_json", error: "Request body must be a JSON object." }, 400);
|
||||
}
|
||||
|
||||
if (Object.hasOwn(body, "runId") && !grant?.runId) {
|
||||
return c.json({ ok: false, code: "run_grant_required", error: "Actual-run reports require their run-bound credential." }, 403);
|
||||
}
|
||||
if (typeof body.sessionName === "string") body = { ...body, sessionName: physicalSessionName(store.db, body.sessionName) };
|
||||
if (grant) {
|
||||
const bound = bindToGrant(c, store, grant, body);
|
||||
@@ -466,7 +495,7 @@ function isPiRpcHarness(db: import("better-sqlite3").Database, sessionId: string
|
||||
if (!generation) reason = "generation_unverifiable";
|
||||
else if (!current || !sessionRegistry.isOccupantGenerationRegistered(resolved.nodeId, generation)) {
|
||||
reason = "generation_unresolvable";
|
||||
} else if (current.generationUuid !== generation) reason = "generation_mismatch";
|
||||
} else if (current.generationUuid !== generation && !(grant?.runId && grant.generation === generation)) reason = "generation_mismatch";
|
||||
} catch {
|
||||
return c.json({
|
||||
ok: false, code: "generation_resolver_error", tokenPersisted: false,
|
||||
|
||||
@@ -44,22 +44,25 @@ import { CodexThreadIdResolver } from "../domain/codex-thread-id.js";
|
||||
import { resolveLiveCodexThreadId } from "../domain/model-divergence/current-generation-record.js";
|
||||
import { SeatIdentityStore } from "../domain/seat-identity-store.js";
|
||||
import { parseSqliteUtcMs } from "../domain/sqlite-time.js";
|
||||
import { runSyncSiteWhenSlow } from "../domain/sync-site-wrap.js";
|
||||
|
||||
const generationCensus = new ProcessCensus({ freshnessMs: 0 }); // coalesce concurrent receipts; recheck each later read
|
||||
const generationThreadIds = new CodexThreadIdResolver({ codexHome: process.env.CODEX_HOME || undefined });
|
||||
|
||||
/** The node inventory's synchronous steps run one at a time, each after a turn of the event loop. They're SQLite work
|
||||
* that can't yield, so concurrent requests ran their whole builds back to back and a read queued behind a burst (a
|
||||
* /healthz, a queue read) waited for all of them; now it waits for one step. Steps that await I/O run outside. */
|
||||
* /healthz, a queue read) waited for all of them; now it waits for one step. Steps that await I/O run outside.
|
||||
* A step that takes the slow threshold or more is named `site` in the slow-operation log. That's its own synchronous
|
||||
* time, measured once its turn comes: not the wait for the turn, and not the settling of a promise it returns. */
|
||||
let inventoryTurn: Promise<void> = Promise.resolve();
|
||||
async function inventoryStep<T>(work: () => T): Promise<Awaited<T>> {
|
||||
async function inventoryStep<T>(site: string, work: () => T): Promise<Awaited<T>> {
|
||||
const previous = inventoryTurn;
|
||||
let release = () => {};
|
||||
inventoryTurn = new Promise<void>((resolve) => { release = () => resolve(); });
|
||||
try {
|
||||
await previous;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return await work();
|
||||
return await runSyncSiteWhenSlow(site, work);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
@@ -147,7 +150,7 @@ nodesRoutes.get("/", observeInventoryHandler, async (c) => {
|
||||
|
||||
const contextUsageStore = c.get("contextUsageStore" as never) as ContextUsageStore | undefined;
|
||||
const transcriptStore = c.get("transcriptStore" as never) as TranscriptStore | undefined;
|
||||
const inventory = await inventoryStep(() => contextUsageStore
|
||||
const inventory = await inventoryStep("nodes.inventory.build", () => contextUsageStore
|
||||
? getNodeInventoryWithContext(deps.rigRepo.db, rigId, contextUsageStore, transcriptStore)
|
||||
: getNodeInventory(deps.rigRepo.db, rigId));
|
||||
// Slice 15 — enrich with the two new orthogonal primitives. Order is
|
||||
@@ -181,8 +184,8 @@ nodesRoutes.get("/", observeInventoryHandler, async (c) => {
|
||||
seatActivity: deps.seatActivityService,
|
||||
captureFallback: full,
|
||||
});
|
||||
const withActivity = full ? await attachActivity() : await inventoryStep(attachActivity);
|
||||
const withTerminalAndWork = await inventoryStep(() => attachTerminalActivityAndWork(withActivity, {
|
||||
const withActivity = full ? await attachActivity() : await inventoryStep("nodes.inventory.activity_sync", attachActivity);
|
||||
const withTerminalAndWork = await inventoryStep("nodes.inventory.terminal_work", () => attachTerminalActivityAndWork(withActivity, {
|
||||
db: deps.rigRepo.db,
|
||||
seatActivity: deps.seatActivityService,
|
||||
agentActivity: deps.agentActivityStore,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runnerGrantIssuer } from "./domain/runner-grant-launch.js";
|
||||
import { piAccountStateRoot } from "./domain/pi-account-state.js";
|
||||
import { EnvironmentStartup } from "./domain/environment-startup.js";
|
||||
import { EnvironmentPlatform } from "./domain/environment-platform.js";
|
||||
@@ -652,6 +653,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
const seatIdentityReconciler = new SeatIdentityReconciler({
|
||||
db,
|
||||
tmux: tmuxAdapter,
|
||||
readPiRunnerState: sessionName => piAdapter.readRunnerSidecar(sessionName).state,
|
||||
});
|
||||
// cmuxFactory takes precedence (for tests), then cmuxExec-based CLI transport, then default
|
||||
const cmuxFactory = opts?.cmuxFactory
|
||||
@@ -816,11 +818,20 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
const piOpenRouterChargeEnabled = (): boolean => {
|
||||
try { return new ContextPackSettingsStore().resolveOne("usage.pi_openrouter_charge").value === true; } catch { return false; }
|
||||
};
|
||||
const runnerGrants = runnerGrantIssuer(db, tmuxAdapter);
|
||||
const piResume = new PiResumeAdapter(
|
||||
tmuxAdapter,
|
||||
{ readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }) },
|
||||
{ stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath },
|
||||
{ accountHomeForSession: session => seatRuntimeHomeBySession(db, session, "pi"), stateRootForSession: piStateRootForSession, seatLaunchEnvironment, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled },
|
||||
{ runnerGrant: async (session, runId, cwd) => {
|
||||
const owners = db.prepare(`SELECT b.node_id AS nodeId FROM bindings b JOIN nodes n ON n.id=b.node_id
|
||||
JOIN sessions s ON s.node_id=n.id AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)
|
||||
WHERE b.tmux_session=? AND s.session_name=? AND n.runtime='pi' AND s.status NOT IN ('superseded','detached','exited')`)
|
||||
.all(session, session) as Array<{ nodeId: string }>;
|
||||
const binding = owners.length === 1 ? sessionRegistry.getBindingForNode(owners[0]!.nodeId) : null;
|
||||
if (!binding) throw new Error("The restored Pi run has no unambiguous Core binding.");
|
||||
return runnerGrants({ ...binding, cwd }, "pi", runId, 0);
|
||||
}, accountHomeForSession: session => seatRuntimeHomeBySession(db, session, "pi"), stateRootForSession: piStateRootForSession, seatLaunchEnvironment, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled },
|
||||
);
|
||||
const ompResume = new OmpResumeAdapter(
|
||||
tmuxAdapter,
|
||||
@@ -1021,7 +1032,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
} });
|
||||
// OPR.0.4.6.PI1 — the RPC-first Pi adapter (runner-in-a-pane). Same fsOps
|
||||
// shape as the Codex adapter; seat isolation roots under piStateRoot.
|
||||
const piAdapter = new PiRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), linkInfo: lstatLinkInfo }, stateRootForSession: piStateRootForSession, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled });
|
||||
const piAdapter = new PiRuntimeAdapter({ runnerGrants, managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), linkInfo: lstatLinkInfo }, stateRootForSession: piStateRootForSession, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled });
|
||||
const ompAdapter = new OmpRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; } }, stateRoot: ompStateRoot, runnerEntryPath: piRunnerEntryPath });
|
||||
// OPR.0.5.1.1 — the stub runtime adapter (Pi-shaped node-script runner in a pane).
|
||||
// Same fsOps shape as Pi; the compiled runner entry lives in the daemon dist.
|
||||
@@ -1214,7 +1225,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
db, rigRepo, podRepo,
|
||||
sessionRegistry, eventBus, nodeLauncher, startupOrchestrator,
|
||||
fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), exists: (p: string) => fs.existsSync(p) },
|
||||
adapters: withCommunityHarnesses({ "claude-code": claudeAdapter, "codex": codexAdapter, "pi": piAdapter, "omp": ompAdapter, "stub": stubAdapter, "terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter() }, { tmux: tmuxAdapter, seatLaunchEnvironment, environmentStartup, piAdapter }),
|
||||
adapters: withCommunityHarnesses({ "claude-code": claudeAdapter, "codex": codexAdapter, "pi": piAdapter, "omp": ompAdapter, "stub": stubAdapter, "terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter() }, { tmux: tmuxAdapter, seatLaunchEnvironment, environmentStartup, piAdapter, runnerGrants }),
|
||||
tmuxAdapter,
|
||||
agentImageLibrary,
|
||||
continuityPolicyMaterializer,
|
||||
@@ -1279,7 +1290,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
"omp": ompAdapter,
|
||||
"stub": stubAdapter,
|
||||
"terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter(),
|
||||
}, { tmux: tmuxAdapter, seatLaunchEnvironment, environmentStartup, piAdapter });
|
||||
}, { tmux: tmuxAdapter, seatLaunchEnvironment, environmentStartup, piAdapter, runnerGrants });
|
||||
let kernelBootTracker: import("./domain/kernel-boot-tracker.js").KernelBootTracker | undefined;
|
||||
try {
|
||||
const { bootKernelIfNeeded } = await import("./domain/kernel-boot.js");
|
||||
@@ -2381,7 +2392,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// seat can hold a newer one, and a context watchdog would then read that node's telemetry. An external-CLI seat
|
||||
// addressed by its external session name, which the guard's address rules don't match, reads its own.
|
||||
resolveTargetGeneration: (s) => { const target = resolveGuardTarget(db, s); return target ? target.occupant ?? null : resolveExternalCliOccupant(db, s); },
|
||||
targetNameCarried: (s) => sessionRegistry.currentOccupantGenerationForSession(s) !== null,
|
||||
targetNameCarried: (s) => sessionRegistry.carriesSessionName(s),
|
||||
// Issue #1180 — sampled context use for token/percent context watchdogs,
|
||||
// generation-guarded against a predecessor's frozen telemetry.
|
||||
resolveContextUsage: (s, occupantGeneration) =>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
import { piUsageEntriesSchema } from "../src/db/migrations/107_pi_usage_entries.js";
|
||||
import { piUsageChargesSchema } from "../src/db/migrations/108_pi_usage_charges.js";
|
||||
// A seat activity grant on the existing hook ingress, through the real route, store, occupant ledger and oracle. The
|
||||
// grant is resolved first; only then is the seat and generation a post claims accepted. A refused post records nothing,
|
||||
// so the seat's activity stays unknown. The install-wide token keeps today's behaviour.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
@@ -45,6 +48,7 @@ function fixture(options: { guest?: boolean } = {}) {
|
||||
c.set("agentActivityStore" as never, store as never);
|
||||
if (!options.guest) c.set("activityHookToken" as never, INSTALL_TOKEN as never);
|
||||
c.set("sessionRegistry" as never, sessions as never);
|
||||
c.set("eventBus" as never, new EventBus(db) as never);
|
||||
c.set("seatActivityService" as never, oracle as never);
|
||||
await next();
|
||||
});
|
||||
@@ -90,7 +94,7 @@ function fixture(options: { guest?: boolean } = {}) {
|
||||
const recorded = () => (db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'agent.activity'").get() as { n: number }).n;
|
||||
const activity = (nodeId: string) => oracle.getSeatState(nodeId)?.activity ?? "unknown";
|
||||
const replace = (reserved?: string) => occupy(guest.id, GUEST, "handover", reserved);
|
||||
return { guest, other, sessions, grants, generation, issue, post, heldPost, raw, event, recorded, activity, replace };
|
||||
return { db, app, guest, other, sessions, grants, generation, issue, post, heldPost, raw, event, recorded, activity, replace };
|
||||
}
|
||||
|
||||
describe("a seat activity grant on the hook ingress", () => {
|
||||
@@ -116,6 +120,123 @@ describe("a seat activity grant on the hook ingress", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("requires bound authority for a claimed run even when install and generation are known", async () => {
|
||||
const f = fixture();
|
||||
const legacy = f.issue();
|
||||
for (const token of [INSTALL_TOKEN, legacy.token]) {
|
||||
expect(await f.post(token, f.event("Stop", "end_turn", { sessionName: GUEST, runId: "claimed-run" })))
|
||||
.toMatchObject({ status: 403, body: { code: "run_grant_required" } });
|
||||
}
|
||||
expect(f.recorded()).toBe(0);
|
||||
});
|
||||
|
||||
it("checks activation, run ID and the concrete binding before accepting run activity", async () => {
|
||||
const f = fixture();
|
||||
const row = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.guest.id) as { id: string };
|
||||
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.guest.id, GUEST);
|
||||
const grant = f.grants.issue({ nodeId: f.guest.id, generation: f.generation(f.guest.id), issuedBy: "launch", run: {
|
||||
runId: "actual-attempt", sessionId: row.id, binding: { runtime: "synth", runner: 1, pane: "%1", panePid: 101 } } });
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId }))).toMatchObject({ status: 409, body: { code: "activity_grant_pending" } });
|
||||
expect(f.grants.activate(grant.grantId)).toBe(true);
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: "forged" }))).toMatchObject({ status: 409 });
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId }))).toMatchObject({ status: 200 });
|
||||
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.guest.id);
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId }))).toMatchObject({ status: 409 });
|
||||
expect(f.recorded()).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses unverified run activity without recording or revoking, then recovers", async () => {
|
||||
const f = fixture();
|
||||
const row = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.guest.id) as { id: string };
|
||||
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.guest.id, GUEST);
|
||||
const grant = f.grants.issue({ nodeId: f.guest.id, generation: f.generation(f.guest.id), issuedBy: "launch", run: {
|
||||
runId: "temporarily-unverified", sessionId: row.id, binding: { runtime: "synth", runner: 1, pane: "%1", panePid: 101 } } });
|
||||
expect(f.grants.activate(grant.grantId)).toBe(true);
|
||||
const read = vi.spyOn(SeatIdentityStore.prototype, "getForNode").mockImplementation(() => { throw new Error("observation unavailable"); });
|
||||
try {
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId })))
|
||||
.toMatchObject({ status: 409, body: { code: "activity_grant_unverified", retryable: true } });
|
||||
expect(f.recorded()).toBe(0);
|
||||
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
|
||||
} finally { read.mockRestore(); }
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId }))).toMatchObject({ status: 200 });
|
||||
expect(f.recorded()).toBe(1);
|
||||
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.guest.id);
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId })))
|
||||
.toMatchObject({ status: 409, body: { code: "activity_grant_stale" } });
|
||||
expect(f.recorded()).toBe(1);
|
||||
});
|
||||
|
||||
it("allows a retained Pi run to reannounce identity after explicit same-process adoption", async () => {
|
||||
const f = fixture();
|
||||
f.db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(f.guest.id);
|
||||
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.guest.id, GUEST);
|
||||
const row = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.guest.id) as { id: string };
|
||||
const grant = f.grants.issue({ nodeId: f.guest.id, generation: f.generation(f.guest.id), issuedBy: "launch", run: {
|
||||
runId: "retained-pi", sessionId: row.id, binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
|
||||
f.grants.activate(grant.grantId);
|
||||
new SeatIdentityStore(f.db).upsert({ nodeId: f.guest.id, verdict: "unconfirmed", evidenceSource: "pane_process", reason: null,
|
||||
sessionName: GUEST, observedAt: new Date().toISOString(), evidence: { registeredPane: "%1", observedPid: 101,
|
||||
observedPanePid: 101, observedCommand: "node", matchedLayer: null } });
|
||||
f.sessions.registerClaimedSession(f.guest.id, GUEST, "adopt");
|
||||
expect(await f.post(grant.token, { runtime: "pi", eventFamily: "session_identity", hookEvent: "SessionStart",
|
||||
sessionName: GUEST, sessionId: "native-id", sessionFile: "/tmp/retained.jsonl", generation: grant.generation, runId: grant.runId }))
|
||||
.toMatchObject({ status: 200, body: { tokenPersisted: true } });
|
||||
});
|
||||
|
||||
it.each(["claude-code", undefined])("refuses another or absent runtime identity on a Pi grant without changing the saved resume pointer: %s", async runtime => {
|
||||
const f = fixture();
|
||||
f.db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(f.guest.id);
|
||||
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.guest.id, GUEST);
|
||||
const row = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.guest.id) as { id: string };
|
||||
f.sessions.updateResumeToken(row.id, "pi_session_file", "/tmp/original-pi.jsonl", "hook");
|
||||
const grant = f.grants.issue({ nodeId: f.guest.id, generation: f.generation(f.guest.id), issuedBy: "launch", run: {
|
||||
runId: "pi-attempt", sessionId: row.id, binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
|
||||
f.grants.activate(grant.grantId);
|
||||
const response = await f.post(grant.token, { runtime, eventFamily: "session_identity", hookEvent: "SessionStart",
|
||||
sessionId: "12345678-1234-1234-1234-123456789abc", generation: grant.generation, runId: grant.runId });
|
||||
expect(response).toMatchObject({ status: 403, body: { code: "activity_grant_runtime_mismatch" } });
|
||||
expect(f.db.prepare("SELECT resume_type, resume_token FROM sessions WHERE id = ?").get(row.id))
|
||||
.toEqual({ resume_type: "pi_session_file", resume_token: "/tmp/original-pi.jsonl" });
|
||||
expect(f.db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'agent.session_identity'").get()).toEqual({ n: 0 });
|
||||
expect(await f.post(grant.token, { runtime: "pi", eventFamily: "session_identity", hookEvent: "SessionStart",
|
||||
sessionId: "native-id", sessionFile: "/tmp/current-pi.jsonl", generation: grant.generation, runId: grant.runId }))
|
||||
.toMatchObject({ status: 200, body: { tokenPersisted: true } });
|
||||
expect(f.db.prepare("SELECT resume_type, resume_token FROM sessions WHERE id = ?").get(row.id))
|
||||
.toEqual({ resume_type: "pi_session_file", resume_token: "/tmp/current-pi.jsonl" });
|
||||
// Ordinary posts may omit runtime; a bound credential still supplies seat and generation.
|
||||
expect(await f.post(grant.token, { eventFamily: "turn_end", hookEvent: "Stop", runId: grant.runId })).toMatchObject({ status: 200 });
|
||||
expect(await f.post(grant.token, { runtime: "claude-code", eventFamily: "turn_end", hookEvent: "Stop", runId: grant.runId }))
|
||||
.toMatchObject({ status: 403, body: { code: "activity_grant_runtime_mismatch" } });
|
||||
// The install token still follows the existing identity path; it is not narrowed into a run-bound credential.
|
||||
expect(await f.post(INSTALL_TOKEN, { runtime: "claude-code", eventFamily: "session_identity", hookEvent: "SessionStart",
|
||||
sessionName: GUEST, sessionId: "12345678-1234-1234-1234-123456789abc", generation: grant.generation }))
|
||||
.toMatchObject({ status: 200, body: { tokenPersisted: true } });
|
||||
});
|
||||
|
||||
it("accepts bound Pi usage while preserving legacy install-token usage", async () => {
|
||||
const f = fixture();
|
||||
f.db.exec(piUsageEntriesSchema.sql!);
|
||||
f.db.exec(piUsageChargesSchema.sql!);
|
||||
f.db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(f.guest.id);
|
||||
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.guest.id, GUEST);
|
||||
const row = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.guest.id) as { id: string };
|
||||
const grant = f.grants.issue({ nodeId: f.guest.id, generation: f.generation(f.guest.id), issuedBy: "launch", run: {
|
||||
runId: "pi-attempt", sessionId: row.id, binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
|
||||
const post = (token: string, path: string, extra = {}) => f.app.request(`/api/activity/${path}`, { method: "POST",
|
||||
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({ sessionName: GUEST, piSessionId: "pi-native-id", facts: [], charges: [], ...extra }) });
|
||||
for (const path of ["pi-usage", "pi-usage-charges"]) expect((await post(grant.token, path)).status).toBe(409);
|
||||
f.grants.activate(grant.grantId);
|
||||
for (const path of ["pi-usage", "pi-usage-charges"]) {
|
||||
expect((await post(grant.token, path)).status).toBe(200);
|
||||
expect((await post(INSTALL_TOKEN, path)).status).toBe(200);
|
||||
expect((await post(grant.token, path, { nodeId: f.other.id })).status).toBe(403);
|
||||
}
|
||||
f.grants.revoke({ grantId: grant.grantId }, "stopped");
|
||||
expect((await post(grant.token, "pi-usage")).status).toBe(401);
|
||||
});
|
||||
|
||||
const wrongSeat: Array<[string, (f: ReturnType<typeof fixture>) => Record<string, unknown>]> = [
|
||||
["another seat's name", () => ({ sessionName: OTHER })],
|
||||
["another seat's node", (f) => ({ nodeId: f.other.id })],
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
// The seat activity grant's store and the only activity values a guest receives.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
import { runnerGrantIssuer } from "../src/domain/runner-grant-launch.js";
|
||||
import { ActivityGrantStore, grantGeneration, guestActivityEnv } from "../src/domain/activity-grants.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
|
||||
@@ -23,6 +30,16 @@ function fixture() {
|
||||
}
|
||||
|
||||
describe("issuing a seat activity grant", () => {
|
||||
|
||||
it.each(["claude-code", "codex", "terminal", "stub"])("does no run-binding work for an unrelated %s registration", runtime => {
|
||||
const f = fixture(); f.db.prepare("UPDATE nodes SET runtime = ? WHERE id = ?").run(runtime, f.node.id);
|
||||
const legacy = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "legacy" });
|
||||
const session = f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig");
|
||||
expect(session.status).toBe("running");
|
||||
expect(f.grants.get(legacy.grantId)?.revokedAt).toBeNull();
|
||||
expect(f.db.prepare("SELECT count(*) AS n FROM activity_grants WHERE run_id IS NOT NULL").get()).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it("returns the token once and stores only its sha256", () => {
|
||||
const f = fixture();
|
||||
const issued = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "driver@test" });
|
||||
@@ -76,6 +93,331 @@ describe("issuing a seat activity grant", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("actual run grants", () => {
|
||||
function run() {
|
||||
const f = fixture();
|
||||
const session = f.db.prepare("SELECT id FROM sessions WHERE node_id = ?").get(f.node.id) as { id: string };
|
||||
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,'dev-guest@guest-rig','%1')").run(f.node.id);
|
||||
const anchor = { runtime: "synth", runner: 1, pane: "%1", panePid: 101 };
|
||||
f.db.prepare("UPDATE sessions SET agent_app = 'synth', agent_runner = 1 WHERE id = ?").run(session.id);
|
||||
const issue = (runId: string) => f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "launch", run: { runId, sessionId: session.id, binding: anchor } });
|
||||
return { ...f, session, anchor, issue };
|
||||
}
|
||||
|
||||
it("stages a successor without replacing its predecessor; activation and late cleanup address exact grants", () => {
|
||||
const f = run();
|
||||
const first = f.issue("attempt-1");
|
||||
expect(f.grants.standing(first.grantId)).toBe("pending");
|
||||
expect(f.grants.activeForNode(f.node.id)).toBeNull();
|
||||
expect(f.grants.activate(first.grantId)).toBe(true);
|
||||
const failed = f.issue("attempt-2");
|
||||
expect(f.grants.activeForNode(f.node.id)?.grantId).toBe(first.grantId);
|
||||
f.grants.revoke({ grantId: failed.grantId }, "failed launch");
|
||||
expect(f.grants.activate(failed.grantId)).toBe(false);
|
||||
const next = f.issue("attempt-3");
|
||||
expect(f.grants.activate(next.grantId)).toBe(true);
|
||||
expect(f.grants.standing(first.grantId)).toBe("revoked");
|
||||
f.grants.revoke({ grantId: first.grantId }, "late completion");
|
||||
expect(f.grants.standing(next.grantId)).toBe("current");
|
||||
expect(JSON.stringify(f.db.prepare("SELECT * FROM activity_grants").all())).not.toContain(next.token);
|
||||
});
|
||||
|
||||
it("supersedes pending credentials for the same run without touching another staged attempt", () => {
|
||||
const f = run();
|
||||
const a = f.issue("a"), b = f.issue("b"), replacement = f.issue("a");
|
||||
expect(f.grants.standing(a.grantId)).toBe("revoked");
|
||||
expect(f.grants.standing(b.grantId)).toBe("pending");
|
||||
expect(f.grants.standing(replacement.grantId)).toBe("pending");
|
||||
});
|
||||
|
||||
it("does not let an older pending dispatch replace a newer active run", () => {
|
||||
const f = run();
|
||||
const older = f.issue("older"), newer = f.issue("newer");
|
||||
expect(f.grants.activate(newer.grantId)).toBe(true);
|
||||
expect(f.grants.activate(older.grantId)).toBe(false);
|
||||
expect(f.grants.standing(newer.grantId)).toBe("current");
|
||||
});
|
||||
|
||||
it("activates a staged runner only at the Core session and binding cutover", () => {
|
||||
const f = run();
|
||||
const first = f.issue("first");
|
||||
f.grants.activate(first.grantId);
|
||||
const generation = f.sessions.reserveOccupantGeneration()!;
|
||||
const staged = f.grants.issue({ nodeId: f.node.id, generation, issuedBy: "launch", run: {
|
||||
runId: "staged", binding: { ...f.anchor, pane: "%2", panePid: 202 } } });
|
||||
expect(f.grants.activate(staged.grantId)).toBe(false);
|
||||
expect(f.grants.activeForNode(f.node.id)?.grantId).toBe(first.grantId);
|
||||
// The reconciler can have sampled the predecessor while the successor was staging.
|
||||
new SeatIdentityStore(f.db).upsert({ nodeId: f.node.id, verdict: "unconfirmed", evidenceSource: "pane_process",
|
||||
reason: null, sessionName: "dev-guest@guest-rig", observedAt: "2999-01-01T00:00:00.000Z",
|
||||
evidence: { registeredPane: "%1", observedPid: 101, observedPanePid: 101, observedCommand: "node", matchedLayer: null } });
|
||||
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.node.id);
|
||||
const successor = f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "handover", generation);
|
||||
expect(f.grants.standing(staged.grantId)).toBe("current");
|
||||
expect(f.grants.get(staged.grantId)?.sessionId).toBe(successor.id);
|
||||
expect(f.grants.standing(first.grantId)).toBe("revoked");
|
||||
});
|
||||
|
||||
it("preserves explicit same-pane adoption only with retained concrete process evidence", () => {
|
||||
const f = run();
|
||||
const first = f.issue("first");
|
||||
f.grants.activate(first.grantId);
|
||||
new SeatIdentityStore(f.db).upsert({ nodeId: f.node.id, verdict: "unconfirmed", evidenceSource: "pane_process",
|
||||
reason: null, sessionName: "dev-guest@guest-rig", observedAt: new Date().toISOString(),
|
||||
evidence: { registeredPane: "%1", observedPid: 101, observedPanePid: 101, observedCommand: "node", matchedLayer: null } });
|
||||
f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "adopt");
|
||||
expect(f.grants.standing(first.grantId)).toBe("current");
|
||||
f.db.prepare("UPDATE seat_identity_verdicts SET evidence_pane_pid = 999 WHERE node_id = ?").run(f.node.id);
|
||||
expect(f.grants.standing(first.grantId)).toBe("stale");
|
||||
});
|
||||
|
||||
it("revokes an adopted run on current-session stop after its concrete anchors are lost", () => {
|
||||
const f = run(); const first = f.issue("first"); f.grants.activate(first.grantId);
|
||||
new SeatIdentityStore(f.db).upsert({ nodeId: f.node.id, verdict: "unconfirmed", evidenceSource: "pane_process", reason: null,
|
||||
sessionName: "dev-guest@guest-rig", observedAt: new Date().toISOString(), evidence: { registeredPane: "%1", observedPid: 101,
|
||||
observedPanePid: 101, observedCommand: "node", matchedLayer: null } });
|
||||
const adopted = f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "adopt");
|
||||
expect(f.grants.get(first.grantId)?.sessionId).toBe(f.session.id); // immutable origin evidence
|
||||
f.db.prepare("DELETE FROM seat_identity_verdicts WHERE node_id = ?").run(f.node.id);
|
||||
f.sessions.markIntentionallyStopped(adopted.id);
|
||||
expect(f.grants.standing(first.grantId)).toBe("revoked");
|
||||
});
|
||||
|
||||
|
||||
it("preserves an active grant through unreadable binding evidence and recovery", () => {
|
||||
const f = run(), grant = f.issue("current"); f.grants.activate(grant.grantId);
|
||||
const prepare = f.db.prepare.bind(f.db);
|
||||
let bindingReads = 0;
|
||||
const spy = vi.spyOn(f.db, "prepare").mockImplementation(((sql: string) => {
|
||||
if (sql.includes("SELECT s.id, s.session_name") && ++bindingReads > 1) throw new Error("observation read unavailable");
|
||||
return prepare(sql);
|
||||
}) as typeof f.db.prepare);
|
||||
try {
|
||||
f.grants.reconcileBinding(f.node.id);
|
||||
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
|
||||
expect(f.grants.standing(grant.grantId)).toBe("unverified"); // refusal is not revocation
|
||||
} finally { spy.mockRestore(); }
|
||||
expect(f.grants.standing(grant.grantId)).toBe("current");
|
||||
});
|
||||
|
||||
it("does not fail claimed registration because optional grant reconciliation cannot read", () => {
|
||||
const f = run();
|
||||
const spy = vi.spyOn(ActivityGrantStore.prototype, "reconcileBinding").mockImplementation(() => { throw new Error("grant table unavailable"); });
|
||||
try {
|
||||
const claimed = f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig");
|
||||
expect(claimed.status).toBe("running");
|
||||
expect(f.sessions.currentOccupantTenure(f.node.id)).toBeTruthy();
|
||||
} finally { spy.mockRestore(); }
|
||||
});
|
||||
|
||||
|
||||
it("does not activate an older pending grant when the newer active binding cannot be read", () => {
|
||||
const f = run(), older = f.issue("older"), newer = f.issue("newer"); f.grants.activate(newer.grantId);
|
||||
const prepare = f.db.prepare.bind(f.db); let bindingReads = 0;
|
||||
const spy = vi.spyOn(f.db, "prepare").mockImplementation(((sql: string) => {
|
||||
if (sql.includes("SELECT s.id, s.session_name") && ++bindingReads === 2) throw new Error("newer binding unreadable");
|
||||
return prepare(sql);
|
||||
}) as typeof f.db.prepare);
|
||||
try { expect(f.grants.activate(older.grantId)).toBe(false); }
|
||||
finally { spy.mockRestore(); }
|
||||
expect(f.grants.standing(newer.grantId)).toBe("current");
|
||||
expect(f.grants.standing(older.grantId)).toBe("pending");
|
||||
});
|
||||
|
||||
it.each(["missing", "stale"])("preserves adopted authority when identity evidence is %s, then recovers", missing => {
|
||||
const f = run(), grant = f.issue("adopted"); f.grants.activate(grant.grantId);
|
||||
const evidence = { nodeId: f.node.id, verdict: "unconfirmed" as const, evidenceSource: "pane_process" as const,
|
||||
reason: null, sessionName: "dev-guest@guest-rig", observedAt: new Date().toISOString(),
|
||||
evidence: { registeredPane: "%1", observedPid: 101, observedPanePid: 101, observedCommand: "node", matchedLayer: null } };
|
||||
new SeatIdentityStore(f.db).upsert(evidence);
|
||||
f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "adopt");
|
||||
f.db.prepare("DELETE FROM seat_identity_verdicts WHERE node_id = ?").run(f.node.id);
|
||||
if (missing === "stale") new SeatIdentityStore(f.db).upsert({ ...evidence, sessionName: "prior-session" });
|
||||
f.grants.reconcileBinding(f.node.id);
|
||||
f.grants.revokeForSession(f.session.id, "old_session_stopped");
|
||||
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
|
||||
expect(f.grants.standing(grant.grantId)).toBe("unverified");
|
||||
new SeatIdentityStore(f.db).upsert(evidence);
|
||||
expect(f.grants.standing(grant.grantId)).toBe("current");
|
||||
});
|
||||
|
||||
|
||||
it.each(["anchor", "pid", "generation"] as const)("does not issue weaker authority when %s is missing", async missing => {
|
||||
const f = run();
|
||||
if (missing === "generation") f.db.prepare("DELETE FROM occupant_tenures WHERE node_id = ?").run(f.node.id);
|
||||
const setSessionEnv = vi.fn(async () => {});
|
||||
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => missing === "pid" ? null : 101,
|
||||
setSessionEnv, unsetSessionEnv: async () => {} });
|
||||
await expect(begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: missing === "anchor" ? null : "%1" } as never,
|
||||
"synth", "unbound", 1)).rejects.toThrow(/authority needs/);
|
||||
expect(setSessionEnv).not.toHaveBeenCalled();
|
||||
expect(f.db.prepare("SELECT count(*) AS n FROM activity_grants").get()).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it.each(["read", "partial read", "remove"] as const)("executes unbound with the original install token when private credential %s fails", async failure => {
|
||||
const f = run(), dir = mkdtempSync(join(tmpdir(), "grant-fallback-"));
|
||||
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
|
||||
writeFileSync(join(dir, "tmux"), '#!/bin/sh\ncase "$1" in show-environment) [ "$TEST_FAIL" = read ] && exit 1; printf "%s=%s\\n" "$TEST_KEY" "$TEST_PRIVATE"; [ "$TEST_FAIL" != "partial read" ];; set-environment) [ "$TEST_FAIL" != remove ];; *) exit 1;; esac\n');
|
||||
chmodSync(join(dir, "tmux"), 0o700);
|
||||
let token = "", key = "";
|
||||
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => 101,
|
||||
setSessionEnv: async (_session, name, value) => { key = name; token = value; }, unsetSessionEnv: async () => {} });
|
||||
const launch = await begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never, "synth", "not-claimed", 1);
|
||||
const program = `printf '%s' "$OPENRIG_ACTIVITY_HOOK_TOKEN|\${OPENRIG_RUN_ID-unset}|$OPENRIG_OCCUPANT_GENERATION"`;
|
||||
const result = spawnSync("/bin/sh", ["-xc", launch.wrap(`/bin/sh -c ${shellQuote(program)}`)], { encoding: "utf8", env: {
|
||||
...process.env, PATH: `${dir}:${process.env.PATH}`, OPENRIG_ACTIVITY_HOOK_TOKEN: "legacy-install", OPENRIG_RUN_ID: "stale-run",
|
||||
OPENRIG_OCCUPANT_GENERATION: "legacy-generation", TEST_FAIL: failure, TEST_KEY: key, TEST_PRIVATE: token,
|
||||
} });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toBe("legacy-install|unset|legacy-generation");
|
||||
expect(result.stderr).not.toContain(token);
|
||||
expect(result.stderr).toContain("using existing install-token reporting");
|
||||
await launch.finish(false);
|
||||
});
|
||||
|
||||
it.each([false, true])("preserves an adopted grant when declaration is unavailable, without hiding positive replacement=%s", replaced => {
|
||||
const f = run(), grant = f.issue("adopted"); f.grants.activate(grant.grantId);
|
||||
f.db.prepare("DELETE FROM seat_identity_verdicts WHERE node_id = ?").run(f.node.id);
|
||||
f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "adopt");
|
||||
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
|
||||
expect(f.grants.standing(grant.grantId)).toBe("unverified");
|
||||
if (replaced) {
|
||||
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.node.id);
|
||||
f.grants.reconcileBinding(f.node.id);
|
||||
expect(f.grants.standing(grant.grantId)).toBe("revoked");
|
||||
return;
|
||||
}
|
||||
new SeatIdentityStore(f.db).upsert({ nodeId: f.node.id, verdict: "unconfirmed", evidenceSource: "pane_process",
|
||||
reason: null, sessionName: "dev-guest@guest-rig", observedAt: new Date().toISOString(),
|
||||
evidence: { registeredPane: "%1", observedPid: 101, observedPanePid: 101, observedCommand: "node", matchedLayer: null } });
|
||||
f.grants.reconcileBinding(f.node.id);
|
||||
expect(f.grants.standing(grant.grantId)).toBe("current");
|
||||
});
|
||||
|
||||
it("preserves authority through an unavailable occupant generation and recovery", () => {
|
||||
const f = run(), grant = f.issue("current"); f.grants.activate(grant.grantId);
|
||||
const spy = vi.spyOn(SessionRegistry.prototype, "currentOccupantTenure").mockReturnValue(null);
|
||||
try {
|
||||
f.grants.reconcileBinding(f.node.id);
|
||||
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
|
||||
expect(f.grants.standing(grant.grantId)).toBe("unverified");
|
||||
} finally { spy.mockRestore(); }
|
||||
expect(f.grants.standing(grant.grantId)).toBe("current");
|
||||
});
|
||||
|
||||
it("records a Core-selected runner declaration on a previously registered empty launch row", async () => {
|
||||
const f = run();
|
||||
f.db.prepare("UPDATE sessions SET agent_app = NULL, agent_runner = NULL WHERE id = ?").run(f.session.id);
|
||||
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => 101, setSessionEnv: async () => {}, unsetSessionEnv: async () => {} });
|
||||
const launch = await begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never, "synth", "registered-first", 1);
|
||||
expect(() => launch.dispatched()).not.toThrow();
|
||||
expect(f.grants.standing(launch.grantId)).toBe("current");
|
||||
expect(f.db.prepare("SELECT agent_app, agent_runner FROM sessions WHERE id = ?").get(f.session.id)).toEqual({ agent_app: "synth", agent_runner: 1 });
|
||||
await launch.finish(true);
|
||||
});
|
||||
|
||||
it("activates an earlier staged grant after a later predecessor rotation becomes stale at cutover", () => {
|
||||
const f = run(); const first = f.issue("first"); f.grants.activate(first.grantId);
|
||||
const generation = f.sessions.reserveOccupantGeneration()!;
|
||||
const staged = f.grants.issue({ nodeId: f.node.id, generation, issuedBy: "launch", run: { runId: "staged",
|
||||
binding: { ...f.anchor, pane: "%2", panePid: 202 } } });
|
||||
const rotation = f.issue("predecessor-rotation");
|
||||
expect(f.grants.activate(rotation.grantId)).toBe(true);
|
||||
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.node.id);
|
||||
f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "handover", generation);
|
||||
expect(f.grants.standing(staged.grantId)).toBe("current");
|
||||
expect(f.grants.standing(rotation.grantId)).toBe("revoked");
|
||||
});
|
||||
|
||||
it("revokes the current run on an explicit stop or observed exit", () => {
|
||||
const f = run();
|
||||
const first = f.issue("first");
|
||||
f.grants.activate(first.grantId);
|
||||
f.sessions.markIntentionallyStopped(f.session.id);
|
||||
expect(f.grants.standing(first.grantId)).toBe("revoked");
|
||||
});
|
||||
|
||||
it("distributes privately, and late failed cleanup cannot revoke the replacement", async () => {
|
||||
const f = run();
|
||||
const env = new Map<string, string>();
|
||||
const tmux = { getPaneCommand: async () => "bash", getPanePid: async () => 101,
|
||||
setSessionEnv: async (_session: string, key: string, value: string) => { env.set(key, value); },
|
||||
unsetSessionEnv: async (_session: string, key: string) => { env.delete(key); } };
|
||||
const begin = runnerGrantIssuer(f.db, tmux);
|
||||
const binding = { nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never;
|
||||
const first = await begin(binding, "synth", "first", 1);
|
||||
const token = [...env.values()][0]!;
|
||||
expect(first.wrap("node runner.js")).not.toContain(token);
|
||||
expect(first.wrap("node runner.js")).toContain("set +x");
|
||||
expect(first.wrap("node runner.js")).toContain("set-environment");
|
||||
first.dispatched();
|
||||
const second = await begin(binding, "synth", "second", 1);
|
||||
second.dispatched();
|
||||
await first.finish(false);
|
||||
expect(f.grants.activeForNode(f.node.id)?.runId).toBe("second");
|
||||
expect(env.size).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves the active predecessor when private credential distribution fails after its write", async () => {
|
||||
const f = run();
|
||||
const first = f.issue("first"); f.grants.activate(first.grantId);
|
||||
const env = new Map<string, string>();
|
||||
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => 101,
|
||||
setSessionEnv: async (_session: string, key: string, token: string) => { env.set(key, token); throw new Error("private transport unavailable"); },
|
||||
unsetSessionEnv: async (_session: string, key: string) => { env.delete(key); } });
|
||||
await expect(begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never,
|
||||
"synth", "failed", 1)).rejects.toThrow("credential distribution failed");
|
||||
expect(f.grants.standing(first.grantId)).toBe("current");
|
||||
expect(f.grants.activeForNode(f.node.id)?.runId).toBe("first");
|
||||
expect(env.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the existing staged /bin/sh launch path for non-POSIX parent shells", async () => {
|
||||
const f = run();
|
||||
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => 101, setSessionEnv: async () => {}, unsetSessionEnv: async () => {} });
|
||||
const launch = await begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never,
|
||||
"synth", "shell-independent-bootstrap", 1);
|
||||
expect(launch.wrap("node runner.js")).toMatch(/^\/bin\/sh -c /);
|
||||
await launch.finish(false);
|
||||
});
|
||||
|
||||
it("consumes the private tmux environment before exec without token text in argv or tracing", async () => {
|
||||
const f = run();
|
||||
const dir = mkdtempSync(join(tmpdir(), "run-grant-transfer-"));
|
||||
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const marker = join(dir, "consumed");
|
||||
writeFileSync(join(dir, "tmux"), '#!/bin/sh\ncase "$1" in show-environment) printf "%s=%s\\n" "$TEST_KEY" "$TEST_PRIVATE";; set-environment) touch "$TEST_REMOVED";; *) exit 1;; esac\n');
|
||||
chmodSync(join(dir, "tmux"), 0o700);
|
||||
let token = "", key = "";
|
||||
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => 101,
|
||||
setSessionEnv: async (_session, name, value) => { key = name; token = value; }, unsetSessionEnv: async () => {} });
|
||||
const launch = await begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never, "synth", "opaque-run", 1);
|
||||
const program = `const t=process.env.OPENRIG_ACTIVITY_HOOK_TOKEN; process.stdout.write(JSON.stringify({bound:t===process.env.TEST_PRIVATE,run:process.env.OPENRIG_RUN_ID,generation:process.env.OPENRIG_OCCUPANT_GENERATION,argvLeak:process.argv.some(a=>a.includes(t))}));`;
|
||||
const command = launch.wrap(`${shellQuote(process.execPath)} -e ${shellQuote(program)}`);
|
||||
expect(command).not.toContain(token);
|
||||
const result = spawnSync("/bin/sh", ["-xc", command], { encoding: "utf8", env: { ...process.env, PATH: `${dir}:${process.env.PATH}`,
|
||||
OPENRIG_ACTIVITY_HOOK_TOKEN: "legacy-install", OPENRIG_OCCUPANT_GENERATION: "stale-shell", TEST_KEY: key, TEST_PRIVATE: token, TEST_REMOVED: marker } });
|
||||
expect(result.status, result.error?.message ?? result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ bound: true, run: "opaque-run", generation: f.generation, argvLeak: false });
|
||||
expect(result.stderr).not.toContain(token);
|
||||
expect(existsSync(marker)).toBe(true);
|
||||
await launch.finish(false);
|
||||
});
|
||||
|
||||
it("refuses activation and standing after concrete pane or recorded runtime changes", () => {
|
||||
const f = run();
|
||||
const first = f.issue("first");
|
||||
expect(f.grants.activate(first.grantId)).toBe(true);
|
||||
const staged = f.issue("staged");
|
||||
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.node.id);
|
||||
expect(f.grants.standing(first.grantId)).toBe("stale");
|
||||
expect(f.grants.activate(staged.grantId)).toBe(false);
|
||||
f.db.prepare("UPDATE bindings SET tmux_pane = '%1' WHERE node_id = ?").run(f.node.id);
|
||||
f.db.prepare("UPDATE sessions SET agent_runner = 0 WHERE id = ?").run(f.session.id);
|
||||
expect(f.grants.standing(first.grantId)).toBe("stale");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the activity values a guest receives", () => {
|
||||
const grant = { token: "oag_guest", nodeId: "node-1", generation: "gen-1" };
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { communityHarnessLabel, loadCommunityHarnessPreset } from "../src/domain/community-harness-presets.js";
|
||||
import type { RunnerGrantIssuer } from "../src/domain/runner-grant-launch.js";
|
||||
import { CommunityHarnessRuntimeAdapter } from "../src/adapters/community-harness-runtime-adapter.js";
|
||||
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
|
||||
|
||||
@@ -15,7 +16,7 @@ const PRESET = "format: 1\nname: synth\ncommand: synth-launch --serve\nmaintaine
|
||||
const binding = { tmuxSession: "w@r" } as NodeBinding;
|
||||
|
||||
/** A seat whose launcher writes `record(attempt)` to the file this launch names (or nothing, for undefined). */
|
||||
function seat(record?: (attempt: string, file: string) => unknown, preset = PRESET) {
|
||||
function seat(record?: (attempt: string, file: string) => unknown, preset = PRESET, runnerGrants?: RunnerGrantIssuer) {
|
||||
const presets = scratch("harness-presets-");
|
||||
writeFileSync(join(presets, "synth.yaml"), preset);
|
||||
const root = scratch("harness-results-");
|
||||
@@ -27,7 +28,7 @@ function seat(record?: (attempt: string, file: string) => unknown, preset = PRES
|
||||
return { ok: true as const };
|
||||
});
|
||||
const app = new CommunityHarnessRuntimeAdapter("synth", { tmux: { hasSession: vi.fn(async () => true), getPaneCommand: vi.fn(async () => "node"), sendShellCommand },
|
||||
presetsDir: () => presets, launchResultRoot: () => root, launchResultWait: { timeoutMs: 20, intervalMs: 10 } });
|
||||
runnerGrants, presetsDir: () => presets, launchResultRoot: () => root, launchResultWait: { timeoutMs: 20, intervalMs: 10 } });
|
||||
return { app, sendShellCommand, presets, root };
|
||||
}
|
||||
const good = (attempt: string) => ({ format: 1, attempt, harness: "synth", nativeRef: "chat-123", producer: "synth-launch 1.0" });
|
||||
@@ -158,3 +159,69 @@ describe("a community harness whose launcher can reopen a reported conversation"
|
||||
expect(loadCommunityHarnessPreset("synth", presets)).toMatchObject({ ok: false, message: expect.stringContaining("resume needs launch_result: true or names_conversation: true") });
|
||||
});
|
||||
});
|
||||
|
||||
describe("community run authority", () => {
|
||||
it.each([true, false])("binds the launch-result attempt and finalizes exact readiness=%s", async ready => {
|
||||
let runId: string | undefined;
|
||||
const dispatched = vi.fn(), finish = vi.fn(async (_ok: boolean) => {});
|
||||
const runnerGrants: RunnerGrantIssuer = async (_binding, runtime, attempt, runner) => {
|
||||
expect([runtime, runner]).toEqual(["synth", 1]); runId = attempt;
|
||||
return { grantId: "test-grant", generation: "generation", wrap: command => command, dispatched, finish };
|
||||
};
|
||||
const f = seat(ready ? (attempt) => { expect(attempt).toBe(runId); return good(attempt); } : undefined,
|
||||
`${PRESET}runner: true\n`, runnerGrants);
|
||||
expect((await f.app.launchHarness(binding, { name: "w@r" })).ok).toBe(ready);
|
||||
expect(runId).toBeTruthy();
|
||||
expect(dispatched).toHaveBeenCalledOnce();
|
||||
expect(finish).toHaveBeenCalledWith(ready);
|
||||
});
|
||||
|
||||
it("allocates and revokes an attempt when the launch-result folder is unavailable", async () => {
|
||||
const finish = vi.fn(async (_ok: boolean) => {});
|
||||
const issue = vi.fn(async (_binding, _runtime, _runId, _runner) => ({ grantId: "test-grant", generation: "generation", wrap: (command: string) => command, dispatched: () => {}, finish }));
|
||||
const f = seat(undefined, `${PRESET}runner: true\n`, issue);
|
||||
rmSync(f.root, { recursive: true });
|
||||
writeFileSync(f.root, "blocked");
|
||||
expect((await f.app.launchHarness(binding, { name: "w@r" })).ok).toBe(false);
|
||||
expect(issue.mock.calls[0]?.[2]).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(finish).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("finalizes its exact community grant when post-dispatch standing refuses", async () => {
|
||||
const finish = vi.fn(async (_ok: boolean) => {});
|
||||
const f = seat(good, `${PRESET}runner: true\n`, async () => ({ grantId: "refused", generation: "generation", wrap: command => command,
|
||||
dispatched: () => { throw new Error("binding changed"); }, finish }));
|
||||
expect(await f.app.launchHarness(binding, { name: "w@r" })).toMatchObject({ ok: true, resumeToken: "chat-123" });
|
||||
expect(f.sendShellCommand).toHaveBeenCalledOnce();
|
||||
expect(finish).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
|
||||
describe("community reporting grants are optional to native launch", () => {
|
||||
it("retries only its original command when the optional grant wrapper cannot be staged", async () => {
|
||||
const finish = vi.fn(async () => {}), dispatched = vi.fn();
|
||||
const issue: RunnerGrantIssuer = async () => ({ grantId: "own", generation: "g", wrap: command => `private-wrapper ${command}`, dispatched, finish });
|
||||
const f = seat(good, `${PRESET}runner: true\n`, issue);
|
||||
f.sendShellCommand.mockImplementationOnce(async () => ({ ok: false, code: "launch_path_too_long", message: "not dispatched" }) as never);
|
||||
expect(await f.app.launchHarness(binding, { name: "w@r" })).toMatchObject({ ok: true, resumeToken: "chat-123" });
|
||||
expect(f.sendShellCommand).toHaveBeenCalledTimes(2);
|
||||
expect(f.sendShellCommand.mock.calls[0]![1]).toBe(`private-wrapper ${f.sendShellCommand.mock.calls[1]![1]}`);
|
||||
expect(dispatched).not.toHaveBeenCalled();
|
||||
expect(finish).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it.each(["setup", "activation"] as const)("preserves one dispatched native launch when grant %s fails", async failure => {
|
||||
const finish = vi.fn(async () => {});
|
||||
const issue: RunnerGrantIssuer = async () => {
|
||||
if (failure === "setup") throw new Error("credential environment unavailable");
|
||||
return { grantId: "affected", generation: "g", wrap: command => command,
|
||||
dispatched: () => { throw new Error("activation failed after send"); }, finish };
|
||||
};
|
||||
const f = seat(good, `${PRESET}runner: true\n`, issue);
|
||||
expect(await f.app.launchHarness(binding, { name: "w@r" })).toMatchObject({ ok: true, resumeToken: "chat-123" });
|
||||
expect(f.sendShellCommand).toHaveBeenCalledOnce();
|
||||
expect(f.sendShellCommand.mock.calls[0]![1]).not.toContain("OPENRIG_RUN_ID=");
|
||||
if (failure === "activation") expect(finish).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -659,7 +659,7 @@ describe("context-usage-threshold watchdog on a name two seat generations share"
|
||||
const engine = new WatchdogPolicyEngine({ jobsRepo: repo, historyLog: history, eventBus: new EventBus(db),
|
||||
deliver: async (request) => { deliveries.push(request); return { status: "ok" as const }; },
|
||||
resolveTargetGeneration: (s) => resolveGuardTarget(db, s)?.occupant ?? null,
|
||||
targetNameCarried: (s) => sessions.currentOccupantGenerationForSession(s) !== null });
|
||||
targetNameCarried: (s) => sessions.carriesSessionName(s) });
|
||||
const job = repo.register({ policy: "context-usage-threshold",
|
||||
specYaml: "policy: context-usage-threshold\ngenerated_by: continuity-policy-materializer\ncontinuity_mode: managed-compaction\n"
|
||||
+ "target:\n session: dev-impl@seat-rig\nmessage: Context threshold crossed.\n",
|
||||
@@ -689,6 +689,25 @@ describe("context-usage-threshold watchdog on a name two seat generations share"
|
||||
lastFiredGeneration: "gen-current" });
|
||||
});
|
||||
|
||||
it.each(["session", "tmux binding", "external binding"])("keeps a watch with missing tenure while a %s carries its name", async (carrier) => {
|
||||
seat("rig-current", "node-current", "gen-current", join(tmp, "current.jsonl"));
|
||||
db.prepare("DELETE FROM occupant_tenures WHERE node_id = 'node-current'").run();
|
||||
if (carrier !== "session") {
|
||||
new SessionRegistry(db).updateBinding("node-current", carrier === "tmux binding"
|
||||
? { tmuxSession: "dev-impl@seat-rig", tmuxPane: "%1" }
|
||||
: { attachmentType: "external_cli", externalSessionName: "dev-impl@seat-rig" });
|
||||
db.prepare("DELETE FROM sessions WHERE node_id = 'node-current'").run();
|
||||
}
|
||||
const { repo, history, deliveries, engine, job } = watch();
|
||||
expect(new SessionRegistry(db).currentOccupantGenerationForSession("dev-impl@seat-rig")).toBeNull();
|
||||
|
||||
const unresolved = await engine.evaluate(repo.getByIdOrThrow(job.jobId));
|
||||
expect(unresolved.outcome).toMatchObject({ action: "skip", reason: "occupant_generation_unresolved" });
|
||||
expect(repo.getByIdOrThrow(job.jobId)).toMatchObject({ state: "active", watchedFilePath: null });
|
||||
expect(history.listForJob(job.jobId)[0]).toMatchObject({ outcome: "skipped", skipReason: "occupant_generation_unresolved" });
|
||||
expect(deliveries).toEqual([]);
|
||||
});
|
||||
|
||||
it("ends the watch of a seat that's gone: its rig removed, nothing carries the name", async () => {
|
||||
seat("rig-current", "node-current", "gen-current", join(tmp, "current.jsonl"));
|
||||
const { repo, deliveries, engine, job } = watch();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runBoundActivityGrantsSchema } from "../../src/db/migrations/165_run_bound_activity_grants.js";
|
||||
import { piAccountSwitchesSchema } from "../../src/db/migrations/151_pi_account_switches.js";
|
||||
import { eventsRetentionMarksSchema } from "../../src/db/migrations/153_events_retention_marks.js";
|
||||
import { queueErasureSchema } from "../../src/db/migrations/155_queue_erasure.js";
|
||||
@@ -171,7 +172,7 @@ import fs from "node:fs";
|
||||
|
||||
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
|
||||
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema, sessionAddressReadIndexesSchema, networkInstallationsSchema, queueItemsReplyToIndexSchema];
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema, sessionAddressReadIndexesSchema, networkInstallationsSchema, queueItemsReplyToIndexSchema, runBoundActivityGrantsSchema];
|
||||
|
||||
/**
|
||||
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { seedHostShaped } from "./helpers/seed-host-shaped.js";
|
||||
import { buildStallApp } from "./fixtures/ps-stall-http-child.js";
|
||||
import { configureSyncSiteRecorder, runSyncSiteWhenSlow } from "../src/domain/sync-site-wrap.js";
|
||||
|
||||
// The node inventory's synchronous steps named in the slow-operation log, so a stall during a nodes read is attributed
|
||||
// to the step that ran. A fake recorder takes every call; the real recorder's slow threshold is pinned in
|
||||
// slow-op-recorder.test.ts.
|
||||
describe("the node inventory's synchronous steps in the slow-operation log", () => {
|
||||
const slow: Array<{ site: string; durationMs: number; outcome: string; at: number }> = [];
|
||||
/** Takes every record; the first one then holds the event loop for `holdMs`, after its own time was measured. */
|
||||
const record = (holdMs = 0) => configureSyncSiteRecorder({
|
||||
recordSlowSync(site: string, durationMs: number, outcome: "ok" | "failed"): void {
|
||||
slow.push({ site, durationMs, outcome, at: performance.now() });
|
||||
if (slow.length === 1 && holdMs > 0) { const until = performance.now() + holdMs; while (performance.now() < until) { /* hold */ } }
|
||||
},
|
||||
});
|
||||
afterEach(() => { configureSyncSiteRecorder(undefined); slow.length = 0; });
|
||||
|
||||
function fixture() {
|
||||
const db = createFullTestDb();
|
||||
const shape = seedHostShaped(db);
|
||||
return { db, app: buildStallApp(db, true), rig: shape.activeRigIds[0]! };
|
||||
}
|
||||
const ids = (nodes: Array<{ logicalId: string }>) => nodes.map((n) => n.logicalId);
|
||||
|
||||
it("names each default step once, in order, and serves the same nodes", async () => {
|
||||
const { db, app, rig } = fixture();
|
||||
try {
|
||||
const bare = await app.request(`/api/rigs/${rig}/nodes`);
|
||||
expect(bare.status).toBe(200);
|
||||
const bareNodes = await bare.json() as Array<{ logicalId: string }>;
|
||||
expect(bareNodes.length).toBeGreaterThan(0);
|
||||
|
||||
record();
|
||||
const res = await app.request(`/api/rigs/${rig}/nodes`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(ids(await res.json() as Array<{ logicalId: string }>)).toEqual(ids(bareNodes));
|
||||
expect(slow.map(({ site, outcome }) => ({ site, outcome }))).toEqual([
|
||||
{ site: "nodes.inventory.build", outcome: "ok" },
|
||||
{ site: "nodes.inventory.activity_sync", outcome: "ok" },
|
||||
{ site: "nodes.inventory.terminal_work", outcome: "ok" },
|
||||
]);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a full read's activity step unnamed: it awaits pane captures outside the turns", async () => {
|
||||
const { db, app, rig } = fixture();
|
||||
try {
|
||||
record();
|
||||
const res = await app.request(`/api/rigs/${rig}/nodes?full=true`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(slow.map((s) => s.site)).toEqual(["nodes.inventory.build", "nodes.inventory.terminal_work"]);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("measures a step from its turn, so the wait behind another request's step isn't counted", async () => {
|
||||
const HOLD_MS = 400;
|
||||
const { db, app, rig } = fixture();
|
||||
try {
|
||||
record(HOLD_MS);
|
||||
const [a, b] = await Promise.all([app.request(`/api/rigs/${rig}/nodes`), app.request(`/api/rigs/${rig}/nodes`)]);
|
||||
expect([a.status, b.status]).toEqual([200, 200]);
|
||||
const builds = slow.filter((s) => s.site === "nodes.inventory.build");
|
||||
expect(builds).toHaveLength(2);
|
||||
// The second build began only after the first step's hold ended, and its own time doesn't include that wait.
|
||||
expect(builds[1]!.at - builds[1]!.durationMs).toBeGreaterThanOrEqual(builds[0]!.at + HOLD_MS - 1);
|
||||
for (const s of slow) expect(s.durationMs).toBeLessThan(HOLD_MS);
|
||||
expect(slow).toHaveLength(6);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("counts a returned promise's synchronous start, never its settling, and keeps its rejection", async () => {
|
||||
record();
|
||||
const failure = new Error("activity failed");
|
||||
const settled = runSyncSiteWhenSlow("test.async_start", () => new Promise((_, reject) => setTimeout(() => reject(failure), 300)));
|
||||
expect(slow.map(({ site, outcome }) => ({ site, outcome }))).toEqual([{ site: "test.async_start", outcome: "ok" }]);
|
||||
expect(slow[0]!.durationMs).toBeLessThan(300);
|
||||
await expect(settled).rejects.toBe(failure);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,8 @@ function seat(runtime: string, pane: Pane) {
|
||||
registry.updateBinding(node.id, { tmuxSession: name, tmuxPane: paneId });
|
||||
const tmux = {
|
||||
listSessions: vi.fn(async () => [{ name }] as never),
|
||||
listPanes: vi.fn(async () => [{ id: paneId }] as never),
|
||||
// A live session still has another pane after the registered pane is gone.
|
||||
listPanes: vi.fn(async () => [{ id: pane.pid === null ? "%8" : paneId }] as never),
|
||||
getPanePid: vi.fn(async () => pane.pid),
|
||||
getPaneCommand: vi.fn(async () => pane.command),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { piSeatPaths, PI_RUNNER_READY_MARKER, PI_RUNNER_ERROR_MARKER } from "../src/adapters/pi-runner-protocol.js";
|
||||
|
||||
@@ -78,10 +79,24 @@ if(cmd.type==='prompt') { console.error('Unknown option: --name'); console.error
|
||||
type: "api_key", key: credential === "dynamic" ? "!private-command" : "private-value",
|
||||
} }));
|
||||
}
|
||||
const reports: Array<{ body: Record<string, unknown>; token: string | undefined }> = [];
|
||||
const endpoint = createServer((req, res) => {
|
||||
let body = ""; req.on("data", chunk => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
if (req.method === "POST") reports.push({ body: JSON.parse(body), token: req.headers.authorization });
|
||||
res.statusCode = credential === "present" ? 409 : 200;
|
||||
res.end(JSON.stringify({ tokenPersisted: credential !== "present" }));
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => endpoint.listen(0, "127.0.0.1", resolve));
|
||||
const port = (endpoint.address() as { port: number }).port;
|
||||
const token = credential === "absent" ? "fixture-install-token" : "oag_fixture-bound";
|
||||
const envRunId = credential === "dynamic" ? "owned-attempt" : "inherited-predecessor";
|
||||
const child = spawn(process.execPath, ["--import", "tsx", fileURLToPath(new URL("../src/adapters/pi-runner.ts", import.meta.url)),
|
||||
"--session-name", "fixture", "--state-root", stateRoot, "--cwd", temp,
|
||||
"--launch-id", "owned-attempt", "--no-approve", "--model", "openrouter/fixture"], {
|
||||
env: { PATH: bin + path.delimiter + path.dirname(process.execPath), HOME: temp, OPENRIG_HOME: path.join(temp, "home") },
|
||||
env: { PATH: bin + path.delimiter + path.dirname(process.execPath), HOME: temp, OPENRIG_HOME: path.join(temp, "home"),
|
||||
OPENRIG_URL: `http://127.0.0.1:${port}`, OPENRIG_ACTIVITY_HOOK_TOKEN: token, OPENRIG_RUN_ID: envRunId },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let output = "";
|
||||
@@ -104,6 +119,15 @@ if(cmd.type==='prompt') { console.error('Unknown option: --name'); console.error
|
||||
expect(state.launchId).toBe("owned-attempt");
|
||||
if (success) {
|
||||
expect(state.sessionFile).toBe(sessionFile);
|
||||
const reportDeadline = Date.now() + 5000;
|
||||
while (!reports.some(r => r.body.eventFamily === "session_identity") && Date.now() < reportDeadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
const identity = reports.find(r => r.body.eventFamily === "session_identity");
|
||||
expect(identity).toBeDefined();
|
||||
if (credential === "absent") expect(identity!.body).not.toHaveProperty("runId");
|
||||
else expect(identity!.body.runId).toBe(envRunId); // a refused bound claim is never rewritten or downgraded
|
||||
expect(reports.every(r => r.token === `Bearer ${token}`)).toBe(true);
|
||||
child.stdin.write("emit a runtime warning\n");
|
||||
const warningDeadline = Date.now() + 5000;
|
||||
while (!output.includes("AFTER_RUNNING_WARNING") && Date.now() < warningDeadline) {
|
||||
@@ -122,6 +146,7 @@ if(cmd.type==='prompt') { console.error('Unknown option: --name'); console.error
|
||||
}
|
||||
child.kill("SIGTERM");
|
||||
await new Promise<void>(resolve => { if (child.exitCode !== null || child.signalCode !== null) resolve(); else child.once("exit", () => resolve()); });
|
||||
await new Promise<void>(resolve => endpoint.close(() => resolve()));
|
||||
fs.rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
@@ -1372,3 +1372,24 @@ describe("cursor refresh via get_entries (QA RED, qitem-20260707020922)", () =>
|
||||
expect(f.sidecars.at(-1)!.lastEntryId).toBe("e5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed run identity retries", () => {
|
||||
it("retries a pending run identity until Core confirms persistence, then stops", async () => {
|
||||
const f = fakeIo();
|
||||
let persisted = false;
|
||||
f.io.postActivity = vi.fn(async (payload) => { f.activity.push(payload); return persisted ? { tokenPersisted: true } : null; });
|
||||
const core = new RunnerCore(f.io, { sessionName: SESSION, launchId: "actual-run", runId: "actual-run" });
|
||||
core.start();
|
||||
core.handlePiLine(JSON.stringify({ type: "response", id: "pi-runner-get-state", data: { sessionFile: SESSION_FILE, sessionId: "native" } }));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const count = () => f.activity.filter(p => p.eventFamily === "session_identity").length;
|
||||
const initial = count();
|
||||
persisted = true;
|
||||
core.retrySessionIdentity();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(count()).toBe(initial + 1);
|
||||
expect(f.activity.filter(p => p.eventFamily === "session_identity").at(-1)).toMatchObject({ runId: "actual-run" });
|
||||
core.retrySessionIdentity();
|
||||
expect(count()).toBe(initial + 1);
|
||||
});
|
||||
});
|
||||
@@ -1135,3 +1135,175 @@ it("stages a Pi account successor with canonical identity and a separate state d
|
||||
expect(await adapter.checkReady(binding)).toMatchObject({ ready: true });
|
||||
expect(fs.files[predecessorPath]).toBe(readyState(SESSION_FILE, "old-launch"));
|
||||
});
|
||||
|
||||
describe("run grant at the actual Pi dispatch", () => {
|
||||
it.each([true, false])("uses the actual attempt, activates at dispatch, and finalizes readiness=%s", async ready => {
|
||||
const fs = memFs();
|
||||
const statePath = piSeatPaths(STATE_ROOT, SESSION).runnerStatePath;
|
||||
const calls: string[] = [];
|
||||
const finish = vi.fn(async (ok: boolean) => { calls.push(`finish:${ok}`); });
|
||||
const runnerGrants = vi.fn(async (_binding, runtime, runId, runner) => {
|
||||
expect([runtime, runId, runner]).toEqual(["pi", "attempt-bound", 0]);
|
||||
calls.push("issued");
|
||||
return { grantId: "test-grant", generation: "generation", wrap: (command: string) => `private-wrapper ${command}`, dispatched: () => { calls.push("dispatched"); }, finish };
|
||||
});
|
||||
const sendShellCommand = vi.fn(async (_target: string, command: string) => {
|
||||
expect(command).toContain("private-wrapper");
|
||||
expect(command).toContain("--launch-id 'attempt-bound'");
|
||||
if (ready) fs.files[statePath] = readyState(SESSION_FILE, "attempt-bound");
|
||||
return { ok: true as const };
|
||||
});
|
||||
const adapter = new PiRuntimeAdapter({ tmux: mockTmux({ sendShellCommand }), fsOps: fs, stateRoot: STATE_ROOT,
|
||||
runnerEntryPath: RUNNER, runnerGrants, newLaunchId: () => "attempt-bound", sleep: async () => {} });
|
||||
const result = await adapter.launchHarness({ tmuxSession: SESSION, cwd: "/work" } as never, { name: SESSION });
|
||||
expect(result.ok).toBe(ready);
|
||||
expect(calls).toEqual(["issued", "dispatched", `finish:${ready}`]);
|
||||
expect(runnerGrants).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the run grant and its private transfer keys out of native Pi/tools", () => {
|
||||
const child = buildPiChildEnv({ PATH: "/bin", OPENRIG_ACTIVITY_HOOK_TOKEN: "oag_private",
|
||||
OPENRIG_RUN_ID: "actual-run", OPENRIG_RUN_GRANT_TEST: "oag_private", OPENRIG_NODE_ID: "seat" },
|
||||
{ agentDir: "/agent", sessionsDir: "/sessions" });
|
||||
expect(child.OPENRIG_NODE_ID).toBe("seat");
|
||||
expect(JSON.stringify(child)).not.toContain("oag_private");
|
||||
expect(child).not.toHaveProperty("OPENRIG_RUN_ID");
|
||||
});
|
||||
|
||||
describe("direct Pi resume run authority", () => {
|
||||
it("resumes unbound when Core cannot resolve the restored grant binding", async () => {
|
||||
const fs = memFs({ [SESSION_FILE]: "history" });
|
||||
const sendShellCommand = vi.fn(async () => {
|
||||
fs.files[piSeatPaths(STATE_ROOT, SESSION).runnerStatePath] = readyState(SESSION_FILE, "restored");
|
||||
return { ok: true as const };
|
||||
});
|
||||
const adapter = new PiResumeAdapter(mockTmux({ sendShellCommand }), fs,
|
||||
{ stateRoot: STATE_ROOT, runnerEntryPath: RUNNER }, { runnerGrant: async () => { throw new Error("no Core binding"); },
|
||||
newLaunchId: () => "restored", sleep: async () => {}, maxWaitMs: 1 });
|
||||
expect(await adapter.resume(SESSION, "pi_session_file", SESSION_FILE, "/work")).toMatchObject({ ok: true });
|
||||
expect(sendShellCommand).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([true, false])("uses its actual new attempt and finishes exact authority, ready=%s", async ready => {
|
||||
const fs = memFs({ [SESSION_FILE]: "history" });
|
||||
const statePath = piSeatPaths(STATE_ROOT, SESSION).runnerStatePath;
|
||||
const dispatched = vi.fn(), finish = vi.fn(async (_ok: boolean) => {});
|
||||
const runnerGrant = vi.fn(async (_session: string, _runId: string, _cwd: string) => ({ grantId: "resume-grant", generation: "generation",
|
||||
wrap: (command: string) => `private-resume ${command}`, dispatched, finish }));
|
||||
const sendShellCommand = vi.fn(async () => {
|
||||
if (ready) fs.files[statePath] = readyState(SESSION_FILE, "resume-attempt");
|
||||
return { ok: true as const };
|
||||
});
|
||||
const adapter = new PiResumeAdapter(mockTmux({ sendShellCommand }), fs, { stateRoot: STATE_ROOT, runnerEntryPath: RUNNER },
|
||||
{ runnerGrant, newLaunchId: () => "resume-attempt", maxWaitMs: 1, pollMs: 1, sleep: async () => {} });
|
||||
const result = await adapter.resume(SESSION, "pi_session_file", SESSION_FILE, "/work");
|
||||
expect(result.ok).toBe(ready);
|
||||
expect(runnerGrant).toHaveBeenCalledWith(SESSION, "resume-attempt", "/work");
|
||||
expect(sendShellCommand.mock.calls[0]?.[1]).toContain("private-resume");
|
||||
expect(dispatched).toHaveBeenCalledOnce();
|
||||
expect(finish).toHaveBeenCalledWith(ready);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
describe("optional run authority preserves existing native dispatch", () => {
|
||||
it.each(["launch", "resume"] as const)("%s continues unbound when grant setup fails", async owner => {
|
||||
const fs = memFs({ [SESSION_FILE]: "history" });
|
||||
const sendShellCommand = vi.fn(async (_target: string, command: string) => {
|
||||
fs.files[piSeatPaths(STATE_ROOT, SESSION).runnerStatePath] = readyState(SESSION_FILE, "maintainer-attempt");
|
||||
return { ok: true as const };
|
||||
});
|
||||
const issue = async () => { throw new Error("missing anchor, PID, generation or restore binding"); };
|
||||
const tmux = mockTmux({ sendShellCommand });
|
||||
const result = owner === "launch"
|
||||
? await new PiRuntimeAdapter({ tmux, fsOps: fs, stateRoot: STATE_ROOT, runnerEntryPath: RUNNER, runnerGrants: issue, newLaunchId: () => "maintainer-attempt", sleep: async () => {} }).launchHarness({ tmuxSession: SESSION, cwd: "/work" } as never, { name: SESSION })
|
||||
: await new PiResumeAdapter(tmux, fs, { stateRoot: STATE_ROOT, runnerEntryPath: RUNNER }, { runnerGrant: issue, newLaunchId: () => "maintainer-attempt", sleep: async () => {}, maxWaitMs: 1, pollMs: 1 }).resume(SESSION, "pi_session_file", SESSION_FILE, "/work");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(sendShellCommand).toHaveBeenCalledOnce();
|
||||
expect(sendShellCommand.mock.calls[0]![1]).not.toContain("OPENRIG_RUN_ID=");
|
||||
expect(result).not.toHaveProperty("activityGrantId");
|
||||
});
|
||||
|
||||
it.each([["launch", false, true], ["resume", false, true], ["launch", true, true], ["resume", true, true], ["launch", false, false], ["resume", false, false]] as const)("%s preserves dispatch truth when optional activation fails, cleanup failure=%s, ready=%s", async (owner, cleanupFails, ready) => {
|
||||
const fs = memFs({ [SESSION_FILE]: "history" });
|
||||
const finish = vi.fn(async () => { if (cleanupFails) throw new Error("cleanup unreadable"); });
|
||||
const issue = async () => ({ grantId: "failed-activation", generation: "g", wrap: (s: string) => s,
|
||||
dispatched: () => { throw new Error("activation unreadable after send"); }, finish });
|
||||
const sendShellCommand = vi.fn(async (_target: string, command: string) => {
|
||||
if (ready) fs.files[piSeatPaths(STATE_ROOT, SESSION).runnerStatePath] = readyState(SESSION_FILE, "maintainer-attempt");
|
||||
return { ok: true as const };
|
||||
});
|
||||
const tmux = mockTmux({ sendShellCommand });
|
||||
const result = owner === "launch"
|
||||
? await new PiRuntimeAdapter({ tmux, fsOps: fs, stateRoot: STATE_ROOT, runnerEntryPath: RUNNER, runnerGrants: issue, newLaunchId: () => "maintainer-attempt", sleep: async () => {} }).launchHarness({ tmuxSession: SESSION, cwd: "/work" } as never, { name: SESSION })
|
||||
: await new PiResumeAdapter(tmux, fs, { stateRoot: STATE_ROOT, runnerEntryPath: RUNNER }, { runnerGrant: issue, newLaunchId: () => "maintainer-attempt", sleep: async () => {}, maxWaitMs: 1, pollMs: 1 }).resume(SESSION, "pi_session_file", SESSION_FILE, "/work");
|
||||
expect(result.ok).toBe(ready);
|
||||
expect(sendShellCommand).toHaveBeenCalledOnce();
|
||||
expect(finish).toHaveBeenCalledWith(false);
|
||||
expect(result).not.toHaveProperty("activityGrantId");
|
||||
});
|
||||
|
||||
it.each(["launch", "resume"] as const)("%s retains a no-effect long-command retry without run authority", async owner => {
|
||||
const fs = memFs({ [SESSION_FILE]: "history" });
|
||||
const finish = vi.fn(async () => {}), dispatched = vi.fn();
|
||||
const issue = async () => ({ grantId: "too-long", generation: "g", wrap: (s: string) => `private-wrapper ${s}`, dispatched, finish });
|
||||
const sendShellCommand = vi.fn(async (_target: string, command: string) => {
|
||||
if (command.startsWith("private-wrapper")) return { ok: false as const, code: "launch_path_too_long", message: "not dispatched" };
|
||||
fs.files[piSeatPaths(STATE_ROOT, SESSION).runnerStatePath] = readyState(SESSION_FILE, "maintainer-attempt");
|
||||
return { ok: true as const };
|
||||
});
|
||||
const tmux = mockTmux({ sendShellCommand });
|
||||
const result = owner === "launch"
|
||||
? await new PiRuntimeAdapter({ tmux, fsOps: fs, stateRoot: STATE_ROOT, runnerEntryPath: RUNNER, runnerGrants: issue, newLaunchId: () => "maintainer-attempt", sleep: async () => {} }).launchHarness({ tmuxSession: SESSION, cwd: "/work" } as never, { name: SESSION })
|
||||
: await new PiResumeAdapter(tmux, fs, { stateRoot: STATE_ROOT, runnerEntryPath: RUNNER }, { runnerGrant: issue, newLaunchId: () => "maintainer-attempt", sleep: async () => {}, maxWaitMs: 1, pollMs: 1 }).resume(SESSION, "pi_session_file", SESSION_FILE, "/work");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(sendShellCommand).toHaveBeenCalledTimes(2);
|
||||
expect(finish).toHaveBeenCalledWith(false);
|
||||
expect(dispatched).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["launch", "resume"] as const)("%s preserves the selected account command when only the optional wrapper is too long", async owner => {
|
||||
const fs = memFs({ [SESSION_FILE]: "history" }), finish = vi.fn(async () => {});
|
||||
const issue = async () => ({ grantId: "own", generation: "g", wrap: (s: string) => `private-wrapper ${s}`, dispatched: vi.fn(), finish });
|
||||
const sendShellCommand = vi.fn(async (_session: string, command: string) => {
|
||||
if (command.startsWith("private-wrapper")) return { ok: false as const, code: "launch_path_too_long", message: "not dispatched" };
|
||||
expect(command).toContain("ACCOUNT_GUARD=selected");
|
||||
fs.files[piSeatPaths(STATE_ROOT, SESSION).runnerStatePath] = readyState(SESSION_FILE, "selected");
|
||||
return { ok: true as const };
|
||||
});
|
||||
const seatLaunchEnvironment = { accountHome: () => "/selected", command: async (_session: string, command: string) => `/usr/bin/env ACCOUNT_GUARD=selected ${command}` } as never;
|
||||
const tmux = mockTmux({ sendShellCommand });
|
||||
const result = owner === "launch"
|
||||
? await new PiRuntimeAdapter({ tmux, fsOps: fs, stateRoot: STATE_ROOT, runnerEntryPath: RUNNER, runnerGrants: issue,
|
||||
seatLaunchEnvironment, newLaunchId: () => "selected", sleep: async () => {} }).launchHarness({ tmuxSession: SESSION, cwd: "/work", nodeId: "node-pi" } as never, { name: SESSION })
|
||||
: await new PiResumeAdapter(tmux, fs, { stateRoot: STATE_ROOT, runnerEntryPath: RUNNER }, { runnerGrant: issue, seatLaunchEnvironment,
|
||||
accountHomeForSession: () => "/selected", newLaunchId: () => "selected", sleep: async () => {}, maxWaitMs: 1 }).resume(SESSION, "pi_session_file", SESSION_FILE, "/work");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(sendShellCommand).toHaveBeenCalledTimes(2);
|
||||
expect(sendShellCommand.mock.calls[0]![1]).toBe(`private-wrapper ${sendShellCommand.mock.calls[1]![1]}`);
|
||||
expect(finish).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("rechecks the selected launch before a known-no-effect long-command retry", async () => {
|
||||
const finish = vi.fn(async () => {}), callback = vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false);
|
||||
const sendShellCommand = vi.fn(async () => ({ ok: false as const, code: "launch_path_too_long", message: "not dispatched" }));
|
||||
const adapter = new PiRuntimeAdapter({ tmux: mockTmux({ sendShellCommand }), fsOps: memFs(), stateRoot: STATE_ROOT,
|
||||
runnerEntryPath: RUNNER, runnerGrants: async () => ({ grantId: "own", generation: "g", wrap: s => `wrapped ${s}`, dispatched: () => {}, finish }) });
|
||||
expect(await adapter.launchHarness({ tmuxSession: SESSION, cwd: "/work" } as never,
|
||||
{ name: SESSION, beforeNativeDispatch: callback })).toMatchObject({ ok: false, error: expect.stringContaining("selection changed") });
|
||||
expect(sendShellCommand).toHaveBeenCalledOnce();
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
expect(finish).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("still honors invalidated selection when optional grant setup fails", async () => {
|
||||
const sendShellCommand = vi.fn(async () => ({ ok: true as const }));
|
||||
const adapter = new PiRuntimeAdapter({ tmux: mockTmux({ sendShellCommand }), fsOps: memFs(), stateRoot: STATE_ROOT,
|
||||
runnerEntryPath: RUNNER, runnerGrants: async () => { throw new Error("optional grant unavailable"); } });
|
||||
expect(await adapter.launchHarness({ tmuxSession: SESSION, cwd: "/work" } as never,
|
||||
{ name: SESSION, beforeNativeDispatch: () => false })).toMatchObject({ ok: false, error: expect.stringContaining("selection changed") });
|
||||
expect(sendShellCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -127,13 +127,19 @@ setInterval(() => {}, 1000);
|
||||
expect(fs.existsSync(path.join(temp, "rc-ready"))).toBe(true);
|
||||
}
|
||||
const seatLaunchEnvironment = routing ? new SeatLaunchEnvironment(tmux, sessionEnv, cwd, cli) : undefined;
|
||||
const optionalGrant = routing && longTmp ? {
|
||||
grantId: "fixture-grant", generation: launchMode === "resume" ? "existing-generation" : "reserved-generation",
|
||||
wrap: (command: string) => `/bin/sh -c ${quote(`exec ${command}`)}`,
|
||||
dispatched: vi.fn(), finish: vi.fn(async () => {}),
|
||||
} : undefined;
|
||||
if (launchMode === "resume") {
|
||||
const adapter = new PiResumeAdapter(tmux, fsOps, { stateRoot, runnerEntryPath: runner },
|
||||
{ seatLaunchEnvironment, maxWaitMs: 4000, pollMs: 25, newLaunchId: () => "attempt" });
|
||||
{ seatLaunchEnvironment, runnerGrant: optionalGrant ? async () => optionalGrant : undefined, maxWaitMs: 4000, pollMs: 25, newLaunchId: () => "attempt" });
|
||||
const result = await adapter.resume(session, "pi_session_file", parent, cwd, model);
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
|
||||
} else {
|
||||
const adapter = new PiRuntimeAdapter({ tmux, fsOps, stateRoot, runnerEntryPath: runner, seatLaunchEnvironment,
|
||||
runnerGrants: optionalGrant ? async () => optionalGrant : undefined,
|
||||
// The adapter polls 60 times for readiness; 100 ms each gives a loaded runner ~6 s, like the resume path's 4 s.
|
||||
sleep: () => new Promise(resolve => setTimeout(resolve, 100)), newLaunchId: () => "attempt" });
|
||||
const result = await adapter.launchHarness({ tmuxSession: session, cwd, model, nodeId: "fixture-node", launchGeneration: "reserved-generation" } as never,
|
||||
@@ -142,10 +148,13 @@ setInterval(() => {}, 1000);
|
||||
}
|
||||
const received = JSON.parse(fs.readFileSync(path.join(stateRoot, session, "received.json"), "utf8"));
|
||||
if (routing && longTmp) {
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(send).toHaveBeenCalledTimes(3); // optional wrapper, original environment command, then main's bare fallback
|
||||
expect(Buffer.byteLength(send.mock.calls[0]![1])).toBeGreaterThanOrEqual(1024);
|
||||
expect(await send.mock.results[0]!.value).toMatchObject({ ok: false, code: "launch_path_too_long" });
|
||||
expect(send.mock.calls[1]![1]).toBe(expected);
|
||||
expect(await send.mock.results[1]!.value).toMatchObject({ ok: false, code: "launch_path_too_long" });
|
||||
expect(send.mock.calls[2]![1]).toBe(expected);
|
||||
expect(optionalGrant!.finish).toHaveBeenCalledWith(false);
|
||||
expect(optionalGrant!.dispatched).not.toHaveBeenCalled();
|
||||
// The supported bare fallback launches; routing correction is best-effort here.
|
||||
expect(received.route).toMatchObject({ body: { instance: "wrong-cli" }, home: "/wrong", node: "wrong" });
|
||||
} else if (routing) {
|
||||
|
||||
@@ -138,6 +138,31 @@ describe("PodRigInstantiator", () => {
|
||||
expect(done.edges.outgoing).toMatchObject([{ kind: "delegates_to", to: { logicalId: "dev.qa" } }]);
|
||||
});
|
||||
|
||||
it.each(["pi", "claude-code", "codex", "stub"])("limits Core-observed pane forwarding to grant-owning runtimes: %s", async runtime => {
|
||||
const adapter = mockAdapter(runtime);
|
||||
const { db, inst, tmux, sessionRegistry } = setup(undefined, { [runtime]: adapter });
|
||||
try {
|
||||
vi.mocked(tmux.listPanes).mockResolvedValue([{ id: "%47" } as Awaited<ReturnType<TmuxAdapter["listPanes"]>>[number]]);
|
||||
const spec = makeRigSpec(); spec.pods[0]!.members[0]!.runtime = runtime;
|
||||
const result = await inst.instantiate(RigSpecCodec.serialize(spec), RIG_ROOT);
|
||||
expect(result.ok).toBe(true);
|
||||
const binding = vi.mocked(adapter.launchHarness!).mock.calls[0]?.[0];
|
||||
expect(binding).toBeDefined();
|
||||
expect(binding!.tmuxPane).toBe(runtime === "pi" ? "%47" : null);
|
||||
expect(sessionRegistry.getBindingForNode(binding!.nodeId)?.tmuxPane).toBe("%47");
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
it("does not invent an unresolved launch pane for a grant-owning runtime", async () => {
|
||||
const adapter = mockAdapter("pi");
|
||||
const { db, inst } = setup(undefined, { pi: adapter });
|
||||
try {
|
||||
const spec = makeRigSpec(); spec.pods[0]!.members[0]!.runtime = "pi";
|
||||
expect((await inst.instantiate(RigSpecCodec.serialize(spec), RIG_ROOT)).ok).toBe(true);
|
||||
expect(vi.mocked(adapter.launchHarness!).mock.calls[0]?.[0].tmuxPane).toBeNull();
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
// T1: valid rig instantiates pods + nodes + edges
|
||||
it("instantiates pods + nodes correctly", async () => {
|
||||
const { db, inst } = setup();
|
||||
|
||||
@@ -5932,6 +5932,30 @@ describe("RestoreOrchestrator", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Run-bound reporting unavailable; using existing unbound install-token reporting.",
|
||||
"Native command dispatched; run-bound reporting could not be confirmed. Cleanup of its grant was attempted; nothing was replayed.",
|
||||
])("surfaces direct Pi restore limitation warnings without repeating resume: %s", async warning => {
|
||||
const rig = rigRepo.createRig("pi-warning");
|
||||
const node = rigRepo.addNode(rig.id, "worker", { runtime: "pi", cwd: "/repo/pi" });
|
||||
const session = sessionRegistry.registerSession(node.id, "r99-pi-warning");
|
||||
db.prepare("UPDATE sessions SET resume_type = ?, resume_token = ?, restore_policy = ? WHERE id = ?")
|
||||
.run("pi_session_file", "/tmp/pi-warning.jsonl", "resume_if_possible", session.id);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
const snap = snapshotCapture.captureSnapshot(rig.id, "manual");
|
||||
sessionRegistry.updateStatus(session.id, "exited");
|
||||
const resume = vi.fn(async (_session: string) => ({ ok: true as const, warnings: [warning] }));
|
||||
const orch = createOrchestrator({ pi: {
|
||||
canResume: vi.fn((type: string | null) => type === "pi_session_file"), resume,
|
||||
} as unknown as PiResumeAdapter });
|
||||
|
||||
const result = await orch.restore(snap.id);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(resume).toHaveBeenCalledTimes(1);
|
||||
expect(result.result.warnings).toContain(`${resume.mock.calls[0]![0]}: ${warning}`);
|
||||
});
|
||||
|
||||
describe("launch_wrapper native resume, fail-closed, and snapshot retention", () => {
|
||||
it("passes current launch_wrapper as the new LAST arg after effort on Pi native resume and after beforeNativeDispatch (with undefined padding) on Codex native resume", async () => {
|
||||
const rig = rigRepo.createRig("r99");
|
||||
@@ -5967,7 +5991,17 @@ describe("RestoreOrchestrator", () => {
|
||||
|
||||
const piResume = {
|
||||
canResume: vi.fn((type: string | null, token: string | null) => type === "pi_session_file" && !!token),
|
||||
resume: vi.fn(async () => ({ ok: true as const })),
|
||||
resume: vi.fn(async (session: string) => {
|
||||
// The production Pi grant resolver must see the newly committed restore row, not the exited snapshot row.
|
||||
const owners = db.prepare(`SELECT b.node_id AS nodeId, s.id AS sessionId, s.status FROM bindings b JOIN nodes n ON n.id=b.node_id
|
||||
JOIN sessions s ON s.node_id=n.id AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)
|
||||
WHERE b.tmux_session=? AND s.session_name=? AND n.runtime='pi' AND s.status NOT IN ('superseded','detached','exited')`)
|
||||
.all(session, session) as Array<{ nodeId: string; sessionId: string; status: string }>;
|
||||
expect(owners).toHaveLength(1);
|
||||
expect(owners[0]).toMatchObject({ nodeId: piNode.id, status: "running" });
|
||||
expect(owners[0]!.sessionId).not.toBe(piSess.id);
|
||||
return { ok: true as const };
|
||||
}),
|
||||
} as unknown as PiResumeAdapter;
|
||||
const codexResume = mockCodexResume({ ok: true as const });
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
// A runner seat's answered permission path, through the real activity routes, queue, Mission Control answer route,
|
||||
// activity store and seat grant. The chosen person's one offered option reaches only that live request; nothing else
|
||||
// ever reads as an answer. No model, Slack or terminal is involved.
|
||||
@@ -465,6 +466,30 @@ describe("a runner seat's answered permission path", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])("refuses unverified grant reads and writes without changing the live ask, then recovers (guest=%s)", async guest => {
|
||||
const f = fixture({ guest });
|
||||
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.runner.id, SEAT);
|
||||
const session = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.runner.id) as { id: string };
|
||||
const grant = f.grants.issue({ nodeId: f.runner.id, generation: f.generation(), issuedBy: "launch", run: {
|
||||
runId: "permission-run", sessionId: session.id, binding: { runtime: "synth", runner: 1, pane: "%1", panePid: 101 } } });
|
||||
expect(f.grants.activate(grant.grantId)).toBe(true);
|
||||
const askId = String((await f.submit({}, grant.token)).body.askId);
|
||||
const before = f.queueRepo.getById(askId);
|
||||
const events = f.db.prepare("SELECT COUNT(*) AS n FROM events").get();
|
||||
const read = vi.spyOn(SeatIdentityStore.prototype, "getForNode").mockImplementation(() => { throw new Error("observation unavailable"); });
|
||||
try {
|
||||
for (const reply of [await f.read(askId, 0, grant.token), await f.withdraw(askId, "turn_ended", grant.token),
|
||||
await f.submit({ requestId: "unverified-new" }, grant.token)]) {
|
||||
expect(reply).toMatchObject({ status: 409, body: { code: "activity_grant_unverified", retryable: true } });
|
||||
}
|
||||
expect(f.queueRepo.getById(askId)).toEqual(before);
|
||||
expect(f.db.prepare("SELECT COUNT(*) AS n FROM events").get()).toEqual(events);
|
||||
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
|
||||
} finally { read.mockRestore(); }
|
||||
expect(await f.read(askId, 0, grant.token)).toMatchObject({ status: 200, body: { state: "pending" } });
|
||||
expect(f.grants.standing(grant.grantId)).toBe("current");
|
||||
});
|
||||
|
||||
it("serves the real guest listener's app for a seat grant, and never for the install token", async () => {
|
||||
const f = fixture({ realGuest: true });
|
||||
const grant = f.grants.issue({ nodeId: f.runner.id, generation: f.generation(), issuedBy: "test" });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ActivityGrantStore } from "../src/domain/activity-grants.js";
|
||||
import { SeatLifecycleService } from "../src/domain/seat-lifecycle-service.js";
|
||||
import { CodexRuntimeAdapter, type CodexAdapterFsOps } from "../src/adapters/codex-runtime-adapter.js";
|
||||
import { mockShellCommand } from "./helpers/shell-command-mock.js";
|
||||
@@ -995,6 +996,32 @@ describe("SeatHandoverService", () => {
|
||||
expect(generation).not.toBe(retiringGen); // the SWAP identity is the successor tenure, not the retiree
|
||||
});
|
||||
|
||||
it.each(["notification", "transaction"])("keeps the committed successor grant and cleans only a failed transaction: %s", async failure => {
|
||||
const { node } = seedSeat({ runtime: "codex" });
|
||||
const grants = new ActivityGrantStore(db);
|
||||
let grantId: string | undefined;
|
||||
launchHarness.mockImplementation(async (binding) => {
|
||||
const grant = grants.issue({ nodeId: node.id, generation: binding.launchGeneration!, issuedBy: "launch", run: {
|
||||
runId: "successor", binding: { runtime: "codex", runner: 0, pane: binding.tmuxPane!, panePid: 101 } } });
|
||||
grantId = grant.grantId;
|
||||
return { ok: true, activityGrantId: grantId, resumeToken: "codex-launch-tok", resumeType: "codex_id" };
|
||||
});
|
||||
if (failure === "notification") declareOccupantSwap.mockImplementation(() => { throw new Error("oracle notification failed"); });
|
||||
else vi.spyOn(eventBus, "persistWithinTransaction").mockImplementationOnce(() => { throw new Error("transaction failed"); });
|
||||
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "notification-failure-proof", source: "fresh" });
|
||||
expect(grantId).toBeDefined();
|
||||
if (failure === "transaction") {
|
||||
expect(result).toMatchObject({ ok: false, code: "handover_commit_failed" });
|
||||
expect(grants.standing(grantId!)).toBe("revoked");
|
||||
expect(declareOccupantSwap).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
expect(result.ok).toBe(true);
|
||||
expect(grants.standing(grantId!)).toBe("current");
|
||||
expect(db.prepare("SELECT handover_result FROM nodes WHERE id = ?").get(node.id)).toEqual({ handover_result: "complete" });
|
||||
expect(db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'seat.handover_completed'").get()).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it("S19: a handover that fails before commit never declares a swap (no phantom swap events)", async () => {
|
||||
seedSeat({ runtime: "codex" });
|
||||
respawnPane.mockResolvedValue({ ok: false, code: "no_server", message: "no server running" });
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ActivityGrantStore } from "../src/domain/activity-grants.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
// OPR.0.4.3.19 — SeatIdentityReconciler unit tests.
|
||||
//
|
||||
// The reconciler owns the liveness identity verdict (the THIRD axis, orthogonal
|
||||
@@ -340,7 +342,8 @@ describe("SeatIdentityReconciler.reconcileAll", () => {
|
||||
const db = createFullTestDb();
|
||||
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", pane: "%1", runtime: "claude-code" });
|
||||
// session listed live, but the registered pane no longer resolves.
|
||||
const tmux = makeTmux({ sessions: ["s1@rig"], panePid: { "%1": null } });
|
||||
const tmux = { ...makeTmux({ sessions: ["s1@rig"], panePid: { "%1": null } }),
|
||||
listPanes: async () => [{ id: "%2" }] as never };
|
||||
const rec = new SeatIdentityReconciler({ db, tmux, now: NOW });
|
||||
|
||||
await rec.reconcileAll();
|
||||
@@ -471,7 +474,8 @@ describe("SeatIdentityReconciler.reconcileAll", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, body) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
).run("q1", "2026-07-02T12:00:00Z", "2026-07-02T12:00:00Z", "op@rig", "s1@rig", "pending", "do work");
|
||||
const tmux = makeTmux({ sessions: ["s1@rig"], panePid: { "%1": null } });
|
||||
const tmux = { ...makeTmux({ sessions: ["s1@rig"], panePid: { "%1": null } }),
|
||||
listPanes: async () => [{ id: "%2" }] as never };
|
||||
const rec = new SeatIdentityReconciler({ db, tmux, now: NOW });
|
||||
|
||||
await rec.reconcileAll();
|
||||
@@ -611,3 +615,120 @@ describe("SeatIdentityReconciler — bounded polling", () => {
|
||||
} finally { rec.stop(); db.close(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe("definitive Core exit ends exact run authority", () => {
|
||||
|
||||
it.each(["present", "unreadable"] as const)("does not revoke on unreadable pane PID when session census is %s, then recovers", async census => {
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", runtime: "pi", pane: "%1" });
|
||||
const generation = new SessionRegistry(db).mintOccupantTenure("n1", "initial").generationUuid;
|
||||
const grants = new ActivityGrantStore(db);
|
||||
const grant = grants.issue({ nodeId: "n1", generation, issuedBy: "launch", run: { runId: "current", sessionId: "sess-n1",
|
||||
binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
|
||||
grants.activate(grant.grantId);
|
||||
const state = { sessions: ["s1@rig"], panePid: { "%1": null as number | null }, paneCommand: { "%1": "node" } };
|
||||
const tmux = { ...makeTmux(state), listPanes: async () => {
|
||||
if (census === "unreadable") throw new Error("transport unavailable");
|
||||
return [{ id: "%1" }] as never;
|
||||
} };
|
||||
const rec = new SeatIdentityReconciler({ db, tmux, now: NOW });
|
||||
await rec.reconcileAll();
|
||||
expect(new SeatIdentityStore(db).getForNode("n1")?.verdict).toBe("tmux_unavailable");
|
||||
expect(grants.get(grant.grantId)?.revokedAt).toBeNull();
|
||||
state.panePid["%1"] = 101;
|
||||
await rec.reconcileAll();
|
||||
expect(grants.standing(grant.grantId)).toBe("current");
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
it.each(["no reader", "unreadable", "missing", "stale", "pending", "ready", "exited"] as const)("requires current launch evidence before shell exit revocation: %s", async launch => {
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", runtime: "pi", pane: "%1" });
|
||||
const generation = new SessionRegistry(db).mintOccupantTenure("n1", "initial").generationUuid;
|
||||
const grants = new ActivityGrantStore(db);
|
||||
const grant = grants.issue({ nodeId: "n1", generation, issuedBy: "launch", run: { runId: "current", sessionId: "sess-n1",
|
||||
binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
|
||||
grants.activate(grant.grantId);
|
||||
const tmux = makeTmux({ sessions: ["s1@rig"], panePid: { "%1": 101 }, paneCommand: { "%1": "sh" } });
|
||||
const readPiRunnerState = () => {
|
||||
if (launch === "unreadable") throw new Error("sidecar unavailable");
|
||||
return launch === "missing" ? null : {
|
||||
launchId: launch === "stale" ? "predecessor" : "current", ready: launch === "ready" || launch === "stale",
|
||||
updatedAt: NOW().toISOString(), ...(launch === "exited" ? { exited: { code: 1, at: NOW().toISOString() } } : {}),
|
||||
};
|
||||
};
|
||||
await new SeatIdentityReconciler({ db, tmux, now: NOW, ...(launch === "no reader" ? {} : { readPiRunnerState }) }).reconcileAll();
|
||||
expect(grants.standing(grant.grantId)).toBe(launch === "ready" || launch === "exited" ? "revoked" : "current");
|
||||
// Readiness limits irreversible authority cleanup; it does not turn the shell into a live agent verdict.
|
||||
expect(new SeatIdentityStore(db).getForNode("n1")?.verdict).toBe("mismatch");
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
it.each([false, true])("preserves a starting grant through bootstrap and revokes it only on a later established shell exit, late ready=%s", async lateReady => {
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", runtime: "pi", pane: "%1" });
|
||||
const generation = new SessionRegistry(db).mintOccupantTenure("n1", "initial").generationUuid;
|
||||
const grants = new ActivityGrantStore(db);
|
||||
const grant = grants.issue({ nodeId: "n1", generation, issuedBy: "launch", run: { runId: "current", sessionId: "sess-n1",
|
||||
binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
|
||||
grants.activate(grant.grantId);
|
||||
const state = { sessions: ["s1@rig"], panePid: { "%1": 101 }, paneCommand: { "%1": "sh" } };
|
||||
let ready = false;
|
||||
const tmux = makeTmux(state);
|
||||
if (lateReady) vi.mocked(tmux.listSessions).mockImplementationOnce(async () => {
|
||||
ready = true; return [{ name: "s1@rig" }] as never;
|
||||
});
|
||||
const rec = new SeatIdentityReconciler({ db, tmux, now: NOW,
|
||||
readPiRunnerState: () => ({ launchId: "current", ready, updatedAt: NOW().toISOString() }) });
|
||||
await rec.reconcileAll();
|
||||
expect(grants.standing(grant.grantId)).toBe("current");
|
||||
ready = true; state.paneCommand["%1"] = "node";
|
||||
await rec.reconcileAll();
|
||||
expect(grants.standing(grant.grantId)).toBe("current");
|
||||
state.paneCommand["%1"] = "sh";
|
||||
await rec.reconcileAll();
|
||||
expect(grants.standing(grant.grantId)).toBe("revoked");
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
it.each([true, false])("preserves authority on transient or unconfirmed observations, transient=%s", async transient => {
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", runtime: "synth", pane: "%1" });
|
||||
db.prepare("UPDATE sessions SET agent_app = 'synth', agent_runner = 1 WHERE node_id = 'n1'").run();
|
||||
const generation = new SessionRegistry(db).mintOccupantTenure("n1", "initial").generationUuid;
|
||||
const grants = new ActivityGrantStore(db);
|
||||
const grant = grants.issue({ nodeId: "n1", generation, issuedBy: "launch", run: { runId: "current", sessionId: "sess-n1",
|
||||
binding: { runtime: "synth", runner: 1, pane: "%1", panePid: 101 } } });
|
||||
expect(grants.activate(grant.grantId)).toBe(true);
|
||||
const tmux = makeTmux({ sessions: ["s1@rig"], panePid: { "%1": 101 }, paneCommand: { "%1": "node" }, throwListSessions: transient });
|
||||
await new SeatIdentityReconciler({ db, tmux, now: NOW }).reconcileAll();
|
||||
expect(grants.get(grant.grantId)?.revokedAt).toBeNull();
|
||||
expect(new SeatIdentityStore(db).getForNode("n1")?.verdict).toBe(transient ? "tmux_unavailable" : "unconfirmed");
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
it.each([false, true])("revokes the sampled run without touching a replacement during the await, replacement=%s", async replace => {
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", runtime: "pi", pane: "%1" });
|
||||
const generation = new SessionRegistry(db).mintOccupantTenure("n1", "initial").generationUuid;
|
||||
const grants = new ActivityGrantStore(db);
|
||||
const issue = (runId: string) => grants.issue({ nodeId: "n1", generation, issuedBy: "launch", run: {
|
||||
runId, sessionId: "sess-n1", binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
|
||||
const first = issue("first"); grants.activate(first.grantId);
|
||||
let successor: ReturnType<typeof issue> | undefined;
|
||||
const tmux = makeTmux({ sessions: ["another@rig"] });
|
||||
tmux.listSessions = vi.fn(async () => {
|
||||
if (replace) { successor = issue("successor"); grants.activate(successor.grantId); }
|
||||
return [{ name: "another@rig" }] as never;
|
||||
});
|
||||
await new SeatIdentityReconciler({ db, tmux, now: NOW }).reconcileAll();
|
||||
expect(grants.standing(first.grantId)).toBe("revoked");
|
||||
if (successor) expect(grants.standing(successor.grantId)).toBe("current");
|
||||
} finally { db.close(); }
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import type { ArbitratedSeatState } from "../src/domain/activity-taxonomy.js";
|
||||
import type { InPlaceRestartIo, ProcessFacts } from "../src/domain/seat-restart-in-place.js";
|
||||
import { accountLineNames, accountTextMark, defaultInPlaceRestartIo, linesAfterAnchor, parseEffectiveSettings, procCmdlineArgv, shellWords } from "../src/domain/seat-restart-in-place.js";
|
||||
import { CodexRuntimeAdapter } from "../src/adapters/codex-runtime-adapter.js";
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
import { CodexThreadIdResolver, type CodexThreadEvidence } from "../src/domain/codex-thread-id.js";
|
||||
import { SeatLaunchEnvironment } from "../src/domain/seat-launch-environment.js";
|
||||
|
||||
@@ -644,6 +645,28 @@ describe("rig seat restart --keep-conversation: gates before any signal", () =>
|
||||
expect(guidance).not.toMatch(/run rig down/);
|
||||
});
|
||||
|
||||
it("#3131: names rig launch for a recoverable seat whose tmux session is gone, and nothing for one that isn't", async () => {
|
||||
const w = world();
|
||||
w.tmux.probeSession = async () => ({ state: "absent" as const });
|
||||
const plain = await w.service.restartInPlace({ seatRef: SEAT, plan: true });
|
||||
expect(plain).toMatchObject({ ok: false, code: "session_not_live" });
|
||||
if (plain.ok) throw new Error("expected refusal");
|
||||
expect((plain as { guidance?: string }).guidance).toBeUndefined();
|
||||
// Recoverable, as OpenRig itself derives it: the session confirmed missing, and a usable snapshot holds this seat's token.
|
||||
new SeatIdentityStore(w.db).upsert({ nodeId: w.node.id, verdict: "pane_missing", evidenceSource: "tmux_session", reason: "session_missing",
|
||||
evidence: { registeredPane: "%1", observedPid: null, observedCommand: null, matchedLayer: null }, sessionName: SEAT,
|
||||
observedAt: new Date(Date.now() + 1000).toISOString() }); // observed after the session's registration
|
||||
const rigId = w.db.prepare("SELECT rig_id FROM nodes WHERE id = ?").get(w.node.id) as { rig_id: string };
|
||||
w.db.prepare("INSERT INTO snapshots (id, rig_id, kind, status, data) VALUES (?, ?, ?, ?, ?)").run("snap-3131", rigId.rig_id, "manual", "complete",
|
||||
JSON.stringify({ rig: { id: rigId.rig_id, name: "r", createdAt: "2026-11-13T00:00:00Z", updatedAt: "2026-11-13T00:00:00Z" }, nodes: [], edges: [],
|
||||
sessions: [{ id: "sess-snap", nodeId: w.node.id, sessionName: SEAT, status: "detached", resumeType: "codex_id", resumeToken: TOKEN,
|
||||
restorePolicy: "resume_if_possible", lastSeenAt: null, createdAt: "2026-11-13T00:00:00Z", origin: "launched", startupStatus: "ready",
|
||||
startupCompletedAt: null }], checkpoints: {} }));
|
||||
const recoverable = await w.service.restartInPlace({ seatRef: SEAT, plan: true });
|
||||
expect(recoverable).toMatchObject({ ok: false, code: "session_not_live",
|
||||
guidance: "The seat is recoverable from its saved conversation: rig launch 'r' 'dev.codex' (add --plan to preview)." });
|
||||
});
|
||||
|
||||
it("names a seat handed over before 0.7.9 instead of pointing at rig unclaim", async () => {
|
||||
const w = world();
|
||||
w.db.prepare("UPDATE sessions SET origin = 'claimed' WHERE id = ?").run(w.session.id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ActivityGrantStore } from "../src/domain/activity-grants.js";
|
||||
import { piAccountStateRoot, piAccountSwitchStateRoot } from "../src/domain/pi-account-state.js";
|
||||
import { piSeatPaths } from "../src/adapters/pi-runner-protocol.js";
|
||||
import { PiRuntimeAdapter } from "../src/adapters/pi-runtime-adapter.js";
|
||||
@@ -872,6 +873,30 @@ describe("live seat runtime switch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("abandons only the staged run grant while the live predecessor keeps its authority", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(nodeId);
|
||||
const grants = new ActivityGrantStore(db);
|
||||
const old = grants.issue({ nodeId, generation: predecessorGeneration, issuedBy: "launch", run: { runId: "predecessor",
|
||||
binding: { runtime: "pi", runner: 0, pane: "%0", panePid: 100 } } });
|
||||
expect(grants.activate(old.grantId)).toBe(true);
|
||||
let stagedId = "";
|
||||
const target = adapter("pi");
|
||||
vi.mocked(target.launchHarness).mockImplementation(async binding => {
|
||||
const staged = grants.issue({ nodeId, generation: binding.launchGeneration!, issuedBy: "launch", run: { runId: "staged",
|
||||
binding: { runtime: "pi", runner: 0, pane: binding.tmuxPane!, panePid: 200 } } });
|
||||
stagedId = staged.grantId;
|
||||
return { ok: true, activityGrantId: stagedId };
|
||||
});
|
||||
const accountHome = path.join(home, "next-account"); fs.mkdirSync(accountHome);
|
||||
fs.writeFileSync(path.join(accountHome, "auth.json"), "selected-login");
|
||||
const result = await service({ runtimeAdapters: { pi: target } }).setRuntime({ seatRef: SEAT, runtime: "pi", accountHome,
|
||||
reason: "change account", confirmTimeoutMs: 1_000 });
|
||||
expect(result).toMatchObject({ ok: false, committed: false });
|
||||
expect(stagedId).not.toBe("");
|
||||
expect(grants.standing(stagedId)).toBe("revoked");
|
||||
expect(grants.standing(old.grantId)).toBe("current");
|
||||
});
|
||||
|
||||
it("without a confirmation in time nothing changes: the current occupant keeps the seat and its claim, and the staged successor is retired", async () => {
|
||||
const claimed = await queueRepo.create({ sourceSession: "orch-lead@seat-rig", destinationSession: SEAT, body: "review" });
|
||||
queueRepo.claim({ qitemId: claimed.qitemId, destinationSession: SEAT });
|
||||
@@ -957,6 +982,19 @@ describe("live seat runtime switch", () => {
|
||||
.run(nodeId, SEAT, predecessorGeneration, phase, phase === "committed" ? "2026-10-24T02:01:00Z" : null);
|
||||
};
|
||||
|
||||
it("withdraws only the interrupted operation's pending issuance before recovery awaits", async () => {
|
||||
record("preparing"); panes.set("openrig-switch-sw1", "%5");
|
||||
const grants = new ActivityGrantStore(db);
|
||||
const predecessor = grants.issue({ nodeId, generation: predecessorGeneration, issuedBy: "legacy" });
|
||||
const issue = (generation: string, runId: string) => grants.issue({ nodeId, generation, issuedBy: "launch", run: {
|
||||
runId, binding: { runtime: "pi", runner: 0, pane: "%5", panePid: 200 } } });
|
||||
const abandoned = issue("g-next", "abandoned"), unrelated = issue("other-reservation", "unrelated");
|
||||
expect(await service().recover(nodeId)).toMatchObject({ ok: true });
|
||||
expect(grants.standing(abandoned.grantId)).toBe("revoked");
|
||||
expect(grants.standing(unrelated.grantId)).toBe("pending");
|
||||
expect(grants.get(predecessor.grantId)?.revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("stopped before commit, panes not exchanged: the staged successor is retired and the old runtime keeps the seat", async () => {
|
||||
record("preparing");
|
||||
panes.set("openrig-switch-sw1", "%5");
|
||||
|
||||
@@ -274,6 +274,60 @@ describe("update --reply-to an earlier item's thread (#96)", () => {
|
||||
const inboundRow = (ts: string, channel = "C-TEST") =>
|
||||
repo.getById(`qitem-slack-inbound-${createHash("sha256").update(`${channel}:${ts}`).digest("hex").slice(0, 20)}`);
|
||||
|
||||
it.each(["open", "closed"] as const)("#2990: an answer to a reply in the old shared root uses its arrival thread (%s)", async (state) => {
|
||||
const humanReply = await connectInbound();
|
||||
const work = await park();
|
||||
await deliver(work.qitemId); // root 1.1
|
||||
await resolvePark(work.qitemId);
|
||||
const update = await repo.create({ ...request, sourceSession: "worker@rig", humanIntent: "update", body: "Merged.", replyTo: work.qitemId });
|
||||
await deliver(update.qitemId);
|
||||
expect(posts.at(-1)?.thread_ts).toBe("1.1");
|
||||
repo.update({ qitemId: work.qitemId, actorSession: "worker@rig", state: "blocked", blockedOn: "human-founder@kernel", summary: "Deploy it too?", evidenceRef: "/proof/deploy.md", transitionNote: "parked again" });
|
||||
await deliver(work.qitemId);
|
||||
expect(posts.at(-1)?.thread_ts).toBeUndefined();
|
||||
expect(new ThreadSeatMap(db).resolveByThread(`${posts.length}.1`)?.conversationId).toBe(work.qitemId);
|
||||
|
||||
await humanReply("1.1", "930.1");
|
||||
expect(repo.getById(work.qitemId)?.state).toBe("blocked");
|
||||
await vi.waitFor(() => expect(inboundRow("930.1")).toMatchObject({ destinationSession: "worker@rig", replyTo: null }));
|
||||
if (state === "closed") new ThreadSeatMap(db).close("1.1");
|
||||
|
||||
const answer = await repo.create({ ...request, sourceSession: "worker@rig", humanIntent: "update", body: "On it.", replyTo: inboundRow("930.1")!.qitemId });
|
||||
await deliver(answer.qitemId);
|
||||
if (state === "open") {
|
||||
expect(posts.at(-1)?.thread_ts).toBe("1.1");
|
||||
expect(repo.getById(answer.qitemId)?.replyToFallback).toBeNull();
|
||||
} else {
|
||||
expect(posts.at(-1)?.thread_ts).toBeUndefined();
|
||||
expect(repo.getById(answer.qitemId)?.replyToFallback).toBe("root-closed (thread 1.1)");
|
||||
}
|
||||
expect(repo.getById(work.qitemId)?.state).toBe("blocked");
|
||||
});
|
||||
|
||||
it("#2990: an answer to a reply in a superseded person-started root uses that root's mapped channel", async () => {
|
||||
saveConfig({ ...DEFAULT_CONFIG, enabled: true, channel: "C-DEFAULT", secretsEnvFile: join(home, "fake.env"), minimumLevelThatInterrupts: "NOTICE",
|
||||
channelMap: [{ match: "operator-agent@kernel", channel: "C-AGENT" }] }, home);
|
||||
const humanReply = await connectInbound();
|
||||
await humanReply(undefined, "0.1", "C-PERSON");
|
||||
const conversation = inboundRow("0.1", "C-PERSON")!;
|
||||
expect(conversation).toMatchObject({ destinationSession: "operator-agent@kernel", replyTo: null });
|
||||
expect(conversation.tags).toContain("slack-thread-root");
|
||||
|
||||
repo.update({ qitemId: conversation.qitemId, actorSession: "operator-agent@kernel", state: "blocked", blockedOn: "human-founder@kernel",
|
||||
summary: "Which branch?", evidenceRef: "/proof/branches.md", transitionNote: "parked for a question" });
|
||||
await deliver(conversation.qitemId);
|
||||
expect(posts.at(-1)?.thread_ts).toBeUndefined();
|
||||
expect(new ThreadSeatMap(db).resolveByThread("1.1", String(posts.at(-1)?.channel))).toMatchObject({ conversationId: conversation.qitemId });
|
||||
|
||||
await humanReply("0.1", "0.2", "C-PERSON");
|
||||
await vi.waitFor(() => expect(inboundRow("0.2", "C-PERSON")).toMatchObject({ destinationSession: "operator-agent@kernel", replyTo: null }));
|
||||
const answer = await repo.create({ ...request, sourceSession: "operator-agent@kernel", humanIntent: "update", body: "On it.",
|
||||
replyTo: inboundRow("0.2", "C-PERSON")!.qitemId });
|
||||
await deliver(answer.qitemId);
|
||||
expect(posts.at(-1)).toMatchObject({ channel: "C-PERSON", thread_ts: "0.1" });
|
||||
expect(repo.getById(answer.qitemId)?.replyToFallback).toBeNull();
|
||||
});
|
||||
|
||||
it("#822: an answer to a reply the person typed in an OpenRig thread posts in that thread", async () => {
|
||||
const humanReply = await connectInbound();
|
||||
const decision = await repo.create({ ...request, humanAlert: true });
|
||||
@@ -793,6 +847,23 @@ describe("update --reply-to an earlier item's thread (#96)", () => {
|
||||
expect(repo.replyToChoiceFor(update.qitemId)).toEqual({ kind: "thread", threadTs: "1.1" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ actorSession: "author@rig" },
|
||||
{ actorSession: "author@rig", identityProvenance: "transport:v1" },
|
||||
{ actorSession: "daemon@kernel", identityProvenance: "transport:v1" },
|
||||
])("#2990: ignores an untrusted arrival-thread note (%j)", async (identity) => {
|
||||
const first = await repo.create({ ...request, humanIntent: "update" });
|
||||
await deliver(first.qitemId);
|
||||
const inbound = await repo.create({ sourceSession: human, destinationSession: "author@rig", body: "thanks", nudge: false });
|
||||
expect(repo.getById(inbound.qitemId)?.replyTo).toBeNull();
|
||||
repo.update({ ...identity, qitemId: inbound.qitemId, transitionNote: "slack-arrival-thread thread_ts=1.1 channel=C-TEST" });
|
||||
|
||||
const answer = await repo.create({ ...request, humanIntent: "update", replyTo: inbound.qitemId });
|
||||
await deliver(answer.qitemId);
|
||||
expect(posts.at(-1)?.thread_ts).toBeUndefined();
|
||||
expect(repo.getById(answer.qitemId)?.replyToFallback).toBe(`root-missing (qitem ${inbound.qitemId})`);
|
||||
});
|
||||
|
||||
it("falls back when the referenced root lives in a channel other than the configured one", async () => {
|
||||
const first = await repo.create({ ...request, humanIntent: "update" });
|
||||
await deliver(first.qitemId);
|
||||
|
||||
@@ -210,6 +210,26 @@ describe("inbound routing — the four classes, wrong-seat ABSENCE pinned", () =
|
||||
expect(creates[0]!.tags).not.toContain("unrouted-signal");
|
||||
});
|
||||
|
||||
it("#2990: a superseded root records the arrival thread without gate correlation; the latest root only correlates", () => {
|
||||
const map = new ThreadSeatMap(mapDb(), clock);
|
||||
const conversation = { channel: "C1", human: "mike@external", seat: "dev-driver@v-openrig-build", conversationId: "q-reparked" };
|
||||
map.open({ ...conversation, threadTs: "1.1" });
|
||||
map.open({ ...conversation, threadTs: "2.1" });
|
||||
const route = makeThreadRouteResolver({ map, unroutedDestination: "orch-lead@v-openrig-build" });
|
||||
|
||||
const oldRoot = route(ev("900.1", "1.1"));
|
||||
expect(oldRoot).not.toHaveProperty("correlationQitemId");
|
||||
expect(oldRoot).toMatchObject({
|
||||
destination: conversation.seat,
|
||||
tags: expect.arrayContaining(["reply-to:q-reparked"]),
|
||||
arrivalThread: { threadTs: "1.1", channel: "C1" },
|
||||
});
|
||||
|
||||
const latestRoot = route(ev("900.2", "2.1"));
|
||||
expect(latestRoot).toMatchObject({ correlationQitemId: conversation.conversationId });
|
||||
expect(latestRoot).not.toHaveProperty("arrivalThread");
|
||||
});
|
||||
|
||||
it("CLOSED thread: STILL exactly the mapped seat (closure is not a routing black hole)", async () => {
|
||||
const { router, creates } = harness();
|
||||
await router.route(ev("2.1", "T-CLOSED"));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ActivityGrantStore } from "../src/domain/activity-grants.js";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
@@ -160,6 +161,40 @@ describe("StartupOrchestrator", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it.each([false, true])("revokes its exact run grant on later readiness failure, replacement=%s", async replace => {
|
||||
const seed = seedSession();
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(seed.nodeId);
|
||||
db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('bound',?,'r01-impl','%1')").run(seed.nodeId);
|
||||
const grants = new ActivityGrantStore(db);
|
||||
const issue = (runId: string) => grants.issue({ nodeId: seed.nodeId, generation: sessionRegistry.currentOccupantTenure(seed.nodeId)!.generationUuid,
|
||||
issuedBy: "launch", run: { runId, sessionId: seed.sessionId, binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
|
||||
const first = issue("first"); grants.activate(first.grantId);
|
||||
let replacement: ReturnType<typeof issue> | undefined;
|
||||
const adapter = mockAdapter({ runtime: "pi", launchHarness: vi.fn(async () => ({ ok: true, activityGrantId: first.grantId })),
|
||||
checkReady: vi.fn(async () => { if (replace) { replacement = issue("replacement"); grants.activate(replacement.grantId); } throw new Error("readiness unavailable"); }) });
|
||||
const result = await createOrchestrator().startNode(makeInput(seed, { adapter,
|
||||
binding: { ...makeBinding(), nodeId: seed.nodeId, tmuxPane: "%1" } }));
|
||||
expect(result.startupStatus).toBe("failed");
|
||||
expect(grants.get(first.grantId)?.revokedAt).not.toBeNull();
|
||||
if (replacement) expect(grants.standing(replacement.grantId)).toBe("current");
|
||||
});
|
||||
|
||||
it("revokes its run when context delivery reveals a definitive readiness gate", async () => {
|
||||
const seed = seedSession();
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(seed.nodeId);
|
||||
db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('bound',?,'r01-impl','%1')").run(seed.nodeId);
|
||||
const grants = new ActivityGrantStore(db);
|
||||
const grant = grants.issue({ nodeId: seed.nodeId, generation: sessionRegistry.currentOccupantTenure(seed.nodeId)!.generationUuid,
|
||||
issuedBy: "launch", run: { runId: "first", sessionId: seed.sessionId, binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
|
||||
grants.activate(grant.grantId);
|
||||
const adapter = mockAdapter({ runtime: "pi", launchHarness: vi.fn(async () => ({ ok: true, activityGrantId: grant.grantId })),
|
||||
checkReady: vi.fn().mockResolvedValueOnce({ ready: true }).mockResolvedValue({ ready: false, code: "login_required", reason: "login needed" }) });
|
||||
const result = await createOrchestrator().startNode(makeInput(seed, { adapter, startupActions: [makeAction({ type: "send_text", value: "startup text" })],
|
||||
binding: { ...makeBinding(), nodeId: seed.nodeId, tmuxPane: "%1" } }));
|
||||
expect(result.startupStatus).toBe("attention_required");
|
||||
expect(grants.get(grant.grantId)?.revokedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("community contract: launch surfaces undelivered startup files without placing or pasting them", async () => {
|
||||
const root = nodeFs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), "community-startup-files-"));
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ActivityGrantStore } from "../src/domain/activity-grants.js";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
@@ -63,6 +64,38 @@ describe("SuccessorSessionLauncher", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("revokes the staged attempt if its separate readiness probe fails", async () => {
|
||||
db.prepare("INSERT INTO rigs (id,name) VALUES ('rig-bound','bound')").run();
|
||||
db.prepare("INSERT INTO nodes (id,rig_id,logical_id,runtime) VALUES ('node-1','rig-bound','dev.impl','codex')").run();
|
||||
const grants = new ActivityGrantStore(db);
|
||||
const issue = (runId: string) => grants.issue({ nodeId: "node-1", generation: "reserved", issuedBy: "launch",
|
||||
run: { runId, binding: { runtime: "pi", runner: 0, pane: "%7", panePid: 101 } } });
|
||||
const first = issue("staged"), unrelated = issue("other-staged");
|
||||
launchHarness.mockResolvedValue({ ok: true, activityGrantId: first.grantId });
|
||||
checkReady.mockRejectedValue(new Error("probe unavailable"));
|
||||
expect(await launcher().createSuccessor({ node: { id: "node-1", runtime: "codex", cwd: "/w" },
|
||||
departingSessionName: "dev-impl@rig" })).toMatchObject({ ok: false, code: "successor_readiness_failed" });
|
||||
expect(grants.standing(first.grantId)).toBe("revoked");
|
||||
expect(grants.standing(unrelated.grantId)).toBe("pending");
|
||||
});
|
||||
|
||||
it("carries its exact issuance to abandoned cutover cleanup without revoking another pending run", async () => {
|
||||
db.prepare("INSERT INTO rigs (id,name) VALUES ('rig-bound','bound')").run();
|
||||
db.prepare("INSERT INTO nodes (id,rig_id,logical_id,runtime) VALUES ('node-1','rig-bound','dev.impl','codex')").run();
|
||||
const grants = new ActivityGrantStore(db);
|
||||
const issue = (runId: string) => grants.issue({ nodeId: "node-1", generation: "reserved", issuedBy: "launch",
|
||||
run: { runId, binding: { runtime: "pi", runner: 0, pane: "%7", panePid: 101 } } });
|
||||
const first = issue("ready-staged"), unrelated = issue("other-staged");
|
||||
launchHarness.mockResolvedValue({ ok: true, activityGrantId: first.grantId });
|
||||
const subject = launcher();
|
||||
const result = await subject.createSuccessor({ node: { id: "node-1", runtime: "codex", cwd: "/w" }, departingSessionName: "dev-impl@rig" });
|
||||
expect(result).toMatchObject({ ok: true, activityGrantId: first.grantId });
|
||||
if (!result.ok) throw new Error("successor did not start");
|
||||
await subject.cleanup(result.tmuxSession, result.discoveredId, result.activityGrantId);
|
||||
expect(grants.standing(first.grantId)).toBe("revoked");
|
||||
expect(grants.standing(unrelated.grantId)).toBe("pending");
|
||||
});
|
||||
|
||||
it("the successor's respawned pane gets the seat's declared env, and the declared names are recorded", async () => {
|
||||
const res = await launcher().createSuccessor({
|
||||
node: { id: "node-1", runtime: "codex", cwd: "/w", seatEnv: { MODE: "fast", REGION: "eu" } },
|
||||
|
||||
@@ -131,6 +131,16 @@ describe("terminal-runtime send framing", () => {
|
||||
expect(f.executions).toEqual([{ status: 0, stdout: OUTPUT, stderr: "" }]);
|
||||
});
|
||||
|
||||
it("sends exact text to a terminal whose physical session has a different name", async () => {
|
||||
const f = fixture("terminal", false);
|
||||
f.registry.updateBinding(f.node.id, { tmuxSession: "shell-physical" });
|
||||
db.prepare("UPDATE sessions SET session_name = ? WHERE id = ?").run("shell-physical", f.session.id);
|
||||
const original = wrapSendBody("operator@framing", f.name, BODY);
|
||||
const result = await f.transport.send(f.name, original, { exactText: BODY });
|
||||
expect(result).toMatchObject({ ok: true, envelopeOmitted: true });
|
||||
expect(f.pasted).toEqual([BODY]);
|
||||
});
|
||||
|
||||
it("direct HTTP explicit interaction keeps text when exactText is also supplied", async () => {
|
||||
const f = fixture("terminal", false);
|
||||
const app = new Hono();
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
const WINDOWS = [
|
||||
["s1@rig", 1, 1790000100], ["s2@rig", 0, 1790000999], ["s2@rig", 1, 1790000050], ["s3@rig", 1, 0], ["s4@rig", 1, 1790000200],
|
||||
] as const;
|
||||
const PANES = [["%1", 4242, "node"], ["%2", 5151, "zsh"], ["%3", 0, ""], ["%4", 7777, "codex"]] as const;
|
||||
const PANES = [["%1", 4242, "node"], ["%2", 5151, "zsh"], ["%3", 0, ""], ["%4", 7777, "codex"], ["%8", 8888, "node"]] as const;
|
||||
const unquote = (t: string) => t.replace(/^'(.*)'$/, "$1");
|
||||
function fakeExec() {
|
||||
const calls: string[] = [];
|
||||
@@ -21,6 +21,12 @@ function fakeExec() {
|
||||
calls.push(cmd);
|
||||
if (cmd.startsWith("tmux list-windows -a")) return WINDOWS.map(([s, a, t]) => `${a}|${t}|${s}`).join("\n") + "\n";
|
||||
if (cmd.startsWith("tmux list-panes -a")) return PANES.map(([p, pid, c]) => `${p}|${pid}|0|${c}`).join("\n") + "\n";
|
||||
const census = cmd.match(/^tmux list-panes -s -t '=(s[1-4])@rig:' /);
|
||||
if (census) {
|
||||
// s3's registered %3 is gone; %8 is its remaining live pane. s4 has %4, not the bound %9.
|
||||
const id = census[1] === "s3" ? "%8" : `%${census[1]!.slice(1)}`;
|
||||
return `${id}|0|/tmp|80|24|1\n`;
|
||||
}
|
||||
const m = cmd.match(/^tmux display-message -p -t (\S+) ["']#\{(\w+)\}["']$/);
|
||||
if (!m) throw new Error(`unexpected: ${cmd}`);
|
||||
const target = unquote(m[1]!), field = m[2]!;
|
||||
@@ -174,11 +180,14 @@ describe("SeatIdentityReconciler with the batch", () => {
|
||||
});
|
||||
|
||||
async function reconcile(batched: boolean) {
|
||||
const db = createFullTestDb(); seed(db, SEATS.slice(0, 4));
|
||||
const db = createFullTestDb();
|
||||
// Exercise pane-read parity without selecting the separate Claude native-process proof path.
|
||||
seed(db, SEATS.slice(0, 4).map(([node, session, pane]): [string, string, string, string] => [node, session, pane, "pi"]));
|
||||
const { exec, calls } = fakeExec(); const real = new TmuxAdapter(exec as never);
|
||||
const tmux = {
|
||||
listSessions: async () => ["s1@rig", "s2@rig", "s3@rig", "s4@rig"].map((name) => ({ name })) as never,
|
||||
getPanePid: (p: string) => real.getPanePid(p), getPaneCommand: (p: string) => real.getPaneCommand(p),
|
||||
listPanes: (s: string, allWindows?: boolean) => real.listPanes(s, allWindows),
|
||||
...(batched ? { readAllPaneProcesses: () => real.readAllPaneProcesses() } : {}),
|
||||
};
|
||||
await new SeatIdentityReconciler({ db, tmux, now: () => new Date("2026-09-30T07:00:00Z") }).reconcileAll();
|
||||
@@ -196,6 +205,7 @@ describe("SeatIdentityReconciler with the batch", () => {
|
||||
expect(per.out.n3?.verdict).toBe("pane_missing");
|
||||
expect(per.calls.filter((c) => c.startsWith("tmux display-message"))).toHaveLength(6); // 4 pids + 2 commands (no command read once the pid is gone)
|
||||
expect(bat.calls.filter((c) => c.startsWith("tmux list-panes -a"))).toHaveLength(1);
|
||||
expect(bat.calls.filter((c) => c.startsWith("tmux list-panes -s"))).toHaveLength(2);
|
||||
// only the misses (%3 without a pid, %9 unknown) are read per pane
|
||||
expect(bat.calls.filter((c) => c.startsWith("tmux display-message")).map((c) => unquote(c.match(/-t (\S+)/)![1]!)).sort()).toEqual(["%3", "%9"]);
|
||||
});
|
||||
|
||||
@@ -149,6 +149,19 @@ describe("start-up wake recovery and seats with nothing to receive a wake", () =
|
||||
expect({ attempt: t.repo.getById(u!)!.lastNudgeAttempt, transitions: t.transitions(u!) }).toEqual(before);
|
||||
});
|
||||
|
||||
it("does not send the first wake to a terminal addressed by its canonical name", async () => {
|
||||
const t = team();
|
||||
t.db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime) VALUES ('node-u', 'rig-1', 'dev.u', 'terminal')").run();
|
||||
t.db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES ('sess-u', 'node-u', 'u-phys', 'running')").run();
|
||||
t.db.prepare("INSERT INTO bindings (id, node_id, attachment_type, tmux_session) VALUES ('binding-u', 'node-u', 'tmux', 'u-phys')").run();
|
||||
t.repo.attachTransport({ send: t.send });
|
||||
t.live.add("dev-u@rig1");
|
||||
const id = (await t.repo.create({ sourceSession: "dev-a@rig1", destinationSession: "dev-u@rig1", body: "work" })).qitemId;
|
||||
await vi.waitFor(() => expect(t.state(id)).toBe("failed"));
|
||||
expect(t.repo.getById(id)!.lastNudgeResult).toMatch(/^failed:terminal-seat:/);
|
||||
expect(t.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replays the failed wake once the seat is an agent again (#2939)", async () => {
|
||||
const t = team();
|
||||
terminalSeat(t);
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("(i-c) wiring pin — startup injects the real target-generation resolv
|
||||
it("passes resolveTargetGeneration to the WatchdogPolicyEngine, wired to the live occupant-generation", () => {
|
||||
expect(src).toMatch(/resolveTargetGeneration:\s*\(s\)\s*=>\s*\{\s*const target = resolveGuardTarget\(db,\s*s\);\s*return target \? target\.occupant \?\? null : resolveExternalCliOccupant\(db,\s*s\);\s*\}/);
|
||||
// Without it a context watch on an ambiguous name ends for good instead of measuring again once it resolves.
|
||||
expect(src).toMatch(/targetNameCarried:\s*\(s\)\s*=>\s*sessionRegistry\.currentOccupantGenerationForSession\(s\)\s*!==\s*null/);
|
||||
expect(src).toMatch(/targetNameCarried:\s*\(s\)\s*=>\s*sessionRegistry\.carriesSessionName\(s\)/);
|
||||
});
|
||||
|
||||
it("passes the queue's terminal-timer resolver to the pre-delivery seam", () => {
|
||||
|
||||
Reference in new issue
Block a user