mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 17:30:37 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd352cf44d | ||
|
|
98b3f3cb15 |
No files matched your search
@@ -428,24 +428,22 @@ running seat only through the action in the last column.
|
||||
|
||||
### New guidance for a running seat
|
||||
|
||||
There are three ways for a seat to pick up new guidance:
|
||||
These reach a running seat without a restart, in the order they reach the agent:
|
||||
|
||||
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. The seat's
|
||||
interruption settings can hold a message; `rig send` may report `retained, not delivered`.
|
||||
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. It's in the
|
||||
conversation at the seat's next turn. The seat's reply is the only evidence that it read the file.
|
||||
2. **The refocus hook**, where the seat runs OpenRig's `openrig-core` hooks (**Refocus** in `rig seat status` shows whether
|
||||
the hook has recorded it). The hook's content comes from `OPENRIG_REFOCUS_CONTENT_REF` (a `rig context get` address),
|
||||
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. After
|
||||
compaction the hook waits for an eligible prompt. For managed Claude compaction it skips the acknowledgement boundary,
|
||||
and other prompts can arrive first and be skipped while restore is pending. Claude can also refocus after the
|
||||
conversation grows past the hook's size threshold.
|
||||
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. It
|
||||
reaches the agent at the next prompt after a compaction, and for Claude also once the conversation has grown past the
|
||||
hook's size threshold.
|
||||
3. **Its guidance file, loaded again:** for Claude at its next compaction, `/clear` or start, for Codex at its next start.
|
||||
**Guidance** in `rig seat status` shows which of OpenRig's blocks were written after the session's recorded startup finish.
|
||||
|
||||
A file pointer leaves the read to the seat, under its own permission settings. `rig walk` reads the named files in the
|
||||
calling process and sends their contents. A seat whose harness asks before reading a file outside its project (its own
|
||||
`LEARNED.md` under the topology root, a plugin's skill) waits at that prompt until someone answers it, even though a hook
|
||||
could deliver an excerpt of the same file. Finding a file, a hook picking it up, and the agent reading and using it are
|
||||
separate facts.
|
||||
A pointer or a message names a file; the seat still reads it itself, under its own permission settings. A seat whose
|
||||
harness asks before reading a file outside its project (its own `LEARNED.md` under the topology root, a plugin's skill)
|
||||
waits at that prompt until someone answers it, even though a hook could deliver an excerpt of the same file. Finding a
|
||||
file, a hook picking up, and the agent reading and using it are separate facts.
|
||||
|
||||
Per harness: Claude Code's and Codex's permission choices are in the setup reference
|
||||
([Claude Code](getting-started.md#claude-code-a-different-launch-flag),
|
||||
|
||||
@@ -218,9 +218,5 @@ an open FYI, or a closed ask is not that receipt. The rig-wide reads and their l
|
||||
and every catalog project's missions root. A row may omit `project:` or `mission:` only when its remaining
|
||||
tags identify exactly one physical slice with a resolved project and mission. Ambiguous matches count nowhere,
|
||||
even if first-wins discovery or a list filter leaves only one row visible; an explicit mismatched project or
|
||||
mission never counts. Several `slice:` or `mission:` tags on one row count only when they all name the same
|
||||
physical slice or mission (a slice's folder name and its frontmatter `id:`, say); a row that names two distinct
|
||||
slices, missions or projects counts nowhere, while `qitemCount` can include it under several tagged slices,
|
||||
subject to the existing project filter and the newest 500 rows per slice. Counts are recomputed on every list
|
||||
request. `?refresh=1|true` invalidates the separate listing cache, not a stored queue-count snapshot. This
|
||||
resolution does not change the returned slice list or filters.
|
||||
mission never counts. Counts are recomputed on every list request. `?refresh=1|true` invalidates the separate
|
||||
listing cache, not a stored queue-count snapshot. This resolution does not change the returned slice list or filters.
|
||||
@@ -76,24 +76,6 @@ 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`),
|
||||
|
||||
@@ -281,7 +281,7 @@ Examples:
|
||||
.description("List the registered people, with delivery readiness and quiet hours (rig gateway participants adds organizations, groups, policy and shared channels)")
|
||||
.option("--json", "Complete record(s) as JSON")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const { listHumans, NO_HUMAN_CONFIGURED } = await import("@openrig/daemon/gateway-human-registry");
|
||||
const { listHumans } = await import("@openrig/daemon/gateway-human-registry");
|
||||
const res = listHumans();
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
const humans = await Promise.all(res.humans.map(async (record) => {
|
||||
@@ -290,7 +290,7 @@ Examples:
|
||||
quiet: quiet.ok ? quiet.quiet : { unknown: `quiet hours could not be read from the daemon: ${quiet.reason}` } };
|
||||
}));
|
||||
if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); return; }
|
||||
if (res.humans.length === 0) { console.log(NO_HUMAN_CONFIGURED); return; }
|
||||
if (res.humans.length === 0) { console.log("no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"); return; }
|
||||
for (const h of humans) {
|
||||
const inbound = h.bindings.inboundResolvable || !h.bindings.primary ? "" : " [outbound-only]";
|
||||
const primary = h.bindings.primary ? `primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}` : "no connector: Feed and Steering only";
|
||||
|
||||
@@ -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 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("--inbound-destination <session>", "where inbound human messages land (default operator-agent@kernel)")
|
||||
.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; destinationFrom?: string; destinationNote?: string };
|
||||
inboundRouting?: { fallback: string; destination: 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,7 +268,6 @@ 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,103 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
function processExists(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === "ESRCH") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function acquire(stateFile) {
|
||||
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
||||
const base = `${stateFile}.guard`;
|
||||
const token = `${process.pid}-${randomUUID()}`;
|
||||
const until = performance.now() + 200;
|
||||
const wait = new Int32Array(new SharedArrayBuffer(4));
|
||||
let lock = base;
|
||||
do {
|
||||
try {
|
||||
fs.symlinkSync(token, lock);
|
||||
return () => {
|
||||
try {
|
||||
if (fs.readlinkSync(lock) === token) fs.unlinkSync(lock);
|
||||
} catch {}
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
}
|
||||
let owner;
|
||||
try {
|
||||
owner = fs.readlinkSync(lock);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") continue;
|
||||
throw error;
|
||||
}
|
||||
const match = /^([1-9][0-9]*)-([0-9a-f-]{36})$/.exec(owner);
|
||||
if (!match) throw new Error("unrecognized state guard owner");
|
||||
if (!processExists(Number(match[1]))) {
|
||||
// The owner might have released the path before exiting. Recheck its
|
||||
// token after proving death; that same owner can no longer remove it.
|
||||
try {
|
||||
if (fs.readlinkSync(lock) === owner) lock = `${base}.${match[2]}`;
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
// Keep the dead link. Contenders follow it to one shared successor
|
||||
// without ever removing a guard another process might have acquired.
|
||||
continue;
|
||||
}
|
||||
Atomics.wait(wait, 0, 0, 10);
|
||||
} while (performance.now() < until);
|
||||
throw new Error("state guard still busy");
|
||||
}
|
||||
|
||||
function openRefocusState(stateFile) {
|
||||
const report = (operation, error) =>
|
||||
process.stderr.write(`refocus: could not ${operation} (${error.code || error.message})\n`);
|
||||
let release;
|
||||
try {
|
||||
release = acquire(stateFile);
|
||||
} catch (error) {
|
||||
report("coordinate state. Continuing with available evidence", error);
|
||||
}
|
||||
function read(file) {
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (state && typeof state === "object" && !Array.isArray(state)) return state;
|
||||
throw new Error("expected a state object");
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") report("read state", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function write(file, state) {
|
||||
const tmp = `${file}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(tmp, JSON.stringify(state), { flag: "wx", mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
return true;
|
||||
} catch (error) {
|
||||
try { fs.unlinkSync(tmp); } catch {}
|
||||
report("record state", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exclusive: Boolean(release),
|
||||
close: release || (() => {}),
|
||||
read: () => read(stateFile),
|
||||
write: (state) => write(stateFile, state),
|
||||
readDeferred: (kind) => read(`${stateFile}.${kind}-pending`),
|
||||
writeDeferred: (kind, pending) => write(`${stateFile}.${kind}-pending`, pending),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { openRefocusState, processExists };
|
||||
@@ -12,6 +12,7 @@ const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const { recordRefocusResult } = require("./refocus-health.cjs");
|
||||
const { openRefocusState, processExists } = require("./refocus-state.cjs");
|
||||
|
||||
// Keep the operator's command lookup order; env-node CLI scripts can also use this hook's Node.
|
||||
const childEnv = { ...process.env, PATH: [process.env.PATH, path.dirname(process.execPath)].filter(Boolean).join(path.delimiter) };
|
||||
@@ -45,6 +46,18 @@ const lookupTimeout = (reserve = 0) => SELECTED_BUDGET ? Math.min(LOOKUP_CAP_MS,
|
||||
// Normal managed restore advances on ~30 s polls with a 10 s idle wait.
|
||||
// A lost daemon stage must not suppress this occupant's refocus indefinitely.
|
||||
const MANAGED_RESTORE_HOLD_MS = 10 * 60_000;
|
||||
const DELIVERY_CLAIM_MS = 60_000;
|
||||
|
||||
function liveDelivery(claim) {
|
||||
if (!claim) return false;
|
||||
const age = Date.now() - claim.at;
|
||||
try {
|
||||
if (Number.isSafeInteger(claim.pid) && claim.pid > 0
|
||||
&& age >= 0 && age < DELIVERY_CLAIM_MS && processExists(claim.pid)) return true;
|
||||
} catch {}
|
||||
process.stderr.write("refocus: prior delivery owner could not be confirmed. Attempting the pending refocus\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
function runtime() {
|
||||
const index = process.argv.indexOf("--runtime");
|
||||
@@ -373,8 +386,6 @@ function runsForThisSeatsProject(env = process.env) {
|
||||
const seat = process.env.OPENRIG_SESSION_NAME || "unknown-seat";
|
||||
const home = process.env.OPENRIG_HOME || path.join(process.env.HOME || "/tmp", ".openrig");
|
||||
const transcriptPath = input.transcript_path || input.transcriptPath || "";
|
||||
let size = 0;
|
||||
try { size = fs.statSync(transcriptPath).size; } catch {}
|
||||
|
||||
const stateDir = path.join(home, "refocus");
|
||||
|
||||
@@ -434,80 +445,139 @@ function runsForThisSeatsProject(env = process.env) {
|
||||
} catch {}
|
||||
|
||||
const stateFile = path.join(stateDir, `${seatKey}__${keyFor(identity)}.json`);
|
||||
let state = null;
|
||||
try { state = JSON.parse(fs.readFileSync(stateFile, "utf8")) || null; } catch {}
|
||||
const persist = () => {
|
||||
try {
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.writeFileSync(stateFile, JSON.stringify(state));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
if (state === null) {
|
||||
// First observation for this occupant: the baseline is its OWN current size —
|
||||
// growth accumulates from here; nothing is inherited. A zero-size read means
|
||||
// the transcript is absent/unreadable, which is instrument absence, not a
|
||||
// baseline: record zero only when that is what was genuinely measured.
|
||||
state = { lastBytes: size, baselineAt: new Date().toISOString() };
|
||||
persist();
|
||||
} else if (size > 0 && size < Number(state.lastBytes || 0)) {
|
||||
// Shrink invalidates growth-based due state, not an exact PostCompact event
|
||||
// awaiting its actionable restore turn.
|
||||
if (state.pendingOn !== "PostCompact") {
|
||||
delete state.pendingOn;
|
||||
delete state.pendingAt;
|
||||
delete state.pendingFailure;
|
||||
}
|
||||
state.lastReset = { at: new Date().toISOString(), fromBytes: Number(state.lastBytes || 0), toBytes: size };
|
||||
state.lastBytes = size;
|
||||
persist();
|
||||
process.stderr.write(`refocus: transcript shrank for ${seat} — baseline reset; PostCompact pending retained if present\n`);
|
||||
}
|
||||
|
||||
const lastBytes = Number(state.lastBytes || 0);
|
||||
const grown = size > lastBytes ? size - lastBytes : 0;
|
||||
// Only the two state transitions need exclusive access. Trace rendering and
|
||||
// stdout run outside the guard so an arriving PostCompact can record its work.
|
||||
const start = openRefocusState(stateFile);
|
||||
const promptId = firstString(input.prompt_id);
|
||||
const onDemand = enabled(process.env.OPENRIG_REFOCUS_NOW, false);
|
||||
const thresholdDue = harness === "claude" && grown >= threshold();
|
||||
const due = onDemand
|
||||
|| event === "PostCompact"
|
||||
|| Boolean(state.pendingOn)
|
||||
|| thresholdDue;
|
||||
if (!due) process.exit(0);
|
||||
|
||||
if (event !== "UserPromptSubmit") {
|
||||
if (event === "PostCompact" && harness === "claude") {
|
||||
// PreCompact records whether THIS compact was initiated by the enforcer.
|
||||
// A manual /compact overwrites the marker with false; other occupants
|
||||
// cannot hold this session. Read the early sentinel first if it survives.
|
||||
state.managedRestorePending = false;
|
||||
for (const suffix of [".expected.json", ".json"]) {
|
||||
try {
|
||||
const marker = JSON.parse(fs.readFileSync(path.join(home, "compaction", "restore-pending", seatKey + suffix), "utf8"));
|
||||
if (marker.sessionName !== seat || (marker.sessionId ? marker.sessionId !== identity : !transcriptPath || marker.transcriptPath !== transcriptPath)) continue;
|
||||
state.managedRestorePending = marker.managedRefocusPending === true;
|
||||
break;
|
||||
} catch {}
|
||||
let state, size = 0, grown, claimed;
|
||||
const claim = { id: crypto.randomUUID(), pid: process.pid, at: Date.now(), promptId };
|
||||
try {
|
||||
state = start.read();
|
||||
try { size = fs.statSync(transcriptPath).size; } catch {}
|
||||
if (state === null) {
|
||||
// First observation for this occupant: the baseline is its OWN current size —
|
||||
// growth accumulates from here; nothing is inherited. A zero-size read means
|
||||
// the transcript is absent/unreadable, which is instrument absence, not a
|
||||
// baseline: record zero only when that is what was genuinely measured.
|
||||
state = { lastBytes: size, baselineAt: new Date().toISOString() };
|
||||
if (start.exclusive) start.write(state);
|
||||
} else if (size > 0 && size < Number(state.lastBytes || 0)) {
|
||||
// Shrink invalidates growth-based due state, not an exact PostCompact event
|
||||
// awaiting its actionable restore turn.
|
||||
if (state.pendingOn !== "PostCompact") {
|
||||
delete state.pendingOn;
|
||||
delete state.pendingAt;
|
||||
delete state.pendingFailure;
|
||||
delete state.pendingRevision;
|
||||
}
|
||||
if (state.managedRestorePending) state.managedRestorePendingAt = new Date().toISOString();
|
||||
else delete state.managedRestorePendingAt;
|
||||
state.lastReset = { at: new Date().toISOString(), fromBytes: Number(state.lastBytes || 0), toBytes: size };
|
||||
state.lastBytes = size;
|
||||
if (start.exclusive) start.write(state);
|
||||
process.stderr.write(`refocus: transcript shrank for ${seat} — baseline reset; PostCompact pending retained if present\n`);
|
||||
}
|
||||
// A new compaction is a fresh obligation, not a retry of the refocus before it.
|
||||
if (event === "PostCompact") delete state.pendingFailure;
|
||||
if (event === "PostCompact" || !state.pendingOn) state.pendingOn = event;
|
||||
state.pendingAt ||= new Date().toISOString();
|
||||
persist();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (harness === "claude" && state.managedRestorePending) {
|
||||
const heldFor = Date.now() - Date.parse(state.managedRestorePendingAt);
|
||||
// Missing/invalid timestamps from older state, or a clock moving backwards,
|
||||
// cannot establish a live hold. No timer or extra turn is created here.
|
||||
if (heldFor >= 0 && heldFor < MANAGED_RESTORE_HOLD_MS
|
||||
&& !prompt.startsWith("Please respond to this normal user message now by restoring this Claude session after compaction.")) process.exit(0);
|
||||
delete state.managedRestorePending;
|
||||
delete state.managedRestorePendingAt;
|
||||
persist();
|
||||
// An event that could not take the guard records its pending work separately.
|
||||
// The delivery callback never writes these slots.
|
||||
for (const kind of ["other", "compact"]) {
|
||||
const deferred = start.readDeferred(kind);
|
||||
if (!deferred?.pendingRevision || state.seenDeferred?.[kind] === deferred.pendingRevision) continue;
|
||||
state.pendingRevision = deferred.pendingRevision;
|
||||
if (kind === "compact" || !state.pendingOn) {
|
||||
state.pendingOn = deferred.pendingOn;
|
||||
state.pendingAt = deferred.pendingAt;
|
||||
}
|
||||
if (kind === "compact") {
|
||||
delete state.pendingFailure;
|
||||
state.managedRestorePending = deferred.managedRestorePending;
|
||||
state.managedRestorePendingAt = deferred.managedRestorePendingAt;
|
||||
}
|
||||
state.seenDeferred = { ...state.seenDeferred, [kind]: deferred.pendingRevision };
|
||||
if (start.exclusive) start.write(state);
|
||||
}
|
||||
|
||||
const lastBytes = Number(state.lastBytes || 0);
|
||||
grown = size > lastBytes ? size - lastBytes : 0;
|
||||
const growthThreshold = threshold();
|
||||
const thresholdDue = harness === "claude" && grown >= growthThreshold;
|
||||
const due = onDemand
|
||||
|| event === "PostCompact"
|
||||
|| Boolean(state.pendingOn)
|
||||
|| thresholdDue;
|
||||
if (start.exclusive && event === "UserPromptSubmit" && promptId && state.lastPromptId === promptId) {
|
||||
process.stderr.write("refocus: another handler already wrote refocus output for this prompt\n");
|
||||
return;
|
||||
}
|
||||
if (!due) return;
|
||||
|
||||
if (event !== "UserPromptSubmit") {
|
||||
if (event === "PostCompact" && harness === "claude") {
|
||||
// PreCompact records whether THIS compact was initiated by the enforcer.
|
||||
// A manual /compact overwrites the marker with false; other occupants
|
||||
// cannot hold this session. Read the early sentinel first if it survives.
|
||||
state.managedRestorePending = false;
|
||||
for (const suffix of [".expected.json", ".json"]) {
|
||||
try {
|
||||
const marker = JSON.parse(fs.readFileSync(path.join(home, "compaction", "restore-pending", seatKey + suffix), "utf8"));
|
||||
if (marker.sessionName !== seat || (marker.sessionId ? marker.sessionId !== identity : !transcriptPath || marker.transcriptPath !== transcriptPath)) continue;
|
||||
state.managedRestorePending = marker.managedRefocusPending === true;
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
if (state.managedRestorePending) state.managedRestorePendingAt = new Date().toISOString();
|
||||
else delete state.managedRestorePendingAt;
|
||||
}
|
||||
// A new compaction is a fresh obligation, not a retry of the refocus before it.
|
||||
const compact = event === "PostCompact";
|
||||
if (compact) delete state.pendingFailure;
|
||||
const newWork = compact || !state.pendingOn || onDemand
|
||||
|| (thresholdDue && size - (state.deliveryClaim?.bytes ?? lastBytes) >= growthThreshold);
|
||||
if (newWork) state.pendingRevision = crypto.randomUUID();
|
||||
if (compact || !state.pendingOn) state.pendingOn = event;
|
||||
state.pendingAt ||= new Date().toISOString();
|
||||
if ((!start.exclusive || !start.write(state)) && newWork) {
|
||||
start.writeDeferred(compact ? "compact" : "other", {
|
||||
pendingOn: event,
|
||||
pendingRevision: state.pendingRevision,
|
||||
pendingAt: state.pendingAt,
|
||||
...(compact ? {
|
||||
managedRestorePending: state.managedRestorePending,
|
||||
managedRestorePendingAt: state.managedRestorePendingAt,
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (start.exclusive && liveDelivery(state.deliveryClaim)) {
|
||||
if (onDemand && promptId && state.deliveryClaim.promptId && promptId !== state.deliveryClaim.promptId) {
|
||||
const pending = {
|
||||
pendingOn: state.pendingOn || event,
|
||||
pendingAt: state.pendingAt || new Date().toISOString(),
|
||||
pendingRevision: crypto.randomUUID(),
|
||||
};
|
||||
Object.assign(state, pending);
|
||||
if (!start.write(state)) start.writeDeferred("other", pending);
|
||||
}
|
||||
process.stderr.write("refocus: another hook is delivering. Any later pending work stays queued\n");
|
||||
return;
|
||||
}
|
||||
if (harness === "claude" && state.managedRestorePending) {
|
||||
const heldFor = Date.now() - Date.parse(state.managedRestorePendingAt);
|
||||
// Missing/invalid timestamps from older state, or a clock moving backwards,
|
||||
// cannot establish a live hold. No timer or extra turn is created here.
|
||||
if (heldFor >= 0 && heldFor < MANAGED_RESTORE_HOLD_MS
|
||||
&& !prompt.startsWith("Please respond to this normal user message now by restoring this Claude session after compaction.")) return;
|
||||
delete state.managedRestorePending;
|
||||
delete state.managedRestorePendingAt;
|
||||
}
|
||||
|
||||
state.pendingOn ||= event;
|
||||
state.pendingAt ||= new Date().toISOString();
|
||||
state.deliveryClaim = { ...claim, bytes: size };
|
||||
claimed = start.exclusive && start.write(state);
|
||||
} finally {
|
||||
start.close();
|
||||
}
|
||||
|
||||
// Run the public trace before resolving a context ref. Besides keeping the
|
||||
@@ -571,22 +641,36 @@ function runsForThisSeatsProject(env = process.env) {
|
||||
// their fixed caps, so a default attempt can run past its 4.5 s budget).
|
||||
recordRefocusResult({ home, seat, identity, failed: failed || Boolean(error), env: childEnv,
|
||||
deadlineMs: Math.max(BUDGET_MS, Math.floor(process.uptime() * 1_000)) + REPORT_MS });
|
||||
if (failed || error) {
|
||||
state.pendingOn ||= event;
|
||||
state.pendingAt ||= new Date().toISOString();
|
||||
const failure = rendered.failure
|
||||
|| (configured.failure ? `content ref ${configured.contentRef}: ${configured.failure}` : null)
|
||||
|| `the refocus could not be written: ${String(error?.message ?? error).replace(/\s+/g, " ").trim()}`;
|
||||
state.pendingFailure = failure.length > PENDING_FAILURE_MAX ? `${failure.slice(0, PENDING_FAILURE_MAX - 1)}…` : failure;
|
||||
persist();
|
||||
return;
|
||||
if (!claimed) return;
|
||||
const finish = openRefocusState(stateFile);
|
||||
try {
|
||||
if (!finish.exclusive) return;
|
||||
const latest = finish.read();
|
||||
if (latest?.deliveryClaim?.id !== claim.id) return;
|
||||
delete latest.deliveryClaim;
|
||||
if (!error && promptId) latest.lastPromptId = promptId;
|
||||
const samePending = latest.pendingRevision === state.pendingRevision;
|
||||
if (failed || error) {
|
||||
if (samePending) {
|
||||
const failure = rendered.failure
|
||||
|| (configured.failure ? `content ref ${configured.contentRef}: ${configured.failure}` : null)
|
||||
|| `the refocus could not be written: ${String(error?.message ?? error).replace(/\s+/g, " ").trim()}`;
|
||||
latest.pendingFailure = failure.length > PENDING_FAILURE_MAX ? `${failure.slice(0, PENDING_FAILURE_MAX - 1)}…` : failure;
|
||||
}
|
||||
} else {
|
||||
if (size > 0 && latest.lastReset?.at === state.lastReset?.at) latest.lastBytes = size;
|
||||
latest.firedAt = new Date().toISOString();
|
||||
latest.firedOn = event;
|
||||
if (samePending) {
|
||||
delete latest.pendingOn;
|
||||
delete latest.pendingAt;
|
||||
delete latest.pendingFailure;
|
||||
delete latest.pendingRevision;
|
||||
}
|
||||
}
|
||||
finish.write(latest);
|
||||
} finally {
|
||||
finish.close();
|
||||
}
|
||||
if (size > 0) state.lastBytes = size;
|
||||
state.firedAt = new Date().toISOString();
|
||||
state.firedOn = event;
|
||||
delete state.pendingOn;
|
||||
delete state.pendingAt;
|
||||
delete state.pendingFailure;
|
||||
persist();
|
||||
});
|
||||
})();
|
||||
@@ -145,21 +145,6 @@ person needs in their project or home folder and name that path.
|
||||
seat sees that name, recorded as declared, and can reply to them if they're registered (`rig gateway human list`).
|
||||
A declared name is a claim, not a seat's own identity: for a team that acts only on seat identity, the attached
|
||||
shell above is the route.
|
||||
- **Who answers OpenRig's questions:** read `rig gateway participants` (its `default:` line) before the handoff.
|
||||
- A default person is there: say requests and asks go to them, and don't offer again.
|
||||
- Several people with no default: ask which should get requests nobody is named for (`rig gateway default <id>`),
|
||||
or leave it. Never pick one yourself.
|
||||
- Nobody is registered: offer it in a sentence or two. OpenRig's questions, such as another machine asking to pair,
|
||||
need a person to reach. Ask the name to use (their login name is only a suggestion). On yes, run
|
||||
`rig gateway human add <id> --display-name "<name>" --delivery-class B --default`: they're reached in the TUI's Feed
|
||||
and Steering, and Slack can be added later. On no or not now, register nothing and say that questions to a person
|
||||
can't reach anyone until someone is registered, with that command for later.
|
||||
- Never pass `--default` over an existing default, or `--replace`, unless they ask. Their own words decide: a
|
||||
different name, Slack now, or not now.
|
||||
- An agent reads their answers: the kernel operator, or the agent chosen in `agents.operator_session`. If the kernel
|
||||
isn't running here and no agent is chosen, offer to choose the team's lead (`rig config set agents.operator_session
|
||||
<seat>@<rig>`), on their yes only.
|
||||
- A machine they'll pair with needs its own person: registering here doesn't give that machine one.
|
||||
- **Give the operator the goal:** ask what they want worked on, in which repository and on which branch, unless they've
|
||||
said. Find the `operator.agent` row in `rig ps --nodes --rig kernel --json`, take its `canonicalSessionName`
|
||||
(never the logical ID or a guessed name) and send:
|
||||
|
||||
@@ -87,19 +87,6 @@ After install, the person usually talks to you first. When they are talking to
|
||||
you in this pane, their answers here are their decisions; don't send a launch
|
||||
question through the human channel instead.
|
||||
|
||||
If no person is registered yet (`rig gateway participants` shows `default: none`
|
||||
and no people), offer to register the person you're talking to, as an installer
|
||||
would: on their yes, `rig gateway human add <id> --display-name "<name>"
|
||||
--delivery-class B --default`; on a no, register nothing and say that questions
|
||||
to a person can't reach anyone until someone is. Keep an existing default, and
|
||||
never pick one of several people yourself.
|
||||
|
||||
Unless `agents.operator_session` names another agent, you're this host's
|
||||
receiving agent: what a person answers on a request nobody else owns, such as
|
||||
another machine asking to pair, comes to you. Act on what they meant through
|
||||
that request's own command (for pairing, `rig host approve <code>` or
|
||||
`rig host deny <code>`), and ask back only when it isn't clear.
|
||||
|
||||
An installing Codex agent may hand the person to this conversation because
|
||||
they declined remembered command approvals. Keep that choice: don't offer it
|
||||
again or change their permissions. Take the supplied goal and constraints,
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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}.`;
|
||||
}
|
||||
@@ -998,7 +998,7 @@ export function pendingConversationsFor(entityId: string, home: string = getOpen
|
||||
|
||||
/** The refusal when nobody is registered yet; the wording `rig gateway human list` uses for an empty registry. */
|
||||
export const NO_HUMAN_CONFIGURED =
|
||||
"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)";
|
||||
"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …";
|
||||
|
||||
export type LoadResult =
|
||||
| { ok: true; entities: HumanFragment[] }
|
||||
|
||||
@@ -13,7 +13,6 @@ 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;
|
||||
@@ -66,33 +65,9 @@ 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: INHERIT_INBOUND_DESTINATION,
|
||||
inboundDestination: "operator-agent@kernel",
|
||||
outboundDestinations: [],
|
||||
sourceLabel: "openrig",
|
||||
channel: null,
|
||||
@@ -143,9 +118,7 @@ export function saveConfig(cfg: SlackConnectorConfig, home?: string): string {
|
||||
validateConfig(cfg);
|
||||
const p = configPathFor(home);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
// 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");
|
||||
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -173,8 +146,7 @@ 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 === INHERIT_INBOUND_DESTINATION ? `${INHERIT_INBOUND_DESTINATION} (the host's default receiving agent)` : cfg.inboundDestination },
|
||||
{ ok: Boolean(cfg.inboundDestination), label: "inbound-destination", detail: cfg.inboundDestination },
|
||||
{ ok: cfg.enabled, label: "enabled", detail: cfg.enabled ? "yes" : "no (run `rig slack enable`)" },
|
||||
...channelMapReadiness(cfg),
|
||||
...botInboxReadiness(cfg),
|
||||
|
||||
@@ -21,8 +21,7 @@ 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 { effectiveInboundDestination, inboundDestinationNote, loadConfig } from "./config.js";
|
||||
import { resolveDefaultReceiver } from "../../default-receiver.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { resolveSecret } from "./secrets.js";
|
||||
import { SeenStore, DeadLetterStore, InboundReceiptStore, slackMicros } from "./state-store.js";
|
||||
import { makeQueuePorts } from "./queue-access.js";
|
||||
@@ -311,12 +310,7 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
|
||||
const outboundReady = cfg.enabled && bot !== null && cfg.channel !== null;
|
||||
const inboundReady = cfg.enabled && app !== null;
|
||||
// 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 inboundRouting = { fallback: "local", destination: cfg.inboundDestination };
|
||||
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.
|
||||
@@ -1365,14 +1359,14 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
queue: ports,
|
||||
seen: inboundSeen,
|
||||
deadLetter: dead,
|
||||
destination: inbound.destination,
|
||||
destination: cfg.inboundDestination,
|
||||
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: inbound.destination,
|
||||
unroutedDestination: cfg.inboundDestination,
|
||||
// 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,11 +8,10 @@ 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 { KERNEL_OPERATOR_SESSION };
|
||||
export const KERNEL_OPERATOR_SESSION = "operator-agent@kernel";
|
||||
|
||||
const applicationSpec = (applicationId: string) => `applicationId: "${applicationId}"`;
|
||||
|
||||
@@ -38,8 +37,6 @@ 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. */
|
||||
@@ -101,27 +98,20 @@ 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 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. */
|
||||
/** 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. */
|
||||
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 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 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 };
|
||||
}
|
||||
// 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 noOperator = `${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 };
|
||||
|
||||
@@ -20,22 +20,13 @@ 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;
|
||||
session = typeof v === "string" ? v.trim() : "";
|
||||
} catch (error) {
|
||||
return { session: null, problem: `agents.operator_session couldn't be read (${(error as Error).message})` };
|
||||
const session = typeof v === "string" ? v.trim() : "";
|
||||
return session && parseSessionName(session).kind === "canonical" && !isHumanSeatSessionRef(session) ? session : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
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
|
||||
|
||||
@@ -2466,7 +2466,7 @@ export class QueueRepository {
|
||||
throw new QueueRepositoryError("invalid_human_notification", "humanAlert marks an ask to a person as alert-worthy: it needs a human destination.");
|
||||
}
|
||||
if (humanIntent === "update") {
|
||||
throw new QueueRepositoryError("invalid_human_notification", "humanAlert marks an ask to a person as alert-worthy, and an update isn't an ask: send it without --alert; updates are notices; delivery depends on the person's connector and settings.");
|
||||
throw new QueueRepositoryError("invalid_human_notification", "humanAlert marks an ask to a person as alert-worthy, and an update isn't an ask: send it without --alert; updates are notices and follow Slack's posting floor and delivery settings.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// The seat boundary. A seat's kind comes from its binding's `attachment_type`, the key the seat-kinds facade in #1429
|
||||
// proposed (by rlch, credited). This first boundary covers today's kinds only: tmux, which is also the column's default
|
||||
// and what a legacy row with no value means, and external_cli. Each seat's three responsibilities stay where they are:
|
||||
// the runtime that keeps the process alive (RuntimeAdapter), the session that carries messages and turns to the agent
|
||||
// (SessionTransport, which asks this resolver), and the view a person watches and types into (TerminalProvider).
|
||||
|
||||
/** Today's seat kinds. */
|
||||
export type SeatKind = "tmux" | "external_cli";
|
||||
|
||||
/** A binding's kind as stored. `unknown` names a stored value that isn't one of today's kinds, and is never read as tmux. */
|
||||
export type ResolvedSeatKind = { kind: SeatKind } | { kind: "unknown"; stored: string };
|
||||
|
||||
export function resolveSeatKind(stored: string | null | undefined): ResolvedSeatKind {
|
||||
if (stored === null || stored === undefined || stored === "tmux") return { kind: "tmux" };
|
||||
if (stored === "external_cli") return { kind: "external_cli" };
|
||||
return { kind: "unknown", stored };
|
||||
}
|
||||
|
||||
/** How OpenRig's inbound transport (send, capture and the activity probe) reaches a seat of this kind. An external CLI
|
||||
* seat runs inside someone else's program, so OpenRig can't type into it or read it: `none`. An unknown stored kind
|
||||
* keeps the path it takes today, the tmux one, so nothing changes for it here; whether it should is an open question
|
||||
* for its owner, not something this boundary settles. */
|
||||
export function seatInboundTransport(seat: ResolvedSeatKind): "tmux" | "none" {
|
||||
return seat.kind === "external_cli" ? "none" : "tmux";
|
||||
}
|
||||
@@ -51,7 +51,6 @@ import type { SlowOperationInstrumentation } from "./slow-op-recorder.js";
|
||||
import { hashSentText, type CaptureObserverSink, type CaptureSlot, type ObservationInput, type ObservedBinding } from "./capture-observer.js";
|
||||
import { isAttentionRequiredReadinessCode, type NodeBinding, type ReadinessOptions, type ReadinessResult } from "./runtime-adapter.js";
|
||||
import { getLatestStartupFailedEvent, hasClaudeResumeIdentityWork, isRecoverableStartupFailure } from "./startup-orchestrator.js";
|
||||
import { resolveSeatKind, seatInboundTransport } from "./seat-kinds.js";
|
||||
|
||||
// OPR.0.4.1.10 — send-readiness freshness. The runtime-hook store keeps a 5min freshness for activity
|
||||
// DISPLAY, but "safe to send NOW" needs a tight window: a stale "idle" read must not authorize a send
|
||||
@@ -449,18 +448,14 @@ function closesFramedPromptAbove(lines: string[], open: number, indent: number,
|
||||
const codexFooter = (line: string): boolean => isComposerFooter(line)
|
||||
|| /(?:\b\d+% context left\b|\bContext \[)/.test(line);
|
||||
|
||||
/** Preserve the styling used for autocomplete ghosts and the drawn cursor.
|
||||
* Color channel values must never be interpreted as style codes. */
|
||||
/** Preserve faint styling so autocomplete ghosts can be separated from authored
|
||||
* text. Color channel values must never be interpreted as style codes. */
|
||||
function styledTextLines(screen: string) {
|
||||
let faint = false, inverse = false;
|
||||
let faint = false;
|
||||
return screen.replace(/\r\n/g, "\n").split("\n").map(raw => {
|
||||
let text = "", offset = 0;
|
||||
const faintCells: boolean[] = [], inverseCells: boolean[] = [];
|
||||
const append = (value: string) => {
|
||||
text += value;
|
||||
faintCells.push(...Array<boolean>(value.length).fill(faint));
|
||||
inverseCells.push(...Array<boolean>(value.length).fill(inverse));
|
||||
};
|
||||
const faintCells: boolean[] = [];
|
||||
const append = (value: string) => { text += value; faintCells.push(...Array<boolean>(value.length).fill(faint)); };
|
||||
for (const match of raw.matchAll(/\x1b\[([0-9;:]*)m/g)) {
|
||||
append(raw.slice(offset, match.index));
|
||||
const codes = match[1]!.split(";");
|
||||
@@ -471,18 +466,15 @@ function styledTextLines(screen: string) {
|
||||
const skip = codes[i + 1] === "5" ? 2 : codes[i + 1] === "2" ? (codes[i + 2] === "" ? 5 : 4) : 0;
|
||||
// Color channels are not style codes. Ambiguous compound sequences
|
||||
// provide no faint-style proof, rather than interpreting an RGB 2 as dim.
|
||||
if (!skip || i + skip !== codes.length - 1) { faint = inverse = false; break; }
|
||||
if (!skip || i + skip !== codes.length - 1) { faint = false; break; }
|
||||
i += skip;
|
||||
} else if (code === "" || code === "0") faint = inverse = false;
|
||||
else if (code === "22") faint = false;
|
||||
} else if (code === "" || code === "0" || code === "22") faint = false;
|
||||
else if (code === "2") faint = true;
|
||||
else if (code === "7") inverse = true;
|
||||
else if (code === "27") inverse = false;
|
||||
}
|
||||
offset = match.index! + match[0].length;
|
||||
}
|
||||
append(raw.slice(offset));
|
||||
return { text, faintCells, inverseCells };
|
||||
return { text, faintCells };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -577,11 +569,7 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
|
||||
else if (cursorOffset >= 0 && !rows[cursorRow]!.slice(cursorOffset).trim()) visible[cursorRow] = rows[cursorRow]!.slice(0, cursorOffset);
|
||||
}
|
||||
const atStart = y === start && x === prefix;
|
||||
// Claude can draw an inverse-video blank in the cursor cell (#2925). Only
|
||||
// that single cell at the start is padding; ordinary/preceding spaces stay input.
|
||||
const inverseCells = styled[start]!.inverseCells;
|
||||
const cursorBlank = prompt.glyph === "❯" && rows[0] === " " && inverseCells[prefix] && !inverseCells[prefix - 1];
|
||||
const empty = atStart && rows.length === 1 && (rows[0] === "" || cursorBlank);
|
||||
const empty = atStart && rows.length === 1 && rows[0] === "";
|
||||
const last = visible.length - 1;
|
||||
const cursorAtEnd = y === start + last && x === prefix + stringWidth(visible[last] ?? "");
|
||||
const label = visible.length === 1 && COMPOSER_COLLAPSED_PASTE_PATTERN.test(visible[0]!) ? visible[0]! : null;
|
||||
@@ -981,7 +969,7 @@ export async function probeSessionActivity(input: {
|
||||
};
|
||||
}
|
||||
const readTarget = binding?.pane ?? sessionName;
|
||||
if (seatInboundTransport(resolveSeatKind(attachmentType)) === "none") {
|
||||
if (attachmentType === "external_cli") {
|
||||
return {
|
||||
state: "unknown",
|
||||
reason: "unsupported_attachment",
|
||||
@@ -2581,7 +2569,7 @@ export class SessionTransport {
|
||||
const binding = this.sessionRegistry.getBindingForNode(node.id);
|
||||
const latestSession = latestByNode.get(node.id);
|
||||
|
||||
if (resolveSeatKind(binding?.attachmentType).kind === "external_cli" && binding?.externalSessionName) {
|
||||
if (binding?.attachmentType === "external_cli" && binding.externalSessionName) {
|
||||
targets.push({
|
||||
sessionName: binding.externalSessionName,
|
||||
rigName,
|
||||
@@ -2912,7 +2900,7 @@ export class SessionTransport {
|
||||
// External CLI seats never wait: main refuses them before anything else,
|
||||
// and a probe or wait would only delay that refusal (or mistake it for
|
||||
// an absent session).
|
||||
if (seatInboundTransport(resolveSeatKind(meta.attachmentType)) === "none") {
|
||||
if (meta.attachmentType === "external_cli") {
|
||||
return { ok: false, failure: {
|
||||
ok: false,
|
||||
sessionName,
|
||||
@@ -3106,7 +3094,7 @@ export class SessionTransport {
|
||||
const waitMode = waitForIdleMs !== undefined;
|
||||
let waitEvidence: Pick<SendResult, "activity" | "waitedMs" | "attempts"> = {};
|
||||
|
||||
if (seatInboundTransport(resolveSeatKind(sessionMeta.attachmentType)) === "none") {
|
||||
if (sessionMeta.attachmentType === "external_cli") {
|
||||
return {
|
||||
ok: false,
|
||||
sessionName,
|
||||
@@ -4372,7 +4360,7 @@ export class SessionTransport {
|
||||
readable?: { width: number; height: number }; readableBudget?: ReadableBudget }): Promise<CaptureResult> {
|
||||
sessionName = physicalSessionName(this.db, sessionName);
|
||||
const sessionMeta = this.getSessionMeta(sessionName);
|
||||
if (seatInboundTransport(resolveSeatKind(sessionMeta.attachmentType)) === "none") {
|
||||
if (sessionMeta.attachmentType === "external_cli") {
|
||||
return {
|
||||
ok: false,
|
||||
sessionName,
|
||||
|
||||
@@ -4,11 +4,10 @@ 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, INHERIT_INBOUND_DESTINATION } from "../gateway/slack/config.js";
|
||||
import { DEFAULT_CONFIG } 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";
|
||||
@@ -105,8 +104,7 @@ 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(),
|
||||
receiver: DefaultReceiver | null = null) {
|
||||
export function settingsBrowser(store: SettingsStore, gateway: Record<string, unknown> | null = null, home = getOpenRigHome()) {
|
||||
const read = readConnectionConfiguration(home);
|
||||
const redact = read.text;
|
||||
const entries: BrowserEntry[] = [];
|
||||
@@ -141,13 +139,6 @@ 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";
|
||||
@@ -167,11 +158,8 @@ 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, undefined,
|
||||
unchosen ? "Not chosen: when the connector starts, unrouted messages go to the default receiving agent (Agents & runtime)." : undefined);
|
||||
cfg ? read.fields.includes(key) ? "file" : "default" : "unavailable", kind);
|
||||
}
|
||||
// #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,8 +134,6 @@ 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",
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
|
||||
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,
|
||||
@@ -44,12 +42,7 @@ 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 */ }
|
||||
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));
|
||||
return c.json(settingsBrowser(store, observed, opts.home));
|
||||
}
|
||||
// OPR.0.4.4.15: dynamic-class enumeration rides ADDITIVELY beside the
|
||||
// static settings map (existing consumers of `settings` unaffected).
|
||||
|
||||
@@ -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, resolveDefaultReceiver } from "../domain/default-receiver.js";
|
||||
import { KERNEL_OPERATOR_SESSION } from "../domain/policies/mode-scheduled-end.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,12 +141,6 @@ 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 {
|
||||
@@ -186,29 +180,23 @@ 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 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
|
||||
/** 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
|
||||
* 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" | "freeSession" | "ownerNote" | "freeNote"> {
|
||||
function pairOwner(c: { get: (key: string) => unknown }): Pick<IssuedPair, "owner" | "freeReason"> {
|
||||
const repo = getRepo(c);
|
||||
try {
|
||||
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 };
|
||||
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" };
|
||||
} catch { return { owner: null, freeReason: "unknown" }; }
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/** Why nobody reads answers on an agent-free request, as the person is told it. */
|
||||
function noReader(rec: IssuedPair): string {
|
||||
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})`;
|
||||
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})`;
|
||||
}
|
||||
|
||||
/** Whether the owner's session is running now; null when that can't be read. */
|
||||
@@ -255,7 +243,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.${rec.ownerNote ? ` (${rec.ownerNote}, so ${owner} reads it instead.)` : ""}`,
|
||||
`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.`,
|
||||
...((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.`,
|
||||
] : [
|
||||
|
||||
@@ -162,15 +162,6 @@ describe("cursor-bound composer input", () => {
|
||||
expect(inspectComposerInput(claude("\x1b[2;38;5;245mAsk Codex to do anything\x1b[0m", { x: 2, y: 2 })).state).toBe("empty");
|
||||
});
|
||||
|
||||
it("recognizes only a single inverse-video Claude cursor blank at the prompt start", () => {
|
||||
const cursor = { x: 2, y: 2 };
|
||||
expect(inspectComposerInput(claude("\x1b[7m \x1b[0m", cursor))).toMatchObject({ state: "empty", rows: [""], cursorAtEnd: true });
|
||||
for (const body of [" ", "\x1b[38;5;7m \x1b[0m", "\x1b[7m\x1b[27m ", "\x1b[7m \x1b[0mdraft"]) {
|
||||
expect(inspectComposerInput(claude(body, cursor)).state).toBe("text");
|
||||
}
|
||||
expect(inspectComposerInput(claude("\x1b[7m \x1b[0m", { x: 3, y: 2 }))).toMatchObject({ state: "text", rows: [" "] });
|
||||
});
|
||||
|
||||
it("requires the complete owned text and preserves significant spaces", () => {
|
||||
const input = inspectComposerInput(claude("Run two commands\nthen report."));
|
||||
expect(composerContainsOwnedText(input, "Run two commands\nthen report.")).toBe(true);
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { annotateInputBox, inspectComposerInput } from "../src/domain/session-transport.js";
|
||||
import { inspectComposerInput } from "../src/domain/session-transport.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
@@ -133,7 +133,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.each(["update-banner", "cursor-block"])("submits a multiline paste with %s and keeps a real draft through tmux", async variant => {
|
||||
it("submits a multiline paste under a combined update footer through real tmux", async () => {
|
||||
const scratch = mkdtempSync(join(tmpdir(), "draft-banner-"));
|
||||
const socket = join(scratch, "s");
|
||||
const env = { ...process.env }; delete env.TMUX; delete env.TMUX_TMPDIR;
|
||||
@@ -143,9 +143,8 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
|
||||
const db = new Database(":memory:");
|
||||
let transport: SessionTransport | undefined;
|
||||
try {
|
||||
const command = [process.execPath, composer, "claude", stateFile, variant].map(shellQuote).join(" ");
|
||||
const [width, height] = variant === "cursor-block" ? ["68", "31"] : ["120", "40"];
|
||||
const pane = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", width, "-y", height, "-s", "worker@test", command])).trim();
|
||||
const command = [process.execPath, composer, "claude", stateFile, "update-banner"].map(shellQuote).join(" ");
|
||||
const pane = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", "120", "-y", "40", "-s", "worker@test", command])).trim();
|
||||
await vi.waitFor(() => expect(state().input).toBe(""), { timeout: 5000 });
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test');
|
||||
@@ -158,14 +157,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
|
||||
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name)); adapter.deliveryGuard = guard;
|
||||
transport = new SessionTransport({ db, tmuxAdapter: adapter, rigRepo: new RigRepository(db), sessionRegistry: new SessionRegistry(db) });
|
||||
await guard.set("a", { mode: "draft-aware" }, "human@test", "protect fixture input");
|
||||
if (variant === "cursor-block") {
|
||||
await vi.waitFor(async () => {
|
||||
const snapshot = await adapter.captureComposerSnapshot(pane);
|
||||
expect(snapshot?.cursor).toEqual({ x: 2, y: 26, width: 68, height: 31 });
|
||||
expect(snapshot!.screen.split("\n")[26]).toContain("❯\u00a0\x1b[7m ");
|
||||
expect(annotateInputBox(snapshot!.screen, snapshot!.cursor, "claude-code")).toMatchObject({ status: "empty", confidence: "high" });
|
||||
});
|
||||
} else await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
|
||||
await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
|
||||
const text = "first line\nsecond line\nlast line";
|
||||
const result = await transport.send("worker@test", text, { deliveryId: "banner", actorSession: "sender@test", verify: false });
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, sent: true });
|
||||
@@ -173,7 +165,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
|
||||
await tmux(["send-keys", "-t", pane, "-l", "person's draft"]);
|
||||
await vi.waitFor(() => expect(state().input).toBe("person's draft"));
|
||||
expect(await transport.send("worker@test", "another message", { deliveryId: "draft", verify: false }))
|
||||
.toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
|
||||
.toMatchObject({ outcome: "retained", sent: false });
|
||||
expect(state()).toEqual({ input: "person's draft", submissions: [text] });
|
||||
} finally {
|
||||
await transport?.guardedDelivery?.stop();
|
||||
|
||||
+3
-7
@@ -5,23 +5,19 @@ import { StringDecoder } from "node:string_decoder";
|
||||
import stringWidth from "string-width";
|
||||
|
||||
const [runtime, stateFile, footerVariant] = process.argv.slice(2);
|
||||
const cursorBlock = footerVariant === "cursor-block";
|
||||
const rule = cursorBlock ? `\x1b[38;5;244m${"─".repeat(68)}\x1b[39m` : "────────────────────────────────────────";
|
||||
let input = "", pending = "", pasting = false, cursor = 0, ghost = false;
|
||||
const submissions = [];
|
||||
const decoder = new StringDecoder("utf8");
|
||||
|
||||
function render() {
|
||||
const rows = ["Editable delivery fixture", ...submissions.slice(-3).flatMap(text => text.split("\n"))];
|
||||
if (cursorBlock) while (rows.length < 25) rows.push("");
|
||||
if (runtime === "claude") rows.push(rule);
|
||||
if (runtime === "claude") rows.push("────────────────────────────────────────");
|
||||
const start = rows.length;
|
||||
const displayed = ghost && cursor === input.length ? `${input}\x1b[2m suggested follow-up\x1b[22m`
|
||||
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : cursorBlock ? "\x1b[7m \x1b[0m" : "");
|
||||
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : "");
|
||||
const body = displayed.split("\n");
|
||||
rows.push(`${runtime === "claude" ? "❯\u00a0" : "› "}${body[0]}`, ...body.slice(1).map(line => ` ${line}`));
|
||||
rows.push(...(runtime === "claude" ? [rule, cursorBlock
|
||||
? " \x1b[38;5;211m⏵⏵ bypass permissions on\x1b[38;5;246m (shift+tab to cycle)\x1b[0m" : "? for shortcuts"] : ["", "fixture · 90% context left"]));
|
||||
rows.push(...(runtime === "claude" ? ["────────────────────────────────────────", "? for shortcuts"] : ["", "fixture · 90% context left"]));
|
||||
if (runtime === "claude" && ghost) {
|
||||
rows[rows.length - 1] = "⏵⏵ accept edits on (shift+tab to cycle) · ← for agents";
|
||||
rows.push("✘ Auto-update failed: no write permission to npm prefix · Run claude doctor");
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-a/spec.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-a/spec.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
|
||||
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-a/spec.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-a/spec.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
|
||||
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro-new","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-b/new.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-b/new.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
|
||||
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro-new","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-b/new.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-b/new.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
|
||||
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
|
||||
@@ -147,7 +147,7 @@ describe("public human registry -> queue admission journey", () => {
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json() as Record<string, unknown>;
|
||||
expect(body).toMatchObject({ error: "human_registry_unavailable", registryLoadError: true });
|
||||
const nobody = "no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)";
|
||||
const nobody = "no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …";
|
||||
expect(String(body.registryError)).toBe(nobody);
|
||||
expect(body).not.toHaveProperty("registryProjectionError");
|
||||
// The queue's own frame says nobody is registered, never "failed to load" or "repair the projection".
|
||||
|
||||
@@ -158,7 +158,7 @@ describe("A3 proof-5 — fragments -> generated projection; re-project; hand-edi
|
||||
const loaded = loadHumanRegistry(home);
|
||||
expect(loaded).toEqual({
|
||||
ok: false,
|
||||
error: "no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)",
|
||||
error: "no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …",
|
||||
noHumans: true,
|
||||
});
|
||||
expect(existsSync(projectionPath(home))).toBe(false); // a load never creates the projection
|
||||
|
||||
@@ -58,8 +58,7 @@ 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) => {
|
||||
// The receiving agent's seat read, whichever query reads it.
|
||||
if (sql.includes("FROM sessions WHERE session_name = ?")) throw new Error("sessions unreadable");
|
||||
if (sql.includes("FROM sessions WHERE session_name = ? LIMIT 1")) 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" },
|
||||
@@ -113,7 +112,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 (no receiving agent is set in agents.operator_session, and 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 (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 });
|
||||
});
|
||||
@@ -488,27 +487,6 @@ 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 (no receiving agent is set in agents.operator_session, and 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 (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, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -76,60 +76,7 @@ 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 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");
|
||||
expect(f.toOperator()[0]!.body).toContain(`Sent to orch@rig because ${KERNEL_OPERATOR_SESSION} isn't on this host.`);
|
||||
});
|
||||
|
||||
it("applied by a named operator outside any seat, with no kernel operator here, the person is told, as an update", async () => {
|
||||
@@ -141,7 +88,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 (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)` });
|
||||
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)` });
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -167,7 +114,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: 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`;
|
||||
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`;
|
||||
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 });
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// Refocus: the hook's time budget against the harness's outer kill, its failure report, and (characterized, unresolved)
|
||||
// overlapping events for one occupant.
|
||||
// Refocus: the hook's time budget, its failure report, and overlapping events for one occupant.
|
||||
// Each case runs the real hook as a harness does (JSON on stdin, killed at the registration's outer bound), with a stub
|
||||
// `rig` and a stub trace script whose delays are set per case. The overlaps are hook processes overlapping; whether a
|
||||
// harness overlaps these events natively is not established here.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -62,7 +61,7 @@ echo "TRACE OK: work and topology"
|
||||
interface Run { killed: boolean; ms: number; stdout: string; stderr: string }
|
||||
|
||||
// Run the hook as the harness does, killed at `outerMs` (both registrations' bound for refocus: 60 s).
|
||||
function hook(f: Fixture, event: string, session: string, extra: Record<string, string> = {}, outerMs = 60_000): Promise<Run> {
|
||||
function hook(f: Fixture, event: string, session: string, extra: Record<string, string> = {}, outerMs = 60_000, promptId?: string): Promise<Run> {
|
||||
const transcript = join(root!, `${session}.jsonl`);
|
||||
if (!existsSync(transcript)) writeFileSync(transcript, "");
|
||||
const started = Date.now();
|
||||
@@ -81,7 +80,7 @@ function hook(f: Fixture, event: string, session: string, extra: Record<string,
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
||||
const timer = setTimeout(() => { killed = true; child.kill("SIGKILL"); }, outerMs);
|
||||
child.on("close", () => { clearTimeout(timer); done({ killed, ms: Date.now() - started, stdout, stderr }); });
|
||||
child.stdin.end(JSON.stringify({ hook_event_name: event, session_id: session, transcript_path: transcript, prompt: "next" }));
|
||||
child.stdin.end(JSON.stringify({ hook_event_name: event, session_id: session, transcript_path: transcript, prompt: "next", prompt_id: promptId }));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,7 +89,6 @@ const stateOf = (f: Fixture, session: string) =>
|
||||
JSON.parse(readFileSync(join(f.home, "refocus", `${SEAT}__${session}.json`), "utf8")) as Record<string, unknown>;
|
||||
const healthOf = (f: Fixture) => readdirSync(join(f.home, "refocus")).filter((n) => n.endsWith(".health.json"))
|
||||
.map((n) => JSON.parse(readFileSync(join(f.home, "refocus", n), "utf8")) as { failures?: number });
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
describe("refocus: the registrations' outer bound", () => {
|
||||
it("allows refocus.cjs 60 s on both harnesses and leaves every other hook at 5 s", () => {
|
||||
@@ -205,12 +203,8 @@ describe("refocus: the time budget", () => {
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe("refocus: overlapping events for one occupant (UNRESOLVED; characterized, not fixed)", () => {
|
||||
// These describe main's behaviour, which this change keeps: two hook runs for one occupant are not serialized, so
|
||||
// overlapping hook processes can lose a just-recorded compaction or deliver one refocus twice. Whether a harness
|
||||
// overlaps these events natively is NOT ESTABLISHED; the gap is open, not shown safe. If a later change serializes
|
||||
// the state, these flip and should then assert the fixed behaviour.
|
||||
it("can lose a PostCompact that lands while an earlier refocus is being delivered", async () => {
|
||||
describe("refocus: overlapping events for one occupant", () => {
|
||||
it("preserves a PostCompact that lands while an earlier refocus is being delivered", async () => {
|
||||
const f = fixture();
|
||||
await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_NOW: "1" });
|
||||
const file = join(f.home, "refocus", `${SEAT}__s1.json`);
|
||||
@@ -220,7 +214,7 @@ describe("refocus: overlapping events for one occupant (UNRESOLVED; characterize
|
||||
// Hold delivery after it has read the state; process start-up time cannot choose the interleaving.
|
||||
const delivering = hook(f, "UserPromptSubmit", "s1", {
|
||||
OPENRIG_REFOCUS_BUDGET_MS: "52000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
|
||||
});
|
||||
}, 60_000, "prompt-1");
|
||||
try {
|
||||
await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 10000 });
|
||||
await hook(f, "PostCompact", "s1");
|
||||
@@ -229,21 +223,142 @@ describe("refocus: overlapping events for one occupant (UNRESOLVED; characterize
|
||||
writeFileSync(release, "");
|
||||
}
|
||||
expect(context(await delivering)).toContain("REFOCUS");
|
||||
// The delivery wrote back the state it read before the PostCompact: the compaction's pending is gone.
|
||||
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" });
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1", {}, 60_000, "prompt-1"))).toBe("");
|
||||
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" });
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1", {}, 60_000, "prompt-2"))).toContain("REFOCUS (just compacted");
|
||||
expect(stateOf(f, "s1")["pendingOn"]).toBeUndefined();
|
||||
}, 20_000);
|
||||
|
||||
it("delivers a pending refocus once when two handlers overlap", async () => {
|
||||
const f = fixture();
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const ready = join(root!, "trace-ready");
|
||||
const release = join(root!, "trace-release");
|
||||
const first = hook(f, "UserPromptSubmit", "s1", {
|
||||
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
|
||||
}, 60_000, "prompt-A");
|
||||
try {
|
||||
await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 5000 });
|
||||
const revision = stateOf(f, "s1").pendingRevision;
|
||||
const second = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_NOW: "1" }, 60_000, "prompt-A");
|
||||
expect(context(second)).toBe("");
|
||||
expect(second.stderr).toContain("another hook is delivering");
|
||||
expect(stateOf(f, "s1").pendingRevision).toBe(revision);
|
||||
} finally {
|
||||
writeFileSync(release, "");
|
||||
expect(context(await first)).toContain("REFOCUS");
|
||||
}
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
|
||||
}, 20_000);
|
||||
|
||||
it("can deliver one pending refocus twice when two prompts overlap", async () => {
|
||||
it("retains a distinct identified on-demand prompt while an earlier prompt is rendering", async () => {
|
||||
const f = fixture();
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const first = hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "1000" });
|
||||
await wait(200);
|
||||
const second = hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "1000" });
|
||||
const delivered = (await Promise.all([first, second])).filter((r) => context(r).includes("REFOCUS"));
|
||||
expect(delivered).toHaveLength(2);
|
||||
const ready = join(root!, "trace-ready");
|
||||
const release = join(root!, "trace-release");
|
||||
const first = hook(f, "UserPromptSubmit", "s1", {
|
||||
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
|
||||
}, 60_000, "prompt-A");
|
||||
try {
|
||||
await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 5000 });
|
||||
const second = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_NOW: "1" }, 60_000, "prompt-B");
|
||||
expect(context(second)).toBe("");
|
||||
} finally {
|
||||
writeFileSync(release, "");
|
||||
expect(context(await first)).toContain("REFOCUS");
|
||||
}
|
||||
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: expect.any(String) });
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1", {}, 60_000, "prompt-C"))).toContain("REFOCUS");
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1", {}, 60_000, "prompt-D"))).toBe("");
|
||||
}, 20_000);
|
||||
|
||||
it("delivers once per identified prompt even if the second handler starts after completion", async () => {
|
||||
const f = fixture();
|
||||
const env = { OPENRIG_REFOCUS_NOW: "1" };
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-1"))).toContain("REFOCUS");
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-1"))).toBe("");
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-2"))).toContain("REFOCUS");
|
||||
});
|
||||
|
||||
it("recovers pending work after the rendering hook is killed", async () => {
|
||||
const f = fixture();
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const ready = join(root!, "trace-ready");
|
||||
const release = join(root!, "trace-release");
|
||||
const interrupted = hook(f, "UserPromptSubmit", "s1", {
|
||||
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
|
||||
}, 1000);
|
||||
try {
|
||||
await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 5000 });
|
||||
expect(await interrupted).toMatchObject({ killed: true, stdout: "" });
|
||||
} finally {
|
||||
writeFileSync(release, "");
|
||||
}
|
||||
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact", deliveryClaim: { pid: expect.any(Number) } });
|
||||
const resumed = await hook(f, "UserPromptSubmit", "s1");
|
||||
expect(resumed.stderr).toContain("prior delivery owner could not be confirmed");
|
||||
expect(context(resumed)).toContain("REFOCUS (just compacted");
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
|
||||
});
|
||||
|
||||
it("uses a shared successor guard after its former owner exits", async () => {
|
||||
const f = fixture();
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const guard = join(f.home, "refocus", `${SEAT}__s1.json.guard`);
|
||||
const owner = spawnSync(process.execPath, ["-e", ""]).pid;
|
||||
const token = `${owner}-00000000-0000-4000-8000-000000000001`;
|
||||
symlinkSync(token, guard);
|
||||
const first = hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "200" });
|
||||
const second = hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "200" });
|
||||
const runs = await Promise.all([first, second]);
|
||||
expect(runs.filter((run) => context(run).includes("REFOCUS"))).toHaveLength(1);
|
||||
expect(readlinkSync(guard)).toBe(token);
|
||||
});
|
||||
|
||||
it("does not take a successor when the old guard was released before the owner exited", async () => {
|
||||
const f = fixture();
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const stateFile = join(f.home, "refocus", `${SEAT}__s1.json`);
|
||||
const guard = stateFile + ".guard";
|
||||
const oldPid = spawnSync(process.execPath, ["-e", ""]).pid;
|
||||
symlinkSync(`${oldPid}-00000000-0000-4000-8000-000000000001`, guard);
|
||||
const liveToken = `${process.pid}-00000000-0000-4000-8000-000000000002`;
|
||||
const result = spawnSync(process.execPath, ["-e", `
|
||||
const fs = require("node:fs");
|
||||
const [helper, stateFile, liveToken, oldPid] = process.argv.slice(1);
|
||||
const kill = process.kill;
|
||||
process.kill = (pid, signal) => {
|
||||
if (pid === Number(oldPid)) {
|
||||
fs.unlinkSync(stateFile + ".guard");
|
||||
fs.symlinkSync(liveToken, stateFile + ".guard");
|
||||
}
|
||||
return kill(pid, signal);
|
||||
};
|
||||
const access = require(helper).openRefocusState(stateFile);
|
||||
console.log(access.exclusive);
|
||||
access.close();
|
||||
`, join(dirname(HOOK), "refocus-state.cjs"), stateFile, liveToken, String(oldPid)], { encoding: "utf8" });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe("false");
|
||||
expect(readlinkSync(guard)).toBe(liveToken);
|
||||
});
|
||||
|
||||
it("reports an unreadable guard and leaves the pending compaction available", async () => {
|
||||
const f = fixture();
|
||||
await hook(f, "UserPromptSubmit", "s1");
|
||||
const guard = join(f.home, "refocus", `${SEAT}__s1.json.guard`);
|
||||
mkdirSync(guard);
|
||||
const compact = await hook(f, "PostCompact", "s1");
|
||||
expect(compact.stderr).toContain("could not coordinate state");
|
||||
const uncoordinated = await hook(f, "UserPromptSubmit", "s1");
|
||||
expect(uncoordinated.stderr).toContain("could not coordinate state");
|
||||
expect(context(uncoordinated)).toContain("REFOCUS (just compacted");
|
||||
rmSync(guard, { recursive: true });
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toContain("REFOCUS (just compacted");
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
|
||||
});
|
||||
|
||||
it("still delivers to each of two occupants of one seat when they overlap", async () => {
|
||||
const f = fixture();
|
||||
await hook(f, "PostCompact", "s1");
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("seat status reads what OpenRig's own refocus hook recorded", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)("an earlier record that can't be read makes the history unknown, never 'no record' or an older time (root bypasses mode 000)", () => {
|
||||
it("an earlier record that can't be read makes the history unknown, never 'no record' or an older time", () => {
|
||||
withHome((home) => {
|
||||
// Alone: an unreadable earlier record, and the seat now has another conversation (or no resume token).
|
||||
prompt(home, "earlier");
|
||||
@@ -90,7 +90,7 @@ describe("seat status reads what OpenRig's own refocus hook recorded", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)("a state folder that can't be read reads unknown, not 'no record' (root bypasses mode 000)", () => {
|
||||
it("a state folder that can't be read reads unknown, not 'no record'", () => {
|
||||
withHome((home) => {
|
||||
prompt(home, conversation);
|
||||
const dir = join(home, "refocus");
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// The seat boundary's resolver: today's two kinds, and a stored value that's neither read as unknown, never as tmux.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveSeatKind, seatInboundTransport } from "../src/domain/seat-kinds.js";
|
||||
|
||||
describe("resolveSeatKind", () => {
|
||||
it.each([
|
||||
[null, { kind: "tmux" }],
|
||||
[undefined, { kind: "tmux" }],
|
||||
["tmux", { kind: "tmux" }],
|
||||
["external_cli", { kind: "external_cli" }],
|
||||
["herdr", { kind: "unknown", stored: "herdr" }],
|
||||
["", { kind: "unknown", stored: "" }],
|
||||
["TMUX", { kind: "unknown", stored: "TMUX" }],
|
||||
] as const)("reads %j as %j", (stored, expected) => {
|
||||
expect(resolveSeatKind(stored)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("gives an external CLI seat no inbound transport, and every other kind today's tmux path", () => {
|
||||
expect(seatInboundTransport({ kind: "external_cli" })).toBe("none");
|
||||
expect(seatInboundTransport({ kind: "tmux" })).toBe("tmux");
|
||||
// An unknown stored kind keeps the path it takes today; the resolver still names it unknown.
|
||||
expect(seatInboundTransport({ kind: "unknown", stored: "herdr" })).toBe("tmux");
|
||||
});
|
||||
});
|
||||
@@ -2741,27 +2741,6 @@ describe("SessionTransport", () => {
|
||||
expect(hasSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A stored kind that isn't one of today's (only reachable by writing the column directly) takes today's path, the tmux
|
||||
// one, for send and capture: the seat boundary adds no refusal and changes nothing for it.
|
||||
it("a binding whose stored kind isn't tmux or external_cli sends and captures through tmux, as before the boundary", async () => {
|
||||
const { node } = seedCanonicalRig();
|
||||
db.prepare("UPDATE bindings SET attachment_type = 'herdr' WHERE node_id = ?").run(node.id);
|
||||
const callOrder: string[] = [];
|
||||
const tmux = mockTmux({
|
||||
sendText: async () => { callOrder.push("sendText"); return { ok: true }; },
|
||||
sendKeys: async (_t, keys) => { callOrder.push(`sendKeys:${keys.join(",")}`); return { ok: true }; },
|
||||
capturePaneContent: async () => "line1",
|
||||
});
|
||||
const transport = createTransport(tmux);
|
||||
|
||||
const sent = await transport.send("dev-impl@my-rig", "hello");
|
||||
expect(sent.reason).not.toBe("transport_unavailable");
|
||||
expect(callOrder).toContain("sendText");
|
||||
const captured = await transport.capture("dev-impl@my-rig");
|
||||
expect(captured.ok).toBe(true);
|
||||
expect(captured.content).toContain("line1");
|
||||
});
|
||||
|
||||
// Test 11: resolveSessions by rig returns running sessions
|
||||
it("resolveSessions by rig returns running sessions", async () => {
|
||||
seedCanonicalRig();
|
||||
|
||||
@@ -18,8 +18,7 @@ describe("Slice-11 config — first-class + honest unconfigured (item 5)", () =>
|
||||
expect(configFileExists(home)).toBe(false);
|
||||
const cfg = loadConfig(home);
|
||||
expect(cfg.enabled).toBe(false);
|
||||
// Unchosen: the host's default receiving agent, resolved when the connector starts.
|
||||
expect(cfg.inboundDestination).toBe("default-receiver");
|
||||
expect(cfg.inboundDestination).toBe("operator-agent@kernel");
|
||||
expect(cfg).not.toHaveProperty("alertTag");
|
||||
});
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ describe("an alert on an update", () => {
|
||||
try {
|
||||
const repo = new QueueRepository(db, new EventBus(db), { loadHumanRegistry: () => registry });
|
||||
await expect(repo.create({ ...request, humanIntent: "update" })).rejects.toThrow(
|
||||
"humanAlert marks an ask to a person as alert-worthy, and an update isn't an ask: send it without --alert; updates are notices; delivery depends on the person's connector and settings.");
|
||||
"humanAlert marks an ask to a person as alert-worthy, and an update isn't an ask: send it without --alert; updates are notices and follow Slack's posting floor and delivery settings.");
|
||||
} finally { db.close(); }
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,6 @@ 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",
|
||||
|
||||
@@ -145,21 +145,6 @@ person needs in their project or home folder and name that path.
|
||||
seat sees that name, recorded as declared, and can reply to them if they're registered (`rig gateway human list`).
|
||||
A declared name is a claim, not a seat's own identity: for a team that acts only on seat identity, the attached
|
||||
shell above is the route.
|
||||
- **Who answers OpenRig's questions:** read `rig gateway participants` (its `default:` line) before the handoff.
|
||||
- A default person is there: say requests and asks go to them, and don't offer again.
|
||||
- Several people with no default: ask which should get requests nobody is named for (`rig gateway default <id>`),
|
||||
or leave it. Never pick one yourself.
|
||||
- Nobody is registered: offer it in a sentence or two. OpenRig's questions, such as another machine asking to pair,
|
||||
need a person to reach. Ask the name to use (their login name is only a suggestion). On yes, run
|
||||
`rig gateway human add <id> --display-name "<name>" --delivery-class B --default`: they're reached in the TUI's Feed
|
||||
and Steering, and Slack can be added later. On no or not now, register nothing and say that questions to a person
|
||||
can't reach anyone until someone is registered, with that command for later.
|
||||
- Never pass `--default` over an existing default, or `--replace`, unless they ask. Their own words decide: a
|
||||
different name, Slack now, or not now.
|
||||
- An agent reads their answers: the kernel operator, or the agent chosen in `agents.operator_session`. If the kernel
|
||||
isn't running here and no agent is chosen, offer to choose the team's lead (`rig config set agents.operator_session
|
||||
<seat>@<rig>`), on their yes only.
|
||||
- A machine they'll pair with needs its own person: registering here doesn't give that machine one.
|
||||
- **Give the operator the goal:** ask what they want worked on, in which repository and on which branch, unless they've
|
||||
said. Find the `operator.agent` row in `rig ps --nodes --rig kernel --json`, take its `canonicalSessionName`
|
||||
(never the logical ID or a guessed name) and send:
|
||||
|
||||
Reference in new issue
Block a user