mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 01:41:39 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2c4fddc4a | ||
|
|
8fca221aab | ||
|
|
0b60f8da4a | ||
|
|
fbca740dd1 | ||
|
|
522d635cdf | ||
|
|
8834943465 | ||
|
|
b9a4390b96 | ||
|
|
c3eb2f73e5 | ||
|
|
d942e12954 | ||
|
|
c1783989c8 | ||
|
|
551fb0e2f7 | ||
|
|
d1839dfcf1 | ||
|
|
2b929b3d76 | ||
|
|
b28dcd8f21 | ||
|
|
da9ef8273c | ||
|
|
16ac9bfabd | ||
|
|
94f7b27ac1 | ||
|
|
3c0fee3e2d | ||
|
|
978e95fbff | ||
|
|
b78894736d | ||
|
|
cca17f54d5 |
No files matched your search
+2
-1
@@ -174,7 +174,8 @@ stand-in follows.
|
||||
|
||||
## [0.7.7]
|
||||
|
||||
**Not yet published.** 0.7.7 is a separate reliability patch for 0.7.6. Its three
|
||||
Published 2026-11-02 as `@openrig/[email protected]`, built from the commit tagged
|
||||
`v0.7.7` (`f3d8bac4`). 0.7.7 is a separate reliability patch for 0.7.6. Its three
|
||||
fixes are also in 0.7.8.
|
||||
|
||||
OpenRig 0.7.7 is 0.7.6 with three reliability fixes for managed Claude seats: an
|
||||
|
||||
@@ -741,6 +741,32 @@ rig uses it, so it stays until then. To remove it sooner, by hand:
|
||||
3. Keep a copy of the file, remove only those lines, and compare the result with
|
||||
the copy: nothing else in the file should differ.
|
||||
|
||||
**The kernel's own folder.** A newly created default kernel works in
|
||||
`<workspace.root>/kernel`, beside your projects (`<workspace.root>/projects` by
|
||||
default) rather than above them. Claude reads `CLAUDE.md` from a seat's folder
|
||||
and from every folder above it, so a kernel working in the workspace root put its
|
||||
guidance into every Claude project seat below it. If that folder can't be made,
|
||||
the new kernel works in the workspace root, as before.
|
||||
|
||||
A kernel that already exists keeps its folders, usually `workspace.root`.
|
||||
Upgrading moves no seat, changes no restored conversation and removes no file.
|
||||
To move an existing kernel yourself, if you want to:
|
||||
|
||||
1. Save the new folder for each kernel seat with `rig seat set-cwd <seat> --cwd
|
||||
<workspace.root>/kernel --reason <text>`, using the absolute path. It creates
|
||||
the folder ([details](#change-an-existing-seats-working-directory)).
|
||||
2. Choose when each seat adopts it. A seat keeps its current folder until it
|
||||
starts again, and the new start doesn't continue its current native
|
||||
conversation: `rig seat launch <seat> --fresh --stop --reason <text>` starts a
|
||||
blank one, and `rig handover <seat> --source rebuild --reason <text>` carries
|
||||
durable context into a new one. Nothing does this for you.
|
||||
3. Tidy the old `<workspace.root>/CLAUDE.md` (or `AGENTS.md`) by hand, once no
|
||||
running rig's seat still works in that folder: a seat there still reads those
|
||||
blocks, and `rig down` checks the same before it removes them. Remove only
|
||||
OpenRig's managed blocks, each from its `<!-- BEGIN OpenRig MANAGED BLOCK: … -->`
|
||||
line through its `END` line, as in the steps above, and keep everything else,
|
||||
including your own text. Don't move or delete the whole file.
|
||||
|
||||
**Separate project context from role**
|
||||
|
||||
Don't put project documentation inside the role guidance. The role is about the agent's function; project context is about what the agent is working on. Use rig-level or pod-level startup files for project context.
|
||||
|
||||
@@ -5,7 +5,7 @@ It runs in the seat's pane, hosts `dsh --profile acp` over ACP on stdio, and doe
|
||||
section:
|
||||
- **identity:** the launch record, written once the conversation is open;
|
||||
- **one prompt per block:** each submitted block becomes one `session/prompt`, using bracketed paste in a TTY;
|
||||
- **turn boundaries:** reported as activity hooks;
|
||||
- **turn boundaries:** reported as activity hooks, with a working beat every 10 s through a long, quiet turn;
|
||||
- **cancel:** `/abort` or Ctrl-C sends `session/cancel`, and a turn reads cancelled only when dsh says so;
|
||||
- **permissions:** each request goes to a person through OpenRig;
|
||||
- **resume:** it reopens the exact conversation, or says honestly that it couldn't.
|
||||
@@ -37,6 +37,11 @@ The runner submits each request to OpenRig, which asks one person:
|
||||
|
||||
That person answers one of the options dsh offered, and the runner gives that answer to the same live request, once.
|
||||
|
||||
**When the person answers in their own words** rather than with a choice, a yes or a no, OpenRig permits by default and
|
||||
passes their words back as `said`. The runner gives dsh its option as usual, shows the words in the pane, and queues
|
||||
them like any other message, so dsh gets them after the current turn and after any message already waiting. The words
|
||||
are never the answer.
|
||||
|
||||
**When no one can be asked, the request is permitted** with dsh's own allow option: `allow_once` when dsh offers it,
|
||||
else `allow_always`. That's OpenRig's default. The runner does the same when OpenRig can't answer: an older OpenRig
|
||||
without this path, an OpenRig it can't reach, or a refusal other than `--deny`.
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
//
|
||||
// It follows docs/reference/harness-runner-contract.md: it hosts `dsh --profile acp` over ACP on stdio, writes the
|
||||
// launch record once the conversation is open, turns each submitted block into one prompt, reports turn boundaries as
|
||||
// activity hooks, cancels on /abort or Ctrl-C, and sends permission requests to OpenRig: a person answers, or OpenRig
|
||||
// permits when no one can be asked, and so does the runner when OpenRig can't answer (refusing and holding needs input
|
||||
// only when the seat opted in to refusal). Copy it and maintain it as your own.
|
||||
// activity hooks with a working beat through a long, quiet turn, cancels on /abort or Ctrl-C, and sends permission
|
||||
// requests to OpenRig: a person answers, or OpenRig permits when no one can be asked, and so does the runner when OpenRig
|
||||
// can't answer (refusing and holding needs input only when the seat opted in to refusal). Copy it and maintain it as your
|
||||
// own.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
||||
@@ -19,6 +20,9 @@ const RUNNER = "openrig-dsh-runner 1";
|
||||
const NAME = process.env.OPENRIG_RUNTIME || "dsh"; // the preset's name: OpenRig passes it, and checks the record against it
|
||||
const LIVENESS_SECONDS = 60;
|
||||
const BEAT_MS = 30_000;
|
||||
// The working beat (contract section 4): every 10 s, more often than half its 30 s lifetime.
|
||||
const WORK_LIVENESS_SECONDS = 30;
|
||||
const WORK_BEAT_MS = 10_000;
|
||||
const ACTIVE_EVERY_MS = 15_000;
|
||||
const POST_TIMEOUT_MS = 2_000;
|
||||
const WAIT_SECONDS = 25;
|
||||
@@ -253,13 +257,17 @@ async function main() {
|
||||
const child = spawn(env.OPENRIG_DSH || "dsh", ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] });
|
||||
child.stdin.on("error", () => {});
|
||||
const peer = new AgentPeer(child, (line) => say(`[dsh] ${line}`));
|
||||
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0 };
|
||||
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0, asking: 0, waiting: 0, resolvedAsk: null };
|
||||
const inflight = new Set();
|
||||
const track = (promise) => { inflight.add(promise); promise.finally(() => inflight.delete(promise)); return promise; };
|
||||
let agentExited = false;
|
||||
let stopping = false;
|
||||
let heartbeat;
|
||||
let workBeat;
|
||||
let tty = false;
|
||||
// The turn may read working: it runs, nothing holds needs input, no permission request is being submitted or waits for its
|
||||
// permission_resolved, and dsh is alive. The beat and the agent's own reports both go by it.
|
||||
const working = () => state.running && !state.holding && state.asking === 0 && !state.resolvedAsk && !agentExited;
|
||||
|
||||
const restoreTty = () => {
|
||||
if (!tty) return;
|
||||
@@ -269,6 +277,7 @@ async function main() {
|
||||
};
|
||||
const finish = async (code) => {
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(workBeat);
|
||||
restoreTty();
|
||||
await Promise.race([Promise.allSettled([...inflight, activity.flushed()]), sleep(2_000)]);
|
||||
process.exit(code);
|
||||
@@ -299,7 +308,7 @@ async function main() {
|
||||
peer.onNotification("session/update", (params) => {
|
||||
if (params.sessionId !== state.sessionId || !params.update) return;
|
||||
const update = params.update;
|
||||
const reporting = state.running && !state.holding; // while needs input is held, nothing may read working
|
||||
const reporting = working();
|
||||
if (update.sessionUpdate === "agent_message_chunk") {
|
||||
if (update.content?.type === "text") process.stdout.write(update.content.text);
|
||||
if (reporting && Date.now() - state.lastActive >= ACTIVE_EVERY_MS) { state.lastActive = Date.now(); activity.post("active", "agent_message"); }
|
||||
@@ -346,10 +355,17 @@ async function main() {
|
||||
const allow = options.find((option) => option.kind === "allow_once") ?? options.find((option) => option.kind === "allow_always");
|
||||
const permit = (why) => {
|
||||
if (!allow) return refuse(`${why}, and the agent offered no allow option`);
|
||||
state.holding = false;
|
||||
state.holding = state.refused || state.waiting > 0; // another ask may still wait on a person
|
||||
say(`[permission] permitted: ${why} (${allow.optionId})`);
|
||||
return { outcome: { outcome: "selected", optionId: allow.optionId } };
|
||||
};
|
||||
// A person who answered in their own words: OpenRig passes them back as `said`. They join the prompt queue like any
|
||||
// message, so dsh gets them after this turn and after any message already waiting. They're never the answer.
|
||||
const passOn = (said) => {
|
||||
if (typeof said !== "string" || !said.trim()) return;
|
||||
say(`[permission] the person also wrote: ${firstLine(said)}`);
|
||||
submit(`The person who answered your permission request (${toolCall.title}) also wrote:\n\n${said}`);
|
||||
};
|
||||
if (!askId) {
|
||||
if (submitted.body?.code === "denied_by_seat") return refuse("the seat's operator chose to refuse its permission requests");
|
||||
if (NOT_LIVE.includes(submitted.body?.code)) {
|
||||
@@ -360,7 +376,12 @@ async function main() {
|
||||
: submitted.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't ask anyone (${submitted.body?.code ?? submitted.status})`);
|
||||
}
|
||||
state.holding = true; // OpenRig recorded the seat's permission prompt when it opened the ask
|
||||
state.waiting += 1;
|
||||
say(`[permission] OpenRig asked a person (${askId}); waiting for their answer`);
|
||||
// This ask no longer waits on a person; the seat still needs input while another one does, or after its own refusal.
|
||||
const stopWaiting = () => { state.waiting -= 1; state.holding = state.refused || state.waiting > 0; };
|
||||
// The agent got an answer or a permit for this ask: once nothing else waits, permission_resolved says the turn goes on.
|
||||
const resolved = () => { state.resolvedAsk = { turn, askId }; };
|
||||
return new Promise((resolve) => {
|
||||
let done = false; // the agent has its answer
|
||||
let ended = false; // OpenRig was told the request is over
|
||||
@@ -371,7 +392,7 @@ async function main() {
|
||||
withdraw(askId, reason);
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (!state.refused) state.holding = false; // the turn ended the person's ask: no one is still being asked
|
||||
stopWaiting(); // the turn ended the person's ask
|
||||
resolve(CANCELLED);
|
||||
} };
|
||||
state.asks.add(ask);
|
||||
@@ -387,18 +408,23 @@ async function main() {
|
||||
// A person's option, or OpenRig's permit when they closed it without one.
|
||||
if ((answer.state === "answered" || answer.state === "permitted") && offered(answer.optionId) && live()) {
|
||||
state.asks.delete(ask);
|
||||
state.holding = false;
|
||||
stopWaiting();
|
||||
say(`[permission] ${answer.state}: ${answer.optionId}`);
|
||||
resolve({ outcome: { outcome: "selected", optionId: answer.optionId } });
|
||||
resolved();
|
||||
passOn(answer.said);
|
||||
} else if (live() && (read.status === 200 ? !ENDED.includes(answer.state) : !NOT_LIVE.includes(answer.code))) {
|
||||
// OpenRig stopped answering, or answered in a way the contract doesn't list: nobody can be asked. The ask stays
|
||||
// tracked, so the turn's end still withdraws it.
|
||||
stopWaiting();
|
||||
resolve(permit(read.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't answer (${answer.code ?? read.status})`));
|
||||
if (!state.refused) resolved(); // permitted, not refused for want of an allow option
|
||||
} else {
|
||||
state.asks.delete(ask);
|
||||
state.holding = false; // a person said no, or the request is over: no one is still waiting
|
||||
stopWaiting(); // a person said no, or the request is over
|
||||
say(`[permission] not answered (${answer.reason ?? answer.state ?? answer.code ?? read.status}); answered cancelled`);
|
||||
resolve(CANCELLED);
|
||||
passOn(answer.said);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -411,9 +437,19 @@ async function main() {
|
||||
const options = Array.isArray(params.options) ? params.options : [];
|
||||
say(`\n[permission] dsh asks: ${toolCall.title} (${toolCall.kind}); options ${options.map((option) => option.optionId).join(", ")}`);
|
||||
if (!state.running || state.cancelRequestedAt) return CANCELLED;
|
||||
return askOwner(requestId, toolCall, options);
|
||||
state.asking += 1; // no beat while a permission request is open: it may be waiting on a person
|
||||
return askOwner(requestId, toolCall, options).finally(() => { state.asking -= 1; setImmediate(settle); });
|
||||
});
|
||||
|
||||
// Once nothing waits on a person or is being submitted, the seat holds no refusal of its own and dsh is alive, say the turn
|
||||
// goes on. It goes out after the agent has its answer, and before the next beat.
|
||||
const settle = () => {
|
||||
const done = state.resolvedAsk;
|
||||
if (!done || state.holding || state.asking > 0) return;
|
||||
state.resolvedAsk = null;
|
||||
if (state.running && state.turn === done.turn && !agentExited) activity.post("active", "permission_resolved", { turnId: String(done.turn), askId: done.askId });
|
||||
};
|
||||
|
||||
const abort = () => {
|
||||
if (!state.running) { say("[openrig-dsh] no turn is running"); return; }
|
||||
if (state.cancelRequestedAt) return;
|
||||
@@ -429,6 +465,8 @@ async function main() {
|
||||
state.turn += 1;
|
||||
state.holding = false;
|
||||
state.refused = false;
|
||||
state.waiting = 0;
|
||||
state.resolvedAsk = null;
|
||||
state.cancelRequestedAt = null;
|
||||
state.lastActive = 0;
|
||||
const turnId = String(state.turn);
|
||||
@@ -522,6 +560,12 @@ async function main() {
|
||||
say("[openrig-dsh] each submitted message is one prompt; /abort or Ctrl-C cancels the running turn");
|
||||
|
||||
heartbeat = setInterval(() => { if (!state.running && !state.holding && !agentExited) postIdle("idle_heartbeat"); }, BEAT_MS);
|
||||
// While a turn runs and nothing waits on a person: the turn is still outstanding and dsh is alive. Not progress.
|
||||
workBeat = setInterval(() => {
|
||||
if (working()) {
|
||||
activity.post("active", "liveness", { turnId: String(state.turn), livenessSeconds: WORK_LIVENESS_SECONDS });
|
||||
}
|
||||
}, WORK_BEAT_MS);
|
||||
for (const signal of ["SIGHUP", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
if (state.running && !state.cancelRequestedAt) peer.notify("session/cancel", { sessionId: state.sessionId });
|
||||
|
||||
@@ -55,7 +55,8 @@ together in that home; it needs its own working sign-in and doesn't relocate `~/
|
||||
> - Codex: hooks, workspace trust and team-selected config fragments (including MCP) in the selected
|
||||
> `CODEX_HOME/config.toml`, default `~/.codex/config.toml`. These affect other Codex sessions using that home.
|
||||
> - Claude Code: workspace trust in `~/.claude.json`, and activity hooks in the project's
|
||||
> `.claude/settings.local.json`, plus a status line unless you already have your own.
|
||||
> `.claude/settings.local.json`, plus a status line unless you already have your own. `rig down` takes the hooks
|
||||
> and status line back out once no Claude seat uses that directory.
|
||||
> - Discovery skills go in `~/.claude/skills` and `~/.agents/skills`.
|
||||
> - Each seat's `CLAUDE.md` or `AGENTS.md` gets OpenRig's managed blocks (culture, identity, onboarding). One more
|
||||
> line, pointing an agent at its seat skills, is added only on a yes: saved at setup, or set where the daemon runs
|
||||
|
||||
@@ -104,6 +104,8 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
|
||||
|---|---|---|
|
||||
| You gave the agent the prompt for a submitted message | `UserPromptSubmit` / `accepted`, `turnId` = your prompt number | working |
|
||||
| The agent is working (message chunks, tool calls) | `active` / `agent_message`; `PreToolUse` / `<tool kind>` | working |
|
||||
| Optional working beat, from 0.7.9: the turn is still outstanding and the agent alive, with nothing new to report (a long tool or model call) | `active` / `liveness`, with `turnId`, `sequence` and `livenessSeconds` | working while beats keep coming |
|
||||
| From 0.7.9: you delivered an answer or permit to a request OpenRig had shown as needing input, and the agent continues the same turn (section 7) | `active` / `permission_resolved`, with `turnId` and `askId` | working |
|
||||
| You refused a permission request (section 7) | `Notification` / `permission_prompt` | needs input |
|
||||
| The turn ended | `Stop` / its stop reason (`end_turn`, `cancelled`, `max_tokens`, `refusal`) | idle. While you hold attention (section 7), don't post it: OpenRig reads any non-exit `Stop` as idle and clears needs input |
|
||||
| Optional idle heartbeat | `Stop` / `idle_heartbeat`, with `livenessSeconds` | idle while beats keep coming |
|
||||
@@ -114,6 +116,41 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
|
||||
idle `Stop`, the turn's end as well as each beat. OpenRig stops reading the seat idle after 2 × `livenessSeconds` without
|
||||
a newer idle report. Without it, an idle report holds until your next post, even if your runner has died. Never send a
|
||||
non-exit `Stop` or a beat while holding permission attention.
|
||||
- **Working evidence lasts 15 seconds.** A working post (`UserPromptSubmit`, `active`, `PreToolUse`) decides the seat's
|
||||
activity for 15 s. After that the seat reads from its other evidence: the screen can still show work, but it never
|
||||
reads a runner seat idle, so with nothing else the seat reads unknown.
|
||||
- **The working beat (from 0.7.9)** keeps a long, quiet turn reading working. It says only that this turn's prompt is
|
||||
outstanding and the agent process you started is alive. It isn't progress.
|
||||
- **When to beat:** only while a prompt is outstanding and the agent is alive, more often than every
|
||||
`livenessSeconds` ÷ 2.
|
||||
- **When to stop:** stop at once on the turn's `Stop`, on a permission request that needs a person, or on the agent's
|
||||
exit, and post that boundary first.
|
||||
- **`livenessSeconds`** is how long one beat counts as working. OpenRig clamps it to 5-60 s, and uses 30 s when a beat
|
||||
has none. After that the beat stops counting, and the seat reads from its other evidence, as a working post's does.
|
||||
- **A beat counts only when all of these hold:**
|
||||
- it carries `generation`, equal to the seat's live occupant generation, and so did the `UserPromptSubmit` /
|
||||
`accepted` post that opened its turn;
|
||||
- its `turnId` is your latest `UserPromptSubmit` / `accepted` turn, and you haven't posted that turn's `Stop` or an
|
||||
exit;
|
||||
- it carries a `sequence`, greater than every earlier post's in the same generation that OpenRig counted, beats
|
||||
included;
|
||||
- the seat has no attention outstanding, such as a permission request waiting on a person.
|
||||
|
||||
So number every post with one `sequence` that rises across your whole runner process, beats included. OpenRig
|
||||
orders your posts by `occurredAt`, then `sequence`. A post older than one OpenRig already counted changes nothing:
|
||||
a late `Stop` for an earlier turn doesn't end the current one, and an older beat doesn't count. A post without a
|
||||
valid `occurredAt` is dated when it arrives, by OpenRig's clock, so it can read older than it is when your clock
|
||||
runs ahead. Give every post its `occurredAt`.
|
||||
- **After you deliver an answer or permit** to a request OpenRig had shown as needing input (section 7; it created an
|
||||
ask, and a permit at submit opens none), post `active` / `permission_resolved` with the turn's `turnId` and the
|
||||
`askId`, before your next beat. That clears the seat's attention. So does any other working or idle post OpenRig
|
||||
counts, and an operator's `rig seat clear-attention`. OpenRig doesn't match `permission_resolved` to its `askId`.
|
||||
Beats are ignored until attention clears.
|
||||
- **A beat that fails any of these is ignored.** The seat reads as it would without it. A beat never starts or extends
|
||||
a turn, never clears attention, and never stands in for a `Stop`.
|
||||
- **OpenRig keeps what it needs to judge beats in memory.** After a daemon restart, a beat already counted keeps its
|
||||
lifetime, but new beats are ignored until your next accepted prompt, and the seat reads as it would without them.
|
||||
- **A runner that sends no beats** behaves as before.
|
||||
- **Exit:** post `Stop` / `<preset name>_exited` only when you observe the agent's process exit.
|
||||
|
||||
- **The screen still counts,** but only as evidence of work. On its own it never reads a runner seat idle.
|
||||
@@ -252,11 +289,15 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
|
||||
`refused` (with `reason`: `said_no` when the person said no and the agent offered no reject option, or
|
||||
`no_allow_offered` when it was closed without a choice and the agent offered no allow option), `withdrawn` or
|
||||
`stale`.
|
||||
- **From 0.7.9, `said`,** with `permitted` or `refused` (`no_allow_offered`), is the person's own words, when they
|
||||
answered in words that were neither a choice nor a plain yes or no. It never changes the outcome.
|
||||
- **A seat grant reads and withdraws only its own launch's requests.** Once its occupant is replaced, the grant is
|
||||
refused (`409 activity_grant_stale`), even during a read's wait.
|
||||
3. **Deliver** `{"outcome": {"outcome": "selected", "optionId": "<optionId>"}}` to the agent once, and only while that
|
||||
request is still its live one. For `refused`, `withdrawn` or `stale`, answer `cancelled` and hold no attention: no
|
||||
person is still waiting. A refusal of the read itself follows the refusals below.
|
||||
person is still waiting. A refusal of the read itself follows the refusals below. From 0.7.9, when the read carries
|
||||
`said`, queue those words for the agent as an ordinary prompt: they reach it after the current turn, behind any prompt
|
||||
already waiting. They're never the answer. A runner that drops them still keeps this contract.
|
||||
4. **Withdraw** when the turn is cancelled or ends, or the agent exits:
|
||||
`POST <base>/api/activity/permission-requests/<askId>/withdraw` with
|
||||
`{"reason": "turn_cancelled" | "turn_ended" | "exited"}`.
|
||||
|
||||
@@ -107,6 +107,38 @@ OpenRig doesn't observe a hosted member between its calls, so it never reads idl
|
||||
|
||||
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
|
||||
|
||||
## Assistants connected through a grant service
|
||||
|
||||
With `rig mcp serve --http --grant-service <url>`, an outside service answers each connection's key with its grant
|
||||
(`openrig.grant/v1`): the seat it acts as, its rigs, and the object/action pairs it may use. Each pair offers one tool,
|
||||
and nothing else is offered:
|
||||
|
||||
| Pair | Tool |
|
||||
|---|---|
|
||||
| `queue`/`create`, `queue`/`show`, `queue`/`list` | `rig_queue_create`, `rig_queue_show`, `rig_queue_list` |
|
||||
| `queue`/`claim`, `queue`/`update` | `rig_queue_claim`, `rig_queue_update` |
|
||||
| `team`/`list` | `rig_team` |
|
||||
| `artifact`/`read` | `rig_artifact_read` |
|
||||
|
||||
- **One seat per connected assistant:** the service gives each connection its own seat and keeps it across that
|
||||
connection's reconnects. The seat must be one of the rig's nodes; a node created without a launch is enough. The caller
|
||||
is exactly that seat.
|
||||
- **What it reaches:**
|
||||
- It hands work to its rigs' other seats, never to a person or another host's seat.
|
||||
- It reads the items it sent and the items addressed to it from those seats, never the team's internal items.
|
||||
- With `queue`/`claim` and `queue`/`update`, it claims and closes only items addressed to its own seat that it can read,
|
||||
and waits (blocked) only on an item it names and can read. Its answers hide what reading the item hides.
|
||||
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
|
||||
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
|
||||
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
|
||||
- **Messages are items:** a message is an item the receiver reads and closes, and a reply is an item back to its sender.
|
||||
- **What the sender can tell, from the item it reads:**
|
||||
- **Queued:** the item exists and is `pending` with no claim.
|
||||
- **Picked up:** the receiver's claim (`in-progress`).
|
||||
- **Answered:** closed `done`, with the answer in `resolution`.
|
||||
- Reading or listing an item records nothing, and nothing wakes an assistant, so an item stays queued until its receiver
|
||||
claims it.
|
||||
|
||||
## Limits today
|
||||
|
||||
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
|
||||
|
||||
@@ -105,7 +105,8 @@ The daemon starts and boots the kernel with the coding agents you're signed in t
|
||||
**What starting OpenRig changes, for the coding agents you use** (paths are the usual defaults):
|
||||
- **For Codex:** hooks and trust in `~/.codex/config.toml`, when OpenRig or a Codex agent starts.
|
||||
- **For Claude Code:** workspace trust in `~/.claude.json`, and activity hooks in the project's
|
||||
`.claude/settings.local.json`, plus a status line unless you already have your own.
|
||||
`.claude/settings.local.json`, plus a status line unless you already have your own. `rig down` takes the hooks and
|
||||
status line back out once no Claude seat uses that directory.
|
||||
- **For both:** small discovery skills in `~/.claude/skills` and `~/.agents/skills`.
|
||||
- **Kernel access:** by default the kernel gets broader permissions than a project team gets by default. On Codex
|
||||
that's full access with no approval prompts. On Claude Code, its file tools, web fetches and operational commands,
|
||||
|
||||
@@ -18,7 +18,7 @@ node check-runner.mjs --runner 'my-runner --agent-command {agent}' --name my-har
|
||||
- **`--wait-ms 20000`** waits longer for each step (default 8000), for a runner whose agent takes a while to start. The check's shorter settling times, and the fake agent's own delays and holds, grow in proportion.
|
||||
- **`--pass-env NAME`** passes one more variable through. Otherwise the runner gets only `PATH`, `HOME`, `USER`,
|
||||
`SHELL`, `LANG` and `TMPDIR`, plus the OpenRig variables.
|
||||
- **The exit status** is 0 when nothing fails, 1 when a case fails, and 2 for a usage or setup problem. An advisory result (7c) never changes it.
|
||||
- **The exit status** is 0 when nothing fails, 1 when a case fails, and 2 for a usage or setup problem. An advisory result (4c, 7c) never changes it.
|
||||
|
||||
In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/docs/reference/runner-conformance/`.
|
||||
|
||||
@@ -51,11 +51,13 @@ In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/doc
|
||||
| 1b | 3, 4 | A handshake that fails at `initialize` or `session/new` (an error or an exit) writes no conversation in a launch record, and posts no accepted prompt or idle report |
|
||||
| 2 | 5, 6 | One pasted block becomes exactly one `session/prompt`, byte for byte. A line reading `/abort` inside a block isn't control. A block sent mid-turn waits for the next turn. A block that is exactly `/abort`, or Ctrl-C outside a paste, sends one `session/cancel`. `Stop/cancelled` is posted only when the agent itself returns `cancelled` |
|
||||
| 3 | 4 | `UserPromptSubmit/accepted` with a `turnId`, then `Stop/end_turn`. Every post carries the contract's fields, the hook token, this seat's identity and a rising `sequence`. `livenessSeconds`, when given, is from 1 to 3600 |
|
||||
| 3b | 4 | **Optional, from 0.7.9; skipped when the runner sends no beat.** In a long, quiet turn, each `active/liveness` beat names the accepted turn, `sequence` rises with the runner's own `occurredAt` across every post, beats come more often than every `livenessSeconds` ÷ 2, and none follows the turn's `Stop`. While a person is asked, no beat is posted; after the answer, `active/permission_resolved` names the turn and the ask, before any beat. A permit at submit opens no ask, so the quiet turn's beats go on |
|
||||
| 4 | 7 | When no one can be asked, the request is permitted: an OpenRig without the answered path (404) at once, and one the runner can't reach, each get the agent's `allow_once` option, never its `allow_always` while `allow_once` is offered. The turn then reports as usual, with no `permission_prompt`. Only `denied_by_seat`, or a request with no allow option, is answered at once with a refusal, never an allow option. `Notification/permission_prompt` is then posted, and for `denied_by_seat` the pane shows what was asked (the tool call's title). No idle `Stop` or beat is posted while that attention is held, even when `/abort` then cancels the turn. The next prompt is accepted as usual |
|
||||
| 4b | 7 | The live request is submitted with the hook token, its `requestId` as the agent's JSON-RPC id, passed unchanged, the accepted prompt's `turnId`, this conversation and generation, and the agent's options as offered. The runner doesn't post `permission_prompt` itself for a created ask, and each read waits at most 25 s. The chosen option, allow or deny, reaches the agent once, and the turn then reports as usual. OpenRig's `permitted`, at submit or at a read, reaches the agent the same way. A refusal is read by its code, never its status: one section 7's table marks "permits" (`duplicate_request`, `activity_grant_pending`, `activity_grant_revoked`), a read that gets no answer, and a status or read state the section doesn't list (500, `deferred`) each give the agent its `allow_once` option. A refusal saying the request isn't the current occupant's (`stale_generation` at submit, `activity_grant_stale` on a read), and a `refused` (`said_no`), stale or withdrawn read, give the agent `cancelled`, and the turn then ends as usual, with nothing held. With no answer, the request stays pending with no idle report, and a pasted message isn't an answer. `/abort` withdraws it with `turn_cancelled`, and an answer after that isn't delivered. A turn's end or the agent's exit while it waits withdraws it with `turn_ended` or `exited`. Once the ask is withdrawn no person is still waiting, so the turn's end is reported as usual. An ask the runner permitted after a read got no answer, or an unlisted one, may still be open, so it's withdrawn at the turn's end too |
|
||||
| 4b | 7 | The live request is submitted with the hook token, its `requestId` as the agent's JSON-RPC id, passed unchanged, the accepted prompt's `turnId`, this conversation and generation, and the agent's options as offered. The runner doesn't post `permission_prompt` itself for a created ask, and each read waits at most 25 s. The chosen option, allow or deny, reaches the agent once, and the turn then reports as usual. OpenRig's `permitted`, at submit or at a read, reaches the agent the same way, and a read that carries the person's own words (`said`) still gives the agent the read's option. A refusal is read by its code, never its status: one section 7's table marks "permits" (`duplicate_request`, `activity_grant_pending`, `activity_grant_revoked`), a read that gets no answer, and a status or read state the section doesn't list (500, `deferred`) each give the agent its `allow_once` option. A refusal saying the request isn't the current occupant's (`stale_generation` at submit, `activity_grant_stale` on a read), and a `refused` (`said_no`), stale or withdrawn read, give the agent `cancelled`, and the turn then ends as usual, with nothing held. With no answer, the request stays pending with no idle report after the turn's accepted prompt, and a pasted message isn't an answer. `/abort` withdraws it with `turn_cancelled`, and an answer after that isn't delivered. A turn's end or the agent's exit while it waits withdraws it with `turn_ended` or `exited`. Once the ask is withdrawn no person is still waiting, so the turn's end is reported as usual. An ask the runner permitted after a read got no answer, or an unlisted one, may still be open, so it's withdrawn at the turn's end too |
|
||||
| 4c | 7 | **Advisory.** A read's `said`, the words of a person who answered in words, is queued to the agent as a prompt, after the turn and after any prompt already waiting. Section 7 lets a runner drop them and still keep the contract, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status |
|
||||
| 5 | 4 | Every post carries `OPENRIG_OCCUPANT_GENERATION` |
|
||||
| 6 | 8 | A resume asks the agent to reopen `OPENRIG_RESUME_REF` in the launch's working folder, and records that conversation. The agent's own not-found becomes `error: "missing"`. Any other failure is neither missing nor resumed. No case starts a new conversation |
|
||||
| 7 | 4 | An agent that dies mid-turn gets `Stop/<preset>_exited`, and no idle report after it |
|
||||
| 7 | 4 | An agent that dies mid-turn gets `Stop/<preset>_exited`, and no idle report for that turn. Posts are taken in the runner's own order, as OpenRig orders them (`occurredAt` when it's a time string, otherwise the post's arrival, then `sequence` when both are positive whole numbers), so an idle report from before the turn began doesn't count, however late it arrives |
|
||||
| 7b | 4 | A failed `session/prompt` posts `Notification/runtime_error`, not a permission request |
|
||||
| 7c | 9 | **Advisory.** Closing the pane ends the agent process. Section 9 marks this duty as proposed, and lets a runner list in `limits` what may keep running, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status. The fake agent outlives a hangup here, so the runner has to end it |
|
||||
| 8 | 7 | An unknown request from the agent gets JSON-RPC `-32601`, and the next prompt still goes through |
|
||||
|
||||
@@ -133,8 +133,8 @@ function askState(h, entry) {
|
||||
const script = h.asks.script;
|
||||
if (entry.withdrawn) return script.late ?? { state: "withdrawn" };
|
||||
if (script.answer && Date.now() >= entry.created + (script.answer.afterMs ?? 0)) {
|
||||
const { state, optionId, reason } = script.answer;
|
||||
return { state, ...(optionId ? { optionId } : {}), ...(reason ? { reason } : {}) };
|
||||
const { state, optionId, reason, said } = script.answer;
|
||||
return { state, ...(optionId ? { optionId } : {}), ...(reason ? { reason } : {}), ...(said ? { said } : {}) };
|
||||
}
|
||||
return { state: "pending" };
|
||||
}
|
||||
@@ -242,6 +242,19 @@ async function launch(label, { scenario = {}, env = {}, asks } = {}) {
|
||||
// An ordinary send arrives inside an envelope, so a line in it that reads /abort is still just text.
|
||||
const message = (h, body) => `From: checker@runner-check\nTo: ${h.session}\n---\n${body}`;
|
||||
const nonExitStops = (h, after = 0) => h.hooks("Stop").filter((p) => p.at >= after && !String(p.body?.subtype ?? "").endsWith("_exited"));
|
||||
// Whether post `a` comes before post `b` in the runner's own order, as OpenRig orders them: by `occurredAt` when it's a
|
||||
// non-blank string that reads as a time, otherwise by its arrival; then by `sequence`, when both carry a positive whole number.
|
||||
const postedAt = (p) => {
|
||||
const value = p?.body?.occurredAt;
|
||||
const t = typeof value === "string" && value.trim() ? Date.parse(value) : NaN;
|
||||
return Number.isFinite(t) ? t : p?.at ?? 0;
|
||||
};
|
||||
const sequenceOf = (p) => { const value = p?.body?.sequence; return Number.isSafeInteger(value) && value > 0 ? value : null; };
|
||||
const precedes = (a, b) => postedAt(a) !== postedAt(b) ? postedAt(a) < postedAt(b)
|
||||
: sequenceOf(a) !== null && sequenceOf(b) !== null && sequenceOf(a) < sequenceOf(b);
|
||||
// Non-exit Stops the runner posted after `post` in that order. An idle report from before the turn began (a runner may report
|
||||
// idle at startup) is never one made during it, however late its POST arrives.
|
||||
const stopsAfter = (h, post) => nonExitStops(h).filter((p) => !post || !precedes(p, post));
|
||||
const ready = (h, ms) => waitFor(() => h.record()?.nativeRef && h.record(), ms);
|
||||
|
||||
function result(id, title, { proposed = false } = {}) {
|
||||
@@ -364,6 +377,65 @@ const cases = {
|
||||
return r;
|
||||
},
|
||||
|
||||
// The optional working beat (section 4). When a runner posts active/liveness in a quiet turn, each beat names the open
|
||||
// turn it belongs to, keeps the post order, comes often enough for its own lifetime, stops at the turn's end, and never
|
||||
// runs while a person is asked; after an answer, active/permission_resolved comes before the next beat. Order is read
|
||||
// from the runner's own sequence and times, so a slow delivery never reads as a broken runner.
|
||||
async "3b"() {
|
||||
const r = result("3b", "Working beat (optional): beats name their open turn, keep order, and stop at its end and while a person is asked");
|
||||
const quiet = scale(22000);
|
||||
const seqOf = (p) => Number(p?.body?.sequence);
|
||||
const at = (p) => Date.parse(p.body.occurredAt);
|
||||
const h = await launch("3b", { scenario: { turns: [{ silent: true, holdMs: quiet }, {}] } });
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
h.paste(message(h, "a long, quiet turn"));
|
||||
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0]);
|
||||
const end = await waitFor(() => h.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
|
||||
await sleep(scale(11000)); // room for a beat a runner wrongly sends after the Stop
|
||||
const beats = h.hooks("active", "liveness");
|
||||
if (!beats.length) return r.skip("the runner sends no working beat in a quiet turn; the beat is optional (section 4)");
|
||||
r.check("each beat names the turn it belongs to", accepted && beats.every((b) => b.body.turnId === accepted.body.turnId),
|
||||
`turnIds: ${beats.map((b) => b.body.turnId).join(", ")}`);
|
||||
// In the runner's own time order, not delivery's: a post delivered late still has to fit where the runner sent it.
|
||||
const byTime = (a, b) => at(a) - at(b) || seqOf(a) - seqOf(b);
|
||||
const timed = h.posts().slice().sort(byTime);
|
||||
r.check("sequence rises with the runner's own time across every post, beats included",
|
||||
timed.every((p, i) => Number.isFinite(seqOf(p)) && Number.isFinite(at(p)) && (i === 0 || seqOf(p) > seqOf(timed[i - 1]))),
|
||||
`sequences in occurredAt order: ${timed.map(seqOf).join(", ")}`);
|
||||
const life = (b) => (Number.isFinite(b.body.livenessSeconds) ? b.body.livenessSeconds : 30);
|
||||
const inTurn = beats.filter((b) => !end || seqOf(b) < seqOf(end)).sort(byTime);
|
||||
const gaps = inTurn.map((b, i) => at(b) - at(i === 0 ? accepted : inTurn[i - 1]));
|
||||
r.check("beats come more often than every livenessSeconds / 2", inTurn.every((b, i) => gaps[i] <= life(b) * 500 + 1000),
|
||||
`gaps by the runner's own times: ${gaps.join(", ")} ms`);
|
||||
r.check("no beat after the turn's Stop", end && beats.every((b) => seqOf(b) < seqOf(end)),
|
||||
`beats after Stop/end_turn: ${beats.filter((b) => end && seqOf(b) > seqOf(end)).length}`);
|
||||
|
||||
// A person is asked first, and answers after a while; the agent then carries on, quietly, in the same turn.
|
||||
const p = await launch("3b-asked", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
|
||||
asks: { answer: { afterMs: scale(12000), state: "answered", optionId: "allow-once" } } });
|
||||
if (!r.check("asked: the runner opens its conversation", await ready(p), "no launch record")) return r;
|
||||
p.paste(message(p, "edit the notes"));
|
||||
const submitted = await waitFor(() => p.permissionCalls("POST").find((c) => c.path === "/api/activity/permission-requests"), scale(5000));
|
||||
if (!r.check("asked: the request is submitted", submitted, "no submit")) return r;
|
||||
await waitFor(() => p.hooks("Stop", "end_turn")[0], scale(12000) + quiet + opts.waitMs);
|
||||
const resolvedPost = p.hooks("active", "permission_resolved")[0];
|
||||
const before = p.hooks("active", "liveness").filter((b) => at(b) > submitted.at && (!resolvedPost || seqOf(b) < seqOf(resolvedPost)));
|
||||
r.check("asked: no beat while the ask waits on a person", before.length === 0, `${before.length} beats while it waited`);
|
||||
r.check("asked: after the answer, active/permission_resolved names the turn and the ask, before any beat",
|
||||
resolvedPost && resolvedPost.body.askId && resolvedPost.body.turnId === p.hooks("UserPromptSubmit", "accepted")[0]?.body.turnId,
|
||||
resolvedPost ? `permission_resolved: ${JSON.stringify({ turnId: resolvedPost.body.turnId, askId: resolvedPost.body.askId })}` : "no permission_resolved");
|
||||
|
||||
// OpenRig permits at submit when no one can be asked: no ask opens, so nothing waits and the beats go on.
|
||||
const q = await launch("3b-permitted", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
|
||||
asks: { submit: { permitted: true } } });
|
||||
if (!r.check("permitted at submit: the runner opens its conversation", await ready(q), "no launch record")) return r;
|
||||
q.paste(message(q, "edit the notes"));
|
||||
const qEnd = await waitFor(() => q.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
|
||||
const qBeats = q.hooks("active", "liveness").filter((b) => !qEnd || seqOf(b) < seqOf(qEnd));
|
||||
r.check("permitted at submit: the quiet turn's beats go on, with no ask to resolve", qBeats.length > 0, "no beat in the quiet turn after the permit");
|
||||
return r;
|
||||
},
|
||||
|
||||
// Section 7's default is permit: when no one can be asked, the agent gets its own allow option at once, allow_once before
|
||||
// allow_always. Only the seat's opt-in refusal (denied_by_seat), or a request with no allow option, is refused at once,
|
||||
// shown, and held as needs input until the next prompt, even when the turn is cancelled.
|
||||
@@ -449,7 +521,8 @@ const cases = {
|
||||
const submitted = await waitFor(() => submits(h)[0], scale(3000));
|
||||
if (!r.check(`${label}: the request is submitted to /api/activity/permission-requests`, submitted, "no submit")) continue;
|
||||
const asked = h.sent("session/request_permission")[0];
|
||||
const accepted = h.hooks("UserPromptSubmit", "accepted")[0];
|
||||
// The submit isn't one of the runner's ordered activity posts, so it can arrive before the accepted prompt does.
|
||||
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0], scale(3000));
|
||||
const sent = submitted.body ?? {};
|
||||
if (label === "allow") {
|
||||
r.check("the submit passes OpenRig's checks", h.asks.submitted[0]?.problems.length === 0, h.asks.submitted[0]?.problems.join("; "));
|
||||
@@ -486,6 +559,8 @@ const cases = {
|
||||
const outcomes = [
|
||||
["permitted at submit", { submit: { permitted: true } }, "allow-once"],
|
||||
["permitted at read", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once" } }, "allow-once"],
|
||||
// A person's own words come back beside the outcome, and never change the option delivered.
|
||||
["permitted at read, with the person's words", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said: "only the notes file" } }, "allow-once"],
|
||||
["duplicate_request", { submit: { status: 409, code: "duplicate_request" } }, "allow-once"],
|
||||
["activity_grant_pending", { submit: { status: 409, code: "activity_grant_pending" } }, "allow-once"],
|
||||
["activity_grant_revoked", { submit: { status: 401, code: "activity_grant_revoked" } }, "allow-once"],
|
||||
@@ -530,7 +605,8 @@ const cases = {
|
||||
await sleep(scale(1500));
|
||||
r.check("pending: the runner keeps reading the ask", h.permissionCalls("GET").length >= 1, "no read of the ask");
|
||||
r.check("pending: nothing is delivered while no one has answered", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
|
||||
r.check("pending: no idle Stop while it waits", nonExitStops(h).length === 0, nonExitStops(h).map((p) => p.body.subtype).join(", "));
|
||||
const turn = h.hooks("UserPromptSubmit", "accepted").at(-1);
|
||||
r.check("pending: no idle Stop while it waits", stopsAfter(h, turn).length === 0, stopsAfter(h, turn).map((p) => p.body.subtype).join(", "));
|
||||
h.paste(message(h, "allow it"));
|
||||
await sleep(scale(800));
|
||||
r.check("pending: a pasted message isn't an answer", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
|
||||
@@ -573,6 +649,22 @@ const cases = {
|
||||
return r;
|
||||
},
|
||||
|
||||
// Section 7: a person who answers in their own words gets them passed on. OpenRig carries them as the read's `said`, and
|
||||
// a runner queues them for the agent like any other prompt, after the turn, never as the answer. A runner that drops
|
||||
// them still keeps the contract, so the result is advisory.
|
||||
async "4c"() {
|
||||
const r = result("4c", "The person's words: a read's said is queued to the agent as a prompt (advisory)", { proposed: true });
|
||||
const said = "Only the notes file, please. Leave the rest alone.";
|
||||
const h = await launch("4c", { scenario: { turns: [{ permission: true }, {}] },
|
||||
asks: { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said } } });
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
h.paste(message(h, "edit the notes"));
|
||||
if (!r.check("the first turn ends", await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(6000)), "no Stop/end_turn")) return r;
|
||||
const later = await waitFor(() => h.prompts().slice(1).find((prompt) => prompt?.includes(said)), scale(4000));
|
||||
r.check("the person's words are queued to the agent as a prompt, after the turn", later, `prompts: ${JSON.stringify(h.prompts())}`);
|
||||
return r;
|
||||
},
|
||||
|
||||
async "5"() {
|
||||
const r = result("5", "Generation: every post carries the launch's occupant generation");
|
||||
const h = await launch("5");
|
||||
@@ -630,7 +722,10 @@ const cases = {
|
||||
if (!r.check("the agent exits mid-turn", died, "the agent didn't exit")) return r;
|
||||
const exited = await waitFor(() => h.hooks("Stop", `${opts.name}_exited`)[0], scale(4000));
|
||||
r.check(`Stop/${opts.name}_exited is posted`, exited, `posts: ${h.posts().map((p) => `${p.body?.hookEvent}/${p.body?.subtype}`).join(", ")}`);
|
||||
r.check("no idle Stop after the exit", nonExitStops(h, died.at).length === 0, nonExitStops(h, died.at).map((p) => p.body.subtype).join(", "));
|
||||
// Any idle report for the turn the agent died in counts, before or after the exit; one from before the turn doesn't.
|
||||
const turn = h.hooks("UserPromptSubmit", "accepted")[0];
|
||||
const idle = turn ? stopsAfter(h, turn) : nonExitStops(h, died.at);
|
||||
r.check("no idle report for the turn it died in", idle.length === 0, idle.map((p) => p.body.subtype).join(", "));
|
||||
return r;
|
||||
},
|
||||
|
||||
@@ -686,7 +781,7 @@ const cases = {
|
||||
},
|
||||
};
|
||||
|
||||
const order = ["1", "1b", "2", "3", "4", "4b", "5", "6", "7", "7b", "7c", "8", "9"];
|
||||
const order = ["1", "1b", "2", "3", "3b", "4", "4b", "4c", "5", "6", "7", "7b", "7c", "8", "9"];
|
||||
const selected = opts.cases ?? order;
|
||||
const unknown = selected.filter((id) => !order.includes(id));
|
||||
if (unknown.length) { console.error(`unknown case ${unknown.join(", ")}; cases are ${order.join(", ")}`); process.exit(2); }
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
// stubborn keep running after a hangup or after input closes; only SIGTERM or SIGKILL ends it
|
||||
// turns one entry per `session/prompt`, in order (the last one repeats):
|
||||
// holdMs keep the turn open this long; `session/cancel` ends it early as "cancelled"
|
||||
// silent send no message chunk for this turn, so a held turn is entirely quiet (a long tool or model call)
|
||||
// ignoreCancel on `session/cancel`, keep the turn open and end it with stopReason (default end_turn) anyway
|
||||
// permission ask `session/request_permission` first, then carry on whatever the answer. As an object:
|
||||
// options the options offered (default: allow-once, allow_once; reject-once, reject_once)
|
||||
@@ -65,7 +66,7 @@ async function prompt(id, params) {
|
||||
turn += 1;
|
||||
if (spec.exit) exit(3, `turn ${turn}`);
|
||||
if (spec.error) { fail(id, -32603, "fake agent: this turn fails"); return; }
|
||||
send({ jsonrpc: "2.0", method: "session/update",
|
||||
if (!spec.silent) send({ jsonrpc: "2.0", method: "session/update",
|
||||
params: { sessionId: params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: `turn ${turn}` } } } });
|
||||
let stopReason = spec.stopReason ?? "end_turn";
|
||||
if (spec.permission) {
|
||||
|
||||
@@ -207,7 +207,7 @@ rig slack channel-map remove pr@my-rig
|
||||
their routing also keeps the conversations separate: new messages in a seat's own channel
|
||||
still go to the inbound destination unless its entry names `--inbound`. A conversation shared
|
||||
on purpose is fine.
|
||||
- Missed-message recovery (below) covers the default channel only.
|
||||
- Missed-message recovery (below) covers the default channel and every mapped channel, except a channel relayed to another machine.
|
||||
|
||||
Invite the app to every mapped channel (`/invite @OpenRig`). `rig slack verify` checks
|
||||
membership in the default channel and every mapped channel, and reports NOT ready until the
|
||||
|
||||
@@ -62,3 +62,16 @@ export function writeTextAtomically(path: string, content: string, label = "file
|
||||
if (owned) try { unlinkSync(temporary); } catch { /* Renamed or already removed. */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a file, failing with EEXIST rather than replacing anything already at the path, even a dangling link. */
|
||||
export function writeNewText(path: string, content: string): void {
|
||||
const fd = openSync(path, "wx", 0o666);
|
||||
try {
|
||||
writeFileSync(fd, content, "utf-8");
|
||||
} catch (error) {
|
||||
try { unlinkSync(path); } catch { /* Report the write failure. */ }
|
||||
throw error;
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
@@ -227,7 +227,8 @@ function renderBroadcastResults(data: Record<string, unknown>): void {
|
||||
if (r.ok) {
|
||||
console.log(`${r.sessionName}: sent`);
|
||||
} else {
|
||||
console.log(`${r.sessionName}: FAILED — ${r.error ?? "unknown error"}`);
|
||||
// A target that resolved to no sessions has no name to report.
|
||||
console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}FAILED — ${r.error ?? "unknown error"}`);
|
||||
}
|
||||
if (typeof r.managedCompaction?.notice === "string") console.log(`Notice: ${r.managedCompaction.notice}`);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ interface TeardownResult {
|
||||
keptSkills?: string[];
|
||||
/** Managed guidance blocks a person edited, kept in their file as `<file>: <block id>`. */
|
||||
keptGuidance?: string[];
|
||||
/** Files in a Claude seat's cwd that keep OpenRig's additions because down couldn't safely change them, as `<file>: <why>`. */
|
||||
keptFiles?: string[];
|
||||
/** Seats joined with `rig attach` as an external CLI, which OpenRig doesn't stop. */
|
||||
leftRunning?: string[];
|
||||
paneOnly?: string[];
|
||||
@@ -295,6 +297,10 @@ the command warns and exits without stopping; re-run with --yes to confirm expli
|
||||
console.log(`Kept ${result.keptGuidance.length} edited OpenRig block(s) in guidance files; remove them by hand if you don't want them:`);
|
||||
for (const block of result.keptGuidance) console.log(` ${block}`);
|
||||
}
|
||||
if (result.keptFiles && result.keptFiles.length > 0) {
|
||||
console.log(`Kept ${result.keptFiles.length} file(s) that still hold OpenRig's additions; check them by hand:`);
|
||||
for (const file of result.keptFiles) console.log(` ${file}`);
|
||||
}
|
||||
|
||||
if (result.leftRunning && result.leftRunning.length > 0) {
|
||||
console.log(`Not stopped: ${result.leftRunning.join(", ")}. Joined with rig attach as an external CLI, which OpenRig doesn't stop or check.`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import nodePath from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { Command } from "commander";
|
||||
import { writeTextAtomically } from "../atomic-text-write.js";
|
||||
import { writeNewText, writeTextAtomically } from "../atomic-text-write.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
@@ -10,6 +10,8 @@ import { rigIdForHandle } from "../rig-handle.js";
|
||||
|
||||
export interface ExportDeps extends StatusDeps {
|
||||
writeFile: (path: string, content: string) => void;
|
||||
/** Must fail with EEXIST when the path is taken. */
|
||||
writeNewFile?: (path: string, content: string) => void;
|
||||
exists?: (path: string) => boolean;
|
||||
}
|
||||
|
||||
@@ -37,18 +39,35 @@ function copyCulture(file: string, root: string, outDir: string): void {
|
||||
console.log(`Copied culture to ${target}`);
|
||||
}
|
||||
|
||||
/** rig.yaml is also the name of an authored spec, and an export doesn't carry all of one (comments, a summary, startup
|
||||
* it couldn't read back). So a default export takes the first free name, created so that it can't replace a file that
|
||||
* appeared after the daemon was asked. Only a path the person named with -o may replace a file. */
|
||||
function writeDefaultExport(write: (path: string, content: string) => void, first: string, content: string): string {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const path = attempt === 1 ? first : attempt === 2 ? "rig-export.yaml" : `rig-export-${attempt - 1}.yaml`;
|
||||
try {
|
||||
write(path, content);
|
||||
if (attempt > 1) console.warn(`${first} already exists and was left unchanged. Use -o ${first} to replace it.`);
|
||||
return path;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function exportCommand(depsOverride?: ExportDeps): Command {
|
||||
const cmd = new Command("export").description("Export a rig spec as YAML");
|
||||
const getDeps = (): ExportDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
writeFile: (p, content) => writeTextAtomically(p, content, "export"),
|
||||
writeNewFile: writeNewText,
|
||||
exists: (p) => fs.existsSync(p),
|
||||
};
|
||||
|
||||
cmd
|
||||
.argument("<rig>", "Rig name or ID to export")
|
||||
.option("-o, --output <path>", "Output file path", "rig.yaml")
|
||||
.option("-o, --output <path>", "Output file path, replacing any file there. Without it, an existing rig.yaml is kept and the export goes to an unused rig-export*.yaml", "rig.yaml")
|
||||
.action(async (rigId: string, opts: { output: string }) => {
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
@@ -67,8 +86,10 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
|
||||
console.error(`Export failed: ${res.data}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
deps.writeFile(opts.output, res.data);
|
||||
console.log(`Exported to ${opts.output}`);
|
||||
const written = cmd.getOptionValueSource("output") === "default"
|
||||
? writeDefaultExport(deps.writeNewFile ?? deps.writeFile, opts.output, res.data)
|
||||
: (deps.writeFile(opts.output, res.data), opts.output);
|
||||
console.log(`Exported to ${written}`);
|
||||
for (const line of String(res.data).split("\n")) {
|
||||
if (line.startsWith("# Not carried: ")) console.log(line.slice(2));
|
||||
if (line.startsWith("# Member startup not carried for ")) console.warn(`Warning: ${line.slice(2)}`);
|
||||
@@ -92,7 +113,7 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
|
||||
const parsed = parse(res.data) as Record<string, unknown> | null;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if (parsed["name"] === "starter") {
|
||||
console.warn("Warning: this export still installs as 'starter'. Before sharing, rename the team's name in rig.yaml; changing the bundle name alone does not rename the installed rig.");
|
||||
console.warn(`Warning: this export still installs as 'starter'. Before sharing, rename the team's name in ${written}; changing the bundle name alone does not rename the installed rig.`);
|
||||
}
|
||||
const pods = parsed["pods"] as Array<{
|
||||
id?: string;
|
||||
|
||||
@@ -356,6 +356,17 @@ function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
// Priority is matched exactly where it matters (the founder view, attention ranks), so "Critical" would quietly file
|
||||
// the row as ordinary work. The daemon stores what it's given, so a documented value is normalized here. Other values
|
||||
// are kept as given: `high`, `normal` and `background` are in real use, and the mission view ranks some of them.
|
||||
const QUEUE_PRIORITIES = ["routine", "urgent", "critical"];
|
||||
function queuePriorityArg(value: string): string {
|
||||
const lower = value.toLowerCase();
|
||||
if (QUEUE_PRIORITIES.includes(lower)) return lower;
|
||||
console.error(`Note: --priority '${value}' is outside the documented choices (${QUEUE_PRIORITIES.join(", ")}); keeping it as given.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** #2276: the daemon's checks of a new row's human-facing fields (missing summary or evidence for a person,
|
||||
* notification, questions, reply-to). The queue repository raises them before the row's first write, inside the
|
||||
* create's transaction. A remote host's refusal arrives as remote_queue_write_failed instead, so it isn't one of these. */
|
||||
@@ -639,6 +650,18 @@ async function printQueueItemResult(
|
||||
// IMPL-SPEC §2.3-2.4.
|
||||
const SHOW_BODY_PREVIEW_MAX_CODEPOINTS = 512;
|
||||
|
||||
// A misspelled state ("in_progress") would otherwise read as an empty queue, not as a mistake: the list route matches
|
||||
// names as given. Each name is checked and trimmed the way the route trims it. This warns rather than refuses, so the
|
||||
// CLI's copy of the list doesn't stop it working with a newer daemon that knows more states.
|
||||
const QUEUE_STATE_NAMES = ["pending", "in-progress", "done", "blocked", "failed", "denied", "canceled", "handed-off"];
|
||||
function warnIfUnknownStates(value?: string): void {
|
||||
const names = (value ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
||||
const unknown = names.filter((name) => !QUEUE_STATE_NAMES.includes(name));
|
||||
if (unknown.length === 0) return;
|
||||
const effect = unknown.length === names.length ? " Nothing is filed under these names, so an empty result doesn't mean an empty queue." : "";
|
||||
console.error(`Warning: unrecognized --state '${unknown.join("', '")}'. Supported states: ${QUEUE_STATE_NAMES.join(", ")}.${effect}`);
|
||||
}
|
||||
|
||||
// Commander option parser. InvalidArgumentError (like positiveIntArg/enumArg) is what Commander
|
||||
// renders as its usual "option ... argument ... is invalid" error; a plain Error would escape parse.
|
||||
const WAKE_DURATION_FORMAT = "must be a positive integer with an optional s, m or h suffix (for example 90s, 15m, 168h)";
|
||||
@@ -911,7 +934,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--mission <id>", "First-class mission scope; translated to a mission:<id> tag (composes with --tags)")
|
||||
.option("--slice <id>", "First-class slice scope; translated to a slice:<id> tag (composes with --tags)")
|
||||
.option("--gate <role>", "OPR.0.4.3.16: mark this as a gate qitem; translated to a gate:<role> tag (role e.g. guard | spec-review | pm-lead | qa | human). The idle-gate watchdog reads this predicate. Composes with --tags.")
|
||||
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
|
||||
.option("--priority <priority>", "Priority: routine | urgent | critical", queuePriorityArg, "routine")
|
||||
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
|
||||
.option("--tags <tags>", "Comma-separated tags (composes with --mission and --slice)")
|
||||
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
|
||||
@@ -1385,7 +1408,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
|
||||
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
|
||||
.option("--note <text>", "Transition note")
|
||||
.option("--priority <priority>", "Override priority for the new qitem")
|
||||
.option("--priority <priority>", "Override priority for the new qitem: routine | urgent | critical", queuePriorityArg)
|
||||
.option("--tier <tier>", "Override tier for the new qitem")
|
||||
.option("--tags <tags>", "Comma-separated tags for the new qitem")
|
||||
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
|
||||
@@ -1489,7 +1512,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
|
||||
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
|
||||
.option("--note <text>", "Transition note")
|
||||
.option("--priority <priority>", "Override priority for the new qitem")
|
||||
.option("--priority <priority>", "Override priority for the new qitem: routine | urgent | critical", queuePriorityArg)
|
||||
.option("--tier <tier>", "Override tier for the new qitem")
|
||||
.option("--tags <tags>", "Comma-separated tags for the new qitem")
|
||||
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
|
||||
@@ -1772,6 +1795,7 @@ Examples:
|
||||
}
|
||||
if (opts.destination) params.set("destinationSession", opts.destination);
|
||||
if (opts.source) params.set("sourceSession", opts.source);
|
||||
warnIfUnknownStates(opts.state);
|
||||
if (opts.state) params.set("state", opts.state);
|
||||
if (opts.targetRepo) params.set("targetRepo", opts.targetRepo);
|
||||
if (opts.limit) params.set("limit", String(opts.limit));
|
||||
|
||||
@@ -998,6 +998,8 @@ effort, Codex profile and native permission choice are cleared, not mapped; two
|
||||
processes run until cutover; messages sent to the seat during the switch are
|
||||
held, not typed, and need sending again afterwards.
|
||||
Codex and Claude Code, single-pane seats only. --dry-run lists the details.
|
||||
A seat that has never started has no pane to switch: declare the other runtime for it in its rig spec and bring the team
|
||||
up again from that spec. That's a new generation of the seat, without this seat's edges, settings or queued work.
|
||||
--clear-account-home returns to configured member/rig/daemon/native selection; it does not restore historical credentials.
|
||||
It uses a fresh successor and clears an account-home token reference only after confirmation. Do not combine it with
|
||||
--account-home or a new token reference. Anthropic's global active/default profile is not isolated by CLAUDE_CONFIG_DIR.
|
||||
|
||||
@@ -62,6 +62,31 @@ function bodyStartIndex(lines: string[]): number {
|
||||
return 0; // unterminated frontmatter — treat as no frontmatter
|
||||
}
|
||||
|
||||
/** Keep source indices while hiding code examples from the progress grammar. */
|
||||
function progressLines(lines: string[]): string[] {
|
||||
let fence: { marker: string; length: number } | null = null;
|
||||
const start = bodyStartIndex(lines);
|
||||
return lines.map((line, i) => {
|
||||
if (i < start) return "";
|
||||
const match = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
|
||||
if (match) {
|
||||
const marker = match[1]![0]!;
|
||||
const length = match[1]!.length;
|
||||
const tail = match[2]!;
|
||||
if (fence) {
|
||||
if (marker === fence.marker && length >= fence.length && /^[ \t\r]*$/.test(tail)) {
|
||||
fence = null;
|
||||
return line; // A new row may be inserted after the closing fence.
|
||||
}
|
||||
} else if (marker !== "`" || !tail.includes("`")) {
|
||||
fence = { marker, length };
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return fence ? "" : line;
|
||||
});
|
||||
}
|
||||
|
||||
export interface ProgressEditResult {
|
||||
content: string;
|
||||
changed: boolean;
|
||||
@@ -92,32 +117,35 @@ export function addProgressRow(
|
||||
const newRow = `- [${statusIndicator(opts.status)}] ${text}`;
|
||||
const lines = content.split("\n");
|
||||
const start = bodyStartIndex(lines);
|
||||
const visible = progressLines(lines);
|
||||
|
||||
// Locate the target section heading.
|
||||
let sectionIdx = -1;
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const h = lines[i]!.match(HEADING_RE);
|
||||
const h = visible[i]!.match(HEADING_RE);
|
||||
if (h && h[1] === "##" && h[2]!.trim() === section) { sectionIdx = i; break; }
|
||||
}
|
||||
|
||||
if (sectionIdx === -1) {
|
||||
// Create the section, appended after the last existing content.
|
||||
let end = lines.length;
|
||||
while (end > start && lines[end - 1]!.trim() === "") end--;
|
||||
while (end > start && visible[end - 1]!.trim() === "") end--;
|
||||
const head = lines.slice(0, end);
|
||||
const rebuilt = [...head, "", `## ${section}`, "", newRow, ""].join("\n");
|
||||
const suffix = lines.slice(end);
|
||||
const rebuilt = [...head, "", `## ${section}`, "", newRow, "",
|
||||
...(suffix.some(line => line.trim() !== "") ? suffix : [])].join("\n");
|
||||
return { content: rebuilt, changed: true };
|
||||
}
|
||||
|
||||
// Section block = [sectionIdx+1, nextHeadingOrEOF).
|
||||
let blockEnd = lines.length;
|
||||
for (let i = sectionIdx + 1; i < lines.length; i++) {
|
||||
if (HEADING_RE.test(lines[i]!)) { blockEnd = i; break; }
|
||||
if (HEADING_RE.test(visible[i]!)) { blockEnd = i; break; }
|
||||
}
|
||||
|
||||
// Idempotency / conflict scan within the section.
|
||||
for (let i = sectionIdx + 1; i < blockEnd; i++) {
|
||||
const cb = lines[i]!.match(ROW_RE);
|
||||
const cb = visible[i]!.match(ROW_RE);
|
||||
if (cb && cb[3]!.trim() === text) {
|
||||
const current = indicatorToStatus(cb[2]!);
|
||||
if (current === opts.status) return { content, changed: false };
|
||||
@@ -132,7 +160,7 @@ export function addProgressRow(
|
||||
// Insert after the last non-blank line in the section block.
|
||||
let insertAt = sectionIdx + 1;
|
||||
for (let i = sectionIdx + 1; i < blockEnd; i++) {
|
||||
if (lines[i]!.trim() !== "") insertAt = i + 1;
|
||||
if (visible[i]!.trim() !== "") insertAt = i + 1;
|
||||
}
|
||||
lines.splice(insertAt, 0, newRow);
|
||||
return { content: lines.join("\n"), changed: true };
|
||||
@@ -155,10 +183,11 @@ export function setProgressRow(
|
||||
const text = opts.text.trim();
|
||||
const lines = content.split("\n");
|
||||
const start = bodyStartIndex(lines);
|
||||
const visible = progressLines(lines);
|
||||
|
||||
const matches: number[] = [];
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const cb = lines[i]!.match(ROW_RE);
|
||||
const cb = visible[i]!.match(ROW_RE);
|
||||
if (cb && cb[3]!.trim() === text) matches.push(i);
|
||||
}
|
||||
|
||||
|
||||
@@ -336,10 +336,11 @@ function scopeFor(access: McpAccess, seatOf: () => string | undefined): Scope {
|
||||
};
|
||||
}
|
||||
|
||||
/** The grant service names an external caller's seat cus-<id>-caller@<rig>. v1 marks no seat as a caller, so an item
|
||||
* from a seat of this shape is never the team's question to an external caller, and an external caller can't hand a
|
||||
* task to one. The shape never grants anything. */
|
||||
const CALLER_SHAPED = /^cus-.+-caller@[^@]+$/;
|
||||
/** The grant service names each connected assistant's seat cus-<id>-<connection>@<rig> (an existing connection's is
|
||||
* cus-<id>-caller@<rig>). v1 marks no seat as a caller, so for a grant without team/list an item from a seat of this
|
||||
* shape is never the team's question to it, and it can't hand a task to one. The shape only refuses; it never grants
|
||||
* anything, and a grant with team/list doesn't read it. */
|
||||
const CALLER_SHAPED = /^cus-[^@]+-[^@]+@[^@]+$/;
|
||||
|
||||
/** OpenRig's own tags that decide what a person asked about a row sees of its body: what Slack ingestion records about where
|
||||
* the row came from and which parts of its body OpenRig wrote (the sender's channel, the attachments block, the body's
|
||||
@@ -349,11 +350,15 @@ const CALLER_SHAPED = /^cus-.+-caller@[^@]+$/;
|
||||
const OPENRIG_PROVENANCE_TAG = /^(?:founder-slack|inbound|shared-channel|gate)$|^participant:|^slack-/;
|
||||
|
||||
/** An external grant (openrig.grant/v1): a requester, narrower than a work grant. The grant's own seat is the
|
||||
* only caller. It may hand tasks to its rigs' team seats (never a caller-shaped seat, a person or another host's
|
||||
* seat), read the items it sent and the team's questions to it (never team-internal or another caller's items), follow
|
||||
* a handed-on item only where it came back to it, and read the artifacts of its own items under its named roots. */
|
||||
* only caller. It may hand tasks to its rigs' other seats (never a person or another host's seat, and without team/list
|
||||
* never a caller-shaped seat), read the items it sent and those addressed to it from those seats (never team-internal
|
||||
* items or other callers' items between themselves), change only the items addressed to its own seat (with the claim and
|
||||
* update pairs), follow a handed-on item only where it came back to it, and read the artifacts of its own items under
|
||||
* its named roots. */
|
||||
function externalScope(access: McpAccess, seatOf: () => string | undefined): Scope {
|
||||
const rigs = new Set(access.rigs);
|
||||
// team/list makes the caller one of its rigs' participants: it may reach another connected assistant's seat.
|
||||
const participant = access.external?.objects.team?.includes("list") === true;
|
||||
const caller = (value: unknown): boolean => typeof value === "string" && value !== "" && value === seatOf();
|
||||
// A seat of one of its rigs on this instance: member@rig, never a person or another host's seat.
|
||||
const member = (value: unknown): boolean => {
|
||||
@@ -361,17 +366,20 @@ function externalScope(access: McpAccess, seatOf: () => string | undefined): Sco
|
||||
const parts = value.split("@");
|
||||
return parts.length === 2 && parts[0] !== "" && parts[1] !== "external" && rigs.has(parts[1]!);
|
||||
};
|
||||
const team = (value: unknown): boolean => member(value) && !caller(value) && !CALLER_SHAPED.test(value as string);
|
||||
const team = (value: unknown): boolean => member(value) && !caller(value) && (participant || !CALLER_SHAPED.test(value as string));
|
||||
const field = (row: unknown, name: "sourceSession" | "destinationSession"): unknown => ((row ?? {}) as Record<string, unknown>)[name];
|
||||
const item = (row: unknown): boolean => caller(field(row, "sourceSession"))
|
||||
|| (caller(field(row, "destinationSession")) && team(field(row, "sourceSession")));
|
||||
return {
|
||||
session: (value) => caller(value) || team(value),
|
||||
item,
|
||||
owner: () => false,
|
||||
// Only an item addressed to its exact seat, and one it may read, is its own to change; the daemon's claim also checks
|
||||
// the claimant.
|
||||
owner: (row) => caller(field(row, "destinationSession")) && item(row),
|
||||
rig: (name) => typeof name === "string" && rigs.has(name),
|
||||
root: (name) => typeof name === "string" && access.roots.includes(name),
|
||||
// A task goes to the team: never to the caller itself or to another caller-shaped seat, which v1 can't tell apart.
|
||||
// A task goes to another seat of its rigs: never to the caller itself, and without team/list never to another
|
||||
// caller-shaped seat, which v1 can't tell apart.
|
||||
destination: team,
|
||||
successor: caller,
|
||||
artifact: (row) => caller(field(row, "sourceSession")),
|
||||
@@ -480,7 +488,8 @@ export type McpAccess = {
|
||||
|
||||
/** The tool each external object/action pair offers. A pair not listed here offers nothing. */
|
||||
const EXTERNAL_TOOLS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
||||
queue: { create: "rig_queue_create", show: "rig_queue_show", list: "rig_queue_list" },
|
||||
queue: { create: "rig_queue_create", show: "rig_queue_show", list: "rig_queue_list", claim: "rig_queue_claim", update: "rig_queue_update" },
|
||||
team: { list: "rig_team" },
|
||||
artifact: { read: "rig_artifact_read" },
|
||||
};
|
||||
|
||||
@@ -564,6 +573,16 @@ const SHARED_INSTRUCTIONS = [
|
||||
];
|
||||
const INSTRUCTIONS = [...SHARED_INSTRUCTIONS, MEMBER_WORKFLOW].join("\n");
|
||||
const EXTERNAL_INSTRUCTIONS = SHARED_INSTRUCTIONS.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, close it done, and reply, when it needs one, with a new item to its sender. Nothing promises to wake you, so check your items again.",
|
||||
].join("\n");
|
||||
|
||||
function instructionsFor(access: McpAccess | undefined): string {
|
||||
if (!access?.external) return INSTRUCTIONS;
|
||||
const tools = externalTools(access.external.objects);
|
||||
return tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? EXTERNAL_INBOX_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a DaemonResponse to MCP tool result.
|
||||
@@ -616,7 +635,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
const server = new McpServer({
|
||||
name: "openrig",
|
||||
version: CLI_VERSION,
|
||||
}, { instructions: options.access?.external ? EXTERNAL_INSTRUCTIONS : INSTRUCTIONS });
|
||||
}, { instructions: instructionsFor(options.access) });
|
||||
const { access } = options;
|
||||
const rostersFolder = options.rostersFolder ?? (() => nodePath.join(new ConfigStore().get("workspace.root") as string, "rosters"));
|
||||
const presenceFile = options.presenceFile ?? (() => mcpPresenceFilePath());
|
||||
@@ -1345,7 +1364,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
try {
|
||||
const denied = await itemOutOfScope(qitemId);
|
||||
if (denied) return denied;
|
||||
return mapResult(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {}), undefined, { structured: true });
|
||||
const res = await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {});
|
||||
// An external caller's claimed item goes through the same projection as show and list.
|
||||
const view = externalView(scope);
|
||||
return mapResult(view && res.status < 400 ? { ...res, data: projectForExternal(res.data, view) } : res, undefined, { structured: true });
|
||||
} catch (err) {
|
||||
return caught(err);
|
||||
}
|
||||
@@ -1376,9 +1398,18 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
try {
|
||||
const denied = await itemOutOfScope(qitemId, "owner");
|
||||
if (denied) return denied;
|
||||
return mapResult(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
// An external caller waits only on an item it names and can read: parking on a person would ask them, parking on
|
||||
// another item would say whether it exists, and a park that names nothing would restore a blocker the team set.
|
||||
if (access?.external && (blockedOn || state === "blocked")) {
|
||||
const blocker = blockedOn?.startsWith("qitem-") ? await client.get(`/api/queue/${encodeURIComponent(blockedOn)}`) : null;
|
||||
if (!blocker || blocker.status >= 400 || !scope?.item(blocker.data)) return outOfScope(`Blocker ${blockedOn ?? "(none named)"}`);
|
||||
}
|
||||
const res = await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
state, closureReason, closureTarget, summary, evidenceRef, blockedOn, wakeAfterSeconds, transitionNote: note, resolution, reopen,
|
||||
}), undefined, { structured: true });
|
||||
});
|
||||
// An external caller's updated item goes through the same projection as show and list.
|
||||
const view = externalView(scope);
|
||||
return mapResult(view && res.status < 400 ? { ...res, data: projectForExternal(res.data, view) } : res, undefined, { structured: true });
|
||||
} catch (err) {
|
||||
return caught(err);
|
||||
}
|
||||
@@ -1491,6 +1522,8 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
},
|
||||
async ({ rigId }) => {
|
||||
try {
|
||||
// An external caller names one of its grant's rigs, so a lookup never answers about another workspace's rig.
|
||||
if (access?.external && !scope?.rig(rigId)) return outOfScope(`Rig ${rigId}`);
|
||||
const rig = await rigIdFor(client, rigId);
|
||||
if ("content" in rig) return rig;
|
||||
const id = encodeURIComponent(rig.id);
|
||||
@@ -1511,7 +1544,9 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
async (seat) => {
|
||||
const rows = await client.get(`/api/queue/list?${new URLSearchParams({ destinationSession: seat, compact: "1", limit: String(PRESENCE_ROWS) }).toString()}`)
|
||||
.then((res) => (res.status < 400 ? res.data : null), () => null);
|
||||
return { lastCallAt: readPresence(presenceFile())[seat]?.lastCallAt ?? null, lastWake: newestWake(rows, seat),
|
||||
// An external caller sees a ring only on an item it may read, so another pair's item isn't named.
|
||||
const visible = access?.external && Array.isArray(rows) ? rows.filter((row) => scope?.item(row)) : rows;
|
||||
return { lastCallAt: readPresence(presenceFile())[seat]?.lastCallAt ?? null, lastWake: newestWake(visible, seat),
|
||||
lastWakeScope: `the latest ring among its ${PRESENCE_ROWS} most recent rows` };
|
||||
});
|
||||
return mapResult({ status: 200, data: { ...view, hostedMembers: hosted.members, ...(hosted.note ? { hostedMembersNote: hosted.note } : {}) } }, undefined, { structured: true });
|
||||
|
||||
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { writeTextAtomically } from "../src/atomic-text-write.js";
|
||||
import { writeNewText, writeTextAtomically } from "../src/atomic-text-write.js";
|
||||
import { exportCommand } from "../src/commands/export.js";
|
||||
import { STATE_FILE } from "../src/daemon-lifecycle.js";
|
||||
import { allowFetchTarget, resetFetchAllowlist } from "./fetch-guard.js";
|
||||
@@ -35,6 +35,23 @@ it("preserves an existing export and removes staging after a partial failed writ
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("name: original\n");
|
||||
expect(fs.readdirSync(dir)).toEqual(["rig.yaml"]);
|
||||
});
|
||||
it("writeNewText creates a file but never replaces one, even a dangling link", () => {
|
||||
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
|
||||
const target = join(dir, "rig.yaml"), link = join(dir, "link.yaml");
|
||||
writeNewText(target, "name: first\n");
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("name: first\n");
|
||||
expect(() => writeNewText(target, "name: second\n")).toThrow(expect.objectContaining({ code: "EEXIST" }));
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("name: first\n");
|
||||
fs.symlinkSync(join(dir, "missing.yaml"), link);
|
||||
expect(() => writeNewText(link, "name: second\n")).toThrow(expect.objectContaining({ code: "EEXIST" }));
|
||||
expect(fs.existsSync(join(dir, "missing.yaml"))).toBe(false);
|
||||
});
|
||||
it("writeNewText leaves nothing behind after a failed write", () => {
|
||||
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
|
||||
failure.active = true;
|
||||
expect(() => writeNewText(join(dir, "rig.yaml"), "name: new\n")).toThrow(/no space left/);
|
||||
expect(fs.readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
it("retains export symlinks and target permissions on successful replacement", () => {
|
||||
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
|
||||
const target = join(dir, "saved.yaml"), link = join(dir, "rig.yaml");
|
||||
|
||||
@@ -326,6 +326,7 @@ describe("Broadcast CLI", () => {
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("No running sessions found");
|
||||
expect(output).toContain("0/0 delivered");
|
||||
expect(logs).toContainEqual(expect.stringMatching(/^FAILED — No running sessions found/));
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -344,6 +344,23 @@ describe("Down CLI", () => {
|
||||
expect(logs).toContain(" /work/project/CLAUDE.md: openrig-seat-skills.md");
|
||||
});
|
||||
|
||||
it("names each file in a Claude seat's cwd that still holds OpenRig's additions, and keeps the exit code", async () => {
|
||||
const kept = "/work/project/.openrig/context-collector.cjs: not the shipped collector's bytes, so it was kept";
|
||||
responseOverride = {
|
||||
status: 200,
|
||||
body: {
|
||||
rigId: "rig-1", sessionsKilled: 1, snapshotId: null, deleted: false, deleteBlocked: false, alreadyStopped: false, errors: [],
|
||||
keptFiles: [kept],
|
||||
},
|
||||
};
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "rig-1"]);
|
||||
});
|
||||
expect(logs).toContain("Kept 1 file(s) that still hold OpenRig's additions; check them by hand:");
|
||||
expect(logs).toContain(` ${kept}`);
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ rigName: "workshop", isUniqueName: false },
|
||||
{},
|
||||
|
||||
@@ -262,6 +262,67 @@ describe("rig export + import", () => {
|
||||
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
|
||||
});
|
||||
|
||||
describe("export: a default export never replaces a file", () => {
|
||||
const occupied = (...taken: string[]) => vi.fn((p: string) => {
|
||||
if (taken.includes(p)) throw Object.assign(new Error(`EEXIST: ${p}`), { code: "EEXIST" });
|
||||
});
|
||||
const run = async (deps: Partial<ExportDeps>, ...args: string[]) => {
|
||||
const program = new Command();
|
||||
program.addCommand(exportCommand(exportDeps(deps)));
|
||||
return captureLogs(() => program.parseAsync(["node", "rig", "export", "rig-1", ...args]));
|
||||
};
|
||||
|
||||
it("the first export writes rig.yaml", async () => {
|
||||
const writeNewFile = occupied();
|
||||
const logs = await run({ writeNewFile });
|
||||
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml"]);
|
||||
expect(logs).toContain("Exported to rig.yaml");
|
||||
expect(logs.join("\n")).not.toContain("left unchanged");
|
||||
});
|
||||
|
||||
it("an occupied rig.yaml sends the export to a sibling, and says which", async () => {
|
||||
const writeFile = vi.fn();
|
||||
const writeNewFile = occupied("rig.yaml");
|
||||
const exitCode = process.exitCode;
|
||||
const logs = await run({ writeFile, writeNewFile });
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
expect(writeNewFile).toHaveBeenLastCalledWith("rig-export.yaml", expect.any(String));
|
||||
expect(logs).toContain("Exported to rig-export.yaml");
|
||||
expect(logs.join("\n")).toContain("rig.yaml already exists and was left unchanged");
|
||||
expect(process.exitCode).toBe(exitCode);
|
||||
});
|
||||
|
||||
it("occupied siblings are kept too", async () => {
|
||||
const writeNewFile = occupied("rig.yaml", "rig-export.yaml", "rig-export-2.yaml");
|
||||
const logs = await run({ writeNewFile });
|
||||
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml", "rig-export.yaml", "rig-export-2.yaml", "rig-export-3.yaml"]);
|
||||
expect(logs).toContain("Exported to rig-export-3.yaml");
|
||||
});
|
||||
|
||||
it("-o replaces the file it names, including rig.yaml", async () => {
|
||||
const writeFile = vi.fn();
|
||||
const writeNewFile = occupied("rig.yaml");
|
||||
const logs = await run({ writeFile, writeNewFile }, "-o", "rig.yaml");
|
||||
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
|
||||
expect(writeNewFile).not.toHaveBeenCalled();
|
||||
expect(logs).toContain("Exported to rig.yaml");
|
||||
});
|
||||
|
||||
it("a real write failure stays a failure, with no success reported", async () => {
|
||||
const writeNewFile = vi.fn(() => { throw Object.assign(new Error("no space"), { code: "ENOSPC" }); });
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
try {
|
||||
const program = new Command();
|
||||
program.addCommand(exportCommand(exportDeps({ writeNewFile })));
|
||||
await expect(program.parseAsync(["node", "rig", "export", "rig-1"])).rejects.toThrow("no space");
|
||||
expect(writeNewFile).toHaveBeenCalledTimes(1);
|
||||
expect(log).not.toHaveBeenCalledWith(expect.stringContaining("Exported to"));
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("export: warns when culture_file or startup files do not resolve relative to output directory", async () => {
|
||||
const writeFile = vi.fn();
|
||||
const exists = vi.fn((p: string) => !p.includes("missing-culture.md") && !p.includes("missing-start.md") && !p.includes("optional-start.md"));
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
// Agent network, increment A: two assistants connected through the grant service (openrig.grant/v1), each with its own
|
||||
// seat, discover each other and the team, queue work to each other, claim it and return the result on the same item,
|
||||
// and come back to it without duplicates. Driven through rig mcp serve --http --grant-service and the daemon's real
|
||||
// routes; the fake grant service answers each connection's key with that connection's grant.
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { serve, type ServerType } from "@hono/node-server";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createTestApp } from "../../daemon/test/helpers/test-app.js";
|
||||
import { createDb } from "../../daemon/src/db/connection.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
|
||||
import { EventBus } from "../../daemon/src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
|
||||
import { setSelfHostId } from "../../daemon/src/domain/hosts/fanout-contract.js";
|
||||
import { startMcpHttpServer } from "../src/mcp-http.js";
|
||||
|
||||
const SCHEMA = "openrig.grant/v1";
|
||||
const INSTANCE = "core-test-01";
|
||||
const RIG = "team-a";
|
||||
const LEAD = `lead@${RIG}`;
|
||||
/** Two connections of one account: each its own key, grant and seat. */
|
||||
const A = { key: `${"8977f2dc0000aaaa1111bbbb"}.cus_VVMAexample01.${"ab".repeat(24)}`, grant: "8977f2dc0000aaaa1111bbbb", seat: `cus-vvmaexample01-chatgpt@${RIG}` };
|
||||
const B = { key: `${"8977f2dc0000aaaa2222cccc"}.cus_VVMAexample01.${"cd".repeat(24)}`, grant: "8977f2dc0000aaaa2222cccc", seat: `cus-vvmaexample01-claude@${RIG}` };
|
||||
const NETWORK = { queue: ["create", "show", "list", "claim", "update"], team: ["list"], artifact: ["read"] };
|
||||
const V1 = { queue: ["create", "show", "list"], artifact: ["read"] };
|
||||
|
||||
const active = (who: typeof A, objects: Record<string, string[]>) => ({
|
||||
schema: SCHEMA, status: "active",
|
||||
principal: { id: "cus_VVMAexample01", kind: "external_caller", label: "External caller …mple01" },
|
||||
grant: { id: who.grant, version: 1, updated_at: "2026-11-02T05:00:00.000Z", expires_at: null, cache_seconds: 0, revocation: "per_request" },
|
||||
environment: { id: RIG, core_instance: INSTANCE, studio: "https://studio.example", lead: LEAD },
|
||||
seat: who.seat, rigs: [RIG], artifact_roots: [], access_class: "work", objects,
|
||||
});
|
||||
|
||||
describe("agent network increment A: OAuth-connected assistants work with each other and the team", () => {
|
||||
let db: Database.Database;
|
||||
let home: string;
|
||||
let repo: QueueRepository;
|
||||
let daemon: ServerType;
|
||||
let daemonUrl: string;
|
||||
let setup: ReturnType<typeof createTestApp>;
|
||||
let service: http.Server;
|
||||
let serviceUrl: string;
|
||||
/** What each connection's grant names, by key; a key set to "revoked" is answered revoked. */
|
||||
let grants: Map<string, Record<string, string[]> | "revoked">;
|
||||
const closers: Array<() => Promise<void>> = [];
|
||||
const saved = { home: process.env.OPENRIG_HOME, session: process.env.OPENRIG_SESSION_NAME };
|
||||
|
||||
beforeEach(async () => {
|
||||
db = createDb(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-network-"));
|
||||
process.env.OPENRIG_HOME = home;
|
||||
process.env.OPENRIG_SESSION_NAME = "env-seat@elsewhere";
|
||||
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
|
||||
setup = createTestApp(db, { appDeps: { queueRepo: repo } });
|
||||
daemon = await new Promise<ServerType>((resolve) => { const s = serve({ fetch: setup.app.fetch, port: 0, hostname: "127.0.0.1" }, () => resolve(s)); });
|
||||
daemonUrl = `http://127.0.0.1:${(daemon.address() as AddressInfo).port}`;
|
||||
setSelfHostId(INSTANCE);
|
||||
grants = new Map([[A.key, NETWORK], [B.key, NETWORK]]);
|
||||
service = http.createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
const key = String(req.headers.authorization ?? "").replace(/^Bearer /, "");
|
||||
const who = key === A.key ? A : key === B.key ? B : null;
|
||||
const objects = who ? grants.get(who.key) : undefined;
|
||||
const body = !who || !objects ? { schema: SCHEMA, status: "unknown" }
|
||||
: objects === "revoked" ? { schema: SCHEMA, status: "revoked" } : active(who, objects);
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(body));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => service.listen(0, "127.0.0.1", resolve));
|
||||
serviceUrl = `http://127.0.0.1:${(service.address() as AddressInfo).port}/api/connector/v1/grant`;
|
||||
for (const member of ["cus-vvmaexample01-chatgpt", "cus-vvmaexample01-claude", "lead"]) seat(RIG, member);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const close of closers.splice(0).reverse()) await close();
|
||||
await new Promise<void>((resolve) => service.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => daemon.close(() => resolve()));
|
||||
setSelfHostId(null);
|
||||
db.close();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
if (saved.home === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = saved.home;
|
||||
if (saved.session === undefined) delete process.env.OPENRIG_SESSION_NAME; else process.env.OPENRIG_SESSION_NAME = saved.session;
|
||||
});
|
||||
|
||||
function seat(rigName: string, member: string) {
|
||||
const rig = setup.rigRepo.listRigs?.().find((r: { name: string }) => r.name === rigName) ?? setup.rigRepo.createRig(rigName);
|
||||
const node = setup.rigRepo.addNode(rig.id, `pod.${member}`, { role: "worker" });
|
||||
setup.sessionRegistry.registerSession(node.id, `${member}@${rigName}`);
|
||||
}
|
||||
|
||||
async function listen() {
|
||||
const served = await startMcpHttpServer({ daemonUrl, host: "127.0.0.1", port: 0, tokenFile: path.join(home, "mcp-http-tokens.json"),
|
||||
grantService: { url: serviceUrl, timeoutMs: 1_000 } });
|
||||
closers.push(() => served.close());
|
||||
return served;
|
||||
}
|
||||
|
||||
async function connect(url: string, who: typeof A) {
|
||||
const client = new Client({ name: "network-test", version: "1.0.0" });
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: { Authorization: `Bearer ${who.key}` } } }));
|
||||
closers.push(() => client.close());
|
||||
const call = async (name: string, args: Record<string, unknown> = {}) => {
|
||||
const result = await client.callTool({ name, arguments: args });
|
||||
return { isError: result.isError === true, structured: result.structuredContent as Record<string, any>, text: (result.content as Array<{ text: string }>)[0]?.text ?? "" };
|
||||
};
|
||||
return Object.assign(call, { tools: async () => (await client.listTools()).tools.map((tool) => tool.name).sort() });
|
||||
}
|
||||
|
||||
const rows = () => (db.prepare("SELECT COUNT(*) AS n FROM queue_items").get() as { n: number }).n;
|
||||
const row = (qitemId: string) => repo.getById(qitemId)!;
|
||||
const createdId = (result: { structured: Record<string, any>; text: string }) => String(result.structured?.qitemId ?? JSON.parse(result.text).qitemId);
|
||||
|
||||
it("offers claim, update and rig_team only to a grant that names them; a v1 grant keeps exactly today's four tools", async () => {
|
||||
grants.set(B.key, V1);
|
||||
const served = await listen();
|
||||
expect(await (await connect(served.url, A)).tools()).toEqual(
|
||||
["rig_artifact_read", "rig_queue_claim", "rig_queue_create", "rig_queue_list", "rig_queue_show", "rig_queue_update", "rig_team"]);
|
||||
expect(await (await connect(served.url, B)).tools()).toEqual(["rig_artifact_read", "rig_queue_create", "rig_queue_list", "rig_queue_show"]);
|
||||
});
|
||||
|
||||
it("A discovers B and the team, queues work to B; B claims it and returns the result on the same item, which A reads", async () => {
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const b = await connect(served.url, B);
|
||||
const team = await a("rig_team", { rigId: RIG });
|
||||
expect(team.isError).toBe(false);
|
||||
expect(team.text).toContain(B.seat);
|
||||
expect(team.text).toContain(LEAD);
|
||||
|
||||
const sent = await a("rig_queue_create", { destination: B.seat, body: "Critique this plan.", tags: ["review"] });
|
||||
expect(sent.isError).toBe(false);
|
||||
const qitemId = createdId(sent);
|
||||
expect(row(qitemId)).toMatchObject({ sourceSession: A.seat, destinationSession: B.seat });
|
||||
|
||||
// What A reads at each step: queued (pending, unclaimed), picked up (B's claim), answered (done with a resolution).
|
||||
// B reading or listing the item changes none of it.
|
||||
const inbox = await b("rig_queue_list", { owned: true });
|
||||
expect(inbox.text).toContain(qitemId);
|
||||
expect((await b("rig_queue_show", { qitemId })).isError).toBe(false);
|
||||
expect((await a("rig_queue_show", { qitemId })).structured.item).toMatchObject({ state: "pending", claimedAt: null });
|
||||
expect((await b("rig_queue_claim", { qitemId })).isError).toBe(false);
|
||||
expect((await a("rig_queue_show", { qitemId })).structured.item).toMatchObject({ state: "in-progress", resolution: null });
|
||||
expect((await b("rig_queue_update", { qitemId, state: "done", resolution: "Two risks; see the notes.", closureReason: "no-follow-on" })).isError).toBe(false);
|
||||
expect(row(qitemId)).toMatchObject({ state: "done", resolution: "Two risks; see the notes." });
|
||||
|
||||
const followUp = await a("rig_queue_show", { qitemId });
|
||||
expect(followUp.isError).toBe(false);
|
||||
expect(followUp.text).toContain("Two risks; see the notes.");
|
||||
expect(followUp.text).toContain(B.seat);
|
||||
});
|
||||
|
||||
it("a message is an item the receiver reads and closes, and the reply is an item back, read by the first assistant", async () => {
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const b = await connect(served.url, B);
|
||||
const message = createdId(await a("rig_queue_create", { destination: B.seat, summary: "FYI: the plan changed", body: "We're shipping the smaller fix first.", tags: ["message"] }));
|
||||
expect((await b("rig_queue_show", { qitemId: message })).text).toContain("We're shipping the smaller fix first.");
|
||||
expect((await b("rig_queue_update", { qitemId: message, state: "done", closureReason: "no-follow-on", resolution: "Read." })).isError).toBe(false);
|
||||
const reply = createdId(await b("rig_queue_create", { destination: A.seat, summary: "Re: the plan changed", body: "Agreed; I'll review the fix.", tags: ["message"] }));
|
||||
expect(row(reply)).toMatchObject({ sourceSession: B.seat, destinationSession: A.seat });
|
||||
expect((await a("rig_queue_list", { owned: true })).text).toContain(reply);
|
||||
expect((await a("rig_queue_show", { qitemId: reply })).text).toContain("Agreed; I'll review the fix.");
|
||||
});
|
||||
|
||||
it("B hands work on to the team and reads the team's result; A's task and B's task are separate items, each with its own result", async () => {
|
||||
const served = await listen();
|
||||
const b = await connect(served.url, B);
|
||||
const delegated = createdId(await b("rig_queue_create", { destination: LEAD, body: "Implement the agreed change." }));
|
||||
repo.update({ qitemId: delegated, actorSession: LEAD, state: "done", closureReason: "no-follow-on", resolution: "Merged as abc123." } as never);
|
||||
expect((await b("rig_queue_show", { qitemId: delegated })).text).toContain("Merged as abc123.");
|
||||
});
|
||||
|
||||
it("a create retried with the same qitemId after a lost reply is one item, and a new session with the same key finds the same work", async () => {
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const first = createdId(await a("rig_queue_create", { destination: B.seat, body: "Once.", qitemId: "qitem-network-retry-1" }));
|
||||
const before = rows();
|
||||
const again = createdId(await a("rig_queue_create", { destination: B.seat, body: "Once.", qitemId: "qitem-network-retry-1" }));
|
||||
expect(again).toBe(first);
|
||||
expect(rows()).toBe(before);
|
||||
const reentered = await connect(served.url, A);
|
||||
expect((await reentered("rig_queue_list", {})).text).toContain(first);
|
||||
expect(rows()).toBe(before);
|
||||
});
|
||||
|
||||
it("two assistants calling at once each act as their own seat", async () => {
|
||||
const served = await listen();
|
||||
const [a, b] = await Promise.all([connect(served.url, A), connect(served.url, B)]);
|
||||
const [fromA, fromB] = await Promise.all([
|
||||
a("rig_queue_create", { destination: LEAD, body: "from A" }),
|
||||
b("rig_queue_create", { destination: LEAD, body: "from B" }),
|
||||
]);
|
||||
expect(row(createdId(fromA)).sourceSession).toBe(A.seat);
|
||||
expect(row(createdId(fromB)).sourceSession).toBe(B.seat);
|
||||
});
|
||||
|
||||
it("each changes only items addressed to its own seat, stays in its workspace, and a revoked connection changes nothing", async () => {
|
||||
seat("team-b", "lead");
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const b = await connect(served.url, B);
|
||||
const toB = createdId(await a("rig_queue_create", { destination: B.seat, body: "for B" }));
|
||||
const toTeam = (await repo.create({ sourceSession: LEAD, destinationSession: "worker@team-a", body: "team-internal", nudge: false } as never)).qitemId;
|
||||
const toA = (await repo.create({ sourceSession: LEAD, destinationSession: A.seat, body: "for A", nudge: false } as never)).qitemId;
|
||||
const refused = { isError: true, structured: { code: "out_of_scope" } };
|
||||
// A sent B's item and may read it; the daemon refuses its claim as someone else's.
|
||||
expect((await a("rig_queue_claim", { qitemId: toB })).isError).toBe(true);
|
||||
expect(await a("rig_queue_update", { qitemId: toB, state: "done", closureReason: "no-follow-on" })).toMatchObject(refused);
|
||||
expect(await b("rig_queue_claim", { qitemId: toTeam })).toMatchObject(refused);
|
||||
expect(await b("rig_queue_claim", { qitemId: toA })).toMatchObject(refused);
|
||||
expect(await b("rig_queue_show", { qitemId: toTeam })).toMatchObject(refused);
|
||||
expect(await a("rig_queue_create", { destination: "lead@team-b", body: "other workspace" })).toMatchObject(refused);
|
||||
expect(await a("rig_queue_create", { destination: "someone@external", body: "a person" })).toMatchObject(refused);
|
||||
expect(await a("rig_team", { rigId: "team-b" })).toMatchObject(refused);
|
||||
expect(row(toB).state).toBe("pending");
|
||||
|
||||
// B waits only on an item it can read: never on a person (who would be asked) or on the team's own item.
|
||||
expect((await b("rig_queue_claim", { qitemId: toB })).isError).toBe(false);
|
||||
for (const blockedOn of ["someone@external", toTeam, "qitem-network-missing"]) {
|
||||
expect((await b("rig_queue_update", { qitemId: toB, state: "blocked", blockedOn }))).toMatchObject({ isError: true, structured: { code: "out_of_scope" } });
|
||||
}
|
||||
expect(row(toB).state).toBe("in-progress");
|
||||
const ask = createdId(await b("rig_queue_create", { destination: LEAD, body: "Which of the two plans?" }));
|
||||
expect((await b("rig_queue_update", { qitemId: toB, state: "blocked", blockedOn: ask })).isError).toBe(false);
|
||||
expect(row(toB)).toMatchObject({ state: "blocked", blockedOn: ask });
|
||||
|
||||
grants.set(B.key, "revoked");
|
||||
const before = rows();
|
||||
await expect(b("rig_queue_update", { qitemId: toB, state: "in-progress" })).rejects.toThrow();
|
||||
expect(row(toB).state).toBe("blocked");
|
||||
expect(rows()).toBe(before);
|
||||
});
|
||||
|
||||
it("claim and update answer an assistant with what show shows: a blocker or closure target it can't read stays hidden", async () => {
|
||||
const served = await listen();
|
||||
const b = await connect(served.url, B);
|
||||
const internal = (await repo.create({ sourceSession: LEAD, destinationSession: "worker@team-a", body: "team-internal", nudge: false } as never)).qitemId;
|
||||
const mine = (await repo.create({ sourceSession: LEAD, destinationSession: B.seat, body: "for B", nudge: false } as never)).qitemId;
|
||||
repo.update({ qitemId: mine, actorSession: LEAD, state: "blocked", blockedOn: internal, closureReason: "blocked_on", closureTarget: internal, transitionNote: "waiting on the team" } as never);
|
||||
const shown = await b("rig_queue_show", { qitemId: mine });
|
||||
const noted = await b("rig_queue_update", { qitemId: mine, note: "seen it" });
|
||||
const claimed = await b("rig_queue_claim", { qitemId: mine });
|
||||
for (const result of [shown, noted, claimed]) {
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.text).not.toContain(internal);
|
||||
}
|
||||
expect(row(mine).state).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("an assistant can't change an item addressed to it that it can't read: another workspace's, or another caller's under a partial grant", async () => {
|
||||
grants.set(B.key, { queue: ["create", "show", "list", "claim", "update"], artifact: ["read"] });
|
||||
const served = await listen();
|
||||
const b = await connect(served.url, B);
|
||||
const refused = { isError: true, structured: { code: "out_of_scope" } };
|
||||
const foreign = (await repo.create({ sourceSession: "lead@team-b", destinationSession: B.seat, body: "from another workspace", nudge: false } as never)).qitemId;
|
||||
const fromCaller = (await repo.create({ sourceSession: "cus-vvmaexample02-caller@team-a", destinationSession: B.seat, body: "from another caller", nudge: false } as never)).qitemId;
|
||||
for (const qitemId of [foreign, fromCaller]) {
|
||||
const before = repo.listTransitions(qitemId).length;
|
||||
expect(await b("rig_queue_update", { qitemId, note: "mine?" })).toMatchObject(refused);
|
||||
expect(await b("rig_queue_update", { qitemId, state: "done", closureReason: "no-follow-on" })).toMatchObject(refused);
|
||||
expect(row(qitemId).state).toBe("pending");
|
||||
expect(repo.listTransitions(qitemId).length).toBe(before);
|
||||
}
|
||||
});
|
||||
|
||||
it("an assistant's park names a blocker it can read: a bare re-park after a claim is refused, whether the team's blocker is open or closed", async () => {
|
||||
const served = await listen();
|
||||
const b = await connect(served.url, B);
|
||||
for (const closedAfterClaim of [false, true]) {
|
||||
const internal = (await repo.create({ sourceSession: LEAD, destinationSession: "worker@team-a", body: "team-internal", nudge: false } as never)).qitemId;
|
||||
const mine = (await repo.create({ sourceSession: LEAD, destinationSession: B.seat, body: "for B", nudge: false } as never)).qitemId;
|
||||
repo.update({ qitemId: mine, actorSession: LEAD, state: "blocked", blockedOn: internal, transitionNote: "waiting on the team" } as never);
|
||||
expect((await b("rig_queue_claim", { qitemId: mine })).isError).toBe(false);
|
||||
if (closedAfterClaim) repo.update({ qitemId: internal, actorSession: "worker@team-a", state: "done", closureReason: "no-follow-on" } as never);
|
||||
const reparked = await b("rig_queue_update", { qitemId: mine, state: "blocked" });
|
||||
expect(reparked).toMatchObject({ isError: true, structured: { code: "out_of_scope" } });
|
||||
expect(reparked.text).not.toContain(internal);
|
||||
expect(row(mine)).toMatchObject({ state: "in-progress", blockedOn: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("team discovery names a hosted member's last ring only when it's on an item the assistant may read", async () => {
|
||||
const workspace = path.join(home, "workspace");
|
||||
const savedWorkspace = process.env.OPENRIG_WORKSPACE_ROOT;
|
||||
process.env.OPENRIG_WORKSPACE_ROOT = workspace;
|
||||
try {
|
||||
fs.mkdirSync(path.join(workspace, "rosters"), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspace, "rosters", "team.json"), JSON.stringify({
|
||||
version: 1, id: "team", name: "Team", purpose: "Ship the release", curator: { seat: LEAD, host: INSTANCE }, updated_at: "2026-11-02",
|
||||
members: [{ seat: `grok@${RIG}`, host: INSTANCE, capabilities: ["web research"], engagement: ["delegate"], use_when: "Current sources", why: "Searches the web", hosted: true }],
|
||||
}));
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const ring = (qitemId: string, at: string) =>
|
||||
db.prepare("UPDATE queue_items SET last_nudge_attempt = ?, last_nudge_result = ? WHERE qitem_id = ?").run(at, "wake-accepted:webhook answered 202", qitemId);
|
||||
const fromA = (await repo.create({ sourceSession: A.seat, destinationSession: `grok@${RIG}`, body: "Find the release date.", nudge: false } as never)).qitemId;
|
||||
const internal = (await repo.create({ sourceSession: LEAD, destinationSession: `grok@${RIG}`, body: "team-internal", nudge: false } as never)).qitemId;
|
||||
ring(fromA, "2026-11-02T11:00:00.000Z");
|
||||
ring(internal, "2026-11-02T12:00:00.000Z");
|
||||
const team = await a("rig_team", { rigId: RIG });
|
||||
expect(team.isError).toBe(false);
|
||||
expect(team.structured.hostedMembers).toMatchObject([{ session: `grok@${RIG}`, presence: { lastWake: { qitemId: fromA, at: "2026-11-02T11:00:00.000Z" } } }]);
|
||||
expect(team.text).not.toContain(internal);
|
||||
} finally {
|
||||
if (savedWorkspace === undefined) delete process.env.OPENRIG_WORKSPACE_ROOT; else process.env.OPENRIG_WORKSPACE_ROOT = savedWorkspace;
|
||||
}
|
||||
});
|
||||
|
||||
it("a v1 grant keeps today's rules: no task to or from another caller's seat", async () => {
|
||||
grants.set(A.key, V1);
|
||||
grants.set(B.key, V1);
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const refused = { isError: true, structured: { code: "out_of_scope" } };
|
||||
expect(await a("rig_queue_create", { destination: B.seat, body: "v1 can't" })).toMatchObject(refused);
|
||||
const fromB = (await repo.create({ sourceSession: B.seat, destinationSession: A.seat, body: "from another caller", nudge: false } as never)).qitemId;
|
||||
expect(await a("rig_queue_show", { qitemId: fromB })).toMatchObject(refused);
|
||||
});
|
||||
});
|
||||
@@ -127,3 +127,33 @@ it("does not mistake a deeper subsection for the requested H2 section", () => {
|
||||
expect(addProgressRow(source, { section: "Rail", text: "Release verified", status: "done" }).content)
|
||||
.toBe(source + "\n## Rail\n\n- [x] Release verified\n");
|
||||
});
|
||||
|
||||
|
||||
describe("progress edits preserve fenced examples", () => {
|
||||
it.each(["```md", "~~~md", "````md", " ```md"])("sets only the real row after %s", (opening) => {
|
||||
const marker = opening.trimStart().startsWith("~") ? "~" : "`";
|
||||
const closing = marker.repeat(5);
|
||||
const example = [opening, "## Rail", "- [ ] Ship it", marker.repeat(2), "- [ ] Still an example", closing].join("\n");
|
||||
const content = `# Progress\n${example}\n## Rail\n- [ ] Ship it\n`;
|
||||
const result = setProgressRow(content, { text: "Ship it", status: "done" });
|
||||
expect(result.content).toBe(`# Progress\n${example}\n## Rail\n- [x] Ship it\n`);
|
||||
});
|
||||
|
||||
it("does not update a row that exists only in a code block", () => {
|
||||
const content = "# Progress\n## Rail\n```md\n- [ ] Example\n```\n";
|
||||
expect(() => setProgressRow(content, { text: "Example", status: "done" })).toThrow(/No progress row/);
|
||||
});
|
||||
|
||||
it("ignores example headings and duplicates when adding a row", () => {
|
||||
const example = "```md\n## Rail\n- [x] Ship it\n```";
|
||||
const content = `# Progress\n${example}\n## Rail\n${example}\n\n## Notes\nKeep this.\n`;
|
||||
const result = addProgressRow(content, { section: "Rail", text: "Ship it", status: "active" });
|
||||
expect(result.content).toBe(`# Progress\n${example}\n## Rail\n${example}\n- [ ] Ship it\n\n## Notes\nKeep this.\n`);
|
||||
});
|
||||
|
||||
it("inserts outside an unclosed example at the end of the section", () => {
|
||||
const content = "# Progress\n## Rail\n- [ ] Existing\n\n```md\n## Example\n- [ ] Ship it\n";
|
||||
const result = addProgressRow(content, { section: "Rail", text: "Ship it", status: "active" });
|
||||
expect(result.content).toBe(content.replace("- [ ] Existing", "- [ ] Existing\n- [ ] Ship it"));
|
||||
});
|
||||
});
|
||||
@@ -77,3 +77,15 @@ describe("FR-4 — verb output parses into the expected UI tree", () => {
|
||||
expect(file.counts.blocked).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it.each(["```md\n## Rail\n- [ ] Ship it\n```\n", "```md\n## Rail\n- [ ] Ship it\n"])("adds and updates a real row without editing example %j", (example) => {
|
||||
const original = `# Progress\n\n${example}`;
|
||||
const added = addProgressRow(original, { section: "Rail", text: "Ship it", status: "active" }).content;
|
||||
const updated = setProgressRow(added, { text: "Ship it", status: "done" }).content;
|
||||
expect(updated).toContain(example);
|
||||
fs.writeFileSync(path.join(root, "PROGRESS.md"), updated);
|
||||
const file = indexerFor().scan().files[0]!;
|
||||
expect(file.counts).toMatchObject({ total: 1, done: 1 });
|
||||
expect(file.rows.filter(r => r.kind === "checkbox")).toMatchObject([{ text: "Ship it", status: "done" }]);
|
||||
});
|
||||
@@ -18,8 +18,8 @@ vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
function makeQueueDeps(): { deps: QueueDeps; calls: Array<{ method: string; path: string }> } {
|
||||
const calls: Array<{ method: string; path: string }> = [];
|
||||
function makeQueueDeps(): { deps: QueueDeps; calls: Array<{ method: string; path: string; body?: Record<string, unknown> }> } {
|
||||
const calls: Array<{ method: string; path: string; body?: Record<string, unknown> }> = [];
|
||||
return {
|
||||
calls,
|
||||
deps: {
|
||||
@@ -27,7 +27,7 @@ function makeQueueDeps(): { deps: QueueDeps; calls: Array<{ method: string; path
|
||||
clientFactory: () => ({
|
||||
get: vi.fn(async (path: string) => { calls.push({ method: "GET", path }); return { status: 200, data: [] }; }),
|
||||
getText: vi.fn(async () => ({ status: 200, data: "" })),
|
||||
post: vi.fn(async (path: string) => { calls.push({ method: "POST", path }); return { status: 201, data: {} }; }),
|
||||
post: vi.fn(async (path: string, body?: Record<string, unknown>) => { calls.push({ method: "POST", path, body }); return { status: 201, data: {} }; }),
|
||||
delete: vi.fn(async () => ({ status: 204, data: null })),
|
||||
postText: vi.fn(async () => ({ status: 200, data: "" })),
|
||||
postExpectText: vi.fn(async () => ({ status: 200, data: "" })),
|
||||
@@ -64,6 +64,59 @@ describe("Slice 15 — CLI contract honesty", () => {
|
||||
expect(parsed.error.message).toMatch(/positive integer/);
|
||||
});
|
||||
|
||||
const PRIORITY_VERBS: Array<[string, string[], string]> = [
|
||||
["create", ["--source", "a@r", "--destination", "b@r", "--summary", "s", "--body", "x"], "/api/queue/create"],
|
||||
["handoff", ["qitem-x", "--to", "b@r"], "/api/queue/qitem-x/handoff"],
|
||||
["handoff-and-complete", ["qitem-x", "--to", "b@r"], "/api/queue/qitem-x/handoff-and-complete"],
|
||||
];
|
||||
|
||||
async function sentPriority(verb: string, args: string[], path: string, flag: string[]) {
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "seat@rig"); // handoff verbs act as the calling seat
|
||||
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
try {
|
||||
const { deps, calls } = makeQueueDeps();
|
||||
const run = await runCli(["queue", verb, ...args, ...flag, "--json"], { queueDeps: deps });
|
||||
const call = calls.find((c) => c.path === path);
|
||||
expect(call, JSON.stringify({ calls, run })).toBeDefined();
|
||||
return { body: call!.body!, notes: stderr.mock.calls.map((c) => c.join(" ")).filter((l) => l.includes("--priority")) };
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
stdout.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
}
|
||||
|
||||
describe.each(PRIORITY_VERBS)("queue %s --priority", (verb, args, path) => {
|
||||
it.each([["Critical", "critical"], ["URGENT", "urgent"], ["Routine", "routine"], ["critical", "critical"]])(
|
||||
"normalizes the documented value %s without a note", async (given, expected) => {
|
||||
const { body, notes } = await sentPriority(verb, args, path, ["--priority", given]);
|
||||
expect(body.priority).toBe(expected);
|
||||
expect(notes).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["high", "normal", "background", "P1-Hot"])("keeps %s as given, with one note", async (given) => {
|
||||
const { body, notes } = await sentPriority(verb, args, path, ["--priority", given]);
|
||||
expect(body.priority).toBe(given);
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]).toContain("outside the documented choices");
|
||||
expect(notes[0]).not.toContain("invalid");
|
||||
});
|
||||
});
|
||||
|
||||
it("omitting --priority keeps create's routine default, without a note", async () => {
|
||||
const [verb, args, path] = PRIORITY_VERBS[0]!;
|
||||
const { body, notes } = await sentPriority(verb, args, path, []);
|
||||
expect(body.priority).toBe("routine");
|
||||
expect(notes).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(PRIORITY_VERBS.slice(1))("omitting --priority on %s sends none, so the priority is inherited", async (verb, args, path) => {
|
||||
const { body, notes } = await sentPriority(verb, args, path, []);
|
||||
expect(body.priority).toBeUndefined();
|
||||
expect(notes).toEqual([]);
|
||||
});
|
||||
|
||||
it("without --json, the same failure is plain text on stderr (no JSON on stdout) + nonzero exit", async () => {
|
||||
const { deps } = makeQueueDeps();
|
||||
const { out, err, exitCode } = await runCli(["queue", "list", "--limit", "-1"], { queueDeps: deps });
|
||||
@@ -72,6 +125,61 @@ describe("Slice 15 — CLI contract honesty", () => {
|
||||
expect(err).toMatch(/positive integer/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["pending, in_progress", ["in_progress"], "pending, in_progress"],
|
||||
["bogus,also_bogus", ["bogus", "also_bogus"], "bogus,also_bogus"],
|
||||
])("queue list --state %j warns on stderr and still runs the query as given", async (state, unknown, sent) => {
|
||||
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
try {
|
||||
const { deps, calls } = makeQueueDeps();
|
||||
const { exitCode } = await runCli(["queue", "list", "--state", state, "--json"], { queueDeps: deps });
|
||||
expect(exitCode).toBe(0);
|
||||
const warning = stderr.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
for (const name of unknown) expect(warning).toContain(`'${name}'`);
|
||||
expect(warning).toContain("Supported states: pending, in-progress, done");
|
||||
if (state.includes("pending")) expect(warning).not.toContain("empty queue");
|
||||
else expect(warning).toContain("doesn't mean an empty queue");
|
||||
const listCalls = calls.filter((c: { path: string }) => c.path.startsWith("/api/queue/list"));
|
||||
expect(listCalls).toHaveLength(1);
|
||||
expect(new URLSearchParams(listCalls[0]!.path.split("?")[1]).get("state")).toBe(sent);
|
||||
expect(JSON.parse(stdout.mock.calls.map((c) => c.join(" ")).join("\n"))).toEqual([]);
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["pending", " pending , in-progress ", "done,handed-off,failed,denied,canceled,blocked", "pending,", " "])(
|
||||
"queue list --state %j runs without a warning", async (state) => {
|
||||
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
try {
|
||||
const { deps, calls } = makeQueueDeps();
|
||||
const { exitCode } = await runCli(["queue", "list", "--state", state, "--json"], { queueDeps: deps });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).not.toHaveBeenCalled();
|
||||
expect(calls.filter((c: { path: string }) => c.path.startsWith("/api/queue/list"))).toHaveLength(1);
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("queue list without --state sends no state filter and no warning", async () => {
|
||||
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
try {
|
||||
const { deps, calls } = makeQueueDeps();
|
||||
await runCli(["queue", "list", "--json"], { queueDeps: deps });
|
||||
expect(stderr).not.toHaveBeenCalled();
|
||||
expect(calls.find((c: { path: string }) => c.path.startsWith("/api/queue/list"))!.path).not.toContain("state=");
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("the path is SHARED across families — a send-family error also emits JSON under --json", async () => {
|
||||
const { out, exitCode } = await runCli(["send", "--json", "--no-such-flag"]);
|
||||
expect(exitCode).toBe(1);
|
||||
|
||||
@@ -109,7 +109,8 @@ configurations, as below; do not take the pin from a cached page summary.
|
||||
handoff carries, such as a trust or hook-review prompt they declined.
|
||||
2. **Where it works.** If the folder is missing, ask which folder the team should
|
||||
work in (usually a clone of their repository) and use its absolute path. Your
|
||||
own working directory is OpenRig's workspace, not their project, so never launch with
|
||||
own working directory is OpenRig's: the workspace's `kernel` folder (on an older
|
||||
install, the workspace itself), not their project, so never launch with
|
||||
`--cwd .` from here.
|
||||
3. **Three teams, one recommendation.** Present starter, workshop and factory,
|
||||
recommend one with a short reason tied to their goal, and show each graph
|
||||
|
||||
@@ -22,7 +22,8 @@ import { subtreeNamesClaudeExecutable, claudeCommandSessionToken, listNativeProc
|
||||
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
|
||||
import { claudeResumeLaunchEnv, claudeResumeLaunchPrefix } from "./claude-resume-launch.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
import { validateClaudeActivityHookDelivery, claudeActivityRelayPath, CLAUDE_ACTIVITY_RELAY_RELATIVE_PATH, parseManagedClaudeNodeCommand } from "../domain/claude-activity-hooks.js";
|
||||
import { validateClaudeActivityHookDelivery, claudeActivityRelayPath, parseManagedClaudeNodeCommand } from "../domain/claude-activity-hooks.js";
|
||||
import { isOwnedCollectorCommand, stripOwnedRelayHooks } from "../domain/claude-owned-settings.js";
|
||||
import { isFilesystemRoot } from "../domain/cwd-resolution.js";
|
||||
import { observeClaudePermission } from "../domain/permission-drift.js";
|
||||
import { unresolvedClaudePermissionModes } from "../domain/native-permission-selection.js";
|
||||
@@ -1248,22 +1249,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
|
||||
// 1. Strip OpenRig-owned relay entries (EXACT node-command shape) from all events;
|
||||
// prune emptied groups + events. User-authored hooks are untouched.
|
||||
let changed = false;
|
||||
for (const event of Object.keys(hooks)) {
|
||||
const groups = Array.isArray(hooks[event]) ? (hooks[event] as unknown[]) : null;
|
||||
if (!groups) continue;
|
||||
const keptGroups: unknown[] = [];
|
||||
for (const group of groups) {
|
||||
if (!isPlainObject(group) || !Array.isArray(group["hooks"])) { keptGroups.push(group); continue; }
|
||||
const groupHooks = group["hooks"] as unknown[];
|
||||
const keptHooks = groupHooks.filter((h) => !isOwnedRelayCommand(hookCommand(h)));
|
||||
if (keptHooks.length !== groupHooks.length) changed = true;
|
||||
if (keptHooks.length === 0) continue; // prune emptied group
|
||||
keptGroups.push({ ...group, hooks: keptHooks });
|
||||
}
|
||||
if (keptGroups.length === 0) delete hooks[event]; // prune emptied event
|
||||
else hooks[event] = keptGroups;
|
||||
}
|
||||
let changed = stripOwnedRelayHooks(hooks);
|
||||
|
||||
// 2. Deliverable enable: copy the relay asset + upsert the owned entry for each
|
||||
// PREVALIDATED relay event (derived from the canonical manifest above).
|
||||
@@ -1309,11 +1295,6 @@ interface ActivityHookOutcome {
|
||||
settingsUnparseable: boolean;
|
||||
}
|
||||
|
||||
// OpenRig-owned relay path suffix. Ownership is the generated Node command whose single
|
||||
// argument ends with this path — a changed prefix still matches (replace, not duplicate); a
|
||||
// user command that merely contains the path (echo, or node with extra args) does NOT.
|
||||
const LEGACY_OWNED_RELAY_SUFFIX = "/.openrig/hooks/scripts/activity-relay.cjs";
|
||||
|
||||
function managedClaudeNodeCommand(executable: string, ...args: string[]): string {
|
||||
const quotedArgs = args.map(shellQuote).join(" ");
|
||||
const pinned = `${shellQuote(executable)} ${quotedArgs}`;
|
||||
@@ -1321,37 +1302,6 @@ function managedClaudeNodeCommand(executable: string, ...args: string[]): string
|
||||
return parseManagedClaudeNodeCommand(pinned) ? pinned : `node ${quotedArgs}`;
|
||||
}
|
||||
|
||||
function hookCommand(hook: unknown): string | undefined {
|
||||
return isPlainObject(hook) && typeof hook["command"] === "string" ? (hook["command"] as string) : undefined;
|
||||
}
|
||||
|
||||
// OpenRig-owned collectors use canonical POSIX-quoted paths. Recognise older unquoted
|
||||
// three/four-token commands too, but preserve commands composed with shell operators or
|
||||
// extra arguments. Old unquoted paths containing spaces cannot be identified unambiguously.
|
||||
const OWNED_COLLECTOR_SUFFIX = nodePath.sep + nodePath.join(".openrig", "context-collector.cjs");
|
||||
|
||||
function isOwnedCollectorCommand(cmd: string): boolean {
|
||||
if (/[\r\n]/.test(cmd)) return false;
|
||||
const parsed = parseManagedClaudeNodeCommand(cmd);
|
||||
if (parsed && (parsed.args.length === 2 || parsed.args.length === 3)) {
|
||||
return parsed.args[0]!.endsWith(OWNED_COLLECTOR_SUFFIX);
|
||||
}
|
||||
const tokens = cmd.trim().split(/\s+/);
|
||||
if ((tokens.length !== 3 && tokens.length !== 4) || tokens[0] !== "node") return false;
|
||||
if (tokens.some((token) => /[;&|<>`$()'"\\]/.test(token))) return false;
|
||||
return tokens[1]!.endsWith(OWNED_COLLECTOR_SUFFIX);
|
||||
}
|
||||
|
||||
function isOwnedRelayCommand(cmd: string | undefined): boolean {
|
||||
if (!cmd) return false;
|
||||
const parsed = parseManagedClaudeNodeCommand(cmd);
|
||||
if (!parsed || parsed.args.length !== 1) return false;
|
||||
const relay = parsed.args[0]!;
|
||||
return relay.endsWith("/plugins/openrig-core/hooks/scripts/activity-relay.cjs")
|
||||
|| relay.endsWith(LEGACY_OWNED_RELAY_SUFFIX)
|
||||
|| relay.endsWith(`/${CLAUDE_ACTIVITY_RELAY_RELATIVE_PATH}`);
|
||||
}
|
||||
|
||||
function hashContent(content: string): string {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
@@ -384,6 +384,8 @@ export async function readPiSessionEntryIds(
|
||||
|
||||
export class RunnerCore {
|
||||
private streaming = false;
|
||||
/** Pi runs this runner has opened, each posted as an accepted turn (contract section 4): the working beat names it. */
|
||||
private turn = 0;
|
||||
private sessionFile: string | undefined;
|
||||
private sessionId: string | undefined;
|
||||
private lastEntryId: string | undefined;
|
||||
@@ -536,6 +538,7 @@ export class RunnerCore {
|
||||
const message = block.slice("/followup ".length);
|
||||
this.io.sendRpc({ type: "follow_up", message });
|
||||
this.io.mirrorLine(`you (follow-up) ▸ ${message}`);
|
||||
this.acceptIntoOpenRun();
|
||||
return;
|
||||
}
|
||||
// The runtime decides, not the runner: with streamingBehavior "steer", Pi and OMP run the prompt when idle and,
|
||||
@@ -544,6 +547,17 @@ export class RunnerCore {
|
||||
// when a prompt was consumed locally and started none. It labels the mirror line only.
|
||||
this.io.sendRpc({ type: "prompt", message: block, streamingBehavior: "steer" });
|
||||
this.io.mirrorLine(this.streaming ? `you (steer) ▸ ${block}` : `you ▸ ${block}`);
|
||||
this.acceptIntoOpenRun();
|
||||
}
|
||||
|
||||
/** A working beat counts only for the latest accepted turn, and each prompt the runner gives the agent is accepted with
|
||||
* its own number (contract section 4). One sent while a Pi run is open is posted as it's sent, and the run's end
|
||||
* closes it. One sent while idle is posted when its run opens (handleEvent), so a prompt Pi handles without a run opens
|
||||
* no turn. */
|
||||
private acceptIntoOpenRun(): void {
|
||||
if (this.runtime !== "pi" || !this.streaming) return;
|
||||
this.turn += 1;
|
||||
this.io.postActivity(this.activityPayload("UserPromptSubmit", "accepted", { turnId: String(this.turn) }));
|
||||
}
|
||||
|
||||
/** Child process exit — honest, loud, durable. */
|
||||
@@ -722,8 +736,9 @@ export class RunnerCore {
|
||||
* idle seat read `unknown` five minutes after its last turn, and a seat mid-turn read `unknown`
|
||||
* through a long silent LLM request (the seat-activity oracle stops a bare working hook from
|
||||
* deciding after 15s, HOOK_AUTHORITY_WINDOW_MS). Beat while ready and not waiting on the
|
||||
* operator: idle (Stop) when no turn is in flight, streaming (active) while `streaming` says
|
||||
* one is, each at most once per its own interval behind the newest real event. Same honesty
|
||||
* operator: idle (Stop) when no turn is in flight, and while `streaming` says one is, the
|
||||
* contract's working beat (`active` / `liveness`, section 4) for the run's accepted turn, each
|
||||
* at most once per its own interval behind the newest real event. Same honesty
|
||||
* rule: a runner that dies stops beating and the seat goes back to `unknown` (handlePiExit
|
||||
* clears `ready`, so no beat outlives the process it vouches for). Pi only; the OMP identity
|
||||
* handshake has its own retry.
|
||||
@@ -742,7 +757,7 @@ export class RunnerCore {
|
||||
const now = Date.parse(this.io.now());
|
||||
if (this.streaming) {
|
||||
if (this.lastActivityPostMs !== null && now - this.lastActivityPostMs < STREAM_HEARTBEAT_MS) return;
|
||||
this.io.postActivity(this.activityPayload("active", "streaming"));
|
||||
this.io.postActivity(this.activityPayload("active", "liveness", { turnId: String(this.turn), livenessSeconds: WORK_LIVENESS_SECONDS }));
|
||||
return;
|
||||
}
|
||||
// `streaming` follows only agent_start/agent_end; turn_start, compaction and auto-retry also report the agent
|
||||
@@ -809,7 +824,15 @@ export class RunnerCore {
|
||||
if (event.type === "turn_start") this.assistantErrorShown = false;
|
||||
}
|
||||
const mapped = mapPiEvent(event, this.runtime);
|
||||
// A working beat counts only for the latest accepted turn. Pi's runs start from this runner's prompts, so the run
|
||||
// that opens is posted as one, before its first event; a prompt Pi handles without a run opens none. A prompt sent
|
||||
// into an open run is accepted as it's sent (acceptIntoOpenRun).
|
||||
const opening = this.runtime === "pi" && mapped.streaming === true && !this.streaming;
|
||||
if (mapped.streaming !== undefined) this.streaming = mapped.streaming;
|
||||
if (opening) {
|
||||
this.turn += 1;
|
||||
this.io.postActivity(this.activityPayload("UserPromptSubmit", "accepted", { turnId: String(this.turn) }));
|
||||
}
|
||||
// OMP writes its JSONL lazily; re-announce identity after each turn until
|
||||
// the daemon confirms the token. Pi's get_state identity is final.
|
||||
if (event.type === "agent_end" && this.runtime === "omp") this.postSessionIdentity();
|
||||
@@ -902,7 +925,7 @@ export class RunnerCore {
|
||||
/** Each activity post's place in this runner's event order: posts aren't awaited, so they can arrive out of order. */
|
||||
private activitySequence = 0;
|
||||
|
||||
private activityPayload(hookEvent: string, subtype: string | null): Record<string, unknown> {
|
||||
private activityPayload(hookEvent: string, subtype: string | null, extra: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
this.lastPostedHook = hookEvent;
|
||||
this.lastActivityPostMs = Date.parse(this.io.now());
|
||||
// Any posted Stop is an idle report, so the next beat is due a full interval after it, not just after a beat.
|
||||
@@ -917,6 +940,7 @@ export class RunnerCore {
|
||||
subtype,
|
||||
// A Pi runner's idle report holds only while it keeps beating; the daemon expires it after two missed beats.
|
||||
...(this.runtime === "pi" && hookEvent === "Stop" && subtype !== "pi_exited" ? { livenessSeconds: IDLE_HEARTBEAT_MS / 1000 } : {}),
|
||||
...extra,
|
||||
occurredAt: this.io.now(),
|
||||
};
|
||||
}
|
||||
@@ -1074,6 +1098,8 @@ const IDLE_HEARTBEAT_MS = 3 * 60_000;
|
||||
const PI_HEARTBEAT_TICK_MS = 5_000;
|
||||
/** Minimum age of the newest activity post before the streaming beat may re-assert it. */
|
||||
const STREAM_HEARTBEAT_MS = 5_000;
|
||||
/** How long one working beat counts (contract section 4). Beats land 5–10 s apart, well inside half of it. */
|
||||
const WORK_LIVENESS_SECONDS = 30;
|
||||
|
||||
/** The Pi heartbeat scheduler — one 5s tick drives both beats (see PI_HEARTBEAT_TICK_MS).
|
||||
* Exported so tests drive the real interval with fake timers instead of calling heartbeat()
|
||||
|
||||
@@ -132,6 +132,9 @@ export interface ActivityEvidence {
|
||||
/** How often the source promises to report while idle. A durable idle boundary holds for two of these, then lapses to
|
||||
* unknown: a runner that died without saying so stops reading idle. Absent: the boundary holds until the next. */
|
||||
livenessSeconds?: number;
|
||||
/** A counted working beat from a runner (the runner contract §4): how long it counts as working, 5-60 s, in place of
|
||||
* the hook window a bare working post has. */
|
||||
workingLivenessSeconds?: number;
|
||||
activity?: ActivityValue;
|
||||
needsInput?: NeedsInput;
|
||||
permissionRequest?: { id: string; hookAgentId?: string | null };
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// OpenRig's own additions in a Claude seat's cwd: the context collector `.openrig/context-collector.cjs`, its
|
||||
// `statusLine`, and the activity relay hooks in `.claude/settings.local.json`. Ownership is the exact generated
|
||||
// Node-command shape. The adapter's install and reconcile and rig down's cleanup (#875) share these matchers, so what
|
||||
// down takes back is exactly what a launch would recognise as its own.
|
||||
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import { CLAUDE_ACTIVITY_RELAY_RELATIVE_PATH, parseManagedClaudeNodeCommand } from "./claude-activity-hooks.js";
|
||||
|
||||
// OpenRig-owned relay path suffix. Ownership is the generated Node command whose single
|
||||
// argument ends with this path — a changed prefix still matches (replace, not duplicate); a
|
||||
// user command that merely contains the path (echo, or node with extra args) does NOT.
|
||||
const LEGACY_OWNED_RELAY_SUFFIX = "/.openrig/hooks/scripts/activity-relay.cjs";
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hookCommand(hook: unknown): string | undefined {
|
||||
return isPlainObject(hook) && typeof hook["command"] === "string" ? (hook["command"] as string) : undefined;
|
||||
}
|
||||
|
||||
// OpenRig-owned collectors use canonical POSIX-quoted paths. Recognise older unquoted
|
||||
// three/four-token commands too, but preserve commands composed with shell operators or
|
||||
// extra arguments. Old unquoted paths containing spaces cannot be identified unambiguously.
|
||||
const OWNED_COLLECTOR_SUFFIX = nodePath.sep + nodePath.join(".openrig", "context-collector.cjs");
|
||||
|
||||
export function isOwnedCollectorCommand(cmd: string): boolean {
|
||||
if (/[\r\n]/.test(cmd)) return false;
|
||||
const parsed = parseManagedClaudeNodeCommand(cmd);
|
||||
if (parsed && (parsed.args.length === 2 || parsed.args.length === 3)) {
|
||||
return parsed.args[0]!.endsWith(OWNED_COLLECTOR_SUFFIX);
|
||||
}
|
||||
const tokens = cmd.trim().split(/\s+/);
|
||||
if ((tokens.length !== 3 && tokens.length !== 4) || tokens[0] !== "node") return false;
|
||||
if (tokens.some((token) => /[;&|<>`$()'"\\]/.test(token))) return false;
|
||||
return tokens[1]!.endsWith(OWNED_COLLECTOR_SUFFIX);
|
||||
}
|
||||
|
||||
export function isOwnedRelayCommand(cmd: string | undefined): boolean {
|
||||
if (!cmd) return false;
|
||||
const parsed = parseManagedClaudeNodeCommand(cmd);
|
||||
if (!parsed || parsed.args.length !== 1) return false;
|
||||
const relay = parsed.args[0]!;
|
||||
return relay.endsWith("/plugins/openrig-core/hooks/scripts/activity-relay.cjs")
|
||||
|| relay.endsWith(LEGACY_OWNED_RELAY_SUFFIX)
|
||||
|| relay.endsWith(`/${CLAUDE_ACTIVITY_RELAY_RELATIVE_PATH}`);
|
||||
}
|
||||
|
||||
/** Strip OpenRig-owned relay entries (EXACT node-command shape) from all events, hook by hook: a group keeps its other
|
||||
* hooks and fields, and an emptied group or event is pruned. User-authored hooks are untouched. Returns whether an
|
||||
* owned entry was removed. */
|
||||
export function stripOwnedRelayHooks(hooks: Record<string, unknown>): boolean {
|
||||
let changed = false;
|
||||
for (const event of Object.keys(hooks)) {
|
||||
const groups = Array.isArray(hooks[event]) ? (hooks[event] as unknown[]) : null;
|
||||
if (!groups) continue;
|
||||
const keptGroups: unknown[] = [];
|
||||
for (const group of groups) {
|
||||
if (!isPlainObject(group) || !Array.isArray(group["hooks"])) { keptGroups.push(group); continue; }
|
||||
const groupHooks = group["hooks"] as unknown[];
|
||||
const keptHooks = groupHooks.filter((h) => !isOwnedRelayCommand(hookCommand(h)));
|
||||
if (keptHooks.length !== groupHooks.length) changed = true;
|
||||
if (keptHooks.length === 0) continue; // prune emptied group
|
||||
keptGroups.push({ ...group, hooks: keptHooks });
|
||||
}
|
||||
if (keptGroups.length === 0) delete hooks[event]; // prune emptied event
|
||||
else hooks[event] = keptGroups;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Take OpenRig's own entries out of parsed Claude settings: its relay hooks, and the `type` and `command` its collector
|
||||
* install wrote into `statusLine`. The person's other `statusLine` fields and every other setting stay. Returns
|
||||
* whether anything changed. */
|
||||
export function removeOwnedClaudeSettings(settings: Record<string, unknown>): boolean {
|
||||
let changed = false;
|
||||
const hooks = settings["hooks"];
|
||||
if (isPlainObject(hooks) && stripOwnedRelayHooks(hooks)) {
|
||||
changed = true;
|
||||
if (Object.keys(hooks).length === 0) delete settings["hooks"];
|
||||
}
|
||||
const statusLine = settings["statusLine"];
|
||||
if (isPlainObject(statusLine) && typeof statusLine["command"] === "string" && isOwnedCollectorCommand(statusLine["command"])) {
|
||||
const rest = { ...statusLine };
|
||||
delete rest["command"];
|
||||
if (rest["type"] === "command") delete rest["type"];
|
||||
if (Object.keys(rest).length > 0) settings["statusLine"] = rest;
|
||||
else delete settings["statusLine"];
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function removeIfEmpty(dir: string, isShared: (path: string) => boolean): void {
|
||||
try {
|
||||
if (!isShared(dir) && fs.lstatSync(dir).isDirectory() && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
|
||||
} catch {
|
||||
// Missing, or not ours to judge: leave it.
|
||||
}
|
||||
}
|
||||
|
||||
const SHARED = "another seat's directory still uses it through a link, so it was kept";
|
||||
|
||||
/** rig down (#875), for a cwd no Claude seat still uses: take back what OpenRig added there for Claude.
|
||||
* - Its entries leave `.claude/settings.local.json`, which is kept even when that leaves `{}`, since nothing records
|
||||
* whether OpenRig created it. Settings that don't parse as an object are left as they are.
|
||||
* - The collector goes only while its bytes are the shipped collector's.
|
||||
* - `.openrig` and `.claude` go only if they're then empty, never recursively.
|
||||
* - A target `isShared` names (one another seat's directory reaches through a symlink or a hardlink) is left as it is.
|
||||
* Returns what was kept for the person to look at. */
|
||||
export function cleanupOwnedClaudeCwd(cwd: string, collectorAssetPath: string | null | undefined,
|
||||
isShared: (path: string) => boolean = () => false): string[] {
|
||||
const kept: string[] = [];
|
||||
const settingsPath = nodePath.join(cwd, ".claude", "settings.local.json");
|
||||
if (fs.existsSync(settingsPath) && isShared(settingsPath)) kept.push(`${settingsPath}: ${SHARED}`);
|
||||
else if (fs.existsSync(settingsPath)) {
|
||||
const text = fs.readFileSync(settingsPath, "utf-8");
|
||||
let parsed: unknown;
|
||||
try { parsed = text.trim() === "" ? {} : JSON.parse(text); } catch { parsed = undefined; }
|
||||
if (!isPlainObject(parsed)) kept.push(`${settingsPath}: not a JSON settings object, so it was left as it is`);
|
||||
else if (removeOwnedClaudeSettings(parsed)) fs.writeFileSync(settingsPath, JSON.stringify(parsed, null, 2), "utf-8");
|
||||
}
|
||||
const collectorPath = nodePath.join(cwd, ".openrig", "context-collector.cjs");
|
||||
if (fs.existsSync(collectorPath) && isShared(collectorPath)) kept.push(`${collectorPath}: ${SHARED}`);
|
||||
else if (fs.existsSync(collectorPath)) {
|
||||
let shipped: Buffer | null = null;
|
||||
try { shipped = collectorAssetPath ? fs.readFileSync(collectorAssetPath) : null; } catch { shipped = null; }
|
||||
if (!shipped) kept.push(`${collectorPath}: couldn't be compared with the shipped collector, so it was kept`);
|
||||
else if (fs.lstatSync(collectorPath).isFile() && fs.readFileSync(collectorPath).equals(shipped)) fs.unlinkSync(collectorPath);
|
||||
else kept.push(`${collectorPath}: not the shipped collector's bytes, so it was kept`);
|
||||
}
|
||||
for (const dir of [".openrig", ".claude"]) removeIfEmpty(nodePath.join(cwd, dir), isShared);
|
||||
return kept;
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import nodePath from "node:path";
|
||||
import os from "node:os";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import type { RigRepository } from "./rig-repository.js";
|
||||
import type { BootstrapOrchestrator } from "./bootstrap-orchestrator.js";
|
||||
@@ -45,12 +45,11 @@ export interface KernelBootDeps {
|
||||
* inject a fixture root). Production callsite resolves to the
|
||||
* packaged `packages/daemon/specs/` location. */
|
||||
specsDir: string;
|
||||
/** cwdOverride passed to BootstrapOrchestrator.bootstrap. Kernel
|
||||
* members run against the operator's environment, not the daemon
|
||||
* installation tree — without this override, the bootstrap path
|
||||
* refuses with `cwd is inside the OpenRig installation`. Production
|
||||
* callsite passes the resolved workspace.root setting; tests inject
|
||||
* a fixture path. */
|
||||
/** The workspace root (the resolved workspace.root setting; tests inject a fixture path). A new kernel's members run
|
||||
* in its `kernel` folder, or here when that can't be prepared (`prepareNewKernelCwd`), and that's passed to
|
||||
* BootstrapOrchestrator.bootstrap as its cwdOverride. Kernel members run against the operator's environment, not the
|
||||
* daemon installation tree: without an override, the bootstrap path refuses with `cwd is inside the OpenRig
|
||||
* installation`. */
|
||||
cwdOverride: string;
|
||||
/** Runtime auth probe — defaults to live shellouts; tests inject. */
|
||||
probeRuntimes?: () => Promise<RuntimeProbeResult>;
|
||||
@@ -151,7 +150,7 @@ export async function bootKernelIfNeeded(deps: KernelBootDeps): Promise<KernelBo
|
||||
sourceRef: specPath,
|
||||
sourceKind: "rig_spec",
|
||||
autoApprove: true,
|
||||
cwdOverride: deps.cwdOverride,
|
||||
cwdOverride: prepareNewKernelCwd(deps.cwdOverride, log),
|
||||
});
|
||||
tracker.startBooting(variant, bootstrapPromise, expectedKernelSeats(specPath));
|
||||
return tracker;
|
||||
@@ -227,6 +226,19 @@ export function classifyManagedKernel(rigRepo: RigRepository, sessionRegistry: S
|
||||
}
|
||||
}
|
||||
|
||||
/** The folder a newly created kernel works in: `<workspace.root>/kernel`, beside the projects (`<workspace.root>/projects`
|
||||
* by default) rather than above them, so the guidance a kernel seat writes in its folder (a Claude seat's CLAUDE.md)
|
||||
* isn't read by the project seats below. It's made here, inside an existing workspace root. When it can't be (a file is
|
||||
* in the way, or there's no workspace root), the kernel works in the workspace root as before, and nothing is refused.
|
||||
* An existing kernel keeps the folders it was created with. */
|
||||
export function prepareNewKernelCwd(workspaceRoot: string, log: (level: "warn", message: string) => void = defaultLog): string {
|
||||
const cwd = nodePath.join(workspaceRoot, "kernel");
|
||||
try { mkdirSync(cwd); } catch { /* it's already there, or the workspace root isn't */ }
|
||||
try { if (statSync(cwd).isDirectory()) return cwd; } catch { /* nothing there */ }
|
||||
log("warn", `kernel-boot: couldn't prepare ${cwd} for the new kernel; it works in ${workspaceRoot}`);
|
||||
return workspaceRoot;
|
||||
}
|
||||
|
||||
/** True when a rig named `kernel` already exists in the DB. */
|
||||
export function kernelAlreadyManaged(rigRepo: RigRepository): boolean {
|
||||
const rigs = rigRepo.listRigs();
|
||||
|
||||
@@ -539,6 +539,9 @@ export interface QueueListOptions {
|
||||
handedOffFrom?: string;
|
||||
/** Items updated at or after this time: an ISO-8601 UTC timestamp in the stored form (toISOString). */
|
||||
updatedSince?: string;
|
||||
/** Pick and order rows by their latest update instead of their creation, so a bounded window of closed items holds
|
||||
* the ones closed most recently. */
|
||||
sortByUpdated?: boolean;
|
||||
}
|
||||
|
||||
/** Adds the handoff-successor and updated-since filters both list paths share. */
|
||||
@@ -4135,9 +4138,10 @@ export class QueueRepository {
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const columns = opts?.compact ? this.compactColumns() : "*";
|
||||
const useActiveFirst = !!(opts?.rig || opts?.asSession || opts?.activeOnly);
|
||||
const newest = opts?.sortByUpdated ? "ts_updated DESC" : "ts_created DESC";
|
||||
const orderBy = useActiveFirst
|
||||
? "CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ts_created DESC"
|
||||
: "ts_created DESC";
|
||||
? `CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ${newest}`
|
||||
: newest;
|
||||
params.push(limit);
|
||||
|
||||
const rows = this.db
|
||||
|
||||
@@ -47,7 +47,8 @@ export interface SeatGuidance {
|
||||
}
|
||||
|
||||
/** The file a seat's runtime merges its managed guidance blocks into, whatever a spec's `target` declares: the rig's
|
||||
* Claude file for Claude, `AGENTS.md` for Codex, or null for a runtime this doesn't know. */
|
||||
* Claude file for Claude, `AGENTS.md` for Codex, Pi and omp, or null for a runtime this doesn't know. Teardown, its
|
||||
* live-file protection, exact resume and node inventory all read the target from here. */
|
||||
export function seatGuidanceFile(
|
||||
runtime: string | null | undefined,
|
||||
cwd: string,
|
||||
@@ -55,7 +56,7 @@ export function seatGuidanceFile(
|
||||
): string | null {
|
||||
const fileName = runtime === "claude-code"
|
||||
? (claudeManagedBlockFile ?? DEFAULT_CLAUDE_MANAGED_BLOCK_FILE)
|
||||
: runtime === "codex" ? "AGENTS.md" : null;
|
||||
: runtime === "codex" || runtime === "pi" || runtime === "omp" ? "AGENTS.md" : null;
|
||||
return fileName ? nodePath.join(cwd, fileName) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1424,6 +1424,9 @@ export class RestoreOrchestrator {
|
||||
launchResult: { session: import("./types.js").Session; binding: import("./types.js").Binding },
|
||||
warnings: string[] | undefined,
|
||||
activityHookGaps: string[] | undefined,
|
||||
// The directory the seat was launched in (snapshot cwd, else live node
|
||||
// cwd, else "." — resolved by the caller, which owns the live lookup).
|
||||
launchCwd: string,
|
||||
): Promise<void> {
|
||||
let desired: DesiredClaudeActivityHooks;
|
||||
try {
|
||||
@@ -1458,9 +1461,9 @@ export class RestoreOrchestrator {
|
||||
};
|
||||
try {
|
||||
const result = await activityAdapter.project({
|
||||
runtime: "claude-code", cwd: node.cwd ?? ".", entries: [marker],
|
||||
runtime: "claude-code", cwd: launchCwd, entries: [marker],
|
||||
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [],
|
||||
}, { ...launchResult.binding, cwd: node.cwd ?? "." });
|
||||
}, { ...launchResult.binding, cwd: launchCwd });
|
||||
warnings?.push(...(result.warnings ?? []));
|
||||
if (result.projected.includes(marker.effectiveId)) {
|
||||
const detail = "saved startup context had no Claude activity-hook selection; the current seat specification selects it, so the current marker was re-applied without replaying startup content.";
|
||||
@@ -1505,7 +1508,15 @@ export class RestoreOrchestrator {
|
||||
if (!["resumed", "fresh-primed", "fresh", "rebuilt", "attention_required"].includes(result.status)) return result;
|
||||
let guidance: ReturnType<typeof seatGuidance>;
|
||||
try {
|
||||
guidance = seatGuidance(data.nodeStartupContext?.[node.id] ?? null, node.cwd,
|
||||
// The seat was launched in the snapshot node's cwd when set, else the
|
||||
// live node's cwd — the same resolved launch directory the hook
|
||||
// projection and startup replay use. Passing node.cwd alone returns
|
||||
// no guidance at all when the snapshot node has no cwd. No "." fallback:
|
||||
// when neither cwd exists the old skip is kept, so no guidance file is
|
||||
// written into the daemon's own working directory.
|
||||
const guidanceCwd = node.cwd
|
||||
?? this.rigRepo.getRig(rigId)?.nodes.find(current => current.logicalId === node.logicalId)?.cwd;
|
||||
guidance = seatGuidance(data.nodeStartupContext?.[node.id] ?? null, guidanceCwd,
|
||||
this.rigRepo.getRigClaudeManagedBlockFile(rigId), existsSync, (path) => readFileSync(path, "utf-8"));
|
||||
} catch (err) {
|
||||
warnings?.push(`Restore: ${node.logicalId}: could not check its guidance: ${(err as Error).message}`);
|
||||
@@ -1675,6 +1686,16 @@ export class RestoreOrchestrator {
|
||||
// deliberately skips the resume attempt; its launch reports `fresh-primed`.
|
||||
const liveRuntime = this.rigRepo.getRig(rigId)?.nodes.find(current => current.logicalId === node.logicalId
|
||||
&& (!selected || current.id === node.id))?.runtime ?? node.runtime;
|
||||
// The seat was launched in the snapshot node's cwd when set; NodeLauncher
|
||||
// otherwise falls back to the live node's cwd, and to the daemon's working
|
||||
// directory when neither is set (tmux new-session without -c inherits it;
|
||||
// "." is the codebase-wide convention for that case). Projecting hooks
|
||||
// with the snapshot cwd alone would write another project's settings when
|
||||
// the live node moved the seat.
|
||||
const launchCwd = node.cwd
|
||||
?? this.rigRepo.getRig(rigId)?.nodes.find(current => current.logicalId === node.logicalId
|
||||
&& (!selected || current.id === node.id))?.cwd
|
||||
?? ".";
|
||||
const restorePolicy = effectiveRestorePolicy(seatFacts(data, node.id, node.runtime, liveRuntime), session?.restorePolicy);
|
||||
const noConversation = isOrdinaryTerminalSeat(seatFacts(data, node.id, node.runtime, liveRuntime));
|
||||
// A terminal's restore is its shell and its saved startup: one whose startup failed wasn't restored whole.
|
||||
@@ -1704,17 +1725,26 @@ export class RestoreOrchestrator {
|
||||
// re-apply the CURRENT marker, marker-only: no profile-wide resolution,
|
||||
// no guidance, skills or startup replay, and the saved context is never
|
||||
// rewritten. A seat whose current spec does not select hooks stays silent.
|
||||
// The no-context fallback is scoped: it applies only to a legacy
|
||||
// (non-pod) agent whose live runtime still matches the snapshot's. A
|
||||
// runtime changed after the snapshot would otherwise read the snapshot's
|
||||
// runtime and write Claude hooks into another agent's working folder
|
||||
// (with a saved context, the compatibility gate already stops that
|
||||
// mismatch), and a pod-aware agent must get no write from this recovery
|
||||
// at all — it still ends at the "Harness not started" gate, unchanged.
|
||||
const activityAdapter = opts?.adapters?.["claude-code"];
|
||||
if (resumeRequested && resumeToken && launchResult && startupCtx?.runtime === "claude-code") {
|
||||
const entries = startupCtx.projectionEntries.filter(e =>
|
||||
const recoveryRuntime = startupCtx?.runtime
|
||||
?? (!isPodAware && liveRuntime === node.runtime ? node.runtime : null);
|
||||
if (resumeRequested && resumeToken && launchResult && recoveryRuntime === "claude-code") {
|
||||
const entries = (startupCtx?.projectionEntries ?? []).filter(e =>
|
||||
e.category === "runtime_resource" && e.resourceType === "claude_activity_hooks");
|
||||
if (entries.length > 0 && activityAdapter) try {
|
||||
const result = await activityAdapter.project({
|
||||
runtime: "claude-code", cwd: node.cwd ?? ".",
|
||||
runtime: "claude-code", cwd: launchCwd,
|
||||
entries: entries.map(e => ({ ...e, category: "runtime_resource" as const, classification: "safe_projection" as const,
|
||||
mergeStrategy: e.mergeStrategy as import("./projection-planner.js").ProjectionEntry["mergeStrategy"] })),
|
||||
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [],
|
||||
}, { ...launchResult.binding, cwd: node.cwd ?? "." });
|
||||
}, { ...launchResult.binding, cwd: launchCwd });
|
||||
warnings?.push(...(result.warnings ?? []));
|
||||
for (const skipped of result.skipped) warnings?.push(`Restore activity hooks: skipped ${skipped}; saved hooks could not be reapplied.`);
|
||||
for (const failure of result.failed) warnings?.push(`Restore activity hooks: ${failure.error}`);
|
||||
@@ -1722,7 +1752,7 @@ export class RestoreOrchestrator {
|
||||
// Activity delivery remains best-effort, as on an ordinary fresh launch.
|
||||
warnings?.push(`Restore activity hooks: ${(error as Error).message}`);
|
||||
} else if (entries.length === 0) {
|
||||
await this.restoreCurrentActivityHooks(node, activityAdapter, launchResult, warnings, activityHookGaps);
|
||||
await this.restoreCurrentActivityHooks(node, activityAdapter, launchResult, warnings, activityHookGaps, launchCwd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1734,7 +1764,13 @@ export class RestoreOrchestrator {
|
||||
if (resumeRequested && resumeToken && launchResult) {
|
||||
let guidance: ReturnType<typeof seatGuidance> = null;
|
||||
try {
|
||||
guidance = seatGuidance(startupCtx, node.cwd, this.rigRepo.getRigClaudeManagedBlockFile(rigId), existsSync, (path) => readFileSync(path, "utf-8"));
|
||||
// Same resolved directory as launchCwd, without its "." fallback:
|
||||
// when neither the snapshot nor the live node has a cwd, keep the
|
||||
// old skip instead of writing guidance into the daemon's folder.
|
||||
const guidanceCwd = node.cwd
|
||||
?? this.rigRepo.getRig(rigId)?.nodes.find(current => current.logicalId === node.logicalId
|
||||
&& (!selected || current.id === node.id))?.cwd;
|
||||
guidance = seatGuidance(startupCtx, guidanceCwd, this.rigRepo.getRigClaudeManagedBlockFile(rigId), existsSync, (path) => readFileSync(path, "utf-8"));
|
||||
} catch (err) {
|
||||
// Guidance never blocks the exact resume; the post-launch check discloses the gap.
|
||||
warnings?.push(`Restore guidance: ${node.logicalId}: could not read its saved guidance selection: ${(err as Error).message}`);
|
||||
@@ -1776,7 +1812,7 @@ export class RestoreOrchestrator {
|
||||
} catch { /* best-effort: without it a rotation stays unproved, as before */ }
|
||||
}
|
||||
if (selected && !selected.beforeNative(launchedSessionId, launchResult?.binding.id)) return { nodeId: node.id, logicalId: node.logicalId, status: "attention_required", error: "The selected seat, settings or history changed after shell creation; native resume was not dispatched." };
|
||||
const resumeOutcome = await this.attemptResume(node.id, sessionName, resumeType, resumeToken, node.cwd ?? "/", this.liveCodexProfile(rigId, node, selected?.nodeId), modelForRestore(), this.resolveRestorePosture(node.id, rigId), effortForRestore(), warnings, node.advisor,
|
||||
const resumeOutcome = await this.attemptResume(node.id, sessionName, resumeType, resumeToken, launchCwd, this.liveCodexProfile(rigId, node, selected?.nodeId), modelForRestore(), this.resolveRestorePosture(node.id, rigId), effortForRestore(), warnings, node.advisor,
|
||||
selected ? () => selected.beforeNative(launchedSessionId, launchResult?.binding.id) : undefined, opts?.claudeExecutable);
|
||||
if (resumeOutcome.kind === "resumed") {
|
||||
baseStatus = "resumed";
|
||||
@@ -1985,7 +2021,7 @@ export class RestoreOrchestrator {
|
||||
// Build fresh projection plan (all safe_projection; replaySkip says which seat skills are left alone)
|
||||
const plan: import("./projection-planner.js").ProjectionPlan = {
|
||||
runtime: startupCtx.runtime,
|
||||
cwd: node.cwd ?? ".",
|
||||
cwd: launchCwd,
|
||||
entries: filteredEntries.map((e) => ({
|
||||
...e,
|
||||
classification: "safe_projection" as const,
|
||||
@@ -2002,7 +2038,7 @@ export class RestoreOrchestrator {
|
||||
...launchResult.binding,
|
||||
...(data.environmentBindingsByNode?.[node.id] ? { environmentRestore: data.environmentBindingsByNode[node.id] } : {}),
|
||||
...(opts?.claudeExecutable ? { claudeExecutable: opts.claudeExecutable } : {}),
|
||||
cwd: node.cwd ?? ".",
|
||||
cwd: launchCwd,
|
||||
codexConfigProfile: this.liveCodexProfile(rigId, node, selected?.nodeId) ?? undefined,
|
||||
// OPR.0.4.8.3 Seam B: the pod-aware restore path binds the restored posture too
|
||||
// (both restore paths consume persisted provenance — preflight surface 3).
|
||||
|
||||
@@ -8,7 +8,9 @@ import { RigNotFoundError } from "./errors.js";
|
||||
import type { ResumeMetadataRefresher } from "./resume-metadata-refresher.js";
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import { removeManagedBlocksFromFile, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE } from "./managed-blocks.js";
|
||||
import { removeManagedBlocksFromFile } from "./managed-blocks.js";
|
||||
import { seatGuidanceFile } from "./restore-guidance.js";
|
||||
import { cleanupOwnedClaudeCwd } from "./claude-owned-settings.js";
|
||||
import { stopTranscriptRotation } from "./transcript-rotation.js";
|
||||
import { findOtherSessionOwner } from "./session-owner.js";
|
||||
import { communityHarnessOf } from "./built-in-runtimes.js";
|
||||
@@ -29,6 +31,10 @@ export interface TeardownResult {
|
||||
/** Managed guidance blocks left in a guidance file because a person edited them, as `<file>: <block id>`. Present
|
||||
* only when something was kept. */
|
||||
keptGuidance?: string[];
|
||||
/** #875: files in a Claude seat's cwd that still hold OpenRig's additions because down couldn't safely change them
|
||||
* (settings that don't parse, a collector that isn't the shipped one), as `<file>: <why>`. Present only when
|
||||
* something was kept. */
|
||||
keptFiles?: string[];
|
||||
/** Sessions of seats joined with `rig attach` as an external CLI, which down doesn't stop or check. Without --delete
|
||||
* each keeps its claim as it was, whether or not the app still runs. Present only when there was one. */
|
||||
leftRunning?: string[];
|
||||
@@ -54,6 +60,8 @@ interface TeardownDeps {
|
||||
eventBus: EventBus;
|
||||
resumeMetadataRefresher?: ResumeMetadataRefresher;
|
||||
serviceOrchestrator?: import("./service-orchestrator.js").ServiceOrchestrator;
|
||||
/** The shipped Claude context collector. A cwd's copy is removed only while its bytes match this file. */
|
||||
claudeCollectorAssetPath?: string;
|
||||
}
|
||||
|
||||
interface LatestNodeSession {
|
||||
@@ -126,6 +134,7 @@ export class RigTeardownOrchestrator {
|
||||
// Additional intent is best-effort; keep down's existing result if it cannot be recorded.
|
||||
await this.recordAlreadyStoppedIntent(rigId).catch(() => {});
|
||||
this.cleanupManagedGuidanceFiles(rigId, currentLiveGuidanceTargets(), result);
|
||||
this.cleanupClaudeCwds(rigId, result);
|
||||
result.alreadyStopped = true;
|
||||
// Still tear down services even if no agent sessions are running
|
||||
if (this.deps.serviceOrchestrator) {
|
||||
@@ -230,6 +239,7 @@ export class RigTeardownOrchestrator {
|
||||
// Stop live sessions before probing historical rows; bookkeeping must not fail down.
|
||||
await this.recordAlreadyStoppedIntent(rigId).catch(() => {});
|
||||
this.cleanupManagedGuidanceFiles(rigId, currentLiveGuidanceTargets(), result);
|
||||
this.cleanupClaudeCwds(rigId, result);
|
||||
|
||||
// 5b. Tear down services if they exist
|
||||
if (this.deps.serviceOrchestrator) {
|
||||
@@ -417,16 +427,59 @@ export class RigTeardownOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
/** #875: take OpenRig's Claude collector, status line and relay hooks back out of each of this rig's Claude cwds that
|
||||
* no seat still uses, and report what had to be kept. */
|
||||
private cleanupClaudeCwds(rigId: string, result: TeardownResult): void {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT DISTINCT cwd FROM nodes WHERE rig_id = ? AND runtime = 'claude-code' AND cwd IS NOT NULL
|
||||
`).all(rigId) as Array<{ cwd: string }>;
|
||||
if (rows.length === 0) return;
|
||||
const inUse = this.claudeTargetsInUse(rigId);
|
||||
const done = new Set<string>();
|
||||
for (const { cwd } of rows) {
|
||||
const key = this.guidancePathKey(cwd);
|
||||
if (inUse.has(key) || done.has(key)) continue;
|
||||
done.add(key); // one cleanup per directory, however many of this rig's seats name it
|
||||
try {
|
||||
const isShared = (path: string) => inUse.has(this.guidancePathKey(path));
|
||||
for (const entry of cleanupOwnedClaudeCwd(cwd, this.deps.claudeCollectorAssetPath, isShared)) {
|
||||
if (!result.keptFiles?.includes(entry)) (result.keptFiles ??= []).push(entry);
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Claude settings cleanup in ${cwd}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What a Claude seat may still use, by the same rule as guidance files: the cwd of a Claude seat of any other rig that
|
||||
* isn't archived, or of one of this rig's Claude seats still live (a failed stop, an inconclusive probe, an attached
|
||||
* CLI). Each cwd's settings file, collector, `.claude` and `.openrig` are keyed by their own identity too, so a target
|
||||
* another directory reaches through a symlink or a hardlink counts as in use. */
|
||||
private claudeTargetsInUse(rigId: string): Set<string> {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT n.cwd FROM nodes n
|
||||
JOIN rigs r ON r.id = n.rig_id
|
||||
WHERE r.archived_at IS NULL AND r.id <> ? AND n.runtime = 'claude-code' AND n.cwd IS NOT NULL
|
||||
`).all(rigId) as Array<{ cwd: string }>;
|
||||
const cwds = rows.map((row) => row.cwd);
|
||||
for (const session of this.getLatestLiveSessions(rigId)) {
|
||||
if (session.runtime === "claude-code" && session.cwd) cwds.push(session.cwd);
|
||||
}
|
||||
const keys = new Set<string>();
|
||||
for (const cwd of cwds) {
|
||||
for (const target of [".", ".claude", nodePath.join(".claude", "settings.local.json"), ".openrig", nodePath.join(".openrig", "context-collector.cjs")]) {
|
||||
keys.add(this.guidancePathKey(nodePath.join(cwd, target)));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private guidanceTargetPath(rigId: string, runtime: string | null, cwd: string | null): string | null {
|
||||
if (!runtime || !cwd) {
|
||||
return null;
|
||||
}
|
||||
// #25: clean only the rig's selected Claude file; the other file is never touched.
|
||||
return runtime === "claude-code"
|
||||
? nodePath.join(cwd, this.deps.rigRepo.getRigClaudeManagedBlockFile(rigId) ?? DEFAULT_CLAUDE_MANAGED_BLOCK_FILE)
|
||||
: runtime === "codex"
|
||||
? nodePath.join(cwd, "AGENTS.md")
|
||||
: null;
|
||||
return seatGuidanceFile(runtime, cwd, this.deps.rigRepo.getRigClaudeManagedBlockFile(rigId));
|
||||
}
|
||||
|
||||
private guidancePathKey(path: string): string {
|
||||
|
||||
@@ -50,6 +50,13 @@ function typedYesOrNo(answer: unknown): "yes" | "no" | null {
|
||||
return SAID_YES.has(words) ? "yes" : SAID_NO.has(words) ? "no" : null;
|
||||
}
|
||||
|
||||
/** A person's typed reply that says more than a plain yes or no: their own words, whole. */
|
||||
function typedWords(answer: unknown): string | null {
|
||||
if (!answer || typeof answer !== "object" || (answer as { kind?: unknown }).kind !== "typed-reply" || typedYesOrNo(answer)) return null;
|
||||
const words = String((answer as { text?: unknown }).text ?? "").trim();
|
||||
return words || null;
|
||||
}
|
||||
|
||||
export type PermissionOwnerRefusal = { ok: false; code: "permissions_to_not_a_person" | "permissions_to_unavailable"; message: string };
|
||||
|
||||
/** The registered person `ref` names, as their canonical address. Only a person answers in this version: a seat address
|
||||
@@ -102,8 +109,8 @@ export type SubmitResult =
|
||||
| { ok: false; code: SubmitRefusalCode; message: string };
|
||||
export type ReadResult =
|
||||
| { ok: true; state: "pending" | "withdrawn" | "stale" }
|
||||
| { ok: true; state: "answered" | "permitted"; optionId: string }
|
||||
| { ok: true; state: "refused"; reason: "no_allow_offered" | "said_no" }
|
||||
| { ok: true; state: "answered" | "permitted"; optionId: string; said?: string }
|
||||
| { ok: true; state: "refused"; reason: "no_allow_offered" | "said_no"; said?: string }
|
||||
| { ok: false; code: "not_found"; message: string };
|
||||
export type WithdrawResult = ReadResult | { ok: false; code: "invalid_permission_request"; message: string };
|
||||
|
||||
@@ -261,14 +268,14 @@ export class RunnerPermissions {
|
||||
if (changedId === askId || changedId === liveId) release();
|
||||
});
|
||||
try {
|
||||
const now = this.read(seat, askId);
|
||||
const now = this.withWords(askId, this.read(seat, askId));
|
||||
if (!now.ok || now.state !== "pending" || waitMs <= 0) return now;
|
||||
const asked = this.deps.queueRepo.getById(askId)!;
|
||||
liveId = this.liveEnd(asked)?.qitemId ?? askId;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
await Promise.race([changed, new Promise<void>((resolve) => { timer = setTimeout(resolve, waitMs); })]);
|
||||
if (timer) clearTimeout(timer);
|
||||
return this.read(seat, askId);
|
||||
return this.withWords(askId, this.read(seat, askId));
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
@@ -287,6 +294,15 @@ export class RunnerPermissions {
|
||||
return { ok: true, state: "withdrawn" };
|
||||
}
|
||||
|
||||
/** A person who closed the ask with their own words, not a choice or a plain yes or no: the read passes them back as
|
||||
* `said`, from the row that holds the answer, for the runner to give the agent. The outcome and option are unchanged. */
|
||||
private withWords(askId: string, result: ReadResult): ReadResult {
|
||||
if (!result.ok || !(result.state === "permitted" || (result.state === "refused" && result.reason === "no_allow_offered"))) return result;
|
||||
const asked = this.deps.queueRepo.getById(askId);
|
||||
const said = typedWords((asked ? this.liveEnd(asked) : null)?.humanAnswers?.[QUESTION_ID]);
|
||||
return said ? { ...result, said } : result;
|
||||
}
|
||||
|
||||
/** A person who routes or hands the ask to a colleague means the colleague decides: follow each handoff (a handed-off
|
||||
* row, or one completed with closure handed_off_to) along its own edge, to the row handed from it at the handed-to
|
||||
* address. Null when that can't be followed on this host. */
|
||||
|
||||
@@ -672,9 +672,11 @@ export class SeatActivityService {
|
||||
if (trust === "positive-only" && evd.activity !== "working") continue;
|
||||
// A durable rung's idle or attention boundary holds; a bare working boundary still expires, then falls to the
|
||||
// rungs below, so a turn whose end was lost can't read working forever.
|
||||
// A runner's counted working beat lasts its own livenessSeconds instead (the runner contract §4).
|
||||
if (rung === "lifecycle-hooks" && !(this.durableBoundaries(seat, rung) && (evd.activity !== "working"
|
||||
|| (this.latestByRung(seat, rung)?.needsInput?.count ?? 0) > 0))
|
||||
&& nowMs - Date.parse(evd.observedAt) > HOOK_AUTHORITY_WINDOW_MS) continue;
|
||||
&& nowMs - Date.parse(evd.observedAt) > (evd.activity === "working" && evd.workingLivenessSeconds
|
||||
? evd.workingLivenessSeconds * 1000 : HOOK_AUTHORITY_WINDOW_MS)) continue;
|
||||
// An idle boundary whose source promised to keep reporting holds for two reports. A source that goes quiet (a runner
|
||||
// killed or out of memory) stops reading idle, but only when nothing is waiting on a person: attention holds.
|
||||
if (rung === "lifecycle-hooks" && evd.activity === "idle-at-prompt" && evd.livenessSeconds
|
||||
|
||||
@@ -135,6 +135,12 @@ interface SwitchRow {
|
||||
committed_at: string | null; outcome: string | null; attention: string | null; created_at: string;
|
||||
}
|
||||
|
||||
/** What to do about a seat with no pane to switch. */
|
||||
const NO_PANE = "it has no bound tmux pane";
|
||||
const NO_PANE_GUIDANCE = "If the seat is stopped, start it first, then switch it. If it has never started, there's nothing to switch: "
|
||||
+ "declare the other runtime for this member in its rig spec and bring the team up again from that spec. That makes a new "
|
||||
+ "generation of the seat, and it doesn't carry this seat's edges, settings or queued work the way a switch does.";
|
||||
|
||||
const COSTS = [
|
||||
"A new conversation: native history isn't converted; only the handover carries over.",
|
||||
"Same address, different terminal: the seat's session and address stay, its pane and scrollback change.",
|
||||
@@ -245,13 +251,14 @@ export class SeatRuntimeSwitchService {
|
||||
}
|
||||
const predecessorGeneration = sessionRegistry.currentOccupantTenure(node.id)?.generationUuid;
|
||||
const running = sessionRegistry.getSessionsForRig(node.rigId).filter((session) => session.nodeId === node.id && session.status === "running");
|
||||
const unsupported = !sessionName || !binding?.tmuxPane ? "it has no bound tmux pane"
|
||||
const unsupported = !sessionName || !binding?.tmuxPane ? NO_PANE
|
||||
: binding.attachmentType && binding.attachmentType !== "tmux" ? `it's attached through ${binding.attachmentType}, not tmux`
|
||||
: !predecessorGeneration ? "its current occupant generation isn't recorded"
|
||||
: running.length !== 1 || running[0]!.sessionName !== sessionName ? "it doesn't have exactly one running session at its address"
|
||||
: !node.cwd || !nodePath.isAbsolute(node.cwd) ? "its working directory isn't an absolute path"
|
||||
: null;
|
||||
if (unsupported) return refuse("runtime_switch_unsupported", `${seatRef} can't switch runtime: ${unsupported}.`);
|
||||
if (unsupported) return refuse("runtime_switch_unsupported", `${seatRef} can't switch runtime: ${unsupported}.`,
|
||||
unsupported === NO_PANE ? NO_PANE_GUIDANCE : undefined);
|
||||
try {
|
||||
const [panes, windows] = await Promise.all([tmux.listPanes(sessionName!), tmux.listWindows(sessionName!)]);
|
||||
if (windows.length !== 1 || panes.length !== 1 || panes[0]!.id !== binding!.tmuxPane) {
|
||||
|
||||
@@ -724,8 +724,8 @@ export interface SkillRouterFootprint {
|
||||
* Slice 20 seam: the exact preview of the bootstrap line's footprint, before any write: the line verbatim, the exact text
|
||||
* written (the line between OpenRig's markers) and where it goes, and each file
|
||||
* it would go into (a Claude seat's rig file, `CLAUDE.md` or `CLAUDE.local.md`; a Codex seat's `AGENTS.md`), one entry
|
||||
* per file with every seat sharing it. Pure: no filesystem or settings read. The file rule is main's
|
||||
* `seatGuidanceFile` (restore-guidance.ts), repeated here until this branch carries it.
|
||||
* per file with every seat sharing it. Pure: no filesystem or settings read. The file rule is `seatGuidanceFile`'s
|
||||
* (restore-guidance.ts) for the two runtimes that get the line, Claude and Codex, repeated here.
|
||||
*/
|
||||
export function describeSkillRouterFootprint(seats: FootprintSeat[]): SkillRouterFootprint {
|
||||
const byPath = new Map<string, { path: string; runtime: "claude-code" | "codex"; seats: string[] }>();
|
||||
|
||||
@@ -410,15 +410,21 @@ export class TranscriptStore {
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
const CHUNK_SIZE = 64 * 1024;
|
||||
let remainder = "";
|
||||
let remainder: string[] = [];
|
||||
|
||||
for (let offset = 0; offset < stat.size; offset += CHUNK_SIZE) {
|
||||
const readSize = Math.min(CHUNK_SIZE, stat.size - offset);
|
||||
const buf = readTranscriptChunk(fd, readSize, offset);
|
||||
// StringDecoder handles incomplete multibyte sequences at chunk boundaries
|
||||
const chunk = remainder + decoder.write(buf);
|
||||
const lines = chunk.split("\n");
|
||||
remainder = lines.pop() ?? "";
|
||||
const lines = decoder.write(buf).split("\n");
|
||||
// Scan only the new chunk. Joining and splitting the growing remainder
|
||||
// on every read makes a long captured line quadratic.
|
||||
if (lines.length === 1) {
|
||||
remainder.push(lines[0]!);
|
||||
continue;
|
||||
}
|
||||
lines[0] = remainder.join("") + lines[0];
|
||||
remainder = [lines.pop() ?? ""];
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const stripped = this.stripAnsi(rawLine);
|
||||
@@ -432,7 +438,7 @@ export class TranscriptStore {
|
||||
}
|
||||
|
||||
// Flush any remaining bytes from the decoder
|
||||
const finalChunk = remainder + decoder.end();
|
||||
const finalChunk = remainder.join("") + decoder.end();
|
||||
if (finalChunk) {
|
||||
const stripped = this.stripAnsi(finalChunk);
|
||||
for (const subLine of stripped.split("\n")) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The runner contract's working beat (section 4): `active` / `liveness` says only that a runner's accepted prompt is
|
||||
* still outstanding and its agent alive, so a long, quiet turn keeps reading working past the 15 s a bare working post
|
||||
* decides for. It isn't progress, and it never starts or extends a turn, clears attention or stands in for a `Stop`.
|
||||
*
|
||||
* A beat counts only when it carries the seat's live occupant generation, for that generation's latest accepted turn
|
||||
* that hasn't ended, with a sequence above every earlier counted post's, and while the activity service shows no
|
||||
* attention outstanding. Attention is read from the service itself, so any post that clears it there (the runner's
|
||||
* `active` / `permission_resolved` after delivering an answer, another counted working or idle post, an operator's
|
||||
* clear) lets that turn's beats count again. The gate orders posts as the service orders a runner's durable
|
||||
* boundaries, so an older post arriving late changes neither. Anything else is ignored, which is how the seat reads
|
||||
* without beats.
|
||||
*
|
||||
* The gate keeps what it saw in memory. After a daemon restart beats are ignored until the runner's next accepted
|
||||
* prompt, so the seat reads as it would without them.
|
||||
*/
|
||||
|
||||
import { hookPrecedes } from "./agent-activity-store.js";
|
||||
|
||||
export const WORKING_LIVENESS_DEFAULT_SECONDS = 30;
|
||||
export const WORKING_LIVENESS_MIN_SECONDS = 5;
|
||||
export const WORKING_LIVENESS_MAX_SECONDS = 60;
|
||||
|
||||
export type WorkingLivenessIgnored =
|
||||
| "no_turn" | "stale_generation" | "turn_ended" | "wrong_turn" | "no_sequence" | "out_of_order" | "attention_outstanding";
|
||||
|
||||
export function isWorkingLivenessBeat(hookEvent: unknown, subtype: unknown): boolean {
|
||||
return hookEvent === "active" && subtype === "liveness";
|
||||
}
|
||||
|
||||
/** How long one beat counts as working: the beat's own livenessSeconds clamped to 5-60 s, or 30 s without one. A value
|
||||
* out of range still gives bounded evidence; it's never a refusal. */
|
||||
export function workingLivenessSeconds(raw: unknown): number {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) return WORKING_LIVENESS_DEFAULT_SECONDS;
|
||||
return Math.min(WORKING_LIVENESS_MAX_SECONDS, Math.max(WORKING_LIVENESS_MIN_SECONDS, Math.round(raw)));
|
||||
}
|
||||
|
||||
/** A counted post that isn't a beat, as the gate needs it. */
|
||||
export interface WorkingLivenessPost {
|
||||
generation: string | null;
|
||||
hookEvent: string;
|
||||
subtype: string | null;
|
||||
turnId: string | null;
|
||||
sequence: number | null;
|
||||
/** When it happened, as the activity service orders it. */
|
||||
eventAt: string | null;
|
||||
/** The runner's report that its agent process ended. */
|
||||
exit: boolean;
|
||||
}
|
||||
|
||||
interface SeatTurn {
|
||||
generation: string | null;
|
||||
/** The latest accepted turn, with the generation its prompt was accepted under. */
|
||||
turn: { id: string; generation: string | null } | null;
|
||||
open: boolean;
|
||||
sequence: number | null;
|
||||
latest: { eventAt: string | null; sourceSequence: number | null } | null;
|
||||
}
|
||||
|
||||
export class WorkingLivenessGate {
|
||||
private readonly seats = new Map<string, SeatTurn>();
|
||||
|
||||
/** Every post the activity service counts for a seat, other than beats. A new generation starts the seat over; a post
|
||||
* older than the latest one changes nothing, as the service drops it. */
|
||||
observe(nodeId: string, post: WorkingLivenessPost): void {
|
||||
let seat = this.seats.get(nodeId);
|
||||
if (!seat || (post.generation && seat.generation && post.generation !== seat.generation)) {
|
||||
seat = { generation: post.generation, turn: null, open: false, sequence: null, latest: null };
|
||||
this.seats.set(nodeId, seat);
|
||||
}
|
||||
const at = { eventAt: post.eventAt, sourceSequence: post.sequence };
|
||||
if (seat.latest && hookPrecedes(at, seat.latest)) return;
|
||||
seat.latest = at;
|
||||
if (post.generation) seat.generation = post.generation;
|
||||
if (post.sequence !== null) seat.sequence = Math.max(seat.sequence ?? 0, post.sequence);
|
||||
if (post.hookEvent === "UserPromptSubmit" && post.subtype === "accepted" && post.turnId) {
|
||||
seat.turn = { id: post.turnId, generation: post.generation };
|
||||
seat.open = true;
|
||||
}
|
||||
if (post.hookEvent === "Stop" || post.exit) seat.open = false;
|
||||
}
|
||||
|
||||
/** Whether a beat counts. `liveGeneration` is the seat's current occupant's, and `attention` whether the activity
|
||||
* service shows the seat waiting on a person. It changes nothing: `count` does, once the beat is recorded. */
|
||||
admit(nodeId: string, beat: { generation: string | null; liveGeneration: string | null; turnId: string | null;
|
||||
sequence: number | null; eventAt: string | null; attention: boolean }):
|
||||
{ ok: true } | { ok: false; reason: WorkingLivenessIgnored } {
|
||||
const seat = this.seats.get(nodeId);
|
||||
if (!seat?.turn) return { ok: false, reason: "no_turn" };
|
||||
if (!beat.generation || beat.generation !== beat.liveGeneration || beat.generation !== seat.turn.generation) {
|
||||
return { ok: false, reason: "stale_generation" };
|
||||
}
|
||||
if (!seat.open) return { ok: false, reason: "turn_ended" };
|
||||
if (beat.turnId !== seat.turn.id) return { ok: false, reason: "wrong_turn" };
|
||||
if (beat.sequence === null) return { ok: false, reason: "no_sequence" };
|
||||
const at = { eventAt: beat.eventAt, sourceSequence: beat.sequence };
|
||||
if (seat.sequence === null || beat.sequence <= seat.sequence || (seat.latest && hookPrecedes(at, seat.latest))) {
|
||||
return { ok: false, reason: "out_of_order" };
|
||||
}
|
||||
if (beat.attention) return { ok: false, reason: "attention_outstanding" };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** A beat that was admitted and recorded, at the time the store recorded for it (its `occurredAt`, or when it arrived).
|
||||
* One older than the latest counted post is dropped, as `observe` and the activity service drop it. Otherwise it
|
||||
* raises the seat's sequence, so a delayed or repeated beat doesn't count again, and later posts are ordered against
|
||||
* its time. */
|
||||
count(nodeId: string, beat: { sequence: number | null; eventAt: string | null }): void {
|
||||
const seat = this.seats.get(nodeId);
|
||||
if (!seat || beat.sequence === null) return;
|
||||
const at = { eventAt: beat.eventAt, sourceSequence: beat.sequence };
|
||||
if (seat.latest && hookPrecedes(at, seat.latest)) return;
|
||||
seat.sequence = Math.max(seat.sequence ?? 0, beat.sequence);
|
||||
seat.latest = at;
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,7 @@ function validateFile(
|
||||
|
||||
let parsed: Record<string, unknown> | null;
|
||||
try {
|
||||
if (fm.body === null) throw new Error("unterminated frontmatter: missing closing --- delimiter");
|
||||
parsed = parseYaml(fm.body) as Record<string, unknown> | null;
|
||||
} catch (err) {
|
||||
out.gaps.push({
|
||||
@@ -186,7 +187,7 @@ function validateFile(
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
out.gaps.push({
|
||||
filePath,
|
||||
relativePath,
|
||||
@@ -229,7 +230,7 @@ function validateFile(
|
||||
|
||||
interface FrontmatterExtraction {
|
||||
found: boolean;
|
||||
body: string;
|
||||
body: string | null;
|
||||
}
|
||||
|
||||
/** Extract YAML frontmatter delimited by `---` lines. The opening `---`
|
||||
@@ -245,5 +246,5 @@ function extractFrontmatter(raw: string): FrontmatterExtraction {
|
||||
return { found: true, body: lines.slice(1, i).join("\n") };
|
||||
}
|
||||
}
|
||||
return { found: false, body: "" };
|
||||
return { found: true, body: null };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Hono, type Context } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import type { AgentActivityStore } from "../domain/agent-activity-store.js";
|
||||
import { hookEventState, isRunnerExit } from "../domain/agent-activity-store.js";
|
||||
import { WorkingLivenessGate, isWorkingLivenessBeat, workingLivenessSeconds, type WorkingLivenessIgnored } from "../domain/working-liveness.js";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import { verifyStartupProof } from "../domain/startup-proof.js";
|
||||
import { ActivityGrantStore, grantGeneration, type ActivityGrant } from "../domain/activity-grants.js";
|
||||
@@ -34,6 +35,8 @@ import {
|
||||
* exclusion); the turn's working/idle stays whatever other evidence says. */
|
||||
export function evidenceFromHookActivity(input: {
|
||||
seatNodeId: string;
|
||||
/** A counted working beat: how long it counts as working, in place of the hook window. */
|
||||
workingLivenessSeconds?: number;
|
||||
sessionName: string;
|
||||
runtime: string | null;
|
||||
activity: AgentActivity;
|
||||
@@ -49,6 +52,7 @@ export function evidenceFromHookActivity(input: {
|
||||
observedAt: input.activity.eventAt ?? input.activity.sampledAt,
|
||||
...(input.activity.sourceSequence ? { sourceSequence: input.activity.sourceSequence } : {}),
|
||||
...(input.activity.livenessSeconds ? { livenessSeconds: input.activity.livenessSeconds } : {}),
|
||||
...(input.workingLivenessSeconds ? { workingLivenessSeconds: input.workingLivenessSeconds } : {}),
|
||||
...(input.route ? { route: input.route } : {}),
|
||||
};
|
||||
if (input.activity.reason === "permission_request_pending") {
|
||||
@@ -92,7 +96,10 @@ export function restoredRunnerHookEvidence(
|
||||
const state = !latest.stale ? latest.state
|
||||
: latest.reason === "stale_runtime_hook"
|
||||
? hookEventState(latest.rawEvent, latest.rawSubtype, runtime, latest.communityRunner === true) : "unknown";
|
||||
// A working beat keeps the lifetime it was admitted with, as when it was posted.
|
||||
return evidenceFromHookActivity({ seatNodeId: seat.seatNodeId, sessionName: seat.sessionName, runtime,
|
||||
...(isWorkingLivenessBeat(latest.rawEvent, latest.rawSubtype)
|
||||
? { workingLivenessSeconds: workingLivenessSeconds(latest.livenessSeconds) } : {}),
|
||||
activity: { ...latest, state }, seq: 0 });
|
||||
}
|
||||
|
||||
@@ -105,6 +112,20 @@ export function hookRelayRoute(value: unknown): EvidenceRoute | undefined {
|
||||
|
||||
// Per-source monotonic seq for ingested hook evidence (the relay does not mint one).
|
||||
const hookEvidenceSeq = new Map<string, number>();
|
||||
// What each seat's runner has said about its turns, for deciding whether a working beat counts (the runner contract §4).
|
||||
const workingLiveness = new WorkingLivenessGate();
|
||||
function sourceSequence(body: Record<string, unknown>): number | null {
|
||||
return typeof body.sequence === "number" && Number.isSafeInteger(body.sequence) && body.sequence > 0 ? body.sequence : null;
|
||||
}
|
||||
/** The seat's current occupant generation, as a runner's permission request checks it; null when none resolves. */
|
||||
function liveOccupantGeneration(c: Context, store: AgentActivityStore, nodeId: string): string | null {
|
||||
const registry = (c.get("sessionRegistry" as never) as SessionRegistry | undefined) ?? new SessionRegistry(store.db);
|
||||
try {
|
||||
return registry.currentOccupantTenure(nodeId)?.generationUuid ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function nextHookSeq(key: string): number {
|
||||
const next = (hookEvidenceSeq.get(key) ?? 0) + 1;
|
||||
hookEvidenceSeq.set(key, next);
|
||||
@@ -600,13 +621,30 @@ async function postHook(c: Context, installTokenAccepted: boolean): Promise<Resp
|
||||
}
|
||||
|
||||
const recorded = recordHookActivity(c, store, body);
|
||||
if ("ignored" in recorded) return c.json({ ok: true, ignored: recorded.ignored });
|
||||
return "refused" in recorded ? recorded.refused : c.json({ ok: true, activity: recorded.activity });
|
||||
}
|
||||
|
||||
/** Record one hook event for its seat and feed it to the activity oracle: the tail of the hook ingress, also used when a
|
||||
* runner's permission request is submitted (its permission prompt). */
|
||||
function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<string, unknown>):
|
||||
{ activity: AgentActivity } | { refused: Response } {
|
||||
{ activity: AgentActivity } | { refused: Response } | { ignored: WorkingLivenessIgnored } {
|
||||
const oracle = c.get("seatActivityService" as never) as
|
||||
| import("../domain/seat-activity-service.js").SeatActivityService
|
||||
| undefined;
|
||||
// A working beat counts only for the live occupant's open turn, in order and outside attention. One that doesn't is
|
||||
// ignored before anything is recorded, so it can't change what the seat or its send gate reads.
|
||||
const beat = isWorkingLivenessBeat(body.hookEvent, body.subtype);
|
||||
if (beat) {
|
||||
const sessionName = stringOrNull(body.sessionName);
|
||||
const nodeId = stringOrNull(body.nodeId) ?? (sessionName ? store.resolveSession({ sessionName })?.nodeId ?? null : null);
|
||||
const admitted = nodeId
|
||||
? workingLiveness.admit(nodeId, { generation: stringOrNull(body.generation), liveGeneration: liveOccupantGeneration(c, store, nodeId),
|
||||
turnId: stringOrNull(body.turnId), sequence: sourceSequence(body), eventAt: stringOrNull(body.occurredAt),
|
||||
attention: (oracle?.getSeatState(nodeId)?.needsInput.count ?? 0) > 0 })
|
||||
: { ok: false as const, reason: "no_turn" as const };
|
||||
if (!admitted.ok) return { ignored: admitted.reason };
|
||||
}
|
||||
const result = store.recordHookEvent({
|
||||
runtime: stringOrNull(body.runtime),
|
||||
sessionName: stringOrNull(body.sessionName),
|
||||
@@ -621,9 +659,11 @@ function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<
|
||||
turnId: stringOrNull(body.turnId),
|
||||
...(body.hookAgentId === null || (typeof body.hookAgentId === "string" && body.hookAgentId.trim().length > 0)
|
||||
? { hookAgentId: body.hookAgentId } : {}),
|
||||
sourceSequence: typeof body.sequence === "number" && Number.isSafeInteger(body.sequence) && body.sequence > 0 ? body.sequence : null,
|
||||
livenessSeconds: typeof body.livenessSeconds === "number" && Number.isSafeInteger(body.livenessSeconds)
|
||||
&& body.livenessSeconds > 0 && body.livenessSeconds <= 3600 ? body.livenessSeconds : null,
|
||||
sourceSequence: sourceSequence(body),
|
||||
// A counted beat is recorded with the lifetime it was admitted with, so a restore reads it back the same.
|
||||
livenessSeconds: beat ? workingLivenessSeconds(body.livenessSeconds)
|
||||
: typeof body.livenessSeconds === "number" && Number.isSafeInteger(body.livenessSeconds)
|
||||
&& body.livenessSeconds > 0 && body.livenessSeconds <= 3600 ? body.livenessSeconds : null,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
@@ -635,9 +675,6 @@ function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<
|
||||
// normalized) event becomes ladder evidence on the lifecycle-hooks rung. The store
|
||||
// remains the raw-event recorder (startup-proof, delivery verification); arbitration
|
||||
// happens only in SeatActivityService.
|
||||
const oracle = c.get("seatActivityService" as never) as
|
||||
| import("../domain/seat-activity-service.js").SeatActivityService
|
||||
| undefined;
|
||||
const emitted = result.event as { nodeId?: string; sessionName?: string; runtime?: string } | undefined;
|
||||
// Recording a historical hook is valid, but its raw activity cannot staff the
|
||||
// current oracle. The store may resolve nodeId to a newer session even when
|
||||
@@ -670,10 +707,25 @@ function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<
|
||||
runtimeRungInventory(runtime, currentSession?.community_harness ?? null, currentSession?.community_runner === 1),
|
||||
);
|
||||
}
|
||||
if (beat) {
|
||||
workingLiveness.count(emitted.nodeId, { sequence: result.activity.sourceSequence ?? null,
|
||||
eventAt: result.activity.eventAt ?? result.activity.sampledAt ?? null });
|
||||
} else {
|
||||
workingLiveness.observe(emitted.nodeId, {
|
||||
generation: result.activity.generation ?? null,
|
||||
hookEvent: result.activity.rawEvent ?? (typeof body.hookEvent === "string" ? body.hookEvent : ""),
|
||||
subtype: result.activity.rawSubtype ?? stringOrNull(body.subtype),
|
||||
turnId: result.activity.turnId ?? null,
|
||||
sequence: result.activity.sourceSequence ?? null,
|
||||
eventAt: result.activity.eventAt ?? result.activity.sampledAt ?? null,
|
||||
exit: result.activity.rawEvent === "Stop" && isRunnerExit(result.activity.rawSubtype, runtime, result.activity.communityRunner === true),
|
||||
});
|
||||
}
|
||||
const evidence = evidenceFromHookActivity({
|
||||
seatNodeId: emitted.nodeId,
|
||||
sessionName: emitted.sessionName,
|
||||
runtime,
|
||||
...(beat ? { workingLivenessSeconds: workingLivenessSeconds(body.livenessSeconds) } : {}),
|
||||
activity: result.activity,
|
||||
seq: nextHookSeq(`${emitted.nodeId}:${runtime ?? "unknown-runtime"}:hooks`),
|
||||
route: hookRelayRoute(body.relayRoute),
|
||||
|
||||
@@ -1179,6 +1179,7 @@ export function queueRoutes(): Hono {
|
||||
activeOnly,
|
||||
handedOffFrom,
|
||||
updatedSince,
|
||||
sortByUpdated: c.req.query("sort") === "updated",
|
||||
});
|
||||
return c.json(items);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { PodRigInstantiator } from "../domain/rigspec-instantiator.js";
|
||||
import type { ResumeMetadataRefresher } from "../domain/resume-metadata-refresher.js";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import type { Node, RigWithRelations, Snapshot, SnapshotData } from "../domain/types.js";
|
||||
import { defaultProbeRuntimes } from "../domain/kernel-boot.js";
|
||||
import { defaultProbeRuntimes, prepareNewKernelCwd } from "../domain/kernel-boot.js";
|
||||
import { SettingsStore } from "../domain/user-settings/settings-store.js";
|
||||
import { buildRestorePlanPreview, collectPreviewSessionRows } from "../domain/restore-plan-preview.js";
|
||||
import { assessCurrentStateRehydrateEligibility } from "../domain/rehydrate-eligibility.js";
|
||||
@@ -215,7 +215,7 @@ startupRoutes.post("/kernel", (c) => exclusive(c, "kernel", async () => {
|
||||
const root = kernelRoot();
|
||||
const source = readFileSync(root + (body.runtime === "codex" ? "rig-codex-only.yaml" : "rig-claude-only.yaml"), "utf8");
|
||||
const result = await dep<PodRigInstantiator>(c, "podInstantiator").materialize(source, root, {
|
||||
cwdOverride: new SettingsStore().resolveConfig().workspaceRoot,
|
||||
cwdOverride: prepareNewKernelCwd(new SettingsStore().resolveConfig().workspaceRoot),
|
||||
});
|
||||
return c.json(result.ok ? { ok: true, rigId: result.result.rigId, message: "Kernel prepared. Choose the seats to start." } : result, result.ok ? 200 : 409);
|
||||
}));
|
||||
|
||||
@@ -16,6 +16,9 @@ import { wrapPaneEnvelope } from "../lib/pane-envelope.js";
|
||||
const UNKNOWN_SENDER_NOTICE =
|
||||
"Delivered without sender identity: this request carried no X-OpenRig-Session header, so your recipient has no way of knowing who sent it. Follow up and sign it: from a seat shell the header stamps automatically; from anywhere else, send it as a declared sender, `rig queue create --source <you>@external --destination <seat> --summary \"<one line>\" --body-file <file>` (recorded as declared; a registered human gets a reply route), or join this shell to a seat, `eval \"$(rig attach --self --rig <rigId> --node <logicalId> --print-env)\"`, so its sends carry that seat's name (`rig attach --help` covers adding a new member).";
|
||||
|
||||
// A fan-out where every recipient holds the text hasn't delivered yet, but the text still arrives unsigned.
|
||||
const UNKNOWN_SENDER_HELD_NOTICE = UNKNOWN_SENDER_NOTICE.replace(/^Delivered/, "Held for delivery");
|
||||
|
||||
// The unknown-sender From: marker, DERIVED from the canonical envelope wrapper
|
||||
// (pane-envelope.ts SENDER_FALLBACK is unexported and its literal is
|
||||
// canonicity-guarded to exactly two twin sites) — deriving at module load keeps
|
||||
@@ -428,9 +431,10 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
}
|
||||
|
||||
// S2 (OPR.0.5.4.3) sender-side honesty: an unattributed fan-out's response carries the
|
||||
// sign-it notice for the CLI renderers to surface. Attributed fan-outs are unchanged.
|
||||
if (!derivedActor) {
|
||||
return c.json({ ...result, warning: UNKNOWN_SENDER_NOTICE });
|
||||
// sign-it notice for the CLI renderers to surface. Attributed fan-outs are unchanged. A fan-out that reached nobody
|
||||
// (an unknown rig, every send refused) doesn't carry it, but a retained send is typed later and is still unsigned.
|
||||
if (!derivedActor && result.sent + (result.retained ?? 0) > 0) {
|
||||
return c.json({ ...result, warning: result.sent > 0 ? UNKNOWN_SENDER_NOTICE : UNKNOWN_SENDER_HELD_NOTICE });
|
||||
}
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
@@ -956,7 +956,8 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
|
||||
const startupOrchestrator = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter, readFile: (p: string) => fs.readFileSync(p, "utf-8") });
|
||||
const runtimeSettings = new ContextPackSettingsStore().resolveConfig();
|
||||
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, claudeManagedLaunch, restoreReadPolicy, 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 }), copyFile: replaceFile, 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; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: nodePath.resolve(import.meta.dirname, "../assets/claude-statusline-context.cjs"), autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json"), recordProjection: (targetPath: string, content: string) => projectionManifestStore.record({ targetPath, lastHash: hashContent(content), writtenAt: new Date().toISOString() }) });
|
||||
const claudeCollectorAssetPath = nodePath.resolve(import.meta.dirname, "../assets/claude-statusline-context.cjs");
|
||||
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, claudeManagedLaunch, restoreReadPolicy, 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 }), copyFile: replaceFile, 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; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: claudeCollectorAssetPath, autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json"), recordProjection: (targetPath: string, content: string) => projectionManifestStore.record({ targetPath, lastHash: hashContent(content), writtenAt: new Date().toISOString() }) });
|
||||
const codexAdapter = new CodexRuntimeAdapter({ prepareTeamWorkspace, prepareSeatFolder, 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), homedir: daemonHome }, codexHome: configuredCodexHome, seatHome: (nodeId: string, ignoreAccountHome?: boolean) => seatRuntimeHome(db, nodeId, "codex", ignoreAccountHome).home, launchPath: process.env.PATH, detectDaemonSupport: codexDaemonSupportProbe(process.env.PATH, undefined, configuredCodexHome), readNetworkDefault: readCodexNetworkDefault, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), resolveCorePluginDir: () => vendoredCorePluginDir(), resolveCorePluginSelected: (nodeId: string) => Boolean(seatCodexCorePluginDir({ id: nodeId, runtime: "codex" }, db)), resolveHooksEnabled: async () => {
|
||||
const { SettingsStore } = await import("./domain/user-settings/settings-store.js");
|
||||
return new SettingsStore().resolveOne("runtime.codex.hooks_enabled").value as boolean;
|
||||
@@ -1267,7 +1268,8 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// workspace, not the daemon installation tree. Without this
|
||||
// cwdOverride, BootstrapOrchestrator refuses with
|
||||
// "cwd is inside the OpenRig installation". Use the resolved
|
||||
// workspace.root setting as the per-operator default.
|
||||
// workspace.root setting as the per-operator default; a new kernel
|
||||
// works in its `kernel` folder (prepareNewKernelCwd).
|
||||
cwdOverride: runtimeSettings.workspaceRoot,
|
||||
degradedTimeoutMs,
|
||||
// A kernel a reboot left down comes back the way `rig up kernel --existing` brings it back.
|
||||
@@ -1539,6 +1541,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
}),
|
||||
teardownOrchestrator: new RigTeardownOrchestrator({
|
||||
db, rigRepo, sessionRegistry, tmuxAdapter, snapshotCapture, eventBus, resumeMetadataRefresher, serviceOrchestrator,
|
||||
claudeCollectorAssetPath,
|
||||
}),
|
||||
podInstantiator,
|
||||
podBundleSourceResolver,
|
||||
|
||||
@@ -55,6 +55,30 @@ createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const text = message.params.prompt.map((part) => part.text).join("");
|
||||
const end = (stopReason) => reply({ stopReason });
|
||||
if (text.includes("EXIT")) process.exit(0);
|
||||
const two = ["TWOASK", "TWOTALK", "TWOQUIT"].find((word) => text.includes(word));
|
||||
if (two) {
|
||||
// Two permission requests at once; the turn ends once both are answered. After the first answer, TWOTALK says
|
||||
// something and starts a tool, and TWOQUIT exits.
|
||||
let left = 2;
|
||||
const answered = () => {
|
||||
left -= 1;
|
||||
if (left === 1 && two === "TWOTALK") { say("first answered"); update({ sessionUpdate: "tool_call", toolCallId: "t6", title: "Read e.txt", kind: "read", status: "pending" }); }
|
||||
if (left === 1 && two === "TWOQUIT") setTimeout(() => process.exit(0), 200);
|
||||
if (left === 0) { say("both answered"); end("end_turn"); }
|
||||
};
|
||||
for (const [id, file] of [["perm-5", "e.txt"], ["perm-6", "f.txt"]]) {
|
||||
waiting.set(id, answered);
|
||||
send({ id, method: "session/request_permission", params: { sessionId: session, toolCall: { toolCallId: id, title: "Edit " + file, kind: "edit" },
|
||||
options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "reject", name: "Reject", kind: "reject_once" }] } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (text.includes("QUIETASK")) {
|
||||
// Asks, then works on quietly after the answer until it's cancelled.
|
||||
waiting.set("perm-4", (response) => { say("outcome " + JSON.stringify(response.result?.outcome ?? null)); held = () => end("cancelled"); });
|
||||
return send({ id: "perm-4", method: "session/request_permission", params: { sessionId: session, toolCall: { toolCallId: "t5", title: "Edit d.txt", kind: "edit" },
|
||||
options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "reject", name: "Reject", kind: "reject_once" }] } });
|
||||
}
|
||||
if (text.includes("PERMIT")) {
|
||||
say("checking");
|
||||
waiting.set("perm-1", (response) => { say("outcome " + JSON.stringify(response.result?.outcome ?? null)); end(cancelled ? "cancelled" : "end_turn"); });
|
||||
@@ -84,15 +108,15 @@ createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
});
|
||||
`;
|
||||
|
||||
type Permissions = "absent" | "deny" | "stale" | "revoked" | "broken" | "permitted" | "answer" | "saidno" | "unknown" | "readfail" | "pending" | "lost";
|
||||
interface Hook { auth: string | undefined; body: Record<string, unknown> }
|
||||
type Permissions = "absent" | "deny" | "stale" | "revoked" | "broken" | "permitted" | "answer" | "words" | "saidno" | "unknown" | "readfail" | "pending" | "lost" | "late" | "two" | "twoslow";
|
||||
interface Hook { auth: string | undefined; body: Record<string, unknown>; at: number }
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => { for (const cleanup of cleanups.splice(0).reverse()) cleanup(); });
|
||||
|
||||
async function fakeOpenRig(permissions: Permissions) {
|
||||
const hooks: Hook[] = [];
|
||||
const submits: Array<{ auth: string | undefined; body: Record<string, unknown> }> = [];
|
||||
const submits: Array<{ auth: string | undefined; body: Record<string, unknown>; at: number }> = [];
|
||||
const withdraws: Array<{ askId: string; body: Record<string, unknown> }> = [];
|
||||
const server: Server = createServer((req, res) => {
|
||||
let raw = "";
|
||||
@@ -101,25 +125,47 @@ async function fakeOpenRig(permissions: Permissions) {
|
||||
const json = (status: number, body: unknown) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
|
||||
const url = new URL(req.url ?? "/", "http://openrig.test");
|
||||
const body = raw ? JSON.parse(raw) as Record<string, unknown> : {};
|
||||
if (req.method === "POST" && url.pathname === "/api/activity/hooks") { hooks.push({ auth: req.headers.authorization, body }); return json(200, { ok: true }); }
|
||||
if (req.method === "POST" && url.pathname === "/api/activity/hooks") { hooks.push({ auth: req.headers.authorization, body, at: Date.now() }); return json(200, { ok: true }); }
|
||||
if (permissions === "absent") return json(404, { error: "not found" });
|
||||
if (req.method === "POST" && url.pathname === "/api/activity/permission-requests") {
|
||||
submits.push({ auth: req.headers.authorization, body });
|
||||
submits.push({ auth: req.headers.authorization, body, at: Date.now() });
|
||||
if (permissions === "deny") return json(409, { ok: false, code: "denied_by_seat", error: "This seat's operator chose to refuse its runner's permission requests." });
|
||||
if (permissions === "stale") return json(409, { ok: false, code: "stale_generation", error: "This request isn't from the seat's current occupant." });
|
||||
if (permissions === "broken") return json(500, { ok: false, code: "internal_error", error: "Something went wrong." });
|
||||
if (permissions === "revoked") return json(401, { ok: false, code: "activity_grant_revoked", error: "This activity grant was revoked; nothing was asked." });
|
||||
if (permissions === "permitted") return json(200, { ok: true, state: "permitted", optionId: "allow", reason: "no one can be asked: no person is registered" });
|
||||
if (permissions === "two") return json(200, { ok: true, askId: `ask-${submits.length}`, created: true });
|
||||
// Two asks, and OpenRig takes 1.5 s to answer the second one's submit.
|
||||
if (permissions === "twoslow") {
|
||||
const askId = `ask-${submits.length}`;
|
||||
if (askId === "ask-2") { setTimeout(() => json(200, { ok: true, askId, created: true }), 1_500); return; }
|
||||
return json(200, { ok: true, askId, created: true });
|
||||
}
|
||||
return json(200, { ok: true, askId: "ask-1", created: true });
|
||||
}
|
||||
const withdraw = url.pathname.match(/^\/api\/activity\/permission-requests\/([^/]+)\/withdraw$/);
|
||||
if (req.method === "POST" && withdraw) { withdraws.push({ askId: withdraw[1]!, body }); return json(200, { ok: true, state: "withdrawn" }); }
|
||||
if (req.method === "POST" && withdraw) {
|
||||
withdraws.push({ askId: withdraw[1]!, body });
|
||||
// With two asks, a withdraw is answered after 500 ms, so a runner that's exiting is still there to post after it.
|
||||
if (permissions === "two") { setTimeout(() => json(200, { ok: true, state: "withdrawn" }), 500); return; }
|
||||
return json(200, { ok: true, state: "withdrawn" });
|
||||
}
|
||||
// Two asks: the first is answered at once, the second 2 s after it was submitted.
|
||||
const two = url.pathname.match(/^\/api\/activity\/permission-requests\/(ask-[12])$/);
|
||||
if ((permissions === "two" || permissions === "twoslow") && req.method === "GET" && two) {
|
||||
if (two[1] === "ask-1" || Date.now() - submits[1]!.at >= 2_000) return json(200, { ok: true, state: "answered", optionId: "allow" });
|
||||
setTimeout(() => json(200, { ok: true, state: "pending" }), 100);
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/activity/permission-requests/ask-1") {
|
||||
if (permissions === "answer") return json(200, { ok: true, state: "answered", optionId: "allow" });
|
||||
if (permissions === "words") return json(200, { ok: true, state: "permitted", optionId: "allow", said: "Only a.txt, please." });
|
||||
if (permissions === "saidno") return json(200, { ok: true, state: "refused", reason: "said_no" });
|
||||
if (permissions === "unknown") return json(200, { ok: true, state: "deferred" });
|
||||
if (permissions === "readfail") return json(500, { ok: false, code: "internal_error", error: "Something went wrong." });
|
||||
if (permissions === "lost") return json(404, { ok: false, code: "not_found", error: "No permission request ask-1 for this seat." });
|
||||
// A person who answers after 11 s, longer than one beat interval.
|
||||
if (permissions === "late" && Date.now() - submits[0]!.at >= 11_000) return json(200, { ok: true, state: "answered", optionId: "allow" });
|
||||
setTimeout(() => json(200, { ok: true, state: "pending" }), 100);
|
||||
return;
|
||||
}
|
||||
@@ -245,6 +291,45 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
|
||||
expect(requests(seat.log(), "session/prompt")).toHaveLength(1);
|
||||
});
|
||||
|
||||
// The working beat (contract section 4) runs on its own 10 s interval, so these two cells take that long.
|
||||
it("beats through a long, quiet turn, naming the accepted turn, and stops at the turn's end", async () => {
|
||||
const openrig = await fakeOpenRig("absent");
|
||||
const seat = start(openrig);
|
||||
await waitFor(seat.launch, "the launch record");
|
||||
seat.type("HOLD this turn");
|
||||
const accepted = await waitFor(() => has(openrig.hooks, "UserPromptSubmit/accepted"), "the accepted prompt");
|
||||
const beat = await waitFor(() => has(openrig.hooks, "active/liveness"), "a working beat", 12_000);
|
||||
expect(beat.body).toMatchObject({ turnId: "1", livenessSeconds: 30, generation: "gen-1", runtime: "dsh" });
|
||||
expect(Number(beat.body.sequence)).toBeGreaterThan(Number(accepted.body.sequence));
|
||||
expect(typeof beat.body.occurredAt).toBe("string");
|
||||
seat.type("/abort");
|
||||
await waitFor(() => has(openrig.hooks, "Stop/cancelled"), "the cancelled turn end");
|
||||
const stopAt = seen(openrig.hooks).indexOf("Stop/cancelled");
|
||||
await new Promise((done) => setTimeout(done, 10_500)); // one more beat interval
|
||||
expect(seen(openrig.hooks).slice(stopAt + 1)).not.toContain("active/liveness");
|
||||
}, 35_000);
|
||||
|
||||
it("holds the beat while a person's ask is open, then posts permission_resolved before beating again", async () => {
|
||||
const openrig = await fakeOpenRig("late");
|
||||
const seat = start(openrig);
|
||||
await waitFor(seat.launch, "the launch record");
|
||||
seat.type("QUIETASK an edit");
|
||||
const answer = await waitFor(() => responseTo(seat.log(), "perm-4"), "the person's answer, given to the agent", 16_000);
|
||||
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
|
||||
const resolved = await waitFor(() => has(openrig.hooks, "active/permission_resolved"), "permission_resolved");
|
||||
expect(resolved.body).toMatchObject({ turnId: "1", askId: "ask-1" });
|
||||
// The ask was open for more than a beat interval, and no beat went out while it was.
|
||||
const asked = openrig.submits[0]!.at;
|
||||
expect(resolved.at - asked).toBeGreaterThanOrEqual(10_000);
|
||||
expect(openrig.hooks.filter((hook) => hook.body.subtype === "liveness" && hook.at > asked && hook.at < resolved.at)).toEqual([]);
|
||||
// The same turn works on quietly after the answer, and beats again.
|
||||
const after = await waitFor(() => openrig.hooks.find((hook) => hook.body.subtype === "liveness" && hook.at > resolved.at), "a beat after the answer", 12_000);
|
||||
expect(after.body.turnId).toBe("1");
|
||||
expect(Number(after.body.sequence)).toBeGreaterThan(Number(resolved.body.sequence));
|
||||
seat.type("/abort");
|
||||
await waitFor(() => has(openrig.hooks, "Stop/cancelled"), "the cancelled turn end");
|
||||
}, 45_000);
|
||||
|
||||
it.each(["absent", "permitted"] as const)("permits with the agent's own allow when no one can be asked (OpenRig %s)", async (permissions) => {
|
||||
const openrig = await fakeOpenRig(permissions);
|
||||
const seat = start(openrig);
|
||||
@@ -254,16 +339,18 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
|
||||
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
|
||||
await waitFor(() => has(openrig.hooks, "Stop/end_turn"), "the turn's idle report");
|
||||
expect(has(openrig.hooks, "Notification/permission_prompt")).toBeUndefined();
|
||||
expect(has(openrig.hooks, "active/permission_resolved"), "OpenRig opened no ask").toBeUndefined();
|
||||
expect(seat.out()).toMatch(/\[permission\] permitted/);
|
||||
});
|
||||
|
||||
// An ask OpenRig opened (the last two) showed the seat needing input, so the permit is followed by permission_resolved.
|
||||
it.each([
|
||||
["OpenRig can't be reached", "absent", { OPENRIG_URL: "http://127.0.0.1:1" }],
|
||||
["the submit is answered with a status the contract doesn't list", "broken", {}],
|
||||
["the submit's grant was revoked, which can happen to the live occupant", "revoked", {}],
|
||||
["reading the answer gets a state the contract doesn't list", "unknown", {}],
|
||||
["reading the answer gets an unexpected reply", "lost", {}],
|
||||
] as const)("permits with the agent's own allow when %s, since nobody can be asked", async (_case, permissions, env) => {
|
||||
["OpenRig can't be reached", "absent", { OPENRIG_URL: "http://127.0.0.1:1" }, false],
|
||||
["the submit is answered with a status the contract doesn't list", "broken", {}, false],
|
||||
["the submit's grant was revoked, which can happen to the live occupant", "revoked", {}, false],
|
||||
["reading the answer gets a state the contract doesn't list", "unknown", {}, true],
|
||||
["reading the answer gets an unexpected reply", "lost", {}, true],
|
||||
] as const)("permits with the agent's own allow when %s, since nobody can be asked", async (_case, permissions, env, opened) => {
|
||||
const openrig = await fakeOpenRig(permissions);
|
||||
const seat = start(openrig, env);
|
||||
await waitFor(seat.launch, "the launch record");
|
||||
@@ -271,6 +358,13 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
|
||||
const answer = await waitFor(() => responseTo(seat.log(), "perm-1"), "the runner's answer to the permission request");
|
||||
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
|
||||
expect(has(openrig.hooks, "Notification/permission_prompt")).toBeUndefined();
|
||||
if (opened) {
|
||||
const resolved = await waitFor(() => has(openrig.hooks, "active/permission_resolved"), "permission_resolved");
|
||||
expect(resolved.body).toMatchObject({ turnId: "1", askId: "ask-1" });
|
||||
} else {
|
||||
await waitFor(() => /turn end/.test(seat.out()), "the turn's end in the pane");
|
||||
expect(has(openrig.hooks, "active/permission_resolved"), "OpenRig opened no ask").toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -285,6 +379,7 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
|
||||
expect(answer.result).toEqual({ outcome: { outcome: "cancelled" } });
|
||||
await waitFor(() => has(openrig.hooks, "Stop/end_turn"), "the turn's idle report");
|
||||
expect(has(openrig.hooks, "Notification/permission_prompt")).toBeUndefined();
|
||||
expect(has(openrig.hooks, "active/permission_resolved"), "nothing was answered or permitted").toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the seat's own refusal held through a cancel, so the cancelled turn reads needs input, not idle", async () => {
|
||||
@@ -365,6 +460,84 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
|
||||
await waitFor(() => has(openrig.hooks, "Stop/end_turn"), "the turn's idle report");
|
||||
expect(has(openrig.hooks, "Notification/permission_prompt")).toBeUndefined();
|
||||
expect(openrig.withdraws).toEqual([]);
|
||||
// The person's answer reached the agent, so the seat's needs input clears for the rest of the turn, before its end.
|
||||
const events = seen(openrig.hooks);
|
||||
expect(has(openrig.hooks, "active/permission_resolved")!.body).toMatchObject({ turnId: "1", askId: "ask-1" });
|
||||
expect(events.indexOf("active/permission_resolved")).toBeLessThan(events.indexOf("Stop/end_turn"));
|
||||
});
|
||||
|
||||
it("gives the agent its option, then queues the person's own words as a prompt, after the turn", async () => {
|
||||
const openrig = await fakeOpenRig("words");
|
||||
const seat = start(openrig);
|
||||
await waitFor(seat.launch, "the launch record");
|
||||
seat.type("PERMIT an edit");
|
||||
const answer = await waitFor(() => responseTo(seat.log(), "perm-1"), "the runner's answer to the permission request");
|
||||
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
|
||||
const prompts = () => requests(seat.log(), "session/prompt")
|
||||
.map((entry) => (entry.params as { prompt: Array<{ text: string }> }).prompt.map((part) => part.text).join(""));
|
||||
const next = await waitFor(() => prompts()[1], "the person's words as a queued prompt");
|
||||
expect(next).toContain("Only a.txt, please.");
|
||||
expect(next).toContain("Edit a.txt");
|
||||
expect(prompts()).toHaveLength(2);
|
||||
expect(seat.out()).toContain("Only a.txt, please.");
|
||||
});
|
||||
|
||||
// Two asks open at once: the first answer reaches the agent at once, but the seat still waits on a person for the second,
|
||||
// so nothing that clears needs input is posted until that one is answered too; then permission_resolved goes out once.
|
||||
it("keeps the seat needing input while a second ask waits, and resolves once, after the last answer", async () => {
|
||||
const openrig = await fakeOpenRig("two");
|
||||
const seat = start(openrig);
|
||||
await waitFor(seat.launch, "the launch record");
|
||||
seat.type("TWOASK two edits");
|
||||
for (const id of ["perm-5", "perm-6"]) {
|
||||
const answer = await waitFor(() => responseTo(seat.log(), id), `the answer to ${id}`, 8_000);
|
||||
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
|
||||
}
|
||||
await waitFor(() => has(openrig.hooks, "Stop/end_turn"), "the turn's end");
|
||||
expect(openrig.submits).toHaveLength(2);
|
||||
const resolved = openrig.hooks.filter((hook) => hook.body.subtype === "permission_resolved");
|
||||
expect(resolved.map((hook) => hook.body.turnId)).toEqual(["1"]);
|
||||
expect(resolved[0]!.at - openrig.submits[1]!.at).toBeGreaterThanOrEqual(2_000); // after the later ask's answer
|
||||
// From the first ask on (the submit isn't one of the runner's ordered posts, so it can arrive before the accepted prompt).
|
||||
const from = Math.max(openrig.submits[0]!.at, has(openrig.hooks, "UserPromptSubmit/accepted")!.at);
|
||||
const between = openrig.hooks.filter((hook) => hook.at > from && hook.at < resolved[0]!.at);
|
||||
expect(between.map((hook) => `${String(hook.body.hookEvent)}/${String(hook.body.subtype)}`)).toEqual([]);
|
||||
});
|
||||
|
||||
// The first answer reaches the agent before OpenRig has answered the second ask's submit, though it has already recorded
|
||||
// that prompt, so what the agent says and does in between mustn't read working.
|
||||
it("reports nothing that reads working while a second ask is still being submitted", async () => {
|
||||
const openrig = await fakeOpenRig("twoslow");
|
||||
const seat = start(openrig);
|
||||
await waitFor(seat.launch, "the launch record");
|
||||
seat.type("TWOTALK two edits");
|
||||
for (const id of ["perm-5", "perm-6"]) {
|
||||
const answer = await waitFor(() => responseTo(seat.log(), id), `the answer to ${id}`, 8_000);
|
||||
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
|
||||
}
|
||||
await waitFor(() => has(openrig.hooks, "Stop/end_turn"), "the turn's end");
|
||||
expect(seat.out()).toContain("first answered");
|
||||
expect(seat.out()).toContain("OpenRig asked a person (ask-2)");
|
||||
expect(seat.out().indexOf("first answered")).toBeLessThan(seat.out().indexOf("OpenRig asked a person (ask-2)"));
|
||||
const resolved = openrig.hooks.filter((hook) => hook.body.subtype === "permission_resolved");
|
||||
expect(resolved.map((hook) => hook.body.turnId)).toEqual(["1"]);
|
||||
const from = Math.max(openrig.submits[0]!.at, has(openrig.hooks, "UserPromptSubmit/accepted")!.at);
|
||||
expect(seen(openrig.hooks.filter((hook) => hook.at > from && hook.at < resolved[0]!.at))).toEqual([]);
|
||||
});
|
||||
|
||||
// The agent exits after the first answer while the second ask still waits on a person: nothing after the exit says the
|
||||
// turn goes on.
|
||||
it("posts no permission_resolved after the agent's exit", async () => {
|
||||
const openrig = await fakeOpenRig("two");
|
||||
const seat = start(openrig);
|
||||
await waitFor(seat.launch, "the launch record");
|
||||
seat.type("TWOQUIT two edits");
|
||||
await waitFor(() => responseTo(seat.log(), "perm-5") ?? responseTo(seat.log(), "perm-6"), "the first answer", 8_000);
|
||||
await seat.exited;
|
||||
expect(openrig.withdraws.map((entry) => entry.body)).toEqual([{ reason: "exited" }]);
|
||||
const events = seen(openrig.hooks);
|
||||
expect(events.at(-1)).toBe("Stop/dsh_exited");
|
||||
expect(events).not.toContain("active/permission_resolved");
|
||||
});
|
||||
|
||||
it("withdraws a pending ask when the turn is cancelled, and answers the agent cancelled", async () => {
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
// catches it.
|
||||
// node minimal-acp-runner.mjs --agent <command> [--break <name>]... [--read-wait <seconds>|none] [--withdraw-after-stop]
|
||||
// --read-wait keeps the contract another way: it reads with that waitSeconds (or none) and sleeps between pending reads.
|
||||
// --read-retries <n> keeps it another way too: a read OpenRig answers with a status other than 200 is retried n times, 1.5 s apart, before the
|
||||
// runner acts on it; section 7 sets no time limit for that.
|
||||
// --read-retries <n> keeps it another way too: a read refusal the runner would permit on (a code section 7 marks "permits",
|
||||
// or one it doesn't list) is retried n times, 1.5 s apart, before the runner permits; section 7 sets no time limit for that.
|
||||
// A refusal section 7 cancels or holds on (a request that isn't the current occupant's, or the seat's opt-in to hold) is
|
||||
// acted on at once.
|
||||
// --withdraw-after-stop keeps it another way too: a cancelled or ended turn's ask is withdrawn after the turn's Stop is
|
||||
// posted, not before; the contract doesn't order the two posts.
|
||||
// --startup-idle <ms> keeps it another way too: with no first input, it reports idle once it's ready, as a runner may, and
|
||||
// that first POST goes out <ms> late, holding the posts behind it, as a slow first request does. The agent gets each
|
||||
// prompt without waiting on its accepted post, so it can die before the late report arrives.
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { request } from "node:http";
|
||||
@@ -18,22 +23,40 @@ const readWait = argv.includes("--read-wait") ? argv[argv.indexOf("--read-wait")
|
||||
const readQuery = readWait === "none" ? "" : `?waitSeconds=${readWait}`;
|
||||
const withdrawAfterStop = argv.includes("--withdraw-after-stop");
|
||||
const readRetries = argv.includes("--read-retries") ? Number(argv[argv.indexOf("--read-retries") + 1]) : 0;
|
||||
const startupIdleMs = argv.includes("--startup-idle") ? Number(argv[argv.indexOf("--startup-idle") + 1]) : null;
|
||||
// --beat: the optional working beat (section 4). While a turn is open and nothing waits on a person, post active/liveness
|
||||
// every 10 s with livenessSeconds 30; after delivering an answer to an ask OpenRig opened, post active/permission_resolved
|
||||
// first. Breaks: beat-after-stop, beat-during-ask, no-resolve, and beat-time-backwards (each beat says it happened 15 s
|
||||
// before it did, so its sequence no longer rises with the runner's own time).
|
||||
const beatMode = argv.includes("--beat");
|
||||
let beatTurn = null;
|
||||
// With --withdraw-after-stop: the ask a cancelled or ended turn withdraws once its Stop is posted.
|
||||
let deferredWithdraw = null;
|
||||
// --break stop-before-end: a non-exit Stop posted while the ask waits stands in for the turn's end, which is then not posted.
|
||||
let stoppedEarly = false;
|
||||
// The current turn's accepted prompt's occurredAt.
|
||||
let acceptedAt = null;
|
||||
if (beatMode) setInterval(() => {
|
||||
if (beatTurn !== null && (!attention || breaks.has("beat-during-ask"))) void post("active", "liveness", { turnId: String(beatTurn), livenessSeconds: 30,
|
||||
...(breaks.has("beat-time-backwards") ? { occurredAt: new Date(Date.now() - 15_000).toISOString() } : {}) });
|
||||
}, 10_000).unref();
|
||||
const resolved = (ask) => { if (beatMode && !breaks.has("no-resolve")) void post("active", "permission_resolved", { turnId: String(turn), askId: ask.askId }); };
|
||||
const env = process.env;
|
||||
const name = env.OPENRIG_RUNTIME ?? "runner";
|
||||
|
||||
// Activity posts go one at a time, in order, and never hold up the agent.
|
||||
let sequence = 0;
|
||||
// With --break idle-string-sequence-on-exit the first post is numbered 2, so a "1" falls below the accepted prompt.
|
||||
let sequence = breaks.has("idle-string-sequence-on-exit") ? 1 : 0;
|
||||
let posting = Promise.resolve();
|
||||
let holdNextPostMs = 0;
|
||||
function post(hookEvent, subtype, extra = {}) {
|
||||
const hold = holdNextPostMs;
|
||||
holdNextPostMs = 0;
|
||||
const body = JSON.stringify({ sessionName: env.OPENRIG_SESSION_NAME, nodeId: env.OPENRIG_NODE_ID, runtime: name,
|
||||
generation: breaks.has("wrong-generation") ? "gen-other" : env.OPENRIG_OCCUPANT_GENERATION,
|
||||
...(breaks.has("no-sequence") ? {} : { sequence: ++sequence }), occurredAt: new Date().toISOString(), hookEvent, subtype, ...extra });
|
||||
const headers = { "content-type": "application/json", ...(breaks.has("no-token") ? {} : { authorization: `Bearer ${env.OPENRIG_ACTIVITY_HOOK_TOKEN}` }) };
|
||||
posting = posting.then(() => new Promise((resolve) => {
|
||||
posting = posting.then(() => hold && new Promise((resolve) => setTimeout(resolve, hold))).then(() => new Promise((resolve) => {
|
||||
const req = request(`${env.OPENRIG_URL}/api/activity/hooks`, { method: "POST", headers, timeout: 2000 }, (res) => { res.resume(); res.on("end", resolve); });
|
||||
req.on("error", resolve);
|
||||
req.on("timeout", () => { req.destroy(); resolve(); });
|
||||
@@ -98,7 +121,13 @@ agent.stdout.on("data", (chunk) => {
|
||||
let exiting = false;
|
||||
agent.on("exit", async () => {
|
||||
if (exiting) return;
|
||||
beatTurn = null;
|
||||
await withdrawLive("exited");
|
||||
// --break idle-low-sequence-on-exit: an idle Stop for the dead turn, numbered below its accepted prompt but later in time.
|
||||
if (breaks.has("idle-low-sequence-on-exit")) await post("Stop", "idle_heartbeat", { livenessSeconds: 30, sequence: 1 });
|
||||
// --break idle-string-sequence-on-exit: an idle Stop for the dead turn at its accepted prompt's time, numbered below it but
|
||||
// as a string, which OpenRig doesn't read as a sequence.
|
||||
if (breaks.has("idle-string-sequence-on-exit")) await post("Stop", "idle_heartbeat", { livenessSeconds: 30, occurredAt: acceptedAt, sequence: "1" });
|
||||
await post("Stop", breaks.has("idle-on-exit") ? "end_turn" : `${name}_exited`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -118,7 +147,8 @@ let cancelSent = false;
|
||||
const queue = [];
|
||||
|
||||
// Permissions. Every request goes to OpenRig's answered path, and the runner delivers the option that comes back: the
|
||||
// person's, or OpenRig's permit. A refusal, at submit or while reading, is read by its code: the seat's opt-in refuses and
|
||||
// person's, or OpenRig's permit. A person's own words (`said`) join the prompt queue, after the turn. A refusal,
|
||||
// at submit or while reading, is read by its code: the seat's opt-in refuses and
|
||||
// holds attention; one that says the request isn't the current occupant's answers cancelled and holds nothing, as a
|
||||
// refused, stale or withdrawn read does; anything else (404, unreachable, a "permits" code or one the contract doesn't
|
||||
// list) permits with the agent's allow_once, else allow_always, or refuses and holds when there's no allow option. An
|
||||
@@ -160,7 +190,13 @@ async function onPermission(msg) {
|
||||
// A person is asked. OpenRig records the seat's permission prompt, so the runner doesn't post it.
|
||||
attention = true;
|
||||
if (breaks.has("self-prompt")) void post("Notification", "permission_prompt");
|
||||
// --break beat-during-ask beats as soon as the ask waits too, so the check sees it whatever the 10 s timer's phase.
|
||||
if (breaks.has("beat-during-ask") && beatTurn !== null) void post("active", "liveness", { turnId: String(beatTurn), livenessSeconds: 30 });
|
||||
if (breaks.has("stop-before-end")) { stoppedEarly = true; void post("Stop", "idle_heartbeat", { livenessSeconds: 30 }); }
|
||||
// --break stop-low-sequence: an idle Stop while the ask waits, numbered below the accepted prompt but later in time.
|
||||
if (breaks.has("stop-low-sequence")) void post("Stop", "idle_heartbeat", { livenessSeconds: 30, sequence: 1 });
|
||||
// --break stop-numeric-time: an idle Stop while the ask waits, its occurredAt a number, which OpenRig dates by arrival.
|
||||
if (breaks.has("stop-numeric-time")) void post("Stop", "idle_heartbeat", { livenessSeconds: 30, occurredAt: 0 });
|
||||
// done: the agent has its answer. open: OpenRig's ask may still be open, so it's withdrawn at the turn's end.
|
||||
const ask = { id: msg.id, askId: submitted.body.askId, offered: options.map((o) => o.optionId), done: false, open: true };
|
||||
live = ask;
|
||||
@@ -169,7 +205,8 @@ async function onPermission(msg) {
|
||||
if (!busy || turn !== askedInTurn) { await withdrawLive("turn_ended"); return; }
|
||||
for (;;) {
|
||||
let read = await api("GET", `/api/activity/permission-requests/${encodeURIComponent(ask.askId)}${readQuery}`);
|
||||
for (let tries = 0; read.status >= 300 && tries < readRetries && !ask.done; tries++) {
|
||||
const permitsOn = (r) => r.status >= 300 && !NOT_CURRENT.has(r.body?.code) && !HOLDS.has(r.body?.code);
|
||||
for (let tries = 0; permitsOn(read) && tries < readRetries && !ask.done; tries++) {
|
||||
await new Promise((done) => setTimeout(done, 1500));
|
||||
read = await api("GET", `/api/activity/permission-requests/${encodeURIComponent(ask.askId)}${readQuery}`);
|
||||
}
|
||||
@@ -178,10 +215,17 @@ async function onPermission(msg) {
|
||||
if (state === "pending") { if (readWait !== "25") await new Promise((done) => setTimeout(done, 200)); continue; }
|
||||
// A listed answer means OpenRig closed its ask; anything else leaves it open, to withdraw at the turn's end.
|
||||
if (["answered", "permitted", "refused", "withdrawn", "stale"].includes(state)) ask.open = false;
|
||||
if ((state === "answered" || state === "permitted") && ask.offered.includes(read.body.optionId)) {
|
||||
// A person who answered in words: their words join the prompt queue in order, after this turn, never as the answer.
|
||||
const said = state && typeof read.body?.said === "string" && read.body.said.trim() ? read.body.said : null;
|
||||
if (said && !breaks.has("drop-words")) queue.push(`The person who answered your permission request (${toolCall?.title}) also wrote:\n\n${said}`);
|
||||
if (said && breaks.has("said-as-answer") && !ask.done) {
|
||||
refuse(ask.id);
|
||||
attention = false;
|
||||
} else if ((state === "answered" || state === "permitted") && ask.offered.includes(read.body.optionId)) {
|
||||
select(ask.id, read.body.optionId);
|
||||
if (breaks.has("deliver-twice")) select(ask.id, read.body.optionId);
|
||||
attention = false;
|
||||
resolved(ask);
|
||||
} else if (!ask.done) {
|
||||
// A refused, withdrawn or stale read, or a refusal saying the request isn't the current occupant's: it can't take an
|
||||
// answer, and no person is still waiting, so it's cancelled and nothing is held. The seat's opt-in refuses and holds.
|
||||
@@ -194,7 +238,7 @@ async function onPermission(msg) {
|
||||
else if (cancelled || (!state && breaks.has("cancel-on-read-error")) || (state && breaks.has("cancel-on-unlisted-state"))) {
|
||||
refuse(ask.id); attention = breaks.has("hold-on-cancel");
|
||||
}
|
||||
else { permit(); attention = !allowOf(options); }
|
||||
else { permit(); attention = !allowOf(options); if (!attention) resolved(ask); }
|
||||
}
|
||||
ask.done = true;
|
||||
if (live === ask && (!ask.open || breaks.has("forget-permitted-ask"))) live = null;
|
||||
@@ -227,11 +271,15 @@ async function prompt(text) {
|
||||
cancelSent = false;
|
||||
turn += 1;
|
||||
stoppedEarly = false;
|
||||
beatTurn = turn;
|
||||
show(`you ▸ ${text.split("\n")[0]}`);
|
||||
attention = false;
|
||||
await post("UserPromptSubmit", "accepted", { turnId: String(turn) });
|
||||
acceptedAt = new Date().toISOString();
|
||||
const accepted = post("UserPromptSubmit", "accepted", { turnId: String(turn), occurredAt: acceptedAt });
|
||||
if (startupIdleMs === null) await accepted; // with --startup-idle, the agent doesn't wait on the post, as in dsh's runner
|
||||
const res = await call("session/prompt", { sessionId, prompt: [{ type: "text", text }] });
|
||||
await withdrawLive("turn_ended");
|
||||
if (!breaks.has("beat-after-stop")) beatTurn = null;
|
||||
if (res.error) {
|
||||
if (!breaks.has("no-error-post")) await post("Notification", "runtime_error");
|
||||
} else {
|
||||
@@ -313,6 +361,10 @@ async function main() {
|
||||
process.stdout.write("\x1b[?2004h");
|
||||
process.stdin.on("data", onInput);
|
||||
show("ready");
|
||||
if (startupIdleMs !== null && !env.OPENRIG_FIRST_INPUT) {
|
||||
holdNextPostMs = startupIdleMs;
|
||||
void post("Stop", "idle_heartbeat", { livenessSeconds: 30 });
|
||||
}
|
||||
if (env.OPENRIG_FIRST_INPUT) {
|
||||
const text = readFileSync(env.OPENRIG_FIRST_INPUT, "utf8");
|
||||
void prompt(breaks.has("first-input-trim") ? text.trim() : text);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// post-bootstrap tracker state.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -209,11 +209,32 @@ describe("bootKernelIfNeeded — fire-and-forget bootstrap", () => {
|
||||
expect(opts.sourceRef).toBe(join(tmpSpecsDir, "rigs/launch/kernel", "rig.yaml"));
|
||||
expect(opts.sourceKind).toBe("rig_spec");
|
||||
expect(opts.autoApprove).toBe(true);
|
||||
expect(opts.cwdOverride).toBe(tmpSpecsDir);
|
||||
// A new kernel works in the workspace's own `kernel` folder, prepared here, not in the workspace above its projects.
|
||||
expect(opts.cwdOverride).toBe(join(tmpSpecsDir, "kernel"));
|
||||
expect(statSync(join(tmpSpecsDir, "kernel")).isDirectory()).toBe(true);
|
||||
release();
|
||||
tracker.stop();
|
||||
});
|
||||
|
||||
it("starts a new kernel in the workspace root, as before, when its folder can't be prepared, and refuses nothing", async () => {
|
||||
// A file where the folder would go, and a workspace root that doesn't exist.
|
||||
writeFileSync(join(tmpSpecsDir, "kernel"), "a user's file\n");
|
||||
const missing = join(tmpSpecsDir, "no-workspace");
|
||||
for (const workspaceRoot of [tmpSpecsDir, missing]) {
|
||||
const bootstrap = makeBootstrapMock();
|
||||
const warnings: string[] = [];
|
||||
const tracker = await bootKernelIfNeeded(makeBaseDeps({ bootstrapOrchestrator: bootstrap, cwdOverride: workspaceRoot,
|
||||
log: (level, message) => { if (level === "warn") warnings.push(message); } }, tmpSpecsDir));
|
||||
expect(tracker.getStatus().kernelState).toBe("booting");
|
||||
const opts = (bootstrap as unknown as { bootstrap: ReturnType<typeof vi.fn> }).bootstrap.mock.calls[0]![0];
|
||||
expect(opts.cwdOverride).toBe(workspaceRoot);
|
||||
expect(warnings).toEqual([expect.stringContaining(join(workspaceRoot, "kernel"))]);
|
||||
tracker.stop();
|
||||
}
|
||||
expect(readFileSync(join(tmpSpecsDir, "kernel"), "utf8")).toBe("a user's file\n");
|
||||
expect(existsSync(missing)).toBe(false);
|
||||
});
|
||||
|
||||
it("transitions to bootstrap_failed when orchestrator returns errors (post-flush)", async () => {
|
||||
const tracker = await bootKernelIfNeeded(makeBaseDeps({
|
||||
bootstrapOrchestrator: makeBootstrapMock({ errors: ["preflight: tmux missing"] }),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Hono } from "hono";
|
||||
import { noServerTerminal } from "./helpers/no-server-terminal.js";
|
||||
import { kernelStatusRoutes } from "../src/routes/kernel-status.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type Database from "better-sqlite3";
|
||||
@@ -58,7 +58,7 @@ function host(claimed = false) {
|
||||
const rig = rigRepo.createRig("kernel");
|
||||
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("kernel-pod", rig.id, "Kernel");
|
||||
const nodes = SEATS.map((logicalId) => {
|
||||
const node = rigRepo.addNode(rig.id, logicalId, { runtime: "claude-code", podId: "kernel-pod" });
|
||||
const node = rigRepo.addNode(rig.id, logicalId, { runtime: "claude-code", podId: "kernel-pod", cwd: "/workspace/stored" });
|
||||
const name = `${logicalId.replace(".", "-")}@kernel`;
|
||||
const session = claimed ? sessionRegistry.registerClaimedSession(node.id, name) : sessionRegistry.registerSession(node.id, name);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
@@ -102,7 +102,7 @@ function host(claimed = false) {
|
||||
const statuses = () => (db.prepare(
|
||||
"SELECT status FROM sessions WHERE node_id IN (SELECT id FROM nodes WHERE rig_id = ?) ORDER BY created_at, id",
|
||||
).all(rig.id) as Array<{ status: string }>).map((row) => row.status);
|
||||
return { db, rigRepo, sessionRegistry, eventBus, tmux, snapshotRepo, snapshotCapture, rig, nodes, live, reboot, down, reconcile, daemonStart, statuses, bootstrap };
|
||||
return { db, rigRepo, sessionRegistry, eventBus, tmux, snapshotRepo, snapshotCapture, rig, nodes, live, reboot, down, reconcile, daemonStart, statuses, bootstrap, specsDir };
|
||||
}
|
||||
|
||||
const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
@@ -118,6 +118,9 @@ describe("daemon start with an existing kernel rig", () => {
|
||||
expect(restore).toHaveBeenCalledWith(h.rig.id);
|
||||
expect(tracker.getStatus().kernelState).toBe("booting");
|
||||
expect(h.bootstrap.bootstrap).not.toHaveBeenCalled();
|
||||
// An existing kernel comes back where it was created: no new kernel folder, and each seat's directory unchanged.
|
||||
expect(h.rigRepo.getRig(h.rig.id)!.nodes.map((node) => node.cwd)).toEqual(["/workspace/stored", "/workspace/stored"]);
|
||||
expect(existsSync(join(h.specsDir, "kernel"))).toBe(false);
|
||||
});
|
||||
|
||||
it("doesn't bring back a kernel that was already down before this start, or its old conversations", async () => {
|
||||
|
||||
@@ -1158,7 +1158,7 @@ describe("node detail reports a guidance block's installed file only from eviden
|
||||
declaredTarget: "AGENTS.md", effectiveTarget: path.join(cwd, "CLAUDE.md") }]);
|
||||
});
|
||||
|
||||
it("follows the rig's Claude managed-blocks file, Codex's AGENTS.md, and the older block markers", () => {
|
||||
it("follows the rig's Claude managed-blocks file, Codex's and Pi's AGENTS.md, and the older block markers", () => {
|
||||
new RigRepository(db).setRigClaudeManagedBlockFile("rig-1", "CLAUDE.local.md");
|
||||
fs.writeFileSync(path.join(cwd, "CLAUDE.local.md"), block("role", "RIGGED"));
|
||||
expect(installed()[0]).toMatchObject({ targetPath: path.join(cwd, "CLAUDE.local.md"), effectiveTarget: path.join(cwd, "CLAUDE.local.md") });
|
||||
@@ -1166,11 +1166,14 @@ describe("node detail reports a guidance block's installed file only from eviden
|
||||
fs.writeFileSync(path.join(cwd, "AGENTS.md"), block("role"));
|
||||
expect(installed("codex", [guidance("CLAUDE.md")])[0]).toMatchObject({ targetPath: path.join(cwd, "AGENTS.md"),
|
||||
declaredTarget: "CLAUDE.md", effectiveTarget: path.join(cwd, "AGENTS.md") });
|
||||
db.prepare("DELETE FROM node_startup_context WHERE node_id = 'node-1'").run();
|
||||
expect(installed("pi", [guidance("CLAUDE.md")])[0]).toMatchObject({ targetPath: path.join(cwd, "AGENTS.md"),
|
||||
declaredTarget: "CLAUDE.md", effectiveTarget: path.join(cwd, "AGENTS.md") });
|
||||
});
|
||||
|
||||
it("claims nothing for a runtime with no known guidance file, a merge that writes nothing, or a half-written block", () => {
|
||||
fs.writeFileSync(path.join(cwd, "AGENTS.md"), block("role"));
|
||||
expect(installed("pi")[0]).toEqual({ id: "role", category: "guidance", targetPath: "", declaredTarget: "AGENTS.md" });
|
||||
expect(installed("synth")[0]).toEqual({ id: "role", category: "guidance", targetPath: "", declaredTarget: "AGENTS.md" });
|
||||
db.prepare("DELETE FROM node_startup_context WHERE node_id = 'node-1'").run();
|
||||
fs.writeFileSync(path.join(cwd, "CLAUDE.md"), block("role"));
|
||||
expect(installed("claude-code", [guidance(undefined, "append")])[0]).toMatchObject({ targetPath: "" });
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("Pi occupant-bound activity (#29)", () => {
|
||||
}
|
||||
f.core.handlePiExit(0);
|
||||
const responses = await f.flush();
|
||||
expect(f.payloads).toHaveLength(7);
|
||||
expect(f.payloads).toHaveLength(8); // the run's accepted turn, its five events' hooks, the identity post and the exit
|
||||
for (const payload of f.payloads) expect(payload).toMatchObject({
|
||||
nodeId: NODE, sessionName: SESSION, runtime: "pi", generation: GENERATION,
|
||||
});
|
||||
|
||||
@@ -206,7 +206,7 @@ describe("idle heartbeat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Streaming heartbeat ─────────────────────────────────────────────────────
|
||||
// ── Streaming heartbeat: the runner contract's working beat (section 4), `active` / `liveness` ──
|
||||
|
||||
describe("streaming heartbeat", () => {
|
||||
function clocked(runtime?: "pi" | "omp") {
|
||||
@@ -215,7 +215,7 @@ describe("streaming heartbeat", () => {
|
||||
f.io.now = () => new Date(t).toISOString();
|
||||
const core = new RunnerCore(f.io, { sessionName: SESSION, nodeId: "node-1", launchId: "launch-77" }, runtime ? { runtime } : undefined);
|
||||
core.start();
|
||||
const beats = () => f.activity.filter(a => a.subtype === "streaming");
|
||||
const beats = () => f.activity.filter(a => a.subtype === "liveness");
|
||||
return { core, f, beats, advance: (ms: number) => { t += ms; } };
|
||||
}
|
||||
const ready = (core: RunnerCore) => core.handlePiLine(JSON.stringify({
|
||||
@@ -228,7 +228,8 @@ describe("streaming heartbeat", () => {
|
||||
ready(c.core);
|
||||
c.core.handlePiLine(JSON.stringify({ type: "agent_start" }));
|
||||
c.advance(10_000); c.core.heartbeat();
|
||||
expect(c.beats()).toEqual([expect.objectContaining({ sessionName: SESSION, hookEvent: "active", subtype: "streaming", runtime: "pi" })]);
|
||||
expect(c.beats()).toEqual([expect.objectContaining({ sessionName: SESSION, hookEvent: "active", subtype: "liveness", runtime: "pi",
|
||||
turnId: "1", livenessSeconds: 30 })]);
|
||||
c.advance(10_000); c.core.heartbeat();
|
||||
expect(c.beats()).toHaveLength(2);
|
||||
});
|
||||
@@ -239,7 +240,7 @@ describe("streaming heartbeat", () => {
|
||||
ready(c.core);
|
||||
c.core.handlePiLine(JSON.stringify({ type: "turn_start" }));
|
||||
c.advance(10_000); c.core.heartbeat();
|
||||
expect(c.beats()).toEqual([expect.objectContaining({ subtype: "streaming" })]);
|
||||
expect(c.beats()).toEqual([expect.objectContaining({ subtype: "liveness", turnId: "1" })]);
|
||||
c.core.handlePiLine(JSON.stringify({ type: "agent_end" }));
|
||||
c.advance(10_000); c.core.heartbeat();
|
||||
expect(c.beats()).toHaveLength(1); // closed: idle branch resumes
|
||||
@@ -283,6 +284,57 @@ describe("streaming heartbeat", () => {
|
||||
c.core.handlePiLine(JSON.stringify({ type: "agent_start" }));
|
||||
c.advance(20_000); c.core.heartbeat();
|
||||
expect(c.beats()).toEqual([]);
|
||||
expect(c.f.activity.filter(a => a.hookEvent === "UserPromptSubmit")).toEqual([]);
|
||||
});
|
||||
|
||||
// A beat counts only for the latest accepted turn (contract section 4). Pi's runs start from the runner's prompts, so
|
||||
// each run that opens posts its accepted turn first; a prompt Pi handles without a run opens none.
|
||||
it("posts one accepted turn per run, before the run's first post, and its beats name that turn", () => {
|
||||
const c = clocked();
|
||||
ready(c.core);
|
||||
const turns = () => c.f.activity.filter(a => a.hookEvent === "UserPromptSubmit");
|
||||
c.core.handlePiLine(JSON.stringify({ type: "agent_start" }));
|
||||
c.core.handlePiLine(JSON.stringify({ type: "turn_start" })); // the same run's next model call opens no turn
|
||||
expect(turns()).toEqual([expect.objectContaining({ subtype: "accepted", turnId: "1", runtime: "pi" })]);
|
||||
const opened = c.f.activity.findIndex(a => a.hookEvent === "UserPromptSubmit");
|
||||
expect(c.f.activity[opened + 1]).toMatchObject({ hookEvent: "active", subtype: "agent_start" });
|
||||
c.advance(10_000); c.core.heartbeat();
|
||||
c.core.handlePiLine(JSON.stringify({ type: "agent_end" }));
|
||||
c.core.handlePiLine(JSON.stringify({ type: "turn_start" })); // a run opened by turn_start alone
|
||||
c.advance(10_000); c.core.heartbeat();
|
||||
expect(turns().map(a => a.turnId)).toEqual(["1", "2"]);
|
||||
expect(c.beats().map(a => a.turnId)).toEqual(["1", "2"]);
|
||||
const sequences = c.f.activity.map(a => Number(a.sequence)).filter(Number.isFinite);
|
||||
expect(sequences).toEqual([...sequences].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
// Section 4: each prompt the runner submits is accepted with its own number. One submitted while a run is open (a
|
||||
// steer, or a follow-up) is posted as it's sent, and the open run's agent_end ends it.
|
||||
it("posts an accepted turn for each prompt or follow-up submitted while a run is open, and its beats name it", () => {
|
||||
const c = clocked();
|
||||
ready(c.core);
|
||||
const turns = () => c.f.activity.filter(a => a.hookEvent === "UserPromptSubmit").map(a => a.turnId);
|
||||
c.core.handlePiLine(JSON.stringify({ type: "agent_start" }));
|
||||
c.core.handleUserBlock("look at b.txt too");
|
||||
expect(turns()).toEqual(["1", "2"]);
|
||||
c.advance(10_000); c.core.heartbeat();
|
||||
c.core.handleUserBlock("/followup then c.txt");
|
||||
c.core.handleUserBlock("/abort");
|
||||
expect(turns()).toEqual(["1", "2", "3"]);
|
||||
c.advance(10_000); c.core.heartbeat();
|
||||
expect(c.beats().map(a => a.turnId)).toEqual(["2", "3"]);
|
||||
const sequences = c.f.activity.map(a => Number(a.sequence)).filter(Number.isFinite);
|
||||
expect(sequences).toEqual([...sequences].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
it("opens no turn for a prompt Pi handles without a run, so the idle beat goes on", () => {
|
||||
const c = clocked();
|
||||
ready(c.core);
|
||||
c.core.handlePiLine(JSON.stringify({ type: "agent_end" })); // idle, with a Stop posted
|
||||
c.core.handleUserBlock("/model some-model");
|
||||
c.advance(180_000); c.core.heartbeat();
|
||||
expect(c.f.activity.filter(a => a.hookEvent === "UserPromptSubmit")).toEqual([]);
|
||||
expect(c.f.activity.at(-1)).toMatchObject({ hookEvent: "Stop", subtype: "idle_heartbeat" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -303,7 +355,7 @@ describe("heartbeat scheduler cadence", () => {
|
||||
data: { sessionFile: SESSION_FILE, sessionId: "0197a2f0" },
|
||||
}));
|
||||
const timer = startHeartbeatTimer(core, "pi");
|
||||
const beats = () => f.activity.filter(a => a.subtype === "streaming");
|
||||
const beats = () => f.activity.filter(a => a.subtype === "liveness");
|
||||
return { f, core, timer, beats, advance: (ms: number) => vi.advanceTimersByTime(ms) };
|
||||
}
|
||||
const after = (c: ReturnType<typeof scheduledCore>) => { c.timer.stop(); vi.useRealTimers(); };
|
||||
|
||||
@@ -70,4 +70,10 @@ describe("inferCatalogProject working-directory depth", () => {
|
||||
catalog({ id: "alpha", root: "/a" }, { id: "beta", root: "/a/b" });
|
||||
expect(infer("/a/b/c")).toEqual({ id: "beta", root: "/a/b", selectedBy: "cwd" });
|
||||
});
|
||||
|
||||
it("finds the workspace's project from a new kernel's own folder, as from the workspace root", () => {
|
||||
catalog({ id: "default", root: "/ws" }, { id: "bundle", root: "/ws/projects/bundle" });
|
||||
expect(infer("/ws/kernel")).toEqual({ id: "default", root: "/ws", selectedBy: "cwd" });
|
||||
expect(infer("/ws")).toEqual(infer("/ws/kernel"));
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.j
|
||||
import { tmuxAttachCommand } from "../src/adapters/tmux-server.js";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
import { makeKernelHookReviewReader } from "../src/domain/kernel-hook-review.js";
|
||||
import { verifyCodexPaneProcess, type NativeProcessRow } from "../src/domain/native-process-lineage.js";
|
||||
import { runWakeLadderTick, queueRecoveryOwnsWake, classifyPromptAfterRefusal } from "../src/domain/queue-wake-ladder.js";
|
||||
import { makeOperatorDeliveryEngine } from "../src/domain/gateway/operator-delivery-engine.js";
|
||||
import { buildSlackGatewayWire } from "../src/domain/gateway/slack/slack-subsystem.js";
|
||||
@@ -528,6 +529,7 @@ describe("prompt-blocked outstanding work reaches the configured route", () => {
|
||||
let pane = "%17";
|
||||
let changeBinding = false;
|
||||
const tmux = {
|
||||
getPanePid: vi.fn(async () => 123),
|
||||
listPanes: vi.fn(async () => [{ id: pane, index: 0, cwd: "/fixture", width: 80, height: 24, active: true }]),
|
||||
readAllPaneProcesses: vi.fn(async () => new Map([["%17", { pid: 123, command: "codex", dead }]])),
|
||||
capturePaneScreen: vi.fn(async () => {
|
||||
@@ -560,6 +562,68 @@ describe("prompt-blocked outstanding work reaches the configured route", () => {
|
||||
expect(posts).toHaveLength(1);
|
||||
});
|
||||
|
||||
function shellHookReaderFixture() {
|
||||
const rigs = new RigRepository(db); const sessions = new SessionRegistry(db);
|
||||
const rig = rigs.createRig("kernel"); const node = rigs.addNode(rig.id, "operator", { runtime: "codex" });
|
||||
const destination = "operator@kernel";
|
||||
const session = sessions.registerSession(node.id, destination); sessions.updateStatus(session.id, "running");
|
||||
sessions.updateBinding(node.id, { attachmentType: "tmux", tmuxSession: destination, tmuxPane: "%17" });
|
||||
let screen = "OpenAI Codex (v0.164.0)\nHooks need review\nTrust all and continue";
|
||||
let panePid = 123, nativePid = 126;
|
||||
const processes = (): NativeProcessRow[] => [
|
||||
{ pid: panePid, ppid: 1, pgid: panePid, tpgid: 124, executableName: "zsh", command: "-zsh", startedAt: "start" },
|
||||
{ pid: 124, ppid: panePid, pgid: 124, tpgid: 124, executableName: "sh", command: "/bin/sh /fixture/start.sh", startedAt: "start" },
|
||||
{ pid: 125, ppid: 124, pgid: 124, tpgid: 124, executableName: "node", command: "node /opt/bin/codex", startedAt: "start" },
|
||||
{ pid: nativePid, ppid: 125, pgid: 124, tpgid: 124, executableName: "codex", command: "/opt/native/codex",
|
||||
argv: ["/opt/native/codex"], startedAt: "start" },
|
||||
];
|
||||
const tmux = {
|
||||
getPanePid: vi.fn(async () => panePid),
|
||||
listPanes: vi.fn(async () => [{ id: "%17", index: 0, cwd: "/fixture", width: 80, height: 24, active: true }]),
|
||||
readAllPaneProcesses: vi.fn(async () => new Map([["%17", { pid: panePid, command: "sh", dead: false }]])),
|
||||
capturePaneScreen: vi.fn(async () => screen),
|
||||
};
|
||||
const listProcesses = vi.fn(async () => processes()); const now = new Date("2030-01-01T00:00:00Z");
|
||||
const deps = { db, tmux, listProcesses, now: () => now };
|
||||
return { tmux, listProcesses, processes, now, destination, read: makeKernelHookReviewReader(deps),
|
||||
screen: (value: string) => { screen = value; }, changeNative: () => { nativePid++; },
|
||||
changePanePid: () => { panePid += 1000; }, changeBinding: () => sessions.updateBinding(node.id, { tmuxPane: "%18" }) };
|
||||
}
|
||||
|
||||
it("escalates a verified shell-backed kernel Codex and leaves its alert unresolved while lineage is unknown", async () => {
|
||||
const f = shellHookReaderFixture();
|
||||
expect(await verifyCodexPaneProcess({ target: "%17", tmux: f.tmux, listProcesses: f.listProcesses })).not.toBeNull();
|
||||
expect((await f.read())[0]?.state).toBe("blocked");
|
||||
const posts: unknown[] = []; const port = engine(posts);
|
||||
await tick(port, { now: f.now, readHookReviews: f.read });
|
||||
await vi.waitFor(() => expect(posts).toHaveLength(1));
|
||||
expect(alerts()[0]!.body).toContain(f.destination);
|
||||
f.screen("OpenAI Codex (v0.164.0)\nmodel: gpt-5\n› ");
|
||||
f.listProcesses.mockResolvedValue([]);
|
||||
expect((await f.read())[0]?.state).toBe("unknown");
|
||||
await tick(port, { now: f.now, readHookReviews: f.read });
|
||||
expect(alerts()).toHaveLength(1); expect(posts).toHaveLength(1);
|
||||
f.listProcesses.mockImplementation(async () => f.processes());
|
||||
expect((await f.read())[0]?.state).toBe("clear");
|
||||
await tick(port, { now: f.now, readHookReviews: f.read });
|
||||
expect(alerts()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["missing", "unavailable", "changing", "capture-change", "pane-change", "binding-change"])(
|
||||
"a shell-backed kernel observation stays unknown without a stable chain: %s", async kind => {
|
||||
const f = shellHookReaderFixture();
|
||||
if (kind === "missing") f.listProcesses.mockResolvedValue([]);
|
||||
if (kind === "unavailable") f.listProcesses.mockRejectedValue(new Error("unavailable process table"));
|
||||
if (kind === "changing") f.listProcesses.mockImplementation(async () => { f.changeNative(); return f.processes(); });
|
||||
if (kind === "capture-change") f.tmux.capturePaneScreen.mockImplementation(async () => { f.changeNative(); return "Hooks need review"; });
|
||||
if (kind === "pane-change") f.tmux.capturePaneScreen.mockImplementation(async () => { f.changePanePid(); return "Hooks need review"; });
|
||||
if (kind === "binding-change") f.tmux.capturePaneScreen.mockImplementation(async () => { f.changeBinding(); return "Hooks need review"; });
|
||||
expect((await f.read())[0]?.state).toBe("unknown");
|
||||
const posts: unknown[] = [];
|
||||
await tick(engine(posts), { now: f.now, readHookReviews: f.read });
|
||||
expect(alerts()).toHaveLength(0); expect(posts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("several rows share one alert, persisted acceptance and receipt across reconstructed ticks", async () => {
|
||||
await refusal(3); const posts: unknown[] = []; const port = engine(posts);
|
||||
expect((await tick(port)).outcome).not.toBe("failed");
|
||||
|
||||
@@ -1667,6 +1667,31 @@ describe("queue routes", () => {
|
||||
expect(await ids(`attention=1&handedOffFrom=${source}`)).toEqual([]);
|
||||
});
|
||||
|
||||
it("GET /api/queue/list?sort=updated picks a bounded window by latest update, so an old item closed last is in it", async () => {
|
||||
const post = (path: string, session: string, body: Record<string, unknown>) => app.request(path, {
|
||||
method: "POST", headers: { "Content-Type": "application/json", "X-OpenRig-Session": session }, body: JSON.stringify(body),
|
||||
});
|
||||
const create = async (body: string) => ((await (await post("/api/queue/create", "a@r", { destinationSession: "b@r", body, nudge: false })).json()) as { qitemId: string }).qitemId;
|
||||
const close = (qitemId: string, ts: string) => {
|
||||
db.prepare("UPDATE queue_items SET state = 'done', ts_updated = ? WHERE qitem_id = ?").run(ts, qitemId);
|
||||
};
|
||||
const oldest = await create("started first, finished last");
|
||||
db.prepare("UPDATE queue_items SET ts_created = '2026-01-01T00:00:00.000Z' WHERE qitem_id = ?").run(oldest);
|
||||
const middle = await create("middle");
|
||||
db.prepare("UPDATE queue_items SET ts_created = '2026-01-02T00:00:00.000Z' WHERE qitem_id = ?").run(middle);
|
||||
const newest = await create("started last");
|
||||
db.prepare("UPDATE queue_items SET ts_created = '2026-01-03T00:00:00.000Z' WHERE qitem_id = ?").run(newest);
|
||||
close(middle, "2026-01-04T00:00:00.000Z");
|
||||
close(newest, "2026-01-05T00:00:00.000Z");
|
||||
close(oldest, "2026-01-06T00:00:00.000Z");
|
||||
const ids = async (query: string) => ((await (await app.request(`/api/queue/list?${query}`)).json()) as Array<{ qitemId: string }>).map((q) => q.qitemId);
|
||||
|
||||
expect(await ids("state=done,handed-off&sort=updated&limit=2")).toEqual([oldest, newest]);
|
||||
// Without it the window stays newest created, and a sort it doesn't know changes nothing.
|
||||
expect(await ids("state=done,handed-off&limit=2")).toEqual([newest, middle]);
|
||||
expect(await ids("state=done,handed-off&sort=other&limit=2")).toEqual([newest, middle]);
|
||||
});
|
||||
|
||||
it("GET /api/queue/list trims each state name and otherwise keeps today's matching", async () => {
|
||||
const post = (path: string, session: string, body: Record<string, unknown>) => app.request(path, {
|
||||
method: "POST", headers: { "Content-Type": "application/json", "X-OpenRig-Session": session }, body: JSON.stringify(body),
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ContextUsageStore } from "../src/domain/context-usage-store.js";
|
||||
import type { ProjectionEntry, ProjectionPlan } from "../src/domain/projection-planner.js";
|
||||
import type { NodeBinding, RuntimeAdapter } from "../src/domain/runtime-adapter.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { MANAGED_BLOCK_START } from "../src/domain/managed-blocks.js";
|
||||
|
||||
// Real snapshot/restore/startup/projection and settings files; only terminal and
|
||||
// native launch/readiness are simulated. No Claude, provider or daemon is started.
|
||||
@@ -99,21 +100,25 @@ describe("contained Claude restore activity resources", () => {
|
||||
return Object.values(s.hooks ?? {}).flatMap((groups: any) => groups.flatMap((g: any) => g.hooks ?? []))
|
||||
.map((h: any) => h.command as string).filter(c => c.includes("state/claude-activity-hooks/activity-relay.cjs"));
|
||||
}
|
||||
function seed(pod: boolean, selected = true, runtime = "claude-code") {
|
||||
function seed(pod: boolean, selected = true, runtime = "claude-code", withStartupContext = true, seedCwd: string | null = cwd,
|
||||
extraEntries: Array<{ category: string; effectiveId: string; absolutePath: string; mergeStrategy: string }> = []) {
|
||||
const rig = rigs.createRig("restore");
|
||||
if (pod) db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("pod", rig.id, "P");
|
||||
const node = rigs.addNode(rig.id, pod ? "p.seat" : "seat", { runtime, cwd, ...(pod ? { podId: "pod" } : {}) });
|
||||
const node = rigs.addNode(rig.id, pod ? "p.seat" : "seat", { runtime, ...(seedCwd ? { cwd: seedCwd } : {}), ...(pod ? { podId: "pod" } : {}) });
|
||||
const session = registry.registerSession(node.id, "seat@restore");
|
||||
registry.updateStatus(session.id, "running");
|
||||
registry.updateResumeToken(session.id, runtime === "codex" ? "codex_id" : runtime === "pi" ? "pi_session_file" : "claude_id", token);
|
||||
const entries = [
|
||||
...(selected ? [activity()] : []),
|
||||
{ category: "guidance", effectiveId: "do-not-replay", absolutePath: path.join(root, "missing-guidance.md"), mergeStrategy: "managed_block" },
|
||||
...extraEntries,
|
||||
];
|
||||
const files = [{ path: "missing.md", absolutePath: path.join(root, "missing.md"), required: true, appliesOn: ["restore"] }];
|
||||
const actions = [{ type: "send_text", value: "DO NOT REPLAY", phase: "after_ready", appliesOn: ["restore"], idempotent: true }];
|
||||
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(node.id, JSON.stringify(entries), JSON.stringify(files), JSON.stringify(actions), runtime);
|
||||
if (withStartupContext) {
|
||||
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(node.id, JSON.stringify(entries), JSON.stringify(files), JSON.stringify(actions), runtime);
|
||||
}
|
||||
const saved = db.prepare("SELECT * FROM node_startup_context WHERE node_id=?").get(node.id);
|
||||
const snap = capture.captureSnapshot(rig.id, "manual");
|
||||
registry.updateStatus(session.id, "exited");
|
||||
@@ -122,8 +127,8 @@ describe("contained Claude restore activity resources", () => {
|
||||
}
|
||||
function restore(f: ReturnType<typeof seed>, runtimeAdapter: RuntimeAdapter = adapter, beforeResume = () => {}, desired?: (nodeId: string) =>
|
||||
| { status: "known"; selected: boolean } | { status: "unknown"; evidence: string },
|
||||
adapters?: Record<string, RuntimeAdapter>) {
|
||||
const claude = { canResume: () => true, resume: vi.fn(async () => { beforeResume(); return { ok: true }; }) } as unknown as ClaudeResumeAdapter;
|
||||
adapters?: Record<string, RuntimeAdapter>, claudeResume?: ClaudeResumeAdapter) {
|
||||
const claude = claudeResume ?? { canResume: () => true, resume: vi.fn(async () => { beforeResume(); return { ok: true }; }) } as unknown as ClaudeResumeAdapter;
|
||||
const nodeLauncher = new NodeLauncher({ db, rigRepo: rigs, sessionRegistry: registry, eventBus: events, tmuxAdapter: tmux });
|
||||
const orch = new RestoreOrchestrator({ db, rigRepo: rigs, sessionRegistry: registry, eventBus: events,
|
||||
snapshotRepo: snapshots, snapshotCapture: capture, checkpointStore: checkpoints, nodeLauncher, tmuxAdapter: tmux,
|
||||
@@ -285,6 +290,227 @@ describe("contained Claude restore activity resources", () => {
|
||||
expect(fs.existsSync(path.join(cwd, "CLAUDE.md"))).toBe(false);
|
||||
});
|
||||
|
||||
// Follow-up to #2338 (CodeRabbit Major 4436149570): an older snapshot with
|
||||
// NO nodeStartupContext row at all must not leave a known-selected legacy
|
||||
// Claude seat hookless with no restore gap. The runtime falls back to the
|
||||
// node's saved runtime and absent projection entries count as an empty list.
|
||||
// The fallback is scoped to legacy seats whose live runtime still matches
|
||||
// the snapshot's: a pod-aware seat, or one whose runtime changed since the
|
||||
// snapshot, gets no hooks write from this recovery (see the two tests
|
||||
// below). The restore itself is unchanged in both cases.
|
||||
it("re-applies the current selection when the saved startup context is absent (legacy seat)", async () => {
|
||||
const f = seed(false, false, "claude-code", false);
|
||||
const project = vi.spyOn(adapter, "project");
|
||||
const result = await restore(f, adapter, () => {}, () => ({ status: "known", selected: true }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const recoveryCall = project.mock.calls.find(([plan]) => plan.entries.some((e) => e.resourceType === "claude_activity_hooks"));
|
||||
expect(recoveryCall).toBeDefined();
|
||||
expect(recoveryCall![0].entries).toHaveLength(1);
|
||||
expect(recoveryCall![0].entries[0]).toMatchObject({
|
||||
category: "runtime_resource", resourceType: "claude_activity_hooks", sourceSpec: "current-selection",
|
||||
});
|
||||
expect(owned().length).toBeGreaterThan(0);
|
||||
expect(result.result.warnings.some((w) => w.startsWith("Restore activity hooks:") && w.includes("current"))).toBe(true);
|
||||
expect(result.result.nodes[0].restoreGaps).toBeUndefined();
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(path.join(cwd, "CLAUDE.md"))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not consult current selection or project claude hooks for legacy codex seat with absent startup context", async () => {
|
||||
const runtime = "codex";
|
||||
const f = seed(false, false, runtime, false);
|
||||
const project = vi.fn(CodexRuntimeAdapter.prototype.project);
|
||||
const launch = vi.fn(async (_b, opts) => ({ ok: true as const, resumeToken: opts.resumeToken, resumeType: "codex_id" }));
|
||||
const other = { runtime, project, listInstalled: async () => [], deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
|
||||
checkReady: async () => ({ ready: true }), launchHarness: launch } as RuntimeAdapter;
|
||||
const claudeProject = vi.spyOn(adapter, "project");
|
||||
const desired = vi.fn((): { status: "known"; selected: boolean } => ({ status: "known", selected: true }));
|
||||
const result = await restore(f, other, () => {}, desired, { "claude-code": adapter, codex: other });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(desired).not.toHaveBeenCalled();
|
||||
const claudeRecovery = claudeProject.mock.calls.find(([plan]) => plan.entries.some((e) => e.resourceType === "claude_activity_hooks"));
|
||||
expect(claudeRecovery).toBeUndefined();
|
||||
const otherRecovery = project.mock.calls.find(([plan]) => plan.entries.some((e) => e.resourceType === "claude_activity_hooks"));
|
||||
expect(otherRecovery).toBeUndefined();
|
||||
expect(owned()).toEqual([]);
|
||||
expect(writes).toEqual([]);
|
||||
expect(result.result.warnings.some((w) => w.startsWith("Restore activity hooks:"))).toBe(false);
|
||||
expect(result.result.nodes[0].restoreGaps).toBeUndefined();
|
||||
});
|
||||
|
||||
// Maintainer review on #2373: the no-context fallback must not write Claude
|
||||
// hooks for a snapshot whose agent runtime changed after the snapshot was
|
||||
// taken. With a saved startup context, the compatibility gate stops the
|
||||
// mismatch; without one, nothing does, so the fallback must compare the
|
||||
// live runtime against the snapshot's before re-applying anything.
|
||||
it("does not project claude hooks when the live runtime no longer matches the snapshot runtime (no startup context)", async () => {
|
||||
const f = seed(false, false, "claude-code", false);
|
||||
db.prepare("UPDATE nodes SET runtime = ? WHERE id = ?").run("codex", f.node.id);
|
||||
const project = vi.spyOn(adapter, "project");
|
||||
const desired = vi.fn((): { status: "known"; selected: boolean } => ({ status: "known", selected: true }));
|
||||
const result = await restore(f, adapter, () => {}, desired);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(desired).not.toHaveBeenCalled();
|
||||
const claudeRecovery = project.mock.calls.find(([plan]) => plan.entries.some((e) => e.resourceType === "claude_activity_hooks"));
|
||||
expect(claudeRecovery).toBeUndefined();
|
||||
expect(owned()).toEqual([]);
|
||||
expect(result.result.warnings.some((w) => w.startsWith("Restore activity hooks:"))).toBe(false);
|
||||
expect(result.result.nodes[0].restoreGaps).toBeUndefined();
|
||||
});
|
||||
|
||||
// Maintainer review on #2373: a pod-aware agent with no startup context
|
||||
// must get no hooks write from this recovery — it still ends at the
|
||||
// "Harness not started" gate, and the restore itself is unchanged.
|
||||
it("does not project claude hooks for a pod-aware seat with no startup context", async () => {
|
||||
const f = seed(true, false, "claude-code", false);
|
||||
const project = vi.spyOn(adapter, "project");
|
||||
const desired = vi.fn((): { status: "known"; selected: boolean } => ({ status: "known", selected: true }));
|
||||
const result = await restore(f, adapter, () => {}, desired);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.result.nodes[0]).toMatchObject({
|
||||
status: "attention_required", error: "Harness not started: no startup context for p.seat.",
|
||||
});
|
||||
expect(desired).not.toHaveBeenCalled();
|
||||
const claudeRecovery = project.mock.calls.find(([plan]) => plan.entries.some((e) => e.resourceType === "claude_activity_hooks"));
|
||||
expect(claudeRecovery).toBeUndefined();
|
||||
expect(owned()).toEqual([]);
|
||||
expect(result.result.warnings.some((w) => w.startsWith("Restore activity hooks:"))).toBe(false);
|
||||
expect(result.result.nodes[0].restoreGaps).toBeUndefined();
|
||||
});
|
||||
|
||||
// CodeRabbit follow-up on #2373: the snapshot node may have no cwd while
|
||||
// the live node does. NodeLauncher launches the seat in the live node's
|
||||
// directory (opts.cwd ?? live node cwd), so the hook projection must use
|
||||
// that resolved directory — not "." (the daemon's working directory), which
|
||||
// would write another project's hook settings. The snapshot is captured
|
||||
// with no cwd; the live row gains one only after capture.
|
||||
function liveProject() {
|
||||
const liveCwd = path.join(root, "live-project");
|
||||
const liveSettingsPath = path.join(liveCwd, ".claude/settings.local.json");
|
||||
fs.mkdirSync(path.dirname(liveSettingsPath), { recursive: true });
|
||||
fs.writeFileSync(liveSettingsPath, JSON.stringify(originalSettings));
|
||||
return { liveCwd, liveSettingsPath };
|
||||
}
|
||||
function liveOwned(liveSettingsPath: string): string[] {
|
||||
return owned(JSON.parse(fs.readFileSync(liveSettingsPath, "utf8")));
|
||||
}
|
||||
|
||||
it("projects the current selection into the live seat directory when the snapshot node has no cwd", async () => {
|
||||
const { liveCwd, liveSettingsPath } = liveProject();
|
||||
const f = seed(false, false, "claude-code", false, null);
|
||||
db.prepare("UPDATE nodes SET cwd = ? WHERE id = ?").run(liveCwd, f.node.id);
|
||||
const project = vi.spyOn(adapter, "project");
|
||||
const result = await restore(f, adapter, () => {}, () => ({ status: "known", selected: true }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const recoveryCall = project.mock.calls.find(([plan]) => plan.entries.some((e) => e.resourceType === "claude_activity_hooks"));
|
||||
expect(recoveryCall).toBeDefined();
|
||||
expect(recoveryCall![0].cwd).toBe(liveCwd);
|
||||
expect(recoveryCall![1].cwd).toBe(liveCwd);
|
||||
expect(liveOwned(liveSettingsPath).length).toBeGreaterThan(0);
|
||||
expect(result.result.nodes[0].restoreGaps).toBeUndefined();
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("projects saved hooks entries into the live seat directory when the snapshot node has no cwd", async () => {
|
||||
const { liveCwd, liveSettingsPath } = liveProject();
|
||||
const f = seed(false, true, "claude-code", true, null);
|
||||
db.prepare("UPDATE nodes SET cwd = ? WHERE id = ?").run(liveCwd, f.node.id);
|
||||
const project = vi.spyOn(adapter, "project");
|
||||
const { StartupOrchestrator } = await import("../src/domain/startup-orchestrator.js");
|
||||
const startNode = vi.spyOn(StartupOrchestrator.prototype, "startNode");
|
||||
const result = await restore(f, adapter);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const savedCall = project.mock.calls.find(([plan]) => plan.entries.some((e) => e.resourceType === "claude_activity_hooks"));
|
||||
expect(savedCall).toBeDefined();
|
||||
expect(savedCall![0].cwd).toBe(liveCwd);
|
||||
expect(savedCall![1].cwd).toBe(liveCwd);
|
||||
expect(liveOwned(liveSettingsPath).length).toBeGreaterThan(0);
|
||||
// The same restore's startup replay must use the live seat directory too:
|
||||
// with the snapshot cwd absent it previously wrote the collector/status
|
||||
// line into the daemon's own working folder ("."), polluting later tests.
|
||||
const replayCalls = startNode.mock.calls.filter(([args]) => args.plan && args.binding);
|
||||
expect(replayCalls.length).toBeGreaterThan(0);
|
||||
for (const [{ plan: replayPlan, binding: replayBinding }] of replayCalls) {
|
||||
expect(replayPlan.cwd).toBe(liveCwd);
|
||||
expect(replayBinding.cwd).toBe(liveCwd);
|
||||
}
|
||||
expect(result.result.warnings.some((w) => w.startsWith("Restore activity hooks:"))).toBe(false);
|
||||
expect(result.result.nodes[0].restoreGaps).toBeUndefined();
|
||||
});
|
||||
|
||||
// CodeRabbit follow-up on #2373: the legacy resume adapter must receive the
|
||||
// same resolved launch directory as the hook projection and the startup
|
||||
// replay — when the snapshot node has no cwd, the seat was launched in the
|
||||
// live node's directory, not "/" (which a managed launch would reject as a
|
||||
// directory mismatch for a seat with an explicit permission mode).
|
||||
it("resumes the legacy seat in the live seat directory when the snapshot node has no cwd", async () => {
|
||||
const { liveCwd, liveSettingsPath } = liveProject();
|
||||
const f = seed(false, true, "claude-code", true, null);
|
||||
db.prepare("UPDATE nodes SET cwd = ? WHERE id = ?").run(liveCwd, f.node.id);
|
||||
const resume = vi.fn(async () => ({ ok: true }));
|
||||
const result = await restore(f, adapter, () => {}, undefined, undefined,
|
||||
{ canResume: () => true, resume } as unknown as ClaudeResumeAdapter);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(resume).toHaveBeenCalled();
|
||||
// 4th positional argument: the launch directory handed to the resume adapter.
|
||||
expect(resume.mock.calls[0]![3]).toBe(liveCwd);
|
||||
expect(liveOwned(liveSettingsPath).length).toBeGreaterThan(0);
|
||||
expect(result.result.nodes[0].restoreGaps).toBeUndefined();
|
||||
});
|
||||
|
||||
// CodeRabbit follow-up on #2373: the saved-guidance lookups must use the
|
||||
// same resolved launch directory as the hook projection and startup replay.
|
||||
// When the snapshot node has no cwd, seatGuidance with node.cwd (null)
|
||||
// returns null, so the pre-resume repair is skipped and the post-launch
|
||||
// check reports nothing — a seat whose guidance blocks are missing in the
|
||||
// live directory comes back fully_restored with no warning. The seed's
|
||||
// "do-not-replay" guidance entry (source always missing) stays a gap in
|
||||
// both tests; only the SOP.md entry distinguishes fixed from unfixed.
|
||||
it("puts back saved guidance blocks in the live seat directory when the snapshot node has no cwd", async () => {
|
||||
const { liveCwd } = liveProject();
|
||||
const sourcePath = path.join(root, "SOP.md");
|
||||
fs.writeFileSync(sourcePath, "Current SOP text.");
|
||||
const guidanceFile = path.join(liveCwd, "CLAUDE.md");
|
||||
fs.writeFileSync(guidanceFile, "# My project notes\n");
|
||||
const f = seed(false, false, "claude-code", true, null, [
|
||||
{ category: "guidance", effectiveId: "SOP.md", absolutePath: sourcePath, mergeStrategy: "managed_block" },
|
||||
]);
|
||||
db.prepare("UPDATE nodes SET cwd = ? WHERE id = ?").run(liveCwd, f.node.id);
|
||||
const result = await restore(f, adapter);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const content = fs.readFileSync(guidanceFile, "utf-8");
|
||||
expect(content).toContain(MANAGED_BLOCK_START("SOP.md"));
|
||||
expect(content).toContain("Current SOP text.");
|
||||
expect(result.result.nodes[0].guidanceGaps).toEqual(["do-not-replay"]);
|
||||
});
|
||||
|
||||
it("discloses a guidance gap in the live seat directory when the snapshot node has no cwd and the source is gone", async () => {
|
||||
const { liveCwd } = liveProject();
|
||||
const sourcePath = path.join(root, "SOP-gone.md");
|
||||
fs.writeFileSync(sourcePath, "Current SOP text.");
|
||||
const guidanceFile = path.join(liveCwd, "CLAUDE.md");
|
||||
fs.writeFileSync(guidanceFile, "# My project notes\n");
|
||||
const f = seed(false, false, "claude-code", true, null, [
|
||||
{ category: "guidance", effectiveId: "SOP.md", absolutePath: sourcePath, mergeStrategy: "managed_block" },
|
||||
]);
|
||||
db.prepare("UPDATE nodes SET cwd = ? WHERE id = ?").run(liveCwd, f.node.id);
|
||||
fs.rmSync(sourcePath);
|
||||
const result = await restore(f, adapter);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect([...(result.result.nodes[0].guidanceGaps ?? [])].sort()).toEqual(["SOP.md", "do-not-replay"]);
|
||||
expect(result.result.warnings.join("\n")).toContain("SOP.md");
|
||||
expect(fs.readFileSync(guidanceFile, "utf-8")).toBe("# My project notes\n");
|
||||
});
|
||||
|
||||
// The recovery projection must be awaited: an adapter whose project()
|
||||
// suspends (real async work) must not have its warnings/gaps silently
|
||||
// dropped because postLaunchRestore continued to the rollup first.
|
||||
|
||||
@@ -61,7 +61,7 @@ const nodeFs = {
|
||||
|
||||
interface SeatOptions {
|
||||
podAware?: boolean;
|
||||
runtime?: "claude-code" | "codex";
|
||||
runtime?: "claude-code" | "codex" | "pi";
|
||||
claudeManagedBlockFile?: "CLAUDE.md" | "CLAUDE.local.md";
|
||||
userText?: string;
|
||||
/** An extra "auto" startup file whose content marks it as a skill. */
|
||||
@@ -74,6 +74,10 @@ interface SeatOptions {
|
||||
seatSkillsLine?: boolean;
|
||||
}
|
||||
|
||||
/** The resume type each runtime records for its native session. */
|
||||
const resumeTypeOf = (runtime: NonNullable<SeatOptions["runtime"]>): string =>
|
||||
runtime === "codex" ? "codex_id" : runtime === "pi" ? "pi_session_file" : "claude_id";
|
||||
|
||||
const SEAT_SKILLS_ASSET = path.join(import.meta.dirname, "../assets/guidance/openrig-seat-skills.md");
|
||||
|
||||
describe("a seat's guidance across rig down then rig up --existing (exact resume)", () => {
|
||||
@@ -128,7 +132,7 @@ describe("a seat's guidance across rig down then rig up --existing (exact resume
|
||||
const sess = sessionRegistry.registerSession(node.id, sessionName);
|
||||
sessionRegistry.updateStatus(sess.id, "running");
|
||||
if (opts.podAware) {
|
||||
sessionRegistry.updateResumeToken(sess.id, runtime === "codex" ? "codex_id" : "claude_id", TOKEN);
|
||||
sessionRegistry.updateResumeToken(sess.id, resumeTypeOf(runtime), TOKEN);
|
||||
} else {
|
||||
db.prepare("UPDATE sessions SET resume_type = ?, resume_token = ?, restore_policy = ? WHERE id = ?")
|
||||
.run("claude_name", TOKEN, "resume_if_possible", sess.id);
|
||||
@@ -165,7 +169,7 @@ describe("a seat's guidance across rig down then rig up --existing (exact resume
|
||||
const actions = [{ type: "send_text", value: "STARTUP-PROMPT-MUST-NOT-REPLAY", phase: "after_ready", appliesOn: ["fresh_start", "restore"], idempotent: true }];
|
||||
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(node.id, JSON.stringify(entries), JSON.stringify(files), JSON.stringify(actions), runtime);
|
||||
const guidanceFile = path.join(cwd, runtime === "codex" ? "AGENTS.md" : (opts.claudeManagedBlockFile ?? "CLAUDE.md"));
|
||||
const guidanceFile = path.join(cwd, runtime === "claude-code" ? (opts.claudeManagedBlockFile ?? "CLAUDE.md") : "AGENTS.md");
|
||||
if (opts.userText) fs.writeFileSync(guidanceFile, opts.userText);
|
||||
mergeManagedBlock(nodeFs, guidanceFile, "lead-role", "You lead this team.");
|
||||
mergeManagedBlock(nodeFs, guidanceFile, "SOP.md", "How work flows here.");
|
||||
@@ -199,7 +203,7 @@ describe("a seat's guidance across rig down then rig up --existing (exact resume
|
||||
checkReady: vi.fn(async () => ({ ready: true })),
|
||||
launchHarness: vi.fn(async () => {
|
||||
observe();
|
||||
return { ok: true as const, resumeToken: TOKEN, resumeType: runtime === "codex" ? "codex_id" : "claude_id" };
|
||||
return { ok: true as const, resumeToken: TOKEN, resumeType: resumeTypeOf(runtime) };
|
||||
}),
|
||||
};
|
||||
const restore = new RestoreOrchestrator({
|
||||
@@ -210,7 +214,7 @@ describe("a seat's guidance across rig down then rig up --existing (exact resume
|
||||
// The pane's process tree shows the same native session resumed (the joined-resume proof).
|
||||
listProcesses: async () => [
|
||||
{ pid: 1234, ppid: 1, command: "zsh" },
|
||||
{ pid: 1235, ppid: 1234, command: runtime === "codex" ? `codex resume ${TOKEN}` : `claude --resume ${TOKEN}` },
|
||||
{ pid: 1235, ppid: 1234, command: runtime === "codex" ? `codex resume ${TOKEN}` : runtime === "pi" ? `pi --session ${TOKEN}` : `claude --resume ${TOKEN}` },
|
||||
],
|
||||
});
|
||||
const up = await restore.restore(down.snapshotId!, { adapters: { [runtime]: adapter as never } });
|
||||
@@ -272,6 +276,20 @@ describe("a seat's guidance across rig down then rig up --existing (exact resume
|
||||
}
|
||||
});
|
||||
|
||||
// #875: a Pi seat's blocks live in AGENTS.md. Down takes them out like a Codex seat's, and the exact resume puts them back.
|
||||
it("a Pi seat's exact resume puts back the blocks down took out of its AGENTS.md, and writes no other file", async () => {
|
||||
const { rigId, guidanceFile } = launchedSeat({ podAware: true, runtime: "pi", userText: "Person notes\n" });
|
||||
let afterDown: string | null = null;
|
||||
const { result, fileAtHarnessStart } = await downThenUpExisting(rigId, guidanceFile, { podAware: true, runtime: "pi",
|
||||
betweenDownAndUp: () => { afterDown = fs.readFileSync(guidanceFile, "utf-8"); } });
|
||||
expect(afterDown, "AGENTS.md after rig down").toBe("Person notes\n");
|
||||
expect(fileAtHarnessStart).toContain("Person notes\n");
|
||||
expect(fileAtHarnessStart).toContain(MANAGED_BLOCK_START("lead-role"));
|
||||
expect(fileAtHarnessStart).toContain(MANAGED_BLOCK_START("SOP.md"));
|
||||
expect(result.nodes[0]?.guidanceGaps).toBeUndefined();
|
||||
for (const other of ["CLAUDE.md", "CLAUDE.local.md"]) expect(fs.existsSync(path.join(cwd, other)), other).toBe(false);
|
||||
});
|
||||
|
||||
it("only a missing block is put back: an existing block keeps its content, and user text is preserved", async () => {
|
||||
const { rigId, guidanceFile } = launchedSeat({ userText: "# My project notes\nKeep tests fast.\n" });
|
||||
const { fileAtHarnessStart } = await downThenUpExisting(rigId, guidanceFile, {
|
||||
|
||||
@@ -23,6 +23,10 @@ import { promisify } from "node:util";
|
||||
import { managedBlockText, mergeManagedBlock } from "../src/domain/managed-blocks.js";
|
||||
import { SEAT_SKILLS_BOOTSTRAP_LINE } from "../src/domain/seat-skill-storage.js";
|
||||
import { getNodeInventory } from "../src/domain/node-inventory.js";
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import { replaceFile } from "../src/adapters/replace-file.js";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
import { claudeActivityRelayPath } from "../src/domain/claude-activity-hooks.js";
|
||||
|
||||
|
||||
function mockTmux(killResult?: { ok: boolean; code?: string; message?: string }): TmuxAdapter {
|
||||
@@ -594,7 +598,7 @@ describe("RigTeardownOrchestrator", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["claude-code", "CLAUDE.md"], ["claude-code", "CLAUDE.local.md"], ["codex", "AGENTS.md"],
|
||||
["claude-code", "CLAUDE.md"], ["claude-code", "CLAUDE.local.md"], ["codex", "AGENTS.md"], ["pi", "AGENTS.md"], ["omp", "AGENTS.md"],
|
||||
])("preserves %s guidance when a sibling sharing %s cannot stop", async (runtime, fileName) => {
|
||||
const { rigId, nodeId, sessionId } = seedRigWithNode({ runtime, cwd: tmpDir });
|
||||
if (fileName === "CLAUDE.local.md") rigRepo.setRigClaudeManagedBlockFile(rigId, fileName);
|
||||
@@ -740,6 +744,204 @@ describe("RigTeardownOrchestrator", () => {
|
||||
expect(fs.readFileSync(guidance, "utf-8")).toBe(content);
|
||||
});
|
||||
|
||||
// #875: Pi and omp seats write their blocks to AGENTS.md too, so a Codex rig stopping must not strip a file they still use.
|
||||
it.each(["pi", "omp"])("keeps a shared AGENTS.md that another rig's live %s seat still uses when a Codex rig stops", async (runtime) => {
|
||||
const { rigId } = seedRigWithNode({ runtime: "codex", cwd: tmpDir });
|
||||
const other = rigRepo.createRig("other-rig");
|
||||
const otherNode = rigRepo.addNode(other.id, "dev", { runtime, cwd: tmpDir });
|
||||
sessionRegistry.updateStatus(sessionRegistry.registerSession(otherNode.id, "dev@other-rig").id, "running");
|
||||
const guidance = path.join(tmpDir, "AGENTS.md");
|
||||
const content = "<!-- BEGIN OpenRig MANAGED BLOCK: role -->\nshared guidance\n<!-- END OpenRig MANAGED BLOCK: role -->\n";
|
||||
fs.writeFileSync(guidance, content);
|
||||
await buildTeardown().teardown(rigId);
|
||||
expect(fs.readFileSync(guidance, "utf-8")).toBe(content);
|
||||
});
|
||||
|
||||
// #875: once no seat in a cwd still uses them, down takes back the collector, its status line and the activity relay hooks
|
||||
// OpenRig added there, and leaves everything else as the person had it.
|
||||
describe("OpenRig's Claude collector, status line and activity hooks in a seat's cwd", () => {
|
||||
const COLLECTOR = "// shipped collector\n";
|
||||
|
||||
/** A Claude seat's cwd after a real collector install, with the person's own settings text if given. */
|
||||
function claudeCwd(ownSettings?: string) {
|
||||
const cwd = fs.mkdtempSync(path.join(tmpDir, "claude-cwd-"));
|
||||
const state = path.join(tmpDir, "openrig-home");
|
||||
const asset = path.join(tmpDir, "collector-asset.cjs");
|
||||
fs.writeFileSync(asset, COLLECTOR);
|
||||
const settings = path.join(cwd, ".claude", "settings.local.json");
|
||||
if (ownSettings !== undefined) {
|
||||
fs.mkdirSync(path.dirname(settings), { recursive: true });
|
||||
fs.writeFileSync(settings, ownSettings);
|
||||
}
|
||||
const 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 }),
|
||||
copyFile: replaceFile, homedir: os.homedir(),
|
||||
};
|
||||
new ClaudeCodeAdapter({ tmux: {} as TmuxAdapter, fsOps, stateDir: state, collectorAssetPath: asset })
|
||||
.ensureContextCollector({ cwd, tmuxSession: "dev@claude-rig" });
|
||||
const relay = `${shellQuote(process.execPath)} ${shellQuote(claudeActivityRelayPath(state))}`;
|
||||
return { cwd, asset, settings, collector: path.join(cwd, ".openrig", "context-collector.cjs"), relay };
|
||||
}
|
||||
|
||||
/** Add hook groups the way the adapter writes them. */
|
||||
function addHooks(settings: string, hooks: Record<string, unknown[]>): void {
|
||||
const current = JSON.parse(fs.readFileSync(settings, "utf-8")) as Record<string, unknown>;
|
||||
current["hooks"] = { ...(current["hooks"] as Record<string, unknown> | undefined), ...hooks };
|
||||
fs.writeFileSync(settings, JSON.stringify(current, null, 2));
|
||||
}
|
||||
|
||||
function claudeRig(name: string, cwd: string): string {
|
||||
const rig = rigRepo.createRig(name);
|
||||
const node = rigRepo.addNode(rig.id, "dev", { runtime: "claude-code", cwd });
|
||||
sessionRegistry.updateStatus(sessionRegistry.registerSession(node.id, `dev@${name}`).id, "running");
|
||||
return rig.id;
|
||||
}
|
||||
|
||||
function teardownWith(asset: string, tmux?: TmuxAdapter) {
|
||||
return new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux ?? mockTmux(),
|
||||
snapshotCapture: mockSnapshotCapture(db), eventBus, claudeCollectorAssetPath: asset });
|
||||
}
|
||||
|
||||
it("gives a non-git cwd back the person's own settings bytes, and removes the collector and its emptied folder", async () => {
|
||||
const own = JSON.stringify({ permissions: { allow: ["Bash(ls:*)"] }, env: { TEAM: "1" } }, null, 2);
|
||||
const s = claudeCwd(own);
|
||||
addHooks(s.settings, { Stop: [{ hooks: [{ type: "command", command: s.relay }] }],
|
||||
PreToolUse: [{ hooks: [{ type: "command", command: s.relay, timeout: 5 }] }] });
|
||||
expect(fs.readFileSync(s.settings, "utf-8")).not.toBe(own);
|
||||
expect(fs.existsSync(path.join(s.cwd, ".git"))).toBe(false);
|
||||
|
||||
const result = await teardownWith(s.asset).teardown(claudeRig("claude-rig", s.cwd));
|
||||
|
||||
expect(fs.readFileSync(s.settings, "utf-8")).toBe(own);
|
||||
expect(fs.existsSync(s.collector)).toBe(false);
|
||||
expect(fs.existsSync(path.join(s.cwd, ".openrig"))).toBe(false);
|
||||
expect(result.keptFiles).toBeUndefined();
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps settings.local.json as {} when OpenRig's entries were all it held, since nothing says who created it", async () => {
|
||||
const s = claudeCwd();
|
||||
addHooks(s.settings, { Stop: [{ hooks: [{ type: "command", command: s.relay }] }] });
|
||||
|
||||
await teardownWith(s.asset).teardown(claudeRig("claude-rig", s.cwd));
|
||||
|
||||
expect(fs.readFileSync(s.settings, "utf-8")).toBe("{}");
|
||||
expect(fs.existsSync(path.join(s.cwd, ".claude"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(s.cwd, ".openrig"))).toBe(false);
|
||||
});
|
||||
|
||||
it("takes only OpenRig's hooks out of a mixed group, keeping the person's hooks, the group's other fields and their status line fields", async () => {
|
||||
const person = { type: "command", command: "echo person" };
|
||||
const s = claudeCwd(JSON.stringify({ permissions: { deny: ["Read(.env)"] }, statusLine: { padding: 2 } }, null, 2));
|
||||
addHooks(s.settings, {
|
||||
PreToolUse: [{ matcher: "Bash", hooks: [person, { type: "command", command: s.relay }] }],
|
||||
Stop: [{ hooks: [{ type: "command", command: s.relay }] }],
|
||||
Notification: [{ matcher: "", hooks: [person] }],
|
||||
});
|
||||
|
||||
await teardownWith(s.asset).teardown(claudeRig("claude-rig", s.cwd));
|
||||
|
||||
expect(JSON.parse(fs.readFileSync(s.settings, "utf-8"))).toEqual({
|
||||
permissions: { deny: ["Read(.env)"] },
|
||||
statusLine: { padding: 2 },
|
||||
hooks: { PreToolUse: [{ matcher: "Bash", hooks: [person] }], Notification: [{ matcher: "", hooks: [person] }] },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps and names a collector whose bytes aren't the shipped collector's, and still takes its status line out", async () => {
|
||||
const s = claudeCwd();
|
||||
fs.appendFileSync(s.collector, "// a person's edit\n");
|
||||
|
||||
const result = await teardownWith(s.asset).teardown(claudeRig("claude-rig", s.cwd));
|
||||
|
||||
expect(fs.readFileSync(s.collector, "utf-8")).toBe(`${COLLECTOR}// a person's edit\n`);
|
||||
expect(result.keptFiles).toEqual([`${s.collector}: not the shipped collector's bytes, so it was kept`]);
|
||||
expect(JSON.parse(fs.readFileSync(s.settings, "utf-8"))).toEqual({});
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["{ \"permissions\": ", "[]"])("leaves settings it can't read as a settings object (%s) byte for byte, and names them", async (text) => {
|
||||
const s = claudeCwd(text);
|
||||
|
||||
const result = await teardownWith(s.asset).teardown(claudeRig("claude-rig", s.cwd));
|
||||
|
||||
expect(fs.readFileSync(s.settings, "utf-8")).toBe(text);
|
||||
expect(result.keptFiles).toEqual([`${s.settings}: not a JSON settings object, so it was left as it is`]);
|
||||
expect(fs.existsSync(s.collector)).toBe(false);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["another rig's seat", "a sibling whose stop failed"] as const)("keeps everything while %s still uses the cwd", async (consumer) => {
|
||||
const own = JSON.stringify({ permissions: { allow: ["Bash(ls:*)"] } }, null, 2);
|
||||
const s = claudeCwd(own);
|
||||
addHooks(s.settings, { Stop: [{ hooks: [{ type: "command", command: s.relay }] }] });
|
||||
const before = fs.readFileSync(s.settings, "utf-8");
|
||||
const rigId = claudeRig("claude-rig", s.cwd);
|
||||
const tmux = mockTmux();
|
||||
if (consumer === "another rig's seat") claudeRig("other-rig", s.cwd);
|
||||
else {
|
||||
const sibling = rigRepo.addNode(rigId, "survivor", { runtime: "claude-code", cwd: s.cwd });
|
||||
sessionRegistry.updateStatus(sessionRegistry.registerSession(sibling.id, "survivor@claude-rig").id, "running");
|
||||
tmux.killSession = vi.fn(async target => target === "survivor@claude-rig"
|
||||
? { ok: false, code: "kill_failed", message: "tmux error" } : { ok: true });
|
||||
}
|
||||
|
||||
await teardownWith(s.asset, tmux).teardown(rigId);
|
||||
|
||||
expect(fs.readFileSync(s.settings, "utf-8")).toBe(before);
|
||||
expect(fs.readFileSync(s.collector, "utf-8")).toBe(COLLECTOR);
|
||||
|
||||
if (consumer === "a sibling whose stop failed") {
|
||||
// Once that seat stops too, the next down takes OpenRig's additions back.
|
||||
tmux.killSession = vi.fn(async () => ({ ok: true }));
|
||||
await teardownWith(s.asset, tmux).teardown(rigId);
|
||||
expect(fs.readFileSync(s.settings, "utf-8")).toBe(own);
|
||||
expect(fs.existsSync(s.collector)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// A link below the cwd shares a file between two directories: the stopping seat's cwd A and a live seat's cwd B. Whichever
|
||||
// way the link points, the shared target stays and is named; A's own, unshared targets still go.
|
||||
it.each(["B's .claude and .openrig link to A's", "A's .claude and .openrig link to B's", "settings.local.json is hardlinked"] as const)(
|
||||
"keeps a Claude target a seat in another directory still uses when %s", async (shape) => {
|
||||
const own = JSON.stringify({ permissions: { allow: ["Bash(ls:*)"] } }, null, 2);
|
||||
const real = claudeCwd(own);
|
||||
addHooks(real.settings, { Stop: [{ hooks: [{ type: "command", command: real.relay }] }] });
|
||||
const other = fs.mkdtempSync(path.join(tmpDir, "claude-other-"));
|
||||
let a = real.cwd, b = other;
|
||||
if (shape === "A's .claude and .openrig link to B's") { a = other; b = real.cwd; }
|
||||
if (shape === "settings.local.json is hardlinked") {
|
||||
fs.mkdirSync(path.join(other, ".claude"));
|
||||
fs.linkSync(real.settings, path.join(other, ".claude", "settings.local.json"));
|
||||
} else {
|
||||
fs.symlinkSync(path.join(real.cwd, ".claude"), path.join(other, ".claude"));
|
||||
fs.symlinkSync(path.join(real.cwd, ".openrig"), path.join(other, ".openrig"));
|
||||
}
|
||||
const before = fs.readFileSync(real.settings, "utf-8");
|
||||
const rigId = claudeRig("claude-rig", a);
|
||||
claudeRig("other-rig", b);
|
||||
|
||||
const result = await teardownWith(real.asset).teardown(rigId);
|
||||
|
||||
expect(fs.readFileSync(real.settings, "utf-8")).toBe(before);
|
||||
const aSettings = path.join(a, ".claude", "settings.local.json");
|
||||
const aCollector = path.join(a, ".openrig", "context-collector.cjs");
|
||||
if (shape === "settings.local.json is hardlinked") {
|
||||
// A's collector isn't shared, so it still goes.
|
||||
expect(fs.existsSync(real.collector)).toBe(false);
|
||||
expect(result.keptFiles).toEqual([`${aSettings}: another seat's directory still uses it through a link, so it was kept`]);
|
||||
} else {
|
||||
expect(fs.readFileSync(real.collector, "utf-8")).toBe(COLLECTOR);
|
||||
expect(result.keptFiles).toEqual([
|
||||
`${aSettings}: another seat's directory still uses it through a link, so it was kept`,
|
||||
`${aCollector}: another seat's directory still uses it through a link, so it was kept`,
|
||||
]);
|
||||
}
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// T12: Kill failure + --delete -> blocked
|
||||
it("kill failure blocks --delete", async () => {
|
||||
const { rigId, nodeId } = seedRig();
|
||||
|
||||
@@ -218,9 +218,10 @@ describe("source order", () => {
|
||||
for (const type of ["agent_end", "agent_start"]) core.handlePiLine(JSON.stringify({ type }));
|
||||
const sent = posts.filter((p) => typeof p.hookEvent === "string" && p.eventFamily === undefined);
|
||||
const newest = sent.at(-1)!;
|
||||
expect([sent.at(-2)!.hookEvent, newest.hookEvent, newest.sequence]).toEqual(["Stop", "active", 36]);
|
||||
await f.deliver(newest); // the next turn's start arrives first, then all 35 older posts, the Stop last
|
||||
for (const older of sent.slice(0, -1)) await f.deliver(older);
|
||||
// Each run opens with its accepted turn (contract section 4), so the next turn's start is two posts.
|
||||
expect([sent.at(-3)!.hookEvent, sent.at(-2)!.hookEvent, newest.hookEvent, newest.sequence]).toEqual(["Stop", "UserPromptSubmit", "active", 38]);
|
||||
for (const next of sent.slice(-2)) await f.deliver(next); // the next turn's start arrives first, then all 36 older posts, the Stop last
|
||||
for (const older of sent.slice(0, -2)) await f.deliver(older);
|
||||
expect(f.state().activity).toBe("working");
|
||||
await f.restart(); // restoring uses the same order: the newest start, not the late Stop
|
||||
expect(f.state().activity).toBe("working");
|
||||
|
||||
@@ -14,10 +14,10 @@ type Check = { name: string; ok: boolean; detail?: string };
|
||||
type Result = { id: string; status: "pass" | "fail" | "skip" | "advisory"; met?: boolean; checks: Check[]; reason?: string };
|
||||
type Report = { results: Result[]; summary: { pass: number; fail: number; skip: number; advisory: number } };
|
||||
|
||||
async function check(args: string[], runnerFlags = "", nodeArgs: string[] = []): Promise<{ code: number; report: Report }> {
|
||||
async function check(args: string[], runnerFlags = "", nodeArgs: string[] = [], timeoutMs = 90_000): Promise<{ code: number; report: Report }> {
|
||||
const runner = `${process.execPath} ${RUNNER} --agent {agent}${runnerFlags}`;
|
||||
try {
|
||||
const { stdout } = await promisify(execFile)(process.execPath, [...nodeArgs, CHECK, "--runner", runner, "--name", "minimal", "--json", ...args], { timeout: 90_000 });
|
||||
const { stdout } = await promisify(execFile)(process.execPath, [...nodeArgs, CHECK, "--runner", runner, "--name", "minimal", "--json", ...args], { timeout: timeoutMs });
|
||||
return { code: 0, report: JSON.parse(stdout) };
|
||||
} catch (error) {
|
||||
const failed = error as { code?: number; stdout?: string; stderr?: string };
|
||||
@@ -32,7 +32,10 @@ describe.skipIf(!hasTmux)("the runner conformance check", () => {
|
||||
it("passes every case for a runner that keeps the contract", async () => {
|
||||
const { code, report } = await check(["--resume", "--first-input"]);
|
||||
expect(failing(report)).toEqual([]);
|
||||
expect(report.summary).toEqual({ pass: 12, fail: 0, skip: 0, advisory: 1 });
|
||||
// 3b, the optional working beat, skips: this double sends no beats unless it's run with --beat.
|
||||
expect(report.summary).toEqual({ pass: 12, fail: 0, skip: 1, advisory: 2 });
|
||||
expect(report.results.find((r) => r.id === "3b")).toMatchObject({ status: "skip", reason: expect.stringContaining("optional") });
|
||||
expect(report.results.find((r) => r.id === "4c")).toMatchObject({ status: "advisory", met: true });
|
||||
expect(report.results.find((r) => r.id === "7c")).toMatchObject({ status: "advisory", met: true });
|
||||
expect(code).toBe(0);
|
||||
}, 120_000);
|
||||
@@ -49,6 +52,18 @@ describe.skipIf(!hasTmux)("the runner conformance check", () => {
|
||||
expect(code).toBe(0);
|
||||
}, 90_000);
|
||||
|
||||
// Section 7 lets a runner drop a person's words (OpenRig's `said`) and still keep the contract, so passing them on is
|
||||
// reported on its own, like the shutdown duty.
|
||||
it("reports a runner that drops the person's words as an advisory result that doesn't fail the run", async () => {
|
||||
const { code, report } = await check(["--cases", "4b,4c"], " --break drop-words");
|
||||
const words = report.results.find((r) => r.id === "4c")!;
|
||||
expect(words).toMatchObject({ status: "advisory", met: false });
|
||||
expect(words.checks.filter((c) => !c.ok).map((c) => c.name)).toContainEqual(expect.stringContaining("the person's words are queued to the agent as a prompt"));
|
||||
expect(report.results.find((r) => r.id === "4b")!.status).toBe("pass");
|
||||
expect(report.summary).toMatchObject({ fail: 0, advisory: 1 });
|
||||
expect(code).toBe(0);
|
||||
}, 90_000);
|
||||
|
||||
it("skips resume and first input when the runner's preset declares neither, and says why", async () => {
|
||||
const { code, report } = await check(["--cases", "6,9"]);
|
||||
expect(report.results.map((r) => [r.id, r.status])).toEqual([["6", "skip"], ["9", "skip"]]);
|
||||
@@ -85,6 +100,9 @@ describe.skipIf(!hasTmux)("the runner conformance check", () => {
|
||||
["hold-on-cancel", "4b", "stale ask: nothing is held"],
|
||||
["hold-after-withdraw", "4b", "turn end: nothing is held once the ask is withdrawn"],
|
||||
["stop-before-end", "4b", "turn end: nothing is held once the ask is withdrawn"],
|
||||
["stop-low-sequence", "4b", "pending: no idle Stop while it waits"],
|
||||
// An idle Stop whose occurredAt isn't a string: OpenRig dates it by its arrival, so it counts.
|
||||
["stop-numeric-time", "4b", "pending: no idle Stop while it waits"],
|
||||
["hold-after-withdraw", "4b", "pending: after /abort, the turn's end is reported, holding nothing"],
|
||||
["forget-permitted-ask", "4b", "unreachable read: the ask is still withdrawn at the turn's end"],
|
||||
["cancel-on-revoked", "4b", "activity_grant_revoked: the agent gets its allow_once option"],
|
||||
@@ -92,10 +110,15 @@ describe.skipIf(!hasTmux)("the runner conformance check", () => {
|
||||
["no-withdraw", "4b", "pending: /abort withdraws the ask with turn_cancelled"],
|
||||
["late-deliver", "4b", "pending: an answer after the withdraw isn't delivered"],
|
||||
["paste-answers", "4b", "pending: a pasted message isn't an answer"],
|
||||
["said-as-answer", "4b", "permitted at read, with the person's words: the agent gets its allow_once option"],
|
||||
["wrong-generation", "5", "generation is OPENRIG_OCCUPANT_GENERATION on every post"],
|
||||
["new-on-resume", "6", "other failure: no new conversation starts"],
|
||||
["missing-on-any-error", "6", "other failure: not reported missing or resumed"],
|
||||
["idle-on-exit", "7", "no idle Stop after the exit"],
|
||||
["idle-on-exit", "7", "no idle report for the turn it died in"],
|
||||
// An idle Stop numbered below the turn's accepted prompt but later in time: OpenRig orders by time first, so it counts.
|
||||
["idle-low-sequence-on-exit", "7", "no idle report for the turn it died in"],
|
||||
// An idle Stop at the accepted prompt's time with a string sequence: OpenRig reads no sequence there, so it counts.
|
||||
["idle-string-sequence-on-exit", "7", "no idle report for the turn it died in"],
|
||||
["no-error-post", "7b", "a failed session/prompt posts Notification/runtime_error"],
|
||||
["ignore-unknown", "8", "the unknown request is answered -32601"],
|
||||
["first-input-trim", "9", "the first prompt is the file's bytes, newlines and spaces included"],
|
||||
@@ -126,6 +149,28 @@ describe.skipIf(!hasTmux)("the runner conformance check", () => {
|
||||
expect(code).toBe(0);
|
||||
}, 90_000);
|
||||
|
||||
// The optional working beat (section 4): a runner that beats in a quiet turn keeps 3b's promises, and each way of
|
||||
// breaking one is caught by the check that names it. Case 3b's three launches take about 90 s, so it gets a longer bound.
|
||||
const CASE_3B_MS = 170_000;
|
||||
it("passes 3b for a runner that beats through a quiet turn, stops while a person is asked, and resolves before beating again", async () => {
|
||||
const { code, report } = await check(["--cases", "3b"], " --beat", [], CASE_3B_MS);
|
||||
expect(failing(report)).toEqual([]);
|
||||
expect(report.results.map((r) => [r.id, r.status])).toEqual([["3b", "pass"]]);
|
||||
expect(code).toBe(0);
|
||||
}, CASE_3B_MS + 10_000);
|
||||
it.concurrent.each([
|
||||
["beat-after-stop", "no beat after the turn's Stop"],
|
||||
["beat-during-ask", "asked: no beat while the ask waits on a person"],
|
||||
["no-resolve", "asked: after the answer, active/permission_resolved names the turn and the ask, before any beat"],
|
||||
["beat-time-backwards", "sequence rises with the runner's own time across every post, beats included"],
|
||||
])("catches %s in case 3b", async (broken, name) => {
|
||||
const { code, report } = await check(["--cases", "3b"], ` --beat --break ${broken}`, [], CASE_3B_MS);
|
||||
const result = report.results.find((r) => r.id === "3b")!;
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.checks.filter((c) => !c.ok).map((c) => c.name)).toContainEqual(expect.stringContaining(name));
|
||||
expect(code).toBe(1);
|
||||
}, CASE_3B_MS + 10_000);
|
||||
|
||||
// Section 7 sets no time limit for a permit after a read OpenRig answered with a status it doesn't list. A runner that
|
||||
// retries such a read a few times first keeps the contract, so the check waits its ordinary per-step bound for it.
|
||||
it("passes 4b for a runner that retries a failed read three times, 1.5 s apart, before it permits", async () => {
|
||||
@@ -135,6 +180,15 @@ describe.skipIf(!hasTmux)("the runner conformance check", () => {
|
||||
expect(code).toBe(0);
|
||||
}, 120_000);
|
||||
|
||||
// A runner may report idle at startup, before any turn: that's neither an idle report while an ask waits (4b) nor one
|
||||
// for a turn the agent died in (7), even when its POST arrives after the agent's exit.
|
||||
it("passes 4b and 7 for a runner that reports idle at startup, even when that report arrives late", async () => {
|
||||
const { code, report } = await check(["--cases", "4b,7"], " --startup-idle 1500");
|
||||
expect(failing(report)).toEqual([]);
|
||||
expect(report.results.map((r) => [r.id, r.status])).toEqual([["4b", "pass"], ["7", "pass"]]);
|
||||
expect(code).toBe(0);
|
||||
}, 150_000);
|
||||
|
||||
// The contract doesn't order a turn's Stop and its ask's withdraw. A runner that posts the Stop first keeps it.
|
||||
it("passes 4b for a runner that withdraws a cancelled or ended turn's ask after posting its Stop", async () => {
|
||||
const { code, report } = await check(["--cases", "4b"], " --withdraw-after-stop");
|
||||
|
||||
@@ -214,14 +214,37 @@ describe("a runner seat's answered permission path", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a typed yes", { decision: "Yes." }, "answered", "allow"],
|
||||
["a typed no", { decision: "no" }, "answered", "reject"],
|
||||
["typed words that are neither", { decision: "what does this edit do?" }, "permitted", "allow"],
|
||||
])("reads %s from the person as the offered option, and no answer as the default", async (_case, choice, state, optionId) => {
|
||||
["a typed yes", { decision: "Yes." }, "answered", "allow", undefined],
|
||||
["a typed no", { decision: "no" }, "answered", "reject", undefined],
|
||||
["typed words that are neither", { decision: "what does this edit do?" }, "permitted", "allow", "what does this edit do?"],
|
||||
])("reads %s from the person as the offered option, and no answer as the default", async (_case, choice, state, optionId, said) => {
|
||||
const f = fixture();
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
expect(await f.answer(askId, choice)).toMatchObject({ status: 200 });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state, optionId } });
|
||||
const read = await f.read(askId);
|
||||
expect(read).toMatchObject({ body: { state, optionId } });
|
||||
// The person's own words reach the runner beside the default, never in place of an option.
|
||||
expect(read.body.said).toBe(said);
|
||||
});
|
||||
|
||||
it("carries the person's words with the refusal when the agent offered no allow option, never reading them as a no", async () => {
|
||||
const f = fixture();
|
||||
const askId = String((await f.submit({ options: [{ optionId: "reject", name: "Reject", kind: "reject_once" },
|
||||
{ optionId: "never", name: "Never", kind: "reject_always" }] })).body.askId);
|
||||
expect(await f.answer(askId, { decision: "use the other file instead" })).toMatchObject({ status: 200 });
|
||||
expect((await f.read(askId)).body).toEqual({ ok: true, state: "refused", reason: "no_allow_offered", said: "use the other file instead" });
|
||||
});
|
||||
|
||||
it("carries a colleague's words, whole, when the ask was routed to them", async () => {
|
||||
const f = fixture();
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.contract.act({ verb: "route", qitemId: askId, actorSession: PERSON, destinationSession: OTHER_PERSON, reason: "Dana decides", notify: false } as never);
|
||||
const colleague = f.queueRepo.list({ limit: 100 }).find((row) => row.destinationSession === OTHER_PERSON && row.handedOffFrom === askId)!;
|
||||
const words = `only the tests folder; ${"and nothing else. ".repeat(200)}`;
|
||||
expect(await f.answer(colleague.qitemId, { decision: words }, OTHER_PERSON)).toMatchObject({ status: 200 });
|
||||
const read = await f.read(askId, 1);
|
||||
expect(read.body).toMatchObject({ state: "permitted", optionId: "allow" });
|
||||
expect(read.body.said).toBe(words.trim());
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
|
||||
import { SeatActivityService, HOOK_AUTHORITY_WINDOW_MS } from "../src/domain/seat-activity-service.js";
|
||||
import { activityRoutes, restoredRunnerHookEvidence } from "../src/routes/activity.js";
|
||||
import { runtimeRungInventory } from "../src/domain/activity-taxonomy.js";
|
||||
import { RunnerCore, type RunnerIo } from "../src/adapters/pi-runner.js";
|
||||
|
||||
// The runner contract's working beat (section 4): `active` / `liveness` keeps a long, quiet turn reading working while
|
||||
// the runner says its prompt is outstanding and its agent alive. A beat counts only for the live generation's latest
|
||||
// accepted turn that hasn't ended, with a sequence above every earlier post's, and never while attention is
|
||||
// outstanding; it lasts its livenessSeconds, clamped to 5-60 s (30 s without one), then the seat reads unknown again.
|
||||
// Every test posts what a runner posts, through the real route, recorder and activity service.
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup(); });
|
||||
|
||||
function fixture() {
|
||||
const runtime = "pi";
|
||||
const clock = { now: Date.parse("2026-11-01T12:00:00.000Z") };
|
||||
const db = createFullTestDb();
|
||||
cleanups.push(() => db.close());
|
||||
const rigs = new RigRepository(db);
|
||||
const rig = rigs.createRig("working-liveness");
|
||||
const node = rigs.addNode(rig.id, "dev.a", { runtime });
|
||||
const sessions = new SessionRegistry(db);
|
||||
const oldSession = sessions.registerSession(node.id, "dev-a-old@working-liveness");
|
||||
const oldGeneration = sessions.currentOccupantTenure(node.id)!.generationUuid;
|
||||
sessions.updateStatus(oldSession.id, "exited");
|
||||
const session = sessions.registerSession(node.id, "dev-a@working-liveness", "handover");
|
||||
sessions.updateStatus(session.id, "running");
|
||||
const eventBus = new EventBus(db);
|
||||
const store = new AgentActivityStore({ db, eventBus, now: () => new Date(clock.now),
|
||||
resolveOccupantGeneration: id => sessions.currentOccupantTenure(id)?.generationUuid ?? null,
|
||||
isRegisteredOccupantGeneration: (id, generation) => sessions.isOccupantGenerationRegistered(id, generation) });
|
||||
const newOracle = () => {
|
||||
const service = new SeatActivityService({ tmux: { readPaneLastActivity: async () => null }, defaultWindowSeconds: 3,
|
||||
now: () => new Date(clock.now), restoreHookEvidence: (seat, seatRuntime) => restoredRunnerHookEvidence(store, seat, seatRuntime) });
|
||||
service.declareRungInventory({ seatNodeId: node.id, sessionName: session.sessionName }, runtimeRungInventory(runtime));
|
||||
return service;
|
||||
};
|
||||
let oracle = newOracle();
|
||||
let app = new Hono();
|
||||
const mount = (routes: typeof activityRoutes) => {
|
||||
app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("agentActivityStore" as never, store as never);
|
||||
c.set("activityHookToken" as never, "fixture-token" as never);
|
||||
c.set("seatActivityService" as never, oracle as never);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/activity", routes);
|
||||
};
|
||||
mount(activityRoutes);
|
||||
let generation = sessions.currentOccupantTenure(node.id)!.generationUuid;
|
||||
async function post(hookEvent: string, subtype: string | null, over: Record<string, unknown> = {}) {
|
||||
const response = await app.request("/api/activity/hooks", { method: "POST",
|
||||
headers: { authorization: "Bearer fixture-token", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionName: session.sessionName, nodeId: node.id, generation, runtime, hookEvent, subtype,
|
||||
occurredAt: new Date(clock.now).toISOString(), ...over }) });
|
||||
expect(response.status).toBe(200);
|
||||
return await response.json() as { ok: boolean; ignored?: string };
|
||||
}
|
||||
let sequence = 0;
|
||||
const next = () => ++sequence;
|
||||
const accepted = (turnId: string) => post("UserPromptSubmit", "accepted", { turnId, sequence: next() });
|
||||
const beat = (turnId: string, over: Record<string, unknown> = {}) =>
|
||||
post("active", "liveness", { turnId, sequence: next(), livenessSeconds: 30, ...over });
|
||||
const state = () => oracle.getSeatState(node.id)!;
|
||||
const later = (ms: number) => { clock.now += ms; };
|
||||
const at = (skewMs = 0) => new Date(clock.now + skewMs).toISOString();
|
||||
// A daemon restart: a new activity service takes the seat's last recorded hook, as its reconcile pass does.
|
||||
const restart = () => {
|
||||
oracle = newOracle();
|
||||
const evidence = restoredRunnerHookEvidence(store, { seatNodeId: node.id, sessionName: session.sessionName }, runtime);
|
||||
if (evidence) oracle.reportEvidence({ ...evidence, seq: 0 });
|
||||
};
|
||||
// The same, with what the route module held in memory lost too, its working-liveness gate among it.
|
||||
const restartDaemon = async () => {
|
||||
vi.resetModules();
|
||||
mount((await import("../src/routes/activity.js")).activityRoutes);
|
||||
restart();
|
||||
};
|
||||
// `rig seat clear-attention`: the operator's word that the prompt is gone, outside the hook route.
|
||||
const clearAttention = () => {
|
||||
store.recordHookEvent({ sessionName: session.sessionName, runtime, generation, hookEvent: "PermissionPromptCleared" });
|
||||
oracle.settlePermissionPrompt(session.sessionName);
|
||||
};
|
||||
// A same-name replacement: a new occupant takes the seat and keeps its session name.
|
||||
const replaceOccupant = () => {
|
||||
const previous = generation;
|
||||
sessions.updateStatus(session.id, "exited");
|
||||
const replacement = sessions.registerSession(node.id, session.sessionName, "handover");
|
||||
sessions.updateStatus(replacement.id, "running");
|
||||
generation = sessions.currentOccupantTenure(node.id)!.generationUuid;
|
||||
return { previous, current: generation };
|
||||
};
|
||||
return { post, accepted, beat, next, state, later, at, restart, restartDaemon, clearAttention, replaceOccupant, oldGeneration, runtime,
|
||||
sessionName: session.sessionName, nodeId: node.id, generation: () => generation };
|
||||
}
|
||||
|
||||
describe("the runner's working beat", () => {
|
||||
it("keeps a long silent turn reading working while beats keep coming, then unknown, never idle, once they stop", async () => {
|
||||
const f = fixture();
|
||||
await f.accepted("1");
|
||||
for (let i = 0; i < 8; i++) {
|
||||
f.later(12_000);
|
||||
expect(await f.beat("1")).toEqual({ ok: true, activity: expect.anything() });
|
||||
f.later(HOOK_AUTHORITY_WINDOW_MS + 5_000); // past the 15 s a bare working post has, inside the beat's 30 s
|
||||
expect(f.state()).toMatchObject({ activity: "working", decidedBy: "lifecycle-hooks" });
|
||||
f.later(-(HOOK_AUTHORITY_WINDOW_MS + 5_000));
|
||||
}
|
||||
f.later(31_000); // the last beat's 30 s are up
|
||||
expect(f.state()).toMatchObject({ activity: "unknown", decidedBy: null });
|
||||
});
|
||||
|
||||
it("lasts its livenessSeconds, clamped to 5-60 s, and 30 s when the beat has none", async () => {
|
||||
for (const [given, lasts] of [[600, 60], [1, 5], [undefined, 30], [20, 20]] as const) {
|
||||
const f = fixture();
|
||||
await f.accepted("1");
|
||||
await f.beat("1", given === undefined ? { livenessSeconds: undefined } : { livenessSeconds: given });
|
||||
f.later(lasts * 1000 - 500);
|
||||
expect(f.state().activity, `livenessSeconds ${given} still counts at ${lasts} s`).toBe("working");
|
||||
f.later(1_000);
|
||||
expect(f.state().activity, `livenessSeconds ${given} has lapsed after ${lasts} s`).toBe("unknown");
|
||||
}
|
||||
});
|
||||
|
||||
it("is ignored after its turn's Stop, for an earlier turn, out of order, or with no sequence", async () => {
|
||||
const f = fixture();
|
||||
await f.accepted("1");
|
||||
await f.post("Stop", "end_turn", { sequence: f.next(), livenessSeconds: 180 });
|
||||
expect(await f.beat("1")).toMatchObject({ ok: true, ignored: "turn_ended" });
|
||||
expect(f.state()).toMatchObject({ activity: "idle-at-prompt" });
|
||||
await f.accepted("2");
|
||||
expect(await f.beat("1")).toMatchObject({ ignored: "wrong_turn" });
|
||||
expect(await f.beat("2", { sequence: 2 })).toMatchObject({ ignored: "out_of_order" });
|
||||
expect(await f.beat("2", { sequence: undefined })).toMatchObject({ ignored: "no_sequence" });
|
||||
f.later(HOOK_AUTHORITY_WINDOW_MS + 1_000);
|
||||
expect(f.state()).toMatchObject({ activity: "unknown" }); // none of them kept turn 2 working
|
||||
});
|
||||
|
||||
it("is ignored before any accepted turn, and from the previous occupant's generation", async () => {
|
||||
const f = fixture();
|
||||
expect(await f.beat("1")).toMatchObject({ ignored: "no_turn" });
|
||||
await f.accepted("1");
|
||||
expect(await f.beat("1", { generation: f.oldGeneration })).toMatchObject({ ignored: "stale_generation" });
|
||||
f.later(HOOK_AUTHORITY_WINDOW_MS + 1_000);
|
||||
expect(f.state()).toMatchObject({ activity: "unknown" });
|
||||
});
|
||||
|
||||
it("never clears attention; after the runner's permission_resolved, the same turn's beats count again", async () => {
|
||||
const f = fixture();
|
||||
await f.accepted("1");
|
||||
// OpenRig records the seat's permission prompt when it opens an ask, with no runner sequence.
|
||||
await f.post("Notification", "permission_prompt", { turnId: "1" });
|
||||
const delayed = f.next(); // a beat posted before the ask, arriving after it
|
||||
expect(await f.beat("1", { sequence: delayed })).toMatchObject({ ignored: "attention_outstanding" });
|
||||
expect(f.state().needsInput).toMatchObject({ count: 1 });
|
||||
// The answer is delivered and the agent goes on with turn 1.
|
||||
await f.post("active", "permission_resolved", { turnId: "1", askId: "ask-1", sequence: f.next() });
|
||||
expect(f.state()).toMatchObject({ activity: "working", needsInput: { count: 0 } });
|
||||
expect(await f.beat("1", { sequence: delayed })).toMatchObject({ ignored: "out_of_order" });
|
||||
expect(await f.beat("1")).toEqual({ ok: true, activity: expect.anything() });
|
||||
f.later(HOOK_AUTHORITY_WINDOW_MS + 5_000);
|
||||
expect(f.state()).toMatchObject({ activity: "working", needsInput: { count: 0 } });
|
||||
});
|
||||
|
||||
it("is ignored after the agent's exit", async () => {
|
||||
const f = fixture();
|
||||
await f.accepted("1");
|
||||
await f.post("Stop", `${f.runtime}_exited`, { sequence: f.next() });
|
||||
expect(await f.beat("1")).toMatchObject({ ignored: "turn_ended" });
|
||||
expect(f.state()).toMatchObject({ activity: "unknown" });
|
||||
});
|
||||
|
||||
it("isn't ended by an earlier turn's Stop that arrives after the next accepted turn", async () => {
|
||||
const f = fixture();
|
||||
await f.accepted("A");
|
||||
f.later(1_000);
|
||||
const stopA = { turnId: "A", sequence: f.next(), occurredAt: f.at() }; // posted now, delivered late
|
||||
f.later(1_000);
|
||||
await f.accepted("B");
|
||||
await f.post("Stop", "end_turn", stopA);
|
||||
expect(await f.beat("B")).toEqual({ ok: true, activity: expect.anything() });
|
||||
f.later(HOOK_AUTHORITY_WINDOW_MS + 5_000);
|
||||
expect(f.state()).toMatchObject({ activity: "working" });
|
||||
});
|
||||
|
||||
it("orders a beat sent without occurredAt by the time it was recorded, so a late older Stop still doesn't end the turn", async () => {
|
||||
const f = fixture();
|
||||
await f.accepted("A");
|
||||
f.later(1_000);
|
||||
const stopA = { turnId: "A", sequence: f.next(), occurredAt: f.at() }; // posted now, delivered late
|
||||
f.later(1_000);
|
||||
await f.accepted("B");
|
||||
f.later(1_000);
|
||||
expect(await f.beat("B", { occurredAt: undefined })).toEqual({ ok: true, activity: expect.anything() });
|
||||
await f.post("Stop", "end_turn", stopA);
|
||||
expect(await f.beat("B")).toEqual({ ok: true, activity: expect.anything() });
|
||||
f.later(HOOK_AUTHORITY_WINDOW_MS + 5_000);
|
||||
expect(f.state()).toMatchObject({ activity: "working" });
|
||||
});
|
||||
|
||||
it("doesn't take an undated beat's arrival as newer when the daemon's clock runs behind the runner's", async () => {
|
||||
const f = fixture();
|
||||
const ahead = 3_000; // the runner's clock runs 3 s ahead of the daemon's
|
||||
await f.post("UserPromptSubmit", "accepted", { turnId: "A", sequence: f.next(), occurredAt: f.at(ahead) });
|
||||
f.later(500);
|
||||
const stopA = { turnId: "A", sequence: f.next(), occurredAt: f.at(ahead) }; // posted now, delivered late
|
||||
f.later(500);
|
||||
await f.post("UserPromptSubmit", "accepted", { turnId: "B", sequence: f.next(), occurredAt: f.at(ahead) });
|
||||
f.later(500);
|
||||
await f.beat("B", { occurredAt: undefined }); // recorded at the daemon's time, which reads before B's accepted
|
||||
await f.post("Stop", "end_turn", stopA);
|
||||
expect(await f.beat("B", { occurredAt: f.at(ahead) })).toEqual({ ok: true, activity: expect.anything() });
|
||||
f.later(HOOK_AUTHORITY_WINDOW_MS + 5_000);
|
||||
expect(f.state()).toMatchObject({ activity: "working" });
|
||||
});
|
||||
|
||||
it("counts again after an operator clears the seat's attention", async () => {
|
||||
const f = fixture();
|
||||
await f.accepted("1");
|
||||
await f.post("Notification", "permission_prompt", { turnId: "1" });
|
||||
expect(await f.beat("1")).toMatchObject({ ignored: "attention_outstanding" });
|
||||
f.clearAttention();
|
||||
expect(f.state().needsInput).toMatchObject({ count: 0 });
|
||||
expect(await f.beat("1")).toEqual({ ok: true, activity: expect.anything() });
|
||||
f.later(HOOK_AUTHORITY_WINDOW_MS + 5_000);
|
||||
expect(f.state()).toMatchObject({ activity: "working" });
|
||||
});
|
||||
|
||||
it("keeps its admitted lifetime across a daemon restart", async () => {
|
||||
for (const [given, lasts] of [[6000, 60], [5, 5]] as const) {
|
||||
const f = fixture();
|
||||
await f.accepted("1");
|
||||
await f.beat("1", { livenessSeconds: given });
|
||||
f.restart();
|
||||
f.later(lasts * 1000 - 500);
|
||||
expect(f.state().activity, `a restored livenessSeconds ${given} beat still counts at ${lasts} s`).toBe("working");
|
||||
f.later(1_000);
|
||||
expect(f.state().activity, `a restored livenessSeconds ${given} beat has lapsed after ${lasts} s`).toBe("unknown");
|
||||
}
|
||||
});
|
||||
|
||||
it("is ignored with no generation, and from the previous occupant after a same-name replacement", async () => {
|
||||
const f = fixture();
|
||||
await f.accepted("1");
|
||||
expect(await f.beat("1", { generation: undefined })).toMatchObject({ ignored: "stale_generation" });
|
||||
const { previous, current } = f.replaceOccupant();
|
||||
// The previous occupant's beat, and the new occupant's beat for the turn its predecessor opened.
|
||||
expect(await f.beat("1", { generation: previous })).toMatchObject({ ignored: "stale_generation" });
|
||||
expect(await f.beat("1", { generation: current })).toMatchObject({ ignored: "stale_generation" });
|
||||
});
|
||||
|
||||
it("is ignored for a turn whose accepted prompt carried no generation", async () => {
|
||||
const f = fixture();
|
||||
await f.post("UserPromptSubmit", "accepted", { turnId: "1", sequence: f.next(), generation: undefined });
|
||||
expect(await f.beat("1")).toMatchObject({ ignored: "stale_generation" });
|
||||
});
|
||||
|
||||
// Pi's stream beat (#2336) on the contract's beat: the real Pi runner core, its posts sent through the real route.
|
||||
it("keeps a real Pi runner's long, quiet run reading working, until its agent_end", async () => {
|
||||
const f = fixture();
|
||||
const sent: Array<Promise<unknown>> = [];
|
||||
const io: RunnerIo = {
|
||||
sendRpc: () => {}, mirrorLine: () => {}, mirrorAppend: () => {}, writeSidecar: () => {}, now: () => f.at(),
|
||||
// Activity posts only: the session-identity post goes to a recorder this fixture doesn't have.
|
||||
postActivity: (payload) => { if (!payload.eventFamily) sent.push(f.post(String(payload.hookEvent), payload.subtype as string | null, payload)); },
|
||||
};
|
||||
const core = new RunnerCore(io, { sessionName: f.sessionName, nodeId: f.nodeId, launchId: "launch-1", generation: f.generation() });
|
||||
core.start();
|
||||
core.handlePiLine(JSON.stringify({ type: "response", id: "pi-runner-get-state", data: { sessionFile: "/pi/session.jsonl", sessionId: "pi-1" } }));
|
||||
core.handlePiLine(JSON.stringify({ type: "agent_start" }));
|
||||
await Promise.all(sent);
|
||||
for (let i = 0; i < 12; i++) { // a minute with nothing from the model, the runner's 5 s tick beating
|
||||
f.later(5_000);
|
||||
core.heartbeat();
|
||||
await Promise.all(sent);
|
||||
expect(f.state(), `${(i + 1) * 5} s in`).toMatchObject({ activity: "working", decidedBy: "lifecycle-hooks" });
|
||||
}
|
||||
core.handlePiLine(JSON.stringify({ type: "agent_end" }));
|
||||
await Promise.all(sent);
|
||||
expect(f.state()).toMatchObject({ activity: "idle-at-prompt" });
|
||||
});
|
||||
|
||||
// After a daemon restart mid-run its gate has no turn, so the run's beats are ignored until the runner's next accepted
|
||||
// prompt (section 4's restart rule). A prompt or a follow-up submitted into the open run is that prompt.
|
||||
it("counts a real Pi runner's beats again after a daemon restart, from its next prompt or follow-up into the open run", async () => {
|
||||
const f = fixture();
|
||||
const posts: Array<{ payload: Record<string, unknown>; result: Promise<{ ok: boolean; ignored?: string }> }> = [];
|
||||
const io: RunnerIo = {
|
||||
sendRpc: () => {}, mirrorLine: () => {}, mirrorAppend: () => {}, writeSidecar: () => {}, now: () => f.at(),
|
||||
postActivity: (payload) => {
|
||||
if (!payload.eventFamily) posts.push({ payload, result: f.post(String(payload.hookEvent), payload.subtype as string | null, payload) });
|
||||
},
|
||||
};
|
||||
const settled = () => Promise.all(posts.map(p => p.result));
|
||||
const lastBeat = async () => {
|
||||
const beat = posts.filter(p => p.payload.subtype === "liveness").at(-1)!;
|
||||
return { turnId: beat.payload.turnId, ...await beat.result };
|
||||
};
|
||||
const core = new RunnerCore(io, { sessionName: f.sessionName, nodeId: f.nodeId, launchId: "launch-1", generation: f.generation() });
|
||||
core.start();
|
||||
core.handlePiLine(JSON.stringify({ type: "response", id: "pi-runner-get-state", data: { sessionFile: "/pi/session.jsonl", sessionId: "pi-1" } }));
|
||||
core.handlePiLine(JSON.stringify({ type: "agent_start" }));
|
||||
await settled();
|
||||
await f.restartDaemon();
|
||||
f.later(5_000); core.heartbeat(); await settled();
|
||||
expect(await lastBeat()).toMatchObject({ turnId: "1", ignored: "no_turn" });
|
||||
for (const [block, turnId] of [["look at b.txt too", "2"], ["/followup then c.txt", "3"]] as const) {
|
||||
core.handleUserBlock(block);
|
||||
for (let i = 0; i < 6; i++) { // half a minute with nothing from the model
|
||||
f.later(5_000); core.heartbeat(); await settled();
|
||||
const beat = await lastBeat();
|
||||
expect(beat, `${block}: ${(i + 1) * 5} s in`).toMatchObject({ turnId, ok: true });
|
||||
expect(beat.ignored).toBeUndefined();
|
||||
expect(f.state(), `${block}: ${(i + 1) * 5} s in`).toMatchObject({ activity: "working", decidedBy: "lifecycle-hooks" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a runner that sends no beats as before: a quiet turn falls to unknown after 15 s", async () => {
|
||||
const f = fixture();
|
||||
await f.accepted("1");
|
||||
f.later(HOOK_AUTHORITY_WINDOW_MS - 1_000);
|
||||
expect(f.state()).toMatchObject({ activity: "working" });
|
||||
f.later(2_000);
|
||||
expect(f.state()).toMatchObject({ activity: "unknown" });
|
||||
});
|
||||
});
|
||||
@@ -570,6 +570,12 @@ describe("live seat runtime switch", () => {
|
||||
expect(result).toMatchObject({ ok: false, code: "runtime_switch_unsupported" });
|
||||
expect((result as { message: string }).message).toMatch(/only a single-window, single-pane seat can/);
|
||||
});
|
||||
it("a seat with no bound pane says how to get it on the other runtime, and what that route doesn't carry", async () => {
|
||||
db.prepare("UPDATE bindings SET tmux_pane = NULL WHERE node_id = ?").run(nodeId);
|
||||
expect(await service().setRuntime({ seatRef: SEAT, runtime: "claude-code", reason: "x", dryRun: true })).toMatchObject({ ok: false, code: "runtime_switch_unsupported",
|
||||
message: expect.stringMatching(/it has no bound tmux pane/),
|
||||
guidance: expect.stringMatching(/start it first.*never started.*declare the other runtime.*rig spec.*doesn't carry this seat's edges, settings or queued work/s) });
|
||||
});
|
||||
it("a seat on another runtime", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(nodeId);
|
||||
expect(await service().setRuntime({ seatRef: SEAT, runtime: "claude-code", reason: "x", dryRun: true }))
|
||||
|
||||
@@ -3,7 +3,9 @@ import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
||||
import { startupRevision } from "../src/routes/startup.js";
|
||||
import { defaultProbeRuntimes } from "../src/domain/kernel-boot.js";
|
||||
import { SeatLifecycleService } from "../src/domain/seat-lifecycle-service.js";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { verifyCodexPaneProcess, verifyPaneIdleShell } from "../src/domain/native-process-lineage.js";
|
||||
vi.mock("../src/domain/native-process-lineage.js", async (original) => {
|
||||
const actual = await original<typeof import("../src/domain/native-process-lineage.js")>();
|
||||
@@ -42,6 +44,27 @@ describe("startup consent and effect boundary", () => {
|
||||
const second = await request();
|
||||
expect(await second.json()).toMatchObject({ ok: true, rigId: body.rigId, reused: true });
|
||||
});
|
||||
it("prepares a new kernel in the workspace's own kernel folder, outside its projects", async () => {
|
||||
const workspace = mkdtempSync(join(tmpdir(), "startup-kernel-ws-"));
|
||||
const saved = process.env["OPENRIG_WORKSPACE_ROOT"];
|
||||
process.env["OPENRIG_WORKSPACE_ROOT"] = workspace;
|
||||
try {
|
||||
setup = createTestApp(db, { podInstantiatorFsOps: { exists: existsSync, readFile: (path) => readFileSync(path, "utf8") } });
|
||||
const response = await setup.app.request("/api/startup/kernel", { method: "POST",
|
||||
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ runtime: "claude-code" }) });
|
||||
const body = await response.json();
|
||||
expect(body).toMatchObject({ ok: true, rigId: expect.any(String) });
|
||||
// Every seat works in `<workspace.root>/kernel`, beside `<workspace.root>/projects` rather than above it, so the
|
||||
// guidance a seat writes in its directory isn't read by project seats. Nothing else is written in the workspace.
|
||||
const cwds = setup.rigRepo.getRig(body.rigId)!.nodes.map((node) => node.cwd);
|
||||
expect(new Set(cwds)).toEqual(new Set([join(workspace, "kernel")]));
|
||||
expect(statSync(join(workspace, "kernel")).isDirectory()).toBe(true);
|
||||
expect(readdirSync(workspace)).toEqual(["kernel"]);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env["OPENRIG_WORKSPACE_ROOT"]; else process.env["OPENRIG_WORKSPACE_ROOT"] = saved;
|
||||
rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it("rejects a changed occupant or model before invoking fresh launch", async () => {
|
||||
const { rig, node } = seat(); const revision = startupRevision(db, node);
|
||||
db.prepare("UPDATE nodes SET model = ? WHERE id = ?").run("changed-model", node.id);
|
||||
|
||||
@@ -464,6 +464,20 @@ describe("TranscriptStore", () => {
|
||||
});
|
||||
|
||||
describe("grep", () => {
|
||||
it.each(["\n", ""])("preserves long matching lines across chunks (ending %j)", (ending) => {
|
||||
const store = new TranscriptStore({ transcriptsRoot: tmpDir });
|
||||
store.ensureTranscriptDir("my-rig");
|
||||
// Numbered sections catch lost or reordered chunks; multibyte characters
|
||||
// straddle the reader's 64 KiB boundaries.
|
||||
const long = Array.from({ length: 10000 }, (_, i) => `${i}:é中😀`).join("|");
|
||||
const first = `MATCH first ${long}`;
|
||||
const last = `MATCH last ${long}`;
|
||||
writeFileSync(store.getTranscriptPath("my-rig", "dev@my-rig"),
|
||||
`ignored\n\x1b[32m${first}\x1b[0m\nignored ${long}\nMATCH short\n${last}${ending}`);
|
||||
expect(store.grep("my-rig", "dev@my-rig", "^MATCH")).toEqual([first, "MATCH short", last]);
|
||||
expect(store.grep("my-rig", "dev@my-rig", "not present")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns matching lines with ANSI stripped", () => {
|
||||
const store = new TranscriptStore({ transcriptsRoot: tmpDir });
|
||||
store.ensureTranscriptDir("my-rig");
|
||||
|
||||
@@ -349,6 +349,36 @@ describe("transport routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("a header-LESS broadcast carries the delivered-unsigned notice only when something was sent", async () => {
|
||||
seedRig();
|
||||
const post = (body: Record<string, unknown>) => sendApp().request("/api/transport/broadcast", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" }, // no X-OpenRig-Session
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const missed = (await (await post({ rig: "no-such-rig", text: "hi" })).json()) as { sent: number; warning?: string };
|
||||
expect(missed.sent).toBe(0);
|
||||
expect(missed.warning).toBeUndefined();
|
||||
const sent = (await (await post({ rig: "my-rig", text: "hi" })).json()) as { sent: number; warning?: string };
|
||||
expect(sent.sent).toBeGreaterThan(0);
|
||||
expect(sent.warning).toContain("Delivered without sender identity");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ total: 2, sent: 0, retained: 2, failed: 0, results: [] }, "Held for delivery without sender identity"],
|
||||
[{ total: 2, sent: 1, retained: 1, failed: 0, results: [] }, "Delivered without sender identity"],
|
||||
])("a header-LESS broadcast still advises signing when recipients retain the text (%#)", async (result, notice) => {
|
||||
const app = createApp({ sessionTransport: { broadcast: async () => result } as unknown as SessionTransport });
|
||||
const res = await app.request("/api/transport/broadcast", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" }, // no X-OpenRig-Session
|
||||
body: JSON.stringify({ rig: "my-rig", text: "hi" }),
|
||||
});
|
||||
const body = (await res.json()) as { warning?: string };
|
||||
expect(body.warning).toContain(notice);
|
||||
expect(body.warning).toContain("Follow up and sign it");
|
||||
});
|
||||
|
||||
it("A3b — a header-LESS broadcast auto-records NO rows (no derived sender to attribute)", async () => {
|
||||
seedRig();
|
||||
const res = await sendApp().request("/api/transport/broadcast", {
|
||||
|
||||
@@ -96,3 +96,36 @@ describe("validateWorkspaceFrontmatter (PL-007)", () => {
|
||||
expect(fs.readFileSync(path.join(dir, "a.md"), "utf-8")).toBe(content);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("malformed frontmatter structure", () => {
|
||||
it.each(["\n", "\r\n"])("reports an unclosed block with %j line endings even in optional mode", (newline) => {
|
||||
const content = ["---", "doc: alpha", "status: active", "created: 2026-05-04", "owner: someone", ""].join(newline);
|
||||
write("broken.md", content);
|
||||
for (const requireFrontmatter of [false, true]) {
|
||||
const report = validateWorkspaceFrontmatter({ root: dir, requireFrontmatter });
|
||||
expect(report.filesWithFrontmatter).toBe(1);
|
||||
expect(report.gaps).toMatchObject([{ relativePath: "broken.md", kind: "parse-error", field: null }]);
|
||||
expect(report.gapCount).toBe(1);
|
||||
}
|
||||
expect(fs.readFileSync(path.join(dir, "broken.md"), "utf8")).toBe(content);
|
||||
});
|
||||
|
||||
it.each(["[]", "- doc: alpha", "- alpha\n- beta"])("reports a YAML sequence as a parse error: %s", (body) => {
|
||||
write("array.md", `---\n${body}\n---\n`);
|
||||
for (const workspaceKind of [undefined, "knowledge"] as const) {
|
||||
const report = validateWorkspaceFrontmatter({ root: dir, workspaceKind });
|
||||
expect(report.filesWithFrontmatter).toBe(1);
|
||||
expect(report.gaps).toMatchObject([{ kind: "parse-error", field: null }]);
|
||||
expect(report.gapCount).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a closing delimiter at EOF valid and skips ordinary Markdown", () => {
|
||||
write("valid.md", "---\ndoc: alpha\n---");
|
||||
write("plain.md", "# Notes\n---\nA horizontal rule.\n");
|
||||
write("prefix.md", "----\nNot a frontmatter opener.\n");
|
||||
const report = validateWorkspaceFrontmatter({ root: dir, workspaceKind: "project" });
|
||||
expect(report).toMatchObject({ totalFiles: 3, filesWithFrontmatter: 1, gapCount: 0 });
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,23 @@ describe("workspace HTTP routes (PL-007)", () => {
|
||||
expect(body.gaps[0]?.field).toBe("owner");
|
||||
});
|
||||
|
||||
it("reports malformed structure without requiring frontmatter or a workspace kind", async () => {
|
||||
fs.writeFileSync(path.join(dir, "unclosed.md"), "---\ndoc: alpha\n");
|
||||
fs.writeFileSync(path.join(dir, "sequence.md"), "---\n- doc: alpha\n---\n");
|
||||
const res = await app.request("/api/workspace/validate", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ root: dir }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({ totalFiles: 2, filesWithFrontmatter: 2, gapCount: 2 });
|
||||
expect(body.gaps).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ relativePath: "unclosed.md", kind: "parse-error", field: null }),
|
||||
expect.objectContaining({ relativePath: "sequence.md", kind: "parse-error", field: null }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("POST /validate rejects missing root with 400", async () => {
|
||||
const res = await app.request("/api/workspace/validate", {
|
||||
method: "POST",
|
||||
|
||||
@@ -386,11 +386,10 @@ export class DaemonClient {
|
||||
}
|
||||
/** Recent terminal transitions (JUST FINISHED) via the SAME shipped /list
|
||||
* route with a COMMA multi-state ?state=done,handed-off (queue.ts splits
|
||||
* stateRaw on ","). No fleet-wide finish-time transitions endpoint exists, so
|
||||
* this is a bounded RECENT WINDOW fetched in ts_created order; the PULSE render
|
||||
* re-sorts by tsUpdated DESC (finish time) for newest-first. NOT a new endpoint. */
|
||||
* stateRaw on ","). sort=updated picks this bounded RECENT WINDOW by finish time,
|
||||
* so an old item closed just now is in it. NOT a new endpoint. */
|
||||
queueRecentlyFinished(limit = 20) {
|
||||
return this.get(`/api/queue/list?state=done,handed-off&limit=${limit}`);
|
||||
return this.get(`/api/queue/list?state=done,handed-off&sort=updated&limit=${limit}`);
|
||||
}
|
||||
/** A single qitem by id via the shipped GET /api/queue/:qitemId (queue.ts:864,
|
||||
* returns the QueueItem or 404). Used for the BLOCKED ON AGENTS bounded per-row
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_TIME_ZONE, displayClock } from "../time.js";
|
||||
import { isHumanSeatSessionRef } from "@openrig/daemon/attention";
|
||||
// PULSE view data model (5.2 Wave B). A reusable, view-local model so the pulse
|
||||
// row/section renderers (pulse/render-pulse.ts) can be shared — crash-cart rides
|
||||
@@ -266,17 +267,6 @@ function parkedRows(
|
||||
* in the header) — never a silent truncation (see [[pagination-terminates-on-data-not-budgets]]). */
|
||||
const LANE_ROW_CAP = 5;
|
||||
|
||||
/** Absolute UTC clock label for a JUST FINISHED transition time (e.g. "11:44").
|
||||
* UTC keeps it deterministic + TZ-independent; an unparseable stamp renders "". */
|
||||
function hhmm(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const t = Date.parse(iso);
|
||||
if (Number.isNaN(t)) return "";
|
||||
const d = new Date(t);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
/** The drill-in Action for a lane row keyed on a SEAT session. A seat resolvable
|
||||
* in the topology drills to that AGENT (the shipped agent drill — recovers the
|
||||
* full identity the compact lane label drops, parity with clicking the agent
|
||||
@@ -316,17 +306,17 @@ function nowRows(seatActivity: SeatActivitySummary[], inProgress: QueueRead[], s
|
||||
}
|
||||
|
||||
/** ✓ JUST FINISHED rows — recent terminal transitions newest-FINISHED-first.
|
||||
* The shipped /list read serves ts_created order, so we re-sort by tsUpdated
|
||||
* The /list read picks the most recently updated rows; we sort them by tsUpdated
|
||||
* DESC (the finish time) and cap to a recent window (count == rendered — this is
|
||||
* inherently a WINDOW, not a total). Time = the transition's tsUpdated (HH:MM). */
|
||||
function finishedRows(recent: QueueRead[], cap: number, snap: FleetSnapshot): PulseLaneRow[] {
|
||||
function finishedRows(recent: QueueRead[], cap: number, snap: FleetSnapshot, timeZone: string): PulseLaneRow[] {
|
||||
return [...recent]
|
||||
.sort((a, b) => Date.parse(b.tsUpdated) - Date.parse(a.tsUpdated))
|
||||
.slice(0, cap)
|
||||
.map((q) => {
|
||||
const label = q.summary ?? bodyHead(q.body);
|
||||
// drill to the seat that FINISHED it (destinationSession → agent).
|
||||
return { glyph: "✓", token: "ok" as Token, time: hhmm(q.tsUpdated), label, action: seatRowAction(snap, q.destinationSession, `${q.destinationSession} · ${label}`) };
|
||||
return { glyph: "✓", token: "ok" as Token, time: displayClock(q.tsUpdated, timeZone), label, action: seatRowAction(snap, q.destinationSession, `${q.destinationSession} · ${label}`) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -358,7 +348,7 @@ function upNextLane(pending: QueueRead[], cap: number, snap: FleetSnapshot): Pul
|
||||
* derived FROM the built model so header numbers can never diverge from their
|
||||
* referent sets (label==referent). demoPulseModel is retained only as a static
|
||||
* renderer fixture (spatial-contract tests), no longer this builder's source. */
|
||||
export function buildPulseModel(snap: FleetSnapshot, nowMs: number = Date.now()): PulseModel {
|
||||
export function buildPulseModel(snap: FleetSnapshot, nowMs: number = Date.now(), timeZone = DEFAULT_TIME_ZONE): PulseModel {
|
||||
const exceptions: PulseExceptionSection[] = [];
|
||||
|
||||
const needs = needsRows(snap.attention, nowMs);
|
||||
@@ -378,7 +368,7 @@ export function buildPulseModel(snap: FleetSnapshot, nowMs: number = Date.now())
|
||||
if (blocked.length > 0) exceptions.push({ glyph: "⧗", token: PULSE_INFO_TOKEN, label: "BLOCKED ON AGENTS", rows: blocked });
|
||||
|
||||
const now = nowRows(snap.seatActivity, snap.inProgress, snap);
|
||||
const finished = finishedRows(snap.recentlyFinished, LANE_ROW_CAP, snap);
|
||||
const finished = finishedRows(snap.recentlyFinished, LANE_ROW_CAP, snap, timeZone);
|
||||
const lanes: [PulseLane, PulseLane, PulseLane] = [
|
||||
{ label: "NOW", count: now.length, rows: now },
|
||||
{ label: "JUST FINISHED", count: finished.length, rows: finished },
|
||||
|
||||
@@ -1568,7 +1568,7 @@ function renderPulseScreen(state: ViewState, snap: FleetSnapshot, options: Rende
|
||||
lines.push(paneRule(cols, explW, "top", explorerTitle, contentTitle));
|
||||
|
||||
const contentWidth = Math.max(cols - explW - 2, 0);
|
||||
const model = buildPulseModel(snap, nowMs);
|
||||
const model = buildPulseModel(snap, nowMs, state.timeZone);
|
||||
const chromeRows = 3; // bottom rule + hint bar + status line
|
||||
const bodyRows = Math.max(rows - lines.length - chromeRows, 1);
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ export function displayTime(value: unknown, timeZone = DEFAULT_TIME_ZONE): strin
|
||||
return `${p("year")}-${p("month")}-${p("day")} ${p("hour")}:${p("minute")}:${p("second")} ${p("timeZoneName")}${zone !== timeZone ? " (timezone fallback)" : ""}`;
|
||||
}
|
||||
|
||||
/** displayTime's HH:MM, for a row with room only for a clock; "" when the time is unknown. */
|
||||
export function displayClock(value: unknown, timeZone = DEFAULT_TIME_ZONE): string {
|
||||
const time = displayTime(value, timeZone);
|
||||
return time === "time unknown" ? "" : time.slice(11, 16);
|
||||
}
|
||||
|
||||
export function resolveTimeZone(setting: unknown, readWarning = false): { timeZone: string; warning: string | null } {
|
||||
const timeZone = validTimeZone(setting) ? setting : DEFAULT_TIME_ZONE;
|
||||
return { timeZone, warning: validTimeZone(setting) && !readWarning ? null
|
||||
|
||||
@@ -470,9 +470,8 @@ export interface FleetSnapshot {
|
||||
* the render keeps only unclaimed (claimedAt null) and caps for display. */
|
||||
pending: QueueRead[];
|
||||
/** PULSE JUST FINISHED source (increment 3) — the shipped
|
||||
* state=done,handed-off read (a bounded recent WINDOW; no fleet-wide
|
||||
* finish-time-ordered transitions endpoint exists — the served order is
|
||||
* ts_created, so the render re-sorts by tsUpdated DESC for newest-finished-first). */
|
||||
* state=done,handed-off read with sort=updated (a bounded recent WINDOW of the
|
||||
* most recently finished; the render sorts by tsUpdated DESC for newest-finished-first). */
|
||||
recentlyFinished: QueueRead[];
|
||||
/** When THIS snapshot finished hydrating (TUI render-time fact, not a daemon
|
||||
* read) — the PULSE footer "updated Ns ago" freshness. Absent before the first
|
||||
|
||||
@@ -160,7 +160,7 @@ const FIXTURES: Record<string, unknown> = {
|
||||
"/api/queue/list?state=in-progress": [],
|
||||
// PULSE ○ UP NEXT + ✓ JUST FINISHED lane sources (increment 3) — same /list route
|
||||
"/api/queue/list?state=pending&limit=50": [],
|
||||
"/api/queue/list?state=done,handed-off&limit=20": [],
|
||||
"/api/queue/list?state=done,handed-off&sort=updated&limit=20": [],
|
||||
"/api/queue/recent-transitions?scope=rig&rig=myrig&limit=20": [],
|
||||
};
|
||||
|
||||
@@ -372,7 +372,7 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
});
|
||||
it("hydrates the seat's recently finished items on an agent page so RECENTLY FINISHED can fill", async () => {
|
||||
const row = (qitemId: string, state: string, destinationSession: string) => ({ qitemId, state, destinationSession, blockedOn: null, handedOffTo: null, tier: null, tags: [], summary: `work ${qitemId}`, body: "", claimedAt: null, tsUpdated: "2026-09-12T10:00:00.000Z" });
|
||||
const finished = { "/api/queue/list?state=done,handed-off&limit=20": [row("qd1", "done", "dev-impl@myrig"), row("qd2", "done", "dev-qa@myrig")] };
|
||||
const finished = { "/api/queue/list?state=done,handed-off&sort=updated&limit=20": [row("qd1", "done", "dev-impl@myrig"), row("qd2", "done", "dev-qa@myrig")] };
|
||||
const page = async (drill: Record<string, unknown>, tab: "table" | "graph" = "table") => {
|
||||
const calls: string[] = [];
|
||||
const full = await hydrateSnapshot(fixtureClient());
|
||||
@@ -384,7 +384,7 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
return { calls, snap, text: renderScreen(view.get(), snap, { cols: 220, rows: 120 }).lines.join("\n") };
|
||||
};
|
||||
const agent = await page({ resource: "agent", name: "dev.impl", target: { host: "mm2-openrig1", rig: "myrig", pod: "dev" } });
|
||||
expect(agent.calls).toContain("/api/queue/list?state=done,handed-off&limit=20");
|
||||
expect(agent.calls).toContain("/api/queue/list?state=done,handed-off&sort=updated&limit=20");
|
||||
expect(agent.snap.recentlyFinished.map((q) => q.qitemId)).toEqual(["qd1", "qd2"]);
|
||||
expect(agent.text).toContain("RECENTLY FINISHED · bounded window");
|
||||
expect(agent.text).toContain("qd1 · done");
|
||||
@@ -392,7 +392,7 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
// The rig table and graph draw no finished items, so they keep that read off.
|
||||
for (const tab of ["table", "graph"] as const) {
|
||||
const rig = await page({ resource: "rig", name: "myrig", target: { host: "mm2-openrig1" } }, tab);
|
||||
expect(rig.calls).not.toContain("/api/queue/list?state=done,handed-off&limit=20");
|
||||
expect(rig.calls).not.toContain("/api/queue/list?state=done,handed-off&sort=updated&limit=20");
|
||||
}
|
||||
});
|
||||
it("marks health unavailable when the selected health view read fails", async () => {
|
||||
@@ -814,7 +814,7 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
];
|
||||
const snap = await hydrateSnapshot(fixtureClient({}, {
|
||||
"/api/queue/list?state=pending&limit=50": pending,
|
||||
"/api/queue/list?state=done,handed-off&limit=20": finished,
|
||||
"/api/queue/list?state=done,handed-off&sort=updated&limit=20": finished,
|
||||
}));
|
||||
expect(snap.pending.map((q) => q.qitemId)).toEqual(["qp1"]);
|
||||
expect(snap.recentlyFinished.map((q) => q.qitemId)).toEqual(["qf1", "qf2"]);
|
||||
|
||||
@@ -280,7 +280,7 @@ describe("S12 view-aware hydration", () => {
|
||||
"/api/queue/list?state=blocked": [],
|
||||
"/api/queue/list?state=in-progress": [],
|
||||
"/api/queue/list?state=pending&limit=50": [],
|
||||
"/api/queue/list?state=done,handed-off&limit=20": [],
|
||||
"/api/queue/list?state=done,handed-off&sort=updated&limit=20": [],
|
||||
"/api/scopes?detail=1": { missions: [] },
|
||||
"/api/views/execution": { rows: [] },
|
||||
"/api/queue/recent-transitions?scope=instance&limit=20": [],
|
||||
@@ -310,7 +310,7 @@ describe("S12 view-aware hydration", () => {
|
||||
"/healthz": {}, "/api/queue/attention-aggregate": { hosts: [] }, "/api/rigs/summary": [],
|
||||
"/api/review/fleet": { needsYou: { items: [] }, hosts: [] }, "/api/stream/list?limit=5&direction=latest": [],
|
||||
"/api/queue/list?attention=1": [], "/api/queue/list?state=blocked": [], "/api/queue/list?state=in-progress": [],
|
||||
"/api/queue/list?state=pending&limit=50": [], "/api/queue/list?state=done,handed-off&limit=20": [],
|
||||
"/api/queue/list?state=pending&limit=50": [], "/api/queue/list?state=done,handed-off&sort=updated&limit=20": [],
|
||||
"/api/scopes?detail=1": { missions: [] }, "/api/views/execution": { rows: [] },
|
||||
};
|
||||
return { ok: route in empty, status: route in empty ? 200 : 404, json: async () => empty[route] ?? {} } as Response;
|
||||
|
||||
@@ -314,13 +314,13 @@ describe("PULSE view increment 3 — Lanes LIVE (NOW / JUST FINISHED / UP NEXT +
|
||||
it("JUST FINISHED: recent done/handed-off newest-FINISHED-first (tsUpdated desc) with HH:MM times", () => {
|
||||
const snap = liveSnap({
|
||||
recentlyFinished: [
|
||||
// served in ts_created order (NOT finish order) — the view re-sorts by tsUpdated
|
||||
// any served order — the view sorts by tsUpdated
|
||||
attn({ qitemId: "f1", state: "done", summary: "older close-out", tsUpdated: "2026-08-06T10:58:00.000Z" }),
|
||||
attn({ qitemId: "f2", state: "handed-off", summary: "newest fold receipt", tsUpdated: "2026-08-06T11:44:00.000Z" }),
|
||||
attn({ qitemId: "f3", state: "done", summary: "mid terminal CLEAR", tsUpdated: "2026-08-06T11:20:00.000Z" }),
|
||||
],
|
||||
});
|
||||
const jf = buildPulseModel(snap, NOW).lanes[1];
|
||||
const jf = buildPulseModel(snap, NOW, "UTC").lanes[1];
|
||||
expect(jf.label).toBe("JUST FINISHED");
|
||||
expect(jf.rows.map((r) => r.label)).toEqual(["newest fold receipt", "mid terminal CLEAR", "older close-out"]);
|
||||
expect(jf.rows.map((r) => r.time)).toEqual(["11:44", "11:20", "10:58"]);
|
||||
@@ -328,6 +328,19 @@ describe("PULSE view increment 3 — Lanes LIVE (NOW / JUST FINISHED / UP NEXT +
|
||||
expect(jf.count).toBe(3);
|
||||
});
|
||||
|
||||
it("JUST FINISHED: clocks read in the TUI's time zone, and an unreadable stamp shows none", () => {
|
||||
const snap = liveSnap({
|
||||
recentlyFinished: [
|
||||
attn({ qitemId: "f1", state: "done", summary: "closed", tsUpdated: "2026-08-06T23:44:00.000Z" }),
|
||||
attn({ qitemId: "f2", state: "done", summary: "unreadable", tsUpdated: "not a time" }),
|
||||
],
|
||||
});
|
||||
const times = (zone: string) => Object.fromEntries(buildPulseModel(snap, NOW, zone).lanes[1].rows.map((r) => [r.label, r.time]));
|
||||
expect(times("Asia/Kolkata")).toEqual({ closed: "05:14", unreadable: "" });
|
||||
expect(times("America/Los_Angeles")).toEqual({ closed: "16:44", unreadable: "" });
|
||||
expect(times("UTC")).toEqual({ closed: "23:44", unreadable: "" });
|
||||
});
|
||||
|
||||
it("UP NEXT: pending in the SERVED order (verbatim — no client priority synthesis); beyond the cap shows a '…' overflow row with the TRUE total count", () => {
|
||||
const pend = Array.from({ length: 6 }, (_, i) => attn({ qitemId: `p${i}`, state: "pending", summary: `pending item ${i}`, claimedAt: null }));
|
||||
const un = buildPulseModel(liveSnap({ pending: pend }), NOW).lanes[2];
|
||||
|
||||
@@ -63,8 +63,16 @@ describe("PULSE view (5.2 Wave B — increment 1: static skeleton from the appro
|
||||
expect(iBlocked).toBeLessThan(iLanes);
|
||||
});
|
||||
|
||||
it("draws JUST FINISHED clocks in the TUI's time zone", () => {
|
||||
const v = createViewState({ instanceId: "t", timeZone: "Asia/Kolkata", ...withSnap });
|
||||
v.dispatch({ type: "tab", tab: "pulse" });
|
||||
const body = renderScreen(v.get(), snap, { cols: 140, rows: 44, nowMs: DEMO_NOW }).lines.join("\n");
|
||||
expect(body).toContain("17:14");
|
||||
expect(body).not.toContain("11:44");
|
||||
});
|
||||
|
||||
it("builds the three lanes + footer LIVE from the demo snapshot (label==referent honesty)", () => {
|
||||
const v = createViewState({ instanceId: "t", ...withSnap });
|
||||
const v = createViewState({ instanceId: "t", timeZone: "UTC", ...withSnap });
|
||||
v.dispatch({ type: "tab", tab: "pulse" });
|
||||
const body = renderScreen(v.get(), snap, { cols: 140, rows: 44, nowMs: DEMO_NOW }).lines.join("\n");
|
||||
|
||||
|
||||
Reference in new issue
Block a user