@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/README.md +64 -3
- package/dist/adapters/acp.cjs +2 -0
- package/dist/adapters/antigravity.cjs +2 -1
- package/dist/adapters/claude.cjs +242 -1
- package/dist/adapters/codex.cjs +2 -0
- package/dist/adapters/gemini.cjs +2 -1
- package/dist/adapters/muse.cjs +2 -1
- package/dist/adapters/opencode.cjs +2 -0
- package/dist/adapters/pi-local.cjs +2 -1
- package/dist/adapters/pi.cjs +2 -0
- package/dist/adapters/qoder.cjs +2 -0
- package/dist/adapters/types.cjs +19 -0
- package/dist/api.cjs +31 -2
- package/dist/claim-loop.cjs +7 -0
- package/dist/commit-attribution.cjs +124 -0
- package/dist/config.cjs +1 -0
- package/dist/git-worktree.cjs +8 -2
- package/dist/prompt.cjs +66 -2
- package/dist/session.cjs +249 -14
- package/dist/steering.cjs +458 -0
- package/package.json +1 -1
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, {
|
|
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
|
-
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
|
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
|
-
|
|
1053
|
-
|
|
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();
|