mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7314e8e1b4 | ||
|
|
5a44237faa | ||
|
|
11011b18d7 | ||
|
|
9f610e02dd |
No files matched your search
@@ -96,8 +96,10 @@ Keys:
|
||||
wake_retry_interval_seconds, wake_retry_cap, wake_unconfirmed_window_minutes,
|
||||
wake_swap_grace_seconds (S01 wake-or-escalate ladder),
|
||||
wake_human_rung (always | explicit-only — post agent-to-agent escalations to a person;
|
||||
under explicit-only, an ask that needs a person must be addressed to the person;
|
||||
without a human registry, explicit-only posts everything, as always does)
|
||||
explicit-only posts rows addressed to a person; other agent work stays visible,
|
||||
permission prompts still reach a person,
|
||||
choice prompts follow this setting; unavailable human delivery stays a visible row;
|
||||
an unreadable registry does not suppress eligibility; delivery still needs a ready route)
|
||||
retention.* enabled, transitions_days, watchdog_days,
|
||||
watchdog_keep_per_job, batch_size
|
||||
launch.non_interruptive accept harness first-launch warnings at full bypass for new rigs (default off)
|
||||
|
||||
@@ -349,11 +349,17 @@ your context, your compaction and your replacement — and it is the only thing
|
||||
`rig config set queue.wake_human_rung explicit-only` (default `always`) keeps that post
|
||||
for rows addressed to a person (an `@external` address or a registered human); the rest stop at
|
||||
`rig view show escalations`. A seat stuck at a prompt only a person can clear still reaches one,
|
||||
and an unreadable human registry posts everything. A seat held at a choice prompt that no hook
|
||||
and an unreadable human registry keeps every row eligible for that last step; it does not
|
||||
establish that a channel can deliver. A seat held at a choice prompt that no hook
|
||||
announces (a usage-limit dialog, say) escalates once the refusal has held for one retry interval
|
||||
(`queue.wake_retry_interval_seconds`, 300 seconds by default). Choice-prompt alerts follow this
|
||||
setting and reach a person one at a time per machine; later ones wait as rows while the first
|
||||
seat stays blocked.
|
||||
seat stays blocked. Escalations aren't posted while the person's channel can't take them. They stay visible
|
||||
as rows with a reason and are tried again later. The configured escalation route still runs first.
|
||||
An older, unaccepted alert addressed to a person is kept on the same row and rerouted locally;
|
||||
a delivery already accepted by the gateway keeps its existing route. Findings about an undelivered
|
||||
wake-escalation notice stay visible without creating another wake escalation. Scheduling notices
|
||||
are sent by OpenRig; their member rows keep their original senders and obligations.
|
||||
- **`rig queue inbox-drop` / `inbox-pending` / `inbox-absorb` / `inbox-deny`** — put something in
|
||||
front of a seat that it can *refuse*. Mail, not assignment.
|
||||
- **`rig queue outbox-record` / `outbox-list`** — what you dispatched, in a record that survives
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
import type { QueueItem, QueueRepository } from "../queue-repository.js";
|
||||
import { DispatchBuffer } from "./dispatch-buffer.js";
|
||||
import type { DispatchResult } from "./dispatcher.js";
|
||||
import type { LoadResult } from "./human-registry.js";
|
||||
import type { HumanFragment, LoadResult } from "./human-registry.js";
|
||||
import { loadHumanRegistry } from "./human-registry.js";
|
||||
import { resolveDefaultPerson } from "./participants.js";
|
||||
import { loadConfig } from "./slack/config.js";
|
||||
import { resolveSecret } from "./slack/secrets.js";
|
||||
import { resolveHumanDeliveryReadiness } from "./human-readiness.js";
|
||||
import { quietHoldFor } from "./human-quiet-windows.js";
|
||||
import { OUTBOUND_OP } from "./slack/outbound-driver.js";
|
||||
import {
|
||||
@@ -21,6 +23,8 @@ import {
|
||||
} from "./delivery-rules-engine.js";
|
||||
|
||||
export interface OperatorDeliveryEngine {
|
||||
/** Read durable pre-marker custody without dispatching or rechecking readiness. */
|
||||
hasAcceptedEscalation?: (row: QueueItem) => boolean;
|
||||
dispatchEscalation: (
|
||||
row: QueueItem,
|
||||
reason: string,
|
||||
@@ -61,17 +65,34 @@ export function makeOperatorDeliveryEngine(deps: {
|
||||
queueRepo: QueueRepository;
|
||||
dispatch: (op: string, entityBindingRef: string, payload: unknown, opts?: { decisionId?: string }) => DispatchResult;
|
||||
registry?: { loadHumanRegistry: () => LoadResult };
|
||||
gatewayState?: () => string;
|
||||
readiness?: (human: HumanFragment) => Promise<{ ready: boolean; reason: string }>;
|
||||
}): OperatorDeliveryEngine {
|
||||
const notificationKeyFor = (row: QueueItem): string => {
|
||||
const owner = deps.queueRepo.transitionLog.latestOwnerNotificationForQitem(row.qitemId);
|
||||
return owner ? `${row.qitemId}:${owner.transitionId}` : `${row.qitemId}:operator-rung`;
|
||||
};
|
||||
const resolvedFor = (row: QueueItem, key: string): string | null => {
|
||||
if (deps.queueRepo.transitionLog.hasOwnerNotificationReceipt(row.qitemId, key)) return "posted";
|
||||
return deps.queueRepo.listTransitions(row.qitemId).some(t => t.transitionNote?.startsWith("delivery-termination:")
|
||||
&& t.transitionNote.split(/\s+/).includes(`notification_key=${key}`)) ? "terminated" : null;
|
||||
};
|
||||
return {
|
||||
hasAcceptedEscalation: row => {
|
||||
const key = notificationKeyFor(row);
|
||||
return resolvedFor(row, key) !== null || new DispatchBuffer(deps.home).pending()
|
||||
.some(d => d.decisionId === `operator:${key}`);
|
||||
},
|
||||
async dispatchEscalation(row: QueueItem, _reason: string) {
|
||||
// The episode identity the receipt must carry (Slice 14's ledger accepts
|
||||
// only the current qitemId:transitionId key; a baton row with no owner
|
||||
// transition gets the stable synthetic operator-rung episode).
|
||||
const owner = deps.queueRepo.transitionLog.latestOwnerNotificationForQitem(row.qitemId);
|
||||
const notificationKey = owner ? `${row.qitemId}:${owner.transitionId}` : `${row.qitemId}:operator-rung`;
|
||||
const notificationKey = notificationKeyFor(row);
|
||||
|
||||
const decisionId = `operator:${notificationKey}`;
|
||||
const accepted = () => new DispatchBuffer(deps.home).pending().some(d => d.decisionId === decisionId);
|
||||
const resolved = resolvedFor(row, notificationKey);
|
||||
if (resolved) return { decision: resolved, resolved: true, dispatched: true, notificationKey, decisionId };
|
||||
// An accepted decision belongs to the gateway even if registry access or
|
||||
// routing changes before the ladder records its dispatch marker.
|
||||
if (accepted()) return { decision: "accepted-pending", resolved: false, dispatched: true, notificationKey, decisionId };
|
||||
@@ -89,6 +110,17 @@ export function makeOperatorDeliveryEngine(deps: {
|
||||
return { decision: reg.entities.length ? "undeliverable:no-default-person" : "undeliverable:no-registered-human", resolved: false, dispatched: false };
|
||||
}
|
||||
const cfg = loadConfig(deps.home);
|
||||
// A local carrier remains visible; no new gateway decision belongs to a channel that cannot post.
|
||||
// Already-accepted decisions returned above retain gateway custody, even if readiness later changes.
|
||||
try {
|
||||
const readiness = await (deps.readiness?.(human) ?? resolveHumanDeliveryReadiness({
|
||||
human, config: cfg, gatewayState: deps.gatewayState?.() ?? "unavailable",
|
||||
botToken: resolveSecret("SLACK_BOT_TOKEN", { envFile: cfg.secretsEnvFile ?? undefined }),
|
||||
}));
|
||||
if (!readiness.ready) return { decision: `unavailable:human-delivery:${readiness.reason}`, resolved: false, dispatched: false };
|
||||
} catch {
|
||||
return { decision: "unavailable:human-delivery:readiness-unavailable", resolved: false, dispatched: false };
|
||||
}
|
||||
const decision = decideDelivery({
|
||||
level: "ALERT",
|
||||
escalation: true,
|
||||
|
||||
@@ -743,7 +743,7 @@ export async function runWakeLadderTick(deps: WakeLadderDeps): Promise<WakeLadde
|
||||
|
||||
for (const { qitem_id } of [...batonRows, ...parkedOwnerFailureRows]) {
|
||||
const row = deps.queueRepo.getById(qitem_id);
|
||||
if (!row) continue;
|
||||
if (!row || recoversEscalationNotice(deps, row)) continue;
|
||||
const usagePool = usagePoolBySeat.get(row.destinationSession);
|
||||
// OPR.0.5.6.24: the usage-limit PARK mutation applies only to pending
|
||||
// batons — a claimed in-progress row is someone's live work and is never
|
||||
@@ -869,7 +869,7 @@ export async function runWakeLadderTick(deps: WakeLadderDeps): Promise<WakeLadde
|
||||
// F3 — per-destination aggregation: ONE escalation carrying the row list, refreshed
|
||||
// not duplicated (the S02 idempotency shape), and rung markers on every member baton.
|
||||
for (const [dest, members] of escalating) {
|
||||
const orch = openEscalationOwner(deps.db, escalationDedupTag(dest)) ?? resolveOrch(dest);
|
||||
const orch = openEscalationOwner(deps, escalationDedupTag(dest)) ?? resolveOrch(dest);
|
||||
const route = !deps.resolveOrchestrator
|
||||
? resolveEscalationOwner(deps.db, dest, target => deps.queueRepo.acceptsDestination(target)) : null;
|
||||
const routeReason = route && route.outcome !== "none" && route.outcome !== "parent" ? route.reason : "";
|
||||
@@ -1083,11 +1083,33 @@ function waitsOnPostedPromptAlert(deps: WakeLadderDeps, row: QueueItem, choiceOn
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Carrier selection skips positively identified people; missing registry data does not turn an agent into a person. */
|
||||
function knownPerson(deps: WakeLadderDeps, destination: string): boolean {
|
||||
if (parseSessionName(destination).kind === "external") return true;
|
||||
try {
|
||||
const registry = (deps.loadHumanRegistry ?? (() => loadHumanRegistry()))();
|
||||
return registry.ok && resolveRegisteredHumanAddress(destination, registry.entities) !== null;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function gatewayOwnsEscalation(deps: WakeLadderDeps, row: QueueItem): boolean {
|
||||
return readLadder(deps.db, row.qitemId, 0).opEngineDispatched
|
||||
|| Boolean(deps.deliveryEngine?.hasAcceptedEscalation?.(row));
|
||||
}
|
||||
|
||||
// The operator engine owns actual human delivery. Its non-nudged queue carrier
|
||||
// must still be locally admissible; cross-host source provenance is not a local destination.
|
||||
function humanAlertCarrier(deps: WakeLadderDeps, dest: string, source?: string): string {
|
||||
const candidates = [resolveOperatorSeat(), source];
|
||||
return candidates.find((candidate): candidate is string => Boolean(candidate && deps.queueRepo.acceptsDestination(candidate))) ?? dest;
|
||||
return candidates.find((candidate): candidate is string => Boolean(candidate && !knownPerson(deps, candidate) && deps.queueRepo.acceptsDestination(candidate))) ?? dest;
|
||||
}
|
||||
|
||||
/** A delivery-recovery finding about our own notice stays readable without spawning another notice.
|
||||
* Ordinary human asks still use the existing recovery route; the original obligations are untouched. */
|
||||
function recoversEscalationNotice(deps: WakeLadderDeps, row: QueueItem): boolean {
|
||||
const prefix = "stuck-sweep:undelivered-human-notice:";
|
||||
return row.tags?.some(tag => tag.startsWith(prefix)
|
||||
&& deps.queueRepo.getById(tag.slice(prefix.length))?.tags?.includes(WAKE_ESCALATION_TAG)) ?? false;
|
||||
}
|
||||
|
||||
/** Positive refusals enter the existing rungs without ever retrying the blocked
|
||||
@@ -1140,14 +1162,14 @@ async function advancePromptRefusals(
|
||||
};
|
||||
for (const { qitem_id, destination_session } of candidates) {
|
||||
const primary = readCandidate(qitem_id, destination_session);
|
||||
if (!primary) continue;
|
||||
if (!primary || recoversEscalationNotice(deps, primary)) continue;
|
||||
// The shared no-route change is prospective, never a revival of old ladders.
|
||||
if (readLadder(deps.db, qitem_id).exhausted) continue;
|
||||
for (const episode of openPromptRefusals(deps.queueRepo.listTransitions(qitem_id))) {
|
||||
if (!episode.key.startsWith(`${primary.destinationSession}|`)) continue;
|
||||
// Ordinary work shares an episode; consumed recoveries retain independent dispositions.
|
||||
const rows = episode.ids.map(id => readCandidate(id, primary.destinationSession)).filter((row): row is QueueItem =>
|
||||
Boolean(row && row.destinationSession === primary.destinationSession && ["pending", "in-progress", "blocked"].includes(row.state)
|
||||
Boolean(row && !recoversEscalationNotice(deps, row) && row.destinationSession === primary.destinationSession && ["pending", "in-progress", "blocked"].includes(row.state)
|
||||
&& !closedEpisode(`${PROMPT_EPISODE_TAG}${episode.key}${hasConsumedEscalationHop(deps.db, row.qitemId) ? `|consumed:${row.qitemId}` : ""}`)));
|
||||
if (!rows.length) continue;
|
||||
let group = groups.get(primary.destinationSession);
|
||||
@@ -1353,7 +1375,7 @@ async function advancePromptRefusals(
|
||||
members.splice(i, 1);
|
||||
}
|
||||
if (!members.length && !group.jobs.size && !group.hookReview) continue;
|
||||
const orch = openEscalationOwner(deps.db, `prompt-destination:${dest}`) ?? resolveOrch(dest);
|
||||
const orch = openEscalationOwner(deps, `prompt-destination:${dest}`) ?? resolveOrch(dest);
|
||||
const toOperator = orch === null || orch === dest;
|
||||
const route = !deps.resolveOrchestrator
|
||||
? resolveEscalationOwner(deps.db, dest, target => deps.queueRepo.acceptsDestination(target)) : null;
|
||||
@@ -1397,6 +1419,11 @@ async function advancePromptRefusals(
|
||||
// The aggregate ID is the episode. Claiming it does not start a second
|
||||
// delivery episode or erase its accepted dispatch/receipt history.
|
||||
const view = readLadder(deps.db, row.qitemId, 0);
|
||||
// A crash can leave a durable gateway decision without its ladder marker. Reconcile that custody before any wake.
|
||||
if (!view.opEngineDispatched && deps.deliveryEngine?.hasAcceptedEscalation?.(row)) {
|
||||
if (await operatorRung(deps, row, reason, actions, view, false)) appendExhausted(deps.queueRepo, row, "engine outcome resolved");
|
||||
continue;
|
||||
}
|
||||
// The explicit-only floor stopped this alert without a person. If the seat's prompt now needs one (a permission prompt
|
||||
// joined it), the permission leg still runs; accepted or deferred delivery stays exactly-once.
|
||||
const floorOnly = view.exhausted && !view.opEngineDispatched
|
||||
@@ -1552,10 +1579,13 @@ async function refreshEscalationRowIfExists(
|
||||
}
|
||||
|
||||
/** Existing recovery custody takes precedence over a newly resolved topology fallback. */
|
||||
function openEscalationOwner(db: Database.Database, dedupTag: string): string | null {
|
||||
return (db.prepare(`SELECT destination_session FROM queue_items WHERE state IN ('pending','in-progress','blocked')
|
||||
function openEscalationOwner(deps: WakeLadderDeps, dedupTag: string): string | null {
|
||||
const found = deps.db.prepare(`SELECT qitem_id FROM queue_items WHERE state IN ('pending','in-progress','blocked')
|
||||
AND json_valid(tags) AND EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?) LIMIT 1`)
|
||||
.get(dedupTag) as { destination_session: string } | undefined)?.destination_session ?? null;
|
||||
.get(dedupTag) as { qitem_id: string } | undefined;
|
||||
const row = found ? deps.queueRepo.getById(found.qitem_id) : null;
|
||||
// An unaccepted legacy person carrier is not an orchestrator; reselect the existing fallback/visible route.
|
||||
return row && (!knownPerson(deps, row.destinationSession) || gatewayOwnsEscalation(deps, row)) ? row.destinationSession : null;
|
||||
}
|
||||
|
||||
async function ensureEscalationRow(
|
||||
@@ -1576,6 +1606,12 @@ async function ensureEscalationRow(
|
||||
)
|
||||
.get(`%"${dedupTag}"%`) as { qitem_id: string } | undefined;
|
||||
if (existing) {
|
||||
const row = deps.queueRepo.getById(existing.qitem_id);
|
||||
if (row && knownPerson(deps, row.destinationSession) && !gatewayOwnsEscalation(deps, row)) {
|
||||
deps.queueRepo.routeToFallback(row.qitemId, knownPerson(deps, orch)
|
||||
? humanAlertCarrier(deps, dest, members[0]?.row.sourceSession) : orch,
|
||||
"unaccepted wake escalation uses a local carrier; human dispatch still requires readiness");
|
||||
}
|
||||
await refreshEscalationRowIfExists(deps, dest, members, dedupTag);
|
||||
return { qitemId: existing.qitem_id };
|
||||
}
|
||||
@@ -1591,7 +1627,7 @@ async function ensureEscalationRow(
|
||||
? resolveEscalationOwner(deps.db, dest, target => deps.queueRepo.acceptsDestination(target)) : null;
|
||||
const oneHop = route?.outcome === "routed" && route.owner === orch;
|
||||
const created = await deps.queueRepo.create({
|
||||
sourceSession: members[0]?.row.sourceSession ?? [...jobs.values()][0] ?? OPENRIG_SENDER,
|
||||
sourceSession: OPENRIG_SENDER,
|
||||
destinationSession: orch,
|
||||
body,
|
||||
summary: `Wake escalation: ${members.length} baton(s)${jobs.size ? ` and ${jobs.size} watchdog reminder(s)` : ""} stuck at ${dest} — ${reason}`,
|
||||
|
||||
@@ -196,7 +196,7 @@ export function startWakeLadderScheduler(deps: {
|
||||
seatActivityService?: Pick<import("./domain/seat-activity-service.js").SeatActivityService, "getSeatState">;
|
||||
agentActivityStore?: Pick<import("./domain/agent-activity-store.js").AgentActivityStore, "getLatestForNode">;
|
||||
tmuxAdapter?: Pick<import("./adapters/tmux.js").TmuxAdapter, "listPanes" | "readAllPaneProcesses" | "capturePaneScreen">;
|
||||
gatewaySubsystem?: { dispatch: (op: string, entityBindingRef: string, payload: unknown, opts?: { decisionId?: string }) => import("./domain/gateway/dispatcher.js").DispatchResult };
|
||||
gatewaySubsystem?: { status?: () => { state?: unknown }; dispatch: (op: string, entityBindingRef: string, payload: unknown, opts?: { decisionId?: string }) => import("./domain/gateway/dispatcher.js").DispatchResult };
|
||||
}): WakeLadderScheduler | null {
|
||||
const queueRepo = deps.queueRepo;
|
||||
if (!queueRepo) return null;
|
||||
@@ -211,6 +211,7 @@ export function startWakeLadderScheduler(deps: {
|
||||
? makeOperatorDeliveryEngine({
|
||||
home: OPENRIG_HOME,
|
||||
queueRepo,
|
||||
gatewayState: () => String(deps.gatewaySubsystem?.status?.().state ?? "unavailable"),
|
||||
dispatch: (op, ref, payload, opts) => deps.gatewaySubsystem!.dispatch(op, ref, payload, opts),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -1066,7 +1066,7 @@ describe("OPR.0.5.6.1 §8 — the production composition is live (R2 B-1/B-2/B-3
|
||||
try {
|
||||
wire.startServices?.();
|
||||
const make = (mod as { makeOperatorDeliveryEngine: (deps: unknown) => { dispatchEscalation: (row: QueueItem, reason: string) => Promise<{ decision: string; resolved: boolean }> } }).makeOperatorDeliveryEngine;
|
||||
const port = make({ home, queueRepo: repo, dispatch: (op: string, ref: string, payload: unknown) => wire.dispatcher.dispatch(op, ref, payload), registry: { loadHumanRegistry: () => registryWith({ deliveryClass: "B", availability: "available" }) } });
|
||||
const port = make({ home, readiness: async () => ({ ready: true, reason: "fixture" }), queueRepo: repo, dispatch: (op: string, ref: string, payload: unknown) => wire.dispatcher.dispatch(op, ref, payload), registry: { loadHumanRegistry: () => registryWith({ deliveryClass: "B", availability: "available" }) } });
|
||||
|
||||
const src = await repo.create({ sourceSession: "sender@r", destinationSession: "relay@r", body: "obligation" });
|
||||
const { created } = await repo.handoff({ qitemId: src.qitemId, fromSession: "relay@r", toSession: "worker@r", nudge: false });
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("the default person, for escalations and Mission Control's my-queue", (
|
||||
|
||||
const escalate = async () => {
|
||||
const sentTo: string[] = [];
|
||||
const engine = makeOperatorDeliveryEngine({ home, queueRepo: repo,
|
||||
const engine = makeOperatorDeliveryEngine({ home, ...{ readiness: async () => ({ ready: true, reason: "fixture" }) }, queueRepo: repo,
|
||||
dispatch: (_op, ref, _payload, opts) => { sentTo.push(ref); return { ok: true, decisionId: opts?.decisionId ?? "d" }; } });
|
||||
const row = await repo.create({ sourceSession: "sender@fixture", destinationSession: "worker@fixture", body: "stuck work", summary: "stuck", nudge: false });
|
||||
const result = await engine.dispatchEscalation(row as never, "blocked");
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("prompt-blocked outstanding work reaches the configured route", () => {
|
||||
});
|
||||
afterEach(() => { for (const w of wires.splice(0)) w.stop(); db.close(); rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
function engine(posts: unknown[], registered: boolean | (() => LoadResult) = true, to?: string[]) {
|
||||
function engine(posts: unknown[], registered: boolean | (() => LoadResult) = true, to?: string[], readiness = async () => ({ ready: true, reason: "fixture delivery ready" })) {
|
||||
const load = typeof registered === "function" ? registered : () => registered ? registry : { ok: true as const, entities: [] };
|
||||
const secrets = join(home, "slack.env");
|
||||
writeFileSync(secrets, "SLACK_BOT_TOKEN=fixture-only\n", { mode: 0o600 });
|
||||
@@ -72,7 +72,7 @@ describe("prompt-blocked outstanding work reaches the configured route", () => {
|
||||
},
|
||||
});
|
||||
wires.push(wire); wire.startServices?.();
|
||||
return makeOperatorDeliveryEngine({ home, queueRepo: repo,
|
||||
return makeOperatorDeliveryEngine({ home, queueRepo: repo, ...{ readiness },
|
||||
registry: { loadHumanRegistry: load },
|
||||
dispatch: (op, ref, payload, opts) => { to?.push(ref); return wire.dispatcher.dispatch(op, ref, payload, opts); },
|
||||
});
|
||||
@@ -144,12 +144,178 @@ describe("prompt-blocked outstanding work reaches the configured route", () => {
|
||||
expect(wakes).toEqual([]);
|
||||
expect(alerts()).toHaveLength(1);
|
||||
expect(alerts()[0]?.destinationSession).toBe(seat); // carrier only, never a pane nudge
|
||||
expect(alerts()[0]?.sourceSession).toBe(staleSource); // provenance is not rewritten
|
||||
expect(alerts()[0]?.sourceSession).toBe("daemon@kernel"); // scheduling is OpenRig; member provenance remains in its own row
|
||||
expect(JSON.stringify(posts)).toContain(row.qitemId);
|
||||
await tick(port);
|
||||
expect(posts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a human operator setting uses a local carrier and preserves explicit-only for a choice", async () => {
|
||||
vi.stubEnv("OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME", "human-owner@external");
|
||||
try {
|
||||
await refusal(1, `Refused: '${seat}' is at an interactive prompt (selection_prompt). No text was sent.`);
|
||||
const posts: unknown[] = [];
|
||||
await tick(engine(posts), { humanRung: "explicit-only", loadHumanRegistry: () => registry });
|
||||
expect(alerts()).toHaveLength(1);
|
||||
expect(alerts()[0]!.destinationSession).not.toBe("human-owner@external");
|
||||
expect(posts).toEqual([]);
|
||||
expect(repo.getById(episodeRows[0]!)!.state).toBe("in-progress");
|
||||
} finally { vi.unstubAllEnvs(); }
|
||||
});
|
||||
|
||||
it.each(["not-ready", "indeterminate"])("%s delivery retains one visible alert, then uses the same episode when ready", async status => {
|
||||
await refusal(); let ready = false; const posts: unknown[] = [];
|
||||
const port = engine(posts, true, undefined, async () => ({ ready, reason: status }));
|
||||
await tick(port); await tick(port);
|
||||
expect(posts).toEqual([]);
|
||||
expect(alerts()).toHaveLength(1);
|
||||
const id = alerts()[0]!.qitemId;
|
||||
expect(notes(id).join("\n")).toContain("unavailable:human-delivery");
|
||||
expect(notes(id).join("\n")).not.toContain("dispatched-to-engine");
|
||||
ready = true; await tick(port, { now: new Date(Date.now() + 240_000) });
|
||||
await vi.waitFor(() => expect(posts).toHaveLength(1));
|
||||
await tick(port); expect(posts).toHaveLength(1); expect(alerts()[0]!.qitemId).toBe(id);
|
||||
});
|
||||
|
||||
it("the real readiness check keeps a disabled connector out of the dispatch buffer", async () => {
|
||||
const row = await refusal(); const dispatch = vi.fn(() => ({ ok: true as const, decisionId: "disabled-fixture" }));
|
||||
const secrets = join(home, "disabled.env"); writeFileSync(secrets, "SLACK_BOT_TOKEN=fixture-only\n");
|
||||
saveConfig({ ...DEFAULT_CONFIG, enabled: false, channel: "CFIXTURE", secretsEnvFile: secrets }, home);
|
||||
const port = makeOperatorDeliveryEngine({ home, queueRepo: repo, registry: { loadHumanRegistry: () => registry }, dispatch });
|
||||
const result = await port.dispatchEscalation(row, "permission prompt");
|
||||
expect(result).toMatchObject({ dispatched: false, resolved: false });
|
||||
expect(result.decision).toContain("unavailable:human-delivery");
|
||||
expect(dispatch).not.toHaveBeenCalled(); expect(new DispatchBuffer(home).pending()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([true, false])("an undelivered notice about a ladder alert does not recurse (ladder=%s)", async ladderNotice => {
|
||||
const notice = await repo.create({ sourceSession: "sender@fixture", destinationSession: "human-owner@external",
|
||||
body: "original undelivered notice", tags: ladderNotice ? ["wake-escalation"] : [], nudge: false });
|
||||
const finding = await refusal();
|
||||
db.prepare("UPDATE queue_items SET tags=? WHERE qitem_id=?").run(JSON.stringify([
|
||||
`stuck-sweep:undelivered-human-notice:${notice.qitemId}`, `recovery-for:${notice.qitemId}`,
|
||||
]), finding.qitemId);
|
||||
const posts: unknown[] = []; const port = engine(posts);
|
||||
await tick(port); await tick(port);
|
||||
expect(alerts()).toHaveLength(ladderNotice ? 0 : 1);
|
||||
if (!ladderNotice) await vi.waitFor(() => expect(posts).toHaveLength(1));
|
||||
else expect(posts).toEqual([]);
|
||||
expect(repo.getById(finding.qitemId)!.state).toBe("in-progress");
|
||||
expect(repo.getById(notice.qitemId)!.state).toBe("pending");
|
||||
});
|
||||
|
||||
it("an unreadable human registry still permits a valid agent carrier", async () => {
|
||||
vi.stubEnv("OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME", "ops@fixture");
|
||||
try {
|
||||
await refusal(); const posts: unknown[] = [];
|
||||
await tick(engine(posts, false), { loadHumanRegistry: () => ({ ok: false, error: "no registered people" }) });
|
||||
expect(alerts()).toHaveLength(1);
|
||||
expect(alerts()[0]!.destinationSession).toBe("ops@fixture");
|
||||
expect(posts).toEqual([]);
|
||||
} finally { vi.unstubAllEnvs(); }
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ choice: true, orchMarker: false }, { choice: true, orchMarker: true },
|
||||
{ choice: false, orchMarker: false }, { choice: false, orchMarker: true },
|
||||
])("an unaccepted old human carrier is repaired in place ($choice/$orchMarker)", async ({ choice, orchMarker }) => {
|
||||
await refusal(1, `Refused: '${seat}' is at an interactive prompt (${choice ? "selection_prompt" : "target_needs_input"}). No text was sent.`);
|
||||
let ready = false; const posts: unknown[] = [];
|
||||
const port = engine(posts, true, undefined, async () => ({ ready, reason: "not-ready" }));
|
||||
await tick(port, { humanRung: "always" });
|
||||
const old = alerts()[0]!;
|
||||
db.prepare("UPDATE queue_items SET destination_session=? WHERE qitem_id=?").run("human-owner@external", old.qitemId);
|
||||
if (!orchMarker) db.prepare("DELETE FROM queue_transitions WHERE qitem_id=? AND transition_note LIKE 'escalation-rung: orchestrator%'").run(old.qitemId);
|
||||
ready = choice; // a choice must not use restored readiness to bypass explicit-only
|
||||
const wakes: string[] = [];
|
||||
await tick(port, { humanRung: "explicit-only", loadHumanRegistry: () => registry, now: new Date(Date.now() + 240_000),
|
||||
attemptWake: async (_id, target) => { wakes.push(target); return "failed:fixture"; } });
|
||||
expect(alerts()).toHaveLength(1);
|
||||
expect(alerts()[0]!.qitemId).toBe(old.qitemId);
|
||||
expect(alerts()[0]!.body).toBe(old.body);
|
||||
expect(alerts()[0]!.destinationSession).not.toBe("human-owner@external");
|
||||
expect(wakes).toEqual([]); expect(posts).toEqual([]);
|
||||
expect(repo.getById(episodeRows[0]!)!.state).toBe("in-progress");
|
||||
if (!choice) {
|
||||
ready = true; await tick(port, { humanRung: "explicit-only", now: new Date(Date.now() + 360_000) });
|
||||
await vi.waitFor(() => expect(posts).toHaveLength(1));
|
||||
expect(alerts()[0]!.qitemId).toBe(old.qitemId);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([true, false])("a repaired carrier wakes its newly configured lead before the human rung (choice=%s)", async choice => {
|
||||
await refusal(1, `Refused: '${seat}' is at an interactive prompt (${choice ? "selection_prompt" : "target_needs_input"}). No text was sent.`);
|
||||
const posts: unknown[] = []; let ready = false;
|
||||
const port = engine(posts, true, undefined, async () => ({ ready, reason: "fixture" }));
|
||||
await tick(port, { humanRung: "always" });
|
||||
const old = alerts()[0]!;
|
||||
db.prepare("UPDATE queue_items SET destination_session=? WHERE qitem_id=?").run("human-owner@external", old.qitemId);
|
||||
expect(notes(old.qitemId).join("\n")).toContain("orchestrator self-skip");
|
||||
if (choice) repo.transitionLog.append({ qitemId: old.qitemId, state: old.state, actorSession: "wake-ladder@system",
|
||||
transitionNote: "ladder-exhausted: explicit-only floor: a visible row, not posted to a person" });
|
||||
ready = true; const wakes: string[] = [];
|
||||
const opts = { humanRung: "explicit-only" as const, loadHumanRegistry: () => registry, resolveOrchestrator: () => "lead@fixture",
|
||||
attemptWake: async (_id: string, target: string) => { wakes.push(target); return "failed:offline"; } };
|
||||
await tick(port, opts);
|
||||
expect(wakes).toEqual(["lead@fixture"]); expect(posts).toEqual([]);
|
||||
expect(alerts()[0]!.qitemId).toBe(old.qitemId); expect(alerts()[0]!.body).toBe(old.body);
|
||||
expect(alerts()[0]!.destinationSession).toBe("lead@fixture");
|
||||
await tick(port, { ...opts, now: new Date(Date.now() + 240_000) });
|
||||
if (choice) expect(posts).toEqual([]);
|
||||
else await vi.waitFor(() => expect(posts).toHaveLength(1));
|
||||
expect(wakes).toEqual(["lead@fixture"]);
|
||||
expect(repo.getById(episodeRows[0]!)!.state).toBe("in-progress");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: "posted", boundary: "progress" }, { kind: "terminated", boundary: "progress" },
|
||||
{ kind: "posted", boundary: "dispatch" }, { kind: "terminated", boundary: "dispatch" },
|
||||
])("a fixed-key old $kind does not resolve a later $boundary episode", async ({ kind, boundary }) => {
|
||||
const row = await refusal(); const key = `${row.qitemId}:operator-rung`;
|
||||
expect(repo.transitionLog.latestOwnerNotificationForQitem(row.qitemId)).toBeNull();
|
||||
repo.transitionLog.append({ qitemId: row.qitemId, state: row.state, actorSession: "daemon@kernel",
|
||||
transitionNote: `${kind === "posted" ? "slack-owner-notification-posted" : "delivery-termination:"} notification_key=${key}` });
|
||||
if (boundary === "progress") await repo.update({ qitemId: row.qitemId, actorSession: seat, transitionNote: "continuing the obligation" });
|
||||
else repo.transitionLog.append({ qitemId: row.qitemId, state: row.state, actorSession: "wake-ladder@system",
|
||||
transitionNote: `escalation-rung: operator dispatched-to-engine notification_key=${key}` });
|
||||
const posts: unknown[] = []; const port = engine(posts);
|
||||
expect(port.hasAcceptedEscalation!(repo.getById(row.qitemId)!)).toBe(false);
|
||||
expect(await port.dispatchEscalation(repo.getById(row.qitemId)!, "later episode")).toMatchObject({ dispatched: true, resolved: false });
|
||||
await vi.waitFor(() => expect(posts).toHaveLength(1));
|
||||
expect(port.hasAcceptedEscalation!(repo.getById(row.qitemId)!)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true, "posted", "terminated"] as const)("an accepted old human carrier is not rerouted or woken (marker=%s)", async marker => {
|
||||
await refusal(1, `Refused: '${seat}' is at an interactive prompt (selection_prompt). No text was sent.`);
|
||||
await tick(engine([], false), { humanRung: "always" });
|
||||
const old = alerts()[0]!;
|
||||
db.prepare("UPDATE queue_items SET destination_session=? WHERE qitem_id=?").run("human-owner@external", old.qitemId);
|
||||
db.prepare("DELETE FROM queue_transitions WHERE qitem_id=? AND transition_note LIKE 'escalation-rung: orchestrator%'").run(old.qitemId);
|
||||
const buffer = new DispatchBuffer(home);
|
||||
const dispatcher = new GatewayDispatcher({ buffer, send: () => { throw new Error("fixture interrupted after persistence"); } });
|
||||
dispatcher.onCapability({ kind: "capability", connectorId: "fixture", platform: "slack", protocolVersion: 1, ops: ["post_message"] });
|
||||
const accepting = makeOperatorDeliveryEngine({ home, queueRepo: repo, registry: { loadHumanRegistry: () => registry },
|
||||
readiness: async () => ({ ready: true, reason: "fixture" }), dispatch: (op, ref, payload, opts) => dispatcher.dispatch(op, ref, payload, opts) });
|
||||
const accepted = await accepting.dispatchEscalation(repo.getById(old.qitemId)!, "permission prompt");
|
||||
expect(accepted.dispatched).toBe(true); const pending = buffer.pending(); expect(pending).toHaveLength(1);
|
||||
if (marker === true) repo.transitionLog.append({ qitemId: old.qitemId, state: old.state, actorSession: "wake-ladder@system",
|
||||
transitionNote: `escalation-rung: operator dispatched-to-engine decision=accepted-pending resolved=false notification_key=${accepted.notificationKey} decision_id=${accepted.decisionId}` });
|
||||
if (marker === "posted" || marker === "terminated") {
|
||||
buffer.ack(pending[0]!.decisionId);
|
||||
repo.transitionLog.append({ qitemId: old.qitemId, state: old.state, actorSession: "gateway@system",
|
||||
transitionNote: `${marker === "posted" ? "slack-owner-notification-posted" : "delivery-termination:"} notification_key=${accepted.notificationKey}` });
|
||||
}
|
||||
const expectedPending = buffer.pending();
|
||||
const readReady = vi.fn(async () => ({ ready: false, reason: "unavailable after acceptance" }));
|
||||
const send = vi.fn(() => { throw new Error("must not dispatch again"); }); const wakes = vi.fn(async () => "failed:fixture");
|
||||
const port = makeOperatorDeliveryEngine({ home, queueRepo: repo, registry: { loadHumanRegistry: () => registry }, readiness: readReady, dispatch: send });
|
||||
await tick(port, { humanRung: "explicit-only", loadHumanRegistry: () => registry, now: new Date(Date.now() + 240_000), attemptWake: wakes });
|
||||
expect(repo.getById(old.qitemId)!.destinationSession).toBe("human-owner@external");
|
||||
expect(repo.getById(old.qitemId)!.body).toBe(old.body);
|
||||
expect(buffer.pending()).toEqual(expectedPending); expect(wakes).not.toHaveBeenCalled();
|
||||
expect(send).not.toHaveBeenCalled(); expect(readReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("an invalid sender does not turn an explicit-only choice alert into a human post", async () => {
|
||||
const row = await refusal(1, `Refused: '${seat}' is at an interactive prompt (selection_prompt). No text was sent.`);
|
||||
const staleSource = "sender@remote@retired-host";
|
||||
@@ -435,6 +601,8 @@ describe("prompt-blocked outstanding work reaches the configured route", () => {
|
||||
expect(classifyPromptAfterRefusal({ ...idle, needsInput: { count: 1, reason: "permission prompt" } }, refusedAt)).toBe("blocked");
|
||||
expect(classifyPromptAfterRefusal({ ...idle, activity: "unknown", changedAt: after, needsInputEvidence: clearAt(after) }, refusedAt)).toBe("unknown");
|
||||
expect(classifyPromptAfterRefusal(null, refusedAt)).toBe("unknown");
|
||||
expect(classifyPromptAfterRefusal({ ...idle, activity: "working", needsInputEvidence: clearAt(after) }, refusedAt)).toBe("clear");
|
||||
expect(classifyPromptAfterRefusal({ ...idle, activity: "working", needsInputEvidence: null }, refusedAt)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("activity-only Codex and generic changes cannot clear a refused prompt", () => {
|
||||
@@ -656,7 +824,7 @@ describe("prompt-blocked outstanding work reaches the configured route", () => {
|
||||
const row = await refusal(); const buffer = new DispatchBuffer(home); let sends = 0;
|
||||
const dispatcher = new GatewayDispatcher({ buffer, send: () => { sends++; throw new Error("connector disconnected after persist"); } });
|
||||
dispatcher.onCapability({ kind: "capability", connectorId: "fixture", platform: "slack", protocolVersion: 1, ops: ["post_message"] });
|
||||
const port = makeOperatorDeliveryEngine({ home, queueRepo: repo, registry: { loadHumanRegistry: () => registry },
|
||||
const port = makeOperatorDeliveryEngine({ home, queueRepo: repo, ...{ readiness: async () => ({ ready: true, reason: "fixture" }) }, registry: { loadHumanRegistry: () => registry },
|
||||
dispatch: (op, ref, payload, opts) => dispatcher.dispatch(op, ref, payload, opts) });
|
||||
expect(await port.dispatchEscalation(row, "blocked")).toMatchObject({ decision: "accepted-pending", dispatched: true, resolved: false });
|
||||
const pending = new DispatchBuffer(home).pending(); expect(pending).toHaveLength(1);
|
||||
@@ -697,7 +865,7 @@ describe("prompt-blocked outstanding work reaches the configured route", () => {
|
||||
it("an unadvertised connector is unresolved with no buffer entry or repeated note", async () => {
|
||||
await refusal(); const sends = vi.fn();
|
||||
const dispatcher = new GatewayDispatcher({ buffer: new DispatchBuffer(home), send: sends });
|
||||
const port = makeOperatorDeliveryEngine({ home, queueRepo: repo, registry: { loadHumanRegistry: () => registry },
|
||||
const port = makeOperatorDeliveryEngine({ home, queueRepo: repo, ...{ readiness: async () => ({ ready: true, reason: "fixture" }) }, registry: { loadHumanRegistry: () => registry },
|
||||
dispatch: (op, ref, payload, opts) => dispatcher.dispatch(op, ref, payload, opts) });
|
||||
await tick(port); const id = alerts()[0]!.qitemId; const before = repo.listTransitions(id);
|
||||
await tick(port); await tick(port);
|
||||
|
||||
@@ -469,7 +469,7 @@ describe("S01 wake-or-escalate — retry ladder, named rungs, derived suspension
|
||||
const tag = consumed ? `consumed-hop:${baton.qitemId}` : (await ladderMod()).escalationDedupTag(baton.destinationSession);
|
||||
const carriers = repo.list({ limit: 500 }).filter(row => row.tags?.includes(tag));
|
||||
expect(carriers).toHaveLength(1);
|
||||
expect(carriers[0]).toMatchObject({ sourceSession: staleSource, destinationSession: baton.destinationSession });
|
||||
expect(carriers[0]).toMatchObject({ sourceSession: "daemon@kernel", destinationSession: baton.destinationSession });
|
||||
await tick(overrides);
|
||||
expect(posted).toEqual([baton.qitemId]);
|
||||
});
|
||||
|
||||
@@ -316,7 +316,7 @@ describe("holding and releasing through the gateway", () => {
|
||||
quietNow();
|
||||
const row = await park();
|
||||
const dispatched: unknown[] = [];
|
||||
const engine = makeOperatorDeliveryEngine({ home, queueRepo: repo, registry: { loadHumanRegistry: () => registryWith(prefs) },
|
||||
const engine = makeOperatorDeliveryEngine({ home, ...{ readiness: async () => ({ ready: true, reason: "fixture" }) }, queueRepo: repo, registry: { loadHumanRegistry: () => registryWith(prefs) },
|
||||
dispatch: (_op, _ref, payload) => { dispatched.push(payload); return { ok: true, decisionId: "d" } as never; } });
|
||||
const out = await engine.dispatchEscalation(row, "test");
|
||||
expect(out).toMatchObject({ dispatched: true, decision: "interrupt" });
|
||||
|
||||
@@ -48,8 +48,8 @@ describe("a wake-rung escalation names the seat the work is waiting on", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "sender-first-"));
|
||||
try {
|
||||
const sent: Array<Record<string, unknown>> = [];
|
||||
const engine = makeOperatorDeliveryEngine({ home, registry,
|
||||
queueRepo: { transitionLog: { latestOwnerNotificationForQitem: () => null }, db: undefined } as never,
|
||||
const engine = makeOperatorDeliveryEngine({ home, ...{ readiness: async () => ({ ready: true, reason: "fixture" }) }, registry,
|
||||
queueRepo: { transitionLog: { latestOwnerNotificationForQitem: () => null, hasOwnerNotificationReceipt: () => false }, listTransitions: () => [], db: undefined } as never,
|
||||
dispatch: (_op, _ref, payload) => { sent.push(payload as Record<string, unknown>); return { ok: true, decisionId: "d" } as never; } });
|
||||
await engine.dispatchEscalation({ qitemId: "q-stuck", body: "Please review.", tags: [], ...row } as QueueItem, "unclaimed");
|
||||
return sent[0]!;
|
||||
|
||||
Reference in new issue
Block a user