feat(daemon): one default receiving agent for answers nothing else reads (#2923)

Some answers have no asking agent able to read them: a person's answer to a host pairing request, the notice when a scheduled mode ends, and Slack messages with no route. Each chose its own receiver. A new resolver gives one answer: agents.operator_session when it names an agent's session, otherwise the kernel operator when its seat exists, otherwise none. It reports where the choice came from and the receiver's state (running, stopped or unknown), and never picks another seat because one is stopped.

Pairing's owner, the scheduled mode end and Slack's unrouted destination now use it. A Slack inboundDestination saved in the config file is kept exactly as written, including one equal to the old default; only an absent field, or the explicit default-receiver choice, inherits from the resolver, and channel and thread routes still come first. When the setting is invalid or can't be read, pairing, mode end and Slack say so instead of calling it unset. rig config and the TUI's settings show the effective receiving agent, where it came from and its state.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-11-10 17:51:03 -08:00
1 parent a5bda7fd31
commit 4fa7ee0ab6
17 files changed
+451 -47

No files matched your search

+18
View File
@@ -76,6 +76,24 @@ go to:
`rig gateway default` shows the current default; `rig gateway default --clear` clears the choice. Choosing a default
never changes `workspace.operator_seat_name`, which keeps its own meaning.
## The default receiving agent
The default person is who OpenRig asks. The default receiving agent is the agent that reads what a person answers when
nothing else reads it: a request with no owner of its own, such as a host pairing request or a mode's scheduled end, and
a Slack message no thread or channel route claims. It is:
1. the agent chosen in `agents.operator_session` (`rig config set agents.operator_session <seat>@<rig>`; a person's
address there is ignored);
2. otherwise the kernel operator, `operator-agent@kernel`, when this host has that seat, running or stopped;
3. otherwise none, and the request says no agent reads answers on this host.
A stopped receiving agent stays the receiver: answers wait for it, and the person is told it isn't running. OpenRig
never picks whichever seat happens to be running. The TUI shows the effective agent, where it came from and its state
under Settings > Agents & runtime ("Default receiving agent"). It never replaces the default person.
For Slack, `rig slack setup --inbound-destination default-receiver` (the default for a new setup) sends unrouted
messages to the default receiving agent, read when the connector starts. A destination already saved in the connector
config, `operator-agent@kernel` included, is kept as chosen; pass `default-receiver` to switch it.
## In the TUI: whose queue, and who acts
The person at the terminal is the **primary user**. The TUI records their acts as unnamed (`human@host`),
+3 -2
View File
@@ -181,7 +181,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
.command("setup")
.description("Configure the connector (first-class config; secrets stay in the env file, never here)")
.option("--channel <id>", "Slack channel id the connector app must be a member of")
.option("--inbound-destination <session>", "where inbound human messages land (default operator-agent@kernel)")
.option("--inbound-destination <session>", "where a person's unrouted message lands: a seat's session, or default-receiver (the default) for the host's default receiving agent (agents.operator_session, else the kernel operator)")
.option("--minimum-level-that-posts <level>", "minimum OWNER level posted to Slack: RECORD|NOTICE|ALERT")
.option("--minimum-level-that-interrupts <level>", "minimum OWNER level that mentions/interrupts: RECORD|NOTICE|ALERT")
.option("--source-label <label>", "label shown in the posted message footer (where the queue lives)")
@@ -251,7 +251,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
for (const r of readiness) log(` ${r.ok ? "✓" : "✗"} ${r.label}: ${r.detail}`);
log(`Daemon: ${observation.state}${observation.reason ? ` (${observation.reason})` : ""}`);
const connector = observation.connector as { configurationDigest?: string; inboundReady?: boolean; outboundReady?: boolean;
inboundRouting?: { fallback: string; destination: string };
inboundRouting?: { fallback: string; destination: string; destinationFrom?: string; destinationNote?: string };
settlement?: { pending: number; lastFailure: string | null };
deadLetterBacklog?: number | null; deadLetterBacklogState?: string; deadLetterBacklogReason?: string; relayAttemptsInFlight?: number;
inbound?: InboundDeliveryStatus & { state?: string; generation?: number; lastEventAt?: string };
@@ -268,6 +268,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
if (connector.settlement.lastFailure) log(` Last ask-edit failure: ${connector.settlement.lastFailure}`);
}
if (connector.inboundRouting?.fallback === "local") log(` Configured routing fallback: local to ${connector.inboundRouting.destination} (after forwarding, thread and channel routes)`);
if (connector.inboundRouting?.destinationNote) log(` ${connector.inboundRouting.destinationFrom === "receiving-agent" ? "Default receiving agent" : "No receiving agent"}: ${connector.inboundRouting.destinationNote}`);
log(` Socket: ${connector.inbound?.state ?? "unknown"}; generation ${connector.inbound?.generation ?? "unknown"}; last event ${connector.inbound?.lastEventAt ?? "unknown"}`);
for (const line of socketDeliveryLines(connector.inbound)) log(` ${line}`);
const recovery = connector.recovery;
@@ -0,0 +1,74 @@
import type Database from "better-sqlite3";
import { readReceivingAgentSetting } from "./queue-owner.js";
/** The kernel's operator agent (pod operator, member agent): the receiving agent when none is set. */
export const KERNEL_OPERATOR_SESSION = "operator-agent@kernel";
export type DefaultReceiverSource = "setting" | "kernel-operator" | "none";
/** The seat's newest recorded session status, as recorded, never as proof it's reading: `running`, `idle` and `unknown`
* are kept apart; `stopped` is an exited, detached or superseded one (`recorded` names which). `missing`: no seat by that
* name on this host. `unknown` with no `recorded`: the seat couldn't be read. None of these picks another seat. */
export type ReceiverState = "running" | "idle" | "stopped" | "missing" | "unknown";
/** The agent that receives what a person answers when nothing else reads it: requests with no owner of their own, such
* as a pairing request or a mode's scheduled end, and Slack messages no thread or channel route claims. */
export interface DefaultReceiver {
session: string | null;
source: DefaultReceiverSource;
state: ReceiverState;
/** The seat's newest recorded status, when there is one. */
recorded?: string;
/** Why the chosen agent couldn't be used (an unreadable setting, or one that isn't an agent's session), carried with
* whatever was resolved instead, so a fallback is never presented as the choice. */
note?: string;
}
/** The chosen agent (`agents.operator_session`, an agent's canonical session), else the kernel operator when this host has
* its seat, else none. A stopped receiver stays the receiver: its answers wait for it, and its state says so. This never
* chooses whichever seat happens to be running, and never a person: the person's own address
* (`workspace.operator_seat_name`) is a separate setting. */
export function resolveDefaultReceiver(
db: Database.Database,
configured: () => string | null | { session: string | null; problem?: string } = readReceivingAgentSetting,
): DefaultReceiver {
const read = configured();
const chosen = typeof read === "object" && read !== null ? read : { session: read };
const note = chosen.problem ? { note: chosen.problem } : {};
if (chosen.session) return { session: chosen.session, source: "setting", ...seatState(db, chosen.session), ...note };
const kernel = seatState(db, KERNEL_OPERATOR_SESSION);
if (kernel.state === "missing" || (kernel.state === "unknown" && !kernel.recorded)) return { session: null, source: "none", state: kernel.state, ...note };
return { session: KERNEL_OPERATOR_SESSION, source: "kernel-operator", ...kernel, ...note };
}
function seatState(db: Database.Database, session: string): Pick<DefaultReceiver, "state" | "recorded"> {
let row: { status: string | null } | undefined;
try {
row = db.prepare("SELECT status FROM sessions WHERE session_name = ? ORDER BY rowid DESC LIMIT 1").get(session) as { status: string | null } | undefined;
} catch {
return { state: "unknown" };
}
if (!row) return { state: "missing" };
const recorded = row.status ?? "unknown";
const state: ReceiverState = recorded === "running" || recorded === "idle" || recorded === "unknown" ? recorded : "stopped";
return { state, recorded };
}
/** One line on who receives answers here and in what recorded state, for the person's settings and status views. It says
* what's recorded, not that the agent is reading. */
export function describeDefaultReceiver(receiver: DefaultReceiver): string {
const note = receiver.note ? `${receiver.note}. ` : "";
if (!receiver.session) {
return note + (receiver.state === "unknown"
? "OpenRig couldn't tell whether this host has a receiving agent: its seats couldn't be read."
: `No receiving agent on this host: set one with rig config set agents.operator_session <seat>, or start the kernel (${KERNEL_OPERATOR_SESSION}).`);
}
const from = receiver.source === "setting" ? "chosen in agents.operator_session"
: receiver.note ? "the kernel operator, used instead" : "the kernel operator, since agents.operator_session is unset";
const state = receiver.state === "running" ? "its session is recorded as running"
: receiver.state === "idle" ? "its session is recorded as idle"
: receiver.state === "stopped" ? `its session is recorded as ${receiver.recorded}: answers wait for it, and nothing is decided meanwhile`
: receiver.state === "missing" ? "no seat by that name on this host yet: answers wait until it exists"
: receiver.recorded ? "its session's state is recorded as unknown: answers may wait"
: "its seat couldn't be read: answers may wait";
return `${note}${receiver.session}, ${from}; ${state}.`;
}
@@ -13,6 +13,7 @@ import { BASELINE_REQUIRED_SCOPES } from "./capabilities.js";
import { unsupportedChannelMapFields, validateChannelMap, type ChannelMapEntry } from "./channel-map.js";
import { botInboxProblems, type BotInboxConfig } from "./bot-inbox.js";
import { forwardChannelProblems, type ForwardChannel } from "./forwarding.js";
import { describeDefaultReceiver, type DefaultReceiver } from "../../default-receiver.js";
export interface SlackConnectorConfig {
enabled: boolean;
@@ -65,9 +66,33 @@ export interface SlackConnectorConfig {
export const SLACK_REACTIONS_MODES = ["route", "off"] as const;
export type SlackReactionsMode = typeof SLACK_REACTIONS_MODES[number];
/** The inbound destination that means "the host's default receiving agent". It's never written to the file: a saved config
* without `inboundDestination` inherits it (so an older daemon reading that file uses its own default), and
* `rig slack setup --inbound-destination default-receiver` returns to it. A destination already saved in the file is kept
* as chosen, `operator-agent@kernel` included: nothing records whether a person chose it. */
export const INHERIT_INBOUND_DESTINATION = "default-receiver";
/** Where an inherited destination goes when the host has no receiving agent: the kernel operator's address, as before. */
export const LEGACY_INBOUND_DESTINATION = "operator-agent@kernel";
/** Where a person's unrouted Slack message goes: the destination saved in the config, else the host's receiving agent, else
* the kernel operator's address. Thread and channel routes still come first. */
export function effectiveInboundDestination(cfg: Pick<SlackConnectorConfig, "inboundDestination">, receiver: string | null): { destination: string; from: "config" | "receiving-agent" | "default" } {
if (cfg.inboundDestination !== INHERIT_INBOUND_DESTINATION) return { destination: cfg.inboundDestination, from: "config" };
return receiver ? { destination: receiver, from: "receiving-agent" } : { destination: LEGACY_INBOUND_DESTINATION, from: "default" };
}
/** What the status says about an inherited destination: the receiving agent and its recorded state, or, with none, that the
* kernel operator's address is kept for compatibility, so it isn't presented as a resolved receiver. Nothing for a
* destination saved in the config. */
export function inboundDestinationNote(inbound: { destination: string; from: "config" | "receiving-agent" | "default" }, receiver: DefaultReceiver | null): string | undefined {
if (inbound.from === "config") return undefined;
const who = receiver ? describeDefaultReceiver(receiver) : "OpenRig couldn't tell whether this host has a receiving agent.";
return inbound.from === "receiving-agent" ? who : `${who} Unrouted messages go to ${inbound.destination} for compatibility, as before.`;
}
export const DEFAULT_CONFIG: SlackConnectorConfig = {
enabled: false,
inboundDestination: "operator-agent@kernel",
inboundDestination: INHERIT_INBOUND_DESTINATION,
outboundDestinations: [],
sourceLabel: "openrig",
channel: null,
@@ -118,7 +143,9 @@ export function saveConfig(cfg: SlackConnectorConfig, home?: string): string {
validateConfig(cfg);
const p = configPathFor(home);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
// An inherited destination is left out of the file, so the file records only a destination someone chose.
const { inboundDestination, ...rest } = cfg;
fs.writeFileSync(p, JSON.stringify(inboundDestination === INHERIT_INBOUND_DESTINATION ? rest : cfg, null, 2) + "\n");
return p;
}
@@ -146,7 +173,8 @@ export function staticReadiness(cfg: SlackConnectorConfig, hasBotToken: boolean,
{ ok: hasBotToken, label: "bot-token", detail: hasBotToken ? "resolved" : "unset (outbound cannot post; scope/membership verify unavailable)" },
{ ok: hasAppToken, label: "app-token (Socket Mode)", detail: hasAppToken ? "resolved" : "unset (inbound cannot connect)" },
{ ok: cfg.channel !== null, label: "channel", detail: cfg.channel ?? "unset (outbound cannot post)" },
{ ok: Boolean(cfg.inboundDestination), label: "inbound-destination", detail: cfg.inboundDestination },
{ ok: Boolean(cfg.inboundDestination), label: "inbound-destination",
detail: cfg.inboundDestination === INHERIT_INBOUND_DESTINATION ? `${INHERIT_INBOUND_DESTINATION} (the host's default receiving agent)` : cfg.inboundDestination },
{ ok: cfg.enabled, label: "enabled", detail: cfg.enabled ? "yes" : "no (run `rig slack enable`)" },
...channelMapReadiness(cfg),
...botInboxReadiness(cfg),
@@ -21,7 +21,8 @@ import { buildInProcessWire, type GatewayWire, type SubsystemDeliverFn } from ".
import { DispatchBuffer } from "../dispatch-buffer.js";
import { downloadPrivateFile, isSlackHost, lookupBotApp, postChatMessage } from "./slack-api.js";
import { botInboxProblems } from "./bot-inbox.js";
import { loadConfig } from "./config.js";
import { effectiveInboundDestination, inboundDestinationNote, loadConfig } from "./config.js";
import { resolveDefaultReceiver } from "../../default-receiver.js";
import { resolveSecret } from "./secrets.js";
import { SeenStore, DeadLetterStore, InboundReceiptStore, slackMicros } from "./state-store.js";
import { makeQueuePorts } from "./queue-access.js";
@@ -310,7 +311,12 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
const outboundReady = cfg.enabled && bot !== null && cfg.channel !== null;
const inboundReady = cfg.enabled && app !== null;
const inboundRouting = { fallback: "local", destination: cfg.inboundDestination };
// Read once, when the connector starts, like the rest of its configuration.
const receiver = (() => { try { return resolveDefaultReceiver(opts.queueRepo.db); } catch { return null; } })();
const inbound = effectiveInboundDestination(cfg, receiver?.session ?? null);
const inboundNote = inboundDestinationNote(inbound, receiver);
if (inbound.from === "default") log(`slack inbound: ${inboundNote}`);
const inboundRouting = { fallback: "local", destination: inbound.destination, destinationFrom: inbound.from, ...(inboundNote ? { destinationNote: inboundNote } : {}) };
const dead = new DeadLetterStore<SlackEvent>(path.join(stateDir(opts.home), "slack-inbound-deadletter.jsonl"));
const deadActions = new DeadLetterStore<SlackBlockActions>(path.join(stateDir(opts.home), "slack-inbound-action-deadletter.jsonl"));
// Retained relays belong to their original host, even after forwarding configuration changes.
@@ -1359,14 +1365,14 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
queue: ports,
seen: inboundSeen,
deadLetter: dead,
destination: cfg.inboundDestination,
destination: inbound.destination,
reactions: cfg.reactions === "off" ? "off" : "route",
resolveSender: makeInboundSenderResolver(registry, opts.home),
// S10 — deterministic thread routing: mapped thread → exactly the mapped seat; unmapped
// or human-initiated → the configured orchestrator slot as an unrouted-signal row.
resolveRoute: makeThreadRouteResolver({
map: threadMap,
unroutedDestination: cfg.inboundDestination,
unroutedDestination: inbound.destination,
// A person's top-level message in a channel the map routes: its `inbound` seat or its rig's representative.
resolveChannelDestination: (channel) =>
resolveInboundChannelDestination(cfg, channel, (rig) => resolveRigRepresentative(opts.queueRepo.db, rig)),
@@ -8,10 +8,11 @@ import type { WatchdogJobsRepository } from "../watchdog-jobs-repository.js";
import { HumanModeService, registryAvailability, type ModeApplication } from "../gateway/human-operating-modes.js";
import { loadHumanRegistry } from "../gateway/human-registry.js";
import type { Policy, PolicyJob, PolicyEvaluation } from "./types.js";
import { describeDefaultReceiver, KERNEL_OPERATOR_SESSION, resolveDefaultReceiver, type DefaultReceiver } from "../default-receiver.js";
export const MODE_SCHEDULED_END_POLICY = "mode-scheduled-end";
/** The kernel's operator agent (pod operator, member agent), whose role guidance covers giving the account. */
export const KERNEL_OPERATOR_SESSION = "operator-agent@kernel";
export { KERNEL_OPERATOR_SESSION };
const applicationSpec = (applicationId: string) => `applicationId: "${applicationId}"`;
@@ -37,6 +38,8 @@ export interface ModeScheduledEndInput {
home: string;
jobId: string;
now?: Date;
/** Who receives requests no one else owns here; the host's default receiving agent unless a caller supplies it. */
receiver?: (db: Database.Database) => DefaultReceiver;
}
/** The notice's stable id: one per application, so a retry finds what an earlier run already sent. */
@@ -98,20 +101,27 @@ const SCHEDULER = "daemon@kernel";
* is decided yet. */
type Recipient = { ok: true; to: string; kind: "operator" | "applied-by" | "person"; why: string | null } | { ok: false; known: boolean; why: string };
/** Who's told, through the queue's own destination check: the kernel operator, which gives the account; where it isn't on
* this host, the seat that applied the mode, if the queue would accept it (a named operator outside a seat isn't a
* destination); otherwise the person, whose account it is. */
/** Who's told, through the queue's own destination check: the host's receiving agent (the chosen one, else the kernel
* operator), which gives the account; where there's none, the seat that applied the mode, if the queue would accept it
* (a named operator outside a seat isn't a destination); otherwise the person, whose account it is. */
function recipientFor(input: ModeScheduledEndInput, ended: ModeApplication): Recipient {
const unknown = (why: string): Recipient => ({ ok: false, known: false, why });
// A check that throws hasn't said no.
const accepts = (to: string): boolean | Error => { try { return input.queueRepo.acceptsDestination(to); } catch (error) { return error as Error; } };
const operatorHere = !!input.db.prepare("SELECT 1 FROM sessions WHERE session_name = ? LIMIT 1").get(KERNEL_OPERATOR_SESSION);
if (operatorHere) {
const ok = accepts(KERNEL_OPERATOR_SESSION);
if (ok instanceof Error) return unknown(`checking ${KERNEL_OPERATOR_SESSION} failed: ${ok.message}`);
if (ok) return { ok: true, to: KERNEL_OPERATOR_SESSION, kind: "operator", why: null };
const receiver = (input.receiver ?? resolveDefaultReceiver)(input.db);
// Not knowing whether there's a receiving agent isn't knowing there's none: the notice stays owed for the next pass.
if (!receiver.session && receiver.state === "unknown") return unknown(describeDefaultReceiver(receiver));
if (receiver.session) {
const ok = accepts(receiver.session);
if (ok instanceof Error) return unknown(`checking ${receiver.session} failed: ${ok.message}`);
// A receiver used instead of an unusable choice is delivered to with the reason, never silently.
if (ok) return { ok: true, to: receiver.session, kind: "operator", why: receiver.note ?? null };
}
const noOperator = `${KERNEL_OPERATOR_SESSION} isn't on this host`;
// An unusable setting is named as such, never as unset.
const note = receiver.note ? `${receiver.note}; ` : "";
const noOperator = receiver.session ? `${note}${receiver.session}, the receiving agent, isn't an address the queue accepts`
: receiver.state === "unknown" ? `${note}OpenRig couldn't tell whether this host has a receiving agent`
: `this host has no receiving agent (${receiver.note ?? "no agents.operator_session"}, and ${KERNEL_OPERATOR_SESSION} isn't on this host)`;
const applier = accepts(ended.startedBy);
if (applier instanceof Error) return unknown(`checking ${ended.startedBy} failed: ${applier.message}`);
if (applier) return { ok: true, to: ended.startedBy, kind: "applied-by", why: noOperator };
+13 -4
View File
@@ -20,13 +20,22 @@ export function resolveOperatorSeat(): string | null {
* approvals use. Null when unset, unreadable, or not an agent's canonical session (a person's address or
* human seat ref never counts). */
export function resolveRecoveryAgentSeat(): string | null {
return readReceivingAgentSetting().session;
}
/** `agents.operator_session` as the receiving agent reads it: the agent's session, or null with why when it can't be used
* (`problem`: unreadable, or not an agent's canonical session). Unset is null with no problem. */
export function readReceivingAgentSetting(): { session: string | null; problem?: string } {
let session: string;
try {
const v = new SettingsStore().resolveOne("agents.operator_session" as never).value;
const session = typeof v === "string" ? v.trim() : "";
return session && parseSessionName(session).kind === "canonical" && !isHumanSeatSessionRef(session) ? session : null;
} catch {
return null;
session = typeof v === "string" ? v.trim() : "";
} catch (error) {
return { session: null, problem: `agents.operator_session couldn't be read (${(error as Error).message})` };
}
if (!session) return { session: null };
return parseSessionName(session).kind === "canonical" && !isHumanSeatSessionRef(session) ? { session }
: { session: null, problem: `agents.operator_session is "${session}", which isn't an agent's session, so it's ignored` };
}
/** The durable session→node binding (the session-registry precedent: latest sessions row
@@ -4,10 +4,11 @@ import { join, isAbsolute } from "node:path";
import { getOpenRigHome } from "../../openrig-compat.js";
import { SETTINGS_VALID_KEYS, type SettingsStore, type ResolvedSetting } from "./settings-store.js";
import { connectionsProjection, readConnectionConfiguration } from "../gateway/connections-projection.js";
import { DEFAULT_CONFIG } from "../gateway/slack/config.js";
import { DEFAULT_CONFIG, INHERIT_INBOUND_DESTINATION } from "../gateway/slack/config.js";
import { projectionPath } from "../gateway/human-registry.js";
import { loadHostRegistry } from "../hosts/hosts-registry-reader.js";
import { DEFAULT_HEALTH_POLICY, validateHealthPolicy } from "../health-policy.js";
import { describeDefaultReceiver, type DefaultReceiver } from "../default-receiver.js";
type Value = string | number | boolean | null;
type State = "available" | "missing" | "malformed" | "unavailable";
@@ -104,7 +105,8 @@ function application(key: string): string {
}
/** Additive safe view of existing owners. No mutation, host probe, provider call or repair. */
export function settingsBrowser(store: SettingsStore, gateway: Record<string, unknown> | null = null, home = getOpenRigHome()) {
export function settingsBrowser(store: SettingsStore, gateway: Record<string, unknown> | null = null, home = getOpenRigHome(),
receiver: DefaultReceiver | null = null) {
const read = readConnectionConfiguration(home);
const redact = read.text;
const entries: BrowserEntry[] = [];
@@ -139,6 +141,13 @@ export function settingsBrowser(store: SettingsStore, gateway: Record<string, un
add(key, "general", r?.value, r?.defaultValue, r?.source ?? "unavailable", kindFor(key),
key === "ui.terminal.max_live_terminals" ? "Legacy web client" : "Displayed daemon instance", application(key));
}
// Derived, not a setting: the agent that receives answers nothing else reads, from agents.operator_session or the
// kernel operator. Shown with where it came from and its state; changed with rig config set agents.operator_session.
if (receiver) {
add("agents.default_receiver", "general", receiver.session ?? "", undefined,
receiver.source === "setting" ? resolved?.["agents.operator_session"]?.source ?? "unreported" : "default", "identity",
"Displayed daemon instance", describeDefaultReceiver(receiver));
}
if (resolved) {
for (const host of store.listFeedHostSubscriptions()) {
const key = "feed.subscriptions." + host.hostId + ".enabled";
@@ -158,8 +167,11 @@ export function settingsBrowser(store: SettingsStore, gateway: Record<string, un
const cfg = read.cfg as unknown as Record<string, unknown> | null;
const defs = DEFAULT_CONFIG as unknown as Record<string, unknown>;
const flatten = (v: unknown) => Array.isArray(v) && v.every((x) => typeof x === "string") ? v.join(", ") : v;
// The cross-reference to Agents & runtime: an unchosen destination is the host's receiving agent.
const unchosen = key === "inboundDestination" && (!cfg || cfg[key] === INHERIT_INBOUND_DESTINATION);
add("slack." + key, "slack", cfg ? flatten(cfg[key]) : null, flatten(defs[key]),
cfg ? read.fields.includes(key) ? "file" : "default" : "unavailable", kind);
cfg ? read.fields.includes(key) ? "file" : "default" : "unavailable", kind, undefined,
unchosen ? "Not chosen: when the connector starts, unrouted messages go to the default receiving agent (Agents & runtime)." : undefined);
}
// #192: the channel map as one read-only line, "match: channel" per entry.
add("slack.channelMap", "slack", read.cfg ? c.configuration!.channelMap.map((e) => `${e.match ?? "withheld"}: ${e.channel ?? "withheld"}`).join(", ") : null, "",
@@ -134,6 +134,8 @@ export const SETTINGS_VALID_KEYS = [
// dispatch ACK §4: allowlist-only edit; no migrations / new
// endpoints / event types.
"agents.advisor_session",
// The default receiving agent: reads what a person answers when nothing else reads it (resolveDefaultReceiver), and the
// agent a dead seat's recovery reaches. Unset means the kernel operator, when this host has that seat.
"agents.operator_session",
// Explicit operator override; unset means discover the registered human.
"workspace.operator_seat_name",
+8 -1
View File
@@ -14,6 +14,8 @@
import { Hono } from "hono";
import { settingsBrowser } from "../domain/user-settings/settings-browser.js";
import { resolveDefaultReceiver, type DefaultReceiver } from "../domain/default-receiver.js";
import type { QueueRepository } from "../domain/queue-repository.js";
import {
SETTINGS_VALID_KEYS,
isSettingsValidKey,
@@ -42,7 +44,12 @@ export function configRoutes(opts: { home?: string } = {}): Hono {
const gateway = c.get("gatewaySubsystem" as never) as { status(): Record<string, unknown> } | undefined;
let observed: Record<string, unknown> | null = null;
try { observed = gateway?.status() ?? null; } catch { /* configuration still browsable */ }
return c.json(settingsBrowser(store, observed, opts.home));
let receiver: DefaultReceiver | null = null;
try {
const repo = c.get("queueRepo" as never) as QueueRepository | undefined;
if (repo) receiver = resolveDefaultReceiver(repo.db);
} catch { /* the rest stays browsable; the receiver row is left out */ }
return c.json(settingsBrowser(store, observed, opts.home, receiver));
}
// OPR.0.4.4.15: dynamic-class enumeration rides ADDITIVELY beside the
// static settings map (existing consumers of `settings` unaffected).
+24 -12
View File
@@ -57,7 +57,7 @@ import { addHostEntry, updateHostEntry, removeHostEntry } from "../domain/hosts/
import { defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, type HostEntry, type SshHostEntry } 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 { KERNEL_OPERATOR_SESSION } from "../domain/policies/mode-scheduled-end.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";
import { existsSync } from "node:fs";
@@ -141,6 +141,12 @@ interface IssuedPair {
/** Why an agent-free request has no owner: this host has no kernel operator, the queue can't take rows to it, or
* OpenRig couldn't read which. */
freeReason?: "none" | "unroutable" | "unknown";
/** The receiving agent the queue couldn't take rows to (`unroutable`). */
freeSession?: string;
/** Why the chosen agent wasn't used on an agent-free request (an unusable agents.operator_session), told with the reason. */
freeNote?: string;
/** Why the owner is a receiver used instead of the chosen one (an unusable agents.operator_session). */
ownerNote?: string;
}
interface ClientPair {
@@ -180,23 +186,29 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
return c.get("queueRepo" as never) as QueueRepository;
}
/** Who reads a person's answer to a pairing request here: the kernel operator, when this host has one (a session, in any
* state) and the queue takes rows to it; else nobody, and the request is agent-free, with the reason. A stopped
* operator still owns it: the person's answer waits in its queue, and the person is told it isn't running. An operator
/** Who reads a person's answer to a pairing request here: the host's receiving agent (the chosen one, else the kernel
* operator), when the queue takes rows to it; else nobody, and the request is agent-free, with the reason. A stopped
* receiver still owns it: the person's answer waits in its queue, and the person is told it isn't running. A receiver
* the queue can't take rows to can't own it: the person's answer would be refused on its way there. */
function pairOwner(c: { get: (key: string) => unknown }): Pick<IssuedPair, "owner" | "freeReason"> {
function pairOwner(c: { get: (key: string) => unknown }): Pick<IssuedPair, "owner" | "freeReason" | "freeSession" | "ownerNote" | "freeNote"> {
const repo = getRepo(c);
try {
if (!repo.db.prepare("SELECT 1 FROM sessions WHERE session_name = ? LIMIT 1").get(KERNEL_OPERATOR_SESSION)) return { owner: null, freeReason: "none" };
return repo.acceptsDestination(KERNEL_OPERATOR_SESSION) ? { owner: KERNEL_OPERATOR_SESSION } : { owner: null, freeReason: "unroutable" };
const receiver = resolveDefaultReceiver(repo.db);
const freeNote = receiver.note ? { freeNote: receiver.note } : {};
if (!receiver.session) return { owner: null, freeReason: receiver.state === "unknown" ? "unknown" : "none", ...freeNote };
return repo.acceptsDestination(receiver.session) ? { owner: receiver.session, ...(receiver.note ? { ownerNote: receiver.note } : {}) }
: { owner: null, freeReason: "unroutable", freeSession: receiver.session, ...freeNote };
} catch { return { owner: null, freeReason: "unknown" }; }
}
/** Why nobody reads answers on an agent-free request, as the person is told it. */
/** Why nobody reads answers on an agent-free request, as the person is told it. An unusable setting is named as such,
* never as unset. */
function noReader(rec: IssuedPair): string {
return rec.freeReason === "unroutable" ? `${KERNEL_OPERATOR_SESSION} can't receive answers on this host`
: rec.freeReason === "unknown" ? "OpenRig couldn't tell whether an agent reads answers on this host"
: `no agent reads answers on this host (it has no ${KERNEL_OPERATOR_SESSION})`;
const note = rec.freeNote ? `${rec.freeNote}; ` : "";
return rec.freeReason === "unroutable" ? `${note}${rec.freeSession ?? "the receiving agent"} can't receive answers on this host`
: rec.freeReason === "unknown" ? `${note}OpenRig couldn't tell whether an agent reads answers on this host`
: rec.freeNote ? `no agent reads answers on this host (${rec.freeNote}, and it has no ${KERNEL_OPERATOR_SESSION})`
: `no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no ${KERNEL_OPERATOR_SESSION})`;
}
/** Whether the owner's session is running now; null when that can't be read. */
@@ -243,7 +255,7 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
`A remote operator (${requester}) is asking to pair with this host. "${requester}" is how the requesting machine describes itself.`,
`Pairing code: ${code} — confirm it matches the code shown on the requesting side.`,
...(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.`,
`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, in a terminal on this host with its operator bearer token: rig host approve ${code}, or rig host deny ${code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`])(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.`,
] : [
@@ -0,0 +1,148 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import Database from "better-sqlite3";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { describeDefaultReceiver, KERNEL_OPERATOR_SESSION, resolveDefaultReceiver } from "../src/domain/default-receiver.js";
import { readReceivingAgentSetting } from "../src/domain/queue-owner.js";
import { effectiveInboundDestination, inboundDestinationNote, INHERIT_INBOUND_DESTINATION, LEGACY_INBOUND_DESTINATION, loadConfig, saveConfig, configPathFor } from "../src/domain/gateway/slack/config.js";
import { readFileSync, writeFileSync } from "node:fs";
import { SettingsStore } from "../src/domain/user-settings/settings-store.js";
import { settingsBrowser } from "../src/domain/user-settings/settings-browser.js";
const closes: Array<() => void> = [];
afterEach(() => { vi.unstubAllEnvs(); for (const close of closes.splice(0)) close(); });
function db(seats: Array<[string, string]> = []): Database.Database {
const home = mkdtempSync(join(tmpdir(), "default-receiver-"));
const d = new Database(join(home, "q.sqlite"));
migrate(d, ALL_MIGRATIONS);
d.prepare("INSERT INTO rigs (id, name) VALUES ('r', 'kernel')").run();
seats.forEach(([session, status], i) => {
d.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, 'r', ?)").run(`n${i}`, `pod.m${i}`);
d.prepare("INSERT INTO sessions (node_id, session_name, status) VALUES (?, ?, ?)").run(`n${i}`, session, status);
});
closes.push(() => { d.close(); rmSync(home, { recursive: true, force: true }); });
return d;
}
describe("the default receiving agent", () => {
it("is the chosen agent first, even with a kernel operator here, with its own seat's state", () => {
const d = db([[KERNEL_OPERATOR_SESSION, "running"], ["front-lead@team", "exited"]]);
expect(resolveDefaultReceiver(d, () => "front-lead@team")).toEqual({ session: "front-lead@team", source: "setting", state: "stopped", recorded: "exited" });
expect(resolveDefaultReceiver(d, () => "nobody-yet@team")).toEqual({ session: "nobody-yet@team", source: "setting", state: "missing" });
});
it("is the kernel operator when none is chosen, running or stopped: a stopped one stays the receiver", () => {
expect(resolveDefaultReceiver(db([[KERNEL_OPERATOR_SESSION, "running"]]), () => null))
.toEqual({ session: KERNEL_OPERATOR_SESSION, source: "kernel-operator", state: "running", recorded: "running" });
expect(resolveDefaultReceiver(db([[KERNEL_OPERATOR_SESSION, "exited"], ["other@team", "running"]]), () => null))
.toEqual({ session: KERNEL_OPERATOR_SESSION, source: "kernel-operator", state: "stopped", recorded: "exited" });
});
it("is none with no chosen agent and no kernel operator, and never whichever seat happens to be running", () => {
expect(resolveDefaultReceiver(db([["other@team", "running"]]), () => null)).toEqual({ session: null, source: "none", state: "missing" });
});
it("keeps idle and unknown apart from stopped, from the seat's newest recorded session", () => {
expect(resolveDefaultReceiver(db([["front-lead@team", "idle"]]), () => "front-lead@team")).toMatchObject({ state: "idle", recorded: "idle" });
expect(resolveDefaultReceiver(db([["front-lead@team", "unknown"]]), () => "front-lead@team")).toMatchObject({ state: "unknown", recorded: "unknown" });
// An older occupant's running row doesn't make a newer superseded one read running.
expect(resolveDefaultReceiver(db([["front-lead@team", "running"], ["front-lead@team", "superseded"]]), () => "front-lead@team"))
.toMatchObject({ state: "stopped", recorded: "superseded" });
});
it("reads unknown, not none, when the seats can't be read", () => {
const d = db();
vi.spyOn(d, "prepare").mockImplementation(() => { throw new Error("unreadable"); });
expect(resolveDefaultReceiver(d, () => null)).toEqual({ session: null, source: "none", state: "unknown" });
expect(resolveDefaultReceiver(d, () => "front-lead@team")).toEqual({ session: "front-lead@team", source: "setting", state: "unknown" });
});
it("takes the chosen agent from agents.operator_session; a person's address there is ignored, and the fallback says so", () => {
const d = db([[KERNEL_OPERATOR_SESSION, "running"]]);
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "front-lead@team");
expect(resolveDefaultReceiver(d)).toMatchObject({ session: "front-lead@team", source: "setting" });
expect(resolveDefaultReceiver(d).note).toBeUndefined();
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "alex@external");
const fallback = resolveDefaultReceiver(d);
expect(fallback).toMatchObject({ session: KERNEL_OPERATOR_SESSION, source: "kernel-operator",
note: 'agents.operator_session is "alex@external", which isn\'t an agent\'s session, so it\'s ignored' });
expect(describeDefaultReceiver(fallback)).toBe(`agents.operator_session is "alex@external", which isn't an agent's session, so it's ignored. ${KERNEL_OPERATOR_SESSION}, the kernel operator, used instead; its session is recorded as running.`);
});
it("an unreadable agents.operator_session is reported, never read as unset", () => {
const d = db([[KERNEL_OPERATOR_SESSION, "running"]]);
const unreadable = { session: null, problem: "agents.operator_session couldn't be read (bad file)" };
expect(resolveDefaultReceiver(d, () => unreadable)).toMatchObject({ session: KERNEL_OPERATOR_SESSION, note: unreadable.problem });
expect(resolveDefaultReceiver(db(), () => unreadable)).toEqual({ session: null, source: "none", state: "missing", note: unreadable.problem });
vi.spyOn(SettingsStore.prototype, "resolveOne").mockImplementation(() => { throw new Error("config unreadable"); });
try {
expect(readReceivingAgentSetting()).toEqual({ session: null, problem: "agents.operator_session couldn't be read (config unreadable)" });
} finally { vi.restoreAllMocks(); }
});
it("is described with where it came from and what its state means for an answer", () => {
expect(describeDefaultReceiver({ session: "front-lead@team", source: "setting", state: "stopped", recorded: "exited" }))
.toBe("front-lead@team, chosen in agents.operator_session; its session is recorded as exited: answers wait for it, and nothing is decided meanwhile.");
// What's recorded, never a claim that it's reading now.
expect(describeDefaultReceiver({ session: KERNEL_OPERATOR_SESSION, source: "kernel-operator", state: "running", recorded: "running" }))
.toBe(`${KERNEL_OPERATOR_SESSION}, the kernel operator, since agents.operator_session is unset; its session is recorded as running.`);
expect(describeDefaultReceiver({ session: null, source: "none", state: "missing" }))
.toContain("No receiving agent on this host: set one with rig config set agents.operator_session <seat>");
});
});
describe("Slack's unrouted destination", () => {
it("keeps a saved destination as chosen, the kernel operator's address included, and inherits only when none is saved", () => {
expect(effectiveInboundDestination({ inboundDestination: "triage@team" }, "front-lead@team")).toEqual({ destination: "triage@team", from: "config" });
// Compatibility control: a saved operator-agent@kernel may have been a person's choice, so it isn't repointed.
expect(effectiveInboundDestination({ inboundDestination: LEGACY_INBOUND_DESTINATION }, "front-lead@team"))
.toEqual({ destination: LEGACY_INBOUND_DESTINATION, from: "config" });
expect(effectiveInboundDestination({ inboundDestination: INHERIT_INBOUND_DESTINATION }, "front-lead@team"))
.toEqual({ destination: "front-lead@team", from: "receiving-agent" });
expect(effectiveInboundDestination({ inboundDestination: INHERIT_INBOUND_DESTINATION }, null))
.toEqual({ destination: LEGACY_INBOUND_DESTINATION, from: "default" });
});
it("an inherited destination with no receiver says the kernel address is kept for compatibility; a saved one says nothing", () => {
expect(inboundDestinationNote({ destination: LEGACY_INBOUND_DESTINATION, from: "default" }, { session: null, source: "none", state: "missing" }))
.toBe(`No receiving agent on this host: set one with rig config set agents.operator_session <seat>, or start the kernel (${KERNEL_OPERATOR_SESSION}). Unrouted messages go to ${LEGACY_INBOUND_DESTINATION} for compatibility, as before.`);
expect(inboundDestinationNote({ destination: LEGACY_INBOUND_DESTINATION, from: "default" }, { session: null, source: "none", state: "unknown" }))
.toContain("OpenRig couldn't tell whether this host has a receiving agent");
expect(inboundDestinationNote({ destination: "front-lead@team", from: "receiving-agent" }, { session: "front-lead@team", source: "setting", state: "idle", recorded: "idle" }))
.toBe("front-lead@team, chosen in agents.operator_session; its session is recorded as idle.");
expect(inboundDestinationNote({ destination: LEGACY_INBOUND_DESTINATION, from: "config" }, null)).toBeUndefined();
});
it("reads a file without a destination as inherited, never writes the inherit choice, and keeps a saved one", () => {
const home = mkdtempSync(join(tmpdir(), "default-receiver-slack-"));
closes.push(() => rmSync(home, { recursive: true, force: true }));
expect(loadConfig(home).inboundDestination).toBe(INHERIT_INBOUND_DESTINATION);
saveConfig({ ...loadConfig(home), channel: "C123" }, home);
expect(JSON.parse(readFileSync(configPathFor(home), "utf8"))).not.toHaveProperty("inboundDestination");
expect(loadConfig(home).inboundDestination).toBe(INHERIT_INBOUND_DESTINATION);
writeFileSync(configPathFor(home), JSON.stringify({ ...JSON.parse(readFileSync(configPathFor(home), "utf8")), inboundDestination: LEGACY_INBOUND_DESTINATION }));
expect(loadConfig(home).inboundDestination).toBe(LEGACY_INBOUND_DESTINATION);
saveConfig(loadConfig(home), home);
expect(JSON.parse(readFileSync(configPathFor(home), "utf8")).inboundDestination).toBe(LEGACY_INBOUND_DESTINATION);
});
});
describe("the settings view", () => {
it("shows the receiving agent beside the settings, and Slack's unchosen destination points to it", () => {
const home = mkdtempSync(join(tmpdir(), "default-receiver-browser-"));
closes.push(() => rmSync(home, { recursive: true, force: true }));
const store = new SettingsStore(join(home, "config.json"));
const view = settingsBrowser(store, null, home, { session: "front-lead@team", source: "setting", state: "running", recorded: "running" });
expect(view.entries.find((entry) => entry.key === "agents.default_receiver")).toMatchObject({
group: "general", value: "front-lead@team", visibility: "shown",
application: "front-lead@team, chosen in agents.operator_session; its session is recorded as running.",
});
expect(view.entries.find((entry) => entry.key === "slack.inboundDestination")?.application)
.toBe("Not chosen: when the connector starts, unrouted messages go to the default receiving agent (Agents & runtime).");
expect(settingsBrowser(store, null, home).entries.some((entry) => entry.key === "agents.default_receiver")).toBe(false);
});
});
@@ -58,7 +58,8 @@ async function fixture({ kernel = true, operator, ownershipUnreadable = false }:
// The ownership read fails while the request is made (the sessions lookup throws).
const real = db.prepare.bind(db);
const unreadable = ownershipUnreadable ? vi.spyOn(db, "prepare").mockImplementation(((sql: string) => {
if (sql.includes("FROM sessions WHERE session_name = ? LIMIT 1")) throw new Error("sessions unreadable");
// The receiving agent's seat read, whichever query reads it.
if (sql.includes("FROM sessions WHERE session_name = ?")) throw new Error("sessions unreadable");
return real(sql);
}) as typeof db.prepare) : null;
const issued = await (await app.request("/api/hosts/pair-request", { method: "POST", headers: { "Content-Type": "application/json" },
@@ -112,7 +113,7 @@ describe("host pairing with no kernel operator running: Approve and Deny decide,
expect(item.sourceSession).toBe("host-pair@kernel");
expect(item.humanQuestions).toEqual([{ id: "pair", question: `Pair requester@far-host (code ${f.issued.code}) with this host?`, options: [
{ id: "approve", label: "Approve: hand this host's bearer token to the requester" }, { id: "deny", label: "Deny" }] }]);
expect(item.body).toContain(`Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: no agent reads answers on this host (it has no ${OPERATOR}).`);
expect(item.body).toContain(`Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no ${OPERATOR}).`);
expect(item.body).toContain(`rig host approve ${f.issued.code}, or rig host deny ${f.issued.code}`);
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
});
@@ -487,6 +488,27 @@ describe("host pairing: exact expiry, and an agent-free request says why nobody
expect(item.body).toContain(`Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: ${OPERATOR} can't receive answers on this host.`);
});
it("a receiving agent chosen in agents.operator_session owns the request, with no kernel operator here", async () => {
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "lead@fixture-rig");
try {
const f = await fixture({ kernel: false });
const item = f.repo.getById(f.ask)!;
expect(item.sourceSession).toBe("lead@fixture-rig");
expect(item.body).not.toContain("Typed words are kept but don't decide it");
} finally { vi.unstubAllEnvs(); }
});
it("with no kernel operator here, an unusable agents.operator_session is named in the request, never as unset", async () => {
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "alex@external");
try {
const f = await fixture();
const item = f.repo.getById(f.ask)!;
expect(item.sourceSession).toBe("host-pair@kernel");
expect(item.body).toContain(`you're asked again: no agent reads answers on this host (agents.operator_session is "alex@external", which isn't an agent's session, so it's ignored, and it has no ${OPERATOR}).`);
expect(item.body).not.toContain("no receiving agent is set");
} finally { vi.unstubAllEnvs(); }
});
it("when OpenRig can't read who owns the request, it's agent-free and says it couldn't tell", async () => {
const f = await fixture({ operator: "running", ownershipUnreadable: true });
const item = f.repo.getById(f.ask)!;
@@ -474,7 +474,7 @@ describe("pair-request — the target-side issuance handshake (FR-6)", () => {
// This host has no kernel operator, so the request is agent-free: Approve and Deny decide, typed words are kept and the
// person is asked again, and a terminal on this host can decide with rig host approve|deny.
expect(item.sourceSession).toBe("host-pair@kernel");
expect(item.body).toContain("Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: no agent reads answers on this host (it has no operator-agent@kernel).");
expect(item.body).toContain("Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel).");
expect(item.body).toContain(`To decide in a terminal on this host with its operator bearer token: rig host approve ${code}, or rig host deny ${code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`);
});
@@ -1,7 +1,7 @@
// A person's mode ends at the scheduled end they chose: armed with the application on the watchdog's own clock, it ends
// that application only, with the same end-if-mine path as "I'm back", and tells the kernel operator to give the account.
// Real SQLite (all migrations), real person records in a temp home, the real queue and watchdog jobs table.
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -76,7 +76,60 @@ describe("a mode's scheduled end", () => {
f.db.prepare("INSERT INTO rigs (id, name) VALUES ('r-rig', 'rig')").run(); // orch@rig is a routable seat
const app = f.modes.apply("alex", "asleep", { scheduledEnd: END }, "orch@rig").application!;
expect(await f.run(f.jobFor(app.applicationId)!.jobId, END)).toEqual({ state: "ended", notified: "orch@rig" });
expect(f.toOperator()[0]!.body).toContain(`Sent to orch@rig because ${KERNEL_OPERATOR_SESSION} isn't on this host.`);
expect(f.toOperator()[0]!.body).toContain(`Sent to orch@rig because this host has no receiving agent (no agents.operator_session, and ${KERNEL_OPERATOR_SESSION} isn't on this host).`);
});
it("a receiving agent chosen in agents.operator_session is told, ahead of the seat that applied the mode", async () => {
const f = fixture();
f.db.prepare("INSERT INTO rigs (id, name) VALUES ('r-rig', 'rig')").run();
const app = f.modes.apply("alex", "asleep", { scheduledEnd: END }, "orch@rig").application!;
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "lead@rig");
try {
expect(await f.run(f.jobFor(app.applicationId)!.jobId, END)).toEqual({ state: "ended", notified: "lead@rig" });
} finally { vi.unstubAllEnvs(); }
});
it("when the host's seats can't be read, no other recipient is chosen: the notice stays owed for the next pass", async () => {
const f = fixture();
f.db.prepare("INSERT INTO rigs (id, name) VALUES ('r-rig', 'rig')").run(); // the applier would be routable
const app = f.modes.apply("alex", "asleep", { scheduledEnd: END }, "orch@rig").application!;
const jobId = f.jobFor(app.applicationId)!.jobId;
const real = f.db.prepare.bind(f.db);
const spy = vi.spyOn(f.db, "prepare").mockImplementation(((sql: string) => {
if (sql.includes("FROM sessions WHERE session_name = ? ORDER BY rowid")) throw new Error("sessions unreadable");
return real(sql);
}) as typeof f.db.prepare);
try {
expect(await f.run(jobId, END)).toMatchObject({ state: "notice-pending", why: expect.stringContaining("OpenRig couldn't tell whether this host has a receiving agent") });
} finally { spy.mockRestore(); }
expect(f.toOperator()).toEqual([]);
expect(new WatchdogJobsRepository(f.db).getById(jobId)?.state).not.toBe("terminal");
});
it("a receiving agent used instead of an unusable setting is told why, in the notice", async () => {
const f = fixture();
f.db.prepare("INSERT INTO rigs (id, name) VALUES ('r-k', 'kernel')").run();
f.db.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES ('n-op', 'r-k', 'operator.agent')").run();
f.db.prepare("INSERT INTO sessions (id, node_id, session_name) VALUES ('s-op', 'n-op', ?)").run(KERNEL_OPERATOR_SESSION);
const app = f.modes.apply("alex", "asleep", { scheduledEnd: END }, "op").application!;
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "alex@external");
try {
expect(await f.run(f.jobFor(app.applicationId)!.jobId, END)).toEqual({ state: "ended", notified: KERNEL_OPERATOR_SESSION });
} finally { vi.unstubAllEnvs(); }
expect(f.toOperator()[0]!.body).toContain(`Sent to ${KERNEL_OPERATOR_SESSION} because agents.operator_session is "alex@external", which isn't an agent's session, so it's ignored.`);
});
it("with no kernel operator here, an unusable setting is named in the reason, never as unset", async () => {
const f = fixture();
f.db.prepare("INSERT INTO rigs (id, name) VALUES ('r-rig', 'rig')").run(); // orch@rig is a routable seat
const app = f.modes.apply("alex", "asleep", { scheduledEnd: END }, "orch@rig").application!;
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "alex@external");
try {
expect(await f.run(f.jobFor(app.applicationId)!.jobId, END)).toEqual({ state: "ended", notified: "orch@rig" });
} finally { vi.unstubAllEnvs(); }
const body = f.toOperator()[0]!.body;
expect(body).toContain(`Sent to orch@rig because this host has no receiving agent (agents.operator_session is "alex@external", which isn't an agent's session, so it's ignored, and ${KERNEL_OPERATOR_SESSION} isn't on this host).`);
expect(body).not.toContain("no agents.operator_session");
});
it("applied by a named operator outside any seat, with no kernel operator here, the person is told, as an update", async () => {
@@ -88,7 +141,7 @@ describe("a mode's scheduled end", () => {
expect(row).toMatchObject({ dest: "alex@external", intent: "update" });
expect(row.body).toBe(`Your asleep ended at its scheduled time. Its account: \`rig gateway human mode account alex ${app.applicationId}\`; ask your operator to go through it.`);
expect(new WatchdogJobsRepository(f.db).getById(jobId)).toMatchObject({ state: "terminal",
terminalReason: `ended at its scheduled time; alex@external told (${KERNEL_OPERATOR_SESSION} isn't on this host, and op, who applied it, isn't an address the queue accepts)` });
terminalReason: `ended at its scheduled time; alex@external told (this host has no receiving agent (no agents.operator_session, and ${KERNEL_OPERATOR_SESSION} isn't on this host), and op, who applied it, isn't an address the queue accepts)` });
});
it("a person registry that can't be read leaves the notice owed, not lost; once it reads again the person is told once", async () => {
@@ -114,7 +167,7 @@ describe("a mode's scheduled end", () => {
const jobId = f.jobFor(app.applicationId)!.jobId;
expect(removeHumanFragment("alex", { inflight: [] }, f.home).ok).toBe(true);
expect(await f.run(jobId, END)).toEqual({ state: "ended", notified: undefined });
const reason = `ended at its scheduled time; no notice: ${KERNEL_OPERATOR_SESSION} isn't on this host, op, who applied it, isn't an address the queue accepts, and alex isn't registered`;
const reason = `ended at its scheduled time; no notice: this host has no receiving agent (no agents.operator_session, and ${KERNEL_OPERATOR_SESSION} isn't on this host), op, who applied it, isn't an address the queue accepts, and alex isn't registered`;
expect(new WatchdogJobsRepository(f.db).getById(jobId)).toMatchObject({ state: "terminal", terminalReason: reason });
await f.run(jobId, new Date("2026-10-21T07:05:00Z")); // a second pass changes nothing
expect(new WatchdogJobsRepository(f.db).getById(jobId)).toMatchObject({ state: "terminal", terminalReason: reason });
+2 -1
View File
@@ -18,7 +18,8 @@ describe("Slice-11 config — first-class + honest unconfigured (item 5)", () =>
expect(configFileExists(home)).toBe(false);
const cfg = loadConfig(home);
expect(cfg.enabled).toBe(false);
expect(cfg.inboundDestination).toBe("operator-agent@kernel");
// Unchosen: the host's default receiving agent, resolved when the connector starts.
expect(cfg.inboundDestination).toBe("default-receiver");
expect(cfg).not.toHaveProperty("alertTag");
});
+1
View File
@@ -51,6 +51,7 @@ const LABELS: Record<string, string> = {
"workspace.projects_root": "Project folders", "workspace.specs_root": "Spec folders",
"workspace.steering_path": "Steering file", "workspace.catalog_path": "Workspace catalog",
"workspace.operator_seat_name": "Operator seat", "db.path": "Database",
"agents.default_receiver": "Default receiving agent", "agents.operator_session": "Receiving agent (chosen)",
"topology.root": "Topology root", "context.root": "Context root", "skills.root": "Skills root",
"ui.timezone": "Timezone", "workflow.exception_routing": "Exception routing",
"queue.wake_retry_interval_seconds": "Retry interval", "queue.wake_retry_cap": "Retry limit",