@indigoai-us/hq-cli 5.233.0 → 5.234.1

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,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.234.1] — 2026-09-27
6
+
7
+ ### Fixed
8
+
9
+ - A primary resume now confirms and syncs an Outpost lane queued during
10
+ owner-side admission. (#926)
11
+ - Outpost messages and account moves for children of primary-hosted lanes now
12
+ carry the bounded hire-grant context required by the owner. (#926)
13
+ - Outpost message resumes, moves, and admission queues now sync the returned
14
+ owner lane record back to the primary. (#926)
15
+
16
+ ## [5.234.0] — 2026-09-27
17
+
18
+ ### Added
19
+
20
+ - A lane's senior can explicitly resume a failed lane's recorded provider
21
+ session with `hq lanes resume --from-failed --reason`; the secret-scanned
22
+ reason and recovered failure are recorded in lane history. Failed lanes
23
+ continue to refuse ordinary messages. (#927)
24
+
25
+ ### Fixed
26
+
27
+ - `hq agents stop` says the stop was accepted and shows the status the API returns, instead of saying the agent has already stopped. (#922)
28
+
5
29
  ## [5.233.0] — 2026-09-27
6
30
 
7
31
  ### Added
@@ -8005,6 +8005,9 @@ export declare const COMMAND_CATALOG: readonly [{
8005
8005
  }, {
8006
8006
  readonly flags: "--worker <profile>";
8007
8007
  readonly description: "Attach a personal worker profile when this message resumes the lane";
8008
+ }, {
8009
+ readonly flags: "--grant-context <base64url>";
8010
+ readonly description: "";
8008
8011
  }, {
8009
8012
  readonly flags: "--no-compact";
8010
8013
  readonly description: "Skip the resume-time compaction check for this message";
@@ -8047,7 +8050,7 @@ export declare const COMMAND_CATALOG: readonly [{
8047
8050
  readonly subcommands: readonly [];
8048
8051
  }, {
8049
8052
  readonly name: "resume";
8050
- readonly description: "Resume an interrupted, done or blocked lane on its recorded provider session";
8053
+ readonly description: "Resume an interrupted, done or blocked lane on its recorded provider session; recover failed lanes with the senior-only --from-failed option";
8051
8054
  readonly aliases: readonly [];
8052
8055
  readonly hidden: false;
8053
8056
  readonly usage: "[options] <lane-id>";
@@ -8059,6 +8062,12 @@ export declare const COMMAND_CATALOG: readonly [{
8059
8062
  readonly options: readonly [{
8060
8063
  readonly flags: "--text <msg>";
8061
8064
  readonly description: "Operator message for the resumed turn";
8065
+ }, {
8066
+ readonly flags: "--from-failed";
8067
+ readonly description: "Explicitly resume a failed lane as its senior";
8068
+ }, {
8069
+ readonly flags: "--reason <text>";
8070
+ readonly description: "Required explanation for resuming from failed";
8062
8071
  }, {
8063
8072
  readonly flags: "--project <slug>";
8064
8073
  readonly description: "Re-point project attribution from this resume";
@@ -8080,6 +8089,9 @@ export declare const COMMAND_CATALOG: readonly [{
8080
8089
  }, {
8081
8090
  readonly flags: "--grant-context <base64url>";
8082
8091
  readonly description: "";
8092
+ }, {
8093
+ readonly flags: "--failed-resume-context <base64url>";
8094
+ readonly description: "";
8083
8095
  }, {
8084
8096
  readonly flags: "--keep-alive";
8085
8097
  readonly description: "Automatically resume done lead lanes while their story remains open";
@@ -10353,6 +10353,10 @@ export const COMMAND_CATALOG = [
10353
10353
  "flags": "--worker <profile>",
10354
10354
  "description": "Attach a personal worker profile when this message resumes the lane"
10355
10355
  },
10356
+ {
10357
+ "flags": "--grant-context <base64url>",
10358
+ "description": ""
10359
+ },
10356
10360
  {
10357
10361
  "flags": "--no-compact",
10358
10362
  "description": "Skip the resume-time compaction check for this message"
@@ -10408,7 +10412,7 @@ export const COMMAND_CATALOG = [
10408
10412
  },
10409
10413
  {
10410
10414
  "name": "resume",
10411
- "description": "Resume an interrupted, done or blocked lane on its recorded provider session",
10415
+ "description": "Resume an interrupted, done or blocked lane on its recorded provider session; recover failed lanes with the senior-only --from-failed option",
10412
10416
  "aliases": [],
10413
10417
  "hidden": false,
10414
10418
  "usage": "[options] <lane-id>",
@@ -10424,6 +10428,14 @@ export const COMMAND_CATALOG = [
10424
10428
  "flags": "--text <msg>",
10425
10429
  "description": "Operator message for the resumed turn"
10426
10430
  },
10431
+ {
10432
+ "flags": "--from-failed",
10433
+ "description": "Explicitly resume a failed lane as its senior"
10434
+ },
10435
+ {
10436
+ "flags": "--reason <text>",
10437
+ "description": "Required explanation for resuming from failed"
10438
+ },
10427
10439
  {
10428
10440
  "flags": "--project <slug>",
10429
10441
  "description": "Re-point project attribution from this resume"
@@ -10452,6 +10464,10 @@ export const COMMAND_CATALOG = [
10452
10464
  "flags": "--grant-context <base64url>",
10453
10465
  "description": ""
10454
10466
  },
10467
+ {
10468
+ "flags": "--failed-resume-context <base64url>",
10469
+ "description": ""
10470
+ },
10455
10471
  {
10456
10472
  "flags": "--keep-alive",
10457
10473
  "description": "Automatically resume done lead lanes while their story remains open"
@@ -1819,8 +1819,16 @@ export function registerAgentsCommand(program) {
1819
1819
  .action(async function (agentUid) {
1820
1820
  try {
1821
1821
  const token = (await resolveVaultCredential()).token;
1822
- await startStopAgent(token, agentUid, action);
1823
- console.log(chalk.green(`${action === "start" ? "Started" : "Stopped"} agent ${agentUid}.`));
1822
+ const result = await startStopAgent(token, agentUid, action);
1823
+ const runtimeStatus = result.runtime?.status;
1824
+ const successMessage = action === "start"
1825
+ ? `Started agent ${agentUid}.`
1826
+ : runtimeStatus === "terminated"
1827
+ ? `Stopped agent ${agentUid}.`
1828
+ : typeof runtimeStatus === "string"
1829
+ ? `Stop accepted for agent ${agentUid} (status: ${runtimeStatus}).`
1830
+ : `Stop accepted for agent ${agentUid}.`;
1831
+ console.log(chalk.green(successMessage));
1824
1832
  }
1825
1833
  catch (err) {
1826
1834
  fail(err);
@@ -3626,6 +3626,10 @@ function outpostRejectedGrantContextOption(result) {
3626
3626
  return result.exitCode !== 0 &&
3627
3627
  /unknown option\s+(?:['"])?--grant-context(?:['"])?(?:\s|$)/i.test(`${result.stderr}\n${result.stdout}`);
3628
3628
  }
3629
+ function outpostRejectedFailedResumeOption(result) {
3630
+ return result.exitCode !== 0 &&
3631
+ /unknown option\s+(?:['"])?--(?:from-failed|failed-resume-context)(?:['"])?(?:\s|$)/i.test(`${result.stderr}\n${result.stdout}`);
3632
+ }
3629
3633
  function verifyOutpostProfileContent(hqRoot, lane) {
3630
3634
  if (lane.host.kind !== "outpost" || lane.worker_profile === undefined)
3631
3635
  return;
@@ -3648,6 +3652,70 @@ function resumeProfileRepinActor(ctx) {
3648
3652
  const sessionId = sessionIdFromEnv(ctx.env);
3649
3653
  return sessionId ? `session:${sessionId}` : "ssm-owner-dispatch";
3650
3654
  }
3655
+ function createOutpostFailedResumeContext(ctx, lane) {
3656
+ return Buffer.from(JSON.stringify({
3657
+ version: 1,
3658
+ lane_id: lane.lane_id,
3659
+ expected_senior: lane.senior,
3660
+ actor: resumeProfileRepinActor(ctx),
3661
+ })).toString("base64url");
3662
+ }
3663
+ function parseOutpostFailedResumeContext(encoded, laneId) {
3664
+ try {
3665
+ if (!/^[A-Za-z0-9_-]{1,4096}$/.test(encoded))
3666
+ throw new Error("invalid encoding");
3667
+ const value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
3668
+ if (value === null || typeof value !== "object" || Array.isArray(value))
3669
+ throw new Error("invalid payload");
3670
+ const payload = value;
3671
+ const seniorValue = payload.expected_senior;
3672
+ if (seniorValue === null || typeof seniorValue !== "object" || Array.isArray(seniorValue))
3673
+ throw new Error("invalid senior");
3674
+ const senior = seniorValue;
3675
+ let expectedSenior;
3676
+ if (senior.kind === "user") {
3677
+ expectedSenior = { kind: "user" };
3678
+ }
3679
+ else if ((senior.kind === "lane" || senior.kind === "session") &&
3680
+ typeof senior.id === "string" && senior.id.trim().length > 0) {
3681
+ expectedSenior = { kind: senior.kind, id: senior.id };
3682
+ }
3683
+ else {
3684
+ throw new Error("invalid senior");
3685
+ }
3686
+ if (payload.version !== 1 ||
3687
+ payload.lane_id !== laneId ||
3688
+ typeof payload.actor !== "string" ||
3689
+ payload.actor.trim().length === 0) {
3690
+ throw new Error("invalid payload");
3691
+ }
3692
+ return { expectedSenior, actor: payload.actor };
3693
+ }
3694
+ catch {
3695
+ throw new LanesError("failed_resume_authorization_invalid", "Failed-resume authorization context is invalid", { lane_id: laneId });
3696
+ }
3697
+ }
3698
+ function authorizeFailedResume(ctx, lane, encodedContext) {
3699
+ if (encodedContext !== undefined) {
3700
+ if (!ctx.isSsmOwnerDispatch()) {
3701
+ throw new LanesError("failed_resume_authorization_invalid", "Failed-resume authorization context is only valid on an Outpost owner dispatch", { lane_id: lane.lane_id });
3702
+ }
3703
+ const context = parseOutpostFailedResumeContext(encodedContext, lane.lane_id);
3704
+ if (lane.host.kind !== "outpost" ||
3705
+ !sameHireGrantSenior(lane.senior, context.expectedSenior)) {
3706
+ throw new LanesError("failed_resume_senior_changed", `Lane ${lane.lane_id}'s senior changed after its failed-resume request was authorized`, {
3707
+ lane_id: lane.lane_id,
3708
+ expected_senior: context.expectedSenior,
3709
+ current_senior: lane.senior,
3710
+ });
3711
+ }
3712
+ return context.actor;
3713
+ }
3714
+ if (!callerMatchesSenior(ctx, lane.senior)) {
3715
+ throw new LanesError("failed_resume_unauthorized", "Only the lane's senior may resume a failed lane with --from-failed", { lane_id: lane.lane_id });
3716
+ }
3717
+ return resumeProfileRepinActor(ctx);
3718
+ }
3651
3719
  function workerProfileHashMismatch(lane, profile) {
3652
3720
  return new LanesError("worker_profile_hash_mismatch", `Worker profile ${JSON.stringify(profile.id)} on Outpost ${lane.host.id} does not match the hash recorded by the lane`, {
3653
3721
  lane_id: lane.lane_id,
@@ -3757,6 +3825,9 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
3757
3825
  actor: resumeProfileRepinActor(ctx),
3758
3826
  })
3759
3827
  : undefined;
3828
+ const failedResumeContext = action === "resume" && options.fromFailed === true
3829
+ ? createOutpostFailedResumeContext(ctx, lane)
3830
+ : undefined;
3760
3831
  let grantContext;
3761
3832
  let grantHierarchy;
3762
3833
  if (action === "grant") {
@@ -3770,12 +3841,13 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
3770
3841
  grantHierarchy = hierarchy;
3771
3842
  grantContext = createOutpostHireGrantContext(lane, hierarchy);
3772
3843
  }
3773
- else if (action === "resume" &&
3844
+ else if ((action === "resume" || action === "message") &&
3774
3845
  lane.senior.kind === "lane") {
3775
3846
  grantContext = createOutpostHireGrantContext(lane, loadAllLanes(ctx.hqRoot()));
3776
3847
  }
3777
3848
  let dispatched;
3778
3849
  let grantContextRejected = false;
3850
+ let failedResumeOptionRejected = false;
3779
3851
  try {
3780
3852
  const dispatchCommand = (contexts) => ctx.dispatchOutpostExec({
3781
3853
  outpostId,
@@ -3797,15 +3869,17 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
3797
3869
  effort: options.effort,
3798
3870
  worker: options.worker,
3799
3871
  keepAlive: options.keepAlive,
3872
+ fromFailed: options.fromFailed,
3800
3873
  hire: options.hire,
3801
3874
  senior: options.senior,
3802
3875
  reason: options.reason,
3803
3876
  grantContext: contexts.grantContext,
3804
3877
  profileRepinContext: contexts.profileRepinContext,
3878
+ failedResumeContext: contexts.failedResumeContext,
3805
3879
  compact: options.compact,
3806
3880
  }),
3807
3881
  });
3808
- let contexts = { grantContext, profileRepinContext };
3882
+ let contexts = { grantContext, profileRepinContext, failedResumeContext };
3809
3883
  dispatched = await dispatchCommand(contexts);
3810
3884
  for (let retry = 0; action === "resume" && retry < 2; retry += 1) {
3811
3885
  if (contexts.grantContext !== undefined && outpostRejectedGrantContextOption(dispatched)) {
@@ -3816,6 +3890,10 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
3816
3890
  outpostRejectedProfileRepinOption(dispatched)) {
3817
3891
  contexts = { ...contexts, profileRepinContext: undefined };
3818
3892
  }
3893
+ else if (options.fromFailed === true && outpostRejectedFailedResumeOption(dispatched)) {
3894
+ failedResumeOptionRejected = true;
3895
+ break;
3896
+ }
3819
3897
  else {
3820
3898
  break;
3821
3899
  }
@@ -3832,6 +3910,13 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
3832
3910
  required_option: "--grant-context",
3833
3911
  });
3834
3912
  }
3913
+ if (failedResumeOptionRejected) {
3914
+ throw new LanesError("outpost_cli_upgrade_required", `Outpost ${outpostId} needs an hq-cli release containing failed-lane resume support before it can resume lane ${lane.lane_id}`, {
3915
+ lane_id: lane.lane_id,
3916
+ outpost_id: outpostId,
3917
+ required_option: "--from-failed",
3918
+ });
3919
+ }
3835
3920
  const ownerError = ownerErrorFromOutput(dispatched.stdout);
3836
3921
  if (options.effort !== undefined && outpostRejectedEffortOption(dispatched)) {
3837
3922
  throw new LanesError("outpost_effort_unsupported", `Outpost ${outpostId} rejected --effort; update hq-cli on that Outpost before changing a lane's effort remotely`, {
@@ -3848,6 +3933,7 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
3848
3933
  if (ownerError) {
3849
3934
  if (action === "resume" &&
3850
3935
  (ownerError.error === "worker_profile_hash_mismatch" ||
3936
+ ownerError.error.startsWith("failed_resume_") ||
3851
3937
  ownerError.error.startsWith("hire_grant_"))) {
3852
3938
  throw new LanesError(ownerError.error, ownerError.message, {
3853
3939
  ...ownerError.details,
@@ -3901,9 +3987,14 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
3901
3987
  : action === "reparent"
3902
3988
  ? remote.lane !== null && typeof remote.lane === "object" &&
3903
3989
  Array.isArray(remote.changed_descendant_ids)
3904
- : (remote.delivery_status === "worker_spawned" ||
3990
+ : (((remote.delivery_status === "worker_spawned" ||
3905
3991
  remote.delivery_status === "queued_on_target_account") &&
3906
- remote.lane !== undefined);
3992
+ remote.lane !== undefined) ||
3993
+ (remote.delivery_status === "queued_on_admission" &&
3994
+ remote.lane !== null &&
3995
+ typeof remote.lane === "object" &&
3996
+ !Array.isArray(remote.lane) &&
3997
+ remote.lane.state === "queued")));
3907
3998
  if (!resultConfirmed) {
3908
3999
  throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} completed exec without a confirmed ${action} result for lane ${lane.lane_id}`, {
3909
4000
  lane_id: lane.lane_id,
@@ -3924,6 +4015,21 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
3924
4015
  });
3925
4016
  return;
3926
4017
  }
4018
+ if (action === "message" &&
4019
+ (remote.delivery_status === "worker_spawned" ||
4020
+ remote.delivery_status === "queued_on_target_account" ||
4021
+ remote.delivery_status === "queued_on_admission")) {
4022
+ const ownerLane = syncLaneRecordFromOwner(ctx.hqRoot(), lane.lane_id, outpostId, remote.lane, undefined, { allowNonTerminal: true });
4023
+ printJson({
4024
+ ...remote,
4025
+ lane_id: ownerLane.lane_id,
4026
+ lane: ownerLane,
4027
+ outpost_id: outpostId,
4028
+ outpost_command_id: dispatched.commandId,
4029
+ outpost_transport: "outposts_exec",
4030
+ });
4031
+ return;
4032
+ }
3927
4033
  if (action === "reparent" || action === "resume") {
3928
4034
  const ownerLane = syncLaneRecordFromOwner(ctx.hqRoot(), lane.lane_id, outpostId, remote.lane, undefined, { allowNonTerminal: true });
3929
4035
  const descendants = [];
@@ -5047,6 +5153,7 @@ async function runShow(ctx, laneId) {
5047
5153
  last_resume: lane.last_resume ?? null,
5048
5154
  account: lane.account ?? null,
5049
5155
  account_history: lane.account_history ?? [],
5156
+ failed_resume_history: lane.failed_resume_history ?? [],
5050
5157
  pending_resume: lane.pending_resume ?? null,
5051
5158
  last_run_usage: lane.last_run_usage ?? null,
5052
5159
  model: lane.model ?? null,
@@ -5785,7 +5892,7 @@ async function prepareAccountMove(ctx, lane, input) {
5785
5892
  next.queued_at = undefined;
5786
5893
  }
5787
5894
  return next;
5788
- }));
5895
+ }), input.grantContext);
5789
5896
  return { lane: moved, from, to: target, copy, queued };
5790
5897
  }, ctx.sleep);
5791
5898
  }
@@ -5903,6 +6010,14 @@ function assertCurrentResumeAccountAvailable(ctx, lane) {
5903
6010
  }
5904
6011
  async function runMessage(ctx, laneId, opts) {
5905
6012
  const hqRoot = ctx.hqRoot();
6013
+ const hireGrantContext = opts.grantContext === undefined
6014
+ ? undefined
6015
+ : (() => {
6016
+ if (!ctx.isSsmOwnerDispatch()) {
6017
+ throw new LanesError("hire_grant_dispatch_unauthorized", "Message grant context is only valid on an Outpost owner dispatch", { lane_id: laneId });
6018
+ }
6019
+ return parseOutpostHireGrantContext(opts.grantContext, laneId);
6020
+ })();
5906
6021
  let accepted = false;
5907
6022
  let claimedMessages = [];
5908
6023
  const markAccepted = () => {
@@ -5979,7 +6094,7 @@ async function runMessage(ctx, laneId, opts) {
5979
6094
  }
5980
6095
  // Check before transcript recovery, account probing, or message writes.
5981
6096
  // The final state transition repeats this check under the senior lock.
5982
- await withResumeHireAdmission(ctx, lane, () => undefined);
6097
+ await withResumeHireAdmission(ctx, lane, () => undefined, hireGrantContext);
5983
6098
  lane = laneWithRecoveredProviderSession(hqRoot, lane);
5984
6099
  const keepCurrentAccount = opts.account !== undefined && accountRequestKeepsCurrentLane(lane, opts.account);
5985
6100
  if (keepCurrentAccount)
@@ -6075,6 +6190,7 @@ async function runMessage(ctx, laneId, opts) {
6075
6190
  cliModel: opts.model?.trim() || undefined,
6076
6191
  ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
6077
6192
  ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
6193
+ ...(hireGrantContext !== undefined ? { grantContext: hireGrantContext } : {}),
6078
6194
  pending: {
6079
6195
  trigger: "message",
6080
6196
  text: deliveryText,
@@ -6099,6 +6215,7 @@ async function runMessage(ctx, laneId, opts) {
6099
6215
  ...(automatic.historyReason !== undefined ? { accountHistoryReason: automatic.historyReason } : {}),
6100
6216
  ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
6101
6217
  ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
6218
+ ...(hireGrantContext !== undefined ? { grantContext: hireGrantContext } : {}),
6102
6219
  pending: {
6103
6220
  trigger: "message",
6104
6221
  text: deliveryText,
@@ -6213,7 +6330,7 @@ async function runMessage(ctx, laneId, opts) {
6213
6330
  });
6214
6331
  archiveResumeMessage(hqRoot, laneId, dropboxMessage);
6215
6332
  return started;
6216
- });
6333
+ }, hireGrantContext);
6217
6334
  }, ctx.sleep);
6218
6335
  },
6219
6336
  }, mechanism);
@@ -6631,6 +6748,30 @@ function laneWithRecoveredProviderSession(hqRoot, lane) {
6631
6748
  async function runResume(ctx, laneId, opts) {
6632
6749
  const hqRoot = ctx.hqRoot();
6633
6750
  let lane = readLane(hqRoot, laneId);
6751
+ const failedResumeReason = opts.reason?.trim();
6752
+ if (opts.reason !== undefined && opts.fromFailed !== true) {
6753
+ throw new LanesError("failed_resume_reason_requires_flag", "--reason on hq lanes resume requires --from-failed", { lane_id: laneId });
6754
+ }
6755
+ if (opts.fromFailed === true && !failedResumeReason) {
6756
+ throw new LanesError("failed_resume_reason_required", "--from-failed requires a non-empty --reason", { lane_id: laneId });
6757
+ }
6758
+ if (opts.reason !== undefined)
6759
+ assertNoSecretText("resume reason", opts.reason);
6760
+ if (opts.failedResumeContext !== undefined && opts.fromFailed !== true) {
6761
+ throw new LanesError("failed_resume_authorization_invalid", "Failed-resume authorization context requires --from-failed", { lane_id: laneId });
6762
+ }
6763
+ const failedResumeActor = opts.fromFailed === true
6764
+ ? authorizeFailedResume(ctx, lane, opts.failedResumeContext)
6765
+ : undefined;
6766
+ if (opts.fromFailed === true && lane.state !== "failed") {
6767
+ throw new LanesError("lane_not_failed", `Lane ${laneId} is ${lane.state}; --from-failed only applies to a failed lane`, { lane_id: laneId, state: lane.state });
6768
+ }
6769
+ const failedResumeExpectedSenior = opts.fromFailed === true
6770
+ ? { ...lane.senior }
6771
+ : undefined;
6772
+ const recoveredFailure = opts.fromFailed === true
6773
+ ? lane.terminal_reason ?? "failure_reason_unavailable"
6774
+ : undefined;
6634
6775
  const hireGrantContext = opts.grantContext === undefined
6635
6776
  ? undefined
6636
6777
  : (() => {
@@ -6671,7 +6812,7 @@ async function runResume(ctx, laneId, opts) {
6671
6812
  if (lane.state === "queued") {
6672
6813
  throw new LanesError("lane_not_resumable", `Lane ${laneId} is queued; use hq lanes admit ${laneId} to request admission`, { lane_id: laneId, state: lane.state, admission_command: `hq lanes admit ${laneId}` });
6673
6814
  }
6674
- const refusal = resumeCommandRefusal(lane);
6815
+ const refusal = resumeCommandRefusal(lane, { allowFailed: opts.fromFailed === true });
6675
6816
  if (refusal !== null) {
6676
6817
  throw new LanesError(refusal.error, refusal.message, {
6677
6818
  lane_id: laneId,
@@ -6741,6 +6882,10 @@ async function runResume(ctx, laneId, opts) {
6741
6882
  pending: {
6742
6883
  trigger: "resume",
6743
6884
  resumed_from: resumedFrom,
6885
+ ...(opts.fromFailed === true ? { from_failed: true } : {}),
6886
+ ...(failedResumeExpectedSenior !== undefined
6887
+ ? { expected_senior: failedResumeExpectedSenior }
6888
+ : {}),
6744
6889
  ...(text !== undefined ? { text } : {}),
6745
6890
  ...(messageId !== undefined ? { message_id: messageId } : {}),
6746
6891
  compact: opts.compact !== false,
@@ -6766,6 +6911,10 @@ async function runResume(ctx, laneId, opts) {
6766
6911
  pending: {
6767
6912
  trigger: "resume",
6768
6913
  resumed_from: resumedFrom,
6914
+ ...(opts.fromFailed === true ? { from_failed: true } : {}),
6915
+ ...(failedResumeExpectedSenior !== undefined
6916
+ ? { expected_senior: failedResumeExpectedSenior }
6917
+ : {}),
6769
6918
  ...(text !== undefined ? { text } : {}),
6770
6919
  ...(messageId !== undefined ? { message_id: messageId } : {}),
6771
6920
  compact: opts.compact !== false,
@@ -6826,6 +6975,10 @@ async function runResume(ctx, laneId, opts) {
6826
6975
  session_id: sessionId,
6827
6976
  trigger: "resume",
6828
6977
  resumed_from: resumedFrom,
6978
+ ...(opts.fromFailed === true ? { from_failed: true } : {}),
6979
+ ...(failedResumeExpectedSenior !== undefined
6980
+ ? { expected_senior: failedResumeExpectedSenior }
6981
+ : {}),
6829
6982
  ...(text !== undefined ? { text } : {}),
6830
6983
  ...(messageId !== undefined ? { message_id: messageId } : {}),
6831
6984
  compact: opts.compact !== false,
@@ -6853,6 +7006,10 @@ async function runResume(ctx, laneId, opts) {
6853
7006
  trigger: {
6854
7007
  kind: "resume",
6855
7008
  from: resumedFrom,
7009
+ ...(opts.fromFailed === true ? { fromFailed: true } : {}),
7010
+ ...(failedResumeExpectedSenior !== undefined
7011
+ ? { expectedSenior: failedResumeExpectedSenior }
7012
+ : {}),
6856
7013
  ...(text !== undefined ? { text } : {}),
6857
7014
  ...(messageId !== undefined ? { message_id: messageId } : {}),
6858
7015
  },
@@ -6875,6 +7032,19 @@ async function runResume(ctx, laneId, opts) {
6875
7032
  });
6876
7033
  }
6877
7034
  let next = resumed;
7035
+ if (failedResumeActor !== undefined &&
7036
+ failedResumeReason !== undefined &&
7037
+ recoveredFailure !== undefined) {
7038
+ next = casUpdateLane(hqRoot, laneId, (current) => ({
7039
+ ...current,
7040
+ failed_resume_history: appendCappedHistory(current.failed_resume_history ?? [], {
7041
+ at,
7042
+ actor: failedResumeActor,
7043
+ reason: failedResumeReason,
7044
+ failure: recoveredFailure,
7045
+ }),
7046
+ }));
7047
+ }
6878
7048
  if (text !== undefined) {
6879
7049
  // An operator text moves the compaction reference forward, as a message does.
6880
7050
  next = casUpdateLane(hqRoot, laneId, (current) => ({
@@ -9612,6 +9782,7 @@ export function registerLanesCommand(parent, deps = {}) {
9612
9782
  .option("--model <id>", "Model for the moved lane (with --account only)")
9613
9783
  .option("--effort <level>", "Reasoning effort for the resumed provider turn")
9614
9784
  .option("--worker <profile>", "Attach a personal worker profile when this message resumes the lane")
9785
+ .addOption(new Option("--grant-context <base64url>").hideHelp())
9615
9786
  .option("--no-compact", "Skip the resume-time compaction check for this message")
9616
9787
  .action(wrap(async (laneId, opts) => {
9617
9788
  await runMessage(ctx, laneId, opts);
@@ -9656,9 +9827,11 @@ export function registerLanesCommand(parent, deps = {}) {
9656
9827
  }));
9657
9828
  lanes
9658
9829
  .command("resume")
9659
- .description("Resume an interrupted, done or blocked lane on its recorded provider session")
9830
+ .description("Resume an interrupted, done or blocked lane on its recorded provider session; recover failed lanes with the senior-only --from-failed option")
9660
9831
  .argument("<lane-id>", "Lane id")
9661
9832
  .option("--text <msg>", "Operator message for the resumed turn")
9833
+ .option("--from-failed", "Explicitly resume a failed lane as its senior")
9834
+ .option("--reason <text>", "Required explanation for resuming from failed")
9662
9835
  .option("--project <slug>", "Re-point project attribution from this resume")
9663
9836
  .option("--story <id>", "Re-point story attribution from this resume")
9664
9837
  .option("--account <id>", "Move the lane's session to this account of its provider before resuming")
@@ -9667,6 +9840,7 @@ export function registerLanesCommand(parent, deps = {}) {
9667
9840
  .option("--worker <profile>", "Attach a personal worker profile for the resumed turn")
9668
9841
  .addOption(new Option("--profile-repin-context <base64url>").hideHelp())
9669
9842
  .addOption(new Option("--grant-context <base64url>").hideHelp())
9843
+ .addOption(new Option("--failed-resume-context <base64url>").hideHelp())
9670
9844
  .option("--keep-alive", "Automatically resume done lead lanes while their story remains open")
9671
9845
  .option("--mesh-company <slug>", "Attach Work Mesh attribution after verifying company membership")
9672
9846
  .option("--no-compact", "Skip the resume-time compaction check for this resume")
@@ -17,7 +17,7 @@
17
17
  * module rather than testing provider or state inline. A guard bound at one
18
18
  * site and not another is the defect class this branch has already shipped.
19
19
  */
20
- import { type LaneProvider, type LaneRecord, type LaneState } from "./types.js";
20
+ import { type LaneProvider, type LaneRecord, type LaneState, type SeniorRef } from "./types.js";
21
21
  /** Which providers can continue an exited worker from a recorded session id. */
22
22
  export declare const PROVIDER_SESSION_RESUME: Record<LaneProvider, boolean>;
23
23
  export declare function providerSupportsSessionResume(provider: LaneProvider): boolean;
@@ -45,7 +45,8 @@ export declare function sessionNotPortableMessage(provider: LaneProvider): strin
45
45
  * reconcile pass resumes it once every question is answered. `killed` and
46
46
  * `cancelled` were stopped on purpose by the operator; a message must not
47
47
  * silently restart them. `failed` means the run ended without an envelope or
48
- * never started; the operator has not seen why. `interrupted` exited with
48
+ * never started; only the lane's senior can explicitly recover it with
49
+ * `hq lanes resume <lane-id> --from-failed --reason <text>`. `interrupted` exited with
49
50
  * partial output and no envelope, so the lane's state was never closed
50
51
  * cleanly; only the explicit `hq lanes resume` opt-in continues it
51
52
  * (RESUME_COMMAND_STATES). `queued` and `running` take the drop box, not a
@@ -139,6 +140,9 @@ export type LaunchTrigger = {
139
140
  kind: "resume";
140
141
  /** Lane state at the moment of the command; recorded as `last_resume.resumed_from`. */
141
142
  from: ResumeCommandState | "failed";
143
+ fromFailed?: true;
144
+ /** Senior that authorized a failed-lane resume, rechecked at the claim CAS. */
145
+ expectedSenior?: SeniorRef;
142
146
  text?: string;
143
147
  message_id?: string;
144
148
  };
@@ -152,7 +156,9 @@ export type LaunchPlan = {
152
156
  * Refusal for `hq lanes resume`, in precedence order: wrong state, provider
153
157
  * without a resume path, no recorded session. Null when the resume can go.
154
158
  */
155
- export declare function resumeCommandRefusal(lane: DeliveryLane): {
159
+ export declare function resumeCommandRefusal(lane: DeliveryLane, options?: {
160
+ allowFailed?: boolean;
161
+ }): {
156
162
  error: "lane_not_resumable" | "resume_unsupported" | "no_provider_session";
157
163
  message: string;
158
164
  } | null;
@@ -65,7 +65,8 @@ export function sessionNotPortableMessage(provider) {
65
65
  * reconcile pass resumes it once every question is answered. `killed` and
66
66
  * `cancelled` were stopped on purpose by the operator; a message must not
67
67
  * silently restart them. `failed` means the run ended without an envelope or
68
- * never started; the operator has not seen why. `interrupted` exited with
68
+ * never started; only the lane's senior can explicitly recover it with
69
+ * `hq lanes resume <lane-id> --from-failed --reason <text>`. `interrupted` exited with
69
70
  * partial output and no envelope, so the lane's state was never closed
70
71
  * cleanly; only the explicit `hq lanes resume` opt-in continues it
71
72
  * (RESUME_COMMAND_STATES). `queued` and `running` take the drop box, not a
@@ -137,7 +138,7 @@ export function deliveryRefusalReason(lane) {
137
138
  case "interrupted":
138
139
  return `Lane is ${state}. The run did not close cleanly and the operator has not decided what to do with it. Resuming from ${state} needs an explicit opt-in: hq lanes resume <lane-id> [--text <message>].`;
139
140
  case "failed":
140
- return `Lane is ${state}. The run did not close cleanly and the operator has not decided what to do with it. Resuming from ${state} needs an explicit opt-in, which does not exist yet.`;
141
+ return `Lane is ${state}. A plain message does not restart it. Its senior can resume the recorded session with: hq lanes resume <lane-id> --from-failed --reason <text>.`;
141
142
  default: {
142
143
  const _exhaustive = state;
143
144
  return _exhaustive;
@@ -345,8 +346,10 @@ export const LAUNCH_TRIGGERS = ["admit", "answered_questions", "message", "resum
345
346
  * Refusal for `hq lanes resume`, in precedence order: wrong state, provider
346
347
  * without a resume path, no recorded session. Null when the resume can go.
347
348
  */
348
- export function resumeCommandRefusal(lane) {
349
- if (!isResumeCommandState(lane.state) && !isFailedEnvelopeRepairLane(lane)) {
349
+ export function resumeCommandRefusal(lane, options = {}) {
350
+ if (!isResumeCommandState(lane.state) &&
351
+ !isFailedEnvelopeRepairLane(lane) &&
352
+ !(options.allowFailed === true && lane.state === "failed")) {
350
353
  return {
351
354
  error: "lane_not_resumable",
352
355
  message: `Lane is ${lane.state}. hq lanes resume continues a lane in ${RESUME_COMMAND_STATES.join(", ")}; ${lane.state === "awaiting_input"
@@ -395,7 +398,7 @@ export function planLaneLaunch(lane, trigger) {
395
398
  return { mode: "resume", session_id: lane.provider_session_id };
396
399
  }
397
400
  case "resume": {
398
- const refusal = resumeCommandRefusal(lane);
401
+ const refusal = resumeCommandRefusal(lane, { allowFailed: trigger.fromFailed === true });
399
402
  if (refusal !== null || !hasRecordedSessionId(lane)) {
400
403
  throw new LanesError(refusal?.error ?? "no_provider_session", refusal?.message ?? `Lane is ${lane.state} and has no recorded provider session`, { state: lane.state, provider: lane.provider });
401
404
  }
@@ -418,7 +421,9 @@ export function claimableStatesFor(trigger, lane) {
418
421
  case "message":
419
422
  return envelopeRepairFailed ? [...RESUMABLE_LANE_STATES, "failed"] : RESUMABLE_LANE_STATES;
420
423
  case "resume":
421
- return envelopeRepairFailed ? [...RESUME_COMMAND_STATES, "failed"] : RESUME_COMMAND_STATES;
424
+ return trigger.fromFailed === true || envelopeRepairFailed
425
+ ? [...RESUME_COMMAND_STATES, "failed"]
426
+ : RESUME_COMMAND_STATES;
422
427
  default: {
423
428
  const _exhaustive = trigger;
424
429
  return _exhaustive;
@@ -37,6 +37,8 @@ export interface OutpostLaneCommand {
37
37
  model?: string;
38
38
  effort?: string;
39
39
  worker?: string;
40
+ fromFailed?: boolean;
41
+ failedResumeContext?: string;
40
42
  keepAlive?: boolean;
41
43
  hire?: string;
42
44
  grantContext?: string;
@@ -151,12 +151,15 @@ export function buildOutpostLaneCommand(input) {
151
151
  ["--hire", input.hire],
152
152
  ["--grant-context", input.grantContext],
153
153
  ["--profile-repin-context", input.profileRepinContext],
154
+ ["--failed-resume-context", input.failedResumeContext],
154
155
  ["--senior", input.senior],
155
156
  ["--reason", input.reason],
156
157
  ]) {
157
158
  if (value !== undefined)
158
159
  args.push(flag, value);
159
160
  }
161
+ if (input.action === "resume" && input.fromFailed === true)
162
+ args.push("--from-failed");
160
163
  if (input.action === "resume" && input.keepAlive === true)
161
164
  args.push("--keep-alive");
162
165
  if (input.compact === false)
@@ -1576,6 +1576,9 @@ function laneLaunchResult(lane) {
1576
1576
  const { provider_home_at_spawn: _providerHomeAtSpawn, ...safe } = lane;
1577
1577
  return safe;
1578
1578
  }
1579
+ function sameSeniorRef(a, b) {
1580
+ return a.kind === b.kind && a.id === b.id;
1581
+ }
1579
1582
  function inferTrigger(lane) {
1580
1583
  if (lane.state === "awaiting_input")
1581
1584
  return { kind: "answered_questions" };
@@ -1631,6 +1634,12 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1631
1634
  // starting fresh from the brief.
1632
1635
  const pendingResume = launch.trigger === undefined && existing.state === "queued" ? existing.pending_resume : undefined;
1633
1636
  const trigger = launch.trigger ?? inferTrigger(existing);
1637
+ const failedResumeClaim = (trigger.kind === "resume" && trigger.fromFailed === true) || pendingResume?.from_failed === true;
1638
+ const failedResumeExpectedSenior = trigger.kind === "resume" && trigger.fromFailed === true
1639
+ ? trigger.expectedSenior
1640
+ : pendingResume?.from_failed === true
1641
+ ? pendingResume.expected_senior
1642
+ : undefined;
1634
1643
  // A message into a finished lane must not turn that lane `failed` when the
1635
1644
  // account cannot be bound: nothing has started yet, so the record keeps its
1636
1645
  // state and the caller sees the error. Admission and question resume keep
@@ -1971,6 +1980,15 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1971
1980
  if (!claimableStatesFor(trigger, current).includes(current.state)) {
1972
1981
  throw new LanesError("spawn_state_mismatch", `Cannot mark ${current.lane_id} running from ${current.state}`, { lane_id: current.lane_id, state: current.state, trigger: trigger.kind });
1973
1982
  }
1983
+ if (failedResumeClaim &&
1984
+ (failedResumeExpectedSenior === undefined ||
1985
+ !sameSeniorRef(current.senior, failedResumeExpectedSenior))) {
1986
+ throw new LanesError("failed_resume_senior_changed", `Lane ${current.lane_id}'s senior changed after its failed-resume request was authorized`, {
1987
+ lane_id: current.lane_id,
1988
+ expected_senior: failedResumeExpectedSenior ?? null,
1989
+ current_senior: current.senior,
1990
+ });
1991
+ }
1974
1992
  claimed = true;
1975
1993
  const next = {
1976
1994
  ...current,
@@ -2044,6 +2062,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2044
2062
  ...(pendingResume.message_id !== undefined ? { message_id: pendingResume.message_id } : {}),
2045
2063
  ...(pendingResume.monitor_event === true ? { monitor_event: true } : {}),
2046
2064
  ...(pendingResume.resumed_from !== undefined ? { resumed_from: pendingResume.resumed_from } : {}),
2065
+ ...(pendingResume.from_failed === true ? { from_failed: true } : {}),
2047
2066
  ...(pendingResume.text !== undefined ? { text: pendingResume.text } : {}),
2048
2067
  ...(pendingResume.compact !== undefined ? { compact: pendingResume.compact } : {}),
2049
2068
  };
@@ -2065,6 +2084,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2065
2084
  ...(trigger.kind === "resume"
2066
2085
  ? {
2067
2086
  resumed_from: trigger.from,
2087
+ ...(trigger.fromFailed === true ? { from_failed: true } : {}),
2068
2088
  ...(trigger.text !== undefined ? { text: trigger.text } : {}),
2069
2089
  ...(trigger.message_id !== undefined ? { message_id: trigger.message_id } : {}),
2070
2090
  }
@@ -888,6 +888,11 @@ export function mergeLaneRecords(live, sidecars) {
888
888
  worker_profile_repin_history: unionHistory(all.map((record) => record.worker_profile_repin_history ?? []), entryIdentity, base.worker_profile_repin_history ?? []),
889
889
  }
890
890
  : {}),
891
+ ...(all.some((record) => record.failed_resume_history !== undefined)
892
+ ? {
893
+ failed_resume_history: unionHistory(all.map((record) => record.failed_resume_history ?? []), entryIdentity, base.failed_resume_history ?? []),
894
+ }
895
+ : {}),
891
896
  stories: mergeLaneStories(all, base),
892
897
  };
893
898
  }
@@ -400,6 +400,8 @@ export interface LaneRecord {
400
400
  * like the other histories.
401
401
  */
402
402
  account_history?: AccountHistoryEntry[];
403
+ /** Bounded audit history for explicitly authorized resumes from failed. */
404
+ failed_resume_history?: FailedResumeHistoryEntry[];
403
405
  /**
404
406
  * Set by an account move (`--account`) until the spawn claims the lane:
405
407
  * the turn the move was asked to run. While set it reserves a slot on the
@@ -537,11 +539,20 @@ export interface AccountHistoryEntry {
537
539
  at: string;
538
540
  reason: "first_assignment" | "model_change" | "usage_limit" | "account_unavailable" | "recorded_account_logged_out" | "load_balance" | "operator_move";
539
541
  }
542
+ export interface FailedResumeHistoryEntry {
543
+ at: string;
544
+ actor: string;
545
+ reason: string;
546
+ failure: string;
547
+ }
540
548
  export interface PendingResume {
541
549
  /** Fresh answered-question launches need no previous session id. */
542
550
  session_id?: string;
543
551
  trigger: "answered_questions" | "message" | "resume";
544
552
  resumed_from?: "interrupted" | "done" | "blocked" | "failed";
553
+ from_failed?: true;
554
+ /** Senior that authorized a failed-lane resume, rechecked at the claim CAS. */
555
+ expected_senior?: SeniorRef;
545
556
  text?: string;
546
557
  message_id?: string;
547
558
  /** The text is already framed as untrusted monitor data, not an operator instruction. */
@@ -572,6 +583,7 @@ export interface LaneResumeRecord {
572
583
  * resumed it (`interrupted`, `done` or `blocked`) and the text they sent.
573
584
  */
574
585
  resumed_from?: "interrupted" | "done" | "blocked" | "failed";
586
+ from_failed?: true;
575
587
  text?: string;
576
588
  /** False when the continued turn explicitly skipped compaction. */
577
589
  compact?: boolean;
@@ -225,7 +225,7 @@ export const LANE_FIELD_WRITERS = {
225
225
  pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
226
226
  priority: ["command"], worker: ["creator", "command"], worker_path: ["creator", "command"], worker_pack: ["creator", "command"], worker_profile: ["creator", "command"], worker_profile_hash: ["creator", "command", "spawn"], worker_profile_repin_history: ["command"], worker_profile_ignored: ["creator", "command", "spawn"], worker_policy_delivery: ["command", "spawn"],
227
227
  keep_alive: ["creator", "command"], keep_alive_standing: ["command"], keep_alive_opted_in_at: ["creator", "command"], keep_alive_blocked_reason: ["command", "reconcile"], keep_alive_resume_count: ["command", "reconcile"], keep_alive_last_resume_at: ["command", "reconcile"], keep_alive_next_eligible_at: ["command", "reconcile"], keep_alive_exhausted_at: ["command", "reconcile"],
228
- provider: ["creator"], account: ["creator", "spawn", "command", "watcher", "reconcile"], account_selection: ["creator", "command"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], pending_resume: ["command", "spawn", "watcher", "reconcile"], placement_default: ["creator"], host: ["creator", "command"], origin_host: ["creator"], origin_host_source: ["creator"],
228
+ provider: ["creator"], account: ["creator", "spawn", "command", "watcher", "reconcile"], account_selection: ["creator", "command"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], failed_resume_history: ["command"], pending_resume: ["command", "spawn", "watcher", "reconcile"], placement_default: ["creator"], host: ["creator", "command"], origin_host: ["creator"], origin_host_source: ["creator"],
229
229
  version: ["creator"], created_at: ["creator"], updated_at: ["creator"], attribution_history: ["command"],
230
230
  last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn", "command"], model_source: ["creator", "spawn", "command"], effort: ["creator", "spawn", "command"], effort_source: ["creator", "spawn", "command"], fast_mode: ["spawn"],
231
231
  last_message_at: ["command"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.233.0",
3
+ "version": "5.234.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {