@namzu/sdk 39.0.0 → 40.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  3. package/dist/connector/mcp/adapter.js +20 -6
  4. package/dist/connector/mcp/adapter.js.map +1 -1
  5. package/dist/manager/run/persistence.d.ts +8 -0
  6. package/dist/manager/run/persistence.d.ts.map +1 -1
  7. package/dist/manager/run/persistence.js +12 -0
  8. package/dist/manager/run/persistence.js.map +1 -1
  9. package/dist/public-runtime.d.ts +3 -1
  10. package/dist/public-runtime.d.ts.map +1 -1
  11. package/dist/public-runtime.js +5 -1
  12. package/dist/public-runtime.js.map +1 -1
  13. package/dist/public-tools.d.ts +11 -0
  14. package/dist/public-tools.d.ts.map +1 -1
  15. package/dist/public-tools.js +14 -0
  16. package/dist/public-tools.js.map +1 -1
  17. package/dist/registry/tool/execute.d.ts.map +1 -1
  18. package/dist/registry/tool/execute.js +2 -3
  19. package/dist/registry/tool/execute.js.map +1 -1
  20. package/dist/registry/tool/portable.d.ts +65 -0
  21. package/dist/registry/tool/portable.d.ts.map +1 -0
  22. package/dist/registry/tool/portable.js +244 -0
  23. package/dist/registry/tool/portable.js.map +1 -0
  24. package/dist/registry/tool/schema.d.ts +32 -5
  25. package/dist/registry/tool/schema.d.ts.map +1 -1
  26. package/dist/registry/tool/schema.js +35 -9
  27. package/dist/registry/tool/schema.js.map +1 -1
  28. package/dist/registry/toolset/catalog.js +8 -8
  29. package/dist/registry/toolset/catalog.js.map +1 -1
  30. package/dist/runtime/jobs/awaited-jobs.d.ts +215 -0
  31. package/dist/runtime/jobs/awaited-jobs.d.ts.map +1 -0
  32. package/dist/runtime/jobs/awaited-jobs.js +259 -0
  33. package/dist/runtime/jobs/awaited-jobs.js.map +1 -0
  34. package/dist/runtime/jobs/registry.d.ts +33 -2
  35. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  36. package/dist/runtime/jobs/registry.js +37 -0
  37. package/dist/runtime/jobs/registry.js.map +1 -1
  38. package/dist/runtime/query/executor.d.ts +28 -0
  39. package/dist/runtime/query/executor.d.ts.map +1 -1
  40. package/dist/runtime/query/executor.js +39 -1
  41. package/dist/runtime/query/executor.js.map +1 -1
  42. package/dist/runtime/query/file-evidence-context.d.ts.map +1 -1
  43. package/dist/runtime/query/file-evidence-context.js +159 -43
  44. package/dist/runtime/query/file-evidence-context.js.map +1 -1
  45. package/dist/runtime/query/file-evidence-replay.d.ts +260 -0
  46. package/dist/runtime/query/file-evidence-replay.d.ts.map +1 -0
  47. package/dist/runtime/query/file-evidence-replay.js +647 -0
  48. package/dist/runtime/query/file-evidence-replay.js.map +1 -0
  49. package/dist/runtime/query/file-evidence-seed.d.ts +50 -0
  50. package/dist/runtime/query/file-evidence-seed.d.ts.map +1 -0
  51. package/dist/runtime/query/file-evidence-seed.js +100 -0
  52. package/dist/runtime/query/file-evidence-seed.js.map +1 -0
  53. package/dist/runtime/query/index.d.ts.map +1 -1
  54. package/dist/runtime/query/index.js +94 -2
  55. package/dist/runtime/query/index.js.map +1 -1
  56. package/dist/runtime/query/iteration/index.d.ts +87 -9
  57. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  58. package/dist/runtime/query/iteration/index.js +193 -28
  59. package/dist/runtime/query/iteration/index.js.map +1 -1
  60. package/dist/runtime/query/iteration/phases/context.d.ts +10 -0
  61. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  62. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  63. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  64. package/dist/runtime/query/iteration/phases/tool-review.js +5 -1
  65. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  66. package/dist/runtime/query/plugin-hooks.d.ts +14 -0
  67. package/dist/runtime/query/plugin-hooks.d.ts.map +1 -1
  68. package/dist/runtime/query/plugin-hooks.js +18 -0
  69. package/dist/runtime/query/plugin-hooks.js.map +1 -1
  70. package/dist/runtime/query/repeat-call.d.ts +17 -4
  71. package/dist/runtime/query/repeat-call.d.ts.map +1 -1
  72. package/dist/runtime/query/repeat-call.js +26 -19
  73. package/dist/runtime/query/repeat-call.js.map +1 -1
  74. package/dist/runtime/query/steering.d.ts +11 -1
  75. package/dist/runtime/query/steering.d.ts.map +1 -1
  76. package/dist/runtime/query/steering.js +12 -1
  77. package/dist/runtime/query/steering.js.map +1 -1
  78. package/dist/runtime/query/tooling.d.ts +2 -0
  79. package/dist/runtime/query/tooling.d.ts.map +1 -1
  80. package/dist/runtime/query/tooling.js +1 -0
  81. package/dist/runtime/query/tooling.js.map +1 -1
  82. package/dist/scheduler/completion-inbox.d.ts +48 -2
  83. package/dist/scheduler/completion-inbox.d.ts.map +1 -1
  84. package/dist/scheduler/completion-inbox.js +102 -10
  85. package/dist/scheduler/completion-inbox.js.map +1 -1
  86. package/dist/tools/builtins/bash.d.ts.map +1 -1
  87. package/dist/tools/builtins/bash.js +4 -10
  88. package/dist/tools/builtins/bash.js.map +1 -1
  89. package/dist/tools/builtins/edit-apply.d.ts +126 -0
  90. package/dist/tools/builtins/edit-apply.d.ts.map +1 -0
  91. package/dist/tools/builtins/edit-apply.js +360 -0
  92. package/dist/tools/builtins/edit-apply.js.map +1 -0
  93. package/dist/tools/builtins/edit.d.ts +143 -1
  94. package/dist/tools/builtins/edit.d.ts.map +1 -1
  95. package/dist/tools/builtins/edit.js +37 -219
  96. package/dist/tools/builtins/edit.js.map +1 -1
  97. package/dist/tools/builtins/index.d.ts +1 -0
  98. package/dist/tools/builtins/index.d.ts.map +1 -1
  99. package/dist/tools/builtins/index.js +9 -3
  100. package/dist/tools/builtins/index.js.map +1 -1
  101. package/dist/tools/builtins/job.js +1 -1
  102. package/dist/tools/builtins/job.js.map +1 -1
  103. package/dist/tools/builtins/read-file.d.ts +2 -2
  104. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  105. package/dist/tools/builtins/read-file.js +50 -65
  106. package/dist/tools/builtins/read-file.js.map +1 -1
  107. package/dist/tools/builtins/read-render.d.ts +56 -0
  108. package/dist/tools/builtins/read-render.d.ts.map +1 -0
  109. package/dist/tools/builtins/read-render.js +73 -0
  110. package/dist/tools/builtins/read-render.js.map +1 -0
  111. package/dist/tools/builtins/wait-for-job-bounds.d.ts +67 -0
  112. package/dist/tools/builtins/wait-for-job-bounds.d.ts.map +1 -0
  113. package/dist/tools/builtins/wait-for-job-bounds.js +108 -0
  114. package/dist/tools/builtins/wait-for-job-bounds.js.map +1 -0
  115. package/dist/tools/builtins/wait-for-job.d.ts +6 -0
  116. package/dist/tools/builtins/wait-for-job.d.ts.map +1 -0
  117. package/dist/tools/builtins/wait-for-job.js +162 -0
  118. package/dist/tools/builtins/wait-for-job.js.map +1 -0
  119. package/dist/tools/builtins/write-file.js +5 -0
  120. package/dist/tools/builtins/write-file.js.map +1 -1
  121. package/dist/tools/coordinator/index.d.ts.map +1 -1
  122. package/dist/tools/coordinator/index.js +1 -7
  123. package/dist/tools/coordinator/index.js.map +1 -1
  124. package/dist/tools/file-read-tracker.d.ts.map +1 -1
  125. package/dist/tools/file-read-tracker.js +88 -10
  126. package/dist/tools/file-read-tracker.js.map +1 -1
  127. package/dist/types/message/index.d.ts +1 -1
  128. package/dist/types/message/index.d.ts.map +1 -1
  129. package/dist/types/message/index.js +2 -0
  130. package/dist/types/message/index.js.map +1 -1
  131. package/dist/types/run/entity.d.ts +13 -0
  132. package/dist/types/run/entity.d.ts.map +1 -1
  133. package/dist/types/sandbox/index.d.ts +15 -14
  134. package/dist/types/sandbox/index.d.ts.map +1 -1
  135. package/dist/types/sandbox/index.js.map +1 -1
  136. package/dist/types/tool/index.d.ts +109 -0
  137. package/dist/types/tool/index.d.ts.map +1 -1
  138. package/dist/types/tool/index.js.map +1 -1
  139. package/dist/utils/env.d.ts +19 -0
  140. package/dist/utils/env.d.ts.map +1 -0
  141. package/dist/utils/env.js +25 -0
  142. package/dist/utils/env.js.map +1 -0
  143. package/package.json +1 -1
  144. package/src/connector/mcp/adapter.ts +20 -6
  145. package/src/manager/run/persistence.ts +12 -0
  146. package/src/public-runtime.ts +9 -1
  147. package/src/public-tools.ts +18 -0
  148. package/src/registry/tool/execute.ts +2 -4
  149. package/src/registry/tool/portable.ts +264 -0
  150. package/src/registry/tool/schema.ts +38 -8
  151. package/src/registry/toolset/catalog.ts +8 -9
  152. package/src/runtime/jobs/awaited-jobs.ts +271 -0
  153. package/src/runtime/jobs/registry.ts +50 -0
  154. package/src/runtime/query/executor.ts +49 -1
  155. package/src/runtime/query/file-evidence-context.ts +190 -46
  156. package/src/runtime/query/file-evidence-replay.ts +776 -0
  157. package/src/runtime/query/file-evidence-seed.ts +126 -0
  158. package/src/runtime/query/index.ts +104 -2
  159. package/src/runtime/query/iteration/index.ts +202 -28
  160. package/src/runtime/query/iteration/phases/context.ts +10 -0
  161. package/src/runtime/query/iteration/phases/tool-review.ts +4 -0
  162. package/src/runtime/query/plugin-hooks.ts +20 -0
  163. package/src/runtime/query/repeat-call.ts +28 -18
  164. package/src/runtime/query/steering.ts +11 -0
  165. package/src/runtime/query/tooling.ts +3 -0
  166. package/src/scheduler/completion-inbox.ts +105 -9
  167. package/src/tools/builtins/bash.ts +4 -10
  168. package/src/tools/builtins/edit-apply.ts +456 -0
  169. package/src/tools/builtins/edit.ts +39 -270
  170. package/src/tools/builtins/index.ts +9 -3
  171. package/src/tools/builtins/job.ts +1 -1
  172. package/src/tools/builtins/read-file.ts +56 -77
  173. package/src/tools/builtins/read-render.ts +104 -0
  174. package/src/tools/builtins/wait-for-job-bounds.ts +179 -0
  175. package/src/tools/builtins/wait-for-job.ts +184 -0
  176. package/src/tools/builtins/write-file.ts +5 -0
  177. package/src/tools/coordinator/index.ts +1 -7
  178. package/src/tools/file-read-tracker.ts +85 -7
  179. package/src/types/message/index.ts +2 -0
  180. package/src/types/run/entity.ts +14 -0
  181. package/src/types/sandbox/index.ts +15 -14
  182. package/src/types/tool/index.ts +104 -0
  183. package/src/utils/env.ts +23 -0
@@ -17,6 +17,7 @@ import { DELEGATION_TIMEOUT_MS } from '../../../tools/coordinator/index.js';
17
17
  import { NamzuError } from '../../../types/errors/index.js';
18
18
  import { createAssistantMessage, createRuntimeContextMessage, createSystemMessage, } from '../../../types/message/index.js';
19
19
  import { classifyProviderError } from '../../../types/provider/errors.js';
20
+ import { readPositiveIntEnv } from '../../../utils/env.js';
20
21
  import { toErrorMessage } from '../../../utils/error.js';
21
22
  import { stableDigest } from '../../../utils/hash.js';
22
23
  import { generateMessageId } from '../../../utils/id.js';
@@ -25,7 +26,7 @@ import { projectObservationContext } from '../observation-context.js';
25
26
  import { applyLifecycleHookResults } from '../plugin-hooks.js';
26
27
  import { diffRequestContext, snapshotRequestContext, } from '../request-context.js';
27
28
  import { DEFAULT_MAX_REQUEST_RICH_CONTENT_BYTES, markProviderRejectedImage, projectRequestRichContent, } from '../request-rich-content.js';
28
- import { formatSteeringNote, isOperatorUserMessage } from '../steering.js';
29
+ import { formatJobNote, formatSteeringNote, isOperatorUserMessage } from '../steering.js';
29
30
  import { parseNativeCandidate } from './native-output.js';
30
31
  import { runAdvisoryPhase } from './phases/advisory.js';
31
32
  import { runIterationCheckpoint } from './phases/checkpoint.js';
@@ -116,6 +117,42 @@ const SETTLE_GRACE_FRACTION = 0.5;
116
117
  export function settleGraceMs(remainingBeforeFinalizeMs) {
117
118
  return Math.min(Math.floor(remainingBeforeFinalizeMs * SETTLE_GRACE_FRACTION), DELEGATION_TIMEOUT_MS);
118
119
  }
120
+ /**
121
+ * The ceiling on the job half of that grace, in milliseconds.
122
+ *
123
+ * `DELEGATION_TIMEOUT_MS` is the wrong ceiling for a shell job, and the gap
124
+ * only opens where it matters most: a run with no `timeoutMs` — the CLI's
125
+ * shipping default, `No run deadline by default` — has infinite time before
126
+ * it must start finishing, so `settleGraceMs` returns the ceiling flat. For a
127
+ * delegated task that is sound, because the hour is the longest the task
128
+ * itself may live: the hold cannot outlast the work. A background job has no
129
+ * such bound. `tail -f`, a watcher and a dev server all outlive any hold, so
130
+ * the same arithmetic parks an interactive session for an hour on a job that
131
+ * was never going to exit.
132
+ *
133
+ * So the job leg gets its own bound, and it is sized to what the wait buys
134
+ * rather than to how long a job may live: a turn in which to use the exit.
135
+ * A model that already waited its `wait_for_job` bound out and saw nothing is
136
+ * not usually two minutes from an exit, and the run ending is not the news
137
+ * being lost — with no run in flight the session announces the exit itself
138
+ * (`docs/cli/background-jobs.md`, *Learning that it ended*), which is the
139
+ * cheaper of the two places to hear it.
140
+ */
141
+ const DEFAULT_JOB_HOLD_MAX_MS = 2 * 60 * 1000;
142
+ /**
143
+ * The same share of the run, under {@link DEFAULT_JOB_HOLD_MAX_MS}.
144
+ *
145
+ * `NAMZU_JOB_HOLD_MAX_MS` overrides the ceiling for a host that wants a
146
+ * longer or shorter park, the way `NAMZU_JOB_WAIT_TIMEOUT_MS` overrides
147
+ * `wait_for_job`'s own bound — and it is the same parse, so a value that is
148
+ * not a positive whole number of milliseconds leaves the default standing
149
+ * rather than holding a run for `NaN`. Called here rather than at module
150
+ * load, because a host that sets it after import is not ignored.
151
+ */
152
+ export function awaitedJobGraceMs(remainingBeforeFinalizeMs) {
153
+ const ceiling = readPositiveIntEnv('NAMZU_JOB_HOLD_MAX_MS', DEFAULT_JOB_HOLD_MAX_MS);
154
+ return Math.min(settleGraceMs(remainingBeforeFinalizeMs), ceiling);
155
+ }
119
156
  export class IterationOrchestrator {
120
157
  ctx;
121
158
  advisoryTurn;
@@ -1301,8 +1338,10 @@ export class IterationOrchestrator {
1301
1338
  // returned — which is what makes a terminal submit_answer tool
1302
1339
  // usable without discarding its output.
1303
1340
  if (await this.shouldStop()) {
1304
- // Outstanding delegated work outranks the host's stop
1305
- // predicate, exactly once.
1341
+ // Outstanding work outranks the host's stop predicate —
1342
+ // a delegated task the completion inbox is expecting, or
1343
+ // a background job the model told `wait_for_job` it is
1344
+ // waiting on.
1306
1345
  //
1307
1346
  // This is a precedence rule chosen here, not something
1308
1347
  // `stopWhen` implies — a stop predicate is a programmable
@@ -1311,13 +1350,20 @@ export class IterationOrchestrator {
1311
1350
  // tool or a captured structured output. Those decide the
1312
1351
  // result, so no turn follows and a hold would buy nothing.
1313
1352
  // This one only says "stop", and stopping one turn later
1314
- // with the worker's result in hand is a better reading of
1315
- // the host's intent than stopping now and discarding it.
1353
+ // with the result in hand is a better reading of the
1354
+ // host's intent than stopping now and discarding it.
1316
1355
  //
1317
- // Bounded: after the notification is delivered the inbox
1318
- // is drained, so the predicate fires again next turn with
1319
- // nothing pending and the run stops. Exactly one extra
1320
- // turn, and `maxIterations` bounds it regardless.
1356
+ // Bounded by what is left to deliver, not by a count.
1357
+ // Each delivery consumes what it delivered — the inbox is
1358
+ // drained, and a job exit's notice is taken with the
1359
+ // record of the exits it accounts for — so the predicate
1360
+ // is asked again next turn against whatever is still
1361
+ // outstanding. One task deferred it once; two awaited
1362
+ // jobs exiting a minute apart defer it twice, each time
1363
+ // for a turn the model spends on news it has not read.
1364
+ // `maxIterations` and the run's own deadline bound all of
1365
+ // it regardless, and a leg with nothing pending never
1366
+ // opens a hold at all.
1321
1367
  if (yield* this.holdForOutstandingWork(iterationNum, true)) {
1322
1368
  // Remember WHY the next turn exists, so the turn that
1323
1369
  // ends the run can name the host's decision instead of
@@ -1529,27 +1575,60 @@ export class IterationOrchestrator {
1529
1575
  }
1530
1576
  }
1531
1577
  /**
1532
- * Hold the run open for a worker that has not finished, and deliver it.
1578
+ * Hold the run open for work that has not finished, and deliver it.
1533
1579
  *
1534
- * Returns whether a completion or operator message entered the transcript —
1535
- * the caller continues on `true`, so the model gets a turn to respond.
1536
- * That turn is the entire justification for waiting, which
1580
+ * Returns whether a completion, a job exit or an operator message entered
1581
+ * the transcript — the caller continues on `true`, so the model gets a turn
1582
+ * to respond. That turn is the entire justification for waiting, which
1537
1583
  * is why only the exits that can still take one call this.
1538
1584
  *
1539
- * Bounded by `settleGraceMs` and by `maxIterations`, so a worker that never
1540
- * finishes cannot keep the run open.
1585
+ * Two kinds of work qualify and they are raced together, because a run has
1586
+ * one settle point and one grace period to spend at it:
1587
+ *
1588
+ * - a delegated task the `CompletionInbox` is still expecting;
1589
+ * - a background job the model told `wait_for_job` it is waiting on.
1590
+ *
1591
+ * The job half is deliberately narrow. Intent comes from the wait and from
1592
+ * nothing else — a dev server the model started and never waited on is
1593
+ * running because somebody wanted it running, and a hold for it would add
1594
+ * the grace period to the end of every turn for the rest of the session.
1595
+ *
1596
+ * Each leg is opened only when it has something pending: both
1597
+ * `waitForArrival` implementations resolve immediately when their own side
1598
+ * is idle, so racing an idle one would end the hold before it began.
1599
+ *
1600
+ * Bounded by `settleGraceMs` and by `maxIterations`, so work that never
1601
+ * finishes cannot keep the run open. On a run with a deadline the grace is
1602
+ * a share of what is LEFT of it rather than a fresh allowance, so a
1603
+ * `wait_for_job` call that already spent minutes has shortened this hold
1604
+ * by the same minutes. On a run without one — the CLI's default — there is
1605
+ * no remainder to take a share of, and the job leg's own ceiling
1606
+ * (`awaitedJobGraceMs`) is what keeps a timed-out wait from being followed
1607
+ * by an hour of silence.
1541
1608
  */
1542
1609
  async *holdForOutstandingWork(iterationNum, hasToolCalls) {
1543
- if (!this.ctx.completionInbox?.hasPendingWork)
1610
+ const inbox = this.ctx.completionInbox?.hasPendingWork ? this.ctx.completionInbox : undefined;
1611
+ const jobs = this.ctx.awaitedJobs?.hasPendingWork ? this.ctx.awaitedJobs : undefined;
1612
+ if (!inbox && !jobs)
1544
1613
  return false;
1545
1614
  // Read HERE rather than from `forceFinalize`, which was sampled at the
1546
1615
  // top of the iteration: one that has since crossed the finalize point
1547
1616
  // must not open a wait against a reserve it has already entered.
1548
- const graceMs = settleGraceMs(this.ctx.guard.remainingBeforeFinalizeMs());
1549
- this.ctx.log.info('Holding the run open for a background task', {
1617
+ const remainingMs = this.ctx.guard.remainingBeforeFinalizeMs();
1618
+ // One deadline for the race, and it is the LONGEST ceiling any pending
1619
+ // leg justifies. A leg resolving on its own timer ends the whole race,
1620
+ // so handing the job leg its shorter ceiling while a task was also
1621
+ // outstanding would cut the task's hold down to the job's — a run
1622
+ // walking away from a worker it had time for, because a job happened
1623
+ // to be running. A job therefore never shortens a wait, and it never
1624
+ // lengthens one either: where a task is outstanding too, that is how
1625
+ // long this run was waiting anyway.
1626
+ const graceMs = inbox ? settleGraceMs(remainingMs) : awaitedJobGraceMs(remainingMs);
1627
+ this.ctx.log.info('Holding the run open for outstanding work', {
1550
1628
  [NAMZU.RUN_ID]: this.ctx.runMgr.id,
1551
1629
  [NAMZU.ITERATION]: iterationNum,
1552
1630
  'namzu.runtime.grace_ms': graceMs,
1631
+ 'namzu.runtime.awaited_jobs': jobs?.outstandingJobIds ?? [],
1553
1632
  });
1554
1633
  // User input releases this wait without cancelling any child. Both waits
1555
1634
  // share a disposable signal so the losing arrival listener cannot leak.
@@ -1561,7 +1640,8 @@ export class IterationOrchestrator {
1561
1640
  cancelWait();
1562
1641
  try {
1563
1642
  await Promise.race([
1564
- this.ctx.completionInbox.waitForArrival(graceMs, waiting.signal),
1643
+ ...(inbox ? [inbox.waitForArrival(graceMs, waiting.signal)] : []),
1644
+ ...(jobs ? [jobs.waitForArrival(graceMs, waiting.signal)] : []),
1565
1645
  ...(this.ctx.waitForInbound ? [this.ctx.waitForInbound(waiting.signal)] : []),
1566
1646
  ]);
1567
1647
  }
@@ -1574,12 +1654,13 @@ export class IterationOrchestrator {
1574
1654
  runSignal.removeEventListener('abort', cancelWait);
1575
1655
  }
1576
1656
  runSignal.throwIfAborted();
1577
- const arrived = this.ctx.completionInbox.drain();
1657
+ const arrived = this.ctx.completionInbox?.drain() ?? [];
1578
1658
  if (arrived.length > 0) {
1579
1659
  this.ctx.runMgr.pushMessage(createRuntimeContextMessage(formatCompletionNotification(arrived), 'task-completion'));
1580
1660
  }
1661
+ const exited = this.deliverAwaitedJobExits();
1581
1662
  const inbound = this.deliverInbound();
1582
- if (arrived.length === 0 && inbound === 0)
1663
+ if (arrived.length === 0 && !exited && inbound === 0)
1583
1664
  return false;
1584
1665
  await this.ctx.emitEvent({
1585
1666
  type: 'iteration_completed',
@@ -1591,8 +1672,45 @@ export class IterationOrchestrator {
1591
1672
  return true;
1592
1673
  }
1593
1674
  /**
1594
- * Account for delegated work on the way out: deliver what arrived, and say
1595
- * what did not.
1675
+ * Put the job exits this hold was waiting for in front of the model.
1676
+ *
1677
+ * Through `jobNotices`, which is the channel a job exit already travels on
1678
+ * — `attachNotice` rides it out on the next tool result — rather than a
1679
+ * second one built for this path. A turn that called no tools has no such
1680
+ * result, so the queued text becomes a `runtime-context` message instead,
1681
+ * exactly as `deliverInbound` does for steering that found no tool result
1682
+ * to attach to.
1683
+ *
1684
+ * That drain is also what keeps one exit from being delivered twice: the
1685
+ * channel hands its text over once, so an exit already attached to a tool
1686
+ * result earlier in the turn leaves nothing here — and the record of it
1687
+ * went with that delivery, so this returns `false` rather than buying a
1688
+ * turn to re-read what the model has read.
1689
+ *
1690
+ * `takeDelivery` is what pairs the two. Taking the exits first and then
1691
+ * finding no notice would discard them, which is the one way this path
1692
+ * can lose an exit outright; neither is taken unless both are there.
1693
+ *
1694
+ * The channel is not per-job, so the text taken here can include a notice
1695
+ * for a job nobody awaited that ended while the hold was open. Delivering
1696
+ * it is right — it is unread either way, and the alternative is stranding
1697
+ * it — but it is not a reason to WAIT, which is why what opens this hold
1698
+ * is `AwaitedJobs`, and the two are asked separately.
1699
+ */
1700
+ deliverAwaitedJobExits() {
1701
+ const delivered = this.ctx.awaitedJobs?.takeDelivery(() => this.ctx.jobNotices?.drain());
1702
+ if (!delivered)
1703
+ return false;
1704
+ this.ctx.log.info('Delivering a background job exit the run held open for', {
1705
+ [NAMZU.RUN_ID]: this.ctx.runMgr.id,
1706
+ 'namzu.runtime.jobs': delivered.exits.map((job) => job.id),
1707
+ });
1708
+ this.ctx.runMgr.pushMessage(createRuntimeContextMessage(formatJobNote(delivered.text), 'job-exit'));
1709
+ return true;
1710
+ }
1711
+ /**
1712
+ * Account for outstanding work on the way out: deliver what arrived, and
1713
+ * say what did not.
1596
1714
  *
1597
1715
  * A run that ends with a worker outstanding must not leave the impression
1598
1716
  * that the worker's result was delivered. There are exactly two honest
@@ -1616,18 +1734,31 @@ export class IterationOrchestrator {
1616
1734
  */
1617
1735
  settleOutstandingWork() {
1618
1736
  this.deliverArrivedCompletions();
1737
+ this.deliverArrivedJobExits();
1619
1738
  this.recordAbandonedWork();
1620
1739
  }
1621
- /** Delegated work this run walked away from. See {@link settleOutstandingWork}. */
1740
+ /** Work this run walked away from. See {@link settleOutstandingWork}. */
1622
1741
  recordAbandonedWork() {
1623
1742
  const abandoned = this.ctx.completionInbox?.outstandingTaskIds ?? [];
1624
- if (abandoned.length === 0)
1743
+ if (abandoned.length > 0) {
1744
+ this.ctx.log.warn('Run ended with delegated work still running', {
1745
+ [NAMZU.RUN_ID]: this.ctx.runMgr.id,
1746
+ 'namzu.runtime.tasks': abandoned,
1747
+ });
1748
+ this.ctx.runMgr.setAbandonedTaskIds(abandoned);
1749
+ }
1750
+ // The same statement for a job the model was waiting on when the grace
1751
+ // ran out. Only awaited ones: a job nobody waited for was never work
1752
+ // this run was holding, so naming it would report an abandonment that
1753
+ // did not happen.
1754
+ const abandonedJobs = this.ctx.awaitedJobs?.outstandingJobIds ?? [];
1755
+ if (abandonedJobs.length === 0)
1625
1756
  return;
1626
- this.ctx.log.warn('Run ended with delegated work still running', {
1757
+ this.ctx.log.warn('Run ended with an awaited background job still running', {
1627
1758
  [NAMZU.RUN_ID]: this.ctx.runMgr.id,
1628
- 'namzu.runtime.tasks': abandoned,
1759
+ 'namzu.runtime.jobs': abandonedJobs,
1629
1760
  });
1630
- this.ctx.runMgr.setAbandonedTaskIds(abandoned);
1761
+ this.ctx.runMgr.setAbandonedJobIds(abandonedJobs);
1631
1762
  }
1632
1763
  deliverArrivedCompletions() {
1633
1764
  const unheard = this.ctx.completionInbox?.drain() ?? [];
@@ -1658,6 +1789,40 @@ export class IterationOrchestrator {
1658
1789
  });
1659
1790
  this.ctx.runMgr.pushMessage(createRuntimeContextMessage(formatCompletionNotification(unheard), 'task-completion'));
1660
1791
  }
1792
+ /**
1793
+ * The job half of {@link deliverArrivedCompletions}: an exit that arrived
1794
+ * too late to earn a turn is still delivered on the way out.
1795
+ *
1796
+ * The window this closes is one tick wide and it is nobody else's. An
1797
+ * awaited job that exits between the hold's grace expiring and the run
1798
+ * settling was never delivered — the hold had already looked — and is no
1799
+ * longer named either, because the exit took it off the outstanding list
1800
+ * on its way past, so `abandonedJobIds` would be lying to claim it. The
1801
+ * host's own listener is no help: the CLI queues an exit for the next
1802
+ * turn only when no run is in flight, and this one is still in flight.
1803
+ * Delivered here it reaches `Run.messages`, so the transcript has it and
1804
+ * a continued thread opens with it.
1805
+ *
1806
+ * Before `recordAbandonedWork`, which then reports only what is still
1807
+ * running, and after `deliverArrivedCompletions`, so the two appended
1808
+ * messages land in the order the work finished in.
1809
+ */
1810
+ deliverArrivedJobExits() {
1811
+ const delivered = this.ctx.awaitedJobs?.takeDelivery(() => this.ctx.jobNotices?.drain());
1812
+ if (!delivered)
1813
+ return;
1814
+ // Fix the run's answer BEFORE appending anything after it — the same
1815
+ // `resolveResult` tail walk `deliverArrivedCompletions` explains just
1816
+ // above, and the same guard against pinning an empty one.
1817
+ const answer = this.ctx.runMgr.materializeResult();
1818
+ if (answer.length > 0)
1819
+ this.ctx.runMgr.setResult(answer);
1820
+ this.ctx.log.info('Delivering a background job exit the run would have settled over', {
1821
+ [NAMZU.RUN_ID]: this.ctx.runMgr.id,
1822
+ 'namzu.runtime.jobs': delivered.exits.map((job) => job.id),
1823
+ });
1824
+ this.ctx.runMgr.pushMessage(createRuntimeContextMessage(formatJobNote(delivered.text), 'job-exit'));
1825
+ }
1661
1826
  stepContextMessage(content) {
1662
1827
  return createRuntimeContextMessage(`Current step context (runtime-generated; not a new user request):\n${content}`, 'step-context');
1663
1828
  }