@indigoai-us/hq-cli 5.345.56 → 5.345.57

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.
@@ -77,6 +77,10 @@ export interface LaneResumeOptions {
77
77
  effort?: string;
78
78
  worker?: string;
79
79
  keepAlive?: boolean;
80
+ freshThread?: boolean;
81
+ workingDirectory?: string;
82
+ /** Internal loop runner session; selects this story's saved provider session. */
83
+ loopSessionId?: string;
80
84
  meshCompany?: string;
81
85
  profileRepinContext?: string;
82
86
  grantContext?: string;
@@ -458,7 +462,14 @@ export interface LaneQuestionsHandlerDeps {
458
462
  questionCompanyForLane(question: QuestionRecord, lane: Pick<LaneRecord, "company" | "mesh_company" | "project_id" | "lane_id">, hqRoot?: string): LaneCompanyRef | null;
459
463
  questionCompanyRefMatches(recordCompany: LaneCompanyRef | null | undefined, filter: LaneCompanyRef): boolean;
460
464
  questionListCompanyArg(company: LaneCompanyRef | null): string | undefined;
461
- readOutpostQuestionRecords(ctx: Ctx, outpostId: string, company: LaneCompanyRef | null): Promise<OutpostQuestionRow[]>;
465
+ readOutpostQuestionRecords(ctx: Ctx, outpostId: string, company: LaneCompanyRef | null, questionId?: string, visibilityFilter?: {
466
+ sessionId?: string;
467
+ for?: string;
468
+ from?: string;
469
+ project?: string;
470
+ story?: string;
471
+ status?: string;
472
+ }): Promise<OutpostQuestionRow[]>;
462
473
  readQuestionBody(hqRoot: string, question: QuestionRecord): QuestionBody | null;
463
474
  printJson(value: unknown): void;
464
475
  readSessionMeta(hqRoot: string, sessionId: string, ctx: Ctx): Record<string, unknown> | null;
@@ -41,11 +41,15 @@ import { readLanesFlagLocalOverride, readLanesRegistryFlag, readLanesRegistryFla
41
41
  import { readLaneStatusRowFields } from "../lib/lanes/status-fields.js";
42
42
  import { appendProjectFocusOverride, readProjectStoryFocusData, removeProjectFocusOverride } from "../lib/lanes/story-ledger.js";
43
43
  import { appendLaneFocusOverride, assertFocusGuardAllows, makeFocusOverride } from "../lib/lanes/focus-guard.js";
44
- import { LANE_FOCUS_GUARD_FLAG, LANE_FOCUS_DASHBOARD_FLAG, LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_LIST_SKIP_REMOTE_REFRESH_FLAG, LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG, LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG, LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG, LANE_STATUS_ROW_FIELDS_FLAG, LANE_REAP_STOP_FLAG, LANE_REUSE_REFRESHES_PROFILE_FLAG, LANE_WORKER_CONTINUITY_FLAG, } from "../lib/lanes/flag-keys.js";
44
+ import { LANE_FOCUS_GUARD_FLAG, LANE_FOCUS_DASHBOARD_FLAG, LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_LIST_SKIP_REMOTE_REFRESH_FLAG, LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG, LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG, LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG, LANE_STATUS_ROW_FIELDS_FLAG, LANE_REAP_STOP_FLAG, LANE_REUSE_REFRESHES_PROFILE_FLAG, LANE_WORKER_CONTINUITY_FLAG, LANE_CONDUCT_LOOP_FLAG, } from "../lib/lanes/flag-keys.js";
45
45
  import { findWorkerContinuityLane, sameLaneHost, workerContinuityPrompt } from "../lib/lanes/worker-continuity.js";
46
+ import { enqueueLoopEnvelope, interruptLoopQueue, loopControllerIsAlive, loopStatus, validateLoopEnvelope } from "../lib/lanes/loop.js";
47
+ import { runLaneLoopController, startLaneLoopController } from "./lanes-loop.js";
46
48
  import { configuredCodexRolloutHomes, retainCodexRollouts } from "../lib/lanes/disk-guard.js";
47
49
  import { isControllerAliasOutpostId } from "../lib/lanes/admission-host.js";
48
50
  import { runLeadAutopilotPass } from "../lib/lanes/autopilot.js";
51
+ import { appendDropboxMessageOnce, appendDropboxMessageOnceAtPath } from "../lib/lanes/dropbox.js";
52
+ import { monitorSessionDir } from "../lib/monitor.js";
49
53
  import { syncOutpostLeadRegistries } from "../lib/lanes/autopilot-registry.js";
50
54
  import { OutpostSnapshotInvocationMemo } from "../lib/lanes/outpost-snapshot-memo.js";
51
55
  import { createOutpostHireGrantContext, parseOutpostHireGrantContext, selectOutpostHireGrantHierarchy, sameHireGrantSenior, } from "../lib/lanes/hire-grant-dispatch.js";
@@ -3326,6 +3330,16 @@ async function runCreate(ctx, opts) {
3326
3330
  }
3327
3331
  meshCompany = resolvedMeshCompany;
3328
3332
  }
3333
+ if (opts.loop === true && !(await readLanesRegistryFlagFailOpen(LANE_CONDUCT_LOOP_FLAG, {
3334
+ company: company ?? meshCompany,
3335
+ defaultValue: true,
3336
+ env: ctx.env,
3337
+ }))) {
3338
+ throw new LanesError("loop_lanes_disabled", `Loop lanes are disabled by the ${LANE_CONDUCT_LOOP_FLAG} kill switch`, {});
3339
+ }
3340
+ if (opts.loop === true && (opts.outpost?.trim() || requestedPlacement?.kind === "outpost" || defaultPlacement?.kind === "outpost")) {
3341
+ throw new LanesError("loop_lane_outpost_unsupported", "Loop lanes currently require the local host", { outpost_id: opts.outpost?.trim() ?? (requestedPlacement?.kind === "outpost" ? requestedPlacement.id : defaultPlacement?.kind === "outpost" ? defaultPlacement.id : undefined) });
3342
+ }
3329
3343
  let placementCompanyResolved = false;
3330
3344
  let placementCompany;
3331
3345
  let placementGate;
@@ -3388,6 +3402,9 @@ async function runCreate(ctx, opts) {
3388
3402
  const profileAccounts = profileDefaultsApply ? profile?.meta.accounts : undefined;
3389
3403
  const keepAlive = opts.keepAlive === true ||
3390
3404
  (profileDefaultsApply && profile?.meta.keep_alive === true);
3405
+ if (opts.loop === true && keepAlive) {
3406
+ throw new LanesError("loop_keep_alive_conflict", "Loop lanes cannot use keep-alive", { worker: resolvedWorker?.id ?? opts.worker ?? null });
3407
+ }
3391
3408
  if (keepAlive && !providerSupportsSessionResume(provider)) {
3392
3409
  throw new LanesError("keep_alive_provider_unsupported", `Provider ${provider} cannot resume recorded sessions, so keep-alive is unavailable`, { provider });
3393
3410
  }
@@ -3755,6 +3772,9 @@ async function runCreate(ctx, opts) {
3755
3772
  const host = placementHostOverride ?? (outpostId !== undefined
3756
3773
  ? { kind: "outpost", id: outpostId }
3757
3774
  : { kind: "local", id: ctx.hostname() });
3775
+ if (opts.loop === true && host.kind === "outpost") {
3776
+ throw new LanesError("loop_lane_outpost_unsupported", "Loop lanes currently require the local host", { outpost_id: host.id });
3777
+ }
3758
3778
  // Repeat the busy check while holding provider admission serialization so
3759
3779
  // two concurrent creates cannot both claim one worker's provider thread.
3760
3780
  if (continuityEnabled) {
@@ -3914,6 +3934,7 @@ async function runCreate(ctx, opts) {
3914
3934
  }
3915
3935
  : {}),
3916
3936
  keep_alive: keepAlive,
3937
+ ...(opts.loop === true ? { loop_mode: true } : {}),
3917
3938
  ...(keepAlive ? { keep_alive_opted_in_at: createdAt } : {}),
3918
3939
  provider,
3919
3940
  ...(workerContinuity.resumable && workerContinuity.previous
@@ -4113,7 +4134,21 @@ async function runCreate(ctx, opts) {
4113
4134
  });
4114
4135
  }
4115
4136
  let started = lane;
4116
- if (admission.action === "start") {
4137
+ let loopControllerPid;
4138
+ if (opts.loop === true) {
4139
+ lane = casUpdateLane(hqRoot, lane.lane_id, (current) => admission.action === "start"
4140
+ ? {
4141
+ ...current,
4142
+ state: "running",
4143
+ queued_reason: undefined,
4144
+ queued_details: undefined,
4145
+ }
4146
+ : { ...current });
4147
+ started = lane;
4148
+ if (admission.action === "start")
4149
+ loopControllerPid = startLaneLoopController(ctx, lane.lane_id, true);
4150
+ }
4151
+ else if (admission.action === "start") {
4117
4152
  started = spawnLaneWorker(hqRoot, lane, {
4118
4153
  env: ctx.env,
4119
4154
  modelDeps: {
@@ -4169,6 +4204,7 @@ async function runCreate(ctx, opts) {
4169
4204
  company: started.company,
4170
4205
  project_id: started.project_id,
4171
4206
  story_id: started.story_id,
4207
+ ...(started.loop_mode ? { loop: loopStatus(runDir(hqRoot, started.lane_id), started.lane_id), loop_pid: loopControllerPid ?? null } : {}),
4172
4208
  stories: laneStories(started),
4173
4209
  story_warning: combinedStoryWarning(started, createStoryWarnings),
4174
4210
  thread_id: started.thread_id ?? null,
@@ -5511,10 +5547,11 @@ async function runList(ctx, opts) {
5511
5547
  action: "list",
5512
5548
  lanes: selectedLanes.map((lane) => {
5513
5549
  const fields = statusFieldsByLane.get(lane.lane_id) ?? {};
5550
+ const loop = lane.loop_mode === true ? { loop: loopStatus(runDir(hqRoot, lane.lane_id), lane.lane_id) } : {};
5514
5551
  if (!isTerminalLaneState(lane.state))
5515
- return { ...lane, ...fields };
5552
+ return { ...lane, ...fields, ...loop };
5516
5553
  const envelope = readLatestEnvelopeRecord(hqRoot, lane.lane_id)?.envelope;
5517
- return { ...lane, ...fields, ...(envelope?.summary ? { envelope_summary: envelope.summary } : {}) };
5554
+ return { ...lane, ...fields, ...loop, ...(envelope?.summary ? { envelope_summary: envelope.summary } : {}) };
5518
5555
  }),
5519
5556
  });
5520
5557
  return;
@@ -5620,6 +5657,7 @@ async function runList(ctx, opts) {
5620
5657
  ...lane,
5621
5658
  ...statusFieldsByLane.get(lane.lane_id),
5622
5659
  ...hireByLane.get(lane.lane_id),
5660
+ ...(lane.loop_mode === true ? { loop: loopStatus(runDir(hqRoot, lane.lane_id), lane.lane_id) } : {}),
5623
5661
  ...(remoteRefresh.unverifiedRemote.has(lane.lane_id) ? { unverified_remote: true } : {}),
5624
5662
  stories: laneStories(lane),
5625
5663
  story_ids: laneStories(lane).map((story) => story.story_id),
@@ -5991,6 +6029,7 @@ async function runShow(ctx, laneId, hasPendingQuestionForLane, resolveLaneTarget
5991
6029
  const laneForShow = {
5992
6030
  ...lane,
5993
6031
  ...await laneStatusFieldsForCommand(ctx, hqRoot, storedLane, ctx.now()),
6032
+ ...(storedLane.loop_mode === true ? { loop: loopStatus(runDir(hqRoot, laneId), laneId) } : {}),
5994
6033
  };
5995
6034
  delete laneForShow.resume_history;
5996
6035
  printJson({
@@ -7785,6 +7824,7 @@ async function runReparentLocked(ctx, laneId, opts, finishedChildrenDoNotCount =
7785
7824
  }
7786
7825
  assertLaneNotRunningForReparent(lane);
7787
7826
  const pending = pendingQuestionsForLane(hqRoot, laneId);
7827
+ const pendingAudiences = new Map(pending.map((question) => [question.question_id, resolveAudience(hqRoot, question, ctx)]));
7788
7828
  const oldDepth = derivedLaneDepth(hqRoot, lane.senior, ctx);
7789
7829
  if (newDepth > oldDepth && pending.length > 0) {
7790
7830
  throw new LanesError("demotion_with_pending_questions", "Cannot increase a lane's depth while it has pending questions", { lane_id: laneId, from_depth: oldDepth, to_depth: newDepth });
@@ -7849,17 +7889,24 @@ async function runReparentLocked(ctx, laneId, opts, finishedChildrenDoNotCount =
7849
7889
  }
7850
7890
  for (const question of pending) {
7851
7891
  casUpdateQuestion(hqRoot, question.question_id, (current) => {
7892
+ const previousAudience = pendingAudiences.get(current.question_id);
7893
+ const nextAudience = resolveAudience(hqRoot, current, ctx);
7894
+ const audienceChanged = previousAudience !== undefined &&
7895
+ seniorKey(previousAudience) !== seniorKey(nextAudience);
7852
7896
  const already = current.routing_history.some((entry) => entry.reason === "reparented" && entry.at === at);
7853
- if (already)
7897
+ if (already && !audienceChanged)
7854
7898
  return current;
7855
7899
  return {
7856
7900
  ...current,
7857
- routing_history: appendCappedHistory(current.routing_history, {
7858
- hop: current.hop,
7859
- audience: resolveAudience(hqRoot, current, ctx),
7860
- at,
7861
- reason: "reparented",
7862
- }),
7901
+ ...(audienceChanged ? { seen_at: null, seen_session_id: null } : {}),
7902
+ routing_history: already
7903
+ ? current.routing_history
7904
+ : appendCappedHistory(current.routing_history, {
7905
+ hop: current.hop,
7906
+ audience: nextAudience,
7907
+ at,
7908
+ reason: "reparented",
7909
+ }),
7863
7910
  };
7864
7911
  });
7865
7912
  }
@@ -8470,7 +8517,7 @@ async function runStop(ctx, laneId, reason, quiet = false) {
8470
8517
  const hqRoot = ctx.hqRoot();
8471
8518
  const target = await resolveCommandLaneTarget(ctx, laneId);
8472
8519
  const lane = target.lane;
8473
- if (!quiet && lane.state !== "queued" && !isTerminalLaneState(lane.state)) {
8520
+ if (!quiet && lane.loop_mode !== true && lane.state !== "queued" && !isTerminalLaneState(lane.state)) {
8474
8521
  process.stderr.write(stopTerminalWarning(lane.lane_id));
8475
8522
  }
8476
8523
  if (target.remoteOnly) {
@@ -8482,6 +8529,23 @@ async function runStop(ctx, laneId, reason, quiet = false) {
8482
8529
  return;
8483
8530
  }
8484
8531
  assertLaneRunsHereForOperatorMutation(ctx, lane, "stop");
8532
+ if (lane.loop_mode === true) {
8533
+ const runDirPath = runDir(hqRoot, lane.lane_id);
8534
+ const loop = loopStatus(runDirPath, lane.lane_id);
8535
+ if (loop.state === "stopped" || loop.state === "failed") {
8536
+ throw new LanesError("already_terminal", `Loop lane ${lane.lane_id} is already ${lane.state}`, { lane_id: lane.lane_id, state: lane.state });
8537
+ }
8538
+ const controllerAlive = loopControllerIsAlive(runDirPath);
8539
+ if (!controllerAlive && lane.state === "running") {
8540
+ await runInterrupt(ctx, lane.lane_id, "loop controller exited; recovering queued work before stop", { preserveLoopQueue: true, quiet: true });
8541
+ }
8542
+ const queued = enqueueLoopEnvelope({ runDir: runDirPath, laneId: lane.lane_id, envelope: { kind: "stop" }, now: ctx.now });
8543
+ if (!controllerAlive && lane.state !== "queued")
8544
+ startLaneLoopController(ctx, lane.lane_id);
8545
+ if (!quiet)
8546
+ printJson({ ok: true, action: "stop", lane_id: lane.lane_id, terminal: false, queued_stop: queued });
8547
+ return;
8548
+ }
8485
8549
  if (isTerminalLaneState(lane.state)) {
8486
8550
  throw new LanesError("already_terminal", `Lane ${laneId} is already ${lane.state}`, { lane_id: laneId, state: lane.state });
8487
8551
  }
@@ -8537,7 +8601,7 @@ function stopTerminalWarning(laneId) {
8537
8601
  * session so `hq lanes resume` continues it. Keep-alive resumes only
8538
8602
  * `exited_with_partial_output` interruptions, so it leaves this lane alone.
8539
8603
  */
8540
- async function runInterrupt(ctx, laneId, reason) {
8604
+ async function runInterrupt(ctx, laneId, reason, options = {}) {
8541
8605
  const operatorReason = reason?.trim();
8542
8606
  if (reason !== undefined && !operatorReason) {
8543
8607
  throw new LanesError("lane_interrupt_reason_invalid", "Interrupt reason must not be empty", {});
@@ -8550,6 +8614,14 @@ async function runInterrupt(ctx, laneId, reason) {
8550
8614
  throw new LanesError("interrupt_remote_unsupported", `Lane ${laneId} runs on Outpost ${lane.host.id}; run hq lanes interrupt on that Outpost`, { lane_id: laneId, outpost_id: lane.host.id });
8551
8615
  }
8552
8616
  assertLaneRunsHereForOperatorMutation(ctx, lane, "interrupt");
8617
+ const queueInterrupt = lane.loop_mode === true && options.preserveLoopQueue !== true
8618
+ ? interruptLoopQueue(runDir(hqRoot, lane.lane_id))
8619
+ : undefined;
8620
+ if (lane.loop_mode === true && lane.state !== "running") {
8621
+ if (!options.quiet)
8622
+ printJson({ ok: true, action: "interrupt", lane_id: lane.lane_id, terminal: false, resumable: true, ...queueInterrupt });
8623
+ return;
8624
+ }
8553
8625
  const assertInterruptible = (current) => {
8554
8626
  if (current.state !== "running") {
8555
8627
  throw new LanesError("lane_not_running", current.state === "queued"
@@ -8592,13 +8664,15 @@ async function runInterrupt(ctx, laneId, reason) {
8592
8664
  const resumable = resumeCommandRefusal(laneWithRecoveredProviderSession(hqRoot, updated)) === null;
8593
8665
  if (!resumable)
8594
8666
  process.stderr.write(interruptNotResumableWarning(laneId));
8595
- printJson({
8596
- ok: true,
8597
- action: "interrupt",
8598
- lane: updated,
8599
- terminal: false,
8600
- resumable,
8601
- });
8667
+ if (!options.quiet)
8668
+ printJson({
8669
+ ok: true,
8670
+ action: "interrupt",
8671
+ lane: updated,
8672
+ terminal: false,
8673
+ resumable,
8674
+ ...(queueInterrupt === undefined ? {} : queueInterrupt),
8675
+ });
8602
8676
  }
8603
8677
  /**
8604
8678
  * The watcher skips lanes that are already terminal, so it never records the
@@ -8762,13 +8836,13 @@ function questionListCompanyArg(company) {
8762
8836
  }
8763
8837
  return value;
8764
8838
  }
8765
- async function readOutpostQuestionRecords(ctx, outpostId, company) {
8839
+ async function readOutpostQuestionRecords(ctx, outpostId, company, questionId, visibilityFilter) {
8766
8840
  const companyArg = questionListCompanyArg(company);
8767
8841
  let result;
8768
8842
  try {
8769
8843
  result = await ctx.dispatchOutpostExec({
8770
8844
  outpostId,
8771
- command: buildOutpostQuestionListCommand({ company: companyArg }),
8845
+ command: buildOutpostQuestionListCommand({ company: companyArg, ...visibilityFilter }),
8772
8846
  timeoutSeconds: CAPACITY_PROBE_TIMEOUT_MS / 1000,
8773
8847
  waitMs: CAPACITY_PROBE_TIMEOUT_MS,
8774
8848
  });
@@ -8801,6 +8875,8 @@ async function readOutpostQuestionRecords(ctx, outpostId, company) {
8801
8875
  throw new LanesError("outpost_question_list_invalid", `Outpost ${outpostId} returned an invalid question record`, { outpost_id: outpostId, index });
8802
8876
  }
8803
8877
  const rowObject = row;
8878
+ if (questionId !== undefined && rowObject.question_id !== questionId)
8879
+ continue;
8804
8880
  let question;
8805
8881
  try {
8806
8882
  question = parseQuestionRecord(row);
@@ -8930,6 +9006,31 @@ async function runReconcile(ctx, opts, recoverQueuedMessages) {
8930
9006
  runCompaction: ctx.runCompaction,
8931
9007
  locateTranscript: ctx.locateTranscript,
8932
9008
  },
9009
+ spawn: (root, candidate) => {
9010
+ if (candidate.loop_mode !== true || candidate.state !== "queued") {
9011
+ return spawnLaneWorker(root, candidate, {
9012
+ env: ctx.env,
9013
+ modelDeps: { readFileSync: ctx.readFileSync, probe: ctx.probeProvider, env: ctx.env },
9014
+ existsSync: ctx.existsSync,
9015
+ spawnProcess: ctx.spawnProcess,
9016
+ proveDetached: ctx.proveDetached,
9017
+ killProcess: ctx.killProcess,
9018
+ now: ctx.now,
9019
+ resolveMachineIdentity: ctx.resolveMachineIdentity,
9020
+ runCompaction: ctx.runCompaction,
9021
+ locateTranscript: ctx.locateTranscript,
9022
+ });
9023
+ }
9024
+ const admitted = casUpdateLane(root, candidate.lane_id, (current) => ({
9025
+ ...current,
9026
+ state: "running",
9027
+ queued_reason: undefined,
9028
+ queued_details: undefined,
9029
+ admission_reserved_at: undefined,
9030
+ }));
9031
+ startLaneLoopController(ctx, candidate.lane_id, true);
9032
+ return admitted;
9033
+ },
8933
9034
  throttleDeps: ctx.throttleDeps,
8934
9035
  livenessDeps: livenessDepsFromCtx(ctx),
8935
9036
  presenceConnected: ctx.presenceConnected,
@@ -8937,6 +9038,63 @@ async function runReconcile(ctx, opts, recoverQueuedMessages) {
8937
9038
  recoverQueuedMessages,
8938
9039
  mesh: ctx.mesh,
8939
9040
  env: ctx.env,
9041
+ deliverBlockedEscalation: async (delivery) => {
9042
+ const currentHost = reconcileHostFromCtx(ctx);
9043
+ const targetOutpostId = delivery.recipient_host?.kind === "outpost"
9044
+ ? delivery.recipient_host.id
9045
+ : "primary";
9046
+ const targetIsCurrent = targetOutpostId === (currentHost.outpostId ?? "primary");
9047
+ if (targetIsCurrent)
9048
+ throw new LanesError("blocked_escalation_local_delivery_misrouted", "Local blocked escalations must use the durable local inbox path");
9049
+ if (!delivery.recipient.id)
9050
+ throw new LanesError("blocked_escalation_recipient_invalid", "Escalation recipient has no id");
9051
+ const waitMs = outpostSingleLaneWaitMs(ctx.env);
9052
+ const dispatched = await ctx.dispatchOutpostExec({
9053
+ outpostId: targetOutpostId,
9054
+ timeoutSeconds: Math.ceil(waitMs / 1000),
9055
+ waitMs,
9056
+ command: buildOutpostLaneCommand({
9057
+ action: "blocked-escalation-target",
9058
+ laneId: delivery.recipient.id,
9059
+ recipientKind: delivery.recipient.kind === "session" ? "session" : "lane",
9060
+ text: delivery.text,
9061
+ messageId: delivery.message_id,
9062
+ }),
9063
+ });
9064
+ if (dispatched.exitCode !== 0)
9065
+ throw new LanesError("blocked_escalation_remote_delivery_failed", dispatchErrorMessage(dispatched), { outpost_id: targetOutpostId });
9066
+ },
9067
+ resolveEscalationLane: async (laneId) => {
9068
+ const currentHost = reconcileHostFromCtx(ctx);
9069
+ if (currentHost.outpostId === null)
9070
+ return null;
9071
+ const waitMs = outpostSingleLaneWaitMs(ctx.env);
9072
+ const dispatched = await ctx.dispatchOutpostExec({
9073
+ outpostId: "primary",
9074
+ command: buildOutpostLaneCommand({ action: "show", laneId }),
9075
+ timeoutSeconds: Math.ceil(waitMs / 1000),
9076
+ waitMs,
9077
+ });
9078
+ if (dispatched.done !== true || dispatched.status !== "Success" || dispatched.exitCode !== 0) {
9079
+ throw new LanesError("blocked_escalation_ancestor_unavailable", `Could not read ancestor lane ${laneId} from the primary hierarchy`, {
9080
+ lane_id: laneId,
9081
+ command_id: dispatched.commandId,
9082
+ exit_code: dispatched.exitCode,
9083
+ });
9084
+ }
9085
+ const remote = parseRemoteJson(dispatched.stdout, "Blocked escalation ancestor");
9086
+ if (remote?.ok !== true || remote.action !== "show" || remote.lane === undefined) {
9087
+ throw new LanesError("blocked_escalation_ancestor_invalid", `Primary did not return ancestor lane ${laneId}`, { lane_id: laneId });
9088
+ }
9089
+ const ancestor = parseLaneRecord(normalizeLaneRecordView(remote.lane));
9090
+ if (ancestor.lane_id !== laneId) {
9091
+ throw new LanesError("blocked_escalation_ancestor_invalid", `Primary returned a different lane for ancestor lookup ${laneId}`, {
9092
+ requested_lane_id: laneId,
9093
+ returned_lane_id: ancestor.lane_id,
9094
+ });
9095
+ }
9096
+ return ancestor;
9097
+ },
8940
9098
  ...(opts.terminalKeepAliveCandidate !== undefined
8941
9099
  ? { terminalKeepAliveCandidate: opts.terminalKeepAliveCandidate }
8942
9100
  : {}),
@@ -9413,6 +9571,7 @@ export function registerLanesCommand(parent, deps = {}) {
9413
9571
  .requiredOption("--brief-file <path>", "Path to the lane brief")
9414
9572
  .option("--worker <name>", "HQ worker or reusable profile id")
9415
9573
  .option("--fresh-thread", "Skip worker continuity and start a cold provider session")
9574
+ .option("--loop", "Keep one worker lane registered to process queued phase envelopes")
9416
9575
  .option("--provider <name>", "claude | codex | grok")
9417
9576
  .option("--account <id>", "Provider account id")
9418
9577
  .option("--model <id>", "Model id for the provider CLI")
@@ -9435,6 +9594,62 @@ export function registerLanesCommand(parent, deps = {}) {
9435
9594
  .action(wrap(async (laneId, opts) => {
9436
9595
  await runApproveOwnerTarget(ctx, laneId, opts);
9437
9596
  }));
9597
+ lanes
9598
+ .command("_loop-worker", { hidden: true })
9599
+ .argument("<lane-id>", "Loop lane id")
9600
+ .action(wrap(async (laneId) => {
9601
+ await runLaneLoopController(ctx, laneId, {
9602
+ resumeLane: (id, options) => resumeHandlers.runResume(ctx, id, options),
9603
+ interruptLane: (id, reason, options) => runInterrupt(ctx, id, reason, options),
9604
+ emitTerminal: (lane) => emitLaneTerminalStateSoft(ctx, lane),
9605
+ });
9606
+ }));
9607
+ lanes
9608
+ .command("enqueue")
9609
+ .description("Validate and queue one phase envelope for a loop lane")
9610
+ .argument("<lane-id>", "Loop lane id")
9611
+ .requiredOption("--envelope <file>", "Generic phase or hq-phase-envelope/v1 JSON file")
9612
+ .action(wrap(async (laneId, opts) => {
9613
+ const hqRoot = ctx.hqRoot();
9614
+ const lane = readLane(hqRoot, laneId);
9615
+ if (lane.loop_mode !== true)
9616
+ throw new LanesError("lane_not_loop_mode", `Lane ${laneId} is not a loop lane`, { lane_id: laneId });
9617
+ const authorization = callerSeniorAuthorization(ctx, lane.senior);
9618
+ if (!authorization.authorized)
9619
+ throw new LanesError("lane_enqueue_unauthorized", `Only the lane's senior may enqueue phases`, { lane_id: laneId, reason: authorization.reason });
9620
+ assertLaneRunsHereForAdmission(ctx, lane);
9621
+ const enabled = await readLanesRegistryFlagFailOpen(LANE_CONDUCT_LOOP_FLAG, { company: lane.company ?? lane.mesh_company, defaultValue: true, env: ctx.env });
9622
+ if (!enabled)
9623
+ throw new LanesError("loop_lanes_disabled", `Loop lanes are disabled by the ${LANE_CONDUCT_LOOP_FLAG} kill switch`, { lane_id: laneId });
9624
+ const source = resolveCallerPath(ctx.callerCwd(), opts.envelope ?? "");
9625
+ let envelope;
9626
+ try {
9627
+ envelope = validateLoopEnvelope(JSON.parse(fs.readFileSync(source, "utf8")));
9628
+ }
9629
+ catch (error) {
9630
+ throw new LanesError("loop_envelope_invalid", `Could not read a valid JSON envelope from ${source}: ${error instanceof Error ? error.message : String(error)}`, { lane_id: laneId, path: source });
9631
+ }
9632
+ const envelopeData = envelope;
9633
+ if (envelopeData.engine !== undefined && envelopeData.engine !== lane.provider) {
9634
+ throw new LanesError("loop_envelope_engine_mismatch", `Envelope engine ${String(envelopeData.engine)} does not match lane provider ${lane.provider}`, { lane_id: lane.lane_id, provider: lane.provider });
9635
+ }
9636
+ if (envelopeData.schema === "hq-phase-envelope/v1" && envelopeData.worker_id !== lane.worker) {
9637
+ throw new LanesError("loop_envelope_worker_mismatch", `Envelope worker ${String(envelopeData.worker_id)} does not match lane worker ${lane.worker ?? "<none>"}`, { lane_id: lane.lane_id, worker: lane.worker ?? null });
9638
+ }
9639
+ const runDirPath = runDir(hqRoot, lane.lane_id);
9640
+ const loop = loopStatus(runDirPath, lane.lane_id);
9641
+ if (loop.state === "stopped" || loop.state === "failed") {
9642
+ throw new LanesError("loop_not_running", `Loop lane ${lane.lane_id} is ${loop.state}`, { lane_id: lane.lane_id, loop_state: loop.state });
9643
+ }
9644
+ const controllerAlive = loopControllerIsAlive(runDirPath);
9645
+ const result = enqueueLoopEnvelope({ runDir: runDirPath, laneId: lane.lane_id, envelope, now: ctx.now });
9646
+ if (!controllerAlive && lane.state === "running") {
9647
+ await runInterrupt(ctx, lane.lane_id, "loop controller exited; recovering the queued phase", { preserveLoopQueue: true, quiet: true });
9648
+ }
9649
+ const shouldRestart = !controllerAlive && lane.state !== "queued";
9650
+ const controllerPid = shouldRestart ? startLaneLoopController(ctx, lane.lane_id) : undefined;
9651
+ printJson({ ok: true, action: "enqueue", ...result, lane: lane.lane_id, ...(controllerPid ? { controller_pid: controllerPid } : {}) });
9652
+ }));
9438
9653
  lanes
9439
9654
  .command("reparent-target", { hidden: true })
9440
9655
  .argument("<lane-id>", "Lane id")
@@ -9701,6 +9916,31 @@ export function registerLanesCommand(parent, deps = {}) {
9701
9916
  .action(wrap(async (laneId) => {
9702
9917
  printJson(await messageHandlers.recoverQueuedMessages(ctx, laneId));
9703
9918
  }));
9919
+ lanes
9920
+ .command("_enqueue-blocked-escalation <recipient-kind> <recipient-id>", { hidden: true })
9921
+ .option("--text <text>", "Escalation notice")
9922
+ .option("--message-id <id>", "Idempotency key")
9923
+ .action(wrap(async (recipientKind, recipientId, opts) => {
9924
+ if (!ctx.isSsmOwnerDispatch())
9925
+ throw new LanesError("blocked_escalation_dispatch_forbidden", "Blocked escalation inbox writes require owner dispatch");
9926
+ if (recipientKind !== "lane" && recipientKind !== "session")
9927
+ throw new LanesError("blocked_escalation_recipient_invalid", `Unknown recipient kind ${recipientKind}`);
9928
+ if (!opts.text?.trim() || !opts.messageId?.trim())
9929
+ throw new LanesError("blocked_escalation_payload_invalid", "Escalation inbox write requires text and message id");
9930
+ const message = { at: ctx.now().toISOString(), message_id: opts.messageId, kind: "blocked_escalation", text: opts.text };
9931
+ if (recipientKind === "lane") {
9932
+ appendDropboxMessageOnce(ctx.hqRoot(), recipientId, message);
9933
+ }
9934
+ else {
9935
+ const dirs = ["claude", "codex", "grok"]
9936
+ .map((provider) => monitorSessionDir(ctx.hqRoot(), provider, recipientId))
9937
+ .filter((dir) => ctx.existsSync(dir));
9938
+ if (dirs.length !== 1)
9939
+ throw new LanesError("blocked_escalation_recipient_unavailable", `Expected one durable inbox for session ${recipientId}; found ${dirs.length}`);
9940
+ appendDropboxMessageOnceAtPath(path.join(dirs[0], "dropbox"), message);
9941
+ }
9942
+ printJson({ ok: true, recipient_kind: recipientKind, recipient_id: recipientId, message_id: opts.messageId });
9943
+ }));
9704
9944
  lanes
9705
9945
  .command("_spawn-envelope-repair", { hidden: true })
9706
9946
  .action(wrap(async () => {
@@ -144,6 +144,8 @@ export type LaunchTrigger = {
144
144
  kind: "resume";
145
145
  /** Lane state at the moment of the command; recorded as `last_resume.resumed_from`. */
146
146
  from: ResumeCommandState | "failed" | "awaiting_input";
147
+ /** Loop phases may deliberately start a fresh session at a story boundary. */
148
+ fresh?: true;
147
149
  allowAwaitingInput?: true;
148
150
  fromFailed?: true;
149
151
  /** Senior that authorized a failed-lane resume, rechecked at the claim CAS. */
@@ -164,6 +166,7 @@ export type LaunchPlan = {
164
166
  export declare function resumeCommandRefusal(lane: DeliveryLane, options?: {
165
167
  allowFailed?: boolean;
166
168
  allowAwaitingInput?: boolean;
169
+ allowFresh?: boolean;
167
170
  }): {
168
171
  error: "lane_not_resumable" | "resume_unsupported" | "no_provider_session";
169
172
  message: string;
@@ -371,6 +371,8 @@ export function resumeCommandRefusal(lane, options = {}) {
371
371
  : "a lane in this state is not resumed."}`,
372
372
  };
373
373
  }
374
+ if (options.allowFresh === true)
375
+ return null;
374
376
  if (!providerSupportsSessionResume(lane.provider)) {
375
377
  return {
376
378
  error: "resume_unsupported",
@@ -411,7 +413,10 @@ export function planLaneLaunch(lane, trigger) {
411
413
  const refusal = resumeCommandRefusal(lane, {
412
414
  allowFailed: trigger.fromFailed === true || isFailedQuotaKeepAliveLane(lane),
413
415
  allowAwaitingInput: trigger.allowAwaitingInput === true,
416
+ allowFresh: trigger.fresh === true,
414
417
  });
418
+ if (trigger.fresh === true && refusal === null)
419
+ return { mode: "fresh" };
415
420
  if (refusal !== null || !hasRecordedSessionId(lane)) {
416
421
  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 });
417
422
  }
@@ -90,8 +90,11 @@ export interface ToolActivityRecord {
90
90
  export declare function recordToolActivity(hqRoot: string, laneId: string, at?: string): ToolActivityRecord;
91
91
  export declare function readToolActivity(hqRoot: string, laneId: string): ToolActivityRecord | null;
92
92
  export declare function appendDropboxMessage(hqRoot: string, laneId: string, message: DropboxMessage): DropboxMessage;
93
+ export declare function appendDropboxMessageOnce(hqRoot: string, laneId: string, message: DropboxMessage): DropboxMessage;
93
94
  /** Append using the same on-disk layout for lane and monitor-session inboxes. */
94
95
  export declare function appendDropboxMessageAtPath(box: string, message: DropboxMessage): DropboxMessage;
96
+ /** Append an idempotent message once, including retries after its inbox was archived. */
97
+ export declare function appendDropboxMessageOnceAtPath(box: string, message: DropboxMessage): DropboxMessage;
95
98
  /**
96
99
  * Drain this lane's inbox exactly once. Concurrent callers: one rename wins;
97
100
  * the loser sees ENOENT and returns whatever it separately claimed (usually
@@ -7,7 +7,7 @@
7
7
  * A read-then-truncate is not this mechanism and loses or duplicates under
8
8
  * overlap.
9
9
  */
10
- import { randomBytes } from "node:crypto";
10
+ import { createHash, randomBytes } from "node:crypto";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { LanesError } from "./errors.js";
@@ -179,6 +179,9 @@ export function readToolActivity(hqRoot, laneId) {
179
179
  export function appendDropboxMessage(hqRoot, laneId, message) {
180
180
  return appendDropboxMessageAtPath(dropboxDir(hqRoot, laneId), message);
181
181
  }
182
+ export function appendDropboxMessageOnce(hqRoot, laneId, message) {
183
+ return appendDropboxMessageOnceAtPath(dropboxDir(hqRoot, laneId), message);
184
+ }
182
185
  /** Append using the same on-disk layout for lane and monitor-session inboxes. */
183
186
  export function appendDropboxMessageAtPath(box, message) {
184
187
  const inbox = path.join(box, INBOX_NAME);
@@ -186,6 +189,105 @@ export function appendDropboxMessageAtPath(box, message) {
186
189
  fs.appendFileSync(inbox, `${JSON.stringify(message)}\n`, "utf8");
187
190
  return message;
188
191
  }
192
+ /** Append an idempotent message once, including retries after its inbox was archived. */
193
+ export function appendDropboxMessageOnceAtPath(box, message) {
194
+ const messageId = message.message_id?.trim();
195
+ if (!messageId) {
196
+ throw new LanesError("dropbox_message_id_required", "Idempotent dropbox delivery requires a message id");
197
+ }
198
+ fs.mkdirSync(box, { recursive: true });
199
+ const digest = createHash("sha256").update(messageId).digest("hex");
200
+ const lockDir = path.join(box, `.message-${digest}.lock`);
201
+ const lockTimeoutAt = Date.now() + 5_000;
202
+ while (true) {
203
+ try {
204
+ fs.mkdirSync(lockDir);
205
+ break;
206
+ }
207
+ catch (error) {
208
+ if (error.code !== "EEXIST")
209
+ throw error;
210
+ try {
211
+ if (Date.now() - fs.statSync(lockDir).mtimeMs > 30_000) {
212
+ fs.rmSync(lockDir, { recursive: true, force: true });
213
+ continue;
214
+ }
215
+ }
216
+ catch (statError) {
217
+ if (statError.code === "ENOENT")
218
+ continue;
219
+ throw statError;
220
+ }
221
+ if (Date.now() >= lockTimeoutAt) {
222
+ throw new LanesError("dropbox_message_claim_busy", `Timed out claiming message ${messageId}`, { message_id: messageId });
223
+ }
224
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
225
+ }
226
+ }
227
+ try {
228
+ const inbox = path.join(box, INBOX_NAME);
229
+ const files = [inbox, ...messageHistoryFiles(box)];
230
+ for (const file of files) {
231
+ let content;
232
+ try {
233
+ content = fs.readFileSync(file, "utf8");
234
+ }
235
+ catch (error) {
236
+ if (error.code === "ENOENT")
237
+ continue;
238
+ throw error;
239
+ }
240
+ for (const line of content.split(/\r?\n/)) {
241
+ if (!line.trim())
242
+ continue;
243
+ let parsed;
244
+ try {
245
+ parsed = JSON.parse(line);
246
+ }
247
+ catch (error) {
248
+ throw new LanesError("dropbox_message_history_invalid", `Cannot verify message ${messageId} against ${file}`, {
249
+ message_id: messageId,
250
+ path: file,
251
+ cause: error instanceof Error ? error.message : String(error),
252
+ });
253
+ }
254
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) &&
255
+ parsed.message_id === messageId)
256
+ return message;
257
+ }
258
+ }
259
+ fs.appendFileSync(inbox, `${JSON.stringify(message)}\n`, "utf8");
260
+ return message;
261
+ }
262
+ finally {
263
+ fs.rmSync(lockDir, { recursive: true, force: true });
264
+ }
265
+ }
266
+ function messageHistoryFiles(box) {
267
+ const files = [];
268
+ for (const directory of [DRAINING_DIR, INFLIGHT_DIR, DELIVERED_DIR]) {
269
+ const root = path.join(box, directory);
270
+ const visit = (current) => {
271
+ let entries;
272
+ try {
273
+ entries = fs.readdirSync(current, { withFileTypes: true });
274
+ }
275
+ catch (error) {
276
+ if (error.code === "ENOENT")
277
+ return;
278
+ throw error;
279
+ }
280
+ for (const entry of entries) {
281
+ if (entry.isDirectory())
282
+ visit(path.join(current, entry.name));
283
+ else if (entry.isFile())
284
+ files.push(path.join(current, entry.name));
285
+ }
286
+ };
287
+ visit(root);
288
+ }
289
+ return files;
290
+ }
189
291
  function listNames(dir, deps) {
190
292
  try {
191
293
  return deps.readdirSync(dir).filter((name) => !name.startsWith("."));