diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index eb2bc1fdb1..059ac86194 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -30,6 +30,8 @@ export function printHelp(input) { " gittensory-miner queue next [--json] Claim the highest-priority queued item", " gittensory-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--json]", " gittensory-miner queue done [--json]", + " gittensory-miner queue release [--json] Return a claimed item to the queue", + " gittensory-miner queue requeue [--json] Put a completed item back on the queue", " gittensory-miner claim claim [--note ] [--json]", " gittensory-miner claim release [--json]", " gittensory-miner claim list [--repo ] [--status active|released|expired] [--json]", diff --git a/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts b/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts index 6bb5dafc09..d1721ffb5a 100644 --- a/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts +++ b/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts @@ -24,6 +24,10 @@ export function parseQueueNextArgs(args: string[]): ParsedQueueNextArgs; export function parseQueueDoneArgs(args: string[]): ParsedQueueDoneArgs; +export function parseQueueReleaseArgs(args: string[]): ParsedQueueDoneArgs; + +export function parseQueueRequeueArgs(args: string[]): ParsedQueueDoneArgs; + export type ParsedQueueClaimBatchArgs = | { json: boolean; globalWipCap: number; perRepoWipCap: number } | { error: string }; @@ -47,6 +51,16 @@ export function runQueueDone( options?: { initPortfolioQueue?: () => PortfolioQueueStore }, ): number; +export function runQueueRelease( + args: string[], + options?: { initPortfolioQueue?: () => PortfolioQueueStore }, +): number; + +export function runQueueRequeue( + args: string[], + options?: { initPortfolioQueue?: () => PortfolioQueueStore }, +): number; + export function runQueueClaimBatch( args: string[], options?: { initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager }, diff --git a/packages/gittensory-miner/lib/portfolio-queue-cli.js b/packages/gittensory-miner/lib/portfolio-queue-cli.js index 82c5e5c35a..2c0e3d5014 100644 --- a/packages/gittensory-miner/lib/portfolio-queue-cli.js +++ b/packages/gittensory-miner/lib/portfolio-queue-cli.js @@ -5,6 +5,8 @@ import { runPortfolioDashboard } from "./portfolio-dashboard.js"; const QUEUE_LIST_USAGE = "Usage: gittensory-miner queue list [--repo ] [--json]"; const QUEUE_NEXT_USAGE = "Usage: gittensory-miner queue next [--json]"; const QUEUE_DONE_USAGE = "Usage: gittensory-miner queue done [--json]"; +const QUEUE_RELEASE_USAGE = "Usage: gittensory-miner queue release [--json]"; +const QUEUE_REQUEUE_USAGE = "Usage: gittensory-miner queue requeue [--json]"; const QUEUE_CLAIM_BATCH_USAGE = "Usage: gittensory-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--json]"; @@ -79,19 +81,21 @@ export function parseQueueNextArgs(args) { return { json: parsed.json }; } -export function parseQueueDoneArgs(args) { +/** Shared ` [--json]` parse for the item-targeting subcommands (done/release/requeue). + * `usage` is the command-specific message surfaced on a malformed argv. */ +function parseRepoIdentifierArgs(args, usage) { const parsed = parseJsonFlag(args); if ("error" in parsed) return parsed; if (parsed.positional.length !== 2) { - return { error: QUEUE_DONE_USAGE }; + return { error: usage }; } - const repo = parseRepoArg(parsed.positional[0], QUEUE_DONE_USAGE); + const repo = parseRepoArg(parsed.positional[0], usage); if ("error" in repo) return repo; const identifier = parsed.positional[1]?.trim(); if (!identifier) { - return { error: QUEUE_DONE_USAGE }; + return { error: usage }; } return { @@ -101,6 +105,18 @@ export function parseQueueDoneArgs(args) { }; } +export function parseQueueDoneArgs(args) { + return parseRepoIdentifierArgs(args, QUEUE_DONE_USAGE); +} + +export function parseQueueReleaseArgs(args) { + return parseRepoIdentifierArgs(args, QUEUE_RELEASE_USAGE); +} + +export function parseQueueRequeueArgs(args) { + return parseRepoIdentifierArgs(args, QUEUE_REQUEUE_USAGE); +} + function display(value) { if (value === null || value === undefined) return "-"; return String(value); @@ -210,6 +226,65 @@ export function runQueueDone(args, options = {}) { } } +/** `release `: manually give up a CLAIMED (in_progress) item, returning it to the queue + * (the manual counterpart to the automated stuck-lease sweep). Exit 2 when there is no in-flight item to release. */ +export function runQueueRelease(args, options = {}) { + const parsed = parseQueueReleaseArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.reclaimStuckItem(parsed.repoFullName, parsed.identifier); + if (!entry) { + console.error("queue_entry_not_in_progress"); + return 2; + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } else { + console.log(entry.status); + } + return 0; + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } +} + +/** `requeue `: manually put a COMPLETED (done) item back on the queue so it is picked up + * again, keeping its original FIFO position. Exit 2 when there is no done item to requeue (already queued, + * in-flight — release it instead — or absent). */ +export function runQueueRequeue(args, options = {}) { + const parsed = parseQueueRequeueArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.requeueItem(parsed.repoFullName, parsed.identifier); + if (!entry) { + console.error("queue_entry_not_requeuable"); + return 2; + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } else { + console.log(entry.status); + } + return 0; + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } +} + export function parseQueueClaimBatchArgs(args) { const options = { json: false, globalWipCap: 1, perRepoWipCap: 1 }; for (let index = 0; index < args.length; index += 1) { @@ -269,6 +344,8 @@ export function runQueueCli(subcommand, args, options = {}) { if (subcommand === "list") return runQueueList(args, options); if (subcommand === "next") return runQueueNext(args, options); if (subcommand === "done") return runQueueDone(args, options); + if (subcommand === "release") return runQueueRelease(args, options); + if (subcommand === "requeue") return runQueueRequeue(args, options); if (subcommand === "claim-batch") return runQueueClaimBatch(args, options); if (subcommand === "dashboard") return runPortfolioDashboard(args, options); console.error(`Unknown queue subcommand: ${subcommand ?? ""}. ${QUEUE_LIST_USAGE}`); diff --git a/packages/gittensory-miner/lib/portfolio-queue.d.ts b/packages/gittensory-miner/lib/portfolio-queue.d.ts index 54d6a827ba..7f623adf43 100644 --- a/packages/gittensory-miner/lib/portfolio-queue.d.ts +++ b/packages/gittensory-miner/lib/portfolio-queue.d.ts @@ -31,6 +31,7 @@ export type PortfolioQueueStore = { markDone(repoFullName: string, identifier: string): QueueEntry | null; markFailed(repoFullName: string, identifier: string): QueueEntry | null; reclaimStuckItem(repoFullName: string, identifier: string): QueueEntry | null; + requeueItem(repoFullName: string, identifier: string): QueueEntry | null; batchClaim( selectFn: (entries: QueueEntry[]) => Array<{ repoFullName: string; identifier: string }>, ): QueueEntry[]; diff --git a/packages/gittensory-miner/lib/portfolio-queue.js b/packages/gittensory-miner/lib/portfolio-queue.js index 31f993c58e..7521c51f89 100644 --- a/packages/gittensory-miner/lib/portfolio-queue.js +++ b/packages/gittensory-miner/lib/portfolio-queue.js @@ -156,6 +156,14 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath()) WHERE repo_full_name = ? AND identifier = ? AND status = 'in_progress' RETURNING * `); + // Requeue only ever targets a COMPLETED ('done') row — an in-flight item is released via reclaimStatement, and + // an already-'queued' item is a no-op — so a caller's manual requeue can never disturb an active claim. The + // row keeps its rowid/enqueued_at, so it re-enters the queue at its original FIFO position, not the back. + const requeueStatement = db.prepare(` + UPDATE miner_portfolio_queue SET status = 'queued', leased_at = NULL + WHERE repo_full_name = ? AND identifier = ? AND status = 'done' + RETURNING * + `); const claimTargetStatement = db.prepare(` UPDATE miner_portfolio_queue SET status = 'in_progress', leased_at = ? WHERE repo_full_name = ? AND identifier = ? AND status = 'queued' @@ -189,6 +197,17 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath()) ); return row ? rowToEntry(row) : null; }, + /** Requeue a COMPLETED ('done') item back to 'queued' so it is picked up again, keeping its FIFO position + * (rowid/enqueued_at unchanged). Returns the entry, or null when there is no 'done' item to requeue — i.e. + * it is already 'queued', is currently 'in_progress' (release it via {@link reclaimStuckItem} instead), or + * does not exist. The manual counterpart to {@link reclaimStuckItem} for the queue CLI's escape hatch (#4828). */ + requeueItem(repoFullName, identifier) { + const row = requeueStatement.get( + normalizeRepoFullName(repoFullName), + normalizeIdentifier(identifier), + ); + return row ? rowToEntry(row) : null; + }, listQueue(repoFullName) { const rows = repoFullName === undefined || repoFullName === null ? listAllStatement.all() diff --git a/test/unit/miner-portfolio-queue-cli.test.ts b/test/unit/miner-portfolio-queue-cli.test.ts index dd682b48c4..329947c865 100644 --- a/test/unit/miner-portfolio-queue-cli.test.ts +++ b/test/unit/miner-portfolio-queue-cli.test.ts @@ -10,11 +10,15 @@ import { parseQueueDoneArgs, parseQueueListArgs, parseQueueNextArgs, + parseQueueReleaseArgs, + parseQueueRequeueArgs, renderQueueTable, runQueueCli, runQueueDone, runQueueList, runQueueNext, + runQueueRelease, + runQueueRequeue, } from "../../packages/gittensory-miner/lib/portfolio-queue-cli.js"; import type { QueueEntry } from "../../packages/gittensory-miner/lib/portfolio-queue.d.ts"; @@ -164,4 +168,140 @@ describe("gittensory-miner portfolio queue CLI (#2292)", () => { expect(runQueueList(["--verbose"])).toBe(2); expect(String(error.mock.calls[0]?.[0])).toContain("Unknown queue subcommand"); }); + + describe("release / requeue escape hatch (#4828)", () => { + it("parseQueueReleaseArgs and parseQueueRequeueArgs validate argv with their own usage", () => { + expect(parseQueueReleaseArgs(["acme/widgets", "issue:1"])).toEqual({ + repoFullName: "acme/widgets", + identifier: "issue:1", + json: false, + }); + expect(parseQueueRequeueArgs(["acme/widgets", "issue:1", "--json"])).toEqual({ + repoFullName: "acme/widgets", + identifier: "issue:1", + json: true, + }); + // Wrong positional count surfaces the command-specific usage string. + expect(parseQueueReleaseArgs(["only-one"])).toEqual({ error: expect.stringContaining("queue release") }); + expect(parseQueueRequeueArgs([])).toEqual({ error: expect.stringContaining("queue requeue") }); + }); + + it("release returns a CLAIMED (in-progress) item to the queue", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:7", priority: 1 }); + portfolioQueue.dequeueNext(); // claim it → in_progress + const options = { initPortfolioQueue: () => portfolioQueue }; + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(runQueueRelease(["acme/widgets", "issue:7"], options)).toBe(0); + expect(log).toHaveBeenCalledWith("queued"); + expect(portfolioQueue.listQueue("acme/widgets")[0]?.status).toBe("queued"); + }); + + it("release emits the full entry as JSON under --json", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:7b", priority: 2 }); + portfolioQueue.dequeueNext(); // → in_progress + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect( + runQueueRelease(["acme/widgets", "issue:7b", "--json"], { initPortfolioQueue: () => portfolioQueue }), + ).toBe(0); + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed.entry).toMatchObject({ identifier: "issue:7b", status: "queued" }); + }); + + it("release exits 2 when the item is not in-progress (nothing to release)", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:8", priority: 1 }); // still 'queued' + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + + expect(runQueueRelease(["acme/widgets", "issue:8"], { initPortfolioQueue: () => portfolioQueue })).toBe(2); + expect(error).toHaveBeenCalledWith("queue_entry_not_in_progress"); + }); + + it("requeue puts a COMPLETED (done) item back on the queue, keeping its position", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:9", priority: 5 }); + portfolioQueue.markDone("acme/widgets", "issue:9"); // → done + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect( + runQueueRequeue(["acme/widgets", "issue:9", "--json"], { initPortfolioQueue: () => portfolioQueue }), + ).toBe(0); + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed.entry).toMatchObject({ repoFullName: "acme/widgets", identifier: "issue:9", status: "queued", priority: 5 }); + expect(portfolioQueue.listQueue("acme/widgets")[0]?.status).toBe("queued"); + }); + + it("requeue exits 2 when the item is not a completed entry (already queued / in-flight / absent)", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:10", priority: 1 }); // 'queued' + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + + expect(runQueueRequeue(["acme/widgets", "issue:10"], { initPortfolioQueue: () => portfolioQueue })).toBe(2); + expect(error).toHaveBeenCalledWith("queue_entry_not_requeuable"); + // Absent item too. + expect(runQueueRequeue(["acme/widgets", "issue:404"], { initPortfolioQueue: () => portfolioQueue })).toBe(2); + }); + + it("the shared parser rejects a bad option, a malformed repo, and an empty identifier", () => { + expect(parseQueueReleaseArgs(["--bad"])).toEqual({ error: expect.stringContaining("Unknown option") }); + expect(parseQueueRequeueArgs(["notarepo", "issue:1"])).toEqual({ + error: "Repository must be in owner/repo form.", + }); + expect(parseQueueReleaseArgs(["acme/widgets", " "])).toEqual({ error: expect.stringContaining("queue release") }); + }); + + it("release and requeue each surface a parse error before touching the store", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(runQueueRelease(["only-one"], {})).toBe(2); + expect(String(error.mock.calls[0]?.[0])).toContain("queue release"); + expect(runQueueRequeue(["only-one"], {})).toBe(2); + expect(String(error.mock.calls[1]?.[0])).toContain("queue requeue"); + }); + + it("release and requeue fail-safe (exit 2) when the store throws", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const throwingStore = { + reclaimStuckItem() { + throw new Error("db_locked"); + }, + requeueItem() { + throw new Error("db_locked"); + }, + } as unknown as ReturnType; + expect(runQueueRelease(["acme/widgets", "issue:1"], { initPortfolioQueue: () => throwingStore })).toBe(2); + expect(runQueueRequeue(["acme/widgets", "issue:1"], { initPortfolioQueue: () => throwingStore })).toBe(2); + expect(error).toHaveBeenCalledWith("db_locked"); + + // A thrown non-Error is stringified rather than crashing (the String(error) fallback branch). + const throwingNonError = { + reclaimStuckItem() { + throw "raw_string_fault"; + }, + requeueItem() { + throw "raw_string_fault"; + }, + } as unknown as ReturnType; + expect(runQueueRelease(["acme/widgets", "issue:1"], { initPortfolioQueue: () => throwingNonError })).toBe(2); + expect(runQueueRequeue(["acme/widgets", "issue:1"], { initPortfolioQueue: () => throwingNonError })).toBe(2); + expect(error).toHaveBeenCalledWith("raw_string_fault"); + }); + + it("runQueueCli dispatches release and requeue", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:11", priority: 1 }); + portfolioQueue.dequeueNext(); // in_progress + const options = { initPortfolioQueue: () => portfolioQueue }; + vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(runQueueCli("release", ["acme/widgets", "issue:11"], options)).toBe(0); + expect(portfolioQueue.listQueue("acme/widgets")[0]?.status).toBe("queued"); + + portfolioQueue.markDone("acme/widgets", "issue:11"); + expect(runQueueCli("requeue", ["acme/widgets", "issue:11"], options)).toBe(0); + expect(portfolioQueue.listQueue("acme/widgets")[0]?.status).toBe("queued"); + }); + }); });