@telora/daemon 0.22.85 → 0.22.87

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 (260) hide show
  1. package/build-info.json +4 -3
  2. package/dist/ci/ci-escalation.d.ts +17 -1
  3. package/dist/ci/ci-escalation.d.ts.map +1 -1
  4. package/dist/ci/ci-escalation.js +51 -3
  5. package/dist/ci/ci-escalation.js.map +1 -1
  6. package/dist/ci/ci-red-attribution.d.ts +85 -0
  7. package/dist/ci/ci-red-attribution.d.ts.map +1 -0
  8. package/dist/ci/ci-red-attribution.js +108 -0
  9. package/dist/ci/ci-red-attribution.js.map +1 -0
  10. package/dist/ci/ci-sensor-loop.d.ts +84 -80
  11. package/dist/ci/ci-sensor-loop.d.ts.map +1 -1
  12. package/dist/ci/ci-sensor-loop.js +132 -129
  13. package/dist/ci/ci-sensor-loop.js.map +1 -1
  14. package/dist/completion/coverage-stabilized.d.ts.map +1 -1
  15. package/dist/completion/coverage-stabilized.js +1 -1
  16. package/dist/completion/coverage-stabilized.js.map +1 -1
  17. package/dist/completion/escalations.d.ts +40 -5
  18. package/dist/completion/escalations.d.ts.map +1 -1
  19. package/dist/completion/escalations.js +19 -11
  20. package/dist/completion/escalations.js.map +1 -1
  21. package/dist/completion/event-escalations.d.ts +12 -1
  22. package/dist/completion/event-escalations.d.ts.map +1 -1
  23. package/dist/completion/event-escalations.js +73 -37
  24. package/dist/completion/event-escalations.js.map +1 -1
  25. package/dist/completion/event.d.ts +4 -1
  26. package/dist/completion/event.d.ts.map +1 -1
  27. package/dist/completion/event.js +1 -1
  28. package/dist/completion/event.js.map +1 -1
  29. package/dist/completion/human-only-act.d.ts +1 -1
  30. package/dist/completion/human-only-act.js +1 -1
  31. package/dist/completion/machine-remediable-demotion.d.ts +28 -1
  32. package/dist/completion/machine-remediable-demotion.d.ts.map +1 -1
  33. package/dist/completion/machine-remediable-demotion.js +9 -6
  34. package/dist/completion/machine-remediable-demotion.js.map +1 -1
  35. package/dist/completion/read-only-pristine.d.ts.map +1 -1
  36. package/dist/completion/read-only-pristine.js +4 -0
  37. package/dist/completion/read-only-pristine.js.map +1 -1
  38. package/dist/completion/review-exit-deps.js +1 -1
  39. package/dist/completion/review-exit-deps.js.map +1 -1
  40. package/dist/completion/review-exit-escalations.d.ts +10 -0
  41. package/dist/completion/review-exit-escalations.d.ts.map +1 -1
  42. package/dist/completion/review-exit-escalations.js +38 -3
  43. package/dist/completion/review-exit-escalations.js.map +1 -1
  44. package/dist/completion/review-exit-phase.d.ts.map +1 -1
  45. package/dist/completion/review-exit-phase.js +8 -7
  46. package/dist/completion/review-exit-phase.js.map +1 -1
  47. package/dist/completion/review-exit-shortfall.d.ts +5 -1
  48. package/dist/completion/review-exit-shortfall.d.ts.map +1 -1
  49. package/dist/completion/review-exit-shortfall.js +8 -3
  50. package/dist/completion/review-exit-shortfall.js.map +1 -1
  51. package/dist/completion/status-advance-phase.d.ts +2 -0
  52. package/dist/completion/status-advance-phase.d.ts.map +1 -1
  53. package/dist/completion/status-advance-phase.js +2 -1
  54. package/dist/completion/status-advance-phase.js.map +1 -1
  55. package/dist/completion/terminated-work-reconcile.js +2 -2
  56. package/dist/completion/terminated-work-reconcile.js.map +1 -1
  57. package/dist/delivery/delivery-advance-block.d.ts +3 -3
  58. package/dist/delivery/delivery-advance-block.d.ts.map +1 -1
  59. package/dist/delivery/delivery-advance-block.js +13 -8
  60. package/dist/delivery/delivery-advance-block.js.map +1 -1
  61. package/dist/delivery/delivery-lifecycle.d.ts +10 -0
  62. package/dist/delivery/delivery-lifecycle.d.ts.map +1 -1
  63. package/dist/delivery/delivery-lifecycle.js +12 -6
  64. package/dist/delivery/delivery-lifecycle.js.map +1 -1
  65. package/dist/delivery/delivery-merge-ops.d.ts.map +1 -1
  66. package/dist/delivery/delivery-merge-ops.js +39 -21
  67. package/dist/delivery/delivery-merge-ops.js.map +1 -1
  68. package/dist/delivery/external-hold.d.ts +8 -2
  69. package/dist/delivery/external-hold.d.ts.map +1 -1
  70. package/dist/delivery/external-hold.js +12 -5
  71. package/dist/delivery/external-hold.js.map +1 -1
  72. package/dist/delivery/operator-hold.d.ts +6 -1
  73. package/dist/delivery/operator-hold.d.ts.map +1 -1
  74. package/dist/delivery/operator-hold.js +31 -10
  75. package/dist/delivery/operator-hold.js.map +1 -1
  76. package/dist/delivery/paused-revival.d.ts +6 -0
  77. package/dist/delivery/paused-revival.d.ts.map +1 -1
  78. package/dist/delivery/paused-revival.js +3 -1
  79. package/dist/delivery/paused-revival.js.map +1 -1
  80. package/dist/directive/directive-dispatch.d.ts.map +1 -1
  81. package/dist/directive/directive-dispatch.js +6 -3
  82. package/dist/directive/directive-dispatch.js.map +1 -1
  83. package/dist/directive/directive-queue.d.ts.map +1 -1
  84. package/dist/directive/directive-queue.js +4 -1
  85. package/dist/directive/directive-queue.js.map +1 -1
  86. package/dist/drift/staleness-sensor.d.ts +14 -0
  87. package/dist/drift/staleness-sensor.d.ts.map +1 -1
  88. package/dist/drift/staleness-sensor.js +30 -1
  89. package/dist/drift/staleness-sensor.js.map +1 -1
  90. package/dist/focus/focus-executor.d.ts.map +1 -1
  91. package/dist/focus/focus-executor.js +6 -8
  92. package/dist/focus/focus-executor.js.map +1 -1
  93. package/dist/focus/focus-loop-registry.d.ts.map +1 -1
  94. package/dist/focus/focus-loop-registry.js +23 -13
  95. package/dist/focus/focus-loop-registry.js.map +1 -1
  96. package/dist/focus/focus-respawn-guard.d.ts.map +1 -1
  97. package/dist/focus/focus-respawn-guard.js +21 -4
  98. package/dist/focus/focus-respawn-guard.js.map +1 -1
  99. package/dist/focus/listener-auto-advance.d.ts.map +1 -1
  100. package/dist/focus/listener-auto-advance.js +10 -6
  101. package/dist/focus/listener-auto-advance.js.map +1 -1
  102. package/dist/focus/verify-open-issues.d.ts.map +1 -1
  103. package/dist/focus/verify-open-issues.js +21 -1
  104. package/dist/focus/verify-open-issues.js.map +1 -1
  105. package/dist/focus-stage-lifecycle/wedge-watchdog.d.ts.map +1 -1
  106. package/dist/focus-stage-lifecycle/wedge-watchdog.js +35 -14
  107. package/dist/focus-stage-lifecycle/wedge-watchdog.js.map +1 -1
  108. package/dist/loops/index.d.ts.map +1 -1
  109. package/dist/loops/index.js +2 -1
  110. package/dist/loops/index.js.map +1 -1
  111. package/dist/merge/focus-merge.d.ts +1 -1
  112. package/dist/merge/focus-merge.d.ts.map +1 -1
  113. package/dist/merge/focus-merge.js +5 -1
  114. package/dist/merge/focus-merge.js.map +1 -1
  115. package/dist/merge/merge-back-loop.d.ts +2 -1
  116. package/dist/merge/merge-back-loop.d.ts.map +1 -1
  117. package/dist/merge/merge-back-loop.js +15 -12
  118. package/dist/merge/merge-back-loop.js.map +1 -1
  119. package/dist/merge/merge-conflict-escalation.d.ts.map +1 -1
  120. package/dist/merge/merge-conflict-escalation.js +4 -0
  121. package/dist/merge/merge-conflict-escalation.js.map +1 -1
  122. package/dist/nexus/return.d.ts +14 -15
  123. package/dist/nexus/return.d.ts.map +1 -1
  124. package/dist/nexus/return.js +41 -31
  125. package/dist/nexus/return.js.map +1 -1
  126. package/dist/prompt-sections/execution-sections.d.ts +13 -5
  127. package/dist/prompt-sections/execution-sections.d.ts.map +1 -1
  128. package/dist/prompt-sections/execution-sections.js +78 -18
  129. package/dist/prompt-sections/execution-sections.js.map +1 -1
  130. package/dist/prompt-sections/routing-decider-sections.d.ts +60 -0
  131. package/dist/prompt-sections/routing-decider-sections.d.ts.map +1 -0
  132. package/dist/prompt-sections/routing-decider-sections.js +197 -0
  133. package/dist/prompt-sections/routing-decider-sections.js.map +1 -0
  134. package/dist/queries/agent-resolvable-aging.d.ts +4 -3
  135. package/dist/queries/agent-resolvable-aging.d.ts.map +1 -1
  136. package/dist/queries/agent-resolvable-aging.js +5 -4
  137. package/dist/queries/agent-resolvable-aging.js.map +1 -1
  138. package/dist/queries/agent-resolvable-promotion.d.ts +172 -124
  139. package/dist/queries/agent-resolvable-promotion.d.ts.map +1 -1
  140. package/dist/queries/agent-resolvable-promotion.js +332 -307
  141. package/dist/queries/agent-resolvable-promotion.js.map +1 -1
  142. package/dist/queries/agent-resolvable-retirement.d.ts +105 -0
  143. package/dist/queries/agent-resolvable-retirement.d.ts.map +1 -0
  144. package/dist/queries/agent-resolvable-retirement.js +227 -0
  145. package/dist/queries/agent-resolvable-retirement.js.map +1 -0
  146. package/dist/queries/cause-key.d.ts +109 -0
  147. package/dist/queries/cause-key.d.ts.map +1 -1
  148. package/dist/queries/cause-key.js +93 -1
  149. package/dist/queries/cause-key.js.map +1 -1
  150. package/dist/queries/deliveries.d.ts +14 -0
  151. package/dist/queries/deliveries.d.ts.map +1 -1
  152. package/dist/queries/deliveries.js +20 -2
  153. package/dist/queries/deliveries.js.map +1 -1
  154. package/dist/queries/delivery-gate-state.d.ts +130 -5
  155. package/dist/queries/delivery-gate-state.d.ts.map +1 -1
  156. package/dist/queries/delivery-gate-state.js +111 -2
  157. package/dist/queries/delivery-gate-state.js.map +1 -1
  158. package/dist/queries/escalation-consumption.d.ts +95 -15
  159. package/dist/queries/escalation-consumption.d.ts.map +1 -1
  160. package/dist/queries/escalation-consumption.js +162 -24
  161. package/dist/queries/escalation-consumption.js.map +1 -1
  162. package/dist/queries/escalation-dispositions.d.ts +19 -7
  163. package/dist/queries/escalation-dispositions.d.ts.map +1 -1
  164. package/dist/queries/escalation-dispositions.js +7 -0
  165. package/dist/queries/escalation-dispositions.js.map +1 -1
  166. package/dist/queries/escalation-suppression.d.ts +63 -19
  167. package/dist/queries/escalation-suppression.d.ts.map +1 -1
  168. package/dist/queries/escalation-suppression.js +64 -35
  169. package/dist/queries/escalation-suppression.js.map +1 -1
  170. package/dist/queries/escalations.d.ts +127 -70
  171. package/dist/queries/escalations.d.ts.map +1 -1
  172. package/dist/queries/escalations.js +170 -188
  173. package/dist/queries/escalations.js.map +1 -1
  174. package/dist/queries/filing-observation.d.ts +82 -0
  175. package/dist/queries/filing-observation.d.ts.map +1 -0
  176. package/dist/queries/filing-observation.js +157 -0
  177. package/dist/queries/filing-observation.js.map +1 -0
  178. package/dist/queries/holds.d.ts +69 -2
  179. package/dist/queries/holds.d.ts.map +1 -1
  180. package/dist/queries/holds.js +106 -11
  181. package/dist/queries/holds.js.map +1 -1
  182. package/dist/queries/incident-correlation.d.ts +291 -0
  183. package/dist/queries/incident-correlation.d.ts.map +1 -0
  184. package/dist/queries/incident-correlation.js +408 -0
  185. package/dist/queries/incident-correlation.js.map +1 -0
  186. package/dist/queries/routing-measurement.d.ts +48 -1
  187. package/dist/queries/routing-measurement.d.ts.map +1 -1
  188. package/dist/queries/routing-measurement.js +64 -2
  189. package/dist/queries/routing-measurement.js.map +1 -1
  190. package/dist/queries/routing-quality.d.ts +8 -0
  191. package/dist/queries/routing-quality.d.ts.map +1 -1
  192. package/dist/queries/routing-quality.js +12 -3
  193. package/dist/queries/routing-quality.js.map +1 -1
  194. package/dist/queries/shared.js +1 -1
  195. package/dist/shell/crash-recovery.d.ts.map +1 -1
  196. package/dist/shell/crash-recovery.js +6 -6
  197. package/dist/shell/crash-recovery.js.map +1 -1
  198. package/dist/shell/install-probe-tick.d.ts.map +1 -1
  199. package/dist/shell/install-probe-tick.js +3 -1
  200. package/dist/shell/install-probe-tick.js.map +1 -1
  201. package/dist/spawn/respawn-cycle-escalation.d.ts +10 -2
  202. package/dist/spawn/respawn-cycle-escalation.d.ts.map +1 -1
  203. package/dist/spawn/respawn-cycle-escalation.js +16 -2
  204. package/dist/spawn/respawn-cycle-escalation.js.map +1 -1
  205. package/dist/spawn/routing-decider-spawn.d.ts +93 -0
  206. package/dist/spawn/routing-decider-spawn.d.ts.map +1 -0
  207. package/dist/spawn/routing-decider-spawn.js +185 -0
  208. package/dist/spawn/routing-decider-spawn.js.map +1 -0
  209. package/dist/spawn/routing-decider.d.ts +42 -0
  210. package/dist/spawn/routing-decider.d.ts.map +1 -0
  211. package/dist/spawn/routing-decider.js +95 -0
  212. package/dist/spawn/routing-decider.js.map +1 -0
  213. package/dist/spawn/shared.d.ts +8 -0
  214. package/dist/spawn/shared.d.ts.map +1 -1
  215. package/dist/spawn/shared.js +1 -1
  216. package/dist/spawn/shared.js.map +1 -1
  217. package/dist/spawn/spawn-decision.d.ts +23 -16
  218. package/dist/spawn/spawn-decision.d.ts.map +1 -1
  219. package/dist/spawn/spawn-decision.js +27 -11
  220. package/dist/spawn/spawn-decision.js.map +1 -1
  221. package/dist/spawn/worktree-session.d.ts +19 -7
  222. package/dist/spawn/worktree-session.d.ts.map +1 -1
  223. package/dist/spawn/worktree-session.js +36 -8
  224. package/dist/spawn/worktree-session.js.map +1 -1
  225. package/dist/testing/fixtures/index.js +1 -1
  226. package/dist/types/index.js +1 -1
  227. package/dist/verify/failure-signature.d.ts +91 -0
  228. package/dist/verify/failure-signature.d.ts.map +1 -0
  229. package/dist/verify/failure-signature.js +233 -0
  230. package/dist/verify/failure-signature.js.map +1 -0
  231. package/dist/verify/shortfall-repeat.d.ts +12 -9
  232. package/dist/verify/shortfall-repeat.d.ts.map +1 -1
  233. package/dist/verify/shortfall-repeat.js +38 -14
  234. package/dist/verify/shortfall-repeat.js.map +1 -1
  235. package/dist/verify/stall-detectors.d.ts +19 -43
  236. package/dist/verify/stall-detectors.d.ts.map +1 -1
  237. package/dist/verify/stall-detectors.js +26 -101
  238. package/dist/verify/stall-detectors.js.map +1 -1
  239. package/dist/verify/unreviewed-settle-detector.d.ts +8 -0
  240. package/dist/verify/unreviewed-settle-detector.d.ts.map +1 -1
  241. package/dist/verify/unreviewed-settle-detector.js +15 -1
  242. package/dist/verify/unreviewed-settle-detector.js.map +1 -1
  243. package/dist/verify/verification-deps-default.d.ts.map +1 -1
  244. package/dist/verify/verification-deps-default.js +14 -2
  245. package/dist/verify/verification-deps-default.js.map +1 -1
  246. package/dist/verify/verification-evidence-unavailable-feedback.d.ts.map +1 -1
  247. package/dist/verify/verification-evidence-unavailable-feedback.js +8 -2
  248. package/dist/verify/verification-evidence-unavailable-feedback.js.map +1 -1
  249. package/dist/verify/verification-feedback.d.ts +62 -14
  250. package/dist/verify/verification-feedback.d.ts.map +1 -1
  251. package/dist/verify/verification-feedback.js +101 -28
  252. package/dist/verify/verification-feedback.js.map +1 -1
  253. package/dist/verify/verify-gate-stall.d.ts +116 -0
  254. package/dist/verify/verify-gate-stall.d.ts.map +1 -0
  255. package/dist/verify/verify-gate-stall.js +169 -0
  256. package/dist/verify/verify-gate-stall.js.map +1 -0
  257. package/dist/verify/verify-restatement.d.ts.map +1 -1
  258. package/dist/verify/verify-restatement.js +2 -1
  259. package/dist/verify/verify-restatement.js.map +1 -1
  260. package/package.json +2 -2
@@ -1,84 +1,119 @@
1
1
  // PINNED-BY-BEGIN (generated by gen:pinned-by -- do not hand-edit; checked by lint:pinned-by)
2
- // Pinned by: packages/daemon/src/queries/agent-resolvable-promotion-sweep.test.ts,
2
+ // Pinned by: packages/daemon/src/integration/resolution-class-routing-flow.test.ts,
3
+ // packages/daemon/src/queries/agent-resolvable-promotion-sweep.test.ts,
3
4
  // packages/daemon/src/queries/agent-resolvable-promotion.test.ts,
4
- // packages/daemon/src/queries/agent-resolvable-retired-focus.test.ts
5
+ // packages/daemon/src/queries/agent-resolvable-retired-focus.test.ts,
6
+ // packages/daemon/src/spawn/routing-decider-spawn.test.ts
5
7
  // PINNED-BY-END
6
8
  /**
7
- * agent_resolvable promotion safety net (delivery "agent_resolvable never
8
- * reaches the human inbox", TEL-3).
9
+ * The agent_resolvable ROUTING HAND-OFF (formerly the promotion backstop).
9
10
  *
10
- * agent_resolvable filings are machine-owned: hidden from the operator inbox
11
- * (TEL-1) and ridden into the focus's next coding spawn to be resolved (TEL-2).
12
- * That is only safe if a filing the machine CANNOT actually clear eventually
13
- * surfaces to a human -- otherwise it lingers forever, invisible. This module is
14
- * that backstop: an agent_resolvable filing that has stayed open past a bound
15
- * (it rode into many spawn cycles and none resolved it -- "bounded failures") is
16
- * PROMOTED to a human by resolving the machine-routed row and refiling the same
17
- * content as a needs_operator escalation, which the operator inbox DOES show.
11
+ * agent_resolvable filings are machine-owned: hidden from the operator inbox and
12
+ * ridden into the focus's next coding spawn, whose agent resolves or routes each.
13
+ * Every daemon detector files into this lane (the createEscalation chokepoint
14
+ * turns any other disposition into an OBSERVATION, metadata.observation), so the
15
+ * daemon has NO human-facing route of its own. Some observations no coding spawn
16
+ * will ever route: the focus is un-armed or stop-held, the filing has no focus,
17
+ * the condition outlives a converged focus, or spawns rode it in and none cleared
18
+ * it. This module is how those still reach a decision.
18
19
  *
19
- * Gate: RECORDED ATTEMPT EVIDENCE, not elapsed open-age. Open-age was the
20
- * original proxy for "it survived N spawn cycles unresolved", but the proxy is
21
- * inverted in practice: a filing anchored to an un-armed or wedged focus ages
22
- * past the bound having had ZERO agent sessions, and promoting it hands a human a
23
- * FIRST attempt dressed up as an exhausted machine. So promotion now gates on the
24
- * actual count of agent sessions that ran on the filing's focus since it was
25
- * created (countFocusSessionsSince): a filing with zero recorded attempts NEVER
26
- * promotes, regardless of age. Open-age is retained only as a cheap CANDIDATE
27
- * pre-filter (don't even count sessions for a fresh filing, which matters for the
28
- * org-wide sweep's up-to-500-row scan); the promotion DECISION is the attempt
29
- * count, and the count is carried into the promoted row's metadata so the human
30
- * lane shows the evidence of the machine attempts that failed.
20
+ * Only an AGENT makes a filing human-facing. The old backstop refiled such a
21
+ * filing as needs_operator deterministically -- surfacing it to a human with no
22
+ * agent deciding (the verify shortfall this delivery closes). Now the org-wide
23
+ * sweep HANDS each eligible observation to a one-shot ROUTING DECIDER
24
+ * (spawn/routing-decider.ts, injected here as `decide`): an agent that assembles
25
+ * the intent ladder, then resolves the condition itself, declines noise, keeps it
26
+ * in the lane with a recorded decision, or files ONE telora_agent escalate naming
27
+ * the specific act only a human can perform, with sourceEscalationId lineage.
31
28
  *
32
- * A filing that cannot accrue attempts -- one with no focus anchor -- therefore
33
- * does not promote either: it has no machine lane to be attempted in. New
34
- * anchorless agent_resolvable filings are refused at the filing boundary, and
35
- * un-taken machine-lane filings are made observable by the aging observer rather
36
- * than dumped on a human.
29
+ * ELIGIBILITY (evaluateCandidate):
30
+ * - immediately, with no age bound:
31
+ * stop_held -- anchored to a focus a stop-kind hold blocks (isStopHoldObservation
32
+ * on any open observation of that focus): no coding team spawns there.
33
+ * unreachable -- no focus anchor, or an un-armed focus: no coding spawn reaches it.
34
+ * outlives_focus -- its focus retired but the condition outlives it (agent-resolvable-retirement.ts).
35
+ * - past the age bound (TELORA_AGENT_RESOLVABLE_PROMOTE_HOURS), on RECORDED ATTEMPT
36
+ * EVIDENCE (promotionBasis): 'exhausted' (>= minAttempts agent sessions ran on its
37
+ * focus and none cleared it) or 'unreachable' (a zero-attempt observation). Open-age
38
+ * alone never hands off a plain filing.
39
+ * A focus-scoped candidate on a retired focus is still retired auto_swept.
37
40
  *
38
- * Promotion is idempotent: the needs_operator refile rides the shared cause-keyed
39
- * gate keyed on the original escalation id, and the source row is resolved in the
40
- * same step, so a filing is promoted at most once.
41
+ * BOUNDS: at most maxPerSweep hand-offs per sweep (ROUTING_DECIDER_MAX_PER_SWEEP),
42
+ * each needing a governor slot inside the decider. Each hand-off whose decider RAN is
43
+ * recorded on the observation (metadata decider_attempts / last_decided_at /
44
+ * last_decider_outcome) and is not re-handed inside the decider window. A decider
45
+ * that cannot run (no slot, spawn error) or fails leaves the observation OPEN in the
46
+ * lane -- there is no deterministic human fallback -- and is counted `undecided` in
47
+ * the sweep counts the loop tick logs.
48
+ *
49
+ * CLOSE THE SOURCE: an open human-lane row whose metadata.originalEscalationId names
50
+ * an open observation means a decider already made it human-facing; the sweep closes
51
+ * the observation auto_swept (no actor, so never judged -- the mis-route measurement
52
+ * judges the decider row's eventual resolver against this source's lane).
41
53
  */
42
- import { ESCALATION_REASONS, ESCALATION_DISPOSITIONS, DELIVERY_STATUS, loadEnvConfig, } from '@telora/daemon-core';
43
- import { markEscalationResolved as defaultMarkEscalationResolved, ESCALATION_DESCRIPTION_MAX } from './escalations.js';
44
- import { getFocusDeliveries as defaultGetFocusDeliveries } from './focuses.js';
45
- import { getActiveFocuses as defaultGetActiveFocuses } from './focus-discovery.js';
54
+ import { loadEnvConfig } from '@telora/daemon-core';
55
+ import { markEscalationResolved as defaultMarkEscalationResolved } from './escalations.js';
56
+ import { checkFocusRetirement, defaultFocusRetired, defaultPriorRetirementOfCause, defaultRetireSwept, memoizeFocusRetired, peekFocusRetirement, } from './agent-resolvable-retirement.js';
46
57
  import { countFocusSessionsSince as defaultCountFocusSessionsSince } from './sessions.js';
47
- import { listOpenAgentResolvableForFocus as defaultListOpenAgentResolvableForFocus, listOpenAgentResolvableForOrg as defaultListOpenAgentResolvableForOrg } from './escalation-consumption.js';
48
- import { fileEscalationIdempotent as defaultFileEscalationIdempotent, } from './escalation-suppression.js';
49
- import { computeCauseKey } from './cause-key.js';
58
+ import { isMachineStopObservation } from './holds.js';
59
+ import { listOpenAgentResolvableForFocus as defaultListOpenAgentResolvableForFocus, listOpenAgentResolvableForOrg as defaultListOpenAgentResolvableForOrg, listOpenLineageCarriersForOrg as defaultListOpenLineageCarriersForOrg, } from './escalation-consumption.js';
60
+ import { callApi } from './shared.js';
50
61
  import { createLogger } from '../shell/log.js';
51
62
  const log = createLogger({ module: 'escalations' });
52
- /** Default hours an agent_resolvable filing may stay open before it is a promotion CANDIDATE. */
63
+ /** Default hours an agent_resolvable filing may stay open before the attempt-evidence gate applies. */
53
64
  export const DEFAULT_AGENT_RESOLVABLE_PROMOTE_HOURS = 24;
54
65
  /**
55
66
  * Minimum recorded agent sessions on a filing's focus (since it was created)
56
- * before it may be promoted to a human. The decisive gate: at least this many
57
- * spawns rode the filing in and none cleared it -- an exhausted machine, not a
58
- * first attempt. A candidate below this floor is NOT promoted regardless of age.
67
+ * before an over-age filing is handed to a routing decider on the 'exhausted'
68
+ * basis: at least this many spawns rode it in and none cleared it.
59
69
  */
60
70
  export const DEFAULT_AGENT_RESOLVABLE_PROMOTE_MIN_ATTEMPTS = 1;
61
- /** Pure: whether a filing's recorded attempts meet the promotion floor. */
71
+ /**
72
+ * Hand-offs per sweep. Recorded choice: 2. Each hand-off is an Opus one-shot
73
+ * session (spawn/routing-decider-spawn.ts ROUTING_DECIDER_MODEL); on the 30-minute
74
+ * sweep cadence 2 bounds worst-case decider spend to ~96 sessions/day while a
75
+ * backlog still drains in priority order (stop_held first). Eligible observations
76
+ * past the cap are counted `deferred` and handed on a later sweep.
77
+ */
78
+ export const ROUTING_DECIDER_MAX_PER_SWEEP = 2;
79
+ /** Pure: whether a filing's recorded attempts meet the floor. */
62
80
  export function meetsAttemptFloor(attempts, minAttempts) {
63
81
  return attempts >= minAttempts;
64
82
  }
65
83
  /**
66
- * Resolve the promotion threshold (hours -> ms) from the typed, Zod-validated
67
- * env-config -- the single declaration site for every operational knob
84
+ * Pure: the evidence basis on which an OVER-AGE candidate is handed off, or null
85
+ * (it stays in the machine lane).
86
+ * - 'exhausted': at least minAttempts agent sessions ran on its focus since it
87
+ * was filed and none cleared it.
88
+ * - 'unreachable': it is a detector OBSERVATION and NO agent session has reached it.
89
+ * A plain (non-observation) filing with zero attempts is not handed off.
90
+ */
91
+ export function promotionBasis(attempts, minAttempts, isObservation) {
92
+ if (meetsAttemptFloor(attempts, minAttempts))
93
+ return 'exhausted';
94
+ if (attempts === 0 && isObservation)
95
+ return 'unreachable';
96
+ return null;
97
+ }
98
+ /** Pure: whole hours a filing has been open (>= 1; an unparseable createdAt reads as 1). */
99
+ export function openAgeHours(createdAt, nowMs) {
100
+ const created = Date.parse(createdAt);
101
+ if (Number.isNaN(created))
102
+ return 1;
103
+ return Math.max(1, Math.floor((nowMs - created) / 3_600_000));
104
+ }
105
+ /**
106
+ * Resolve the age bound (hours -> ms) from the typed, Zod-validated env-config
68
107
  * (packages/daemon-core/src/env-config.ts). An unset value yields the schema
69
- * default (DEFAULT_AGENT_RESOLVABLE_PROMOTE_HOURS); an invalid one fails loudly
70
- * at startup rather than silently degrading to a default here.
71
- *
72
- * Same shape as resolveStuckBranchThresholdMs in merge-back-loop.ts.
108
+ * default; an invalid one fails loudly at startup.
73
109
  */
74
110
  export function resolveAgentResolvablePromoteMs(env = process.env) {
75
111
  return loadEnvConfig(env).TELORA_AGENT_RESOLVABLE_PROMOTE_HOURS * 3_600_000;
76
112
  }
77
113
  /**
78
- * Pure: split the open agent_resolvable filings into the ones that should still
79
- * RIDE into this spawn and the ones that are past the age bound and should be
80
- * PROMOTED to a human. A filing with an unparseable/absent createdAt is treated
81
- * as fresh (ride) -- promotion must never fire on a missing timestamp.
114
+ * Pure: split open filings into the ones still within the age bound (ride) and
115
+ * the ones past it (promote = over-age candidates). An absent/unparseable
116
+ * createdAt is treated as fresh -- nothing is ever decided on a missing timestamp.
82
117
  */
83
118
  export function partitionAgentResolvableByAge(items, nowMs, thresholdMs) {
84
119
  const ride = [];
@@ -94,303 +129,293 @@ export function partitionAgentResolvableByAge(items, nowMs, thresholdMs) {
94
129
  }
95
130
  return { ride, promote };
96
131
  }
97
- /** The cause key that makes a promotion refile idempotent (one per original filing). */
98
- export function agentResolvablePromotionCauseKey(originalEscalationId) {
99
- return computeCauseKey('agent_resolvable_promoted', { escalationId: originalEscalationId });
100
- }
101
- /** escalation_kind for a promoted (formerly agent_resolvable) filing. */
102
- export const AGENT_RESOLVABLE_PROMOTED_KIND = 'agent_resolvable_promoted';
132
+ // ---------------------------------------------------------------------------
133
+ // Stop-kind holds
134
+ // ---------------------------------------------------------------------------
103
135
  /**
104
- * Fit a promotion refile's description under the escalation cap. The invariant
105
- * `head` (the promotion preamble: attempt count, original kind, original filing
106
- * id) is always kept; only the variable-length original `body` is truncated,
107
- * ending with a marker pointing at the original filing (which carries the full
108
- * text) so nothing is silently dropped and no refile is ever rejected for length.
109
- *
110
- * Without this, a candidate filed at exactly the cap (Nexus notifications fit to
111
- * ESCALATION_DESCRIPTION_MAX with zero headroom) produced a ~5300-char refile the
112
- * edge rejected on every sweep tick -- the row could never leave the machine
113
- * lane, and `description` is not writable through product-api to trim in place.
114
- * Modeled on nexus/return.ts fitNotificationDescription (invariant head kept,
115
- * variable tail trimmed with a pointer). Pure + exported for direct unit testing.
136
+ * Blocks respawn by KIND alone, whatever it proposes and whatever its disposition
137
+ * (queries/escalations.ts REVIEW_COVERAGE_INCOMPLETE_ESCALATION_KIND, parked by the
138
+ * respawn guard directly).
116
139
  */
117
- export function fitPromotionDescription(head, body, originalFilingId, cap = ESCALATION_DESCRIPTION_MAX) {
118
- const full = `${head}${body}`;
119
- if (full.length <= cap)
120
- return full;
121
- const marker = `...\n\n[Original filing body truncated to fit the ${cap}-char escalation ` +
122
- `cap -- full text on escalation ${originalFilingId}.]`;
123
- const budget = cap - head.length - marker.length;
124
- if (budget <= 0) {
125
- // Even head + marker do not fit: keep the head, drop the body, point to the record.
126
- const minimal = `${head}[Original filing body omitted for length -- see escalation ${originalFilingId}.]`;
127
- return minimal.length <= cap ? minimal : head.slice(0, cap);
128
- }
129
- return `${head}${body.slice(0, budget)}${marker}`;
130
- }
140
+ const COVERAGE_HOLD_KIND = 'review_coverage_incomplete';
131
141
  /**
132
- * Pure: build the needs_operator refile params for a filing the machine could
133
- * not resolve autonomously. Carries the original description + kind so the
134
- * operator sees what was tried and why it now needs them; anchored to the focus
135
- * WHEN one is present.
136
- *
137
- * focusId is OPTIONAL. The case that most needs the backstop -- a filing from an
138
- * interactive MCP session -- carries no focus by construction, and the
139
- * needs_operator refile does not need a focus to be VISIBLE: the operator inbox
140
- * predicate (isOperatorInboxDisposition, src/lib/escalations.ts) keys on
141
- * disposition alone, so an unanchored needs_operator promotion is renderable. An
142
- * anchorless promotion simply files org-scoped with an empty anchor; a
143
- * focus-anchored one still stamps the focus for provenance.
142
+ * Pure: whether an open filing is a STOP-kind hold observation -- one whose presence
143
+ * blocks the focus's respawn, so no coding team will reach that focus's observations.
144
+ * The rule is the holds contract's ONE definition, isMachineStopObservation
145
+ * (queries/holds.ts: a MACHINE_STOP_KINDS observation carrying its detector's
146
+ * proposed_human_act), plus a review_coverage_incomplete observation, which the
147
+ * respawn guard parks by kind.
144
148
  */
145
- export function buildAgentResolvablePromotionParams(item, ctx, ageHours, attempts) {
146
- const originalKind = item.escalationKind ?? 'agent_resolvable';
147
- return {
148
- causeKey: agentResolvablePromotionCauseKey(item.id),
149
- organizationId: ctx.organizationId,
150
- issueId: null,
151
- reasonType: ESCALATION_REASONS.BLOCKED_BY_EXTERNAL,
152
- // It was machine-routable, but ${attempts} agent session(s) rode it in over
153
- // ~${ageHours}h and none cleared it -- an exhausted machine, so a human takes it.
154
- disposition: ESCALATION_DISPOSITIONS.NEEDS_OPERATOR,
155
- // Anchor to the focus only when one exists; an anchorless filing files
156
- // org-scoped (still visible in the operator inbox, keyed on disposition).
157
- anchor: ctx.focusId ? { focusId: ctx.focusId } : {},
158
- ...(ctx.productId ? { productId: ctx.productId } : {}),
159
- escalationKind: AGENT_RESOLVABLE_PROMOTED_KIND,
160
- // Compose the invariant preamble (head) + the original filing body (tail),
161
- // then fit under the server-side cap: a candidate filed at the cap would
162
- // otherwise produce an over-cap refile the edge rejects on every sweep tick.
163
- description: fitPromotionDescription(`A machine-routable (agent_resolvable) filing has had ${attempts} agent ` +
164
- `session(s) on its focus (over ~${ageHours}h) without any of them clearing ` +
165
- `it, so it is promoted to you.\n\n` +
166
- `**Original kind:** ${originalKind}\n` +
167
- `**Original filing:** ${item.id}\n` +
168
- `**Machine attempts that failed:** ${attempts}\n\n`, `${item.description ?? ''}`, item.id),
169
- whatWasTried: `The filing rode into the focus's coding spawns across ${attempts} recorded ` +
170
- `agent session(s) over ~${ageHours}h (disposition agent_resolvable) but was ` +
171
- `never resolved, so the machine has exhausted its autonomous attempts.`,
172
- helpNeeded: `Resolve the underlying condition manually, or re-file with clearer intent if ` +
173
- `the machine cannot act on it.`,
174
- metadata: {
175
- cause: AGENT_RESOLVABLE_PROMOTED_KIND,
176
- originalEscalationId: item.id,
177
- originalKind,
178
- ageHours,
179
- // Attempt evidence: the recorded agent sessions that failed to clear this
180
- // filing. This is the proof the human lane carries that the machine was
181
- // exhausted, not merely that time elapsed.
182
- spawnsInWindow: attempts,
183
- },
184
- };
149
+ export function isStopHoldObservation(item) {
150
+ if (!item.observation)
151
+ return false;
152
+ if (item.escalationKind === COVERAGE_HOLD_KIND)
153
+ return true;
154
+ // The item's observation was already read from metadata.observation; hand the holds
155
+ // rule that same parsed observation so the two can never disagree.
156
+ return isMachineStopObservation({
157
+ escalationKind: item.escalationKind,
158
+ metadata: { ...(item.metadata ?? {}), observation: item.observation },
159
+ });
160
+ }
161
+ /** Pure: the focuses a stop-kind hold observation blocks. */
162
+ export function stopHeldFocusIds(items) {
163
+ const held = new Set();
164
+ for (const item of items) {
165
+ if (item.focusId && isStopHoldObservation(item))
166
+ held.add(item.focusId);
167
+ }
168
+ return held;
169
+ }
170
+ // ---------------------------------------------------------------------------
171
+ // Decider attempts (recorded on the observation row)
172
+ // ---------------------------------------------------------------------------
173
+ /** metadata keys a hand-off writes on the observation (escalation_update shallow-merges them). */
174
+ export const DECIDER_ATTEMPTS_KEY = 'decider_attempts';
175
+ export const LAST_DECIDED_AT_KEY = 'last_decided_at';
176
+ export const LAST_DECIDER_OUTCOME_KEY = 'last_decider_outcome';
177
+ /** Pure: the recorded decider attempts on a row (0 / null when absent or malformed). */
178
+ export function readDeciderAttempts(metadata) {
179
+ const raw = metadata?.[DECIDER_ATTEMPTS_KEY];
180
+ const attempts = typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
181
+ const at = metadata?.[LAST_DECIDED_AT_KEY];
182
+ const parsed = typeof at === 'string' ? Date.parse(at) : NaN;
183
+ return { attempts, lastDecidedAtMs: Number.isNaN(parsed) ? null : parsed };
184
+ }
185
+ /** Pure: whether a decider already ran on this row inside the window (do not re-hand it). */
186
+ export function withinDeciderWindow(metadata, nowMs, windowMs) {
187
+ const { lastDecidedAtMs } = readDeciderAttempts(metadata);
188
+ return lastDecidedAtMs !== null && nowMs - lastDecidedAtMs < windowMs;
189
+ }
190
+ /** Record one decider attempt on the observation row (escalation_update merges fields.metadata). */
191
+ export async function recordDeciderAttempt(escalationId, patch, deps = {}) {
192
+ const call = deps.callApi ?? callApi;
193
+ await call('escalation_update', { escalationId, fields: { metadata: { ...patch } } });
194
+ }
195
+ /** The default when no decider is wired: nothing runs, every eligible observation stays in the lane (counted undecided). */
196
+ export const NO_ROUTING_DECIDER = async () => ({
197
+ ran: false,
198
+ decided: false,
199
+ detail: 'no routing decider wired',
200
+ });
201
+ const HANDOFF_PRIORITY = { stop_held: 0, unreachable: 1, outlives_focus: 2, exhausted: 3 };
202
+ /** Pure: hand-off order -- stop_held, unreachable, outlives_focus, exhausted; oldest first within a basis. */
203
+ export function orderHandoffs(requests) {
204
+ return [...requests].sort((a, b) => HANDOFF_PRIORITY[a.basis] - HANDOFF_PRIORITY[b.basis] || a.item.createdAt.localeCompare(b.item.createdAt));
185
205
  }
206
+ /** The retired-focus defaults both paths wire. */
207
+ const defaultRetirementDeps = {
208
+ focusRetired: defaultFocusRetired,
209
+ retireSwept: defaultRetireSwept,
210
+ priorRetirementOfCause: (causeKey, focusId, excludeId) => defaultPriorRetirementOfCause(causeKey, focusId, excludeId),
211
+ };
186
212
  function defaultDeps() {
187
213
  return {
188
214
  listOpen: defaultListOpenAgentResolvableForFocus,
189
- markResolved: defaultMarkEscalationResolved,
190
- file: defaultFileEscalationIdempotent,
191
- countSessionsSince: defaultCountFocusSessionsSince,
192
215
  now: () => Date.now(),
193
216
  thresholdMs: resolveAgentResolvablePromoteMs(),
194
- minAttempts: DEFAULT_AGENT_RESOLVABLE_PROMOTE_MIN_ATTEMPTS,
195
- focusRetired: defaultFocusRetired,
196
- retireSwept: defaultRetireSwept,
217
+ ...defaultRetirementDeps,
197
218
  };
198
219
  }
199
220
  /**
200
- * Reconcile a focus's open agent_resolvable filings and return the ones that
201
- * should ride into THIS spawn. Over-age filings are promoted (needs_operator
202
- * refile + resolve the source) so they leave the machine lane and reach the
203
- * human inbox; the rest are returned to ride in as before.
204
- *
205
- * Best-effort per filing: a promotion failure is logged and the filing is left
206
- * to ride (it will be retried next spawn), never thrown -- reconciling must never
207
- * block a spawn. This is the single entry point the spawn path calls instead of
208
- * listOpenAgentResolvableForFocus.
209
- */
210
- /**
211
- * Whether a candidate's focus has RETIRED -- converged (every delivery done or
212
- * cancelled) or no longer active (its status is complete/cancelled, so it is
213
- * absent from the org's active-focus set). A candidate anchored to such a focus
214
- * describes work that is finished; promoting it to a human would dump a stale row
215
- * on the operator inbox (2026-09-09: escalation 9465a40e promoted onto a focus
216
- * closed for hours). Fail-quiet: any read error returns not-retired, so a
217
- * transient DB blip never wrongly retires a live candidate (it promotes as before).
218
- */
219
- export async function defaultFocusRetired(focusId, ctx) {
220
- try {
221
- const dels = await defaultGetFocusDeliveries(focusId);
222
- if (dels.length > 0 &&
223
- dels.every(x => x.executionStatus === DELIVERY_STATUS.DONE || x.executionStatus === DELIVERY_STATUS.CANCELLED)) {
224
- return { retire: true, reason: 'focus converged -- all deliveries done/cancelled' };
225
- }
226
- const active = await defaultGetActiveFocuses(ctx.organizationId, ctx.productId);
227
- if (!active.some(f => f.focus_id === focusId)) {
228
- return { retire: true, reason: 'focus is not active (complete/cancelled)' };
229
- }
230
- }
231
- catch {
232
- // Fail-quiet: do not retire on a read error (promote as before).
233
- }
234
- return { retire: false, reason: '' };
235
- }
236
- /** Retire a candidate whose focus has converged/terminated: resolve auto_swept. */
237
- function defaultRetireSwept(id, notes) {
238
- return defaultMarkEscalationResolved(id, notes, {}, 'auto_swept');
239
- }
240
- /**
241
- * If the candidate's focus has retired, resolve the filing auto_swept and return
242
- * true (the caller then skips promotion). Best-effort; a retire failure falls
243
- * through to the normal promotion path. Absent deps => never retires.
221
+ * Reconcile a focus's open agent_resolvable filings and return the ones that ride
222
+ * into THIS spawn. The spawned agent is itself a routing authority over what rides
223
+ * in, so nothing is filed here: an over-age focus-scoped filing on a retired focus is
224
+ * retired auto_swept; every other filing rides (a condition outliving the focus rides
225
+ * too -- the org-wide sweep hands it to a routing decider). Best-effort: never throws
226
+ * past the list read. The single entry point the spawn path calls.
244
227
  */
245
- async function maybeRetireForRetiredFocus(d, itemId, focusId, ctx) {
246
- if (!focusId || !d.focusRetired || !d.retireSwept)
247
- return false;
248
- let verdict;
249
- try {
250
- verdict = await d.focusRetired(focusId, ctx);
251
- }
252
- catch (err) {
253
- log.warn(`[escalations] focusRetired check failed for ${itemId} (focus ${focusId}): ${err.message}`);
254
- return false;
255
- }
256
- if (!verdict.retire)
257
- return false;
258
- try {
259
- await d.retireSwept(itemId, `Focus retired (${verdict.reason}); this agent_resolvable filing is stale -- auto-swept, not promoted.`);
260
- log.info(`[escalations] Retired (NOT promoted) agent_resolvable ${itemId}: ${verdict.reason} (focus ${focusId}).`);
261
- return true;
262
- }
263
- catch (err) {
264
- log.warn(`[escalations] Failed to retire agent_resolvable ${itemId} for retired focus ${focusId}: ${err.message}`);
265
- return false;
266
- }
267
- }
268
228
  export async function reconcileAgentResolvableForFocus(ctx, deps = {}) {
269
229
  const d = { ...defaultDeps(), ...deps };
230
+ const retirement = { ...d, focusRetired: memoizeFocusRetired(d.focusRetired) };
270
231
  const open = await d.listOpen(ctx.focusId);
271
- const { ride, promote } = partitionAgentResolvableByAge(open, (d.now ?? Date.now)(), d.thresholdMs);
272
- for (const item of promote) {
273
- // Retired-focus guard: never promote a candidate whose focus has converged or
274
- // is complete/cancelled -- retire it auto_swept instead (see maybeRetireForRetiredFocus).
275
- if (await maybeRetireForRetiredFocus(d, item.id, ctx.focusId, ctx))
276
- continue;
277
- const ageHours = Math.max(1, Math.floor(((d.now ?? Date.now)() - Date.parse(item.createdAt)) / 3_600_000));
278
- // Attempt-evidence gate: only promote a candidate the machine actually TRIED
279
- // and failed to clear. A candidate with zero agent sessions since it was
280
- // filed is not exhausted -- it rides on (and the aging observer keeps it
281
- // visible in the machine lane), never dumped on a human as a first attempt.
282
- const attempts = await d.countSessionsSince(ctx.focusId, item.createdAt);
283
- if (!meetsAttemptFloor(attempts, d.minAttempts)) {
284
- log.info(`[escalations] Not promoting agent_resolvable ${item.id} (focus ${ctx.focusId}): ` +
285
- `${attempts} recorded attempt(s) < floor ${d.minAttempts}; it rides on unattempted`);
286
- ride.push(item);
287
- continue;
288
- }
289
- try {
290
- const result = await d.file(buildAgentResolvablePromotionParams(item, ctx, ageHours, attempts));
291
- // Only retire the machine-routed row once the human-facing refile is in
292
- // place (filed OR already suppressed by a prior promotion) -- never resolve
293
- // the source without a landing spot, which would drop the signal entirely.
294
- if (result.filed || result.suppressed) {
295
- await d.markResolved(item.id, `Promoted to a needs_operator escalation after ~${ageHours}h open without ` +
296
- `autonomous resolution (agent_resolvable -> needs_operator).`);
297
- log.info(`[escalations] Promoted agent_resolvable ${item.id} to needs_operator (focus ${ctx.focusId}, ~${ageHours}h)`);
298
- }
299
- }
300
- catch (err) {
301
- log.warn(`[escalations] Failed to promote agent_resolvable ${item.id} for focus ${ctx.focusId}: ${err.message}`);
302
- // Leave it to ride this spawn; next reconcile retries the promotion.
232
+ const { ride, promote: overAge } = partitionAgentResolvableByAge(open, (d.now ?? Date.now)(), d.thresholdMs);
233
+ for (const item of overAge) {
234
+ const retired = await checkFocusRetirement(retirement, item, ctx.focusId, ctx);
235
+ if (retired.kind !== 'retired')
303
236
  ride.push(item);
304
- }
305
237
  }
306
238
  return ride;
307
239
  }
308
240
  function defaultSweepDeps() {
309
241
  return {
310
242
  listOpenForOrg: defaultListOpenAgentResolvableForOrg,
243
+ listLineageCarriers: defaultListOpenLineageCarriersForOrg,
311
244
  markResolved: defaultMarkEscalationResolved,
312
- file: defaultFileEscalationIdempotent,
245
+ decide: NO_ROUTING_DECIDER,
246
+ recordDeciderAttempt: (id, patch) => recordDeciderAttempt(id, patch),
313
247
  countSessionsSince: defaultCountFocusSessionsSince,
314
248
  now: () => Date.now(),
315
249
  thresholdMs: resolveAgentResolvablePromoteMs(),
316
250
  minAttempts: DEFAULT_AGENT_RESOLVABLE_PROMOTE_MIN_ATTEMPTS,
317
- focusRetired: defaultFocusRetired,
318
- retireSwept: defaultRetireSwept,
251
+ maxPerSweep: ROUTING_DECIDER_MAX_PER_SWEEP,
252
+ ...defaultRetirementDeps,
319
253
  };
320
254
  }
321
255
  /**
322
- * Sweep the org's OPEN agent_resolvable filings and promote every one that has
323
- * stayed open past the age bound to a needs_operator escalation -- WITHOUT any
324
- * focus spawning. Idempotent (shared cause key + source resolve) and best-effort
325
- * per filing: a promotion failure is logged and the filing left open for the
326
- * next sweep, never thrown, so one bad row never aborts the sweep.
327
- *
328
- * The refile is org-scoped, so the ONLY hard requirement is an organizationId; a
329
- * filing that lacks even that cannot be refiled and is skipped (logged). A focus
330
- * anchor is NOT required: an interactive-session filing carries no focus by
331
- * construction, yet its needs_operator promotion is still visible (the operator
332
- * inbox keys on disposition, not on a focus), so an anchorless over-age filing IS
333
- * promoted -- it is exactly the write-only-sink case this backstop must drain.
256
+ * Sweep the org's OPEN agent_resolvable filings: close the observations a decider
257
+ * already made human-facing, retire stale ones, and hand every eligible observation
258
+ * (see the module ELIGIBILITY) to the routing decider, bounded by maxPerSweep. The
259
+ * sweep itself never files an escalation. Best-effort per filing: a failure is
260
+ * logged and the filing left open for the next sweep, never thrown.
334
261
  */
335
262
  export async function sweepAgentResolvablePromotions(deps = {}) {
336
263
  const d = { ...defaultSweepDeps(), ...deps };
337
264
  const open = await d.listOpenForOrg();
338
- const { promote } = partitionAgentResolvableByAge(open, (d.now ?? Date.now)(), d.thresholdMs);
339
- let promoted = 0;
340
- let retired = 0;
341
- for (const item of promote) {
342
- if (!item.organizationId) {
343
- log.warn(`[escalations] agent_resolvable ${item.id} is over-age but lacks an org anchor; ` +
344
- `cannot org-scope the refile (skipping)`);
265
+ const nowMs = (d.now ?? Date.now)();
266
+ const counts = {
267
+ scanned: open.length, retired: 0, sourcesClosed: 0, handed: 0, decided: 0, undecided: 0, deferred: 0,
268
+ };
269
+ const lane = await closeDecidedSources(d, open, counts);
270
+ const ordered = orderHandoffs(await collectHandoffs(d, lane, nowMs, counts));
271
+ const batch = ordered.slice(0, Math.max(0, d.maxPerSweep));
272
+ counts.deferred = ordered.length - batch.length;
273
+ counts.handed = batch.length;
274
+ const outcomes = await Promise.all(batch.map((request) => handOff(d, request)));
275
+ for (const decided of outcomes) {
276
+ if (decided)
277
+ counts.decided++;
278
+ else
279
+ counts.undecided++;
280
+ }
281
+ return counts;
282
+ }
283
+ /**
284
+ * Close every open observation an open human-lane row names as its source
285
+ * (auto_swept -- no actor). Returns the rest of the lane. A failed carrier read
286
+ * closes nothing this sweep; a failed close leaves the source open but still
287
+ * withholds it from a second decider (it has been decided).
288
+ */
289
+ async function closeDecidedSources(d, open, counts) {
290
+ let carriers;
291
+ try {
292
+ carriers = await d.listLineageCarriers();
293
+ }
294
+ catch (err) {
295
+ log.warn(`[escalations] lineage-carrier read failed; no sources closed this sweep: ${err.message}`);
296
+ return [...open];
297
+ }
298
+ const carrierBySource = new Map(carriers.map((c) => [c.sourceEscalationId, c.id]));
299
+ const lane = [];
300
+ for (const item of open) {
301
+ const carrierId = carrierBySource.get(item.id);
302
+ if (carrierId === undefined) {
303
+ lane.push(item);
345
304
  continue;
346
305
  }
347
- // Retired-focus guard: a candidate whose focus has converged / is
348
- // complete/cancelled is stale -- retire it auto_swept instead of promoting a
349
- // dead row onto the operator inbox (2026-09-09). Runs BEFORE the attempt floor.
350
- if (await maybeRetireForRetiredFocus(d, item.id, item.focusId, { organizationId: item.organizationId, ...(item.productId ? { productId: item.productId } : {}) })) {
351
- retired++;
352
- continue;
306
+ try {
307
+ await d.markResolved(item.id, 'auto_swept', `A routing decider made this observation human-facing as escalation ${carrierId} ` +
308
+ `(metadata.originalEscalationId). Closed here; that row's resolver is what the routing measurement judges.`);
309
+ counts.sourcesClosed++;
353
310
  }
354
- // Attempt-evidence gate. A filing with no focus anchor cannot accrue agent
355
- // sessions -- there is no machine lane to be attempted in -- so it has zero
356
- // attempts and is NOT promoted (new anchorless filings are refused at the
357
- // filing boundary; the aging observer keeps any residue visible). A
358
- // focus-anchored candidate promotes only once the machine has actually tried
359
- // and failed it minAttempts times.
360
- const attempts = item.focusId
361
- ? await d.countSessionsSince(item.focusId, item.createdAt)
362
- : 0;
363
- if (!meetsAttemptFloor(attempts, d.minAttempts)) {
364
- log.info(`[escalations] Org-wide sweep not promoting agent_resolvable ${item.id} ` +
365
- `(focus ${item.focusId ?? '<none>'}): ${attempts} recorded attempt(s) < floor ${d.minAttempts}`);
366
- continue;
311
+ catch (err) {
312
+ log.warn(`[escalations] Failed to close decided observation ${item.id} (carrier ${carrierId}): ${err.message}`);
367
313
  }
368
- const ageHours = Math.max(1, Math.floor(((d.now ?? Date.now)() - Date.parse(item.createdAt)) / 3_600_000));
369
- const ctx = {
370
- organizationId: item.organizationId,
371
- ...(item.focusId ? { focusId: item.focusId } : {}),
372
- ...(item.productId ? { productId: item.productId } : {}),
373
- };
314
+ }
315
+ return lane;
316
+ }
317
+ /** Evaluate every lane filing outside the decider window; returns the eligible hand-offs. */
318
+ async function collectHandoffs(d, lane, nowMs, counts) {
319
+ const stopHeld = stopHeldFocusIds(lane);
320
+ // One retired-focus read per focus for the whole sweep, however many candidates share it.
321
+ const retirement = { ...d, focusRetired: memoizeFocusRetired(d.focusRetired) };
322
+ const windowMs = d.deciderWindowMs ?? d.thresholdMs;
323
+ const requests = [];
324
+ for (const item of lane) {
325
+ if (withinDeciderWindow(item.metadata, nowMs, windowMs))
326
+ continue;
327
+ const verdict = await evaluateCandidate(d, retirement, item, nowMs, stopHeld);
328
+ if (verdict === 'retired')
329
+ counts.retired++;
330
+ else if (verdict !== null)
331
+ requests.push(verdict);
332
+ }
333
+ return requests;
334
+ }
335
+ /** Pure: whether a filing is past the age bound (an unparseable createdAt is not). */
336
+ function isOverAge(createdAt, nowMs, thresholdMs) {
337
+ const created = Date.parse(createdAt);
338
+ return !Number.isNaN(created) && nowMs - created > thresholdMs;
339
+ }
340
+ /**
341
+ * One lane filing: retire it, hand it off (a request), or leave it (null). See the
342
+ * module ELIGIBILITY. An over-age candidate goes through the full retired-focus guard
343
+ * (which may retire it); a fresh one only READS its focus's state.
344
+ */
345
+ async function evaluateCandidate(d, retirement, item, nowMs, stopHeld) {
346
+ if (!item.organizationId) {
347
+ log.warn(`[escalations] agent_resolvable ${item.id} lacks an org anchor; not handed to a routing decider`);
348
+ return null;
349
+ }
350
+ const base = {
351
+ item,
352
+ ageHours: openAgeHours(item.createdAt, nowMs),
353
+ deciderAttempts: readDeciderAttempts(item.metadata).attempts,
354
+ };
355
+ if (!item.focusId)
356
+ return { ...base, basis: 'unreachable', attempts: 0 };
357
+ const scope = { organizationId: item.organizationId, ...(item.productId ? { productId: item.productId } : {}) };
358
+ const overAge = isOverAge(item.createdAt, nowMs, d.thresholdMs);
359
+ const state = overAge
360
+ ? await checkFocusRetirement(retirement, item, item.focusId, scope)
361
+ : await peekFocusRetirement(retirement, item, item.focusId, scope);
362
+ if (state.kind === 'retired')
363
+ return 'retired';
364
+ if (state.kind === 'awaiting_retirement')
365
+ return null;
366
+ if (state.kind === 'outlives') {
367
+ return { ...base, basis: 'outlives_focus', attempts: 0, retiredFocusReason: state.reason };
368
+ }
369
+ const withFocus = state.focus ? { focus: state.focus } : {};
370
+ if (stopHeld.has(item.focusId))
371
+ return { ...base, ...withFocus, basis: 'stop_held', attempts: 0 };
372
+ if (state.focus && !state.focus.armed)
373
+ return { ...base, ...withFocus, basis: 'unreachable', attempts: 0 };
374
+ if (!overAge)
375
+ return null;
376
+ const attempts = await d.countSessionsSince(item.focusId, item.createdAt);
377
+ const basis = promotionBasis(attempts, d.minAttempts, item.observation !== undefined);
378
+ if (!basis) {
379
+ log.info(`[escalations] Not handing off agent_resolvable ${item.id} (focus ${item.focusId}): ` +
380
+ `${attempts} recorded attempt(s) < floor ${d.minAttempts}; it stays in the lane`);
381
+ return null;
382
+ }
383
+ return { ...base, ...withFocus, basis, attempts };
384
+ }
385
+ /**
386
+ * Hand one observation to the decider. A decider that RAN records the attempt on the
387
+ * row (so it is not re-handed inside the window); one that did not complete cleanly
388
+ * leaves the observation in the lane -- no deterministic human fallback. Returns
389
+ * whether it decided.
390
+ */
391
+ async function handOff(d, request) {
392
+ const { item, basis } = request;
393
+ let result;
394
+ try {
395
+ result = await d.decide(request);
396
+ }
397
+ catch (err) {
398
+ result = { ran: false, decided: false, detail: `decider threw: ${err.message}` };
399
+ }
400
+ if (result.ran) {
374
401
  try {
375
- const result = await d.file(buildAgentResolvablePromotionParams(item, ctx, ageHours, attempts));
376
- // Only retire the source once the human-facing refile is in place (filed OR
377
- // already suppressed by a prior promotion) -- never resolve without a
378
- // landing spot, which would drop the signal entirely.
379
- if (result.filed || result.suppressed) {
380
- await d.markResolved(item.id, `Promoted to a needs_operator escalation after ~${ageHours}h open without ` +
381
- `autonomous resolution, via the org-wide sweep (its focus never spawned a ` +
382
- `team). agent_resolvable -> needs_operator.`);
383
- promoted++;
384
- log.info(`[escalations] Org-wide sweep promoted agent_resolvable ${item.id} to needs_operator ` +
385
- `(focus ${item.focusId ?? '<none>'}, ~${ageHours}h)`);
386
- }
402
+ await d.recordDeciderAttempt(item.id, {
403
+ [DECIDER_ATTEMPTS_KEY]: request.deciderAttempts + 1,
404
+ [LAST_DECIDED_AT_KEY]: new Date((d.now ?? Date.now)()).toISOString(),
405
+ [LAST_DECIDER_OUTCOME_KEY]: result.detail,
406
+ });
387
407
  }
388
408
  catch (err) {
389
- log.warn(`[escalations] Org-wide sweep failed to promote agent_resolvable ${item.id}: ` +
390
- `${err.message}`);
391
- // Left open; the next sweep retries.
409
+ log.warn(`[escalations] Failed to record decider attempt on ${item.id}: ${err.message}`);
392
410
  }
393
411
  }
394
- return { scanned: open.length, promoted, retired };
412
+ if (result.decided) {
413
+ log.info(`[escalations] Routing decider ran on agent_resolvable ${item.id} (basis ${basis}): ${result.detail}`);
414
+ }
415
+ else {
416
+ log.warn(`[escalations] UNDECIDED agent_resolvable ${item.id} (basis ${basis}, focus ${item.focusId ?? '<none>'}): ` +
417
+ `${result.detail}. It stays in the machine lane; no deterministic human fallback.`);
418
+ }
419
+ return result.decided;
395
420
  }
396
421
  //# sourceMappingURL=agent-resolvable-promotion.js.map