@indigoai-us/hq-cli 5.131.0 → 5.133.0

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/dist/command-catalog.generated.d.ts +12 -0
  3. package/dist/command-catalog.generated.js +16 -0
  4. package/dist/commands/lanes.d.ts +15 -4
  5. package/dist/commands/lanes.js +478 -48
  6. package/dist/commands/mesh.js +24 -2
  7. package/dist/commands/update.js +1 -1
  8. package/dist/lib/bot/paths.d.ts +7 -5
  9. package/dist/lib/bot/paths.js +8 -1
  10. package/dist/lib/bot/prompt.d.ts +0 -2
  11. package/dist/lib/bot/prompt.js +5 -11
  12. package/dist/lib/bot/room-policy.d.ts +1 -24
  13. package/dist/lib/bot/room-policy.js +2 -35
  14. package/dist/lib/bot/run.d.ts +7 -8
  15. package/dist/lib/bot/run.js +21 -33
  16. package/dist/lib/bot/session.d.ts +1 -1
  17. package/dist/lib/bot/session.js +1 -1
  18. package/dist/lib/lanes/accounts.d.ts +6 -0
  19. package/dist/lib/lanes/accounts.js +2 -1
  20. package/dist/lib/lanes/config.d.ts +4 -0
  21. package/dist/lib/lanes/config.js +9 -0
  22. package/dist/lib/lanes/constants.d.ts +3 -0
  23. package/dist/lib/lanes/constants.js +3 -0
  24. package/dist/lib/lanes/index.d.ts +11 -3
  25. package/dist/lib/lanes/index.js +6 -1
  26. package/dist/lib/lanes/mesh-events.js +4 -0
  27. package/dist/lib/lanes/models.d.ts +78 -0
  28. package/dist/lib/lanes/models.js +205 -0
  29. package/dist/lib/lanes/move-account.d.ts +56 -0
  30. package/dist/lib/lanes/move-account.js +193 -0
  31. package/dist/lib/lanes/provider-rejection.d.ts +86 -0
  32. package/dist/lib/lanes/provider-rejection.js +258 -0
  33. package/dist/lib/lanes/quota.d.ts +23 -3
  34. package/dist/lib/lanes/quota.js +88 -14
  35. package/dist/lib/lanes/reconcile-install.d.ts +24 -3
  36. package/dist/lib/lanes/reconcile-install.js +48 -12
  37. package/dist/lib/lanes/reconcile.d.ts +5 -2
  38. package/dist/lib/lanes/reconcile.js +135 -13
  39. package/dist/lib/lanes/spawn.js +26 -5
  40. package/dist/lib/lanes/store.js +10 -0
  41. package/dist/lib/lanes/types.d.ts +51 -2
  42. package/dist/lib/lanes/types.js +5 -3
  43. package/dist/lib/lanes/watcher.d.ts +27 -0
  44. package/dist/lib/lanes/watcher.js +68 -5
  45. package/dist/lib/mesh/live/backfill-held.d.ts +4 -3
  46. package/dist/lib/mesh/live/backfill-held.js +37 -14
  47. package/dist/lib/mesh/live/daemon/doctor.d.ts +6 -0
  48. package/dist/lib/mesh/live/daemon/doctor.js +16 -2
  49. package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
  50. package/dist/lib/mesh/live/daemon/install.js +100 -7
  51. package/dist/lib/mesh/live/daemon/legacy-listen.d.ts +43 -0
  52. package/dist/lib/mesh/live/daemon/legacy-listen.js +89 -0
  53. package/dist/lib/mesh/live/daemon/live-cache.d.ts +26 -0
  54. package/dist/lib/mesh/live/daemon/live-cache.js +116 -0
  55. package/dist/lib/mesh/live/daemon/presence.d.ts +7 -3
  56. package/dist/lib/mesh/live/daemon/presence.js +10 -9
  57. package/dist/lib/mesh/live/daemon/run.d.ts +18 -0
  58. package/dist/lib/mesh/live/daemon/run.js +65 -6
  59. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +31 -0
  60. package/dist/lib/mesh/live/daemon/work-subscribe.js +124 -0
  61. package/dist/lib/mesh/live/emit-evidence.d.ts +19 -0
  62. package/dist/lib/mesh/live/emit-evidence.js +20 -2
  63. package/dist/lib/work-context/company.d.ts +16 -0
  64. package/dist/lib/work-context/company.js +46 -14
  65. package/dist/utils/self-update.js +4 -1
  66. package/package.json +2 -2
@@ -8,25 +8,30 @@
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
10
  import { parseUserYaml } from "../../utils/user-yaml-error.js";
11
- import { accountMaxRunning, loadLanesConfig } from "./config.js";
11
+ import { accountMaxRunning, loadLanesConfig, providerQuotaDefaultHoldMs } from "./config.js";
12
+ import { MAX_LANE_DEPTH } from "./constants.js";
12
13
  import { LanesError, isLaneVersionConflict } from "./errors.js";
13
14
  import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, } from "./liveness.js";
14
15
  import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, isPidRecordAlive, laneRunDir, readPidRecord, readSpawnManifest, spawnEnvelopeRepair, spawnLaneWorker, } from "./spawn.js";
15
16
  import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, updateLane, updateQuestion, } from "./store.js";
16
17
  import { applyThrottleDecision, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, throttleStatePath, writeThrottleState, } from "./throttle.js";
17
- import { readQuotaSignal, recordAccountQuota } from "./quota.js";
18
+ import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
18
19
  import { answeredQuestionEntries, raiseEnvelopeQuestions } from "./questions.js";
19
20
  import { SILENCE_MS, classifyLaneExit, isPidAlive, readLatestEnvelope, readLatestEnvelopeFile, readLatestEnvelopeRecord, } from "./watcher.js";
20
21
  import { createLaneMesh, emitLaneDecisionEvents } from "./mesh-events.js";
21
- import { decideEnvelopeRepair, envelopeQuestionIds } from "./watcher.js";
22
+ import { decideEnvelopeRepair, envelopeQuestionIds, routeProviderFailure } from "./watcher.js";
23
+ import { providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
22
24
  /** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
23
25
  export const QUESTION_AGE_MS = 60 * 60 * 1000;
24
- /** Spec §6.6 + §9: v1 permits exactly one aged advance. */
25
- export const MAX_AGED_ADVANCES = 1;
26
+ /** Spec §6.6 + §9: a depth-3 worker has two hops to age toward the user. */
27
+ export const MAX_AGED_ADVANCES = MAX_LANE_DEPTH - 1;
26
28
  /** Aligns with watcher silence: a host that has not reported for this long is dead. */
27
29
  export const DEAD_HOST_MS = SILENCE_MS;
28
30
  export const RECONCILE_LOCK_STALE_MS = 30_000;
29
31
  export const RECONCILE_LOCK_TIMEOUT_MS = 5_000;
32
+ /** A pickup probe that has not cleared its marker within this window is abandoned. */
33
+ export const PICKUP_VALIDATION_MAX_AGE_MS = 15 * 60 * 1000;
34
+ export const PICKUP_VALIDATION_TIMEOUT_REASON = "pickup_validation_timeout";
30
35
  export const RECONCILE_UNIT_NAME = "hq-lanes-reconcile.service";
31
36
  export const RECONCILE_TIMER_NAME = "hq-lanes-reconcile.timer";
32
37
  function logSkip(skip) {
@@ -308,6 +313,89 @@ function casQuestionIf(hqRoot, questionId, shouldWrite, updater, dryRun) {
308
313
  }
309
314
  throw new LanesError("version_conflict", `Exhausted CAS retries for question ${questionId}`, { question_id: questionId });
310
315
  }
316
+ async function emitAgedQuestionAtUser(summary, deps, lane, question) {
317
+ if (deps.dryRun)
318
+ return;
319
+ const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
320
+ if (!mesh || !lane.company || !lane.thread_id)
321
+ return;
322
+ const timeoutMs = deps.meshEmitTimeoutMs ?? 10_000;
323
+ let timer;
324
+ try {
325
+ await Promise.race([
326
+ mesh.appendThreadEvent(lane.company, lane.thread_id, "lane_question_at_user", { lane_id: lane.lane_id, question_id: question.question_id }),
327
+ new Promise((_resolve, reject) => {
328
+ timer = setTimeout(() => reject(new Error(`question-at-user mesh emit timed out after ${timeoutMs}ms`)), timeoutMs);
329
+ }),
330
+ ]);
331
+ }
332
+ catch (err) {
333
+ markDirty(summary, {
334
+ kind: "question",
335
+ id: question.question_id,
336
+ why: `question-at-user mesh emit failed: ${err instanceof Error ? err.message : String(err)}`,
337
+ });
338
+ }
339
+ finally {
340
+ if (timer)
341
+ clearTimeout(timer);
342
+ }
343
+ }
344
+ function pickupValidationPendingAtMs(lane) {
345
+ const raw = lane.pickup_validation_pending_since ?? lane.updated_at;
346
+ const at = Date.parse(raw);
347
+ return Number.isFinite(at) ? at : null;
348
+ }
349
+ function pickupValidationExpired(lane, nowMs) {
350
+ const pendingAtMs = pickupValidationPendingAtMs(lane);
351
+ return pendingAtMs === null || nowMs - pendingAtMs > PICKUP_VALIDATION_MAX_AGE_MS;
352
+ }
353
+ function expireAbandonedPickupValidation(hqRoot, lanes, summary, deps, now) {
354
+ const nowMs = now.getTime();
355
+ for (const lane of lanes) {
356
+ if (lane.state !== "queued" ||
357
+ lane.pickup_validation_pending !== true ||
358
+ !laneRunsHere(lane, deps.host) ||
359
+ !pickupValidationExpired(lane, nowMs)) {
360
+ continue;
361
+ }
362
+ try {
363
+ const result = casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "queued" &&
364
+ current.pickup_validation_pending === true &&
365
+ pickupValidationExpired(current, nowMs), (current) => {
366
+ const pendingAtMs = pickupValidationPendingAtMs(current);
367
+ return {
368
+ ...current,
369
+ state: "failed",
370
+ queued_reason: undefined,
371
+ pickup_validation_pending: undefined,
372
+ pickup_validation_pending_since: undefined,
373
+ terminal_reason: PICKUP_VALIDATION_TIMEOUT_REASON,
374
+ terminal_details: {
375
+ pickup_validation_pending_since: current.pickup_validation_pending_since ?? current.updated_at,
376
+ age_ms: pendingAtMs === null ? null : Math.max(0, nowMs - pendingAtMs),
377
+ max_age_ms: PICKUP_VALIDATION_MAX_AGE_MS,
378
+ },
379
+ };
380
+ }, deps.dryRun);
381
+ if (result.changed) {
382
+ summary.aged.push({
383
+ id: lane.lane_id,
384
+ from: "queued",
385
+ to: "failed",
386
+ reason: PICKUP_VALIDATION_TIMEOUT_REASON,
387
+ });
388
+ }
389
+ }
390
+ catch (err) {
391
+ markDirty(summary, {
392
+ kind: "lane",
393
+ id: lane.lane_id,
394
+ why: `pickup validation expiry failed: ${err instanceof Error ? err.message : String(err)}`,
395
+ });
396
+ }
397
+ }
398
+ }
311
399
  function fileNonEmpty(filePath) {
312
400
  try {
313
401
  return fs.statSync(filePath).size > 0;
@@ -431,13 +519,29 @@ function classifyDeadLocalLane(hqRoot, laneId) {
431
519
  path.join(runDir, "logs", "last-message.txt"),
432
520
  ].some((file) => fileNonEmpty(file));
433
521
  const lane = readLane(hqRoot, laneId);
434
- const quota = envelope
435
- ? null
436
- : readQuotaSignal(runDir, lane.model, new Date(), readSpawnManifest(runDir).worker_out_offset ?? 0);
522
+ // The same routing as the watcher's exit path (watcher.ts): a provider
523
+ // failure on the stream is read first, a 429 or quota-worded one becomes
524
+ // the quota signal, any other becomes provider_rejected, and only a stream
525
+ // with no failure object falls back to the quota reader.
526
+ const workerOutOffset = readSpawnManifest(runDir).worker_out_offset ?? 0;
527
+ const now = new Date();
528
+ const routed = envelope
529
+ ? { quota: null, rejection: null }
530
+ : routeProviderFailure(readProviderRejection(runDir, lane.provider, workerOutOffset), () => readQuotaSignal(runDir, lane.model, now, workerOutOffset), lane.model, now);
531
+ // As in the watcher: a reset-less 429 gets the provider's default hold.
532
+ const quota = routed.quota
533
+ ? applyDefaultQuotaHold(routed.quota, providerQuotaDefaultHoldMs(loadLanesConfig(hqRoot).providers[lane.provider]), now)
534
+ : null;
437
535
  return {
438
- classification: classifyLaneExit({ envelope, hasPartialOutput, quota }),
536
+ classification: classifyLaneExit({
537
+ envelope,
538
+ hasPartialOutput,
539
+ quota,
540
+ providerRejection: routed.rejection,
541
+ }),
439
542
  latest,
440
543
  quota,
544
+ providerRejection: routed.rejection,
441
545
  lane,
442
546
  };
443
547
  }
@@ -572,6 +676,10 @@ export async function runPassBody(hqRoot, deps) {
572
676
  to: String(nextHop),
573
677
  reason: "unanswered_timeout",
574
678
  });
679
+ if (nextAudience.kind === "user") {
680
+ const originLane = laneById.get(question.lane_id) ?? readLane(hqRoot, question.lane_id);
681
+ await emitAgedQuestionAtUser(summary, deps, originLane, result.record);
682
+ }
575
683
  }
576
684
  }
577
685
  }
@@ -722,12 +830,17 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
722
830
  continue;
723
831
  }
724
832
  try {
725
- const { classification, latest, quota, lane: classified } = classifyDeadLocalLane(hqRoot, lane.lane_id);
833
+ const { classification, latest, quota, providerRejection, lane: classified } = classifyDeadLocalLane(hqRoot, lane.lane_id);
726
834
  // Match the watcher's one-repair decision before classifying an
727
- // unparseable envelope after both processes have died.
835
+ // unparseable envelope after both processes have died. As in the
836
+ // watcher, a provider rejection or a quota signal skips the repair.
728
837
  const rawLatest = readLatestEnvelopeFile(hqRoot, lane.lane_id);
729
838
  let invalidEnvelope = null;
730
- if (!deps.dryRun && rawLatest && rawLatest.envelope === null) {
839
+ if (!deps.dryRun &&
840
+ !providerRejection &&
841
+ !quota &&
842
+ rawLatest &&
843
+ rawLatest.envelope === null) {
731
844
  const repair = decideEnvelopeRepair(hqRoot, lane.lane_id, rawLatest, nowMs, deps.spawnRepair ?? spawnEnvelopeRepair, () => undefined);
732
845
  if (repair.action === "spawned") {
733
846
  armLaneWatcher(hqRoot, lane.lane_id, deps.spawnLaneDeps);
@@ -757,6 +870,9 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
757
870
  state: repairedClassification.state,
758
871
  queued_reason: undefined,
759
872
  terminal_reason: repairedClassification.reason ?? "host_lost",
873
+ ...(providerRejection
874
+ ? { terminal_details: providerRejectionDetails(providerRejection, current) }
875
+ : {}),
760
876
  ...(quota?.quota_reset_at ? { quota_reset_at: quota.quota_reset_at } : {}),
761
877
  ...(quota?.marker ? { model_quota_marker: quota.marker } : {}),
762
878
  last_host_report_at: nowIso,
@@ -837,8 +953,14 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
837
953
  }
838
954
  }
839
955
  async function admitQueued(hqRoot, lanes, summary, deps, now) {
956
+ expireAbandonedPickupValidation(hqRoot, lanes, summary, deps, now);
957
+ // A lane whose pickup probe is still out is not admitted: its record is
958
+ // about to be bound or failed by the pickup, and a worker spawned now
959
+ // would keep running behind that write.
840
960
  const localQueued = lanes
841
- .filter((lane) => lane.state === "queued" && laneRunsHere(lane, deps.host))
961
+ .filter((lane) => lane.state === "queued" &&
962
+ lane.pickup_validation_pending !== true &&
963
+ laneRunsHere(lane, deps.host))
842
964
  .sort(compareQueueOrder);
843
965
  if (localQueued.length === 0)
844
966
  return;
@@ -979,13 +979,19 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
979
979
  }
980
980
  return existing;
981
981
  }
982
+ // A queued lane that a move parked at the target's cap carries the turn
983
+ // it was resumed with: the admit spawn continues that session instead of
984
+ // starting fresh from the brief.
985
+ const pendingResume = launch.trigger === undefined && existing.state === "queued" ? existing.pending_resume : undefined;
982
986
  const trigger = launch.trigger ?? inferTrigger(existing);
983
987
  // A message into a finished lane must not turn that lane `failed` when the
984
988
  // account cannot be bound: nothing has started yet, so the record keeps its
985
989
  // state and the caller sees the error. Admission and question resume keep
986
990
  // today's behaviour of naming the terminal reason on the record.
987
991
  const markPreSpawnFailure = trigger.kind !== "message" && trigger.kind !== "resume";
988
- const plan = planLaneLaunch(existing, trigger);
992
+ const plan = pendingResume !== undefined && providerSupportsSessionResume(existing.provider)
993
+ ? { mode: "resume", session_id: pendingResume.session_id }
994
+ : planLaneLaunch(existing, trigger);
989
995
  if (existing.host.kind === "outpost") {
990
996
  refuseOutpostSelfTarget(hqRoot, existing, deps);
991
997
  }
@@ -1065,8 +1071,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1065
1071
  ? trigger.text
1066
1072
  : trigger.kind === "resume"
1067
1073
  ? trigger.text
1068
- : undefined,
1069
- resumedFrom: trigger.kind === "resume" ? trigger.from : undefined,
1074
+ : pendingResume?.text,
1075
+ resumedFrom: trigger.kind === "resume" ? trigger.from : pendingResume?.resumed_from,
1070
1076
  answersPath: trigger.kind === "answered_questions" && existsSync(answersPath)
1071
1077
  ? answersPath
1072
1078
  : undefined,
@@ -1119,7 +1125,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1119
1125
  // Resume-time compaction (compaction.ts). Runs before the claim so the
1120
1126
  // lane is never `running` without a worker while `/compact` works.
1121
1127
  let compaction;
1122
- if (resume && launch.compact !== false) {
1128
+ const compactWanted = pendingResume?.compact ?? launch.compact;
1129
+ if (resume && compactWanted !== false) {
1123
1130
  const nowFn = deps.now ?? (() => new Date());
1124
1131
  const decision = compactionDue(existing, nowFn().getTime(), providerCompactAfterMs(config.providers[lane.provider]));
1125
1132
  if (decision.due) {
@@ -1169,6 +1176,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1169
1176
  ...current,
1170
1177
  state: "running",
1171
1178
  queued_reason: undefined,
1179
+ // An account move's reservation ends with the claim.
1180
+ pending_resume: undefined,
1172
1181
  account: resolved.id,
1173
1182
  ...(model !== undefined ? { model } : {}),
1174
1183
  effort_source: effort.source,
@@ -1187,10 +1196,22 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1187
1196
  }
1188
1197
  if (compaction)
1189
1198
  next.last_compaction = { ...compaction, resume_at: nowIso };
1190
- if (resume && trigger.kind !== "admit") {
1199
+ if (resume && pendingResume !== undefined) {
1191
1200
  next.last_resume = {
1192
1201
  session_id: resume.session_id,
1193
1202
  at: nowIso,
1203
+ account: resolved.id,
1204
+ trigger: pendingResume.trigger,
1205
+ ...(pendingResume.message_id !== undefined ? { message_id: pendingResume.message_id } : {}),
1206
+ ...(pendingResume.resumed_from !== undefined ? { resumed_from: pendingResume.resumed_from } : {}),
1207
+ ...(pendingResume.text !== undefined ? { text: pendingResume.text } : {}),
1208
+ };
1209
+ }
1210
+ else if (resume && trigger.kind !== "admit") {
1211
+ next.last_resume = {
1212
+ session_id: resume.session_id,
1213
+ at: nowIso,
1214
+ account: resolved.id,
1194
1215
  trigger: trigger.kind,
1195
1216
  ...(trigger.kind === "message" ? { message_id: trigger.message_id } : {}),
1196
1217
  ...(trigger.kind === "resume"
@@ -325,6 +325,11 @@ export function parseLaneRecord(raw, filePath) {
325
325
  path: filePath,
326
326
  });
327
327
  }
328
+ if (raw.account_history !== undefined && !Array.isArray(raw.account_history)) {
329
+ throw new LaneSchemaError("account_history must be an array when present", {
330
+ path: filePath,
331
+ });
332
+ }
328
333
  if (!Array.isArray(raw.attribution_history)) {
329
334
  throw new LaneSchemaError("attribution_history must be an array", {
330
335
  path: filePath,
@@ -466,6 +471,11 @@ export function mergeLaneRecords(live, sidecars) {
466
471
  ...base,
467
472
  senior_history: unionHistory(all.map((record) => record.senior_history), entryIdentity, base.senior_history),
468
473
  attribution_history: unionHistory(all.map((record) => record.attribution_history), attributionIdentity, base.attribution_history),
474
+ ...(all.some((record) => record.account_history !== undefined)
475
+ ? {
476
+ account_history: unionHistory(all.map((record) => record.account_history ?? []), entryIdentity, base.account_history ?? []),
477
+ }
478
+ : {}),
469
479
  };
470
480
  }
471
481
  export function mergeQuestionRecords(live, sidecars) {
@@ -250,6 +250,15 @@ export interface LaneRecord {
250
250
  senior_history: SeniorHistoryEntry[];
251
251
  state: LaneState;
252
252
  queued_reason?: QueuedReason;
253
+ /**
254
+ * Set by `hq lanes pickup` when it stores an outpost lane and cleared by
255
+ * the account bind once the model probe has finished. While set, the
256
+ * reconcile pass does not admit the lane: a spawn during the probe would
257
+ * leave a running worker behind a record the probe result then rewrites.
258
+ */
259
+ pickup_validation_pending?: true;
260
+ /** When the pickup validation marker was written. */
261
+ pickup_validation_pending_since?: string;
253
262
  priority: number;
254
263
  worker?: string;
255
264
  /** Registry-relative directory of the resolved worker, when --worker was set. */
@@ -258,6 +267,20 @@ export interface LaneRecord {
258
267
  worker_pack?: string;
259
268
  provider: LaneProvider;
260
269
  account?: string;
270
+ /**
271
+ * Every operator move of the lane's session to another account of the
272
+ * same provider (`hq lanes resume|message --account`), newest last, capped
273
+ * like the other histories.
274
+ */
275
+ account_history?: AccountHistoryEntry[];
276
+ /**
277
+ * Set by an account move (`--account`) until the spawn claims the lane:
278
+ * the turn the move was asked to run. While set it reserves a slot on the
279
+ * account for the capacity check of later moves. When the target was at
280
+ * its cap the lane is queued there and the reconcile pass resumes the
281
+ * recorded session with this turn instead of starting fresh from the brief.
282
+ */
283
+ pending_resume?: PendingResume;
261
284
  host: LaneHost;
262
285
  /**
263
286
  * Creating machine's identity when `host.kind` is `outpost` (spec §5.2).
@@ -308,8 +331,10 @@ export interface LaneRecord {
308
331
  terminal_reason?: string;
309
332
  /**
310
333
  * Structured detail behind a named `terminal_reason` (dispatch, pickup and
311
- * spawn failures), or the missing-binary search path behind
312
- * `provider_binary_missing`. Written by the command and spawn writers.
334
+ * spawn failures), the missing-binary search path behind
335
+ * `provider_binary_missing`, or the provider's own message, status, model
336
+ * and account behind `provider_rejected`. Written by the command, spawn
337
+ * and watcher writers.
313
338
  */
314
339
  terminal_details?: Record<string, unknown>;
315
340
  /** SSM command id of the `hq outposts exec` pickup that started this lane. */
@@ -368,9 +393,27 @@ export interface LaneEnvelopeRepair {
368
393
  envelope_ref?: string;
369
394
  at: string;
370
395
  }
396
+ export interface AccountHistoryEntry {
397
+ from: string | null;
398
+ to: string;
399
+ at: string;
400
+ reason: "operator_move";
401
+ }
402
+ export interface PendingResume {
403
+ session_id: string;
404
+ trigger: "message" | "resume";
405
+ resumed_from?: "interrupted" | "done" | "blocked";
406
+ text?: string;
407
+ message_id?: string;
408
+ /** False when the command passed --no-compact; the deferred spawn honours it. */
409
+ compact?: boolean;
410
+ at: string;
411
+ }
371
412
  export interface LaneResumeRecord {
372
413
  session_id: string;
373
414
  at: string;
415
+ /** Account the resumed run was bound to. */
416
+ account?: string;
374
417
  trigger: "answered_questions" | "message" | "resume";
375
418
  message_id?: string;
376
419
  /**
@@ -508,6 +551,12 @@ export interface ProviderConfig {
508
551
  * means the five-minute default.
509
552
  */
510
553
  progress_event_min_interval_ms?: number;
554
+ /**
555
+ * Hold applied to an account after a 429 whose message names no reset
556
+ * time: the account is skipped for this long. Absent means the
557
+ * fifteen-minute default.
558
+ */
559
+ quota_default_hold_ms?: number;
511
560
  accounts: Record<string, AccountConfig>;
512
561
  }
513
562
  export interface ThrottleConfig {
@@ -198,13 +198,15 @@ export const LANE_FIELD_WRITERS = {
198
198
  project_id: ["command"], story_id: ["command"], thread_id: ["creator", "command"],
199
199
  depth: ["command"], senior: ["command"], senior_history: ["command"],
200
200
  state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "reconcile"],
201
+ pickup_validation_pending: ["creator", "command", "spawn", "reconcile"],
202
+ pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
201
203
  priority: ["command"], worker: ["creator"], worker_path: ["creator"], worker_pack: ["creator"],
202
- provider: ["creator"], account: ["creator", "spawn"], host: ["creator"], origin_host: ["creator"], origin_host_source: ["creator"],
204
+ provider: ["creator"], account: ["creator", "spawn", "command"], account_history: ["command"], pending_resume: ["command", "spawn"], host: ["creator"], origin_host: ["creator"], origin_host_source: ["creator"],
203
205
  version: ["creator"], created_at: ["creator"], updated_at: ["creator"], attribution_history: ["command"],
204
- last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn"], effort: ["creator", "spawn"], effort_source: ["creator", "spawn"],
206
+ last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn", "command"], effort: ["creator", "spawn"], effort_source: ["creator", "spawn"],
205
207
  last_message_at: ["command"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
206
208
  overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
207
- admitted_reason: ["reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], terminal_details: ["command", "spawn"],
209
+ admitted_reason: ["reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
208
210
  quota_reset_at: ["watcher", "reconcile"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
209
211
  provider_session_id: ["watcher"], provider_session_id_absent: ["watcher"], last_resume: ["spawn"], last_run_usage: ["watcher"], envelope_repair: ["watcher", "reconcile"], last_progress_event_at: ["watcher"],
210
212
  };
@@ -33,6 +33,7 @@
33
33
  */
34
34
  import { type LaneEnvelope, type LaneRecord, type LaneState } from "./types.js";
35
35
  import { type QuotaSignal } from "./quota.js";
36
+ import { type ProviderRejection } from "./provider-rejection.js";
36
37
  import { type LaneMesh } from "./mesh-events.js";
37
38
  import { type EnvelopeRepairSpawnInput } from "./spawn.js";
38
39
  /** Spec §5.7: no signal for 20 minutes while the process is still alive. */
@@ -73,6 +74,7 @@ export interface WatcherTickInput {
73
74
  invalidEnvelope?: {
74
75
  errors: string[];
75
76
  } | null;
77
+ providerRejection?: ProviderRejection | null;
76
78
  }
77
79
  export interface WatcherTickResult {
78
80
  emit?: WatcherSignal;
@@ -97,7 +99,30 @@ export declare function classifyLaneExit(input: {
97
99
  invalidEnvelope?: {
98
100
  errors: string[];
99
101
  } | null;
102
+ /**
103
+ * Set when the worker's output stream carries a provider-level turn
104
+ * failure (codex `turn.failed`, a Claude error result with an HTTP status,
105
+ * a grok error object). Failed, never interrupted: the provider refused
106
+ * the turn, so there is nothing to resume and no envelope to repair.
107
+ */
108
+ providerRejection?: ProviderRejection | null;
100
109
  }): TerminalClassification;
110
+ /**
111
+ * One decision for an exited worker's output. An explicit provider status
112
+ * wins over the quota text heuristics: a 429 from any provider (including
113
+ * codex `turn.failed` and grok error objects, which the claude-shaped quota
114
+ * reader never sees) becomes a quota signal; any other explicit status is a
115
+ * rejection even when its text sounds like a quota message (a 402 "requires
116
+ * usage credits" is a rejection, not a hold). A failure with no status at
117
+ * all is matched against the quota text heuristics first: a status-less
118
+ * "usage limit" / "rate limit" turn.failed still creates the account hold;
119
+ * any other status-less message is a rejection. Only when the stream
120
+ * carries no failure object does the quota reader's own read apply.
121
+ */
122
+ export declare function routeProviderFailure(failure: ProviderRejection | null, readQuota: () => QuotaSignal | null, model: string | undefined, now: Date): {
123
+ quota: QuotaSignal | null;
124
+ rejection: ProviderRejection | null;
125
+ };
101
126
  export declare function tickWatcher(input: WatcherTickInput): WatcherTickResult;
102
127
  export declare function watcherEventsPath(hqRoot: string, laneId: string): string;
103
128
  export declare function watcherOutLogPath(hqRoot: string, laneId: string): string;
@@ -119,6 +144,8 @@ export interface ApplyTerminalOpts {
119
144
  envelopeRef?: string;
120
145
  at?: string;
121
146
  quota?: QuotaSignal | null;
147
+ /** Written to `terminal_details` with the model and account the run used. */
148
+ providerRejection?: ProviderRejection | null;
122
149
  }
123
150
  export declare function stalledReason(silenceMs: number): string;
124
151
  /**
@@ -38,9 +38,10 @@ import { raiseEnvelopeQuestions } from "./questions.js";
38
38
  import { isTerminalLaneState, parseLaneEnvelope, } from "./types.js";
39
39
  import { readLane, retryUpdateLane } from "./store.js";
40
40
  import { readWorkerRunResult } from "./delivery.js";
41
- import { readQuotaSignal, recordAccountQuota } from "./quota.js";
41
+ import { applyDefaultQuotaHold, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
42
+ import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
42
43
  import { readToolActivity, toolActivityPath } from "./dropbox.js";
43
- import { loadLanesConfig, providerProgressEventMinIntervalMs } from "./config.js";
44
+ import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefaultHoldMs } from "./config.js";
44
45
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, progressEventDue, } from "./mesh-events.js";
45
46
  import { envelopeQuestionId } from "./questions.js";
46
47
  import { laneRunDir, missingPidProbe, probePidRecord, readPidRecord, readProcStarttime, readSpawnManifest, envelopeRepairMode, spawnEnvelopeRepair, } from "./spawn.js";
@@ -86,6 +87,9 @@ export function classifyLaneExit(input) {
86
87
  return { state: "blocked" };
87
88
  throw new LanesError("unknown_envelope_decision", `Unknown envelope decision ${JSON.stringify(input.envelope.decision)}. Accepted: done, ask, blocked`, { accepted: ["done", "ask", "blocked"], value: input.envelope.decision });
88
89
  }
90
+ if (input.providerRejection) {
91
+ return { state: "failed", reason: PROVIDER_REJECTED_REASON };
92
+ }
89
93
  if (input.invalidEnvelope) {
90
94
  return { state: "failed", reason: ENVELOPE_INVALID_REASON };
91
95
  }
@@ -100,6 +104,34 @@ export function classifyLaneExit(input) {
100
104
  }
101
105
  return { state: "failed", reason: "exited_without_envelope" };
102
106
  }
107
+ /**
108
+ * One decision for an exited worker's output. An explicit provider status
109
+ * wins over the quota text heuristics: a 429 from any provider (including
110
+ * codex `turn.failed` and grok error objects, which the claude-shaped quota
111
+ * reader never sees) becomes a quota signal; any other explicit status is a
112
+ * rejection even when its text sounds like a quota message (a 402 "requires
113
+ * usage credits" is a rejection, not a hold). A failure with no status at
114
+ * all is matched against the quota text heuristics first: a status-less
115
+ * "usage limit" / "rate limit" turn.failed still creates the account hold;
116
+ * any other status-less message is a rejection. Only when the stream
117
+ * carries no failure object does the quota reader's own read apply.
118
+ */
119
+ export function routeProviderFailure(failure, readQuota, model, now) {
120
+ if (failure && failure.status === QUOTA_STATUS) {
121
+ return {
122
+ quota: quotaSignalFromFields({ api_error_status: QUOTA_STATUS, is_error: true, result: failure.message }, model, now),
123
+ rejection: null,
124
+ };
125
+ }
126
+ if (failure && failure.status === null) {
127
+ const quota = quotaSignalFromFields({ api_error_status: undefined, is_error: true, result: failure.message }, model, now);
128
+ if (quota)
129
+ return { quota, rejection: null };
130
+ }
131
+ if (failure)
132
+ return { quota: null, rejection: failure };
133
+ return { quota: readQuota(), rejection: null };
134
+ }
103
135
  export function tickWatcher(input) {
104
136
  const silenceMs = input.silenceMs ?? SILENCE_MS;
105
137
  const at = new Date(input.nowMs).toISOString();
@@ -109,6 +141,7 @@ export function tickWatcher(input) {
109
141
  hasPartialOutput: input.hasPartialOutput,
110
142
  quota: input.quota,
111
143
  invalidEnvelope: input.invalidEnvelope,
144
+ providerRejection: input.providerRejection,
112
145
  });
113
146
  return {
114
147
  emit: { kind: "terminal", at, classification },
@@ -301,6 +334,9 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
301
334
  if (opts.quota?.marker) {
302
335
  next.model_quota_marker = opts.quota.marker;
303
336
  }
337
+ if (opts.providerRejection) {
338
+ next.terminal_details = providerRejectionDetails(opts.providerRejection, current);
339
+ }
304
340
  if (opts.envelope && opts.envelopeRef) {
305
341
  next.last_envelope = {
306
342
  ref: opts.envelopeRef,
@@ -589,19 +625,37 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
589
625
  const hasPartialOutput = logFiles.some((file) => fileNonEmpty(file));
590
626
  let quota = null;
591
627
  let quotaLane = null;
628
+ let providerRejection = null;
592
629
  if (!processAlive && !envelope) {
593
630
  try {
594
631
  quotaLane = readLane(hqRoot, laneId);
595
- quota = readQuotaSignal(runDir, quotaLane.model, new Date(now()), readSpawnManifest(runDir).worker_out_offset ?? 0);
632
+ const workerOutOffset = readSpawnManifest(runDir).worker_out_offset ?? 0;
633
+ const routed = routeProviderFailure(readProviderRejection(runDir, quotaLane.provider, workerOutOffset), () => readQuotaSignal(runDir, quotaLane.model, new Date(now()), workerOutOffset), quotaLane.model, new Date(now()));
634
+ // A reset-less 429 still holds the account: the configured default
635
+ // hold becomes its reset, recorded as such.
636
+ quota = routed.quota
637
+ ? applyDefaultQuotaHold(routed.quota, providerQuotaDefaultHoldMs(loadLanesConfig(hqRoot).providers[quotaLane.provider]), new Date(now()))
638
+ : null;
639
+ providerRejection = routed.rejection;
596
640
  }
597
641
  catch (err) {
598
642
  process.stderr.write(`hq lanes watcher: quota read failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
599
643
  }
600
644
  }
601
645
  // Spec §4: the process exited and its envelope file does not parse.
602
- // Exactly one repair turn, then failed with both errors.
646
+ // Exactly one repair turn, then failed with both errors. A provider
647
+ // rejection skips the repair: the provider refused the turn, so a
648
+ // second turn on the same model and account would be refused the same
649
+ // way. A quota signal skips it too: a repair turn on an exhausted account
650
+ // fails the same way, and would end the lane envelope_invalid without
651
+ // ever recording the hold. The quota result applies immediately instead.
603
652
  let invalidEnvelope = null;
604
- if (!processAlive && !envelope && latestFile && latestFile.envelope === null) {
653
+ if (!processAlive &&
654
+ !envelope &&
655
+ !providerRejection &&
656
+ !quota &&
657
+ latestFile &&
658
+ latestFile.envelope === null) {
605
659
  const repair = decideEnvelopeRepair(hqRoot, laneId, latestFile, now(), spawnRepair, logDecision);
606
660
  if (repair.action === "spawned") {
607
661
  lastSignalMs = now();
@@ -626,6 +680,7 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
626
680
  silenceMs,
627
681
  quota,
628
682
  invalidEnvelope,
683
+ providerRejection,
629
684
  });
630
685
  if (processAlive) {
631
686
  maybeHeartbeatLane(hqRoot, laneId, now());
@@ -702,6 +757,13 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
702
757
  state: classification.state,
703
758
  reason: classification.reason,
704
759
  ...(invalidEnvelope ? { envelope_errors: invalidEnvelope.errors } : {}),
760
+ ...(providerRejection
761
+ ? {
762
+ provider_status: providerRejection.status,
763
+ provider_message: providerRejection.message,
764
+ retryable: providerRejection.retryable,
765
+ }
766
+ : {}),
705
767
  });
706
768
  if (quota &&
707
769
  quotaLane?.account &&
@@ -719,6 +781,7 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
719
781
  envelopeRef: latest?.ref,
720
782
  at: tick.emit.at,
721
783
  quota,
784
+ providerRejection,
722
785
  });
723
786
  const updated = terminalWrite.record;
724
787
  logDecision({
@@ -49,15 +49,16 @@ export interface HeldSessionRef {
49
49
  /** Task id carried on the event, if any. */
50
50
  task?: string;
51
51
  /**
52
- * Per-event local evidence retained while coalescing a held session. Event
53
- * timestamps are instance boundaries: a session id may be reused, so a root
54
- * observed before the current state.startedAt must not attribute it.
52
+ * Per-event local evidence retained while coalescing a held session. A
53
+ * session id may be reused. The current instance is state.bindingEpisodeId,
54
+ * never the event timestamp: a later `at` on another episode is still stale.
55
55
  */
56
56
  eventEvidence?: Array<{
57
57
  hqRoot?: string;
58
58
  cwd?: string;
59
59
  harness?: string;
60
60
  at?: string;
61
+ bindingEpisodeId?: string;
61
62
  }>;
62
63
  }
63
64
  export interface BackfillHeldResult {