Compare commits

...
Author SHA1 Message Date
dev60-review ea349ce66b fix(daemon): tell the receiving agent what a terminal answer means and how to ask the person back
A person's answer typed at this machine's terminal or TUI is recorded,
truthfully, as human@host: OpenRig can't tell who typed. The agent that
reads the answer was never told what that means for acting on it, so a
fresh receiver refused a clear Approve as "unattributed" and sent its
confirmation to human@host, where nothing is delivered. A clarification
also had no named route back to the person, and a runner's decider was
never told what closing its item does.

The receiver's text now says, in one place (receiver-context.ts):
- what an unattributed terminal answer is: the answer of whoever is at
  this machine, usually the person asked, to act on with the request's
  own decision command and without a confirmation;
- who's who on a pairing or runner request: the person asked, the
  requester, and who answered;
- how to ask them back: a new ask to the person the request went to,
  which reaches them in the Feed, Steering and Slack, and never
  human@host. When that person isn't registered, the agent says so once,
  keeps the request and the answer waiting, and names registration or
  the request's own decision command;
- for a runner's request, that closing the item without a decision
  permits nothing: the answer stays kept and the runner waits. The
  default permit still applies only to a request nobody answered.

Text only. The recorded answerer stays human@host, the registered default
person is never recorded as the actor, and no state, refusal or policy
changes.
2026-11-12 03:14:23 +00:00
8 changed files with 109 additions and 4 deletions

No files matched your search

@@ -43,6 +43,7 @@ import { createHash } from "node:crypto";
import type Database from "better-sqlite3";
import type { EventBus } from "../event-bus.js";
import { isTypedGateBlocker, mcOperationTag, UNATTRIBUTED_ACTOR, type QueueRepository, type QueueItem } from "../queue-repository.js";
import { askBackRoute, terminalAnswerMeaning } from "../receiver-context.js";
import type { PersistedEvent } from "../types.js";
import {
MissionControlActionLog,
@@ -695,7 +696,7 @@ export class MissionControlWriteContract {
try {
returned = await this.queueRepo.createHumanAnswer({
sourceSession: input.actorSession, destinationSession: ask.sourceSession,
body: `${reply}\n${answeredBy} in OpenRig.`,
body: `${reply}\n${answeredBy} in OpenRig.${unattributed ? `\n${terminalAnswerMeaning(UNNAMED_PRIMARY_USER)} ${askBackRoute(this.queueRepo.reachablePerson(forAsked), UNNAMED_PRIMARY_USER, input.qitemId)}` : ""}`,
summary: `${forAsked && unattributed ? `Unattributed (${UNNAMED_PRIMARY_USER})` : input.actorSession} answered${forAsked ? ` ${forAsked}'s ask` : ""}: ${ask.summary ?? input.qitemId}`,
tags: [
"human-answer",
+17 -1
View File
@@ -103,6 +103,7 @@ import { WatchdogJobsRepository } from "./watchdog-jobs-repository.js";
import { armQueueWait, backOffQueueWait, refreshQueueWaits, evaluateQueueWait, retargetQueueWait, isQueueWait } from "./queue-wait-backoff.js";
import { parseHumanQuestions, unansweredQuestions, describeTypedReplyPlacement, formatHumanAnswers, humanAnswerText, type HumanQuestion, type HumanAnswers, type TypedHumanReply, type RecordHumanAnswerResult } from "./human-questions.js";
import { slackMicros } from "./gateway/slack/state-store.js";
import { askBackRoute, RUNNER_CLOSE_WAITS, terminalAnswerMeaning } from "./receiver-context.js";
/** A direct ask's answer row ID: from the ask, or from the ask and the park episode its answer belongs to. */
function humanAnswerId(askId: string, episodeKey?: string): string {
@@ -2504,6 +2505,14 @@ export class QueueRepository {
return registry.ok && resolveRegisteredHumanAddress(destination, registry.entities) !== null;
}
/** The registered person an address names, so an ask back reaches them; null for anyone else, the unattributed
* terminal actor included (it's a person address, but nothing is delivered there). */
reachablePerson(address: string | null | undefined): string | null {
if (!address || address === UNATTRIBUTED_ACTOR) return null;
const registry = this.loadHumanRegistryFn();
return registry.ok ? resolveRegisteredHumanAddress(address, registry.entities) : null;
}
/** Who may answer an ask: the asked person, and any other registered person in this workspace, who's recorded as
* themselves with the asked person kept (one trusted workspace, equal authority). A shared channel never answers. */
answersAsk(item: QueueItem, actorSession: string): boolean {
@@ -4245,6 +4254,9 @@ export class QueueRepository {
: answer && typeof answer === "object" && "text" in answer ? `wrote "${String(answer.text).trim()}"`
: "answered without a choice or words";
const person = input.actorSession;
// Who to ask back: the person who answered when OpenRig can name them, else the person the request went to.
const unattributed = person === UNATTRIBUTED_ACTOR;
const askBack = (unattributed ? null : this.reachablePerson(person)) ?? this.reachablePerson(item.destinationSession);
// A handoff that fails after persisting its events rolls them back with its savepoint: discardOnThrow takes their
// tokens out of the caller's notify envelope too, so the kept answer below still commits.
try { this.eventBus.discardOnThrow(() => this.handoffNow({
@@ -4260,8 +4272,12 @@ export class QueueRepository {
"",
`Their whole answer is recorded on the request: rig queue show ${answered.qitemId} --full.`,
`Decide with: rig seat decide-permission ${item.qitemId} --option <id> --reason "<why>"`,
"Act on a clear answer at once: a clear yes or an allow click is the allow option, a clear no or a reject click the reject option. Don't ask them to confirm a clear answer. If their words are unclear or conditional, ask them back, naming this request, then decide.",
`- **Who's who:** ${root.sourceSession} is the agent asking to run the tool; ${item.destinationSession} is the person it asked; ${person} answered.`,
...(unattributed ? [terminalAnswerMeaning(UNATTRIBUTED_ACTOR)] : []),
"Act on a clear answer at once: a clear yes or an allow click is the allow option, a clear no or a reject click the reject option. Don't ask them to confirm a clear answer.",
`${askBackRoute(askBack, UNATTRIBUTED_ACTOR, `request ${item.qitemId}`)} Then decide on what they answer.`,
"Until it's decided (by you, or by anyone with that command), the runner keeps waiting, with no timeout that refuses it; if its turn ends first, nothing is sent.",
RUNNER_CLOSE_WAITS,
].join("\n"),
})); } catch (error) {
// Kept, and committed with the answer's own write (a throw here would roll it back): the request stays open, and
@@ -0,0 +1,26 @@
// What a receiving agent needs to act on a person's answer, in one place: what an answer typed at this machine means,
// how to ask the person back, and what closing a runner's request does. Text only: who is recorded as answering, and
// every state and policy, stay as they are.
/** An answer recorded as `unattributed` (typed at this machine's terminal or TUI): what it means for acting on it. */
export function terminalAnswerMeaning(unattributed: string): string {
return `${unattributed} is how OpenRig records any answer typed at this machine's terminal or TUI: it can't tell who typed, so it names nobody. `
+ "It's the answer of whoever is at this machine, usually the person the request went to. Act on a clear answer with the request's own decision command, and don't ask them to confirm it.";
}
/** How to ask the person back when their answer is unclear or conditional: a new ask to the person the request went to,
* which reaches them where they answer. `asked` is null when that person isn't registered (any more): what to do then is
* the one open case, so it's this one sentence. */
export function askBackRoute(asked: string | null, unattributed: string, request: string): string {
const nowhere = `Don't write to ${unattributed}: nothing is delivered there.`;
if (asked) {
return `If their answer is unclear or conditional, ask them back with an ask to ${asked} that names ${request}: `
+ `rig queue create --destination ${asked} --summary "<your question>" --body-file <file>. It reaches them where they answer (the Feed, Steering, and Slack when it's connected). ${nowhere}`;
}
return `If their answer is unclear or conditional, OpenRig can't ask them back: the person ${request} went to isn't registered. `
+ `Say so once, in this conversation, and keep the request and their answer waiting. The ways through are registering a person (rig gateway human add) or the request's own decision command. ${nowhere}`;
}
/** What closing a runner's permission request without a decision does: a kept answer waits (runner-permissions). */
export const RUNNER_CLOSE_WAITS = "Closing this item without deciding permits nothing: the person's answer stays kept and the runner keeps waiting until it's decided. "
+ "OpenRig's default permit applies only to a request nobody answered.";
+2 -1
View File
@@ -56,6 +56,7 @@ import { addHostEntry, updateHostEntry, removeHostEntry } from "../domain/hosts/
import { defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, type HostEntry } from "../domain/hosts/hosts-registry-reader.js";
import type { SettingsStore } from "../domain/user-settings/settings-store.js";
import { UNATTRIBUTED_ACTOR, type QueueItem, type QueueRepository } from "../domain/queue-repository.js";
import { askBackRoute } from "../domain/receiver-context.js";
import { KERNEL_OPERATOR_SESSION, resolveDefaultReceiver } from "../domain/default-receiver.js";
import type { HumanQuestion } from "../domain/human-questions.js";
import { PAIR_SOURCE_SESSION } from "../domain/hosts/pairing-ask.js";
@@ -268,7 +269,7 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
...(owner ? [
`Answer with Approve or Deny, or in your own words. ${owner} reads your answer and acts on it: a clear yes pairs, a clear no denies, and if your answer isn't clear it asks you back.${rec.ownerNote ? ` (${rec.ownerNote}, so ${owner} reads it instead.)` : ""}`,
...((running) => running === true ? [] : [`${running === false ? `${owner} isn't running right now, so your answer waits` : `OpenRig couldn't tell whether ${owner} is running, so your answer may wait`} until it starts; nothing is granted or denied meanwhile. To decide sooner on this host: ${directDecision(code)}.`])(ownerRunning(c, owner)),
`For ${owner}: when the person's answer to this request comes back, act on it. A clear yes or an Approve click: rig host approve ${code}. A clear no or a Deny click: rig host deny ${code}. Don't ask them to confirm a clear answer. If their words are unclear or conditional, ask them back, naming pairing code ${code}, and act on what they answer.`,
`For ${owner}: when the person's answer to this request comes back, act on it. ${rec.person} is the person asked, on this host; ${requester} is the other machine, asking to pair. A clear yes or an Approve click: rig host approve ${code}. A clear no or a Deny click: rig host deny ${code}. Don't ask them to confirm a clear answer. ${askBackRoute(rec.person, UNATTRIBUTED_ACTOR, `pairing code ${code}`)} Then act on what they answer.`,
] : [
`No agent reads answers here yet: ${noReader(rec)}. Your answer, a click or your words, is kept, and goes to the receiving agent once one is set; nothing is granted or denied meanwhile.`,
`To decide now on this host: ${directDecision(code)}.`,
@@ -259,7 +259,10 @@ describe("host pairing with the kernel operator running: the person's answer goe
const item = f.repo.getById(f.ask)!;
expect(item).toMatchObject({ sourceSession: OPERATOR, destinationSession: PERSON, tier: "human-gate" });
expect(item.body).toContain(`Answer with Approve or Deny, or in your own words. ${OPERATOR} reads your answer and acts on it`);
expect(item.body).toContain(`For ${OPERATOR}: when the person's answer to this request comes back, act on it. A clear yes or an Approve click: rig host approve ${f.issued.code}. A clear no or a Deny click: rig host deny ${f.issued.code}.`);
expect(item.body).toContain(`For ${OPERATOR}: when the person's answer to this request comes back, act on it. ${PERSON} is the person asked, on this host; requester@far-host is the other machine, asking to pair. A clear yes or an Approve click: rig host approve ${f.issued.code}. A clear no or a Deny click: rig host deny ${f.issued.code}.`);
// An unclear answer is asked back through the person asked, never human@host.
expect(item.body).toContain(`ask them back with an ask to ${PERSON} that names pairing code ${f.issued.code}: rig queue create --destination ${PERSON}`);
expect(item.body).toContain("Don't write to human@host: nothing is delivered there. Then act on what they answer.");
});
it.each(["yes", "no", "yes, but which machine?"])("a typed %j (Steering) reaches the operator as a reply with the person's words, and decides nothing by itself", async (words) => {
@@ -16,6 +16,7 @@ import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
import { humanAnswerText } from "../src/domain/human-questions.js";
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
import { missionControlRoutes } from "../src/routes/mission-control.js";
import { addHumanFragment } from "../src/domain/gateway/human-registry.js";
@@ -77,6 +78,12 @@ describe("the primary user acts on anyone's ask, unattributed, on that person's
const reply = repo.list({ limit: 50 }).find((item) => item.replyTo === sarahs.qitemId)!;
expect(reply).toMatchObject({ sourceSession: "human@host", destinationSession: ASKER, summary: "Unattributed (human@host) answered sarah@external's ask: Ship the draft?" });
expect(reply.body).toContain("Answered at the terminal by someone OpenRig can't name (human@host), unattributed, on sarah@external's ask in OpenRig.");
// The receiver reads what that means for acting, and the route to ask the person back: never human@host.
expect(reply.body).toContain("human@host is how OpenRig records any answer typed at this machine's terminal or TUI");
expect(reply.body).toContain("don't ask them to confirm it");
expect(reply.body).toContain(`ask them back with an ask to sarah@external that names ${sarahs.qitemId}: rig queue create --destination sarah@external`);
expect(reply.body).toContain("Don't write to human@host: nothing is delivered there.");
expect(humanAnswerText(reply.body)).toBe("Ship it");
});
it("records a choice on a question ask the same way", async () => {
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { askBackRoute, RUNNER_CLOSE_WAITS, terminalAnswerMeaning } from "../src/domain/receiver-context.js";
describe("what a receiving agent reads to act on a person's answer", () => {
it("names the person to ask back, and never the unattributed terminal actor", () => {
const route = askBackRoute("sarah@external", "human@host", "request q-1");
expect(route).toContain("ask them back with an ask to sarah@external that names request q-1: rig queue create --destination sarah@external");
expect(route).toContain("Don't write to human@host: nothing is delivered there.");
});
it("with no registered person to ask back: says so once, keeps the request and the answer waiting, and names the ways through", () => {
const route = askBackRoute(null, "human@host", "request q-1");
expect(route).toContain("OpenRig can't ask them back: the person request q-1 went to isn't registered.");
expect(route).toContain("Say so once, in this conversation, and keep the request and their answer waiting.");
expect(route).toContain("registering a person (rig gateway human add) or the request's own decision command");
expect(route).not.toContain("rig queue create");
});
it("explains an unattributed terminal answer as the answer of whoever is at this machine, to act on without a confirmation", () => {
expect(terminalAnswerMeaning("human@host")).toContain("human@host is how OpenRig records any answer typed at this machine's terminal or TUI");
expect(terminalAnswerMeaning("human@host")).toContain("don't ask them to confirm it");
});
it("says a close without a decision permits nothing, and keeps the default permit to a request nobody answered", () => {
expect(RUNNER_CLOSE_WAITS).toContain("Closing this item without deciding permits nothing");
expect(RUNNER_CLOSE_WAITS).toContain("OpenRig's default permit applies only to a request nobody answered.");
});
});
@@ -642,6 +642,10 @@ describe("an answered request goes to the receiving agent, which decides while t
expect(handed).toMatchObject({ destinationSession: DECIDER, state: "pending" });
expect(handed.body).toContain(`${PERSON} answered ${SEAT}'s permission request ${askId}: they chose "Allow" (option allow).`);
expect(handed.body).toContain(`Decide with: rig seat decide-permission ${askId} --option <id>`);
// The receiver reads who to ask back (the person who answered), and that closing without a decision permits nothing.
expect(handed.body).toContain(`ask them back with an ask to ${PERSON} that names request ${askId}`);
expect(handed.body).toContain("Closing this item without deciding permits nothing: the person's answer stays kept and the runner keeps waiting");
expect(handed.body).not.toContain("is how OpenRig records any answer typed at this machine's terminal");
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
expect(await f.decide(askId, "allow")).toMatchObject({ status: 200, body: { ok: true, state: "answered", optionId: "allow" } });
expect(await f.read(askId)).toMatchObject({ body: { state: "answered", optionId: "allow" } });
@@ -652,6 +656,25 @@ describe("an answered request goes to the receiving agent, which decides while t
expect(f.permits()).toHaveLength(0);
});
it("an answer typed at the terminal reaches the agent with what it means, who's who, and the asked person to ask back", async () => {
const f = fixture({ decider: DECIDER });
const askId = String((await f.submit()).body.askId);
const asked = f.queueRepo.getById(askId)!.destinationSession;
await f.contract.act({ verb: "answer", qitemId: askId, actorSession: "human@host", authority: "primary-user",
questionId: "permission", optionId: "allow" } as never);
const handed = f.queueRepo.list({ limit: 100 }).find((row) => row.handedOffFrom === askId)!;
expect(handed).toMatchObject({ destinationSession: DECIDER, state: "pending" });
// Provenance stays truthful: the answerer is human@host, never a registered person.
expect(handed.body).toContain(`human@host answered ${SEAT}'s permission request ${askId}: they chose "Allow" (option allow).`);
expect(handed.body).toContain(`- **Who's who:** ${SEAT} is the agent asking to run the tool; ${asked} is the person it asked; human@host answered.`);
expect(handed.body).toContain("human@host is how OpenRig records any answer typed at this machine's terminal or TUI");
expect(handed.body).toContain(`ask them back with an ask to ${asked} that names request ${askId}`);
expect(handed.body).toContain("Don't write to human@host: nothing is delivered there.");
expect(handed.body).toContain("Closing this item without deciding permits nothing");
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
expect(f.permits()).toHaveLength(0);
});
it("a person's words, even a plain yes, go to the agent with the question and options, never permitted by default first", async () => {
for (const words of ["yes", "only if it's the test file"]) {
const f = fixture({ decider: DECIDER });