@indigoai-us/hq-cli 5.345.37 → 5.345.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.39] — 2026-10-07
6
+ - Cap each `hq doctor --fix` invocation at two company journal refreshes and persist a rotating cursor so pending journals continue on later runs.
7
+ - Add `hq db destroy --company <slug> --app <app>` to permanently delete an app's remote database. It requires typing the app id (or `--confirm <app>` when not interactive), offers `hq db dump` first (`--dump-first` / `--no-dump`), and only company owners and admins can run it.
8
+ - Lane story ledger sync now clears resolved sync errors and reads the company's cached feature gate before registry startup finishes.
9
+ - Keep quota-limited keep-alive resumes recoverable, preserve operator-triggered work while waiting for reset, and retry when an eligible account is available.
10
+ - Allow terminal worker lanes to be reparented to a live senior in the same company, while refusing running workers.
11
+ - Add request timeouts to native DM, file download, report deployment, and deploy API requests.
12
+
13
+ ## [5.345.38] — 2026-10-07
14
+ - Windows CI parity suites now derive their shell-subprocess time budget from a warm-up measured on the same runner instead of a fixed 7-second deadline.
15
+
5
16
  ## [5.345.37] — 2026-10-07
6
17
  - Keep pnpm's public global package link readable while self-update scans and replaces existing global installs, so Corepack-managed updates can complete with rollback protection intact.
7
18
 
@@ -2050,6 +2050,49 @@ export declare const COMMAND_CATALOG: readonly [{
2050
2050
  readonly hidden: false;
2051
2051
  }];
2052
2052
  readonly subcommands: readonly [];
2053
+ }, {
2054
+ readonly name: "destroy";
2055
+ readonly description: "Permanently delete an app's remote database (owners and admins only; cannot be undone)";
2056
+ readonly aliases: readonly [];
2057
+ readonly hidden: false;
2058
+ readonly helpOption: true;
2059
+ readonly helpCommand: false;
2060
+ readonly usage: "[options]";
2061
+ readonly helpText: readonly [{
2062
+ readonly position: "after";
2063
+ readonly text: "\nThis deletes the app's database immediately. It cannot be undone.\nInteractive: you are asked to type the app id and whether to dump first.\nNon-interactive: pass --confirm <app> and either --dump-first or --no-dump.\nExample: hq db destroy --company acme --app todo --confirm todo --dump-first";
2064
+ }];
2065
+ readonly arguments: readonly [];
2066
+ readonly options: readonly [{
2067
+ readonly flags: "--company <slug>";
2068
+ readonly description: "Company slug";
2069
+ readonly hidden: false;
2070
+ }, {
2071
+ readonly flags: "--app <app>";
2072
+ readonly description: "App id";
2073
+ readonly hidden: false;
2074
+ }, {
2075
+ readonly flags: "--confirm <app>";
2076
+ readonly description: "Type the app id again to confirm (required when not interactive)";
2077
+ readonly hidden: false;
2078
+ }, {
2079
+ readonly flags: "--yes";
2080
+ readonly description: "Skip the dump question; the app id confirmation is still required";
2081
+ readonly hidden: false;
2082
+ }, {
2083
+ readonly flags: "--dump-first";
2084
+ readonly description: "Run hq db dump before destroying (non-interactive)";
2085
+ readonly hidden: false;
2086
+ }, {
2087
+ readonly flags: "--no-dump";
2088
+ readonly description: "Do not dump before destroying (non-interactive)";
2089
+ readonly hidden: false;
2090
+ }, {
2091
+ readonly flags: "--out <file>";
2092
+ readonly description: "Dump output file (default: <app>.sql)";
2093
+ readonly hidden: false;
2094
+ }];
2095
+ readonly subcommands: readonly [];
2053
2096
  }];
2054
2097
  }, {
2055
2098
  readonly name: "api-keys";
@@ -2459,6 +2459,60 @@ export const COMMAND_CATALOG = [
2459
2459
  }
2460
2460
  ],
2461
2461
  "subcommands": []
2462
+ },
2463
+ {
2464
+ "name": "destroy",
2465
+ "description": "Permanently delete an app's remote database (owners and admins only; cannot be undone)",
2466
+ "aliases": [],
2467
+ "hidden": false,
2468
+ "helpOption": true,
2469
+ "helpCommand": false,
2470
+ "usage": "[options]",
2471
+ "helpText": [
2472
+ {
2473
+ "position": "after",
2474
+ "text": "\nThis deletes the app's database immediately. It cannot be undone.\nInteractive: you are asked to type the app id and whether to dump first.\nNon-interactive: pass --confirm <app> and either --dump-first or --no-dump.\nExample: hq db destroy --company acme --app todo --confirm todo --dump-first"
2475
+ }
2476
+ ],
2477
+ "arguments": [],
2478
+ "options": [
2479
+ {
2480
+ "flags": "--company <slug>",
2481
+ "description": "Company slug",
2482
+ "hidden": false
2483
+ },
2484
+ {
2485
+ "flags": "--app <app>",
2486
+ "description": "App id",
2487
+ "hidden": false
2488
+ },
2489
+ {
2490
+ "flags": "--confirm <app>",
2491
+ "description": "Type the app id again to confirm (required when not interactive)",
2492
+ "hidden": false
2493
+ },
2494
+ {
2495
+ "flags": "--yes",
2496
+ "description": "Skip the dump question; the app id confirmation is still required",
2497
+ "hidden": false
2498
+ },
2499
+ {
2500
+ "flags": "--dump-first",
2501
+ "description": "Run hq db dump before destroying (non-interactive)",
2502
+ "hidden": false
2503
+ },
2504
+ {
2505
+ "flags": "--no-dump",
2506
+ "description": "Do not dump before destroying (non-interactive)",
2507
+ "hidden": false
2508
+ },
2509
+ {
2510
+ "flags": "--out <file>",
2511
+ "description": "Dump output file (default: <app>.sql)",
2512
+ "hidden": false
2513
+ }
2514
+ ],
2515
+ "subcommands": []
2462
2516
  }
2463
2517
  ]
2464
2518
  },
@@ -51,6 +51,7 @@ export const createDmBindDeps = (root, cwd, env, now, home = env.HOME || os.home
51
51
  }),
52
52
  sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
53
53
  });
54
+ const DM_BIND_API_TIMEOUT_MS = 15_000;
54
55
  const fail = (message, code = 2) => utilityError(`hq-dm-bind: ${message}`, code);
55
56
  const sessionDirectory = (d, sid) => path.join(d.root, "workspace/sessions", sid);
56
57
  const filePath = (d, sid, name) => path.join(sessionDirectory(d, sid), name);
@@ -192,13 +193,13 @@ async function fetchRoster(d, ch) {
192
193
  const config = JSON.parse(d.fs.readFileSync(path.join(d.home, ".hq/config.json"), "utf8"));
193
194
  const token = JSON.parse(d.fs.readFileSync(path.join(d.home, ".hq/cognito-tokens.json"), "utf8")).idToken;
194
195
  const headers = { Authorization: `Bearer ${token}` };
195
- const response = await d.fetch(`${config.vaultApiUrl}/v1/notify/channels`, { headers });
196
+ const response = await d.fetch(`${config.vaultApiUrl}/v1/notify/channels`, { headers, signal: AbortSignal.timeout(DM_BIND_API_TIMEOUT_MS) });
196
197
  const channels = await response.json();
197
198
  const rows = Array.isArray(channels) ? channels : channels.channels ?? [];
198
199
  const hit = rows.find((entry) => entry.channelId === ch || slug(entry.name) === slug(ch) || entry.slug === slug(ch));
199
200
  if (!hit?.channelId)
200
201
  return { stdout: "", stderr: "" };
201
- const membersResponse = await d.fetch(`${config.vaultApiUrl}/v1/notify/channels/${encodeURIComponent(String(hit.channelId))}/members`, { headers });
202
+ const membersResponse = await d.fetch(`${config.vaultApiUrl}/v1/notify/channels/${encodeURIComponent(String(hit.channelId))}/members`, { headers, signal: AbortSignal.timeout(DM_BIND_API_TIMEOUT_MS) });
202
203
  const members = await membersResponse.json();
203
204
  const list = Array.isArray(members) ? members : members.members ?? [];
204
205
  const lines = list.flatMap((member) => {
@@ -331,14 +332,14 @@ async function sendHuman(d, ch, payload) {
331
332
  const config = JSON.parse(d.fs.readFileSync(path.join(d.home, ".hq/config.json"), "utf8"));
332
333
  const token = JSON.parse(d.fs.readFileSync(path.join(d.home, ".hq/cognito-tokens.json"), "utf8")).idToken;
333
334
  const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
334
- const channelsResponse = await d.fetch(`${config.vaultApiUrl}/v1/notify/channels`, { headers });
335
+ const channelsResponse = await d.fetch(`${config.vaultApiUrl}/v1/notify/channels`, { headers, signal: AbortSignal.timeout(DM_BIND_API_TIMEOUT_MS) });
335
336
  const channels = await channelsResponse.json();
336
337
  const rows = Array.isArray(channels) ? channels : channels.channels ?? [];
337
338
  const hit = rows.find((entry) => entry.channelId === ch || slug(entry.name) === slug(ch) || entry.slug === slug(ch));
338
339
  if (!hit?.channelId)
339
340
  throw new Error(`channel not found: ${ch}`);
340
341
  const response = await d.fetch(`${config.vaultApiUrl}/v1/notify/channels/${encodeURIComponent(String(hit.channelId))}/messages`, {
341
- method: "POST", headers, body: JSON.stringify(payload),
342
+ method: "POST", headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(DM_BIND_API_TIMEOUT_MS),
342
343
  });
343
344
  const text = await response.text();
344
345
  if (!response.ok)
@@ -0,0 +1,15 @@
1
+ /**
2
+ * hq db destroy — permanently delete one app's remote database.
3
+ */
4
+ import { Command } from "commander";
5
+ import { type DbRemoteCommandDeps } from "./db-remote-deps.js";
6
+ export type DestroyRoleCheck = "allowed" | "denied" | "unknown";
7
+ export interface DbDestroyIo {
8
+ isInteractive: () => boolean;
9
+ ask: (question: string) => Promise<string>;
10
+ checkRole: (deps: DbRemoteCommandDeps, companyUid: string) => Promise<DestroyRoleCheck>;
11
+ }
12
+ export declare function defaultDestroyIo(): DbDestroyIo;
13
+ export declare function describeDestroyError(error: unknown): string | null;
14
+ export declare function registerDbDestroyCommand(db: Command, depsFactory?: () => DbRemoteCommandDeps, ioFactory?: () => DbDestroyIo): void;
15
+ //# sourceMappingURL=db-destroy.d.ts.map
@@ -0,0 +1,143 @@
1
+ /**
2
+ * hq db destroy — permanently delete one app's remote database.
3
+ */
4
+ import chalk from "chalk";
5
+ import * as readline from "node:readline";
6
+ import { safeErrorMessage } from "../lib/db/redact.js";
7
+ import { isPlanGateError } from "../utils/plan-gate-error.js";
8
+ import { dumpAppDatabase, describeSessionGap } from "./db-dump.js";
9
+ import { controlPlaneClient, defaultRemoteDeps, describeRemoteError, } from "./db-remote-deps.js";
10
+ const NOT_ADMIN = "Only company owners and admins can destroy an app database.";
11
+ async function defaultCheckRole(deps, companyUid) {
12
+ if (process.env.HQ_DB_MOCK_CONTROL_PLANE === "1")
13
+ return "unknown";
14
+ try {
15
+ const [{ listActiveMembers }, { resolveCallerPersonUid }] = await Promise.all([
16
+ import("./members.js"),
17
+ import("../utils/vault-api.js"),
18
+ ]);
19
+ const token = await deps.getAccessToken();
20
+ const [personUid, members] = await Promise.all([
21
+ resolveCallerPersonUid(token),
22
+ listActiveMembers(token, companyUid),
23
+ ]);
24
+ const me = members.find((m) => m.personUid === personUid);
25
+ if (!me)
26
+ return "unknown";
27
+ return me.role === "owner" || me.role === "admin" ? "allowed" : "denied";
28
+ }
29
+ catch {
30
+ return "unknown";
31
+ }
32
+ }
33
+ export function defaultDestroyIo() {
34
+ return {
35
+ isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
36
+ ask: (question) => new Promise((resolve) => {
37
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
38
+ rl.question(question, (answer) => {
39
+ rl.close();
40
+ resolve(answer);
41
+ });
42
+ }),
43
+ checkRole: defaultCheckRole,
44
+ };
45
+ }
46
+ export function describeDestroyError(error) {
47
+ const status = error.status;
48
+ if (status === 403)
49
+ return NOT_ADMIN;
50
+ if (status === 404)
51
+ return "This app has no database attached, so there is nothing to destroy.";
52
+ return describeRemoteError(error);
53
+ }
54
+ function fail(message) {
55
+ console.error(chalk.red("Error:"), message);
56
+ process.exitCode = 1;
57
+ }
58
+ export function registerDbDestroyCommand(db, depsFactory = defaultRemoteDeps, ioFactory = defaultDestroyIo) {
59
+ db.command("destroy")
60
+ .description("Permanently delete an app's remote database (owners and admins only; cannot be undone)")
61
+ .requiredOption("--company <slug>", "Company slug")
62
+ .requiredOption("--app <app>", "App id")
63
+ .option("--confirm <app>", "Type the app id again to confirm (required when not interactive)")
64
+ .option("--yes", "Skip the dump question; the app id confirmation is still required")
65
+ .option("--dump-first", "Run hq db dump before destroying (non-interactive)")
66
+ .option("--no-dump", "Do not dump before destroying (non-interactive)")
67
+ .option("--out <file>", "Dump output file (default: <app>.sql)")
68
+ .addHelpText("after", "\nThis deletes the app's database immediately. It cannot be undone.\n" +
69
+ "Interactive: you are asked to type the app id and whether to dump first.\n" +
70
+ "Non-interactive: pass --confirm <app> and either --dump-first or --no-dump.\n" +
71
+ "Example: hq db destroy --company acme --app todo --confirm todo --dump-first")
72
+ .action(async (opts) => {
73
+ try {
74
+ const io = ioFactory();
75
+ const app = String(opts.app ?? "").trim();
76
+ const interactive = io.isInteractive();
77
+ const dumpFlagGiven = opts.dumpFirst === true || opts.dump === false;
78
+ if (opts.dumpFirst && opts.dump === false) {
79
+ fail("Use either --dump-first or --no-dump, not both.");
80
+ return;
81
+ }
82
+ const deps = depsFactory();
83
+ const resolved = await deps.resolveCompany(String(opts.company ?? "").trim());
84
+ if ((await io.checkRole(deps, resolved.companyUid)) === "denied") {
85
+ fail(NOT_ADMIN);
86
+ return;
87
+ }
88
+ console.error(chalk.yellow(`Warning: this deletes the database for app ${app} in ${resolved.companySlug} immediately. It cannot be undone.`));
89
+ let confirmed = opts.confirm;
90
+ if (confirmed === undefined) {
91
+ if (!interactive) {
92
+ fail(`Confirmation required. Re-run with --confirm ${app}.`);
93
+ return;
94
+ }
95
+ confirmed = await io.ask(`Type the app id (${app}) to confirm: `);
96
+ }
97
+ if (confirmed.trim() !== app) {
98
+ fail("The confirmation did not match the app id. Nothing was deleted.");
99
+ return;
100
+ }
101
+ let dumpFirst = opts.dumpFirst === true;
102
+ if (!dumpFlagGiven) {
103
+ if (interactive && !opts.yes) {
104
+ const answer = (await io.ask(`Dump the database to ${opts.out ?? `${app}.sql`} first? [Y/n] `))
105
+ .trim()
106
+ .toLowerCase();
107
+ dumpFirst = answer === "" || answer === "y" || answer === "yes";
108
+ }
109
+ else {
110
+ console.error("No dump was taken. Pass --dump-first to save a copy before destroying.");
111
+ }
112
+ }
113
+ if (dumpFirst) {
114
+ try {
115
+ const summary = await dumpAppDatabase(deps, {
116
+ companyUid: resolved.companyUid,
117
+ appId: app,
118
+ out: opts.out,
119
+ });
120
+ console.log(`Wrote ${summary.tables} table(s) and ${summary.rows} row(s) to ${summary.outPath}.`);
121
+ }
122
+ catch (error) {
123
+ if (isPlanGateError(error))
124
+ throw error;
125
+ fail(`The dump failed, so the database was not destroyed. ${describeSessionGap(error) ?? describeRemoteError(error) ?? safeErrorMessage(error)}`);
126
+ return;
127
+ }
128
+ }
129
+ const result = await controlPlaneClient(deps).destroyApp({
130
+ companyUid: resolved.companyUid,
131
+ appId: app,
132
+ });
133
+ const what = result.mode === "schema" ? "schema and roles dropped" : "database cluster deleted";
134
+ console.log(`Destroyed the database for app ${app}: ${what}.`);
135
+ }
136
+ catch (error) {
137
+ if (isPlanGateError(error))
138
+ throw error;
139
+ fail(describeDestroyError(error) ?? safeErrorMessage(error));
140
+ }
141
+ });
142
+ }
143
+ //# sourceMappingURL=db-destroy.js.map
@@ -4,5 +4,14 @@
4
4
  import { Command } from "commander";
5
5
  import { type DbRemoteCommandDeps } from "./db-remote-deps.js";
6
6
  export declare function describeSessionGap(error: unknown): string | null;
7
+ export declare function dumpAppDatabase(deps: DbRemoteCommandDeps, input: {
8
+ companyUid: string;
9
+ appId: string;
10
+ out?: string;
11
+ }): Promise<{
12
+ tables: number;
13
+ rows: number;
14
+ outPath: string;
15
+ }>;
7
16
  export declare function registerDbDumpCommand(db: Command, depsFactory?: () => DbRemoteCommandDeps): void;
8
17
  //# sourceMappingURL=db-dump.d.ts.map
@@ -14,6 +14,22 @@ export function describeSessionGap(error) {
14
14
  }
15
15
  return null;
16
16
  }
17
+ export async function dumpAppDatabase(deps, input) {
18
+ const conn = await openRemoteAppConnection(remoteEngineDeps(deps), {
19
+ companyUid: input.companyUid,
20
+ appId: input.appId,
21
+ });
22
+ const outPath = input.out ?? `${input.appId}.sql`;
23
+ const fd = fs.openSync(outPath, "w");
24
+ try {
25
+ const summary = await dumpRemoteSchema(conn, (text) => fs.writeSync(fd, text));
26
+ return { tables: summary.tables, rows: summary.rows, outPath };
27
+ }
28
+ finally {
29
+ fs.closeSync(fd);
30
+ await conn.close();
31
+ }
32
+ }
17
33
  export function registerDbDumpCommand(db, depsFactory = defaultRemoteDeps) {
18
34
  db.command("dump")
19
35
  .description("Export an app's remote database schema and data to a SQL file")
@@ -24,20 +40,12 @@ export function registerDbDumpCommand(db, depsFactory = defaultRemoteDeps) {
24
40
  try {
25
41
  const deps = depsFactory();
26
42
  const resolved = await deps.resolveCompany(String(opts.company ?? "").trim());
27
- const conn = await openRemoteAppConnection(remoteEngineDeps(deps), {
43
+ const summary = await dumpAppDatabase(deps, {
28
44
  companyUid: resolved.companyUid,
29
45
  appId: opts.app,
46
+ out: opts.out,
30
47
  });
31
- const outPath = opts.out ?? `${opts.app}.sql`;
32
- const fd = fs.openSync(outPath, "w");
33
- try {
34
- const summary = await dumpRemoteSchema(conn, (text) => fs.writeSync(fd, text));
35
- console.log(`Wrote ${summary.tables} table(s) and ${summary.rows} row(s) to ${outPath}.`);
36
- }
37
- finally {
38
- fs.closeSync(fd);
39
- await conn.close();
40
- }
48
+ console.log(`Wrote ${summary.tables} table(s) and ${summary.rows} row(s) to ${summary.outPath}.`);
41
49
  }
42
50
  catch (error) {
43
51
  if (isPlanGateError(error))
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * hq db — vault database commands (local + remote tiers).
3
3
  */
4
+ import { registerDbDestroyCommand } from "./db-destroy.js";
4
5
  import { registerDbDumpCommand } from "./db-dump.js";
5
6
  import { registerDbMigrateCommand } from "./db-migrate.js";
6
7
  import { registerDbProvisionCommand } from "./db-provision.js";
@@ -20,5 +21,6 @@ export function registerDbCommand(program) {
20
21
  registerDbProvisionCommand(db);
21
22
  registerDbUsageCommand(db);
22
23
  registerDbDumpCommand(db);
24
+ registerDbDestroyCommand(db);
23
25
  }
24
26
  //# sourceMappingURL=db.js.map
@@ -778,7 +778,7 @@ export function createCompanyPresignClient(input) {
778
778
  }
779
779
  throw new Error(message);
780
780
  }
781
- const dl = await fetch(first.url);
781
+ const dl = await fetch(first.url, { signal: AbortSignal.timeout(120_000) });
782
782
  if (!dl.ok) {
783
783
  throw new Error(`Failed to download '${key}' (HTTP ${dl.status})`);
784
784
  }
@@ -426,7 +426,7 @@ export function createLaneMessageHandlers(deps) {
426
426
  // This invocation won the race to classify the dead worker, so the
427
427
  // watcher callback did not own recovery. Fold any earlier queued
428
428
  // messages into this resume to keep one provider turn and one delivery.
429
- if (laneDeliveryMechanism(lane) === "resume" && dropboxInboxHasMessages(hqRoot, laneId)) {
429
+ if (laneDeliveryMechanism(lane, { forMessage: true }) === "resume" && dropboxInboxHasMessages(hqRoot, laneId)) {
430
430
  const queuedMessages = drainDropbox(hqRoot, laneId);
431
431
  if (queuedMessages.length > 0) {
432
432
  claimedMessages = queuedMessages;
@@ -496,7 +496,7 @@ export function createLaneMessageHandlers(deps) {
496
496
  const awaitingInputFallback = lane.state === "awaiting_input";
497
497
  // One decision point for how a follow-up reaches this lane (delivery.ts).
498
498
  // `none` refuses before anything is written: no inbox append, no spawn.
499
- const mechanism = laneDeliveryMechanism(lane);
499
+ const mechanism = laneDeliveryMechanism(lane, { forMessage: true });
500
500
  if (mechanism === "none") {
501
501
  const message = deliveryRefusalReason(lane);
502
502
  throw new LanesError("lane_not_accepting_messages", message, {
@@ -542,8 +542,10 @@ export function createLaneMessageHandlers(deps) {
542
542
  .map((reason) => reason.quota_reset_at)
543
543
  .filter((value) => typeof value === "string" && Number.isFinite(Date.parse(value)))
544
544
  .sort((a, b) => Date.parse(a) - Date.parse(b));
545
+ if (resetTimes.length === 0)
546
+ throw err;
545
547
  quotaHoldDeferred = {
546
- ...(resetTimes[0] !== undefined ? { quota_reset_at: resetTimes[0] } : {}),
548
+ quota_reset_at: resetTimes[0],
547
549
  ...(lane.model !== undefined ? { model: lane.model } : {}),
548
550
  };
549
551
  automaticAccountSelection = undefined;
@@ -674,6 +676,13 @@ export function createLaneMessageHandlers(deps) {
674
676
  queued_details: undefined,
675
677
  queued_at: at,
676
678
  admission_reserved_at: undefined,
679
+ ...(quotaHoldDeferred?.quota_reset_at !== undefined ? {
680
+ quota_reset_at: quotaHoldDeferred.quota_reset_at,
681
+ keep_alive_next_eligible_at: quotaHoldDeferred.quota_reset_at,
682
+ keep_alive_resume_count: 0,
683
+ keep_alive_last_resume_at: undefined,
684
+ keep_alive_exhausted_at: undefined,
685
+ } : {}),
677
686
  pending_resume: {
678
687
  session_id: sessionId,
679
688
  trigger: "message",
@@ -925,7 +934,7 @@ export function createLaneMessageHandlers(deps) {
925
934
  }
926
935
  // Every accepted message moves the compaction reference forward.
927
936
  const next = casUpdateLane(hqRoot, laneId, (current) => ({
928
- ...resetKeepAliveProgress(current),
937
+ ...(queuedOnQuotaHold ? current : resetKeepAliveProgress(current)),
929
938
  last_message_at: at,
930
939
  }));
931
940
  // What this command can claim when it returns: the message is durably
@@ -1050,7 +1059,7 @@ export function createLaneMessageHandlers(deps) {
1050
1059
  ? "done_before_envelope"
1051
1060
  : lane.state === "awaiting_input"
1052
1061
  ? "awaiting_input_requires_question_answer"
1053
- : laneDeliveryMechanism(lane) !== "resume"
1062
+ : laneDeliveryMechanism(lane, { forMessage: true }) !== "resume"
1054
1063
  ? `no_resume_path_for_${lane.state}`
1055
1064
  : undefined);
1056
1065
  if (refusal !== undefined) {
@@ -1,12 +1,12 @@
1
1
  import { BALANCE_MOVABLE_STATES } from "../lib/lanes/claude-balance.js";
2
2
  import { LanesError } from "../lib/lanes/errors.js";
3
- import { appendCappedHistory, providerSessionPortable, providerSupportsSessionResume, readLane, resumeCommandRefusal, sessionNotPortableMessage, validateProjectStories, withLaneAdmissionLock } from "../lib/lanes/index.js";
3
+ import { appendCappedHistory, isFailedQuotaKeepAliveLane, providerSessionPortable, providerSupportsSessionResume, readLane, resumeCommandRefusal, sessionNotPortableMessage, validateProjectStories, withLaneAdmissionLock } from "../lib/lanes/index.js";
4
4
  import { parseCliEffort } from "../lib/lanes/effort.js";
5
5
  import { parseOutpostHireGrantContext } from "../lib/lanes/hire-grant-dispatch.js";
6
6
  import { assertNoSecretText } from "../lib/lanes/text-scan.js";
7
7
  import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
8
8
  import { artifactIdentityKeys, rollbackStorySwitchAttribution, storySwitchNotice } from "../lib/lanes/story-switch.js";
9
- import { spawnLaneWorker } from "../lib/lanes/spawn.js";
9
+ import { markSpawnFailure, spawnLaneWorker } from "../lib/lanes/spawn.js";
10
10
  import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, hasKeepAliveProgress, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
11
11
  export function createLaneResumeHandlers(deps) {
12
12
  const { archiveResumeMessage, assertLaneRunsHereForAdmission, assertLaneRunsHereForOperatorMutation, assessLaneAdmission, automaticBalanceMoveHistoryReason, authorizeFailedResume, authorizeResumeProfileRepin, casUpdateLane, dispatchOutpostLaneCommand, laneIsHostedHere, laneWithRecoveredProviderSession, mintMessageId, persistClaudeAccountSelection, prepareAccountMove, printJson, recordResumeSetup, resolveResumeProfile, selectAutomaticClaudeResumeAccount, validateResumeProfileModel, withResumeHireAdmission } = deps;
@@ -219,7 +219,7 @@ export function createLaneResumeHandlers(deps) {
219
219
  }
220
220
  const refusal = queuedProfileRecovery || lane.state === "awaiting_input"
221
221
  ? null
222
- : resumeCommandRefusal(lane, { allowFailed: opts.fromFailed === true });
222
+ : resumeCommandRefusal(lane, { allowFailed: opts.fromFailed === true || isFailedQuotaKeepAliveLane(lane) });
223
223
  if (refusal !== null) {
224
224
  throw new LanesError(refusal.error, refusal.message, {
225
225
  lane_id: laneId,
@@ -284,12 +284,29 @@ export function createLaneResumeHandlers(deps) {
284
284
  });
285
285
  return;
286
286
  }
287
- const automaticAccountSelection = opts.account === undefined
288
- ? await selectAutomaticClaudeResumeAccount(ctx, lane, resumeProfile, {
289
- repairUnavailableAccount: true,
290
- ...(refreshProfileSettings ? { refreshProfileSettings: true } : {}),
291
- })
292
- : undefined;
287
+ let quotaHoldDeferred;
288
+ let automaticAccountSelection = undefined;
289
+ if (opts.account === undefined) {
290
+ try {
291
+ automaticAccountSelection = await selectAutomaticClaudeResumeAccount(ctx, lane, resumeProfile, {
292
+ repairUnavailableAccount: true,
293
+ ...(refreshProfileSettings ? { refreshProfileSettings: true } : {}),
294
+ });
295
+ }
296
+ catch (err) {
297
+ const reasons = err instanceof LanesError && err.error === "claude_account_balance_no_eligible_account" &&
298
+ Array.isArray(err.details.account_reasons)
299
+ ? err.details.account_reasons.filter((reason) => reason !== null && typeof reason === "object" && !Array.isArray(reason))
300
+ : [];
301
+ const resets = reasons.filter((reason) => reason.reason === "quota_held")
302
+ .map((reason) => reason.quota_reset_at)
303
+ .filter((value) => typeof value === "string" && Number.isFinite(Date.parse(value)))
304
+ .sort((a, b) => Date.parse(a) - Date.parse(b));
305
+ if (resets.length === 0)
306
+ throw err;
307
+ quotaHoldDeferred = { quota_reset_at: resets[0], account_reasons: reasons };
308
+ }
309
+ }
293
310
  const profileRefreshAccount = opts.account === undefined && refreshProfileSettings && resumeProfile !== undefined &&
294
311
  lane.account_source !== undefined && lane.account_source !== "cli" &&
295
312
  resumeProfile.profile.meta.provider === lane.provider
@@ -451,8 +468,37 @@ export function createLaneResumeHandlers(deps) {
451
468
  : {}),
452
469
  });
453
470
  let queuedOnAdmission = false;
471
+ let queuedOnQuotaHold = false;
454
472
  let resumed;
455
- if (move?.queued) {
473
+ if (quotaHoldDeferred !== undefined) {
474
+ const sessionId = lane.provider_session_id;
475
+ if (!sessionId)
476
+ throw new LanesError("no_provider_session", `Lane ${laneId} has no provider session id to queue this resume`, {
477
+ lane_id: laneId,
478
+ provider: lane.provider,
479
+ });
480
+ const quotaHoldAt = new Date(Date.parse(at) + 1).toISOString();
481
+ const pendingResume = {
482
+ session_id: sessionId,
483
+ trigger: "resume",
484
+ caller: ctx.env.HQ_LANE_ID?.trim() || "user",
485
+ resumed_from: resumedFrom,
486
+ ...(opts.fromFailed === true ? { from_failed: true } : {}),
487
+ ...(failedResumeExpectedSenior !== undefined ? { expected_senior: failedResumeExpectedSenior } : {}),
488
+ ...(deliveryText !== undefined ? { text: deliveryText } : {}),
489
+ ...(messageId !== undefined ? { message_id: messageId } : {}),
490
+ compact: opts.compact !== false,
491
+ at: quotaHoldAt,
492
+ };
493
+ resumed = markSpawnFailure(hqRoot, laneId, "account_quota_exhausted", {
494
+ provider: lane.provider,
495
+ account: lane.account ?? null,
496
+ quota_reset_at: quotaHoldDeferred.quota_reset_at,
497
+ account_reasons: quotaHoldDeferred.account_reasons,
498
+ }, { resumeTrigger: "resume", pendingResume, at: quotaHoldAt });
499
+ queuedOnQuotaHold = true;
500
+ }
501
+ else if (move?.queued) {
456
502
  resumed = lane;
457
503
  }
458
504
  else {
@@ -570,7 +616,7 @@ export function createLaneResumeHandlers(deps) {
570
616
  if (deliveryText !== undefined) {
571
617
  // An operator text moves the compaction reference forward, as a message does.
572
618
  next = casUpdateLane(hqRoot, laneId, (current) => ({
573
- ...resetKeepAliveProgress(current),
619
+ ...(queuedOnQuotaHold ? current : resetKeepAliveProgress(current)),
574
620
  last_message_at: at,
575
621
  }));
576
622
  }
@@ -587,9 +633,11 @@ export function createLaneResumeHandlers(deps) {
587
633
  delivery: "resume",
588
634
  delivery_status: move?.queued
589
635
  ? "queued_on_target_account"
590
- : queuedOnAdmission
591
- ? "queued_on_admission"
592
- : "worker_spawned",
636
+ : queuedOnQuotaHold
637
+ ? "queued_on_quota_hold"
638
+ : queuedOnAdmission
639
+ ? "queued_on_admission"
640
+ : "worker_spawned",
593
641
  account_move: move ? accountMoveSummary(move) : null,
594
642
  resumed_session_id: resumed.last_resume?.session_id ?? null,
595
643
  last_resume: next.last_resume ?? null,