mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
108f704375 | ||
|
|
9f610e02dd |
No files matched your search
@@ -96,8 +96,9 @@ 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 keeps agent work 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)
|
||||
|
||||
@@ -353,7 +353,12 @@ your context, your compaction and your replacement — and it is the only thing
|
||||
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. A prompt alert uses a local agent carrier; the operator delivery engine
|
||||
checks the default person's connector readiness before dispatch. An unavailable or uncertain route
|
||||
stays visible with its reason and is re-resolved on later ticks; it is not a posted notice. A
|
||||
configured escalation route still runs first. Findings about an undelivered wake-escalation notice
|
||||
remain visible but do not create another wake escalation. Scheduling notices are sent by OpenRig,
|
||||
while their member rows retain 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 {
|
||||
@@ -61,6 +63,8 @@ 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 {
|
||||
return {
|
||||
async dispatchEscalation(row: QueueItem, _reason: string) {
|
||||
@@ -89,6 +93,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
|
||||
@@ -1087,7 +1087,15 @@ function waitsOnPostedPromptAlert(deps: WakeLadderDeps, row: QueueItem, choiceOn
|
||||
// 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 && !addressedToPerson(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 +1148,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);
|
||||
@@ -1591,7 +1599,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,130 @@ 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([false, true])("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) 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}` });
|
||||
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(pending); 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 +553,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 +776,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 +817,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,7 +48,7 @@ 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,
|
||||
const engine = makeOperatorDeliveryEngine({ home, ...{ readiness: async () => ({ ready: true, reason: "fixture" }) }, registry,
|
||||
queueRepo: { transitionLog: { latestOwnerNotificationForQitem: () => null }, 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");
|
||||
|
||||
Reference in new issue
Block a user