crewly 1.20.361 → 1.20.362

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 (145) hide show
  1. package/config/roles/team-leader/prompt.md +1 -1
  2. package/config/roles/team-leader/tl-addon.md +1 -1
  3. package/config/skills/team-leader/delegate-task/SKILL.md +21 -6
  4. package/config/skills/team-leader/delegate-task/execute.sh +12 -1
  5. package/dist/backend/backend/src/constants.d.ts +153 -6
  6. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  7. package/dist/backend/backend/src/constants.js +153 -6
  8. package/dist/backend/backend/src/constants.js.map +1 -1
  9. package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.d.ts.map +1 -1
  10. package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.js +7 -4
  11. package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.js.map +1 -1
  12. package/dist/backend/backend/src/index.d.ts.map +1 -1
  13. package/dist/backend/backend/src/index.js +53 -1
  14. package/dist/backend/backend/src/index.js.map +1 -1
  15. package/dist/backend/backend/src/services/agent/follow-through.wiring.d.ts.map +1 -1
  16. package/dist/backend/backend/src/services/agent/follow-through.wiring.js +2 -0
  17. package/dist/backend/backend/src/services/agent/follow-through.wiring.js.map +1 -1
  18. package/dist/backend/backend/src/services/agent/handover-interrupted.d.ts +76 -0
  19. package/dist/backend/backend/src/services/agent/handover-interrupted.d.ts.map +1 -0
  20. package/dist/backend/backend/src/services/agent/handover-interrupted.js +139 -0
  21. package/dist/backend/backend/src/services/agent/handover-interrupted.js.map +1 -0
  22. package/dist/backend/backend/src/services/agent/handover-pointers.d.ts +4 -0
  23. package/dist/backend/backend/src/services/agent/handover-pointers.d.ts.map +1 -1
  24. package/dist/backend/backend/src/services/agent/handover-pointers.js +11 -4
  25. package/dist/backend/backend/src/services/agent/handover-pointers.js.map +1 -1
  26. package/dist/backend/backend/src/services/agent/idle-work-guard.d.ts +3 -1
  27. package/dist/backend/backend/src/services/agent/idle-work-guard.d.ts.map +1 -1
  28. package/dist/backend/backend/src/services/agent/idle-work-guard.js +2 -0
  29. package/dist/backend/backend/src/services/agent/idle-work-guard.js.map +1 -1
  30. package/dist/backend/backend/src/services/agent/resource-mode-protection.d.ts +5 -1
  31. package/dist/backend/backend/src/services/agent/resource-mode-protection.d.ts.map +1 -1
  32. package/dist/backend/backend/src/services/agent/resource-mode-protection.js +6 -1
  33. package/dist/backend/backend/src/services/agent/resource-mode-protection.js.map +1 -1
  34. package/dist/backend/backend/src/services/agent/runtime-session-recovery.d.ts +7 -0
  35. package/dist/backend/backend/src/services/agent/runtime-session-recovery.d.ts.map +1 -1
  36. package/dist/backend/backend/src/services/agent/runtime-session-recovery.js +18 -1
  37. package/dist/backend/backend/src/services/agent/runtime-session-recovery.js.map +1 -1
  38. package/dist/backend/backend/src/services/chat-v2/chat-v2.singleton.d.ts +8 -0
  39. package/dist/backend/backend/src/services/chat-v2/chat-v2.singleton.d.ts.map +1 -1
  40. package/dist/backend/backend/src/services/chat-v2/chat-v2.singleton.js +10 -0
  41. package/dist/backend/backend/src/services/chat-v2/chat-v2.singleton.js.map +1 -1
  42. package/dist/backend/backend/src/services/open-items/open-item-extractor.d.ts +43 -2
  43. package/dist/backend/backend/src/services/open-items/open-item-extractor.d.ts.map +1 -1
  44. package/dist/backend/backend/src/services/open-items/open-item-extractor.js +124 -13
  45. package/dist/backend/backend/src/services/open-items/open-item-extractor.js.map +1 -1
  46. package/dist/backend/backend/src/services/open-items/open-items.service.d.ts +40 -1
  47. package/dist/backend/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
  48. package/dist/backend/backend/src/services/open-items/open-items.service.js +76 -3
  49. package/dist/backend/backend/src/services/open-items/open-items.service.js.map +1 -1
  50. package/dist/backend/backend/src/services/open-items/open-items.wiring.d.ts +8 -0
  51. package/dist/backend/backend/src/services/open-items/open-items.wiring.d.ts.map +1 -1
  52. package/dist/backend/backend/src/services/open-items/open-items.wiring.js +19 -1
  53. package/dist/backend/backend/src/services/open-items/open-items.wiring.js.map +1 -1
  54. package/dist/backend/backend/src/services/orc/owner-completion-report.service.d.ts +35 -19
  55. package/dist/backend/backend/src/services/orc/owner-completion-report.service.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/orc/owner-completion-report.service.js +60 -63
  57. package/dist/backend/backend/src/services/orc/owner-completion-report.service.js.map +1 -1
  58. package/dist/backend/backend/src/services/orc/owner-completion-report.text.d.ts +86 -0
  59. package/dist/backend/backend/src/services/orc/owner-completion-report.text.d.ts.map +1 -0
  60. package/dist/backend/backend/src/services/orc/owner-completion-report.text.js +170 -0
  61. package/dist/backend/backend/src/services/orc/owner-completion-report.text.js.map +1 -0
  62. package/dist/backend/backend/src/services/orc/owner-completion-report.wiring.d.ts +24 -0
  63. package/dist/backend/backend/src/services/orc/owner-completion-report.wiring.d.ts.map +1 -1
  64. package/dist/backend/backend/src/services/orc/owner-completion-report.wiring.js +38 -1
  65. package/dist/backend/backend/src/services/orc/owner-completion-report.wiring.js.map +1 -1
  66. package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts +2 -0
  67. package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts.map +1 -1
  68. package/dist/backend/backend/src/services/orc/reply-destination.wiring.js +8 -3
  69. package/dist/backend/backend/src/services/orc/reply-destination.wiring.js.map +1 -1
  70. package/dist/backend/backend/src/services/orc/work-item-destination.d.ts +76 -0
  71. package/dist/backend/backend/src/services/orc/work-item-destination.d.ts.map +1 -1
  72. package/dist/backend/backend/src/services/orc/work-item-destination.js +83 -3
  73. package/dist/backend/backend/src/services/orc/work-item-destination.js.map +1 -1
  74. package/dist/backend/backend/src/services/orc/work-item-destination.wiring.d.ts +31 -2
  75. package/dist/backend/backend/src/services/orc/work-item-destination.wiring.d.ts.map +1 -1
  76. package/dist/backend/backend/src/services/orc/work-item-destination.wiring.js +38 -0
  77. package/dist/backend/backend/src/services/orc/work-item-destination.wiring.js.map +1 -1
  78. package/dist/backend/backend/src/services/stall/background-run-detector.d.ts +56 -0
  79. package/dist/backend/backend/src/services/stall/background-run-detector.d.ts.map +1 -0
  80. package/dist/backend/backend/src/services/stall/background-run-detector.js +93 -0
  81. package/dist/backend/backend/src/services/stall/background-run-detector.js.map +1 -0
  82. package/dist/backend/backend/src/services/stall/claim-check.d.ts +60 -0
  83. package/dist/backend/backend/src/services/stall/claim-check.d.ts.map +1 -0
  84. package/dist/backend/backend/src/services/stall/claim-check.js +98 -0
  85. package/dist/backend/backend/src/services/stall/claim-check.js.map +1 -0
  86. package/dist/backend/backend/src/services/stall/in-flight-runs.store.d.ts +110 -0
  87. package/dist/backend/backend/src/services/stall/in-flight-runs.store.d.ts.map +1 -0
  88. package/dist/backend/backend/src/services/stall/in-flight-runs.store.js +109 -0
  89. package/dist/backend/backend/src/services/stall/in-flight-runs.store.js.map +1 -0
  90. package/dist/backend/backend/src/services/stall/stall-watcher.service.d.ts +456 -0
  91. package/dist/backend/backend/src/services/stall/stall-watcher.service.d.ts.map +1 -0
  92. package/dist/backend/backend/src/services/stall/stall-watcher.service.js +854 -0
  93. package/dist/backend/backend/src/services/stall/stall-watcher.service.js.map +1 -0
  94. package/dist/backend/backend/src/services/stall/stall-watcher.wiring.d.ts +98 -0
  95. package/dist/backend/backend/src/services/stall/stall-watcher.wiring.d.ts.map +1 -0
  96. package/dist/backend/backend/src/services/stall/stall-watcher.wiring.js +217 -0
  97. package/dist/backend/backend/src/services/stall/stall-watcher.wiring.js.map +1 -0
  98. package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts +53 -0
  99. package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
  100. package/dist/backend/backend/src/services/task-pool/task-pool.service.js +162 -15
  101. package/dist/backend/backend/src/services/task-pool/task-pool.service.js.map +1 -1
  102. package/dist/backend/backend/src/services/tl-delegation/tl-delegation.service.js +1 -1
  103. package/dist/backend/backend/src/services/tl-delegation/tl-delegation.service.js.map +1 -1
  104. package/dist/backend/backend/src/types/v2/open-item.types.d.ts +2 -0
  105. package/dist/backend/backend/src/types/v2/open-item.types.d.ts.map +1 -1
  106. package/dist/backend/build-info.json +2 -2
  107. package/dist/cli/backend/src/constants.d.ts +153 -6
  108. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  109. package/dist/cli/backend/src/constants.js +153 -6
  110. package/dist/cli/backend/src/constants.js.map +1 -1
  111. package/dist/cli/backend/src/services/agent/handover-interrupted.d.ts +76 -0
  112. package/dist/cli/backend/src/services/agent/handover-interrupted.d.ts.map +1 -0
  113. package/dist/cli/backend/src/services/agent/handover-interrupted.js +139 -0
  114. package/dist/cli/backend/src/services/agent/handover-interrupted.js.map +1 -0
  115. package/dist/cli/backend/src/services/agent/handover-pointers.d.ts +4 -0
  116. package/dist/cli/backend/src/services/agent/handover-pointers.d.ts.map +1 -1
  117. package/dist/cli/backend/src/services/agent/handover-pointers.js +11 -4
  118. package/dist/cli/backend/src/services/agent/handover-pointers.js.map +1 -1
  119. package/dist/cli/backend/src/services/agent/runtime-session-recovery.d.ts +7 -0
  120. package/dist/cli/backend/src/services/agent/runtime-session-recovery.d.ts.map +1 -1
  121. package/dist/cli/backend/src/services/agent/runtime-session-recovery.js +18 -1
  122. package/dist/cli/backend/src/services/agent/runtime-session-recovery.js.map +1 -1
  123. package/dist/cli/backend/src/services/chat-v2/chat-v2.singleton.d.ts +8 -0
  124. package/dist/cli/backend/src/services/chat-v2/chat-v2.singleton.d.ts.map +1 -1
  125. package/dist/cli/backend/src/services/chat-v2/chat-v2.singleton.js +10 -0
  126. package/dist/cli/backend/src/services/chat-v2/chat-v2.singleton.js.map +1 -1
  127. package/dist/cli/backend/src/services/open-items/open-item-extractor.d.ts +43 -2
  128. package/dist/cli/backend/src/services/open-items/open-item-extractor.d.ts.map +1 -1
  129. package/dist/cli/backend/src/services/open-items/open-item-extractor.js +124 -13
  130. package/dist/cli/backend/src/services/open-items/open-item-extractor.js.map +1 -1
  131. package/dist/cli/backend/src/services/open-items/open-items.service.d.ts +40 -1
  132. package/dist/cli/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
  133. package/dist/cli/backend/src/services/open-items/open-items.service.js +76 -3
  134. package/dist/cli/backend/src/services/open-items/open-items.service.js.map +1 -1
  135. package/dist/cli/backend/src/services/orc/work-item-destination.d.ts +76 -0
  136. package/dist/cli/backend/src/services/orc/work-item-destination.d.ts.map +1 -1
  137. package/dist/cli/backend/src/services/orc/work-item-destination.js +83 -3
  138. package/dist/cli/backend/src/services/orc/work-item-destination.js.map +1 -1
  139. package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts +53 -0
  140. package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
  141. package/dist/cli/backend/src/services/task-pool/task-pool.service.js +162 -15
  142. package/dist/cli/backend/src/services/task-pool/task-pool.service.js.map +1 -1
  143. package/dist/cli/backend/src/types/v2/open-item.types.d.ts +2 -0
  144. package/dist/cli/backend/src/types/v2/open-item.types.d.ts.map +1 -1
  145. package/package.json +1 -1
@@ -0,0 +1,854 @@
1
+ /**
2
+ * Stall watcher (crewly#1363, crewly#1362, specs/2026-10-17-stall-watcher.md).
3
+ *
4
+ * Stalls used to be a metric only (`TRACE_CONSTANTS.STALL_MINUTES`). This
5
+ * service acts on three kinds of in-flight work, each keyed to the agent's
6
+ * own estimate when it gave one:
7
+ *
8
+ * - **Promises to the owner** (open-items commitments): at 1.5× the estimate
9
+ * past the promise with nothing posted, the agent is re-briefed once with
10
+ * the last message from someone else before the promise (the approval it
11
+ * may be re-waiting for, crewly#1361); at 3× the team lead is told once;
12
+ * when the lead has not answered in the thread within LEAD_GRACE_MS, the
13
+ * owner gets one plain line (at most one per item per day).
14
+ * - **Claimed WorkItems**: the run trace's ongoing stall (no tool calls,
15
+ * posts or worktree changes for CLAIM_STALL_MS) nudges the claimer once
16
+ * with the cause; a second window with no progress releases the claim
17
+ * (unassigned, so the route sweep hands it to the lead and then the
18
+ * orchestrator) and tells the lead. Tickets the ticket autopilot already
19
+ * watches are left to it.
20
+ * - **Background runs**: an agent saying it started something that takes a
21
+ * while ("已经开跑…预计 1 小时左右") is recorded in
22
+ * `CREWLY_HOME/in-flight-runs.json`; the agent is protected from idle stops
23
+ * and evictions until expectedBy + grace; the same ladder as promises
24
+ * applies when nothing (a file, a link, a finished / failed report) lands in
25
+ * the thread.
26
+ *
27
+ * Plus the claim check (crewly#1362): "X is doing it" told to the owner with
28
+ * no WorkItem or ticket for X within CLAIM_CHECK_WINDOW_MS gets the speaker
29
+ * one internal line; the owner hears nothing.
30
+ *
31
+ * Cost control: every line to an agent is one line and tagged; one nudge per
32
+ * agent per NUDGE_COOLDOWN_MS; the owner is never the retry loop. The first
33
+ * ARM_DELAY_MS after the first pass is a dry run (logs only).
34
+ *
35
+ * @module services/stall/stall-watcher.service
36
+ */
37
+ import { STALL_WATCHER_CONSTANTS } from '../../constants.js';
38
+ import { LoggerService } from '../core/logger.service.js';
39
+ import { ACTIVE_OPEN_ITEM_STATUSES } from '../../types/v2/open-item.types.js';
40
+ import { STALL_CAUSE_LABELS } from '../trace/trace-timeline.js';
41
+ import { isInterim } from '../slack/slack-typing-placeholder.service.js';
42
+ import { detectBackgroundRun, hasDeliverable, isRunFinishedReport } from './background-run-detector.js';
43
+ import { claimNudgeText, detectTeammateClaims } from './claim-check.js';
44
+ import { emptyState, pruneState } from './in-flight-runs.store.js';
45
+ const C = STALL_WATCHER_CONSTANTS;
46
+ // ---------------------------------------------------------------------------
47
+ // Pure helpers
48
+ // ---------------------------------------------------------------------------
49
+ /**
50
+ * Clip text to one line of at most `max` characters.
51
+ *
52
+ * @param text - Text
53
+ * @param max - Max characters
54
+ * @returns Clipped text
55
+ */
56
+ export function clip(text, max) {
57
+ const one = (text ?? '').replace(/\s+/g, ' ').trim();
58
+ return one.length > max ? `${one.slice(0, max - 1)}…` : one;
59
+ }
60
+ /**
61
+ * A duration in words, in the owner's language.
62
+ *
63
+ * @param ms - Milliseconds
64
+ * @param lang - Language
65
+ * @returns "40 分钟" / "1.5 小时" / "40 minutes" / "1.5 hours"
66
+ */
67
+ export function durationWords(ms, lang = 'en') {
68
+ const minutes = Math.max(1, Math.round(ms / 60_000));
69
+ if (minutes < 60)
70
+ return lang === 'zh' ? `${minutes} 分钟` : `${minutes} minute${minutes === 1 ? '' : 's'}`;
71
+ const hours = Math.round((minutes / 60) * 10) / 10;
72
+ if (hours < 48)
73
+ return lang === 'zh' ? `${hours} 小时` : `${hours} hour${hours === 1 ? '' : 's'}`;
74
+ const days = Math.round((hours / 24) * 10) / 10;
75
+ return lang === 'zh' ? `${days} 天` : `${days} day${days === 1 ? '' : 's'}`;
76
+ }
77
+ /**
78
+ * The owner's language in a thread: Chinese when the owner's own messages
79
+ * hold more CJK characters than Latin letters, else English (CLAUDE.md:
80
+ * harness text is English; a line to the owner follows the owner).
81
+ *
82
+ * @param messages - The thread
83
+ * @returns 'zh' or 'en'
84
+ */
85
+ export function ownerLanguage(messages) {
86
+ let cjk = 0;
87
+ let latin = 0;
88
+ for (const m of messages) {
89
+ if (m.senderType !== 'user')
90
+ continue;
91
+ cjk += (m.content.match(/[぀-ヿ㐀-䶿一-鿿]/g) ?? []).length;
92
+ latin += (m.content.match(/[a-z]/gi) ?? []).length;
93
+ }
94
+ return cjk > 0 && cjk >= latin ? 'zh' : 'en';
95
+ }
96
+ /**
97
+ * The last message by someone other than the agent before a time: the
98
+ * approval or instruction the promise answered (crewly#1361 expectation 2).
99
+ *
100
+ * @param messages - The thread
101
+ * @param agent - The promising agent
102
+ * @param before - Epoch ms of the promise
103
+ * @returns That message, or null
104
+ */
105
+ export function contextBefore(messages, agent, before) {
106
+ let found = null;
107
+ for (const m of messages) {
108
+ if (m.createdAt >= before || m.senderId === agent || m.senderType === 'system' || !m.content.trim())
109
+ continue;
110
+ if (!found || m.createdAt > found.createdAt)
111
+ found = m;
112
+ }
113
+ return found;
114
+ }
115
+ /**
116
+ * Whether a run's agent posted a deliverable or a finished / failed report in
117
+ * the thread after the run started.
118
+ *
119
+ * @param messages - The thread
120
+ * @param run - The run
121
+ * @returns True when the run is over as far as the thread shows
122
+ */
123
+ export function runDeliveredIn(messages, run) {
124
+ return messages.some((m) => m.senderType === 'agent' &&
125
+ m.senderId === run.agent &&
126
+ m.id !== run.id &&
127
+ m.createdAt > run.startedAt &&
128
+ !isInterim(m) &&
129
+ (hasDeliverable({ content: m.content, ...(m.attachmentCount !== undefined ? { attachmentCount: m.attachmentCount } : {}) }) || isRunFinishedReport(m.content)));
130
+ }
131
+ /**
132
+ * The time each ladder step is due.
133
+ *
134
+ * @param madeAt - Promise / run start (ms)
135
+ * @param estimateMs - The agent's estimate
136
+ * @returns Due times (ms)
137
+ */
138
+ export function ladderDue(madeAt, estimateMs) {
139
+ return {
140
+ agentDue: madeAt + Math.max(C.AGENT_STEP_MULTIPLIER * estimateMs, C.MIN_AGENT_STEP_MS),
141
+ leadDue: madeAt + Math.max(C.LEAD_STEP_MULTIPLIER * estimateMs, C.MIN_AGENT_STEP_MS + C.LEAD_GRACE_MS),
142
+ };
143
+ }
144
+ /**
145
+ * The idle-stop / eviction protection reason for a session with a run in
146
+ * flight, or null.
147
+ *
148
+ * @param runs - Recorded runs
149
+ * @param session - Agent session
150
+ * @param now - Epoch ms
151
+ * @returns English reason, or null
152
+ */
153
+ export function runProtectionReason(runs, session, now) {
154
+ const run = runs.find((r) => r.agent === session && now < r.expectedBy + C.RUN_PROTECTION_GRACE_MS);
155
+ if (!run)
156
+ return null;
157
+ return `a background run it announced ("${clip(run.head, C.QUOTE_MAX_CHARS)}") is expected by ${new Date(run.expectedBy).toISOString()}`;
158
+ }
159
+ /**
160
+ * Whether the watcher is switched on.
161
+ *
162
+ * @param env - Environment
163
+ * @returns False when CREWLY_STALL_WATCHER=false
164
+ */
165
+ export function stallWatcherEnabled(env = process.env) {
166
+ return (env[C.ENV_KILL_SWITCH] ?? '').trim().toLowerCase() !== 'false';
167
+ }
168
+ /**
169
+ * Whether a pass is a dry run: CREWLY_STALL_WATCHER_DRY_RUN=true/false wins;
170
+ * otherwise dry until ARM_DELAY_MS after the first pass.
171
+ *
172
+ * @param armedAt - When the first pass ran (ms), or undefined
173
+ * @param now - Epoch ms
174
+ * @param env - Environment
175
+ * @returns True to only log
176
+ */
177
+ export function stallWatcherDryRun(armedAt, now, env = process.env) {
178
+ const forced = (env[C.ENV_DRY_RUN] ?? '').trim().toLowerCase();
179
+ if (forced === 'true' || forced === '1')
180
+ return true;
181
+ if (forced === 'false' || forced === '0')
182
+ return false;
183
+ return armedAt === undefined || now - armedAt < C.ARM_DELAY_MS;
184
+ }
185
+ /**
186
+ * The agent-facing stall cause: the trace's label plus its detail.
187
+ *
188
+ * @param signal - Stall signal
189
+ * @returns One clause
190
+ */
191
+ export function causeText(signal) {
192
+ const label = STALL_CAUSE_LABELS[signal.cause] ?? signal.cause;
193
+ const detail = clip(signal.detail, C.QUOTE_MAX_CHARS);
194
+ return detail && detail.toLowerCase() !== label.toLowerCase() ? `${label} (${detail})` : label;
195
+ }
196
+ /**
197
+ * The one plain line the owner gets about a promise.
198
+ *
199
+ * @param lang - Owner's language
200
+ * @param input - Names and times
201
+ * @returns The line
202
+ */
203
+ export function promiseOwnerLine(lang, input) {
204
+ const est = durationWords(input.estimateMs, lang);
205
+ const elapsed = durationWords(input.elapsedMs, lang);
206
+ if (lang === 'zh') {
207
+ const asked = input.leadName ? `也问了 ${input.leadName}` : '团队里没有负责人可问';
208
+ return `${input.name} 说大约 ${est} 内给「${input.what}」,现在过了 ${elapsed} 还没有,已经提醒过 ${input.name},${asked}。`;
209
+ }
210
+ const asked = input.leadName ? `I asked ${input.leadName}` : 'there is no team lead to ask';
211
+ return `${input.name} said "${input.what}" would come in about ${est}; ${elapsed} later nothing has arrived. ${input.name} has been reminded and ${asked}.`;
212
+ }
213
+ /**
214
+ * The one plain line the owner gets about a background run.
215
+ *
216
+ * @param lang - Owner's language
217
+ * @param input - Names and times
218
+ * @returns The line
219
+ */
220
+ export function runOwnerLine(lang, input) {
221
+ const est = durationWords(input.estimateMs, lang);
222
+ const elapsed = durationWords(input.elapsedMs, lang);
223
+ if (lang === 'zh') {
224
+ const asked = input.leadName ? `也问了 ${input.leadName}` : '团队里没有负责人可问';
225
+ return `${input.name} 说「${input.head}」大约 ${est},现在过了 ${elapsed} 还没有结果,已经提醒过 ${input.name},${asked}。`;
226
+ }
227
+ const asked = input.leadName ? `I asked ${input.leadName}` : 'there is no team lead to ask';
228
+ return `${input.name} said "${input.head}" (about ${est}); ${elapsed} later there is no result in the thread. ${input.name} has been reminded and ${asked}.`;
229
+ }
230
+ /**
231
+ * ISO → epoch ms, NaN-safe.
232
+ *
233
+ * @param iso - ISO string
234
+ * @returns Epoch ms, or null
235
+ */
236
+ function ms(iso) {
237
+ if (!iso)
238
+ return null;
239
+ const t = Date.parse(iso);
240
+ return Number.isFinite(t) ? t : null;
241
+ }
242
+ // ---------------------------------------------------------------------------
243
+ // Service
244
+ // ---------------------------------------------------------------------------
245
+ /** Process-wide instance. */
246
+ let instance = null;
247
+ /**
248
+ * The running watcher, if any.
249
+ *
250
+ * @returns The service, or null
251
+ */
252
+ export function getStallWatcher() {
253
+ return instance;
254
+ }
255
+ /**
256
+ * Install (or clear) the process-wide watcher.
257
+ *
258
+ * @param service - The service, or null
259
+ */
260
+ export function setStallWatcher(service) {
261
+ instance = service;
262
+ }
263
+ /**
264
+ * Watches in-flight work and climbs the agent → lead → owner ladder.
265
+ */
266
+ export class StallWatcherService {
267
+ deps;
268
+ logger;
269
+ now;
270
+ env;
271
+ state = emptyState();
272
+ loaded = false;
273
+ timer = null;
274
+ bootTimer = null;
275
+ chain = Promise.resolve();
276
+ /**
277
+ * @param deps - Collaborators
278
+ */
279
+ constructor(deps) {
280
+ this.deps = deps;
281
+ this.logger = deps.logger ?? LoggerService.getInstance().createComponentLogger('StallWatcher');
282
+ this.now = deps.now ?? (() => Date.now());
283
+ this.env = deps.env ?? process.env;
284
+ }
285
+ /**
286
+ * Whether the kill switch is off.
287
+ *
288
+ * @returns True when the watcher may run
289
+ */
290
+ enabled() {
291
+ return stallWatcherEnabled(this.env);
292
+ }
293
+ /**
294
+ * Load the state file, then schedule the first pass (after the boot
295
+ * gate and BOOT_DELAY_MS) and the periodic passes. No-op when disabled.
296
+ */
297
+ start() {
298
+ if (!this.enabled()) {
299
+ this.logger.info('Stall watcher disabled by environment', { env: C.ENV_KILL_SWITCH });
300
+ return;
301
+ }
302
+ if (this.timer)
303
+ return;
304
+ void this.load();
305
+ this.bootTimer = setTimeout(() => {
306
+ this.bootTimer = null;
307
+ void this.bootTick();
308
+ }, C.BOOT_DELAY_MS);
309
+ this.bootTimer.unref?.();
310
+ this.timer = setInterval(() => void this.tick().catch((err) => this.logger.warn('Stall watcher pass failed', { error: errText(err) })), C.INTERVAL_MS);
311
+ this.timer.unref?.();
312
+ this.logger.info('Stall watcher started', { intervalMs: C.INTERVAL_MS, bootDelayMs: C.BOOT_DELAY_MS, stateFile: this.deps.store.path });
313
+ }
314
+ /** Stop the passes. Safe to call more than once. */
315
+ stop() {
316
+ if (this.bootTimer) {
317
+ clearTimeout(this.bootTimer);
318
+ this.bootTimer = null;
319
+ }
320
+ if (this.timer) {
321
+ clearInterval(this.timer);
322
+ this.timer = null;
323
+ }
324
+ }
325
+ /**
326
+ * Read the state file once (later passes keep the in-memory copy).
327
+ *
328
+ * @returns The state
329
+ */
330
+ async load() {
331
+ if (!this.loaded) {
332
+ this.state = await this.deps.store.load();
333
+ this.loaded = true;
334
+ }
335
+ return this.state;
336
+ }
337
+ /**
338
+ * The first pass: waits at the boot-pressure gate like the other
339
+ * maintenance passes, then ticks.
340
+ *
341
+ * @returns What the pass did
342
+ */
343
+ async bootTick() {
344
+ if (this.deps.bootGate) {
345
+ try {
346
+ const gate = await this.deps.bootGate();
347
+ if (gate.waitedMs > 0)
348
+ this.logger.info('Stall watcher boot pass waited for a quiet machine', { waitedMs: gate.waitedMs, reduced: gate.reduced, reason: gate.reason });
349
+ }
350
+ catch (err) {
351
+ this.logger.warn('Stall watcher boot gate failed (running anyway)', { error: errText(err) });
352
+ }
353
+ }
354
+ return this.tick();
355
+ }
356
+ /**
357
+ * Why an agent must not be idle-stopped or evicted right now: a run it
358
+ * announced is still expected. Synchronous, from the in-memory state.
359
+ *
360
+ * @param session - Agent session
361
+ * @returns English reason, or null
362
+ */
363
+ protectedRunReason(session) {
364
+ return runProtectionReason(this.state.runs, session, this.now());
365
+ }
366
+ /**
367
+ * The runs recorded for a session (tests and the status endpoint).
368
+ *
369
+ * @param session - Agent session
370
+ * @returns Runs, newest last
371
+ */
372
+ runsOf(session) {
373
+ return this.state.runs.filter((r) => r.agent === session);
374
+ }
375
+ /**
376
+ * A chat-v2 message arrived. Agent posts can start a background run,
377
+ * close one (a deliverable or a finished / failed report in its thread)
378
+ * or claim that a teammate is doing something. Other posts are ignored
379
+ * here; the lead's answer is read from the thread when the ladder needs it.
380
+ *
381
+ * @param message - The message
382
+ */
383
+ onMessage(message) {
384
+ if (!this.enabled() || message.senderType !== 'agent')
385
+ return Promise.resolve();
386
+ return this.serial(async () => {
387
+ await this.load();
388
+ const now = this.now();
389
+ const threadId = message.threadId ?? message.id;
390
+ let changed = false;
391
+ // Close runs this post delivers.
392
+ const done = this.state.runs.filter((r) => r.agent === message.senderId &&
393
+ r.channelId === message.channelId &&
394
+ r.threadId === threadId &&
395
+ r.id !== message.id &&
396
+ (hasDeliverable({ content: message.content, ...(message.attachmentCount !== undefined ? { attachmentCount: message.attachmentCount } : {}) }) || isRunFinishedReport(message.content)));
397
+ if (done.length > 0) {
398
+ this.state.runs = this.state.runs.filter((r) => !done.includes(r));
399
+ for (const r of done)
400
+ delete this.state.ladders[`run:${r.id}`];
401
+ this.logger.info('Background run closed by the agent\'s post', { agent: message.senderId, runs: done.map((r) => r.id), message: message.id });
402
+ changed = true;
403
+ }
404
+ // A new run.
405
+ if (!isInterim(message)) {
406
+ const run = detectBackgroundRun(message.content);
407
+ const already = this.state.runs.some((r) => r.agent === message.senderId && r.channelId === message.channelId && r.threadId === threadId);
408
+ if (run && !already) {
409
+ const estimateMs = run.estimateMs ?? C.RUN_DEFAULT_ESTIMATE_MS;
410
+ this.state.runs.push({
411
+ id: message.id,
412
+ agent: message.senderId,
413
+ channelId: message.channelId,
414
+ threadId,
415
+ head: run.head,
416
+ startedAt: message.createdAt,
417
+ estimateMs,
418
+ estimateSource: run.estimateMs !== null ? 'text' : 'default',
419
+ expectedBy: message.createdAt + estimateMs,
420
+ });
421
+ this.logger.info('Background run registered', { agent: message.senderId, message: message.id, estimateMs, expectedBy: new Date(message.createdAt + estimateMs).toISOString() });
422
+ changed = true;
423
+ }
424
+ }
425
+ // "X is doing it" to the owner.
426
+ if (!isInterim(message) && ((await this.deps.isOwnerThread?.(message).catch(() => false)) ?? true)) {
427
+ const teammates = await this.deps.teammatesOf(message.senderId).catch(() => []);
428
+ for (const claim of detectTeammateClaims(message.content, teammates, message.senderId)) {
429
+ const pending = this.state.claims.some((c) => c.speaker === message.senderId && c.member === claim.member && Math.abs(c.saidAt - message.createdAt) < C.CLAIM_CHECK_WINDOW_MS);
430
+ if (pending)
431
+ continue;
432
+ this.state.claims.push({ id: message.id, speaker: message.senderId, member: claim.member, memberName: claim.memberName, saidAt: message.createdAt, head: claim.sentence });
433
+ this.logger.info('Teammate claim noted for a check', { speaker: message.senderId, member: claim.member, message: message.id });
434
+ changed = true;
435
+ }
436
+ }
437
+ if (changed) {
438
+ pruneState(this.state, now);
439
+ await this.deps.store.save(this.state).catch((err) => this.logger.warn('Stall watcher state not saved', { error: errText(err) }));
440
+ }
441
+ });
442
+ }
443
+ /**
444
+ * One pass over every kind of in-flight work.
445
+ *
446
+ * @returns What the pass did
447
+ */
448
+ tick() {
449
+ return this.serial(() => this.pass());
450
+ }
451
+ /**
452
+ * Run `fn` after every earlier change to the state (one writer).
453
+ *
454
+ * @param fn - Work
455
+ * @returns Its result
456
+ */
457
+ serial(fn) {
458
+ const run = this.chain.then(fn, fn);
459
+ this.chain = run.catch(() => undefined);
460
+ return run;
461
+ }
462
+ /**
463
+ * The pass itself (under {@link serial}).
464
+ *
465
+ * @returns What it did
466
+ */
467
+ async pass() {
468
+ const result = {
469
+ dryRun: false,
470
+ promises: { agent: 0, lead: 0, owner: 0 },
471
+ claims: { nudged: 0, released: 0 },
472
+ runs: { agent: 0, lead: 0, owner: 0, cleared: 0 },
473
+ teammateClaims: { checked: 0, nudged: 0 },
474
+ };
475
+ if (!this.enabled())
476
+ return { ...result, skipped: 'disabled' };
477
+ await this.load();
478
+ const now = this.now();
479
+ if (this.state.armedAt === undefined) {
480
+ this.state.armedAt = now;
481
+ this.logger.info('Stall watcher armed: dry run (logs only) until the arm delay passes', { armedAt: new Date(now).toISOString(), armDelayMs: C.ARM_DELAY_MS });
482
+ }
483
+ const dry = stallWatcherDryRun(this.state.armedAt, now, this.env);
484
+ result.dryRun = dry;
485
+ pruneState(this.state, now);
486
+ await this.promisesPass(now, dry, result).catch((err) => this.logger.warn('Stall watcher promises pass failed', { error: errText(err) }));
487
+ await this.claimsPass(now, dry, result).catch((err) => this.logger.warn('Stall watcher claims pass failed', { error: errText(err) }));
488
+ await this.runsPass(now, dry, result).catch((err) => this.logger.warn('Stall watcher runs pass failed', { error: errText(err) }));
489
+ await this.teammateClaimsPass(now, dry, result).catch((err) => this.logger.warn('Stall watcher claim-check pass failed', { error: errText(err) }));
490
+ await this.deps.store.save(this.state).catch((err) => this.logger.warn('Stall watcher state not saved', { error: errText(err) }));
491
+ const acted = result.promises.agent + result.promises.lead + result.promises.owner + result.claims.nudged + result.claims.released + result.runs.agent + result.runs.lead + result.runs.owner + result.runs.cleared + result.teammateClaims.nudged;
492
+ if (acted > 0)
493
+ this.logger.info(dry ? 'Stall watcher pass (dry run, nothing sent)' : 'Stall watcher pass', { ...result });
494
+ return result;
495
+ }
496
+ // -------------------------------------------------------------------------
497
+ // Sending (every path honours the dry run and the per-agent cooldown)
498
+ // -------------------------------------------------------------------------
499
+ /**
500
+ * One line to an agent, at most one per agent per NUDGE_COOLDOWN_MS.
501
+ *
502
+ * @param agent - Session
503
+ * @param text - The line
504
+ * @param now - Epoch ms
505
+ * @param dry - Dry run: log only
506
+ * @param recentNudgeAt - A nudge from elsewhere that counts for the cooldown
507
+ * @returns What happened
508
+ */
509
+ async nudgeAgent(agent, text, now, dry, recentNudgeAt) {
510
+ const last = Math.max(this.state.lastNudgeByAgent[agent] ?? 0, recentNudgeAt ?? 0);
511
+ if (last && now - last < C.NUDGE_COOLDOWN_MS)
512
+ return 'cooldown';
513
+ if (dry) {
514
+ this.logger.info('Dry run: would nudge agent', { agent, text });
515
+ return 'dry';
516
+ }
517
+ const ok = await this.deps.deliverToAgent(agent, text).catch(() => false);
518
+ if (!ok)
519
+ return 'failed';
520
+ this.state.lastNudgeByAgent[agent] = now;
521
+ return 'sent';
522
+ }
523
+ /**
524
+ * One line to an agent's lead.
525
+ *
526
+ * @param agent - The agent whose lead is told
527
+ * @param text - The line (built with the lead's name)
528
+ * @param dry - Dry run
529
+ * @returns The lead session when told (or would be), null when there is no lead or it failed
530
+ */
531
+ async tellLead(agent, text, dry) {
532
+ const lead = await this.deps.leadOf(agent).catch(() => null);
533
+ if (!lead || lead === agent)
534
+ return null;
535
+ const line = await text(await this.name(lead));
536
+ if (dry) {
537
+ this.logger.info('Dry run: would tell the lead', { agent, lead, text: line });
538
+ return lead;
539
+ }
540
+ const ok = await this.deps.deliverToAgent(lead, line).catch(() => false);
541
+ return ok ? lead : null;
542
+ }
543
+ /**
544
+ * Display name of a session (falls back to the session).
545
+ *
546
+ * @param session - Session
547
+ * @returns Name
548
+ */
549
+ async name(session) {
550
+ return (await this.deps.displayName(session).catch(() => session)) || session;
551
+ }
552
+ // -------------------------------------------------------------------------
553
+ // The ladder
554
+ // -------------------------------------------------------------------------
555
+ /**
556
+ * Climb one step of the ladder for an item, when it is due.
557
+ *
558
+ * @param item - The item
559
+ * @param now - Epoch ms
560
+ * @param dry - Dry run
561
+ * @returns The step taken, or null
562
+ */
563
+ async climb(item, now, dry) {
564
+ const ladder = (this.state.ladders[item.key] ??= { seenAt: now });
565
+ ladder.seenAt = now;
566
+ const agentAt = ladder.agentAt ?? item.prior?.agentAt;
567
+ const leadAt = ladder.leadAt ?? item.prior?.leadAt;
568
+ const ownerAt = ladder.ownerAt ?? item.prior?.ownerAt;
569
+ const { agentDue, leadDue } = ladderDue(item.madeAt, item.estimateMs);
570
+ if (!agentAt) {
571
+ if (now < agentDue)
572
+ return null;
573
+ const outcome = await this.nudgeAgent(item.agent, await item.agentText(), now, dry, item.prior?.recentNudgeAt);
574
+ if (outcome === 'cooldown' || outcome === 'failed') {
575
+ this.logger.debug('Stall ladder: agent step deferred', { key: item.key, outcome });
576
+ return null;
577
+ }
578
+ ladder.agentAt = now;
579
+ this.deps.trace?.({ session: item.agent, summary: `Stall watcher: ${item.key} re-briefed at ${Math.round((now - item.madeAt) / 60_000)} min`, ...(item.workItemId ? { workItemId: item.workItemId } : {}), data: { reason: 'stall', step: 'agent', key: item.key, dryRun: dry } });
580
+ if (!dry)
581
+ await item.onStep?.('agent', new Date(now)).catch(() => undefined);
582
+ this.logger.info('Stall ladder: agent re-briefed', { key: item.key, agent: item.agent, dryRun: dry });
583
+ return 'agent';
584
+ }
585
+ if (!leadAt) {
586
+ if (now < leadDue)
587
+ return null;
588
+ const lead = await this.tellLead(item.agent, item.leadText, dry);
589
+ ladder.leadAt = now;
590
+ ladder.leadSession = lead ?? undefined;
591
+ this.deps.trace?.({ session: item.agent, summary: `Stall watcher: ${item.key} escalated to ${lead ?? 'no lead'}`, ...(item.workItemId ? { workItemId: item.workItemId } : {}), data: { reason: 'stall', step: 'lead', key: item.key, dryRun: dry } });
592
+ if (!dry)
593
+ await item.onStep?.('lead', new Date(now)).catch(() => undefined);
594
+ this.logger.info('Stall ladder: lead told', { key: item.key, agent: item.agent, lead, dryRun: dry });
595
+ return 'lead';
596
+ }
597
+ if (now < leadAt + C.LEAD_GRACE_MS)
598
+ return null;
599
+ if (ownerAt && now - ownerAt < C.OWNER_LINE_INTERVAL_MS)
600
+ return null;
601
+ const lead = ladder.leadSession ?? (await this.deps.leadOf(item.agent).catch(() => null));
602
+ const messages = item.thread ? await this.deps.threadMessages(item.thread).catch(() => []) : [];
603
+ if (lead && messages.some((m) => m.senderId === lead && m.createdAt > leadAt)) {
604
+ this.logger.info('Stall ladder: the lead answered in the thread; the owner is not told', { key: item.key, lead });
605
+ return null;
606
+ }
607
+ const line = await item.ownerLine(ownerLanguage(messages), lead ? await this.name(lead) : null);
608
+ if (dry) {
609
+ this.logger.info('Dry run: would tell the owner', { key: item.key, text: line });
610
+ }
611
+ else {
612
+ const ok = await this.deps.notifyOwner(line).catch(() => false);
613
+ if (!ok) {
614
+ this.logger.warn('Stall ladder: owner line not sent', { key: item.key });
615
+ return null;
616
+ }
617
+ await item.onStep?.('owner', new Date(now)).catch(() => undefined);
618
+ }
619
+ ladder.ownerAt = now;
620
+ this.deps.trace?.({ session: item.agent, summary: `Stall watcher: owner told about ${item.key}`, ...(item.workItemId ? { workItemId: item.workItemId } : {}), data: { reason: 'stall', step: 'owner', key: item.key, dryRun: dry } });
621
+ this.logger.info('Stall ladder: owner told', { key: item.key, agent: item.agent, dryRun: dry });
622
+ return 'owner';
623
+ }
624
+ // -------------------------------------------------------------------------
625
+ // (a) Promises to the owner
626
+ // -------------------------------------------------------------------------
627
+ /**
628
+ * Active commitments with a due time: the ladder keyed to the agent's
629
+ * estimate (due − made). The open-items sweep's own stamps count as steps.
630
+ *
631
+ * @param now - Epoch ms
632
+ * @param dry - Dry run
633
+ * @param result - Counters
634
+ */
635
+ async promisesPass(now, dry, result) {
636
+ const requests = await this.deps.listRequests();
637
+ for (const request of requests) {
638
+ if (request.status === 'cancelled' || request.status === 'done')
639
+ continue;
640
+ for (const item of request.openItems ?? []) {
641
+ if (item.type !== 'commitment' || !ACTIVE_OPEN_ITEM_STATUSES.has(item.status) || item.status === 'waiting_owner')
642
+ continue;
643
+ const madeAt = ms(item.createdAt);
644
+ const due = ms(item.due);
645
+ if (madeAt === null || due === null || due <= madeAt)
646
+ continue;
647
+ const step = await this.climb(this.promiseLadderItem(request, item, madeAt, due - madeAt, now), now, dry);
648
+ if (step)
649
+ result.promises[step] += 1;
650
+ }
651
+ }
652
+ }
653
+ /**
654
+ * The ladder item of a commitment.
655
+ *
656
+ * @param request - Its request
657
+ * @param item - The commitment
658
+ * @param madeAt - Promise time (ms)
659
+ * @param estimateMs - due − made
660
+ * @param now - Epoch ms
661
+ * @returns Ladder item
662
+ */
663
+ promiseLadderItem(request, item, madeAt, estimateMs, now) {
664
+ const thread = request.chatRef ? { channelId: request.chatRef.channelId, threadId: request.chatRef.threadRootId } : null;
665
+ const what = clip(item.text, C.QUOTE_MAX_CHARS);
666
+ const elapsedMs = now - madeAt;
667
+ return {
668
+ key: `promise:${request.id}/${item.id}`,
669
+ agent: item.agent,
670
+ madeAt,
671
+ estimateMs,
672
+ thread,
673
+ prior: {
674
+ ...(ms(item.stallNudgedAt) !== null ? { agentAt: ms(item.stallNudgedAt) } : {}),
675
+ ...(ms(item.leadNotifiedAt) !== null ? { leadAt: ms(item.leadNotifiedAt) } : {}),
676
+ ...(ms(item.ownerNotifiedAt) !== null ? { ownerAt: ms(item.ownerNotifiedAt) } : {}),
677
+ ...(ms(item.nudgedAt) !== null ? { recentNudgeAt: ms(item.nudgedAt) } : {}),
678
+ },
679
+ agentText: async () => {
680
+ const messages = thread ? await this.deps.threadMessages(thread).catch(() => []) : [];
681
+ const ctx = contextBefore(messages, item.agent, madeAt);
682
+ const context = ctx
683
+ ? ` Context — the last message before your promise (${ctx.senderType === 'user' ? 'the owner' : await this.name(ctx.senderId)}): "${clip(ctx.content, C.CONTEXT_MAX_CHARS)}" — if that was the go-ahead, do not wait for another.`
684
+ : '';
685
+ return (`${C.NUDGE_TAG} You told the owner "${what}" ${durationWords(elapsedMs)} ago and gave yourself about ${durationWords(estimateMs)}; nothing has been posted in that thread since. ` +
686
+ `Post the deliverable now, or tell the owner in one line what blocks it and when it will come.${context}`);
687
+ },
688
+ leadText: async () => {
689
+ const name = await this.name(item.agent);
690
+ return (`${C.NUDGE_TAG} ${name} promised the owner "${what}" ${durationWords(elapsedMs)} ago (estimate ${durationWords(estimateMs)}) and has posted nothing since being reminded. ` +
691
+ `Check on ${name}, reassign the work, or answer the owner in that thread.`);
692
+ },
693
+ ownerLine: async (lang, leadName) => promiseOwnerLine(lang, { name: await this.name(item.agent), what, estimateMs, elapsedMs, leadName }),
694
+ onStep: (step, at) => this.deps.recordPromiseStep(request.id, item.id, step, at),
695
+ ...(item.workItemId ? { workItemId: item.workItemId } : {}),
696
+ };
697
+ }
698
+ // -------------------------------------------------------------------------
699
+ // (b) Claimed WorkItems
700
+ // -------------------------------------------------------------------------
701
+ /**
702
+ * Running items with an active claim: the trace's ongoing stall nudges the
703
+ * claimer once; a second window releases the claim and tells the lead.
704
+ *
705
+ * @param now - Epoch ms
706
+ * @param dry - Dry run
707
+ * @param result - Counters
708
+ */
709
+ async claimsPass(now, dry, result) {
710
+ const claims = (await this.deps.activeClaims()).filter((c) => c.status === 'active' || c.status === 'expiring');
711
+ if (claims.length === 0)
712
+ return;
713
+ const items = new Map((await this.deps.listWorkItems()).map((w) => [w.id, w]));
714
+ const linked = (await this.deps.ticketLinkedWorkItemIds?.().catch(() => new Set())) ?? new Set();
715
+ for (const claim of claims) {
716
+ const wi = items.get(claim.workItemId);
717
+ if (!wi || wi.status !== 'running')
718
+ continue;
719
+ const key = `claim:${wi.id}`;
720
+ if (linked.has(wi.id))
721
+ continue; // the ticket autopilot re-briefs and releases those
722
+ const signal = await this.deps.stallOf({ workItemId: wi.id, session: claim.agentId }).catch(() => null);
723
+ if (!signal || signal.ms < C.CLAIM_STALL_MS || signal.cause === 'waiting_on_owner') {
724
+ if (this.state.ladders[key])
725
+ delete this.state.ladders[key]; // moving again: a fresh cycle next time
726
+ continue;
727
+ }
728
+ const ladder = (this.state.ladders[key] ??= { seenAt: now });
729
+ ladder.seenAt = now;
730
+ const title = clip(wi.title ?? wi.id, C.QUOTE_MAX_CHARS);
731
+ const minutes = Math.round(signal.ms / 60_000);
732
+ const cause = causeText(signal);
733
+ if (!ladder.agentAt) {
734
+ const text = `${C.NUDGE_TAG} Work item ${wi.id} ("${title}") has shown no progress for ${minutes} min — ${cause}. Continue it now, or release it with a one-line reason.`;
735
+ const outcome = await this.nudgeAgent(claim.agentId, text, now, dry);
736
+ if (outcome === 'cooldown' || outcome === 'failed')
737
+ continue;
738
+ ladder.agentAt = now;
739
+ result.claims.nudged += 1;
740
+ this.deps.trace?.({ session: claim.agentId, summary: `Stall watcher: ${wi.id} stalled ${minutes} min (${signal.cause}) — claimer nudged`, workItemId: wi.id, data: { reason: 'stall', step: 'agent', cause: signal.cause, dryRun: dry } });
741
+ this.logger.info('Stalled claim: claimer nudged', { workItemId: wi.id, agent: claim.agentId, minutes, cause: signal.cause, dryRun: dry });
742
+ continue;
743
+ }
744
+ if (ladder.leadAt || now - ladder.agentAt < C.CLAIM_STALL_MS || signal.ms < 2 * C.CLAIM_STALL_MS)
745
+ continue;
746
+ const reason = `stall watcher: no progress for ${minutes} min after a reminder (${cause})`;
747
+ if (dry) {
748
+ this.logger.info('Dry run: would release the claim and tell the lead', { workItemId: wi.id, agent: claim.agentId, reason });
749
+ }
750
+ else {
751
+ await this.deps.releaseClaim(wi.id, reason).catch((err) => this.logger.warn('Stalled claim not released', { workItemId: wi.id, error: errText(err) }));
752
+ }
753
+ const name = await this.name(claim.agentId);
754
+ const lead = await this.tellLead(claim.agentId, async () => `${C.NUDGE_TAG} ${name}'s work item ${wi.id} ("${title}") showed no progress for ${minutes} min after a reminder (${cause}); its claim was released and it is queued again, unassigned ` +
755
+ `(the route sweep hands it to you, then to the orchestrator). Take it, reassign it, or cancel it with a reason.`, dry);
756
+ ladder.leadAt = now;
757
+ result.claims.released += 1;
758
+ this.deps.trace?.({ session: claim.agentId, summary: `Stall watcher: ${wi.id} released after ${minutes} min with no progress; lead ${lead ?? 'none'} told`, workItemId: wi.id, data: { reason: 'stall', step: 'lead', cause: signal.cause, dryRun: dry } });
759
+ this.logger.warn('Stalled claim released and escalated', { workItemId: wi.id, agent: claim.agentId, lead, minutes, cause: signal.cause, dryRun: dry });
760
+ }
761
+ }
762
+ // -------------------------------------------------------------------------
763
+ // (c) Background runs
764
+ // -------------------------------------------------------------------------
765
+ /**
766
+ * Runs agents announced: closed when the thread shows a deliverable or a
767
+ * report, else the same ladder as promises from the run's start.
768
+ *
769
+ * @param now - Epoch ms
770
+ * @param dry - Dry run
771
+ * @param result - Counters
772
+ */
773
+ async runsPass(now, dry, result) {
774
+ for (const run of [...this.state.runs]) {
775
+ const thread = { channelId: run.channelId, threadId: run.threadId };
776
+ const key = `run:${run.id}`;
777
+ const { agentDue } = ladderDue(run.startedAt, run.estimateMs);
778
+ if (now < agentDue)
779
+ continue;
780
+ const messages = await this.deps.threadMessages(thread).catch(() => []);
781
+ if (runDeliveredIn(messages, run)) {
782
+ this.state.runs = this.state.runs.filter((r) => r.id !== run.id);
783
+ delete this.state.ladders[key];
784
+ result.runs.cleared += 1;
785
+ this.logger.info('Background run closed: the thread shows its result', { agent: run.agent, run: run.id });
786
+ continue;
787
+ }
788
+ const head = clip(run.head, C.QUOTE_MAX_CHARS);
789
+ const elapsedMs = now - run.startedAt;
790
+ const step = await this.climb({
791
+ key,
792
+ agent: run.agent,
793
+ madeAt: run.startedAt,
794
+ estimateMs: run.estimateMs,
795
+ thread,
796
+ agentText: async () => `${C.NUDGE_TAG} You said "${head}" ${durationWords(elapsedMs)} ago (estimate ${durationWords(run.estimateMs)}) and nothing has been posted in that thread since. ` +
797
+ `Check the run: post the result, or say in one line that it failed and what you will do.`,
798
+ leadText: async () => {
799
+ const name = await this.name(run.agent);
800
+ return (`${C.NUDGE_TAG} ${name} said "${head}" ${durationWords(elapsedMs)} ago (estimate ${durationWords(run.estimateMs)}) and has posted no result since being reminded. ` +
801
+ `Check whether the run is still alive, restart it or reassign it, or answer the owner in that thread.`);
802
+ },
803
+ ownerLine: async (lang, leadName) => runOwnerLine(lang, { name: await this.name(run.agent), head, estimateMs: run.estimateMs, elapsedMs, leadName }),
804
+ }, now, dry);
805
+ if (step)
806
+ result.runs[step] += 1;
807
+ }
808
+ }
809
+ // -------------------------------------------------------------------------
810
+ // (#1362) "X is doing it"
811
+ // -------------------------------------------------------------------------
812
+ /**
813
+ * Claims past their window: a WorkItem or ticket for the teammate exists,
814
+ * or the speaker is told once. Never the owner.
815
+ *
816
+ * @param now - Epoch ms
817
+ * @param dry - Dry run
818
+ * @param result - Counters
819
+ */
820
+ async teammateClaimsPass(now, dry, result) {
821
+ const keep = [];
822
+ for (const claim of this.state.claims) {
823
+ if (now < claim.saidAt + C.CLAIM_CHECK_WINDOW_MS) {
824
+ keep.push(claim);
825
+ continue;
826
+ }
827
+ result.teammateClaims.checked += 1;
828
+ const exists = await this.deps.assignmentExists(claim.speaker, claim.member, claim.saidAt, C.CLAIM_CHECK_WINDOW_MS).catch(() => true);
829
+ if (exists) {
830
+ this.logger.debug('Teammate claim backed by an assignment', { speaker: claim.speaker, member: claim.member, message: claim.id });
831
+ continue;
832
+ }
833
+ const outcome = await this.nudgeAgent(claim.speaker, claimNudgeText(claim.memberName, claim.head), now, dry);
834
+ if (outcome === 'cooldown') {
835
+ keep.push(claim); // next pass (pruned if it never gets through)
836
+ continue;
837
+ }
838
+ result.teammateClaims.nudged += 1;
839
+ this.deps.trace?.({ session: claim.speaker, summary: `Claim check: told the owner ${claim.memberName} is doing it; nothing assigned to ${claim.member}`, data: { reason: 'claim_check', member: claim.member, dryRun: dry } });
840
+ this.logger.warn('Teammate claim with no assignment — speaker told', { speaker: claim.speaker, member: claim.member, message: claim.id, outcome });
841
+ }
842
+ this.state.claims = keep;
843
+ }
844
+ }
845
+ /**
846
+ * Error text.
847
+ *
848
+ * @param err - Anything thrown
849
+ * @returns Message
850
+ */
851
+ function errText(err) {
852
+ return err instanceof Error ? err.message : String(err);
853
+ }
854
+ //# sourceMappingURL=stall-watcher.service.js.map