@sagentlab/navarch-runtime 0.1.121 → 0.1.122

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 CHANGED
@@ -278,16 +278,23 @@ instance during an automatic-update handoff.
278
278
  Guidance added to a task that is already running is delivered on that
279
279
  task's next lease heartbeat (every
280
280
  `NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS`, five minutes by default). The runtime
281
- stops the current agent process and starts a **new agent turn** with the
282
- original task context plus all guidance received so far. This is a turn
283
- restart, not a new dispatch:
281
+ stops the current agent process and starts a **new agent turn**. This is a
282
+ turn restart, not a new dispatch:
284
283
 
285
284
  - The task keeps the same session and lease, and lease heartbeats continue.
286
285
  - The replacement turn uses the same worktree (and the same sandbox container
287
286
  in Docker mode), so committed and uncommitted changes from the interrupted
288
287
  turn remain available. It should inspect those changes before continuing.
289
- - The replacement turn is not a resumed agent CLI conversation.
290
- The corrected prompt carries the prior task context and guidance instead.
288
+ - Claude Code and Codex resume the interrupted turn's CLI conversation
289
+ (`claude -p --resume <session>`, `codex exec resume <thread> -`) and are
290
+ sent only the task's one-line summary and the guidance that conversation has
291
+ not seen. Delivery-correction and completion-remediation turns resume the
292
+ same way. Every other agent, a turn that left no session id, and a resume
293
+ the CLI refuses before starting get a fresh invocation with the full task
294
+ context plus every guidance entry received since the claim. Guidance present
295
+ at claim time is already in the task context and is not repeated.
296
+ `prompt.md` in the session directory always holds what the last invocation
297
+ was actually sent.
291
298
  - Transcript, token, and cost accounting is cumulative across turns: the
292
299
  upload includes every turn under a separate label, and reported token and
293
300
  cost totals include every turn. The lease is completed only after the
@@ -12,6 +12,12 @@ const FALLBACK_CLAUDE_MODEL = "claude-opus-5-5";
12
12
  function hasFlag(args, flag) {
13
13
  return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
14
14
  }
15
+ const CLAUDE_SESSION_ID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
16
+ /** Operator flags that pick the conversation themselves; Navarch then never resumes one. */
17
+ const OPERATOR_SESSION_FLAGS = ["--resume", "-r", "--continue", "-c", "--session-id", "--fork-session"];
18
+ function operatorOwnsSession(extraArgs) {
19
+ return OPERATOR_SESSION_FLAGS.some((flag) => hasFlag(extraArgs, flag));
20
+ }
15
21
  /**
16
22
  * Headless Claude Code adapter (project-plan.md §3.9 / implementation-plan.md
17
23
  * WP-07): `claude -p --mcp-config platform-mcp.json --output-format stream-json --verbose`
@@ -53,13 +59,13 @@ async function runClaudeCodeAdapter(options) {
53
59
  if (!parsed?.is_error || !(0, adapter_capacity_cjs_1.detectAdapterCapacityLimit)(primary))
54
60
  return primary;
55
61
  const canResume = typeof parsed.session_id === "string" &&
56
- /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(parsed.session_id) &&
62
+ CLAUDE_SESSION_ID.test(parsed.session_id) &&
57
63
  !hasFlag(options.extraArgs, "--no-session-persistence");
58
64
  // Never replay a task that may already have side effects without its saved
59
65
  // conversation. A structured zero-turn rejection is safe to start afresh.
60
66
  if (!canResume && parsed.num_turns !== 0)
61
67
  return primary;
62
- if (["--resume", "-r", "--continue", "-c", "--session-id", "--fork-session"].some((flag) => hasFlag(options.extraArgs, flag)))
68
+ if (operatorOwnsSession(options.extraArgs))
63
69
  return primary;
64
70
  const fallback = await runClaudeAttempt({
65
71
  ...options,
@@ -68,9 +74,9 @@ async function runClaudeCodeAdapter(options) {
68
74
  prompt: canResume
69
75
  ? "Continue the interrupted task from the saved conversation. Check existing work before taking further action."
70
76
  : options.prompt,
71
- extraArgs: canResume
72
- ? [...options.extraArgs, "--resume", parsed.session_id]
73
- : options.extraArgs,
77
+ // A zero-turn rejection without a session id reruns this same turn, which
78
+ // may itself be a resumed correction turn.
79
+ resumeSessionId: canResume ? parsed.session_id : options.resumeSessionId,
74
80
  }, false);
75
81
  return {
76
82
  ...fallback,
@@ -93,6 +99,19 @@ function addUsage(first, second) {
93
99
  return first === undefined && second === undefined ? undefined : (first ?? 0) + (second ?? 0);
94
100
  }
95
101
  async function runClaudeAttempt(options, automaticFallback) {
102
+ if (options.resumeSessionId &&
103
+ (!CLAUDE_SESSION_ID.test(options.resumeSessionId) || operatorOwnsSession(options.extraArgs))) {
104
+ // Never hand an unvalidated id to argv, and never stack a second
105
+ // conversation selector on an operator's own. Nothing ran.
106
+ return {
107
+ exitCode: null,
108
+ timedOut: false,
109
+ killedByLeaseLoss: false,
110
+ stdout: "",
111
+ stderr: "Navarch did not resume the Claude session: the session id is invalid or the operator selects the conversation.",
112
+ resumeRejected: true,
113
+ };
114
+ }
96
115
  const args = ["-p"];
97
116
  const hasSettingSources = options.extraArgs.some((arg) => arg === "--setting-sources" || arg.startsWith("--setting-sources="));
98
117
  const hasExplicitPermissionMode = options.extraArgs.some((arg) => ["--permission-mode", "--permission-prompt-tool", "--dangerously-skip-permissions"].some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
@@ -145,6 +164,8 @@ async function runClaudeAttempt(options, automaticFallback) {
145
164
  args.push("--verbose");
146
165
  }
147
166
  args.push(...options.extraArgs);
167
+ if (options.resumeSessionId)
168
+ args.push("--resume", options.resumeSessionId);
148
169
  // Project/task policy is appended after machine-wide extra arguments so a
149
170
  // dispatched session consistently uses the settings recorded by Navarch.
150
171
  if (options.model)
@@ -160,7 +181,54 @@ async function runClaudeAttempt(options, automaticFallback) {
160
181
  }
161
182
  : options;
162
183
  const raw = runOptions.dockerExec ? await runViaDocker(runOptions, args) : await runOnHost(runOptions, args);
163
- return attachUsage(raw);
184
+ return attachSession(attachUsage(raw), options);
185
+ }
186
+ /**
187
+ * Reports the conversation this attempt ran in, so a later correction turn in
188
+ * the same lease can `--resume` it and send only what changed. Claude Code
189
+ * prints the session id on its first stream-json record (`system`/`init`) and
190
+ * again on the final result, so a turn cut short by guidance still has one.
191
+ * Nothing is reported when session persistence is off or an operator flag
192
+ * selects the conversation: there is then nothing Navarch may resume.
193
+ *
194
+ * `--resume` with an unknown id exits 1 with a zero-turn error result and no
195
+ * `init` record ("No conversation found with session ID"); that is the only
196
+ * case flagged `resumeRejected`, since no agent work can have started.
197
+ */
198
+ function attachSession(result, options) {
199
+ let sessionId;
200
+ let started = false;
201
+ for (const line of result.stdout.split("\n")) {
202
+ const trimmed = line.trim();
203
+ if (!trimmed.startsWith("{"))
204
+ continue;
205
+ let record;
206
+ try {
207
+ record = JSON.parse(trimmed);
208
+ }
209
+ catch {
210
+ continue;
211
+ }
212
+ if (typeof record !== "object" || record === null || Array.isArray(record))
213
+ continue;
214
+ const { type, subtype, session_id: id, num_turns: turns } = record;
215
+ if ((type === "system" && subtype === "init") || type === "assistant" || type === "user" ||
216
+ (type === "result" && typeof turns === "number" && turns > 0)) {
217
+ started = true;
218
+ }
219
+ if (typeof id === "string" && CLAUDE_SESSION_ID.test(id) && (type === "system" || type === "result")) {
220
+ sessionId = id;
221
+ }
222
+ }
223
+ const resumable = !hasFlag(options.extraArgs, "--no-session-persistence") &&
224
+ !operatorOwnsSession(options.extraArgs);
225
+ const rejected = Boolean(options.resumeSessionId) && !started && result.exitCode !== 0 &&
226
+ !result.timedOut && !result.killedByLeaseLoss;
227
+ return {
228
+ ...result,
229
+ ...(resumable && sessionId && !rejected ? { agentSessionId: sessionId } : {}),
230
+ ...(rejected ? { resumeRejected: true } : {}),
231
+ };
164
232
  }
165
233
  /** Parses the final `claude -p --output-format stream-json` result record for usage and folds it onto the raw result (best-effort; leaves tokensIn/tokensOut/costUsd unset when parsing fails). */
166
234
  function attachUsage(result) {
@@ -282,5 +350,8 @@ function shellQuote(value) {
282
350
  exports.claudeCodeAdapter = {
283
351
  agentType: "claude-code",
284
352
  run: runClaudeCodeAdapter,
353
+ resumesTurns: true,
354
+ // `usage` is per turn; `total_cost_usd` covers the whole resumed conversation.
355
+ cumulativeResumeUsage: ["costUsd"],
285
356
  conversation: claude_conversation_cjs_1.claudeConversationRunner,
286
357
  };
@@ -48,7 +48,22 @@ const kill_agent_cjs_1 = require("../kill-agent.cjs");
48
48
  * approval-bypass flag) without an adapter code change.
49
49
  */
50
50
  async function runCodexAdapter(options) {
51
- const args = ["exec", "-"];
51
+ const resumable = codexCanResume(options.extraArgs);
52
+ if (options.resumeSessionId && (!resumable || !CODEX_THREAD_ID.test(options.resumeSessionId))) {
53
+ return {
54
+ exitCode: null,
55
+ timedOut: false,
56
+ killedByLeaseLoss: false,
57
+ stdout: "",
58
+ stderr: "Navarch did not resume the Codex thread: the thread id is invalid or the operator arguments do not apply to `codex exec resume`.",
59
+ resumeRejected: true,
60
+ };
61
+ }
62
+ // `codex exec resume <thread> -` continues a recorded thread with the
63
+ // stdin prompt (verified against codex-cli 0.163.0). Every other argument
64
+ // below is accepted by both forms, so the guard profile and MCP servers are
65
+ // re-applied identically on the resumed turn.
66
+ const args = options.resumeSessionId ? ["exec", "resume", options.resumeSessionId, "-"] : ["exec", "-"];
52
67
  const env = { ...options.env };
53
68
  if (options.mcpConfigPath)
54
69
  args.push(...(await codexMcpArgs(options.mcpConfigPath, env)));
@@ -68,7 +83,53 @@ async function runCodexAdapter(options) {
68
83
  const raw = runOptions.dockerExec
69
84
  ? await runViaDocker(runOptions, args)
70
85
  : await runOnHost(runOptions, args);
71
- return attachUsage(raw, options.model);
86
+ return attachThread(attachUsage(raw, options.model), options, resumable);
87
+ }
88
+ const CODEX_THREAD_ID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
89
+ /**
90
+ * `codex exec` options that `codex exec resume` does not accept (codex-cli
91
+ * 0.163.0 `--help`), plus `--ephemeral`, which records no thread to resume.
92
+ * An operator argument from this list keeps every turn a fresh `codex exec`.
93
+ */
94
+ const CODEX_NON_RESUMABLE_ARGS = [
95
+ "--ephemeral",
96
+ "--sandbox",
97
+ "-s",
98
+ "--profile",
99
+ "-p",
100
+ "--cd",
101
+ "-C",
102
+ "--add-dir",
103
+ "--oss",
104
+ "--local-provider",
105
+ "--color",
106
+ "--approve-for-me",
107
+ ];
108
+ function codexCanResume(extraArgs) {
109
+ return !extraArgs.some((arg) => CODEX_NON_RESUMABLE_ARGS.some((flag) => arg === flag || (flag.startsWith("--") && arg.startsWith(`${flag}=`))));
110
+ }
111
+ /**
112
+ * Reports the thread this run recorded (`thread.started.thread_id`) so a later
113
+ * correction turn in the same lease can resume it. `codex exec resume` with an
114
+ * unknown id exits 1 before `turn.started` ("no rollout found for thread
115
+ * id"); only that shape is flagged `resumeRejected`, since no agent work ran.
116
+ */
117
+ function attachThread(result, options, resumable) {
118
+ const events = (0, exit_conditions_cjs_1.parseCodexJsonEvents)(result.stdout);
119
+ const started = events.some((event) => event.type === "turn.started");
120
+ let threadId;
121
+ for (const event of events) {
122
+ const id = event.thread_id;
123
+ if (event.type === "thread.started" && typeof id === "string" && CODEX_THREAD_ID.test(id))
124
+ threadId = id;
125
+ }
126
+ const rejected = Boolean(options.resumeSessionId) && !started && result.exitCode !== 0 &&
127
+ !result.timedOut && !result.killedByLeaseLoss;
128
+ return {
129
+ ...result,
130
+ ...(resumable && threadId && !rejected ? { agentSessionId: threadId } : {}),
131
+ ...(rejected ? { resumeRejected: true } : {}),
132
+ };
72
133
  }
73
134
  /** Operator policy wins over Navarch's generated host-mode profile. */
74
135
  function hasExplicitPermissionPolicy(args) {
@@ -274,4 +335,7 @@ function shellQuote(value) {
274
335
  exports.codexAdapter = {
275
336
  agentType: "codex",
276
337
  run: runCodexAdapter,
338
+ resumesTurns: true,
339
+ // `exec resume` reports the thread's total_token_usage, not the turn's.
340
+ cumulativeResumeUsage: ["tokensIn", "tokensOut", "cacheHitTokensIn", "costUsd"],
277
341
  };
package/dist/delivery.cjs CHANGED
@@ -24,7 +24,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
24
24
  exports.probeWorktreeDelivery = probeWorktreeDelivery;
25
25
  exports.evaluateDelivery = evaluateDelivery;
26
26
  exports.renderDeliveryCorrectionPrompt = renderDeliveryCorrectionPrompt;
27
+ exports.renderResumedDeliveryCorrectionPrompt = renderResumedDeliveryCorrectionPrompt;
27
28
  const sandbox_cjs_1 = require("./sandbox.cjs");
29
+ const prompt_cjs_1 = require("./prompt.cjs");
28
30
  const GIT_TIMEOUT_MS = 30_000;
29
31
  /**
30
32
  * Reads the worktree's delivery state.
@@ -153,6 +155,19 @@ function renderDeliveryCorrectionPrompt(args) {
153
155
  return [
154
156
  args.originalPrompt.trim(),
155
157
  "",
158
+ ...deliveryIncompleteSection(args),
159
+ ...(clippedReport ? ["", "### Previous turn's final report", "", clippedReport] : []),
160
+ ].join("\n");
161
+ }
162
+ /**
163
+ * The resumed counterpart of renderDeliveryCorrectionPrompt: the brief and
164
+ * the previous turn's final report are both already in the conversation.
165
+ */
166
+ function renderResumedDeliveryCorrectionPrompt(args) {
167
+ return [(0, prompt_cjs_1.renderResumedTurnPreamble)(args.task), "", ...deliveryIncompleteSection(args)].join("\n");
168
+ }
169
+ function deliveryIncompleteSection(args) {
170
+ return [
156
171
  "## Delivery is incomplete",
157
172
  "",
158
173
  `Your previous turn ended without delivering the task: ${args.reason}.`,
@@ -181,6 +196,5 @@ function renderDeliveryCorrectionPrompt(args) {
181
196
  "",
182
197
  "Your final report replaces the previous one. Restate it in full in the required format, preserving",
183
198
  "the verdict, reviewed head, findings, and validation limitations. Do not claim delivery you cannot verify.",
184
- ...(clippedReport ? ["", "### Previous turn's final report", "", clippedReport] : []),
185
- ].join("\n");
199
+ ];
186
200
  }
package/dist/prompt.cjs CHANGED
@@ -2,7 +2,11 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderPrompt = renderPrompt;
4
4
  exports.renderGuidanceCorrectionPrompt = renderGuidanceCorrectionPrompt;
5
+ exports.renderMidFlightGuidanceSection = renderMidFlightGuidanceSection;
6
+ exports.renderResumedTurnPreamble = renderResumedTurnPreamble;
7
+ exports.renderResumedGuidancePrompt = renderResumedGuidancePrompt;
5
8
  exports.renderCompletionRemediationPrompt = renderCompletionRemediationPrompt;
9
+ exports.renderResumedCompletionRemediationPrompt = renderResumedCompletionRemediationPrompt;
6
10
  exports.renderHostedCheckpointNote = renderHostedCheckpointNote;
7
11
  const CODE_GRAPH_GUIDANCE = "For JS/TS dependency inspection, the runtime provides `navarch-runtime code-graph --query <symbol-or-file> --mode search|callers|callees|impact --commit HEAD`. If the runtime is not on PATH, use its installed entry point; do not install another copy mid-task. Results describe committed source and include commit/freshness warnings; inspect current files for uncommitted changes and unresolved dependencies.";
8
12
  const PROCESS_GUIDANCE = "Process management: this host may run other agent sessions. Keep the PID or tool handle of each preview server you start and stop only that owned process. Never use broad process-name or command-line matching (pkill, killall) to clean up servers: the agent launcher carries the task prompt in its arguments and can match those patterns, terminating your own session. Use a different port when a port is occupied by a process you do not own. Keep scratch files in the current worktree; paths in previous transcripts belong to earlier sessions. Before your final report, inspect `git status --short`, including untracked files. Preserve required evidence in task artifacts, then remove only temporary files you created and restore only tracked edits you made solely for local checks. Do not delete unfamiliar or pre-existing changes, hide leftovers with ignore rules, or commit scratch files merely to make the worktree clean. Review tasks deliver a native review and attestation, never commits, pushes, or a new PR.";
@@ -64,9 +68,12 @@ function renderPrompt(task, bundle) {
64
68
  * partial edits instead of discarding mid-flight work.
65
69
  */
66
70
  function renderGuidanceCorrectionPrompt(originalPrompt, guidance) {
71
+ return [originalPrompt.trim(), renderMidFlightGuidanceSection(guidance)].join("\n");
72
+ }
73
+ /** The guidance section shared by the full-brief and resumed guidance turns. */
74
+ function renderMidFlightGuidanceSection(guidance) {
67
75
  const corrections = guidance.map((entry) => `- **${entry.given_at}**: ${entry.content}`);
68
76
  return [
69
- originalPrompt.trim(),
70
77
  "## Mid-flight human guidance",
71
78
  "",
72
79
  "The task was actively steered while you were working. Human guidance takes precedence over earlier context. Continue in the same worktree: inspect the current changes, correct course, and then finish the task.",
@@ -75,6 +82,24 @@ function renderGuidanceCorrectionPrompt(originalPrompt, guidance) {
75
82
  "",
76
83
  ].join("\n");
77
84
  }
85
+ /**
86
+ * Opens every turn that resumes the previous agent conversation
87
+ * (`AgentAdapter.resumesTurns`). The brief is already in that conversation,
88
+ * so the turn names the task in one line and says the brief still applies
89
+ * instead of re-sending 35–80K characters of it. A turn that cannot resume
90
+ * uses the full-brief renderers, which keep #1978's fix: a fresh agent never
91
+ * receives a correction without its task.
92
+ */
93
+ function renderResumedTurnPreamble(task) {
94
+ return [
95
+ `Task: ${task.summary} (Navarch task ${task.id}).`,
96
+ "This turn continues the conversation above. The full task brief earlier in this conversation still applies and is not repeated here.",
97
+ ].join("\n");
98
+ }
99
+ /** A resumed guidance turn: only the guidance this conversation has not seen. */
100
+ function renderResumedGuidancePrompt(task, guidance) {
101
+ return [renderResumedTurnPreamble(task), "", renderMidFlightGuidanceSection(guidance)].join("\n");
102
+ }
78
103
  /** Keeps a long previous report from crowding the brief out of the prompt. */
79
104
  const REMEDIATION_REPORT_LIMIT = 8_000;
80
105
  /**
@@ -93,21 +118,33 @@ function renderCompletionRemediationPrompt(args) {
93
118
  return [
94
119
  args.originalPrompt.trim(),
95
120
  "",
121
+ ...completionRejectedSection(args),
122
+ ...(clipped
123
+ ? ["", "### Previous turn's final report", "", clipped]
124
+ : []),
125
+ "",
126
+ ].join("\n");
127
+ }
128
+ /**
129
+ * The resumed counterpart of renderCompletionRemediationPrompt: the brief and
130
+ * the previous turn's final report are both already in the conversation.
131
+ */
132
+ function renderResumedCompletionRemediationPrompt(args) {
133
+ return [renderResumedTurnPreamble(args.task), "", ...completionRejectedSection(args), ""].join("\n");
134
+ }
135
+ function completionRejectedSection(args) {
136
+ return [
96
137
  "## Completion was rejected",
97
138
  "",
98
139
  "Your previous turn on this task finished, but the Navarch control plane rejected its completion:",
99
140
  "",
100
141
  ...args.rejection.trim().split("\n").map((line) => `> ${line}`),
101
142
  "",
102
- `This is correction turn ${args.attempt} of ${args.maxAttempts}. The task above is unchanged, and you are in the`,
143
+ `This is correction turn ${args.attempt} of ${args.maxAttempts}. The task is unchanged, and you are in the`,
103
144
  "same worktree, so earlier edits, commits, and branches are still here. Reuse the work and findings from",
104
145
  "the previous turn instead of starting over, fix what the rejection names, and finish the task.",
105
146
  "Your final report replaces the previous one, so restate it in full in the required format.",
106
- ...(clipped
107
- ? ["", "### Previous turn's final report", "", clipped]
108
- : []),
109
- "",
110
- ].join("\n");
147
+ ];
111
148
  }
112
149
  /**
113
150
  * Tells the agent its worktree starts from a hosted attempt's checkpoint
package/dist/session.cjs CHANGED
@@ -529,7 +529,22 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
529
529
  });
530
530
  const agentBudgetStartedAt = node_perf_hooks_1.performance.now();
531
531
  const maxRuntimeMs = task.max_runtime_ms ?? config.sessionTimeoutMs;
532
- let nextPrompt = null;
532
+ // A correction turn in two forms: `full` restates the task brief for a
533
+ // fresh agent; `resumed` is only the correction, for an adapter that
534
+ // continues the previous turn's conversation (AgentAdapter.resumesTurns).
535
+ let nextCorrection = null;
536
+ // The conversation the next turn may resume, and the mid-flight guidance
537
+ // that conversation has already been sent.
538
+ let resumableSessionId;
539
+ let conversationGuidanceIds = new Set();
540
+ // Running usage totals the resumable conversation last reported, for the
541
+ // fields its adapter reports cumulatively (AgentAdapter.cumulativeResumeUsage).
542
+ let conversationUsage = {};
543
+ // Claim-time guidance is part of the brief (task_context's "Human
544
+ // guidance" section, lib/navarch/context.ts), so guidance turns carry only
545
+ // what arrived after the claim.
546
+ const briefGuidanceIds = new Set((bundle.guidance ?? []).map((entry) => entry.id));
547
+ const resumedTask = { id: task.id, summary: task.summary };
533
548
  let completionRemediationRetries = 0;
534
549
  let deliveryCorrectionRetries = 0;
535
550
  let deliveredPullRequest = false;
@@ -540,58 +555,85 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
540
555
  // It is already included in deliveredGuidance, so clear the pending
541
556
  // notification and start the first turn with the corrected prompt.
542
557
  pendingGuidance = [];
543
- activeAbortController = new AbortController();
558
+ const turnAbort = new AbortController();
559
+ activeAbortController = turnAbort;
544
560
  if (leaseLost || deps.shutdownSignal?.aborted)
545
- activeAbortController.abort();
561
+ turnAbort.abort();
546
562
  // The task brief plus any mid-flight guidance. Correction turns build on
547
563
  // this rather than on the previous turn's prompt so they never nest.
548
- const briefPrompt = deliveredGuidance.length > (bundle.guidance?.length ?? 0)
549
- ? (0, prompt_cjs_1.renderGuidanceCorrectionPrompt)(promptText, deliveredGuidance)
564
+ const midFlightGuidance = deliveredGuidance.filter((entry) => !briefGuidanceIds.has(entry.id));
565
+ const briefPrompt = midFlightGuidance.length > 0
566
+ ? (0, prompt_cjs_1.renderGuidanceCorrectionPrompt)(promptText, midFlightGuidance)
550
567
  : promptText;
551
- const runPrompt = nextPrompt ?? briefPrompt;
552
- nextPrompt = null;
553
- await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), runPrompt, "utf8");
568
+ const fullPrompt = nextCorrection ? nextCorrection.full(briefPrompt) : briefPrompt;
569
+ // Resume only to deliver something new: a correction, or guidance this
570
+ // conversation has not been sent. The brief is already in it.
571
+ const unsentGuidance = midFlightGuidance.filter((entry) => !conversationGuidanceIds.has(entry.id));
572
+ const resumeSessionId = adapter.resumesTurns && (nextCorrection || unsentGuidance.length > 0)
573
+ ? resumableSessionId
574
+ : undefined;
575
+ const resumedPrompt = resumeSessionId
576
+ ? nextCorrection
577
+ ? [nextCorrection.resumed, ...(unsentGuidance.length > 0 ? [(0, prompt_cjs_1.renderMidFlightGuidanceSection)(unsentGuidance)] : [])].join("\n\n")
578
+ : (0, prompt_cjs_1.renderResumedGuidancePrompt)(resumedTask, unsentGuidance)
579
+ : null;
580
+ nextCorrection = null;
554
581
  adapterStarted = true;
555
582
  adapterRunning = true;
556
583
  agentTurnStartedAt = Date.now();
557
584
  outputReportedAt = agentTurnStartedAt;
558
585
  outputUpdates = 0;
559
586
  reportActivity("running");
560
- const remainingRuntimeMs = maxRuntimeMs > 0
561
- ? maxRuntimeMs - (node_perf_hooks_1.performance.now() - agentBudgetStartedAt)
562
- : 0;
563
- const turnResult = maxRuntimeMs > 0 && remainingRuntimeMs <= 0
564
- ? { exitCode: null, timedOut: true, timeoutReason: "duration", killedByLeaseLoss: false, stdout: "", stderr: "" }
565
- : await (0, adapter_watchdog_cjs_1.runWithActivityWatchdog)(adapter, {
566
- prompt: runPrompt,
567
- mcpConfigPath,
568
- mcpMetadataDir: workDir,
569
- bin,
570
- extraArgs,
571
- model: execution.model,
572
- reasoningEffort: execution.reasoning_effort,
573
- timeoutMs: remainingRuntimeMs,
574
- env: sessionEnv,
575
- settingsPath: claudeSettingsPath,
576
- codexGuardArgs,
577
- antigravityHostGuard: runtime === "antigravity" && config.sandboxMode === "host" && config.worktreeGuard,
578
- antigravityResume,
579
- museHostGuard: config.sandboxMode === "host" && config.worktreeGuard,
580
- resumeSessionId: runtime === "muse" ? museSessionId : undefined,
581
- opencodeHostGuard: runtime === "opencode" && config.sandboxMode === "host" && config.worktreeGuard,
582
- // ACP requires an absolute session cwd. Docker mounts the worktree at
583
- // this same absolute path, while the other Docker adapters ignore cwd.
584
- cwd: gitWorktree.worktreePath,
585
- dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
586
- signal: activeAbortController.signal,
587
- onActivity: () => { outputObservedAt = Date.now(); outputUpdates += 1; },
588
- onOutput: (stream, chunk) => {
589
- if (stream !== "stdout")
590
- return;
591
- liveStdout += chunk;
592
- liveTranscript?.markDirty();
593
- },
594
- }, task.idle_timeout_ms ?? config.sessionIdleTimeoutMs ?? 45 * 60_000, runtime === "opencode" ? config.opencodeStartupTimeoutMs : 0);
587
+ const runTurn = async (prompt, resumeId) => {
588
+ // prompt.md records exactly what this invocation is sent.
589
+ await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), prompt, "utf8");
590
+ const remainingRuntimeMs = maxRuntimeMs > 0
591
+ ? maxRuntimeMs - (node_perf_hooks_1.performance.now() - agentBudgetStartedAt)
592
+ : 0;
593
+ return maxRuntimeMs > 0 && remainingRuntimeMs <= 0
594
+ ? { exitCode: null, timedOut: true, timeoutReason: "duration", killedByLeaseLoss: false, stdout: "", stderr: "" }
595
+ : await (0, adapter_watchdog_cjs_1.runWithActivityWatchdog)(adapter, {
596
+ prompt,
597
+ mcpConfigPath,
598
+ mcpMetadataDir: workDir,
599
+ bin,
600
+ extraArgs,
601
+ model: execution.model,
602
+ reasoningEffort: execution.reasoning_effort,
603
+ timeoutMs: remainingRuntimeMs,
604
+ env: sessionEnv,
605
+ settingsPath: claudeSettingsPath,
606
+ codexGuardArgs,
607
+ antigravityHostGuard: runtime === "antigravity" && config.sandboxMode === "host" && config.worktreeGuard,
608
+ antigravityResume,
609
+ museHostGuard: config.sandboxMode === "host" && config.worktreeGuard,
610
+ resumeSessionId: runtime === "muse" ? museSessionId : resumeId,
611
+ opencodeHostGuard: runtime === "opencode" && config.sandboxMode === "host" && config.worktreeGuard,
612
+ // ACP requires an absolute session cwd. Docker mounts the worktree at
613
+ // this same absolute path, while the other Docker adapters ignore cwd.
614
+ cwd: gitWorktree.worktreePath,
615
+ dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
616
+ signal: turnAbort.signal,
617
+ onActivity: () => { outputObservedAt = Date.now(); outputUpdates += 1; },
618
+ onOutput: (stream, chunk) => {
619
+ if (stream !== "stdout")
620
+ return;
621
+ liveStdout += chunk;
622
+ liveTranscript?.markDirty();
623
+ },
624
+ }, task.idle_timeout_ms ?? config.sessionIdleTimeoutMs ?? 45 * 60_000, runtime === "opencode" ? config.opencodeStartupTimeoutMs : 0);
625
+ };
626
+ let turnResult = await runTurn(resumedPrompt ?? fullPrompt, resumeSessionId);
627
+ let resumed = Boolean(resumeSessionId);
628
+ if (resumeSessionId && turnResult.resumeRejected && !leaseLost && !turnAbort.signal.aborted) {
629
+ // The CLI refused the session before any work started (expired or
630
+ // unknown id). Nothing ran, so send the full brief to a fresh agent.
631
+ log.warn(`could not resume the agent conversation for ${leaseId} ` +
632
+ `(${(0, redact_cjs_1.redactText)(turnResult.stderr.trim().slice(0, 300), registry.list())}); re-sending the full task brief.`);
633
+ liveStdout = "";
634
+ turnResult = await runTurn(fullPrompt, undefined);
635
+ resumed = false;
636
+ }
595
637
  activeAbortController = null;
596
638
  adapterRunning = false;
597
639
  if (turnResult.timedOut || leaseLost)
@@ -603,6 +645,34 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
603
645
  antigravityResume = turnResult.antigravityResume;
604
646
  if (runtime === "muse")
605
647
  museSessionId = turnResult.agentSessionId;
648
+ if (adapter.cumulativeResumeUsage) {
649
+ // A resumed run reports the conversation's running total: record only
650
+ // this turn's increase. A fresh conversation starts its own total.
651
+ if (!resumed)
652
+ conversationUsage = {};
653
+ for (const key of adapter.cumulativeResumeUsage) {
654
+ const total = turnResult[key];
655
+ if (total === undefined)
656
+ continue;
657
+ turnResult[key] = Math.max(0, total - (conversationUsage[key] ?? 0));
658
+ conversationUsage[key] = total;
659
+ }
660
+ }
661
+ if (adapter.resumesTurns) {
662
+ resumableSessionId = turnResult.resumeRejected ? undefined : turnResult.agentSessionId;
663
+ if (!resumableSessionId) {
664
+ conversationGuidanceIds = new Set();
665
+ }
666
+ else if (resumed) {
667
+ for (const entry of unsentGuidance)
668
+ conversationGuidanceIds.add(entry.id);
669
+ }
670
+ else {
671
+ // A fresh conversation was sent the full brief, which carries every
672
+ // mid-flight guidance entry received so far.
673
+ conversationGuidanceIds = new Set(midFlightGuidance.map((entry) => entry.id));
674
+ }
675
+ }
606
676
  attempts.push(turnResult);
607
677
  liveStdout = "";
608
678
  liveTranscript.markDirty();
@@ -735,14 +805,18 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
735
805
  deliveryCorrectionRetries += 1;
736
806
  log.warn(`session ${leaseId} finished without delivering (${delivery.reason}); ` +
737
807
  `restarting agent turn ${deliveryCorrectionRetries}/${DELIVERY_CORRECTION_RETRIES} in the same worktree.`);
738
- nextPrompt = (0, delivery_cjs_1.renderDeliveryCorrectionPrompt)({
739
- originalPrompt: briefPrompt,
808
+ const correction = {
740
809
  taskType: task.task_type,
741
- previousReport: previousTurnReport(),
742
810
  reason: delivery.reason,
743
811
  attempt: deliveryCorrectionRetries,
744
812
  maxAttempts: DELIVERY_CORRECTION_RETRIES,
745
- });
813
+ };
814
+ const previousReport = previousTurnReport();
815
+ nextCorrection = {
816
+ full: (brief) => (0, delivery_cjs_1.renderDeliveryCorrectionPrompt)({ ...correction, originalPrompt: brief, previousReport }),
817
+ // The resumed conversation already holds the previous report.
818
+ resumed: (0, delivery_cjs_1.renderResumedDeliveryCorrectionPrompt)({ ...correction, task: resumedTask }),
819
+ };
746
820
  continue;
747
821
  }
748
822
  await checkpointForRecovery(result, "incomplete delivery");
@@ -831,15 +905,19 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
831
905
  if (remediable && completionRemediationRetries < COMPLETION_REMEDIATION_RETRIES) {
832
906
  completionRemediationRetries += 1;
833
907
  log.warn(`completion for ${leaseId} needs more work (${rejectionDetail}); restarting agent turn ${completionRemediationRetries}/${COMPLETION_REMEDIATION_RETRIES} in the same worktree.`);
834
- // A new turn is a fresh agent invocation: the rejection alone would
835
- // reach an agent that has never seen the task.
836
- nextPrompt = (0, prompt_cjs_1.renderCompletionRemediationPrompt)({
837
- originalPrompt: briefPrompt,
908
+ // A fresh agent invocation needs the brief: the rejection alone would
909
+ // reach an agent that has never seen the task (#1978). A resumed
910
+ // conversation already holds the brief and the previous report.
911
+ const correction = {
838
912
  rejection: redactedRejection,
839
- previousReport: previousTurnReport(),
840
913
  attempt: completionRemediationRetries,
841
914
  maxAttempts: COMPLETION_REMEDIATION_RETRIES,
842
- });
915
+ };
916
+ const previousReport = previousTurnReport();
917
+ nextCorrection = {
918
+ full: (brief) => (0, prompt_cjs_1.renderCompletionRemediationPrompt)({ ...correction, originalPrompt: brief, previousReport }),
919
+ resumed: (0, prompt_cjs_1.renderResumedCompletionRemediationPrompt)({ ...correction, task: resumedTask }),
920
+ };
843
921
  continue;
844
922
  }
845
923
  if (remediable) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.121",
3
+ "version": "0.1.122",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through local coding agents.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",