@indigoai-us/hq-cli 5.225.0 → 5.226.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 (33) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/dist/command-catalog.generated.d.ts +94 -0
  3. package/dist/command-catalog.generated.js +120 -0
  4. package/dist/command-registration-plan.d.ts +18 -0
  5. package/dist/command-registration-plan.js +6 -0
  6. package/dist/commands/agent-enroll.d.ts +2 -0
  7. package/dist/commands/agent-enroll.js +17 -2
  8. package/dist/commands/cloud.d.ts +10 -0
  9. package/dist/commands/cloud.js +52 -47
  10. package/dist/commands/lanes.d.ts +5 -0
  11. package/dist/commands/lanes.js +498 -312
  12. package/dist/commands/staff/reset-test-account.d.ts +110 -0
  13. package/dist/commands/staff/reset-test-account.js +268 -0
  14. package/dist/commands/staff/test-account.d.ts +51 -0
  15. package/dist/commands/staff/test-account.js +196 -0
  16. package/dist/lib/doctor/client-health-flag-token.d.ts +15 -0
  17. package/dist/lib/doctor/client-health-flag-token.js +66 -0
  18. package/dist/lib/doctor/client-health-reason-codes-flag.d.ts +1 -0
  19. package/dist/lib/doctor/client-health-reason-codes-flag.js +16 -5
  20. package/dist/lib/doctor/client-health-sentry-flag.d.ts +1 -0
  21. package/dist/lib/doctor/client-health-sentry-flag.js +17 -6
  22. package/dist/lib/lanes/dropbox.d.ts +9 -0
  23. package/dist/lib/lanes/dropbox.js +70 -1
  24. package/dist/lib/lanes/reconcile.d.ts +2 -0
  25. package/dist/lib/lanes/reconcile.js +12 -0
  26. package/dist/lib/lanes/watcher-main.js +16 -0
  27. package/dist/lib/lanes/watcher.d.ts +4 -1
  28. package/dist/lib/lanes/watcher.js +77 -3
  29. package/dist/utils/cognito-session.d.ts +8 -0
  30. package/dist/utils/cognito-session.js +198 -3
  31. package/dist/utils/upload-author.d.ts +15 -0
  32. package/dist/utils/upload-author.js +39 -0
  33. package/package.json +1 -1
@@ -51,12 +51,13 @@ import { assertUniqueAccountHomes, providerCompactAfterMs } from "../lib/lanes/c
51
51
  import { parseCliEffort, resolveLaneEffort, } from "../lib/lanes/effort.js";
52
52
  import { LanesError } from "../lib/lanes/errors.js";
53
53
  import { dispatchLaneDelivery } from "../lib/lanes/delivery-core.js";
54
- import { formatMonitorEvent } from "../lib/lanes/dropbox.js";
54
+ import { dropboxInboxHasMessages, drainDropbox, formatDropboxMessages, formatMonitorEvent, requeueDropboxMessages, } from "../lib/lanes/dropbox.js";
55
+ import { isPidAlive, runWatcherLoop, WATCHER_OUT_MAX_BYTES, watcherOutLogPath, writeWatcherDecision, } from "../lib/lanes/watcher.js";
55
56
  import { accountQuotaView, loadQuotaState, quotaBlockFor } from "../lib/lanes/quota.js";
56
57
  import { SESSION_MOVE_REASON } from "../lib/lanes/move-account.js";
57
58
  import { ensureSessionHome } from "../lib/lanes/session-relink.js";
58
59
  import { defaultProbe, validateProfileLaneModel, validateLaneModel, } from "../lib/lanes/models.js";
59
- import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ONLY_RULE, STANDING_AUTHORIZATION, spawnLaneWorker, laneRunDir, readSpawnManifest, stopLaneProcesses, } from "../lib/lanes/spawn.js";
60
+ import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ONLY_RULE, STANDING_AUTHORIZATION, spawnLaneWorker, laneRunDir, readSpawnManifest, readPidRecord, probePidRecord, readProcStarttime, stopLaneProcesses, } from "../lib/lanes/spawn.js";
60
61
  import { handOffInstallHere } from "../lib/daemon/handoff.js";
61
62
  export { spawnLaneWorker };
62
63
  export { questionBodyAbsPath };
@@ -283,6 +284,8 @@ function resolveDeps(deps = {}) {
283
284
  proveDetached: deps.proveDetached,
284
285
  killProcess: deps.killProcess,
285
286
  isAlive: deps.isAlive,
287
+ readStarttime: deps.readStarttime,
288
+ watcherLoopDeps: deps.watcherLoopDeps,
286
289
  stopSleepMs: deps.stopSleepMs,
287
290
  livenessDeps: deps.livenessDeps,
288
291
  presenceConnected: deps.presenceConnected,
@@ -5552,7 +5555,13 @@ function assertCurrentResumeAccountAvailable(ctx, lane) {
5552
5555
  }
5553
5556
  async function runMessage(ctx, laneId, opts) {
5554
5557
  const hqRoot = ctx.hqRoot();
5555
- const text = opts.text;
5558
+ let accepted = false;
5559
+ let claimedMessages = [];
5560
+ const markAccepted = () => {
5561
+ accepted = true;
5562
+ opts.onAccepted?.();
5563
+ };
5564
+ let text = opts.text;
5556
5565
  if (!text) {
5557
5566
  throw new LanesError("missing_text", "--text is required", {});
5558
5567
  }
@@ -5562,330 +5571,498 @@ async function runMessage(ctx, laneId, opts) {
5562
5571
  return;
5563
5572
  }
5564
5573
  assertLaneRunsHereForOperatorMutation(ctx, lane, "message");
5565
- const cliEffort = parseCliEffort(lane.provider, opts.effort);
5566
- if (cliEffort !== undefined && !providerSupportsSessionResume(lane.provider)) {
5567
- throw new LanesError("effort_resume_unsupported", `Provider ${lane.provider} cannot change effort on a resumed session`, { lane_id: laneId, provider: lane.provider });
5568
- }
5569
- // Check before transcript recovery, account probing, or message writes.
5570
- // The final state transition repeats this check under the senior lock.
5571
- await withResumeHireAdmission(ctx, lane, () => undefined);
5572
- lane = laneWithRecoveredProviderSession(hqRoot, lane);
5573
- const keepCurrentAccount = opts.account !== undefined && accountRequestKeepsCurrentLane(lane, opts.account);
5574
- if (keepCurrentAccount)
5575
- assertCurrentResumeAccountAvailable(ctx, lane);
5576
- // A real account move is refused on its own terms first: a running lane and
5577
- // a provider without session resume get the move's named errors, not the
5578
- // generic delivery refusal below. Naming the current account is a keep.
5579
- if (opts.account !== undefined && !keepCurrentAccount && lane.state === "running") {
5580
- throw new LanesError("lane_running", `Lane ${laneId} is running; stop it or wait for it to exit before moving its session`, {
5581
- lane_id: laneId,
5582
- state: lane.state,
5583
- });
5584
- }
5585
- if (opts.account !== undefined && !keepCurrentAccount && !providerSessionPortable(lane.provider)) {
5586
- throw new LanesError("provider_session_not_portable", sessionNotPortableMessage(lane.provider), { lane_id: laneId, provider: lane.provider });
5587
- }
5588
- // One decision point for how a follow-up reaches this lane (delivery.ts).
5589
- // `none` refuses before anything is written: no inbox append, no spawn.
5590
- const mechanism = laneDeliveryMechanism(lane);
5591
- if (mechanism === "none") {
5592
- const message = deliveryRefusalReason(lane);
5593
- throw new LanesError("lane_not_accepting_messages", message, {
5594
- lane_id: laneId,
5595
- state: lane.state,
5596
- provider: lane.provider,
5597
- message,
5598
- });
5599
- }
5600
- if (keepCurrentAccount && opts.model !== undefined && mechanism !== "resume") {
5601
- throw new LanesError("model_requires_resume", "--model with --account requires a message that resumes the lane", { lane_id: laneId, state: lane.state });
5602
- }
5603
- if (opts.worker !== undefined && mechanism === "dropbox") {
5604
- throw new LanesError("worker_profile_requires_resume", `Lane ${laneId} is still running; --worker can only be applied when message starts a resumed provider turn`, { lane_id: laneId, state: lane.state });
5605
- }
5606
- if (cliEffort !== undefined && mechanism === "dropbox") {
5607
- throw new LanesError("lane_effort_requires_resume", `Lane ${laneId} is still running; --effort can only be applied when message starts a resumed provider turn`, { lane_id: laneId, state: lane.state });
5608
- }
5609
- const resumeProfile = opts.worker !== undefined
5610
- ? resolveResumeProfile(ctx, lane, opts.worker)
5611
- : undefined;
5612
- if (opts.model !== undefined && opts.account === undefined) {
5613
- throw new LanesError("model_requires_account", "--model on hq lanes message is only accepted together with --account", {});
5614
- }
5615
- const automaticAccountSelection = opts.account === undefined && mechanism === "resume"
5616
- ? await selectAutomaticClaudeResumeAccount(ctx, lane, resumeProfile)
5617
- : undefined;
5618
- const resumeEffort = resolveResumeEffort(ctx, lane, {
5619
- cliEffort,
5620
- profile: resumeProfile,
5621
- account: keepCurrentAccount ? lane.account : opts.account ?? automaticAccountSelection?.selection.account,
5622
- model: opts.model,
5623
- });
5624
- const messageId = mintMessageId();
5625
- const at = ctx.now().toISOString();
5626
- const projectId = opts.project?.trim() || lane.project_id;
5627
- const storyId = opts.story?.trim() || lane.story_id;
5628
- if (opts.story !== undefined && !opts.story.trim()) {
5629
- throw new LanesError("missing_story", "--story must be non-empty", {});
5630
- }
5631
- if ((opts.project !== undefined || opts.story !== undefined) && ctx.storyLedgerEnabled()) {
5632
- validateProjectStories(hqRoot, projectId, [storyId], lane.company?.slug);
5574
+ if (lane.state === "running") {
5575
+ const workerPidPath = path.join(laneRunDir(hqRoot, laneId), "worker.pid");
5576
+ const workerRecordExists = fs.existsSync(workerPidPath);
5577
+ const worker = readPidRecord(workerPidPath);
5578
+ if (workerRecordExists && worker === null) {
5579
+ throw new LanesError("worker_pid_invalid", `Lane ${laneId} has a malformed worker PID record; message delivery cannot be classified safely`, { lane_id: laneId, worker_pid_path: workerPidPath });
5580
+ }
5581
+ // A fresh spawn may have committed running before publishing worker.pid;
5582
+ // absence is not proof of exit. Reclassify only a recorded process that
5583
+ // fails the PID plus start-time identity probe.
5584
+ const processAlive = worker === null
5585
+ ? true
5586
+ : probePidRecord(worker, ctx.isAlive ?? isPidAlive, ctx.readStarttime ?? readProcStarttime).alive;
5587
+ if (worker !== null && !processAlive) {
5588
+ // The watcher may not have reached its next poll yet. Run the same
5589
+ // classifier synchronously, then make the queue/resume choice again.
5590
+ await runWatcherLoop(hqRoot, laneId, {
5591
+ ...ctx.watcherLoopDeps,
5592
+ now: () => ctx.now().getTime(),
5593
+ isAlive: ctx.isAlive ?? isPidAlive,
5594
+ readStarttime: ctx.readStarttime ?? readProcStarttime,
5595
+ mesh: null,
5596
+ resolveQuestionActiveCompany: () => resolveActiveQuestionCompany(ctx),
5597
+ });
5598
+ lane = readLane(hqRoot, laneId);
5599
+ // This invocation won the race to classify the dead worker, so the
5600
+ // watcher callback did not own recovery. Fold any earlier queued
5601
+ // messages into this resume to keep one provider turn and one delivery.
5602
+ if (laneDeliveryMechanism(lane) === "resume" && dropboxInboxHasMessages(hqRoot, laneId)) {
5603
+ const queuedMessages = drainDropbox(hqRoot, laneId);
5604
+ if (queuedMessages.length > 0) {
5605
+ claimedMessages = queuedMessages;
5606
+ text = `${formatDropboxMessages(queuedMessages)}\n\n${text}`;
5607
+ }
5608
+ }
5609
+ }
5633
5610
  }
5634
- const framedMonitorEvent = opts.monitorEvent === undefined
5635
- ? undefined
5636
- : formatMonitorEvent({
5637
- monitorId: opts.monitorEvent.monitor_id,
5638
- description: opts.monitorEvent.description,
5639
- sequence: opts.monitorEvent.sequence,
5640
- text,
5641
- });
5642
- const deliveryText = framedMonitorEvent ?? text;
5643
- const dropboxMessage = {
5644
- at,
5645
- message_id: messageId,
5646
- text,
5647
- project_id: projectId,
5648
- story_id: storyId,
5649
- ...(opts.monitorEvent === undefined ? {} : {
5650
- kind: "monitor_event",
5651
- monitor_id: opts.monitorEvent.monitor_id,
5652
- description: opts.monitorEvent.description,
5653
- sequence: opts.monitorEvent.sequence,
5654
- }),
5655
- };
5656
- let resumed = null;
5657
- let move = null;
5658
- let queuedOnAdmission = false;
5659
- if (opts.account !== undefined && !keepCurrentAccount) {
5660
- // A moved message is always a resume on the target account: a running
5661
- // lane (the dropbox mechanism) is refused inside prepareAccountMove.
5662
- move = await prepareAccountMove(ctx, lane, {
5663
- account: opts.account,
5664
- cliModel: opts.model?.trim() || undefined,
5665
- ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
5666
- ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
5667
- pending: {
5668
- trigger: "message",
5669
- text: deliveryText,
5670
- ...(opts.monitorEvent === undefined ? {} : { monitor_event: true }),
5671
- message_id: messageId,
5672
- compact: opts.compact !== false,
5673
- },
5674
- });
5675
- lane = move.lane;
5676
- if (move.queued) {
5677
- archiveResumeMessage(hqRoot, laneId, dropboxMessage);
5678
- }
5679
- }
5680
- else if (mechanism === "resume") {
5681
- const automatic = automaticAccountSelection;
5682
- if (automatic !== undefined) {
5683
- const selection = automatic.selection;
5684
- if (selection.account !== lane.account) {
5685
- move = await prepareAccountMove(ctx, lane, {
5686
- account: selection.account,
5687
- cliModel: undefined,
5688
- ...(automatic.historyReason !== undefined ? { accountHistoryReason: automatic.historyReason } : {}),
5689
- ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
5690
- ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
5691
- pending: {
5692
- trigger: "message",
5693
- text: deliveryText,
5694
- ...(opts.monitorEvent === undefined ? {} : { monitor_event: true }),
5695
- message_id: messageId,
5696
- compact: opts.compact !== false,
5697
- },
5698
- accountSelection: selection,
5699
- });
5700
- lane = move.lane;
5701
- if (move.queued)
5702
- archiveResumeMessage(hqRoot, laneId, dropboxMessage);
5611
+ try {
5612
+ const exclusion = messageLaneExclusion(ctx, lane);
5613
+ if (exclusion !== undefined) {
5614
+ let messageCount;
5615
+ if (dropboxInboxHasMessages(hqRoot, laneId)) {
5616
+ messageCount = drainDropbox(hqRoot, laneId, { archiveDirectory: "undeliverable" }).length;
5703
5617
  }
5704
- else {
5705
- lane = persistClaudeAccountSelection(hqRoot, lane, selection);
5618
+ recordMessageUndeliverable(ctx, lane, exclusion, messageCount);
5619
+ const refusalMessage = deliveryRefusalReason(lane);
5620
+ throw new LanesError("lane_not_accepting_messages", refusalMessage || `Lane ${laneId} cannot receive messages: ${exclusion}`, { lane_id: laneId, state: lane.state, reason: exclusion });
5621
+ }
5622
+ if (lane.state === "done" && dropboxInboxHasMessages(hqRoot, laneId)) {
5623
+ const staleMessages = drainDropbox(hqRoot, laneId, { archiveDirectory: "undeliverable" });
5624
+ if (staleMessages.length > 0) {
5625
+ recordMessageUndeliverable(ctx, lane, "done_before_queued_message", staleMessages.length);
5706
5626
  }
5707
5627
  }
5708
- }
5709
- if (resumeProfile !== undefined && (opts.account === undefined || keepCurrentAccount)) {
5710
- validateResumeProfileModel(ctx, lane, resumeProfile.profile);
5711
- }
5712
- if (move === null && (resumeProfile !== undefined || resumeEffort !== undefined || (keepCurrentAccount && opts.model !== undefined))) {
5713
- lane = recordResumeSetup(ctx, lane, {
5714
- ...(resumeProfile !== undefined ? { profile: resumeProfile } : {}),
5715
- ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
5716
- ...(keepCurrentAccount && opts.model !== undefined ? { cliModel: opts.model } : {}),
5628
+ const cliEffort = parseCliEffort(lane.provider, opts.effort);
5629
+ if (cliEffort !== undefined && !providerSupportsSessionResume(lane.provider)) {
5630
+ throw new LanesError("effort_resume_unsupported", `Provider ${lane.provider} cannot change effort on a resumed session`, { lane_id: laneId, provider: lane.provider });
5631
+ }
5632
+ // Check before transcript recovery, account probing, or message writes.
5633
+ // The final state transition repeats this check under the senior lock.
5634
+ await withResumeHireAdmission(ctx, lane, () => undefined);
5635
+ lane = laneWithRecoveredProviderSession(hqRoot, lane);
5636
+ const keepCurrentAccount = opts.account !== undefined && accountRequestKeepsCurrentLane(lane, opts.account);
5637
+ if (keepCurrentAccount)
5638
+ assertCurrentResumeAccountAvailable(ctx, lane);
5639
+ // A real account move is refused on its own terms first: a running lane and
5640
+ // a provider without session resume get the move's named errors, not the
5641
+ // generic delivery refusal below. Naming the current account is a keep.
5642
+ if (opts.account !== undefined && !keepCurrentAccount && lane.state === "running") {
5643
+ throw new LanesError("lane_running", `Lane ${laneId} is running; stop it or wait for it to exit before moving its session`, {
5644
+ lane_id: laneId,
5645
+ state: lane.state,
5646
+ });
5647
+ }
5648
+ if (opts.account !== undefined && !keepCurrentAccount && !providerSessionPortable(lane.provider)) {
5649
+ throw new LanesError("provider_session_not_portable", sessionNotPortableMessage(lane.provider), { lane_id: laneId, provider: lane.provider });
5650
+ }
5651
+ // One decision point for how a follow-up reaches this lane (delivery.ts).
5652
+ // `none` refuses before anything is written: no inbox append, no spawn.
5653
+ const mechanism = laneDeliveryMechanism(lane);
5654
+ if (mechanism === "none") {
5655
+ const message = deliveryRefusalReason(lane);
5656
+ throw new LanesError("lane_not_accepting_messages", message, {
5657
+ lane_id: laneId,
5658
+ state: lane.state,
5659
+ provider: lane.provider,
5660
+ message,
5661
+ });
5662
+ }
5663
+ if (keepCurrentAccount && opts.model !== undefined && mechanism !== "resume") {
5664
+ throw new LanesError("model_requires_resume", "--model with --account requires a message that resumes the lane", { lane_id: laneId, state: lane.state });
5665
+ }
5666
+ if (opts.worker !== undefined && mechanism === "dropbox") {
5667
+ throw new LanesError("worker_profile_requires_resume", `Lane ${laneId} is still running; --worker can only be applied when message starts a resumed provider turn`, { lane_id: laneId, state: lane.state });
5668
+ }
5669
+ if (cliEffort !== undefined && mechanism === "dropbox") {
5670
+ throw new LanesError("lane_effort_requires_resume", `Lane ${laneId} is still running; --effort can only be applied when message starts a resumed provider turn`, { lane_id: laneId, state: lane.state });
5671
+ }
5672
+ const resumeProfile = opts.worker !== undefined
5673
+ ? resolveResumeProfile(ctx, lane, opts.worker)
5674
+ : undefined;
5675
+ if (opts.model !== undefined && opts.account === undefined) {
5676
+ throw new LanesError("model_requires_account", "--model on hq lanes message is only accepted together with --account", {});
5677
+ }
5678
+ const automaticAccountSelection = opts.account === undefined && mechanism === "resume"
5679
+ ? await selectAutomaticClaudeResumeAccount(ctx, lane, resumeProfile)
5680
+ : undefined;
5681
+ const resumeEffort = resolveResumeEffort(ctx, lane, {
5682
+ cliEffort,
5683
+ profile: resumeProfile,
5684
+ account: keepCurrentAccount ? lane.account : opts.account ?? automaticAccountSelection?.selection.account,
5685
+ model: opts.model,
5717
5686
  });
5718
- }
5719
- if (move?.queued) {
5720
- // Parked at the target's cap: reconcile resumes the session there.
5721
- }
5722
- else {
5723
- const dispatched = await dispatchLaneDelivery(lane, {
5724
- dropbox: () => {
5725
- // Live (or about-to-be-live) process: the PreToolUse drain delivers it.
5726
- if (!laneAcceptsOperatorMessage(lane)) {
5727
- throw new LanesError("lane_not_accepting_messages", operatorMessageRefusalReason(lane.state), { lane_id: laneId, state: lane.state });
5687
+ const messageId = mintMessageId();
5688
+ const at = ctx.now().toISOString();
5689
+ const projectId = opts.project?.trim() || lane.project_id;
5690
+ const storyId = opts.story?.trim() || lane.story_id;
5691
+ if (opts.story !== undefined && !opts.story.trim()) {
5692
+ throw new LanesError("missing_story", "--story must be non-empty", {});
5693
+ }
5694
+ if ((opts.project !== undefined || opts.story !== undefined) && ctx.storyLedgerEnabled()) {
5695
+ validateProjectStories(hqRoot, projectId, [storyId], lane.company?.slug);
5696
+ }
5697
+ const framedMonitorEvent = opts.monitorEvent === undefined
5698
+ ? undefined
5699
+ : formatMonitorEvent({
5700
+ monitorId: opts.monitorEvent.monitor_id,
5701
+ description: opts.monitorEvent.description,
5702
+ sequence: opts.monitorEvent.sequence,
5703
+ text,
5704
+ });
5705
+ const deliveryText = framedMonitorEvent ?? text;
5706
+ const dropboxMessage = {
5707
+ at,
5708
+ message_id: messageId,
5709
+ text,
5710
+ project_id: projectId,
5711
+ story_id: storyId,
5712
+ ...(opts.monitorEvent === undefined ? {} : {
5713
+ kind: "monitor_event",
5714
+ monitor_id: opts.monitorEvent.monitor_id,
5715
+ description: opts.monitorEvent.description,
5716
+ sequence: opts.monitorEvent.sequence,
5717
+ }),
5718
+ };
5719
+ let resumed = null;
5720
+ let move = null;
5721
+ let queuedOnAdmission = false;
5722
+ if (opts.account !== undefined && !keepCurrentAccount) {
5723
+ // A moved message is always a resume on the target account: a running
5724
+ // lane (the dropbox mechanism) is refused inside prepareAccountMove.
5725
+ move = await prepareAccountMove(ctx, lane, {
5726
+ account: opts.account,
5727
+ cliModel: opts.model?.trim() || undefined,
5728
+ ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
5729
+ ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
5730
+ pending: {
5731
+ trigger: "message",
5732
+ text: deliveryText,
5733
+ ...(opts.monitorEvent === undefined ? {} : { monitor_event: true }),
5734
+ message_id: messageId,
5735
+ compact: opts.compact !== false,
5736
+ },
5737
+ });
5738
+ lane = move.lane;
5739
+ if (move.queued) {
5740
+ archiveResumeMessage(hqRoot, laneId, dropboxMessage);
5741
+ }
5742
+ }
5743
+ else if (mechanism === "resume") {
5744
+ const automatic = automaticAccountSelection;
5745
+ if (automatic !== undefined) {
5746
+ const selection = automatic.selection;
5747
+ if (selection.account !== lane.account) {
5748
+ move = await prepareAccountMove(ctx, lane, {
5749
+ account: selection.account,
5750
+ cliModel: undefined,
5751
+ ...(automatic.historyReason !== undefined ? { accountHistoryReason: automatic.historyReason } : {}),
5752
+ ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
5753
+ ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
5754
+ pending: {
5755
+ trigger: "message",
5756
+ text: deliveryText,
5757
+ ...(opts.monitorEvent === undefined ? {} : { monitor_event: true }),
5758
+ message_id: messageId,
5759
+ compact: opts.compact !== false,
5760
+ },
5761
+ accountSelection: selection,
5762
+ });
5763
+ lane = move.lane;
5764
+ if (move.queued)
5765
+ archiveResumeMessage(hqRoot, laneId, dropboxMessage);
5728
5766
  }
5729
- appendDropboxMessage(hqRoot, laneId, dropboxMessage);
5730
- return null;
5731
- },
5732
- resume: async () => {
5733
- // Exited worker with a recorded session: queue the message when capacity
5734
- // is occupied, otherwise start its next turn under the shared admission lock.
5735
- assertLaneRunsHereForAdmission(ctx, lane);
5736
- return withLaneAdmissionLock(hqRoot, lane.provider, async () => {
5737
- const admission = await assessLaneAdmission(ctx, lane, { queueAt: at });
5738
- if (admission.action === "refuse" && admission.error)
5739
- throw admission.error;
5740
- return withResumeHireAdmission(ctx, lane, () => {
5741
- if (admission.action === "queue") {
5742
- const sessionId = lane.provider_session_id;
5743
- if (!sessionId) {
5744
- throw new LanesError("no_provider_session", `Lane ${laneId} has no provider session id to queue this message resume`, {
5745
- lane_id: laneId,
5746
- provider: lane.provider,
5747
- });
5748
- }
5749
- archiveResumeMessage(hqRoot, laneId, dropboxMessage);
5750
- queuedOnAdmission = true;
5751
- return casUpdateLane(hqRoot, laneId, (current) => {
5752
- if (current.version !== lane.version || current.state !== lane.state) {
5753
- throw new LanesError("lane_admission_conflict", `Lane ${laneId} changed while message admission was checked`, {
5767
+ else {
5768
+ lane = persistClaudeAccountSelection(hqRoot, lane, selection);
5769
+ }
5770
+ }
5771
+ }
5772
+ if (resumeProfile !== undefined && (opts.account === undefined || keepCurrentAccount)) {
5773
+ validateResumeProfileModel(ctx, lane, resumeProfile.profile);
5774
+ }
5775
+ if (move === null && (resumeProfile !== undefined || resumeEffort !== undefined || (keepCurrentAccount && opts.model !== undefined))) {
5776
+ lane = recordResumeSetup(ctx, lane, {
5777
+ ...(resumeProfile !== undefined ? { profile: resumeProfile } : {}),
5778
+ ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
5779
+ ...(keepCurrentAccount && opts.model !== undefined ? { cliModel: opts.model } : {}),
5780
+ });
5781
+ }
5782
+ if (move?.queued) {
5783
+ // Parked at the target's cap: reconcile resumes the session there.
5784
+ markAccepted();
5785
+ }
5786
+ else {
5787
+ const dispatched = await dispatchLaneDelivery(lane, {
5788
+ dropbox: () => {
5789
+ // Live (or about-to-be-live) process: the PreToolUse drain delivers it.
5790
+ if (!laneAcceptsOperatorMessage(lane)) {
5791
+ throw new LanesError("lane_not_accepting_messages", operatorMessageRefusalReason(lane.state), { lane_id: laneId, state: lane.state });
5792
+ }
5793
+ appendDropboxMessage(hqRoot, laneId, dropboxMessage);
5794
+ return null;
5795
+ },
5796
+ resume: async () => {
5797
+ // Exited worker with a recorded session: queue the message when capacity
5798
+ // is occupied, otherwise start its next turn under the shared admission lock.
5799
+ assertLaneRunsHereForAdmission(ctx, lane);
5800
+ return withLaneAdmissionLock(hqRoot, lane.provider, async () => {
5801
+ const admission = await assessLaneAdmission(ctx, lane, { queueAt: at });
5802
+ if (admission.action === "refuse" && admission.error)
5803
+ throw admission.error;
5804
+ return withResumeHireAdmission(ctx, lane, () => {
5805
+ if (admission.action === "queue") {
5806
+ const sessionId = lane.provider_session_id;
5807
+ if (!sessionId) {
5808
+ throw new LanesError("no_provider_session", `Lane ${laneId} has no provider session id to queue this message resume`, {
5754
5809
  lane_id: laneId,
5755
- prepared_version: lane.version,
5756
- version: current.version,
5757
- state: current.state,
5810
+ provider: lane.provider,
5758
5811
  });
5759
5812
  }
5760
- return {
5761
- ...current,
5762
- state: "queued",
5763
- queued_reason: admission.reason ?? "throttle",
5764
- queued_at: at,
5765
- pending_resume: {
5766
- session_id: sessionId,
5767
- trigger: "message",
5768
- text: deliveryText,
5769
- ...(opts.monitorEvent === undefined ? {} : { monitor_event: true }),
5770
- message_id: messageId,
5771
- compact: opts.compact !== false,
5772
- at,
5773
- },
5774
- };
5775
- });
5776
- }
5777
- const started = spawnLaneWorker(hqRoot, lane, {
5778
- env: ctx.env,
5779
- modelDeps: {
5780
- readFileSync: ctx.readFileSync,
5781
- probe: ctx.probeProvider,
5813
+ archiveResumeMessage(hqRoot, laneId, dropboxMessage);
5814
+ queuedOnAdmission = true;
5815
+ return casUpdateLane(hqRoot, laneId, (current) => {
5816
+ if (current.version !== lane.version || current.state !== lane.state) {
5817
+ throw new LanesError("lane_admission_conflict", `Lane ${laneId} changed while message admission was checked`, {
5818
+ lane_id: laneId,
5819
+ prepared_version: lane.version,
5820
+ version: current.version,
5821
+ state: current.state,
5822
+ });
5823
+ }
5824
+ return {
5825
+ ...current,
5826
+ state: "queued",
5827
+ queued_reason: admission.reason ?? "throttle",
5828
+ queued_at: at,
5829
+ pending_resume: {
5830
+ session_id: sessionId,
5831
+ trigger: "message",
5832
+ text: deliveryText,
5833
+ ...(opts.monitorEvent === undefined ? {} : { monitor_event: true }),
5834
+ message_id: messageId,
5835
+ compact: opts.compact !== false,
5836
+ at,
5837
+ },
5838
+ };
5839
+ });
5840
+ }
5841
+ const started = spawnLaneWorker(hqRoot, lane, {
5782
5842
  env: ctx.env,
5783
- },
5784
- existsSync: ctx.existsSync,
5785
- spawnProcess: ctx.spawnProcess,
5786
- proveDetached: ctx.proveDetached,
5787
- killProcess: ctx.killProcess,
5788
- now: ctx.now,
5789
- resolveMachineIdentity: ctx.resolveMachineIdentity,
5790
- runCompaction: ctx.runCompaction,
5791
- locateTranscript: ctx.locateTranscript,
5792
- }, {
5793
- trigger: {
5794
- kind: "message",
5795
- text: deliveryText,
5796
- message_id: messageId,
5797
- ...(opts.monitorEvent === undefined ? {} : { monitor_event: true }),
5798
- },
5799
- compact: opts.compact !== false,
5800
- effort: resumeEffort ?? recordedResumeEffort(lane),
5843
+ modelDeps: {
5844
+ readFileSync: ctx.readFileSync,
5845
+ probe: ctx.probeProvider,
5846
+ env: ctx.env,
5847
+ },
5848
+ existsSync: ctx.existsSync,
5849
+ spawnProcess: ctx.spawnProcess,
5850
+ proveDetached: ctx.proveDetached,
5851
+ killProcess: ctx.killProcess,
5852
+ now: ctx.now,
5853
+ resolveMachineIdentity: ctx.resolveMachineIdentity,
5854
+ runCompaction: ctx.runCompaction,
5855
+ locateTranscript: ctx.locateTranscript,
5856
+ }, {
5857
+ trigger: {
5858
+ kind: "message",
5859
+ text: deliveryText,
5860
+ message_id: messageId,
5861
+ ...(opts.monitorEvent === undefined ? {} : { monitor_event: true }),
5862
+ },
5863
+ compact: opts.compact !== false,
5864
+ effort: resumeEffort ?? recordedResumeEffort(lane),
5865
+ });
5866
+ archiveResumeMessage(hqRoot, laneId, dropboxMessage);
5867
+ return started;
5801
5868
  });
5802
- archiveResumeMessage(hqRoot, laneId, dropboxMessage);
5803
- return started;
5804
- });
5805
- }, ctx.sleep);
5806
- },
5807
- }, mechanism);
5808
- if (dispatched.mechanism === "resume")
5809
- resumed = dispatched.value;
5810
- }
5811
- // Every accepted message moves the compaction reference forward.
5812
- let next = casUpdateLane(hqRoot, laneId, (current) => ({
5813
- ...resetKeepAliveProgress(current),
5814
- last_message_at: at,
5815
- }));
5816
- if (projectId !== lane.project_id || storyId !== lane.story_id) {
5817
- next = casUpdateLane(hqRoot, laneId, (current) => {
5818
- const already = current.attribution_history.some((entry) => entry.message_id === messageId);
5819
- if (already)
5820
- return current;
5821
- return {
5822
- ...current,
5823
- project_id: projectId,
5824
- story_id: storyId,
5825
- no_story_reason: storyId ? undefined : current.no_story_reason,
5826
- stories: laneStory(current, projectId, storyId)
5827
- ? laneStories(current)
5828
- : [
5829
- ...laneStories(current),
5830
- {
5831
- project_id: projectId,
5832
- story_id: storyId,
5833
- status: "in_progress",
5834
- added_at: at,
5835
- updated_at: at,
5836
- },
5837
- ],
5838
- attribution_history: appendCappedHistory(current.attribution_history, {
5869
+ }, ctx.sleep);
5870
+ },
5871
+ }, mechanism);
5872
+ if (dispatched.mechanism === "resume")
5873
+ resumed = dispatched.value;
5874
+ markAccepted();
5875
+ }
5876
+ // Every accepted message moves the compaction reference forward.
5877
+ let next = casUpdateLane(hqRoot, laneId, (current) => ({
5878
+ ...resetKeepAliveProgress(current),
5879
+ last_message_at: at,
5880
+ }));
5881
+ if (projectId !== lane.project_id || storyId !== lane.story_id) {
5882
+ next = casUpdateLane(hqRoot, laneId, (current) => {
5883
+ const already = current.attribution_history.some((entry) => entry.message_id === messageId);
5884
+ if (already)
5885
+ return current;
5886
+ return {
5887
+ ...current,
5839
5888
  project_id: projectId,
5840
5889
  story_id: storyId,
5841
- message_id: messageId,
5842
- at,
5843
- }),
5844
- };
5890
+ no_story_reason: storyId ? undefined : current.no_story_reason,
5891
+ stories: laneStory(current, projectId, storyId)
5892
+ ? laneStories(current)
5893
+ : [
5894
+ ...laneStories(current),
5895
+ {
5896
+ project_id: projectId,
5897
+ story_id: storyId,
5898
+ status: "in_progress",
5899
+ added_at: at,
5900
+ updated_at: at,
5901
+ },
5902
+ ],
5903
+ attribution_history: appendCappedHistory(current.attribution_history, {
5904
+ project_id: projectId,
5905
+ story_id: storyId,
5906
+ message_id: messageId,
5907
+ at,
5908
+ }),
5909
+ };
5910
+ });
5911
+ }
5912
+ // What this command can claim when it returns: the message is durably
5913
+ // written (accepted). Delivery to the model is asynchronous in both
5914
+ // mechanisms. `dropbox` delivers on the worker's next tool call, and only
5915
+ // if the drain hook is registered in the account home the worker was
5916
+ // started from; `resume` has started a new worker with the message as its
5917
+ // prompt. Neither is "delivered", so the response never says so.
5918
+ const drainHook = drainHookStatusFor(ctx, next);
5919
+ const deliveryStatus = move?.queued
5920
+ ? "queued_on_target_account"
5921
+ : queuedOnAdmission
5922
+ ? "queued_on_admission"
5923
+ : mechanism === "dropbox"
5924
+ ? "awaiting_next_tool_call"
5925
+ : "worker_spawned";
5926
+ const warning = mechanism === "dropbox" && drainHook !== null && !drainHook.registered
5927
+ ? `The drain hook is not registered in ${drainHook.path}; the running worker will not receive this message. It stays in the inbox and is delivered on the lane's next start.`
5928
+ : mechanism === "dropbox" && next.stalled === true
5929
+ ? `The worker has made no tool call since ${next.stalled_at ?? "an unknown time"} (${next.stalled_reason ?? "stalled"}); delivery waits on its next tool call.`
5930
+ : null;
5931
+ if (!opts.silent)
5932
+ printJson({
5933
+ ok: true,
5934
+ action: "message",
5935
+ lane_id: laneId,
5936
+ message_id: messageId,
5937
+ accepted: true,
5938
+ delivered: false,
5939
+ delivery: mechanism,
5940
+ delivery_status: deliveryStatus,
5941
+ inbox_path: mechanism === "dropbox" ? dropboxInboxPath(hqRoot, laneId) : null,
5942
+ drain_hook: drainHook,
5943
+ warning,
5944
+ resumed_session_id: resumed?.last_resume?.session_id ?? null,
5945
+ compaction: resumed?.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
5946
+ ? resumed.last_compaction
5947
+ : null,
5948
+ account_move: move ? accountMoveSummary(move) : null,
5949
+ project_id: next.project_id,
5950
+ story_id: next.story_id,
5951
+ stories: laneStories(next),
5952
+ story_warning: storyWarning(next) ?? null,
5953
+ lane: next,
5954
+ });
5955
+ }
5956
+ catch (err) {
5957
+ if (claimedMessages.length > 0 && !accepted) {
5958
+ const error = err instanceof Error ? err.message : String(err);
5959
+ writeWatcherDecision(watcherOutLogPath(hqRoot, laneId), {
5960
+ event: "message_recovery_failed",
5961
+ at: ctx.now().toISOString(),
5962
+ state: lane.state,
5963
+ error,
5964
+ message_count: claimedMessages.length,
5965
+ }, WATCHER_OUT_MAX_BYTES);
5966
+ try {
5967
+ requeueDropboxMessages(hqRoot, laneId, claimedMessages);
5968
+ }
5969
+ catch (requeueErr) {
5970
+ const requeueMessage = requeueErr instanceof Error ? requeueErr.message : String(requeueErr);
5971
+ writeWatcherDecision(watcherOutLogPath(hqRoot, laneId), {
5972
+ event: "message_requeue_failed",
5973
+ at: ctx.now().toISOString(),
5974
+ state: lane.state,
5975
+ recovery_error: error,
5976
+ requeue_error: requeueMessage,
5977
+ message_count: claimedMessages.length,
5978
+ }, WATCHER_OUT_MAX_BYTES);
5979
+ throw new LanesError("message_requeue_failed", `Could not return ${claimedMessages.length} queued lane message(s) to the inbox after recovery failed: ${requeueMessage}`, { lane_id: laneId, message_count: claimedMessages.length, recovery_error: error });
5980
+ }
5981
+ }
5982
+ throw err;
5983
+ }
5984
+ }
5985
+ function messageLaneExclusion(ctx, lane) {
5986
+ if (lane.state === "killed" || lane.state === "cancelled")
5987
+ return lane.state;
5988
+ return keepAliveExclusion(lane, loadAllLanes(ctx.hqRoot()), loadRetiredLaneIds(ctx.hqRoot()));
5989
+ }
5990
+ function recordMessageUndeliverable(ctx, lane, reason, messageCount) {
5991
+ writeWatcherDecision(watcherOutLogPath(ctx.hqRoot(), lane.lane_id), {
5992
+ event: "message_undeliverable",
5993
+ at: ctx.now().toISOString(),
5994
+ state: lane.state,
5995
+ reason,
5996
+ ...(messageCount === undefined ? {} : { message_count: messageCount }),
5997
+ }, WATCHER_OUT_MAX_BYTES);
5998
+ }
5999
+ /**
6000
+ * Watcher and reconcile call the hidden command below after they win the
6001
+ * terminal transition. The inbox is claimed once with the normal atomic
6002
+ * dropbox drain, and an accepted message is routed through runMessage so the
6003
+ * existing provider, admission, account, worker-profile and senior checks
6004
+ * remain authoritative.
6005
+ */
6006
+ async function recoverQueuedMessages(ctx, laneId) {
6007
+ const hqRoot = ctx.hqRoot();
6008
+ const lane = readLane(hqRoot, laneId);
6009
+ if (!dropboxInboxHasMessages(hqRoot, laneId))
6010
+ return { action: "none", message_count: 0 };
6011
+ const exclusion = messageLaneExclusion(ctx, lane);
6012
+ const refusal = exclusion ?? (lane.state === "done"
6013
+ ? "done_before_envelope"
6014
+ : lane.state === "awaiting_input"
6015
+ ? "awaiting_input_requires_question_answer"
6016
+ : laneDeliveryMechanism(lane) !== "resume"
6017
+ ? `no_resume_path_for_${lane.state}`
6018
+ : undefined);
6019
+ if (refusal !== undefined) {
6020
+ const messages = drainDropbox(hqRoot, laneId, { archiveDirectory: "undeliverable" });
6021
+ if (messages.length > 0)
6022
+ recordMessageUndeliverable(ctx, lane, refusal, messages.length);
6023
+ return { action: messages.length > 0 ? "undeliverable" : "none", message_count: messages.length };
6024
+ }
6025
+ const messages = drainDropbox(hqRoot, laneId);
6026
+ if (messages.length === 0)
6027
+ return { action: "none", message_count: 0 };
6028
+ let accepted = false;
6029
+ try {
6030
+ await runMessage(ctx, laneId, {
6031
+ text: formatDropboxMessages(messages),
6032
+ compact: false,
6033
+ silent: true,
6034
+ onAccepted: () => { accepted = true; },
5845
6035
  });
6036
+ return { action: "resumed", message_count: messages.length };
6037
+ }
6038
+ catch (err) {
6039
+ const error = err instanceof Error ? err.message : String(err);
6040
+ writeWatcherDecision(watcherOutLogPath(hqRoot, laneId), {
6041
+ event: "message_recovery_failed",
6042
+ at: ctx.now().toISOString(),
6043
+ state: lane.state,
6044
+ error,
6045
+ message_count: messages.length,
6046
+ }, WATCHER_OUT_MAX_BYTES);
6047
+ if (!accepted) {
6048
+ try {
6049
+ requeueDropboxMessages(hqRoot, laneId, messages);
6050
+ }
6051
+ catch (requeueErr) {
6052
+ const requeueMessage = requeueErr instanceof Error ? requeueErr.message : String(requeueErr);
6053
+ writeWatcherDecision(watcherOutLogPath(hqRoot, laneId), {
6054
+ event: "message_requeue_failed",
6055
+ at: ctx.now().toISOString(),
6056
+ state: lane.state,
6057
+ recovery_error: error,
6058
+ requeue_error: requeueMessage,
6059
+ message_count: messages.length,
6060
+ }, WATCHER_OUT_MAX_BYTES);
6061
+ throw new LanesError("message_requeue_failed", `Could not return ${messages.length} queued lane message(s) to the inbox after recovery failed: ${requeueMessage}`, { lane_id: laneId, message_count: messages.length, recovery_error: error });
6062
+ }
6063
+ }
6064
+ throw err;
5846
6065
  }
5847
- // What this command can claim when it returns: the message is durably
5848
- // written (accepted). Delivery to the model is asynchronous in both
5849
- // mechanisms. `dropbox` delivers on the worker's next tool call, and only
5850
- // if the drain hook is registered in the account home the worker was
5851
- // started from; `resume` has started a new worker with the message as its
5852
- // prompt. Neither is "delivered", so the response never says so.
5853
- const drainHook = drainHookStatusFor(ctx, next);
5854
- const deliveryStatus = move?.queued
5855
- ? "queued_on_target_account"
5856
- : queuedOnAdmission
5857
- ? "queued_on_admission"
5858
- : mechanism === "dropbox"
5859
- ? "awaiting_next_tool_call"
5860
- : "worker_spawned";
5861
- const warning = mechanism === "dropbox" && drainHook !== null && !drainHook.registered
5862
- ? `The drain hook is not registered in ${drainHook.path}; the running worker will not receive this message. It stays in the inbox and is delivered on the lane's next start.`
5863
- : mechanism === "dropbox" && next.stalled === true
5864
- ? `The worker has made no tool call since ${next.stalled_at ?? "an unknown time"} (${next.stalled_reason ?? "stalled"}); delivery waits on its next tool call.`
5865
- : null;
5866
- printJson({
5867
- ok: true,
5868
- action: "message",
5869
- lane_id: laneId,
5870
- message_id: messageId,
5871
- accepted: true,
5872
- delivered: false,
5873
- delivery: mechanism,
5874
- delivery_status: deliveryStatus,
5875
- inbox_path: mechanism === "dropbox" ? dropboxInboxPath(hqRoot, laneId) : null,
5876
- drain_hook: drainHook,
5877
- warning,
5878
- resumed_session_id: resumed?.last_resume?.session_id ?? null,
5879
- compaction: resumed?.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
5880
- ? resumed.last_compaction
5881
- : null,
5882
- account_move: move ? accountMoveSummary(move) : null,
5883
- project_id: next.project_id,
5884
- story_id: next.story_id,
5885
- stories: laneStories(next),
5886
- story_warning: storyWarning(next) ?? null,
5887
- lane: next,
5888
- });
5889
6066
  }
5890
6067
  function accountMoveSummary(move) {
5891
6068
  return {
@@ -8532,6 +8709,9 @@ async function runReconcile(ctx, opts) {
8532
8709
  livenessDeps: livenessDepsFromCtx(ctx),
8533
8710
  presenceConnected: ctx.presenceConnected,
8534
8711
  resolveQuestionActiveCompany: () => resolveActiveQuestionCompany(ctx),
8712
+ recoverQueuedMessages: async (lane) => {
8713
+ await recoverQueuedMessages(ctx, lane.lane_id);
8714
+ },
8535
8715
  mesh: ctx.mesh,
8536
8716
  env: ctx.env,
8537
8717
  });
@@ -8863,6 +9043,12 @@ export function registerLanesCommand(parent, deps = {}) {
8863
9043
  .action(wrap(async (laneId, opts) => {
8864
9044
  await runMessage(ctx, laneId, opts);
8865
9045
  }));
9046
+ lanes
9047
+ .command("_recover-queued-messages", { hidden: true })
9048
+ .argument("<lane-id>", "Lane id")
9049
+ .action(wrap(async (laneId) => {
9050
+ printJson(await recoverQueuedMessages(ctx, laneId));
9051
+ }));
8866
9052
  lanes
8867
9053
  .command("_monitor-message", { hidden: true })
8868
9054
  .argument("<lane-id>", "Lane id")