mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
466f6898f0 | ||
|
|
e20936fd28 | ||
|
|
61f7109a22 | ||
|
|
5991dde325 |
No files matched your search
@@ -1240,6 +1240,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--reopen", "Explicitly acknowledge a deliberate terminal-to-active repair; requires --state and --note")
|
||||
.option("--closure-reason <reason>", "Required for state=done. --state canceled takes no reason, except 'superseded' (with --closure-target = the successor), which records a supersession, distinct from an abandoned cancel")
|
||||
.option("--closure-target <target>", "Required for handed_off_to, escalation, and superseded; blocked_on park records use state=blocked with --blocked-on")
|
||||
.option("--closure-required-at <iso>", "Accept a revised closure deadline on an in-progress item; keeps the claim and records the old/new time and accepting actor")
|
||||
.option("--resolution <text>", "The outcome, stored on the row and shown by rig queue show. Needs --state done, canceled, denied, failed or handed-off, and non-blank text. Stored when the update closes an open row, after anything the row already has (a fallback line, an earlier outcome); a row that is already closed keeps what it has")
|
||||
.option("--blocked-on <blocker>", "For state=blocked: the blocker — a qitem id (must exist and be live), a human seat (FR-6 park; requires summary + evidence_ref), or a typed non-qitem gate 'fold:<what>' / 'auth:<what>' / 'external:<what>'")
|
||||
.option("--wake-watchdog <jobId>", "For state=blocked: attach an existing live watchdog id targeting the row owner")
|
||||
@@ -1255,6 +1256,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
reopen?: boolean;
|
||||
closureReason?: string;
|
||||
closureTarget?: string;
|
||||
closureRequiredAt?: string;
|
||||
resolution?: string;
|
||||
blockedOn?: string;
|
||||
wakeWatchdog?: string;
|
||||
@@ -1291,6 +1293,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
reopen: opts.reopen,
|
||||
closureReason: opts.closureReason,
|
||||
closureTarget: opts.closureTarget,
|
||||
...(opts.closureRequiredAt !== undefined ? { closureRequiredAt: opts.closureRequiredAt } : {}),
|
||||
resolution: opts.resolution,
|
||||
blockedOn: opts.blockedOn,
|
||||
wakeWatchdogId: opts.wakeWatchdog,
|
||||
|
||||
@@ -1204,6 +1204,17 @@ describe("rig queue CLI", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("update forwards an explicitly accepted closure deadline without a state change", async () => {
|
||||
const { deps, calls } = makeDeps();
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync(["node", "rig", "queue", "update", "qitem-estimate", "--closure-required-at", "2026-11-04T10:00:00.000Z", "--note", "Coordinator accepted the revised estimate", "--json"]);
|
||||
expect(calls.find(c => c.path === "/api/queue/qitem-estimate/update")?.body).toMatchObject({
|
||||
closureRequiredAt: "2026-11-04T10:00:00.000Z", transitionNote: "Coordinator accepted the revised estimate",
|
||||
});
|
||||
expect(calls.find(c => c.path === "/api/queue/qitem-estimate/update")?.body).toHaveProperty("state", undefined);
|
||||
});
|
||||
|
||||
it("update forwards explicit reopen acknowledgment", async () => {
|
||||
const { deps, calls } = makeDeps();
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
|
||||
@@ -417,6 +417,8 @@ export interface QueueUpdateInput {
|
||||
qitemId: string;
|
||||
actorSession: string;
|
||||
state?: QueueState;
|
||||
/** The actor accepts this revised closure deadline without replacing the claim. */
|
||||
closureRequiredAt?: string;
|
||||
/** Explicit acknowledgment for a deliberate terminal → active repair. */
|
||||
reopen?: boolean;
|
||||
/**
|
||||
@@ -3005,15 +3007,28 @@ export class QueueRepository {
|
||||
`qitem ${input.qitemId} not found`
|
||||
);
|
||||
}
|
||||
const hasReestimate = input.closureRequiredAt !== undefined;
|
||||
let closureRequiredAt: string | undefined;
|
||||
if (hasReestimate) {
|
||||
if (qitem.state !== "in-progress" || (input.state !== undefined && input.state !== "in-progress")) {
|
||||
throw new QueueRepositoryError("qitem_not_in_progress", "a revised closure deadline updates an in-progress item without changing its state; nothing was written");
|
||||
}
|
||||
const deadline = typeof input.closureRequiredAt === "string" ? Date.parse(input.closureRequiredAt) : NaN;
|
||||
if (!Number.isFinite(deadline)) {
|
||||
throw new QueueRepositoryError("note_append_fields_not_admitted", "--closure-required-at needs a valid timestamp; nothing was written");
|
||||
}
|
||||
closureRequiredAt = new Date(deadline).toISOString();
|
||||
}
|
||||
const hasNote = typeof input.transitionNote === "string" && input.transitionNote.trim().length > 0;
|
||||
const hasValidSummary = typeof input.summary === "string" && input.summary.trim().length > 0;
|
||||
const isGuardedTerminal = (["done", "canceled", "handed-off"] as const).includes(
|
||||
qitem.state as "done" | "canceled" | "handed-off",
|
||||
);
|
||||
const isStatePreservingAppend = input.state === undefined || (isGuardedTerminal && input.state === qitem.state);
|
||||
const isStatePreservingAppend = input.state === undefined || (isGuardedTerminal && input.state === qitem.state)
|
||||
|| (hasReestimate && input.state === qitem.state);
|
||||
|
||||
if (isStatePreservingAppend) {
|
||||
if (input.state === undefined && !hasNote && !hasValidSummary) {
|
||||
if (input.state === undefined && !hasNote && !hasValidSummary && !hasReestimate) {
|
||||
throw new QueueRepositoryError(
|
||||
"state_or_note_required",
|
||||
"queue update requires --state, a non-empty --note, or a non-empty --summary; nothing was written",
|
||||
@@ -3056,12 +3071,18 @@ export class QueueRepository {
|
||||
this.persistSummary(input.qitemId, input.summary!);
|
||||
}
|
||||
|
||||
const note = hasValidSummary
|
||||
let note = hasValidSummary
|
||||
? (hasNote
|
||||
? (input.transitionNote!.includes("(summary updated)") ? input.transitionNote : `${input.transitionNote} (summary updated)`)
|
||||
: "summary updated")
|
||||
: input.transitionNote;
|
||||
|
||||
if (hasReestimate) {
|
||||
this.db.prepare("UPDATE queue_items SET closure_required_at = ? WHERE qitem_id = ?").run(closureRequiredAt!, input.qitemId);
|
||||
const receipt = `closure deadline re-estimated: ${qitem.closureRequiredAt ?? "(none)"} -> ${closureRequiredAt}; accepted by ${input.actorSession}`;
|
||||
note = hasNote || hasValidSummary ? `${note}\n${receipt}` : receipt;
|
||||
}
|
||||
|
||||
this.transitionLog.append({
|
||||
qitemId: input.qitemId,
|
||||
state: qitem.state,
|
||||
|
||||
@@ -933,8 +933,14 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
for (const c of [...new Map(candidates.map(c => [c.row.qitemId, c])).values()]) {
|
||||
const dedupTag = findingDedupTag(c.kind, c.row.qitemId);
|
||||
const shared = findQueueRecovery(deps.db, c.row.qitemId);
|
||||
if (shared) {
|
||||
const existingTags = deps.queueRepo.getById(shared.qitemId)?.tags ?? [];
|
||||
const sharedRow = shared ? deps.queueRepo.getById(shared.qitemId) : null;
|
||||
// A later accepted deadline can expire after the previous finding closed.
|
||||
// Its newer time is fresh overdue evidence; an OPEN recovery still owns custody.
|
||||
const renewedDeadline = c.kind === "overdue-claim" && sharedRow
|
||||
&& !["pending", "in-progress", "blocked"].includes(sharedRow.state)
|
||||
&& evidenceIsNewer(c.row.closureRequiredAt ?? "", sharedRow.tsUpdated);
|
||||
if (shared && !renewedDeadline) {
|
||||
const existingTags = sharedRow?.tags ?? [];
|
||||
for (const tag of existingTags) if (tag.startsWith("stuck-sweep:")) liveDedupTags.add(tag);
|
||||
if (["pending", "in-progress", "blocked"].includes(shared.state)) findings.push({ kind: c.kind, qitemId: c.row.qitemId, findingQitemId: shared.qitemId, action: "refreshed" });
|
||||
continue;
|
||||
|
||||
@@ -706,6 +706,7 @@ export function queueRoutes(): Hono {
|
||||
classificationBinding?: unknown;
|
||||
state?: QueueState;
|
||||
reopen?: boolean;
|
||||
closureRequiredAt?: string;
|
||||
transitionNote?: string;
|
||||
closureReason?: string;
|
||||
closureTarget?: string;
|
||||
@@ -733,6 +734,7 @@ export function queueRoutes(): Hono {
|
||||
actorSession,
|
||||
state: body.state,
|
||||
reopen: body.reopen,
|
||||
closureRequiredAt: body.closureRequiredAt,
|
||||
transitionNote: body.transitionNote,
|
||||
closureReason: body.closureReason,
|
||||
closureTarget: body.closureTarget,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { Hono } from "hono";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { OutboxHandler } from "../src/domain/outbox-handler.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { runStuckSweep } from "../src/domain/queue-stuck-sweep.js";
|
||||
import { recoveryId } from "../src/domain/queue-recovery.js";
|
||||
import { lastMeaningfulTransition } from "../src/domain/queue-waiting.js";
|
||||
import { queueRoutes } from "../src/routes/queue.js";
|
||||
|
||||
describe("accepted queue re-estimates (#2453)", () => {
|
||||
let db: Database.Database, repo: QueueRepository, app: Hono;
|
||||
const later = "2026-11-03T13:00:00.000Z";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-11-03T12:00:00.000Z"));
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const bus = new EventBus(db), outbox = new OutboxHandler(db);
|
||||
repo = new QueueRepository(db, bus, {
|
||||
validateRig: () => true, resolveOccupantGeneration: () => "estimate-generation",
|
||||
loadHumanRegistry: () => ({ ok: true, entities: [] }),
|
||||
});
|
||||
repo.attachOutbox(outbox);
|
||||
app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("queueRepo" as never, repo); c.set("eventBus" as never, bus); c.set("outboxHandler" as never, outbox);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/queue", queueRoutes());
|
||||
});
|
||||
afterEach(() => { db.close(); vi.useRealTimers(); vi.unstubAllEnvs(); });
|
||||
|
||||
async function overdue() {
|
||||
const row = await repo.create({ sourceSession: "coordinator@fixture", destinationSession: "worker@fixture", body: "work", tier: "fast", nudge: false });
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: row.destinationSession });
|
||||
db.prepare("UPDATE queue_items SET closure_required_at = ? WHERE qitem_id = ?").run("2026-11-03T11:59:00.000Z", row.qitemId);
|
||||
return repo.getById(row.qitemId)!;
|
||||
}
|
||||
const claim = (id: string) => db.prepare("SELECT state, claimed_at, claimed_by_generation_uuid FROM queue_items WHERE qitem_id = ?").get(id);
|
||||
const post = (id: string, body: Record<string, unknown>) => app.request(`/api/queue/${id}/update`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json", "X-OpenRig-Session": "coordinator@fixture" }, body: JSON.stringify(body),
|
||||
});
|
||||
const accept = (id: string) => post(id, { closureRequiredAt: later, actorSession: "body-claim@fixture", transitionNote: "Accepted an extra hour for the remaining work" });
|
||||
const sweep = () => runStuckSweep({
|
||||
db, queueRepo: repo, now: new Date(), unclaimedAgeMinutes: 60,
|
||||
resolveOrchestrator: () => null, sessionConfirmedMissing: () => false,
|
||||
resolveRecoveryAgentSeat: () => null, resolveOperatorSeat: () => null, isRegisteredHost: () => false, log: () => {},
|
||||
});
|
||||
|
||||
it("accepts a deadline through the real update route with old/new/actor provenance and the original claim", async () => {
|
||||
const row = await overdue(), before = claim(row.qitemId);
|
||||
const response = await accept(row.qitemId);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ closureRequiredAt: later });
|
||||
expect(claim(row.qitemId)).toEqual(before);
|
||||
const receipt = repo.transitionLog.listForQitem(row.qitemId).at(-1)!;
|
||||
expect(receipt).toMatchObject({ state: "in-progress", actorSession: "coordinator@fixture", identityProvenance: "transport:v1" });
|
||||
expect(receipt.transitionNote).toContain(row.closureRequiredAt);
|
||||
expect(receipt.transitionNote).toContain(later);
|
||||
expect(receipt.transitionNote).toContain("Accepted an extra hour");
|
||||
});
|
||||
|
||||
it("stops reporting overdue until the accepted deadline actually expires", async () => {
|
||||
const row = await overdue();
|
||||
expect(repo.findOverdue().map(r => r.qitemId)).toContain(row.qitemId);
|
||||
expect((await accept(row.qitemId)).status).toBe(200);
|
||||
expect(repo.findOverdue().map(r => r.qitemId)).not.toContain(row.qitemId);
|
||||
expect((await sweep()).findings.filter(f => f.qitemId === row.qitemId && f.kind === "overdue-claim")).toEqual([]);
|
||||
vi.setSystemTime(new Date(Date.parse(later) + 1));
|
||||
expect(repo.findOverdue().map(r => r.qitemId)).toContain(row.qitemId);
|
||||
expect((await sweep()).findings).toContainEqual(expect.objectContaining({ qitemId: row.qitemId, kind: "overdue-claim", action: "created" }));
|
||||
});
|
||||
|
||||
it("closes an already-open overdue finding on the next sweep without closing or reclaiming the work", async () => {
|
||||
const row = await overdue(), before = claim(row.qitemId);
|
||||
const initial = await sweep();
|
||||
const finding = initial.findings.find(f => f.qitemId === row.qitemId && f.kind === "overdue-claim")!;
|
||||
expect(finding.action).toBe("created");
|
||||
const owner = repo.getById(finding.findingQitemId)!;
|
||||
expect((await accept(row.qitemId)).status).toBe(200);
|
||||
expect((await sweep()).findings).toContainEqual({ ...finding, action: "closed" });
|
||||
expect(repo.getById(finding.findingQitemId)).toMatchObject({ state: "done", closureReason: "no-follow-on", sourceSession: owner.sourceSession, destinationSession: owner.destinationSession });
|
||||
expect(claim(row.qitemId)).toEqual(before);
|
||||
vi.setSystemTime(new Date(Date.parse(later) + 1));
|
||||
const expired = (await sweep()).findings.find(f => f.qitemId === row.qitemId && f.kind === "overdue-claim")!;
|
||||
expect(expired.action).toBe("created");
|
||||
expect(expired.findingQitemId).not.toBe(finding.findingQitemId);
|
||||
expect(claim(row.qitemId)).toEqual(before);
|
||||
});
|
||||
|
||||
it("creates one live overdue after a post-estimate pickup recovery closes before the revised deadline", async () => {
|
||||
vi.stubEnv("OPENRIG_QUEUE_PICKUP_STALL_THRESHOLD_MINUTES", "3");
|
||||
const row = await overdue(), before = claim(row.qitemId);
|
||||
expect((await accept(row.qitemId)).status).toBe(200);
|
||||
const accepted = lastMeaningfulTransition(db, row.qitemId);
|
||||
vi.setSystemTime(new Date("2026-11-03T12:10:00.000Z"));
|
||||
const pickup = (await sweep()).findings.find(f => f.qitemId === row.qitemId && f.kind === "stalled-after-claim")!;
|
||||
expect(pickup.action).toBe("created");
|
||||
expect(pickup.findingQitemId).toBe(recoveryId(db, row.qitemId));
|
||||
repo.update({ qitemId: pickup.findingQitemId, actorSession: "coordinator@fixture", state: "done", closureReason: "no-follow-on", transitionNote: "Owner is working; pickup recovery resolved" });
|
||||
repo.attachActivityReader(() => ({ activity: "working", needsInput: { count: 0, reason: null }, decidedBy: "lifecycle-hooks" }));
|
||||
vi.setSystemTime(new Date(Date.parse(later) + 1));
|
||||
expect(repo.getById(row.qitemId)?.pickup?.state).toBe("working");
|
||||
const expired = (await sweep()).findings.find(f => f.qitemId === row.qitemId && f.kind === "overdue-claim")!;
|
||||
expect(expired.action).toBe("created");
|
||||
expect(lastMeaningfulTransition(db, row.qitemId)).toEqual(accepted);
|
||||
expect(recoveryId(db, row.qitemId)).toBe(pickup.findingQitemId);
|
||||
const live = repo.list({ limit: 100 }).filter(r => ["pending", "in-progress", "blocked"].includes(r.state) && r.tags?.includes(`recovery-for:${row.qitemId}`));
|
||||
expect(live).toHaveLength(1);
|
||||
expect(live[0]!.qitemId).toBe(expired.findingQitemId);
|
||||
expect(expired.findingQitemId).not.toBe(pickup.findingQitemId);
|
||||
expect(repo.getById(pickup.findingQitemId)?.state).toBe("done");
|
||||
expect((await sweep()).findings).toContainEqual({ ...expired, action: "refreshed" });
|
||||
expect(claim(row.qitemId)).toEqual(before);
|
||||
});
|
||||
|
||||
it("leaves an unrelated overdue sibling reportable", async () => {
|
||||
const row = await overdue(), sibling = await overdue();
|
||||
expect((await accept(row.qitemId)).status).toBe(200);
|
||||
const findings = (await sweep()).findings.filter(f => f.kind === "overdue-claim");
|
||||
expect(findings.some(f => f.qitemId === sibling.qitemId)).toBe(true);
|
||||
expect(findings.some(f => f.qitemId === row.qitemId)).toBe(false);
|
||||
expect(repo.getById(sibling.qitemId)?.closureRequiredAt).toBe(sibling.closureRequiredAt);
|
||||
});
|
||||
|
||||
it("does not interpret a prose ETA or an ordinary note as an accepted deadline", async () => {
|
||||
const row = await overdue(), before = claim(row.qitemId);
|
||||
expect((await post(row.qitemId, { transitionNote: `Accepted ETA ${later}, still working` })).status).toBe(200);
|
||||
expect(repo.getById(row.qitemId)?.closureRequiredAt).toBe(row.closureRequiredAt);
|
||||
expect(repo.findOverdue().map(r => r.qitemId)).toContain(row.qitemId);
|
||||
expect(claim(row.qitemId)).toEqual(before);
|
||||
});
|
||||
|
||||
it("can add an estimate to a claim without a tier, without requiring a note or resetting its generation", async () => {
|
||||
const row = await repo.create({ sourceSession: "coordinator@fixture", destinationSession: "worker@fixture", body: "untimed", nudge: false });
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: row.destinationSession });
|
||||
const before = claim(row.qitemId);
|
||||
expect(repo.getById(row.qitemId)?.closureRequiredAt).toBeNull();
|
||||
expect((await post(row.qitemId, { state: "in-progress", closureRequiredAt: "2026-11-03T14:00:00+01:00" })).status).toBe(200);
|
||||
expect(repo.getById(row.qitemId)?.closureRequiredAt).toBe(later);
|
||||
expect(claim(row.qitemId)).toEqual(before);
|
||||
expect(repo.transitionLog.listForQitem(row.qitemId).at(-1)?.transitionNote).toContain(`(none) -> ${later}; accepted by coordinator@fixture`);
|
||||
});
|
||||
|
||||
it.each(["not-a-time", null, 123])("rejects an unpersistable deadline %s without changing the row or ledger", async closureRequiredAt => {
|
||||
const row = await overdue(), count = repo.transitionLog.listForQitem(row.qitemId).length;
|
||||
expect((await post(row.qitemId, { closureRequiredAt, transitionNote: "request" })).status).toBe(400);
|
||||
expect(repo.getById(row.qitemId)).toEqual(row);
|
||||
expect(repo.transitionLog.listForQitem(row.qitemId)).toHaveLength(count);
|
||||
});
|
||||
|
||||
it("does not combine re-estimation with closing work or silently set a pending row's deadline", async () => {
|
||||
const row = await overdue();
|
||||
expect((await post(row.qitemId, { closureRequiredAt: later, state: "done", closureReason: "no-follow-on" })).status).toBe(409);
|
||||
expect(repo.getById(row.qitemId)).toEqual(row);
|
||||
const pending = await repo.create({ sourceSession: "coordinator@fixture", destinationSession: "worker@fixture", body: "pending", nudge: false });
|
||||
expect((await accept(pending.qitemId)).status).toBe(409);
|
||||
expect(repo.getById(pending.qitemId)).toEqual(pending);
|
||||
});
|
||||
|
||||
it("rolls back the deadline when its acceptance receipt cannot be appended", async () => {
|
||||
const row = await overdue(), count = repo.transitionLog.listForQitem(row.qitemId).length;
|
||||
const append = vi.spyOn(repo.transitionLog, "append").mockImplementationOnce(() => { throw new Error("fixture receipt unavailable"); });
|
||||
try {
|
||||
expect(() => repo.update({ qitemId: row.qitemId, actorSession: "coordinator@fixture", closureRequiredAt: later })).toThrow("fixture receipt unavailable");
|
||||
} finally { append.mockRestore(); }
|
||||
expect(repo.getById(row.qitemId)).toEqual(row);
|
||||
expect(repo.transitionLog.listForQitem(row.qitemId)).toHaveLength(count);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user