docs(reference): name ACP session/resume in section 8, and use an agent's session list as positive evidence only (#2364)

Runner contract section 8 now names ACP's `session/resume` (capability `sessionCapabilities.resume`) beside `session/load`, which is what dsh `0.2.1-rc.2` advertises, and marks open question 1 answered. It also says how a runner tells a missing conversation from one that won't reopen, since an agent may answer one error for both: missing comes only from the agent's own not-found (`-32002`), anything else is unconfirmed, and an agent's `session/list` is positive evidence only. An unlisted conversation isn't missing unless the agent guarantees its list is complete, which dsh doesn't.

The dsh example runner reads the list first when dsh advertises it, following any string cursor unchanged (the empty string included) for up to 50 pages; a non-string cursor, an entry without a string `sessionId`, or a failed list decides nothing. It always tries the reopen: a reopen that works resumes, `-32002` is missing unless the list named the conversation, and any other error, `-32602` included, is unconfirmed. It never starts a new conversation. The dsh README and `dsh.yaml` limits say the same, and the runner keeps its executable mode.

Runner +24/-2, contract and dsh docs +30/-7, tests +82/-1.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-11-01 17:51:01 -08:00
1 parent a2d4d261fd
commit fa2336fe23
5 files changed
+136 -10

No files matched your search

@@ -54,9 +54,20 @@ OpenRig says the request isn't the seat's current occupant's (for example, after
## Before you declare resume
The preset leaves `resume` out. The runner reopens a conversation by the method dsh advertises: `session/resume`, or
`session/load`. When dsh advertises neither, it tries `session/resume`. It reports a conversation missing only on dsh's
own not-found error (`-32002`); any other failure leaves the resume unconfirmed, and it never starts a new conversation
on a resume. Add `resume: true` after a real dsh has reopened a conversation and refused a missing one.
`session/load`. When dsh advertises neither, it tries `session/resume`. It never starts a new conversation on a resume.
**Telling a missing conversation from one that won't reopen.** dsh `0.2.1-rc.2` answers `-32602` both for a
conversation it doesn't have and for one already active elsewhere, so the runner never reads that error as missing.
- **Only dsh's own not-found error (`-32002`) counts as missing.** Any other failure leaves the resume unconfirmed.
- **When dsh advertises `session/list`** (it does), the runner first asks the list, following its pages. A conversation
the list names but that won't reopen stays unconfirmed, even on a not-found.
- **An unlisted conversation is still reopened.** dsh's list can leave out a conversation another running dsh holds
unsaved, so it can't show a conversation is gone.
**Limit:** dsh answers `-32602`, not `-32002`, for a conversation it doesn't have, so the runner can't report a missing
dsh conversation as missing. It reads unconfirmed.
Add `resume: true` after a real dsh has reopened a conversation and recalled what was said in it.
## Check it without a model
@@ -11,7 +11,7 @@ limits:
- each seat gets its own dsh home under $OPENRIG_HOME/state/dsh/<node>/home, holding a patch that turns off dsh's session-log and plugin-list uploads to DeepSeek; give it credentials (.credentials.yaml there) or a provider key in the seat's env
- DSH_PERMISSION_MODE defaults to workspace-write; a permission request goes to the default person or the seat's override (rig seat set-permissions-to), is permitted with dsh's allow_once (else allow_always) when no one can be asked or OpenRig can't answer, and is refused and held as needs input only after --deny
- allow_always creates a standing grant in dsh, which OpenRig doesn't record or revoke; the runner prefers allow_once, and where dsh keeps the grant and for how long isn't checked
- resume isn't declared until a real dsh has reopened a conversation and refused a missing one; the runner reopens by the method dsh advertises and reports missing only on dsh's own not-found error
- resume isn't declared until a real dsh has reopened a conversation and recalled it; the runner reopens by the method dsh advertises and reports missing only on dsh's own not-found error, not when dsh's session list names the conversation; dsh answers -32602 for a missing conversation (and for one active elsewhere), so a missing dsh conversation reads unconfirmed
- a prompt whose whole text is /abort can't be sent; a Ctrl-C byte inside a paste also aborts; without a TTY, input is read line by line; this isn't a binary-safe channel
- one prompt at a time; a message that arrives mid-turn waits for the next turn
- the seat's model isn't passed; dsh uses its own configured model
@@ -30,6 +30,8 @@ const NOT_LIVE = ["stale_generation", "activity_grant_wrong_seat", "activity_gra
const ENDED = ["refused", "withdrawn", "stale", "answered"];
const CANCELLED = { outcome: { outcome: "cancelled" } };
const ACP_RESOURCE_NOT_FOUND = -32002;
// The most pages of dsh's session list read before a resume; a longer list decides nothing.
const LIST_PAGES = 50;
const PASTE_START = "\x1b[200~";
const PASTE_END = "\x1b[201~";
@@ -472,14 +474,34 @@ async function main() {
const capabilities = init?.agentCapabilities ?? {};
const producer = `${RUNNER}; dsh ${init?.agentInfo?.version ?? "(version not reported)"}; ACP ${init?.protocolVersion ?? "?"}`;
const resumeRef = env.OPENRIG_RESUME_REF;
// Whether dsh's own session list names a conversation, following its pages: true, false when every page was read and
// none names it, or undefined when the list fails, answers in another shape, or runs past LIST_PAGES pages. An ACP
// cursor is an opaque string, possibly empty, passed back unchanged; an absent or null one ends the list.
const listsConversation = async (id) => {
let cursor;
for (let page = 0; page < LIST_PAGES; page += 1) {
let listing;
try { listing = await peer.request("session/list", cursor === undefined ? {} : { cursor }); } catch { return undefined; }
if (!Array.isArray(listing?.sessions) || listing.sessions.some((session) => typeof session?.sessionId !== "string")) return undefined;
if (listing.sessions.some((session) => session.sessionId === id)) return true;
if (listing.nextCursor === undefined || listing.nextCursor === null) return false;
if (typeof listing.nextCursor !== "string") return undefined;
cursor = listing.nextCursor;
}
return undefined;
};
if (resumeRef) {
// The method dsh advertises; with neither advertised, session/resume, the method dsh 0.2.1-rc.2 answered in its
// maintainer's checks. Only dsh's own "not found" counts as missing; anything else leaves the resume unconfirmed.
// maintainer's checks. dsh 0.2.1-rc.2 answers -32602 both for a conversation it doesn't have and for one active
// elsewhere, so that error can't mean missing. Only dsh's own "not found" counts as missing, and not when dsh's list
// names the conversation. The list can't show a conversation is gone: one another running dsh holds unsaved may be
// left out, so an unlisted conversation is still reopened. Anything else leaves the resume unconfirmed.
const method = capabilities.sessionCapabilities?.resume ? "session/resume" : capabilities.loadSession ? "session/load" : "session/resume";
const listed = capabilities.sessionCapabilities?.list ? await listsConversation(resumeRef) === true : false;
try {
await peer.request(method, { sessionId: resumeRef, cwd, mcpServers: [] });
} catch (error) {
if (error.code === ACP_RESOURCE_NOT_FOUND) {
if (error.code === ACP_RESOURCE_NOT_FOUND && !listed) {
writeLaunchRecord({ error: "missing", producer });
return fatal(`dsh says conversation ${resumeRef} doesn't exist (${error.message}); no new one was started`, 3);
}
+15 -3
View File
@@ -298,8 +298,19 @@ permission nobody chose. That covers:
## 8. Resume: exactly the same conversation, or say honestly that you couldn't
- On a resume, OpenRig passes the conversation to reopen in `OPENRIG_RESUME_REF`. Use the method your agent
advertises: ACP's `session/load` (capability `loadSession`), or your agent's own reopen method, named with its
advertises: ACP's `session/load` (capability `loadSession`), ACP's `session/resume` (capability
`sessionCapabilities.resume`, which dsh `0.2.1-rc.2` uses), or your agent's own reopen method, named with its
capability and pinned version in your `limits`.
- **Telling "missing" from "won't reopen":** an agent may answer one error for both. dsh `0.2.1-rc.2` answers `-32602`
for a conversation it doesn't have and for one already active elsewhere. So don't read an error's code or text as
missing unless the agent defines it that way, as ACP's not-found `-32002` is.
- **Report missing only on the agent's own not-found,** and leave anything else unconfirmed.
- **When the agent advertises `sessionCapabilities.list`,** its `session/list` can show that a conversation exists:
one it lists but won't reopen is unconfirmed, even on a not-found. Follow `nextCursor` (an opaque string, possibly
empty), and treat a page you can't read as deciding nothing.
- **A list doesn't show that a conversation is gone,** unless your agent guarantees it lists every conversation. An
agent may leave out one that another running process holds unsaved; dsh `0.2.1-rc.2` can. So still try the reopen
for a conversation the list doesn't name.
| What happened | Write in the launch record | What OpenRig does |
|---|---|---|
@@ -366,8 +377,9 @@ permission nobody chose. That covers:
## Open questions
1. **For the dsh maintainer:** which method and capability does dsh advertise for reopening a session, at a pinned
version: `session/load` with `loadSession`, or `session/resume`?
1. **Answered:** which method and capability does dsh advertise for reopening a session, at a pinned version? dsh
`0.2.1-rc.2` advertises `sessionCapabilities` `{close, list, resume}`, without `loadSession`, and reopens with
`session/resume` (section 8). This was observed with a stand-in model, not a real one.
2. **For the dsh maintainer:** can dsh's ACP profile run with tools off? A first journey that needs no permission
answers is the simplest to check.
3. **For OpenRig:** should a later version route permission requests to a chosen seat, as `rig acp run` already does
@@ -21,7 +21,7 @@ const send = (message) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0",
log({ start: { argv: process.argv.slice(2), DSH_HOME: process.env.DSH_HOME ?? null, DSH_PERMISSION_MODE: process.env.DSH_PERMISSION_MODE ?? null,
DSH_TELEMETRY_DISABLED: process.env.DSH_TELEMETRY_DISABLED ?? null, token: "OPENRIG_ACTIVITY_HOOK_TOKEN" in process.env } });
process.stdout.write("dsh: a log line that isn't JSON\\n");
let session = null; let held = null; let cancelled = false; const waiting = new Map();
let session = null; let held = null; let cancelled = false; const waiting = new Map(); let listCalls = 0;
const update = (u) => send({ method: "session/update", params: { sessionId: session, update: u } });
const say = (text) => update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text } });
createInterface({ input: process.stdin }).on("line", (line) => {
@@ -35,8 +35,20 @@ createInterface({ input: process.stdin }).on("line", (line) => {
case "session/resume": case "session/load": {
if (process.env.FAKE_RESUME === "missing") return fail(-32002, "Resource not found");
if (process.env.FAKE_RESUME === "error") return fail(-32603, "the session store is locked");
// dsh 0.2.1-rc.2 answers -32602 both for an id it doesn't have and for a session active elsewhere.
if (process.env.FAKE_RESUME === "notresumable") return fail(-32602, "session is not resumable");
session = message.params.sessionId; return reply({});
}
case "session/list": {
// FAKE_LIST_RAW: the exact results to answer, one per call, in order. FAKE_LIST: pages of saved session ids, as
// JSON; the cursor is the next page's index.
if (process.env.FAKE_LIST_RAW) { const raw = JSON.parse(process.env.FAKE_LIST_RAW); return reply(raw[listCalls++] ?? { sessions: [] }); }
if (process.env.FAKE_LIST === "error") return fail(-32603, "the session index is locked");
const pages = JSON.parse(process.env.FAKE_LIST ?? "[[]]");
const page = Number(message.params?.cursor ?? 0);
return reply({ sessions: (pages[page] ?? []).map((sessionId) => ({ sessionId, cwd: process.cwd() })),
...(page + 1 < pages.length ? { nextCursor: String(page + 1) } : {}) });
}
case "session/cancel": cancelled = true; if (held) { held(); held = null; } return;
case "session/prompt": {
cancelled = false;
@@ -429,6 +441,75 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
expect(seat.out()).toMatch(/the session store is locked/);
});
// dsh answers -32602 for a conversation it doesn't have and for one active elsewhere, so that error can't mean missing.
// dsh's session list can show a conversation exists, never that it doesn't: one another running dsh holds unsaved can
// be left out. So the list is only positive evidence, and an unlisted conversation is still reopened.
const listing = { sessionCapabilities: { list: {}, resume: {} } };
it("still reopens a conversation dsh doesn't list, and resumes it when the reopen works", async () => {
const openrig = await fakeOpenRig("absent");
const seat = start(openrig, { OPENRIG_RESUME_REF: "sess-old-7", FAKE_CAPS: JSON.stringify(listing), FAKE_LIST: JSON.stringify([["sess-other"]]) });
expect(await waitFor(seat.launch, "the launch record")).toMatchObject({ nativeRef: "sess-old-7" });
expect(requests(seat.log(), "session/resume").map((entry) => (entry.params as { sessionId: string }).sessionId)).toEqual(["sess-old-7"]);
expect(requests(seat.log(), "session/new")).toEqual([]);
});
it("leaves an unlisted conversation that won't reopen (-32602) unconfirmed, never missing", async () => {
const openrig = await fakeOpenRig("absent");
const seat = start(openrig, { OPENRIG_RESUME_REF: "sess-gone", FAKE_CAPS: JSON.stringify(listing),
FAKE_LIST: JSON.stringify([["sess-other"]]), FAKE_RESUME: "notresumable" });
expect(await seat.exited).toBe(4);
expect(seat.launch()).toBeUndefined();
expect(requests(seat.log(), "session/resume")).toHaveLength(1);
expect(requests(seat.log(), "session/new")).toEqual([]);
});
it("follows an empty-string cursor to the next page, unchanged, so a conversation listed there isn't reported missing", async () => {
const openrig = await fakeOpenRig("absent");
const seat = start(openrig, { OPENRIG_RESUME_REF: "sess-old-7", FAKE_CAPS: JSON.stringify(listing), FAKE_RESUME: "missing",
FAKE_LIST_RAW: JSON.stringify([{ sessions: [], nextCursor: "" }, { sessions: [{ sessionId: "sess-old-7", cwd: "/w" }] }]) });
expect(await seat.exited).toBe(4);
expect(seat.launch()).toBeUndefined();
expect(requests(seat.log(), "session/list").map((entry) => (entry.params as { cursor?: string }).cursor)).toEqual([undefined, ""]);
});
it.each([
["a cursor that isn't a string", { sessions: [], nextCursor: 1 }],
["an entry without a sessionId", { sessions: [{}] }],
])("reads a list page with %s as deciding nothing, and still reopens", async (_case, page) => {
const openrig = await fakeOpenRig("absent");
const seat = start(openrig, { OPENRIG_RESUME_REF: "sess-old-7", FAKE_CAPS: JSON.stringify(listing), FAKE_LIST_RAW: JSON.stringify([page]) });
expect(await waitFor(seat.launch, "the launch record")).toMatchObject({ nativeRef: "sess-old-7" });
expect(requests(seat.log(), "session/list")).toHaveLength(1);
expect(requests(seat.log(), "session/resume")).toHaveLength(1);
});
it("follows the list's pages before deciding, and reopens a conversation it finds", async () => {
const openrig = await fakeOpenRig("absent");
const seat = start(openrig, { OPENRIG_RESUME_REF: "sess-old-7", FAKE_CAPS: JSON.stringify(listing),
FAKE_LIST: JSON.stringify([["sess-a"], ["sess-old-7"]]) });
expect(await waitFor(seat.launch, "the launch record")).toMatchObject({ nativeRef: "sess-old-7" });
expect(requests(seat.log(), "session/list").map((entry) => (entry.params as { cursor?: string }).cursor)).toEqual([undefined, "1"]);
});
it("leaves a listed conversation that won't reopen unconfirmed, whatever dsh's error says", async () => {
const openrig = await fakeOpenRig("absent");
const seat = start(openrig, { OPENRIG_RESUME_REF: "sess-old-7", FAKE_CAPS: JSON.stringify(listing),
FAKE_LIST: JSON.stringify([["sess-old-7"]]), FAKE_RESUME: "notresumable" });
expect(await seat.exited).toBe(4);
expect(seat.launch()).toBeUndefined();
expect(requests(seat.log(), "session/new")).toEqual([]);
});
it("decides nothing from a list that fails, so a resume dsh won't make stays unconfirmed", async () => {
const openrig = await fakeOpenRig("absent");
const seat = start(openrig, { OPENRIG_RESUME_REF: "sess-old-7", FAKE_CAPS: JSON.stringify(listing),
FAKE_LIST: "error", FAKE_RESUME: "notresumable" });
expect(await seat.exited).toBe(4);
expect(seat.launch()).toBeUndefined();
expect(requests(seat.log(), "session/new")).toEqual([]);
});
it("reports the agent's exit as exited, never idle", async () => {
const openrig = await fakeOpenRig("absent");
const seat = start(openrig);