Compare commits

...
Author SHA1 Message Date
dev60-review 6a40cde3cd fix(daemon): write a person's note about their runner answer, and wake a waiting runner on the decision
Both notes to a person who answered a runner's permission request, the
who-decides update and the kept-answer note, now carry an evidence ref
naming the request. A note to the host's human seat without one was
refused by the queue's conventions, so a person answering from the TUI
was never told who decides or that their answer was kept. The answer is
still committed first, and a note that can't be written never rolls it
back.

A runner already waiting when the answer is handed to the receiving
agent now follows that handoff, so the agent's decision on the new row
wakes its read instead of its next one. The answer route's reply points
to the note for the agent's recorded state.
2026-11-11 20:11:37 +00:00
4 changed files with 69 additions and 4 deletions

No files matched your search

@@ -967,7 +967,7 @@ function verbToClosure(input: MissionControlActionInput): ClosureMapping {
* person's own receipt says why). Undefined for a request the answer closed (no agent wired here). */
function runnerAnswerNotice(ask: QueueItem): string | undefined {
if (ask.state === "handed-off" && ask.handedOffTo) {
return `Your answer on ${ask.qitemId} went to ${ask.handedOffTo}, the receiving agent, which decides. The runner waits until then; nothing has run.`;
return `Your answer on ${ask.qitemId} went to ${ask.handedOffTo}, the receiving agent, which decides. The runner waits until then; nothing has run. A note to you says the agent's recorded state.`;
}
if (ask.state === "pending" || ask.state === "in-progress" || ask.state === "blocked") {
return `Your answer is kept on ${ask.qitemId}, and the request is still waiting: the tool hasn't run, and nothing was permitted or refused. A note to you says why it wasn't passed to a receiving agent. To decide directly: rig seat decide-permission ${ask.qitemId} --option <id>.`;
@@ -4272,7 +4272,9 @@ export class QueueRepository {
// The person learns who decides and in what recorded state, so a delay is shown: one update per request.
const toldId = `qitem-runner-permission-passed-${item.qitemId.replace(/^qitem-runner-permission-/, "").replace(/^qitem-/, "")}`;
setImmediate(() => {
// The request the person answered is what they'd judge (convention C3: a note to a person names its evidence).
void this.create({ qitemId: toldId, sourceSession: root.sourceSession, destinationSession: person, humanIntent: "update",
evidenceRef: `rig queue show ${root.qitemId}`,
summary: `Your answer went to ${decider} to decide; ${root.sourceSession} is waiting`,
body: [`Your answer to ${root.sourceSession}'s permission request ${root.qitemId} went to ${decider}, which decides it while ${root.sourceSession} waits. Nothing runs until it decides.`,
...(chosen?.describe ? [`Receiving agent: ${chosen.describe}`] : []),
@@ -4323,6 +4325,7 @@ export class QueueRepository {
// After the answer's own write settles: one receipt per request (its id is the request's), told to who answered.
setImmediate(() => {
void this.create({ qitemId: receiptId, sourceSession: requester, destinationSession: person, humanIntent: "update",
evidenceRef: `rig queue show ${item.qitemId}`,
summary: `Your answer to ${requester}'s permission request is kept; the tool hasn't run`,
body: [`Your answer to ${requester}'s permission request ${item.qitemId} is kept, and the request is still waiting: ${unreachable ? `it couldn't be passed to ${unreachable}, the receiving agent, just now (answer again to retry)` : `no receiving agent on this host can read it before the tool runs (none is set, or the only one is ${requester} itself)`}. The tool hasn't run, and nothing was permitted or refused.`,
`To decide now: rig seat decide-permission ${item.qitemId} --option <id>${options.length ? ` (options: ${options.map((o) => `${o.id}, ${o.label}`).join("; ")})` : ""}. Or set up a receiving agent that isn't ${requester}: rig config set agents.operator_session <seat>@<rig>.`,
@@ -331,8 +331,15 @@ export class RunnerPermissions {
const changed = new Promise<void>((resolve) => { release = resolve; });
let liveId = askId; // after a route or handoff, the colleague's row is where the answer lands
const unsubscribe = this.deps.eventBus.subscribe((event) => {
const changedId = (event as { type?: string; qitemId?: string }).type === "queue.updated" ? (event as { qitemId?: string }).qitemId : undefined;
if (changedId === askId || changedId === liveId) release();
const { type, qitemId } = event as { type?: string; qitemId?: string };
// A handoff while this read waits (the person's answer going to the receiving agent) moves the request to a new
// row: follow it, so the decision recorded there wakes this read rather than its next one.
if (type === "queue.handed_off" && (qitemId === askId || qitemId === liveId)) {
const asked = this.deps.queueRepo.getById(askId);
liveId = (asked ? this.liveEnd(asked)?.qitemId : undefined) ?? liveId;
return;
}
if (type === "queue.updated" && (qitemId === askId || qitemId === liveId)) release();
});
try {
const now = this.withWords(askId, this.read(seat, askId));
@@ -719,7 +719,7 @@ describe("an answered request goes to the receiving agent: the pair's round-0 co
const askId = String((await f.submit()).body.askId);
// The result says what became of the answer (the TUI prints it), never that the request closed.
expect(await f.act(askId, "approve")).toMatchObject({ status: 200,
body: { answer: { message: `Your answer on ${askId} went to ${DECIDER}, the receiving agent, which decides. The runner waits until then; nothing has run.` } } });
body: { answer: { message: `Your answer on ${askId} went to ${DECIDER}, the receiving agent, which decides. The runner waits until then; nothing has run. A note to you says the agent's recorded state.` } } });
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "handed-off", handedOffTo: DECIDER });
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
expect(handedFrom(f, askId)!.body).toContain(`they wrote "yes"`);
@@ -1023,3 +1023,58 @@ describe("a direct answer with no notify envelope (the Slack fallback)", () => {
]);
});
});
// A person answering from the TUI answers as the host's human seat, and a person-addressed row to a human seat must
// carry an evidence ref (convention C3). Without one, both notes to the person were refused and never written.
describe("the person's note about a runner answer is written under the queue's own conventions", () => {
const HUMAN_SEAT = "human@host";
const answerAsHost = (f: ReturnType<typeof fixture>, askId: string, choice: { optionId?: string; decision?: string }) =>
f.contract.act({ verb: "answer", qitemId: askId, actorSession: HUMAN_SEAT, authority: "primary-user",
...(choice.optionId ? { questionId: "permission", optionId: choice.optionId } : {}), ...(choice.decision ? { decision: choice.decision } : {}) } as never);
const notesTo = (f: ReturnType<typeof fixture>) => f.queueRepo.list({ limit: 100 })
.filter((row) => row.destinationSession === HUMAN_SEAT && row.humanIntent === "update");
it("with no agent that can decide, the kept-answer note is written once, linked to the request, with the direct route", async () => {
const f = fixture({ decider: null });
const askId = String((await f.submit()).body.askId);
await answerAsHost(f, askId, { optionId: "allow" });
await vi.waitFor(() => expect(notesTo(f)).toHaveLength(1));
const [note] = notesTo(f);
expect(note).toMatchObject({ qitemId: `qitem-runner-permission-kept-${askId.replace(/^qitem-runner-permission-/, "")}`,
evidenceRef: `rig queue show ${askId}`, sourceSession: SEAT });
expect(note!.body).toContain("The tool hasn't run, and nothing was permitted or refused.");
expect(note!.body).toContain(`rig seat decide-permission ${askId} --option <id>`);
// The answer itself is kept on the request, and the request still waits.
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "pending", humanAnswers: { permission: "allow" } });
// Answering again keeps the new answer and writes no second note.
await answerAsHost(f, askId, { decision: "only the test file" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(notesTo(f)).toHaveLength(1);
expect(f.permits()).toHaveLength(0);
});
it("when the answer goes to an agent, the who-decides note is written once, linked to the request, with the agent's state", async () => {
const f = fixture({ receiver: "dev-other@perm-rig" });
const askId = String((await f.submit()).body.askId);
await answerAsHost(f, askId, { decision: "only the test file" });
await vi.waitFor(() => expect(notesTo(f)).toHaveLength(1));
const [note] = notesTo(f);
expect(note).toMatchObject({ evidenceRef: `rig queue show ${askId}`, sourceSession: SEAT });
expect(note!.body).toContain(`went to dev-other@perm-rig, which decides it while ${SEAT} waits. Nothing runs until it decides.`);
expect(note!.body).toContain("Receiving agent: dev-other@perm-rig, chosen in agents.operator_session; its session is recorded as running.");
expect(note!.body).toContain(`rig seat decide-permission ${askId} --option <id>`);
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "handed-off", handedOffTo: "dev-other@perm-rig" });
});
it("a runner already waiting when the answer is handed to the agent reads the agent's decision at once, not at its read's end", async () => {
const f = fixture({ decider: DECIDER });
const askId = String((await f.submit()).body.askId);
const started = Date.now();
const waiting = f.read(askId, 5);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(await f.answer(askId, { optionId: "allow" })).toMatchObject({ status: 200 });
expect(await f.decide(askId, "allow")).toMatchObject({ status: 200, body: { state: "answered" } });
expect(await waiting).toMatchObject({ body: { state: "answered", optionId: "allow" } });
expect(Date.now() - started).toBeLessThan(2500);
});
});