crewly 1.20.360 → 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.
- package/config/roles/team-leader/prompt.md +1 -1
- package/config/roles/team-leader/tl-addon.md +1 -1
- package/config/skills/team-leader/delegate-task/SKILL.md +21 -6
- package/config/skills/team-leader/delegate-task/execute.sh +12 -1
- package/dist/backend/backend/src/constants.d.ts +155 -6
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +155 -6
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.js +7 -4
- package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +58 -3
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/agent/boot-pressure.d.ts +19 -3
- package/dist/backend/backend/src/services/agent/boot-pressure.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/boot-pressure.js +30 -7
- package/dist/backend/backend/src/services/agent/boot-pressure.js.map +1 -1
- package/dist/backend/backend/src/services/agent/follow-through.wiring.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/follow-through.wiring.js +2 -0
- package/dist/backend/backend/src/services/agent/follow-through.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/agent/handover-interrupted.d.ts +76 -0
- package/dist/backend/backend/src/services/agent/handover-interrupted.d.ts.map +1 -0
- package/dist/backend/backend/src/services/agent/handover-interrupted.js +139 -0
- package/dist/backend/backend/src/services/agent/handover-interrupted.js.map +1 -0
- package/dist/backend/backend/src/services/agent/handover-pointers.d.ts +4 -0
- package/dist/backend/backend/src/services/agent/handover-pointers.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/handover-pointers.js +11 -4
- package/dist/backend/backend/src/services/agent/handover-pointers.js.map +1 -1
- package/dist/backend/backend/src/services/agent/idle-work-guard.d.ts +3 -1
- package/dist/backend/backend/src/services/agent/idle-work-guard.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/idle-work-guard.js +2 -0
- package/dist/backend/backend/src/services/agent/idle-work-guard.js.map +1 -1
- package/dist/backend/backend/src/services/agent/resource-mode-protection.d.ts +5 -1
- package/dist/backend/backend/src/services/agent/resource-mode-protection.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/resource-mode-protection.js +6 -1
- package/dist/backend/backend/src/services/agent/resource-mode-protection.js.map +1 -1
- package/dist/backend/backend/src/services/agent/runtime-session-recovery.d.ts +7 -0
- package/dist/backend/backend/src/services/agent/runtime-session-recovery.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/runtime-session-recovery.js +18 -1
- package/dist/backend/backend/src/services/agent/runtime-session-recovery.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.singleton.d.ts +8 -0
- package/dist/backend/backend/src/services/chat-v2/chat-v2.singleton.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.singleton.js +10 -0
- package/dist/backend/backend/src/services/chat-v2/chat-v2.singleton.js.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-item-extractor.d.ts +43 -2
- package/dist/backend/backend/src/services/open-items/open-item-extractor.d.ts.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-item-extractor.js +124 -13
- package/dist/backend/backend/src/services/open-items/open-item-extractor.js.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-items.service.d.ts +40 -1
- package/dist/backend/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-items.service.js +76 -3
- package/dist/backend/backend/src/services/open-items/open-items.service.js.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-items.wiring.d.ts +8 -0
- package/dist/backend/backend/src/services/open-items/open-items.wiring.d.ts.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-items.wiring.js +19 -1
- package/dist/backend/backend/src/services/open-items/open-items.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/orc/owner-completion-report.service.d.ts +35 -19
- package/dist/backend/backend/src/services/orc/owner-completion-report.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orc/owner-completion-report.service.js +60 -63
- package/dist/backend/backend/src/services/orc/owner-completion-report.service.js.map +1 -1
- package/dist/backend/backend/src/services/orc/owner-completion-report.text.d.ts +86 -0
- package/dist/backend/backend/src/services/orc/owner-completion-report.text.d.ts.map +1 -0
- package/dist/backend/backend/src/services/orc/owner-completion-report.text.js +170 -0
- package/dist/backend/backend/src/services/orc/owner-completion-report.text.js.map +1 -0
- package/dist/backend/backend/src/services/orc/owner-completion-report.wiring.d.ts +24 -0
- package/dist/backend/backend/src/services/orc/owner-completion-report.wiring.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orc/owner-completion-report.wiring.js +38 -1
- package/dist/backend/backend/src/services/orc/owner-completion-report.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts +2 -0
- package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orc/reply-destination.wiring.js +8 -3
- package/dist/backend/backend/src/services/orc/reply-destination.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/orc/work-item-destination.d.ts +76 -0
- package/dist/backend/backend/src/services/orc/work-item-destination.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orc/work-item-destination.js +83 -3
- package/dist/backend/backend/src/services/orc/work-item-destination.js.map +1 -1
- package/dist/backend/backend/src/services/orc/work-item-destination.wiring.d.ts +31 -2
- package/dist/backend/backend/src/services/orc/work-item-destination.wiring.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orc/work-item-destination.wiring.js +38 -0
- package/dist/backend/backend/src/services/orc/work-item-destination.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/stall/background-run-detector.d.ts +56 -0
- package/dist/backend/backend/src/services/stall/background-run-detector.d.ts.map +1 -0
- package/dist/backend/backend/src/services/stall/background-run-detector.js +93 -0
- package/dist/backend/backend/src/services/stall/background-run-detector.js.map +1 -0
- package/dist/backend/backend/src/services/stall/claim-check.d.ts +60 -0
- package/dist/backend/backend/src/services/stall/claim-check.d.ts.map +1 -0
- package/dist/backend/backend/src/services/stall/claim-check.js +98 -0
- package/dist/backend/backend/src/services/stall/claim-check.js.map +1 -0
- package/dist/backend/backend/src/services/stall/in-flight-runs.store.d.ts +110 -0
- package/dist/backend/backend/src/services/stall/in-flight-runs.store.d.ts.map +1 -0
- package/dist/backend/backend/src/services/stall/in-flight-runs.store.js +109 -0
- package/dist/backend/backend/src/services/stall/in-flight-runs.store.js.map +1 -0
- package/dist/backend/backend/src/services/stall/stall-watcher.service.d.ts +456 -0
- package/dist/backend/backend/src/services/stall/stall-watcher.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/stall/stall-watcher.service.js +854 -0
- package/dist/backend/backend/src/services/stall/stall-watcher.service.js.map +1 -0
- package/dist/backend/backend/src/services/stall/stall-watcher.wiring.d.ts +98 -0
- package/dist/backend/backend/src/services/stall/stall-watcher.wiring.d.ts.map +1 -0
- package/dist/backend/backend/src/services/stall/stall-watcher.wiring.js +217 -0
- package/dist/backend/backend/src/services/stall/stall-watcher.wiring.js.map +1 -0
- package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts +53 -0
- package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/task-pool/task-pool.service.js +162 -15
- package/dist/backend/backend/src/services/task-pool/task-pool.service.js.map +1 -1
- package/dist/backend/backend/src/services/tl-delegation/tl-delegation.service.js +1 -1
- package/dist/backend/backend/src/services/tl-delegation/tl-delegation.service.js.map +1 -1
- package/dist/backend/backend/src/types/v2/open-item.types.d.ts +2 -0
- package/dist/backend/backend/src/types/v2/open-item.types.d.ts.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +155 -6
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +155 -6
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/agent/boot-pressure.d.ts +19 -3
- package/dist/cli/backend/src/services/agent/boot-pressure.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/boot-pressure.js +30 -7
- package/dist/cli/backend/src/services/agent/boot-pressure.js.map +1 -1
- package/dist/cli/backend/src/services/agent/handover-interrupted.d.ts +76 -0
- package/dist/cli/backend/src/services/agent/handover-interrupted.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/handover-interrupted.js +139 -0
- package/dist/cli/backend/src/services/agent/handover-interrupted.js.map +1 -0
- package/dist/cli/backend/src/services/agent/handover-pointers.d.ts +4 -0
- package/dist/cli/backend/src/services/agent/handover-pointers.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/handover-pointers.js +11 -4
- package/dist/cli/backend/src/services/agent/handover-pointers.js.map +1 -1
- package/dist/cli/backend/src/services/agent/runtime-session-recovery.d.ts +7 -0
- package/dist/cli/backend/src/services/agent/runtime-session-recovery.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/runtime-session-recovery.js +18 -1
- package/dist/cli/backend/src/services/agent/runtime-session-recovery.js.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.singleton.d.ts +8 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.singleton.d.ts.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.singleton.js +10 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.singleton.js.map +1 -1
- package/dist/cli/backend/src/services/open-items/open-item-extractor.d.ts +43 -2
- package/dist/cli/backend/src/services/open-items/open-item-extractor.d.ts.map +1 -1
- package/dist/cli/backend/src/services/open-items/open-item-extractor.js +124 -13
- package/dist/cli/backend/src/services/open-items/open-item-extractor.js.map +1 -1
- package/dist/cli/backend/src/services/open-items/open-items.service.d.ts +40 -1
- package/dist/cli/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/open-items/open-items.service.js +76 -3
- package/dist/cli/backend/src/services/open-items/open-items.service.js.map +1 -1
- package/dist/cli/backend/src/services/orc/work-item-destination.d.ts +76 -0
- package/dist/cli/backend/src/services/orc/work-item-destination.d.ts.map +1 -1
- package/dist/cli/backend/src/services/orc/work-item-destination.js +83 -3
- package/dist/cli/backend/src/services/orc/work-item-destination.js.map +1 -1
- package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts +53 -0
- package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/task-pool/task-pool.service.js +162 -15
- package/dist/cli/backend/src/services/task-pool/task-pool.service.js.map +1 -1
- package/dist/cli/backend/src/types/v2/open-item.types.d.ts +2 -0
- package/dist/cli/backend/src/types/v2/open-item.types.d.ts.map +1 -1
- 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
|