@sagentlab/navarch-runtime 0.1.129 → 0.1.131

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/dist/session.cjs CHANGED
@@ -7,6 +7,7 @@ exports.runSession = runSession;
7
7
  exports.gitGrantCredentialHelper = gitGrantCredentialHelper;
8
8
  exports.toEnvMap = toEnvMap;
9
9
  const muse_cjs_1 = require("./adapters/muse.cjs");
10
+ const node_crypto_1 = require("node:crypto");
10
11
  const node_path_1 = __importDefault(require("node:path"));
11
12
  const node_perf_hooks_1 = require("node:perf_hooks");
12
13
  const node_fs_1 = require("node:fs");
@@ -30,6 +31,7 @@ const github_pr_cjs_1 = require("./github-pr.cjs");
30
31
  const delivery_cjs_1 = require("./delivery.cjs");
31
32
  const worktree_guard_cjs_1 = require("./worktree-guard.cjs");
32
33
  const adapter_watchdog_cjs_1 = require("./adapter-watchdog.cjs");
34
+ const steering_cjs_1 = require("./steering.cjs");
33
35
  const lease_heartbeat_cjs_1 = require("./lease-heartbeat.cjs");
34
36
  const completion_retry_cjs_1 = require("./completion-retry.cjs");
35
37
  const adapter_capacity_cjs_1 = require("./adapter-capacity.cjs");
@@ -38,6 +40,7 @@ const agent_model_cjs_1 = require("./agent-model.cjs");
38
40
  const github_credential_cjs_1 = require("./github-credential.cjs");
39
41
  const browser_evidence_cjs_1 = require("./browser-evidence.cjs");
40
42
  const git_grant_cjs_1 = require("./git-grant.cjs");
43
+ const commit_attribution_cjs_1 = require("./commit-attribution.cjs");
41
44
  /** Filename the generated platform MCP config is written under inside the session metadata directory. */
42
45
  const MCP_CONFIG_FILENAME = "mcp-config.json";
43
46
  const GIT_CREDENTIAL_HELPER_FILENAME = "git-credential-navarch.cjs";
@@ -50,6 +53,20 @@ const COMPLETION_REMEDIATION_RETRIES = 2;
50
53
  * and one must not consume the other's budget.
51
54
  */
52
55
  const DELIVERY_CORRECTION_RETRIES = 1;
56
+ /**
57
+ * Times one completion is re-sent after the control plane answered
58
+ * `steering_pending` while this runtime held nothing to deliver: the pending
59
+ * messages were being acked, or were handed to the next session by the last
60
+ * re-send. A message the runtime does hold gets another turn instead.
61
+ */
62
+ const STEERING_COMPLETION_RESENDS = 3;
63
+ const STEERING_COMPLETION_RESEND_DELAY_MS = 1_000;
64
+ /**
65
+ * How long an agent process may run before the messages in its prompt count as
66
+ * delivered, when it has printed nothing yet. A launch that fails does so
67
+ * within milliseconds; the control plane gives an interrupt 90 s to be acked.
68
+ */
69
+ const STEERING_START_GRACE_MS = 5_000;
53
70
  const log = (0, logger_cjs_1.createLogger)("session");
54
71
  function resolveSessionExecution(config, claimed) {
55
72
  const runtime = claimed.runtime ?? config.agentType;
@@ -248,6 +265,11 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
248
265
  };
249
266
  }
250
267
  const sessionEnv = toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail, gitGrantEnv);
268
+ // Proxied projects (CX-3.7): the control-plane identity replaces the
269
+ // NAVARCH_GIT_AUTHOR_* default for this session; mode `off` sends none.
270
+ const commitIdentity = (0, commit_attribution_cjs_1.parseCommitIdentity)(bundle.repository?.commit_identity);
271
+ if (commitIdentity)
272
+ (0, commit_attribution_cjs_1.applyCommitIdentityEnv)(sessionEnv, commitIdentity);
251
273
  const ghConfigDir = (0, worktree_guard_cjs_1.sessionGhConfigDir)(workDir);
252
274
  await node_fs_1.promises.mkdir(ghConfigDir, { recursive: true, mode: 0o700 });
253
275
  sessionEnv.GH_CONFIG_DIR = ghConfigDir;
@@ -305,6 +327,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
305
327
  : undefined,
306
328
  // Hosted runs skip checkpoints for verify tasks, which deliver a verdict.
307
329
  restoreHostedCheckpoint: !allowBranchRename && task.task_type !== "verify",
330
+ ...(commitIdentity ? { ownershipCommitIdentity: commitIdentity.committer } : {}),
308
331
  });
309
332
  const knownGuidanceIds = new Set((bundle.guidance ?? []).map((entry) => entry.id));
310
333
  const deliveredGuidance = [...(bundle.guidance ?? [])];
@@ -326,7 +349,52 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
326
349
  let heartbeatInFlight = null;
327
350
  let heartbeatWorkspaceUpdate = null;
328
351
  let completingLease = false;
352
+ let adapterRunning = false;
329
353
  const sessionOutcome = {};
354
+ // Live steering (docs/navarch/live-steering.md): messages a human sent to
355
+ // this task arrive through the heartbeat and the steering long-poll. An
356
+ // adapter without a capability record, or NAVARCH_LIVE_STEERING=off, is a
357
+ // legacy runtime: it sends nothing and keeps the `guidance` handling below.
358
+ const adapter = (0, index_cjs_1.selectAdapter)(runtime);
359
+ let steeringCapabilities = (0, steering_cjs_1.liveSteeringCapabilities)(adapter, config);
360
+ // Native steering: ids of messages the running agent has read during the
361
+ // current turn. The conversation holds them, so a resumed turn must not be
362
+ // sent them again, while a fresh invocation's full brief still lists them.
363
+ const steeredThisTurn = [];
364
+ // A turn was stopped to deliver an interrupt. The message can be canceled
365
+ // after that, so the session has to run another turn even with none held.
366
+ let steeringStoppedTurn = false;
367
+ // Marks a genuine live message to an agent that takes native steering. It is
368
+ // per lease and never leaves the prompt, so registering it keeps it out of
369
+ // transcripts, reports and logs even if the agent repeats it.
370
+ const steeringToken = `nvl-${(0, node_crypto_1.randomBytes)(6).toString("hex")}`;
371
+ registry.register(steeringToken);
372
+ const steering = steeringCapabilities && (0, steering_cjs_1.canTakeLiveMessages)(steeringCapabilities)
373
+ ? new steering_cjs_1.SteeringSession({
374
+ transport: api,
375
+ leaseId,
376
+ capabilities: steeringCapabilities,
377
+ token: steeringToken,
378
+ // Stop only an agent turn. An interrupt must not abort worktree
379
+ // preparation or the dependency install that runs before the first turn.
380
+ interruptTurn: () => {
381
+ if (!adapterRunning || leaseLost || sessionClosing)
382
+ return false;
383
+ steeringStoppedTurn = true;
384
+ activeAbortController?.abort();
385
+ return true;
386
+ },
387
+ onSteerConsumed: (entry) => {
388
+ knownGuidanceIds.add(entry.id);
389
+ deliveredGuidance.push(entry);
390
+ steeredThisTurn.push(entry.id);
391
+ },
392
+ })
393
+ : undefined;
394
+ // The record last acknowledged by a heartbeat. The claim carried the primary
395
+ // adapter's record, and this lease may run another advertised runtime or
396
+ // have an adapter probe upgrade it, so the first heartbeat sends it too.
397
+ let reportedSteeringCapabilities;
330
398
  const checkpointForRecovery = async (result, reason) => {
331
399
  if (result.checkpointPath || result.retainedWorktreePath)
332
400
  return;
@@ -345,12 +413,21 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
345
413
  return Promise.resolve();
346
414
  if (heartbeatInFlight)
347
415
  return heartbeatInFlight;
416
+ const sentSteeringCapabilities = steeringCapabilities;
348
417
  const request = api
349
- .heartbeatLease(leaseId, { guidance_after: guidanceCursor })
418
+ .heartbeatLease(leaseId, {
419
+ guidance_after: guidanceCursor,
420
+ ...(steering ? { steering_after_seq: steering.cursor } : {}),
421
+ ...(steering && sentSteeringCapabilities !== reportedSteeringCapabilities
422
+ ? { steering_capabilities: sentSteeringCapabilities }
423
+ : {}),
424
+ })
350
425
  .then(async (heartbeat) => {
351
426
  if (sessionClosing)
352
427
  return;
353
428
  heartbeatGuard.renew(heartbeat.expires_at);
429
+ if (steering)
430
+ reportedSteeringCapabilities = sentSteeringCapabilities;
354
431
  if (leaseLost)
355
432
  return;
356
433
  heartbeatWorkspaceUpdate = (0, worktree_janitor_cjs_1.markSessionWorkspaceActive)(workDir);
@@ -371,6 +448,14 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
371
448
  log.info(`received ${fresh.length} guidance update${fresh.length === 1 ? "" : "s"} for ${leaseId}; restarting the agent turn in the same worktree.`);
372
449
  activeAbortController?.abort();
373
450
  }
451
+ // After the legacy guidance so a message that interrupts stops the turn once.
452
+ try {
453
+ steering?.ingest(heartbeat.steering, heartbeat.steering_cursor);
454
+ }
455
+ catch (error) {
456
+ // A malformed steering field is not a lease failure: the long-poll and the next heartbeat read again.
457
+ log.warn(`could not read the steering messages in the heartbeat for ${leaseId}: ${String(error)}`);
458
+ }
374
459
  })
375
460
  .catch((err) => {
376
461
  if (leaseLost || sessionClosing)
@@ -536,7 +621,6 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
536
621
  let outputReportedAt = 0;
537
622
  let outputUpdates = 0;
538
623
  let agentTurnStartedAt = 0;
539
- let adapterRunning = false;
540
624
  const reportActivity = (phase, detail) => {
541
625
  activityPending = activityPending.then(() => api.reportActivity(leaseId, phase, detail)).catch((error) => {
542
626
  log.warn(`activity update failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(String(error), registry.list())}`);
@@ -567,6 +651,34 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
567
651
  let liveTranscript;
568
652
  try {
569
653
  reportActivity("preparing");
654
+ if (steering && adapter.probeSteering) {
655
+ // The claim carried the adapter's baseline record. A probe that finds the
656
+ // installed CLI can do more (native steering) upgrades it. The upgrade is
657
+ // reported before the long-poll starts, so no message resolves as `steer`
658
+ // before the probe has passed, or as `queue` after it has.
659
+ const probed = adapterCommand(config, runtime);
660
+ const upgraded = await adapter
661
+ .probeSteering({ bin: probed.bin, extraArgs: probed.extraArgs, env: sessionEnv, sandboxMode: config.sandboxMode })
662
+ .catch(() => null);
663
+ if (upgraded && !leaseLost) {
664
+ steeringCapabilities = upgraded;
665
+ steering.setCapabilities(upgraded);
666
+ if (upgraded.steer === "native") {
667
+ // A native steer reaches the agent next to a tool result, where a
668
+ // model discards it as untrusted tool output unless the brief says
669
+ // how to recognise a real one. This goes in before any message can
670
+ // be dispatched against the upgraded record.
671
+ promptText = `${promptText}\n${(0, prompt_cjs_1.steeringPromptNote)(steeringToken)}`;
672
+ await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), promptText, "utf8");
673
+ }
674
+ await pollLease();
675
+ // An already running heartbeat can answer for the older record.
676
+ if (reportedSteeringCapabilities !== upgraded)
677
+ await pollLease();
678
+ log.info(`steering capabilities for ${leaseId}: steer ${upgraded.steer}, queue ${upgraded.queue}, interrupt ${upgraded.interrupt}.`);
679
+ }
680
+ }
681
+ steering?.start();
570
682
  await gitWorktree.prepare();
571
683
  if (!config.mcpConfigPath && config.sandboxMode === "host"
572
684
  && ["build", "verify", "qa"].includes(task.task_type)
@@ -672,7 +784,6 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
672
784
  // All adapters implement the same AgentAdapter.run() shape
673
785
  // (adapters/types.cts), so nothing else in this function branches on
674
786
  // which agent is running.
675
- const adapter = (0, index_cjs_1.selectAdapter)(runtime);
676
787
  const { bin, extraArgs } = adapterCommand(config, runtime);
677
788
  const attempts = [];
678
789
  // The task view follows the run through periodic redacted snapshots of
@@ -719,6 +830,33 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
719
830
  activeAbortController = turnAbort;
720
831
  if (leaseLost || deps.shutdownSignal?.aborted)
721
832
  turnAbort.abort();
833
+ // Live-steering messages that arrived during preparation, while the last
834
+ // turn ran, or after it ended ride in this turn's prompt as mid-flight
835
+ // guidance, so the resume and full-brief paths below carry them like any
836
+ // other guidance. They are acked once the agent process has started.
837
+ steeringStoppedTurn = false;
838
+ const steeringTaken = steering?.takeForTurn() ?? [];
839
+ const steeringGuidance = steering ? steering.toGuidance(steeringTaken) : [];
840
+ if (steering && steeringTaken.length > 0) {
841
+ for (const entry of steeringGuidance)
842
+ knownGuidanceIds.add(entry.id);
843
+ deliveredGuidance.push(...steeringGuidance);
844
+ log.info(`delivering ${steeringTaken.length} steering message${steeringTaken.length === 1 ? "" : "s"} ` +
845
+ `to ${leaseId} (seq ${steeringTaken.map((item) => item.message.seq).join(", ")}).`);
846
+ }
847
+ // The messages count as delivered once the agent has them: its first
848
+ // output, or a short while after its process started without failing. A
849
+ // spawn that fails on the next tick (no such binary) must not count, and a
850
+ // Docker exec has no process-start signal at all.
851
+ let steeringTurnStarted = false;
852
+ let steeringStartTimer;
853
+ const markSteeringStarted = () => {
854
+ if (steeringTurnStarted)
855
+ return;
856
+ steeringTurnStarted = true;
857
+ clearTimeout(steeringStartTimer);
858
+ steering?.markInjected(steeringTaken);
859
+ };
722
860
  // The task brief plus any mid-flight guidance. Correction turns build on
723
861
  // this rather than on the previous turn's prompt so they never nest.
724
862
  const midFlightGuidance = deliveredGuidance.filter((entry) => !briefGuidanceIds.has(entry.id));
@@ -774,7 +912,21 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
774
912
  cwd: gitWorktree.worktreePath,
775
913
  dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
776
914
  signal: turnAbort.signal,
777
- onActivity: () => { outputObservedAt = Date.now(); outputUpdates += 1; },
915
+ // Native steering: the adapter keeps the agent's input open and
916
+ // reports each message the agent reads. Absent for every adapter
917
+ // whose record says `steer: none`.
918
+ liveInput: steering && steeringCapabilities?.steer === "native" ? steering.liveInput : undefined,
919
+ onProcessStarted: () => {
920
+ if (steeringTaken.length > 0 && !steeringTurnStarted) {
921
+ clearTimeout(steeringStartTimer);
922
+ steeringStartTimer = setTimeout(markSteeringStarted, STEERING_START_GRACE_MS);
923
+ }
924
+ },
925
+ onActivity: () => {
926
+ outputObservedAt = Date.now();
927
+ outputUpdates += 1;
928
+ markSteeringStarted();
929
+ },
778
930
  onOutput: (stream, chunk) => {
779
931
  if (stream !== "stdout")
780
932
  return;
@@ -796,6 +948,23 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
796
948
  }
797
949
  activeAbortController = null;
798
950
  adapterRunning = false;
951
+ clearTimeout(steeringStartTimer);
952
+ // The agent process is gone: a message written to it and never read goes
953
+ // back to waiting for the next turn.
954
+ steering?.turnEnded();
955
+ // An agent that ran and ended before the grace period, or whose launcher
956
+ // reports no start (Docker), still got the prompt.
957
+ if (!steeringTurnStarted && (turnResult.exitCode !== null || turnResult.stdout))
958
+ markSteeringStarted();
959
+ if (steering && steeringTaken.length > 0 && !steeringTurnStarted) {
960
+ // No agent process ever ran, so it holds none of them. They go to the
961
+ // next session, and no later prompt of this one may repeat them.
962
+ await steering.failTaken(steeringTaken, "The agent process did not start, so it never read the message.");
963
+ const dropped = new Set(steeringGuidance.map((entry) => entry.id));
964
+ deliveredGuidance.splice(0, deliveredGuidance.length, ...deliveredGuidance.filter((entry) => !dropped.has(entry.id)));
965
+ for (const id of dropped)
966
+ knownGuidanceIds.delete(id);
967
+ }
799
968
  if (turnResult.timedOut || leaseLost)
800
969
  await checkpointForRecovery(turnResult);
801
970
  if (turnResult.model) {
@@ -832,7 +1001,12 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
832
1001
  // mid-flight guidance entry received so far.
833
1002
  conversationGuidanceIds = new Set(midFlightGuidance.map((entry) => entry.id));
834
1003
  }
1004
+ // Messages the agent read mid-turn are in this conversation too.
1005
+ if (resumableSessionId)
1006
+ for (const id of steeredThisTurn)
1007
+ conversationGuidanceIds.add(id);
835
1008
  }
1009
+ steeredThisTurn.length = 0;
836
1010
  attempts.push(turnResult);
837
1011
  liveStdout = "";
838
1012
  liveTranscript.markDirty();
@@ -870,19 +1044,37 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
870
1044
  log.warn(`session ${leaseId} stopped without completion because its lease is no longer active.`);
871
1045
  return sessionOutcome;
872
1046
  }
873
- if (!capacityLimit && !authFailure && !modelUnavailable && !leaseLost && !turnResult.timedOut && pendingGuidance.length > 0) {
874
- // Muse cancellation kills the whole task-owned container. Guidance
875
- // still holds the lease, so restart that SAME container and restore
876
- // its tmpfs environment before the next turn; never launch into a
877
- // stopped sandbox or substitute an unisolated host process.
878
- if (runtime === "muse" && sandbox && turnResult.killedByLeaseLoss) {
1047
+ const canRunAnotherTurn = !capacityLimit && !authFailure && !modelUnavailable && !leaseLost && !turnResult.timedOut;
1048
+ // An interrupt stopped this turn, but the message may have been canceled
1049
+ // since, so nothing is held. The task is not done: carry on from where the
1050
+ // turn stopped instead of completing a session that only lost a turn.
1051
+ const turnStoppedBySteering = steeringStoppedTurn && turnResult.killedByLeaseLoss && !leaseLost;
1052
+ if (canRunAnotherTurn && (pendingGuidance.length > 0 || steering?.hasDeliverable() || turnStoppedBySteering)) {
1053
+ if (turnStoppedBySteering && pendingGuidance.length === 0 && !steering?.hasDeliverable()) {
1054
+ const stopped = "Your previous turn was stopped for a message that was then canceled. Nothing has changed in the task: inspect the worktree (`git status`, recent changes) and continue from where you stopped.";
1055
+ nextCorrection = {
1056
+ full: (brief) => `${brief.trimEnd()}\n\n${stopped}\n`,
1057
+ resumed: `${(0, prompt_cjs_1.renderResumedTurnPreamble)(resumedTask)}\n\n${stopped}\n`,
1058
+ };
1059
+ }
1060
+ // Cancelling a Docker agent kills the whole task-owned container (every
1061
+ // adapter's Docker path does, Muse's included). The lease is still held,
1062
+ // so restart that SAME container and restore its tmpfs environment before
1063
+ // the next turn; never launch into a stopped sandbox or substitute an
1064
+ // unisolated host process.
1065
+ if (sandbox && turnResult.killedByLeaseLoss) {
879
1066
  const restarted = await sandbox_cjs_1.nodeCommandRunner.run("docker", ["start", sandbox.name], { timeoutMs: 10_000 });
880
1067
  if (restarted.code !== 0)
881
- throw new Error("Could not restart the task-owned Muse sandbox after guidance.");
1068
+ throw new Error("Could not restart the task-owned sandbox after guidance.");
882
1069
  await sandbox.injectEnv(sessionEnv);
883
1070
  }
884
1071
  continue;
885
1072
  }
1073
+ // No further turn can read a message that is still waiting, so give it to
1074
+ // the next session now and say why, instead of leaving it to the lease end.
1075
+ // A lost lease needs no ack: its release already moved them there.
1076
+ if (!leaseLost)
1077
+ await steering?.failHeld("The session ended before the message could be delivered to the agent.");
886
1078
  const result = {
887
1079
  ...turnResult,
888
1080
  tokensIn: sumReportedUsage(attempts, "tokensIn", runtime === "muse"),
@@ -1047,11 +1239,51 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
1047
1239
  cost: completion.cost,
1048
1240
  transcript_url: completion.transcript_url,
1049
1241
  };
1242
+ if (steering && !leaseLost) {
1243
+ if (completion.status === "completed" && steering.hasDeliverable()) {
1244
+ // A message arrived while the delivery checks ran: it gets its turn
1245
+ // before the lease completes.
1246
+ log.info(`steering message received while finishing ${leaseId}; running another turn before completing.`);
1247
+ continue;
1248
+ }
1249
+ // A failed outcome has no further turn, so the next session reads it.
1250
+ await steering.failHeld("The session ended before the message could be delivered to the agent.");
1251
+ }
1252
+ // The control plane refuses a completion while live messages are
1253
+ // undelivered (409 steering_pending, section 6.6). A message this runtime
1254
+ // holds gets another turn; anything else was being acked or is handed to
1255
+ // the next session, and the completion is sent again.
1256
+ const sendCompletion = async () => {
1257
+ for (let resend = 0;; resend += 1) {
1258
+ try {
1259
+ await (0, completion_retry_cjs_1.completeWithTransientRetry)(() => api.completeLease(leaseId, completion), {
1260
+ onRetry: (attempt, delayMs, err) => log.warn(`completion for ${leaseId} did not reach the control plane (${(0, redact_cjs_1.redactText)(String(err), knownSecrets)}); re-sending in ${delayMs / 1000}s (${attempt}).`),
1261
+ });
1262
+ return "sent";
1263
+ }
1264
+ catch (err) {
1265
+ const listed = (0, steering_cjs_1.steeringPendingMessages)(err);
1266
+ if (!listed || !steering || leaseLost)
1267
+ throw err;
1268
+ await steering.flush();
1269
+ // From the start, so a message dispatched but never received is read now.
1270
+ await steering.pullNow(0).catch((pullError) => log.warn(`steering pull before completion failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(String(pullError), knownSecrets)}`));
1271
+ if (steering.hasDeliverable())
1272
+ return "steering_turn";
1273
+ if (resend >= STEERING_COMPLETION_RESENDS)
1274
+ throw err;
1275
+ if (resend === STEERING_COMPLETION_RESENDS - 1) {
1276
+ log.warn(`completion for ${leaseId} still lists ${listed.length} steering message(s); handing them to the next session.`);
1277
+ await steering.failListed(listed, "The runtime could not deliver the message before the session completed.");
1278
+ }
1279
+ await new Promise((resolve) => setTimeout(resolve, STEERING_COMPLETION_RESEND_DELAY_MS));
1280
+ }
1281
+ }
1282
+ };
1050
1283
  try {
1051
1284
  completingLease = true;
1052
- await (0, completion_retry_cjs_1.completeWithTransientRetry)(() => api.completeLease(leaseId, completion), {
1053
- onRetry: (attempt, delayMs, err) => log.warn(`completion for ${leaseId} did not reach the control plane (${(0, redact_cjs_1.redactText)(String(err), knownSecrets)}); re-sending in ${delayMs / 1000}s (${attempt}).`),
1054
- });
1285
+ if (await sendCompletion() === "steering_turn")
1286
+ continue;
1055
1287
  if (deliveredPullRequest && completion.status === "completed" && gitWorktree.restoredHostedCheckpoint) {
1056
1288
  // The pull request carries the hosted attempt's work now; a later
1057
1289
  // hosted attempt must not resume the stale snapshot.
@@ -1142,6 +1374,8 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
1142
1374
  // locally. NavarchApiError.message contains only the HTTP status line; its
1143
1375
  // response body carries the actionable server error shown in session UI.
1144
1376
  const failureSummary = `Session crashed: ${crashDetail}`;
1377
+ if (!leaseLost)
1378
+ await steering?.failHeld("The session crashed before the message could be delivered to the agent.").catch(() => undefined);
1145
1379
  await api
1146
1380
  .completeLease(leaseId, {
1147
1381
  status: "failed",
@@ -1164,6 +1398,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
1164
1398
  await liveTranscript?.stop();
1165
1399
  deps.shutdownSignal?.removeEventListener("abort", abortForRuntimeShutdown);
1166
1400
  sessionClosing = true;
1401
+ steering?.stop();
1167
1402
  clearInterval(heartbeatTimer);
1168
1403
  clearInterval(activityTimer);
1169
1404
  heartbeatGuard.stop();