crewly 1.20.270 → 1.20.272
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/dist/backend/backend/src/constants.d.ts +39 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +39 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js +12 -0
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/team/team.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/team/team.controller.js +11 -0
- package/dist/backend/backend/src/controllers/team/team.controller.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts +18 -0
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +85 -0
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/agent/idle-detection.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/idle-detection.service.js +3 -0
- package/dist/backend/backend/src/services/agent/idle-detection.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/resource-mode.service.d.ts +16 -1
- package/dist/backend/backend/src/services/agent/resource-mode.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/resource-mode.service.js +24 -3
- package/dist/backend/backend/src/services/agent/resource-mode.service.js.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts +17 -0
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.service.js +21 -0
- package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.wiring.js +11 -0
- package/dist/backend/backend/src/services/decisions/decision.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.d.ts +11 -0
- package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.js +25 -0
- package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.service.d.ts +320 -0
- package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.service.js +692 -0
- package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.service.js.map +1 -0
- package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.wiring.d.ts +139 -0
- package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.wiring.d.ts.map +1 -0
- package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.wiring.js +243 -0
- package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.wiring.js.map +1 -0
- package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-agent-post.service.js +3 -0
- package/dist/backend/backend/src/services/slack/slack-agent-post.service.js.map +1 -1
- 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 +13 -1
- package/dist/backend/backend/src/services/task-pool/task-pool.service.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +39 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +39 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/agent/resource-mode.service.d.ts +16 -1
- package/dist/cli/backend/src/services/agent/resource-mode.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/resource-mode.service.js +24 -3
- package/dist/cli/backend/src/services/agent/resource-mode.service.js.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision.service.d.ts +17 -0
- package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision.service.js +21 -0
- package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -1
- package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.d.ts +11 -0
- package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.js +25 -0
- package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.js.map +1 -1
- package/dist/cli/backend/src/services/messaging/owner-thread-sentinel.service.d.ts +320 -0
- package/dist/cli/backend/src/services/messaging/owner-thread-sentinel.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/messaging/owner-thread-sentinel.service.js +692 -0
- package/dist/cli/backend/src/services/messaging/owner-thread-sentinel.service.js.map +1 -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 +13 -1
- package/dist/cli/backend/src/services/task-pool/task-pool.service.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-thread sentinel: an owner Slack thread is never left silent.
|
|
3
|
+
*
|
|
4
|
+
* The failure it closes (2026-10-08, Atlas in #pro-think-tank): an agent says
|
|
5
|
+
* in the owner's thread "I'm reading the attachments, ~15 min", then gets
|
|
6
|
+
* stuck — a browser approval card goes top-level in the channel, Crewly
|
|
7
|
+
* restarts, the card expires, the agent is stopped to free a slot — and the
|
|
8
|
+
* thread says nothing until the owner asks "why no reply?".
|
|
9
|
+
*
|
|
10
|
+
* A thread is watched while the owner's last message there is unanswered, the
|
|
11
|
+
* agent's last post there is a promise ("I'm doing…", "~N min", 「马上」), the
|
|
12
|
+
* agent has owner work from that thread, or a card the agent waits on is open.
|
|
13
|
+
* On any blocking event for that agent — card posted / expired, work blocked,
|
|
14
|
+
* agent stopped, start failed or deferred, delivery held, Crewly restarted —
|
|
15
|
+
* ONE short status line goes into the thread. A promise of "~N min" that
|
|
16
|
+
* passes N×1.5 with no post gets one line saying what the agent is doing now,
|
|
17
|
+
* and the agent is nudged once.
|
|
18
|
+
*
|
|
19
|
+
* Dedupe: one line per state change per thread; the same state is never
|
|
20
|
+
* repeated within REPEAT_STATE_MS; informational lines are at least
|
|
21
|
+
* MIN_INFO_GAP_MS apart. Any post by the agent in the thread resets it.
|
|
22
|
+
*
|
|
23
|
+
* Transport-free: posting, nudging and lookups are injected (see the wiring).
|
|
24
|
+
*
|
|
25
|
+
* @module services/messaging/owner-thread-sentinel.service
|
|
26
|
+
* @see specs/2026-10-08-owner-thread-sentinel.md
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
29
|
+
import * as path from 'path';
|
|
30
|
+
import { ORCHESTRATOR_SESSION_NAME, OWNER_THREAD_SENTINEL_CONSTANTS as C } from '../../constants.js';
|
|
31
|
+
import { LoggerService } from '../core/logger.service.js';
|
|
32
|
+
const EN_PROMISE = [
|
|
33
|
+
/\b(?:i'?m|i am|we'?re|we are)\s+(?:now\s+|currently\s+|still\s+|just\s+)?(?:[a-z]+ing\b|on it|going to|about to)/i,
|
|
34
|
+
/\b(?:i'?ll|i will|we'?ll|we will|let me|i'?m going to|will (?:report|update|follow up|get back|post|send|share|let you know|ping))\b/i,
|
|
35
|
+
/\b(?:on it|eta|shortly|be right back|brb|in a (?:few|bit|moment|minute|sec(?:ond)?))\b/i,
|
|
36
|
+
/\bwaiting (?:for|on)\b/i,
|
|
37
|
+
];
|
|
38
|
+
const ZH_PROMISE = /(正在|在做|在查|在看|在读|在写|马上|稍等|等(?:我|一下|下|会|你)|预计|一会|待会|稍后|这就|我来|我去|我先|做完|弄好|搞定后|完成后|回头)/;
|
|
39
|
+
const DURATION = /(?:~|~|约|大约|大概|about|around|approx\.?|roughly|in|within|需要|要)?\s*(\d+(?:\.\d+)?)\s*(?:[-–~~到至]\s*(\d+(?:\.\d+)?)\s*)?(minutes?|mins?|m\b|分钟|分|hours?|hrs?|h\b|小时)/i;
|
|
40
|
+
const CN_DIGITS = { 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
|
|
41
|
+
/**
|
|
42
|
+
* A Chinese numeral up to 99 ("十五" → 15, "二十" → 20, "五" → 5).
|
|
43
|
+
*
|
|
44
|
+
* @param s - Numeral
|
|
45
|
+
* @returns The number, or undefined
|
|
46
|
+
*/
|
|
47
|
+
function cnNumber(s) {
|
|
48
|
+
if (!s)
|
|
49
|
+
return undefined;
|
|
50
|
+
if (s === '十')
|
|
51
|
+
return 10;
|
|
52
|
+
const m = /^([一二两三四五六七八九])?(十)?([一二三四五六七八九])?$/.exec(s);
|
|
53
|
+
if (!m)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (!m[2])
|
|
56
|
+
return m[1] ? CN_DIGITS[m[1]] : undefined;
|
|
57
|
+
return (m[1] ? CN_DIGITS[m[1]] : 1) * 10 + (m[3] ? CN_DIGITS[m[3]] : 0);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Minutes stated in a text ("~15 min", "10-20 minutes" → 20, "1.5h" → 90,
|
|
61
|
+
* 「半小时」 → 30, 「十五分钟」 → 15, 「几分钟」 → 5).
|
|
62
|
+
*
|
|
63
|
+
* @param text - The post
|
|
64
|
+
* @returns Minutes, or undefined when none is stated
|
|
65
|
+
*/
|
|
66
|
+
export function parsePromisedMinutes(text) {
|
|
67
|
+
const t = text ?? '';
|
|
68
|
+
if (/半(?:个)?小时/.test(t))
|
|
69
|
+
return 30;
|
|
70
|
+
if (/几分钟/.test(t))
|
|
71
|
+
return 5;
|
|
72
|
+
const cn = /([一二两三四五六七八九十]{1,3})\s*(?:个)?(分钟|小时)/.exec(t);
|
|
73
|
+
if (cn) {
|
|
74
|
+
const n = cnNumber(cn[1]);
|
|
75
|
+
if (n !== undefined)
|
|
76
|
+
return cn[2] === '小时' ? n * 60 : n;
|
|
77
|
+
}
|
|
78
|
+
const m = DURATION.exec(t);
|
|
79
|
+
if (!m)
|
|
80
|
+
return undefined;
|
|
81
|
+
const n = Number(m[2] ?? m[1]);
|
|
82
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
83
|
+
return undefined;
|
|
84
|
+
const unit = m[3].toLowerCase();
|
|
85
|
+
const minutes = /^(h|hours?|hrs?|小时)$/.test(unit) ? n * 60 : n;
|
|
86
|
+
return Math.round(minutes);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Whether an agent's post is a promise / in-progress statement, and the
|
|
90
|
+
* minutes it states. A post that only reports what was done is not one.
|
|
91
|
+
*
|
|
92
|
+
* @param text - The agent's post
|
|
93
|
+
* @returns The promise, or null
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```typescript
|
|
97
|
+
* detectPromise("I'm reading the attachments now, ~15 min"); // { minutes: 15 }
|
|
98
|
+
* detectPromise('Done — the report is attached.'); // null
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function detectPromise(text) {
|
|
102
|
+
const t = (text ?? '').trim();
|
|
103
|
+
if (!t)
|
|
104
|
+
return null;
|
|
105
|
+
const minutes = parsePromisedMinutes(t);
|
|
106
|
+
const explicitEta = /(?:~|~|约|大约|预计|\beta\b)\s*\d/i.test(t) || /\b(?:in|within)\s+\d+\s*(?:min|minutes?|h|hours?)\b/i.test(t);
|
|
107
|
+
const said = explicitEta || EN_PROMISE.some((re) => re.test(t)) || ZH_PROMISE.test(t);
|
|
108
|
+
if (!said)
|
|
109
|
+
return null;
|
|
110
|
+
return minutes !== undefined && minutes <= C.MAX_PROMISE_MINUTES ? { minutes } : {};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Key of a thread.
|
|
114
|
+
*
|
|
115
|
+
* @param ref - The place
|
|
116
|
+
* @returns `<channel>:<threadTs or ''>`
|
|
117
|
+
*/
|
|
118
|
+
export function threadKey(ref) {
|
|
119
|
+
return `${ref.slackChannelId}:${ref.threadTs ?? ''}`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* A Slack DM (its top level is the conversation).
|
|
123
|
+
*
|
|
124
|
+
* @param slackChannelId - Channel id
|
|
125
|
+
* @returns True for a DM id
|
|
126
|
+
*/
|
|
127
|
+
function isDm(slackChannelId) {
|
|
128
|
+
return slackChannelId.startsWith('D');
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Clip text to one line.
|
|
132
|
+
*
|
|
133
|
+
* @param text - Text
|
|
134
|
+
* @param max - Limit
|
|
135
|
+
* @returns Clipped text
|
|
136
|
+
*/
|
|
137
|
+
function clip(text, max = C.QUOTE_CHARS) {
|
|
138
|
+
const one = (text ?? '').replace(/\s+/g, ' ').trim();
|
|
139
|
+
return one.length > max ? `${one.slice(0, max - 1)}…` : one;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The status line for an event, in plain English (harness text).
|
|
143
|
+
*
|
|
144
|
+
* @param event - What happened
|
|
145
|
+
* @param name - Agent display name
|
|
146
|
+
* @returns The line, or null when the event posts nothing
|
|
147
|
+
*/
|
|
148
|
+
export function statusLineFor(event, name) {
|
|
149
|
+
const link = (url, label) => (url ? ` — <${url}|${label}>` : '');
|
|
150
|
+
switch (event.kind) {
|
|
151
|
+
case 'card_posted':
|
|
152
|
+
return `⏳ ${name} is waiting for your OK: ${clip(event.question)}${link(event.link, 'Approve here')}`;
|
|
153
|
+
case 'card_expired':
|
|
154
|
+
return event.browser
|
|
155
|
+
? `⚠️ ${name}'s approval request expired before you answered${event.why ? ` (${event.why})` : ''}. ${name} is asking again — the new card will show up here.`
|
|
156
|
+
: `⚠️ ${name}'s question expired before you answered: ${clip(event.question)}${link(event.link, 'see the card')}. Reply here if it is still needed.`;
|
|
157
|
+
case 'card_parked':
|
|
158
|
+
return `⏸ ${name} is still waiting for your OK and has parked this until you answer: ${clip(event.question)}${link(event.link, 'Answer here')}`;
|
|
159
|
+
case 'card_settled':
|
|
160
|
+
return null;
|
|
161
|
+
case 'work_blocked':
|
|
162
|
+
return `⏸ ${name} is blocked${event.reason ? `: ${clip(event.reason)}` : '.'} Your request is kept.`;
|
|
163
|
+
case 'stopped':
|
|
164
|
+
if (event.why === 'slot')
|
|
165
|
+
return `⏸ ${name} was paused to free a slot for another agent; it resumes automatically when one frees (your request is kept).`;
|
|
166
|
+
return `⏸ ${name} was stopped to save memory while it looked idle; your request is kept and ${name} is woken when you reply here or its work moves.`;
|
|
167
|
+
case 'start_failed':
|
|
168
|
+
return `⚠️ ${name} could not start${event.detail ? `: ${clip(event.detail)}` : '.'} Your request is kept — reply here to try again.`;
|
|
169
|
+
case 'start_deferred':
|
|
170
|
+
return `⏳ ${name} is queued to start: this machine is at its running-agent limit. It starts when a slot frees (your request is kept).`;
|
|
171
|
+
case 'delivery_held': {
|
|
172
|
+
const why = event.why === 'unreadable'
|
|
173
|
+
? "Crewly can't read its input box and is recovering it"
|
|
174
|
+
: event.why === 'foreign'
|
|
175
|
+
? 'its input box has text Crewly did not type — check its terminal'
|
|
176
|
+
: event.why === 'busy'
|
|
177
|
+
? 'it has looked busy for a long time and may be stuck'
|
|
178
|
+
: 'its input is blocked';
|
|
179
|
+
return `⚠️ Messages to ${name} are on hold: ${why}. Your request is kept.`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** State id of an event, for dedupe. */
|
|
184
|
+
function stateOf(event) {
|
|
185
|
+
switch (event.kind) {
|
|
186
|
+
case 'card_posted':
|
|
187
|
+
case 'card_expired':
|
|
188
|
+
case 'card_parked':
|
|
189
|
+
case 'card_settled':
|
|
190
|
+
return `${event.kind}:${event.decisionId}`;
|
|
191
|
+
case 'stopped':
|
|
192
|
+
return `stopped:${event.why}`;
|
|
193
|
+
case 'delivery_held':
|
|
194
|
+
return `held:${event.why}`;
|
|
195
|
+
default:
|
|
196
|
+
return event.kind;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Events the owner must act on: never held back by the info gap. */
|
|
200
|
+
function isActionable(event) {
|
|
201
|
+
return event.kind === 'card_posted' || event.kind === 'card_expired' || event.kind === 'card_parked' || event.kind === 'start_failed';
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Watches owner threads and posts one status line per blocking event.
|
|
205
|
+
*/
|
|
206
|
+
export class OwnerThreadSentinelService {
|
|
207
|
+
deps;
|
|
208
|
+
logger;
|
|
209
|
+
entries = new Map();
|
|
210
|
+
timer = null;
|
|
211
|
+
ticking = false;
|
|
212
|
+
/**
|
|
213
|
+
* @param deps - Injected behaviour
|
|
214
|
+
*/
|
|
215
|
+
constructor(deps) {
|
|
216
|
+
this.deps = deps;
|
|
217
|
+
this.logger = LoggerService.getInstance().createComponentLogger('OwnerThreadSentinel');
|
|
218
|
+
this.load();
|
|
219
|
+
}
|
|
220
|
+
now() {
|
|
221
|
+
return this.deps.now ? this.deps.now() : Date.now();
|
|
222
|
+
}
|
|
223
|
+
nameOf(agent) {
|
|
224
|
+
return this.deps.displayNameOf?.(agent) ?? agent;
|
|
225
|
+
}
|
|
226
|
+
/** Start the periodic evaluation. */
|
|
227
|
+
start() {
|
|
228
|
+
if (this.timer)
|
|
229
|
+
return;
|
|
230
|
+
this.timer = setInterval(() => void this.tick(), C.TICK_MS);
|
|
231
|
+
this.timer.unref?.();
|
|
232
|
+
}
|
|
233
|
+
/** Stop the periodic evaluation. */
|
|
234
|
+
stop() {
|
|
235
|
+
if (this.timer)
|
|
236
|
+
clearInterval(this.timer);
|
|
237
|
+
this.timer = null;
|
|
238
|
+
}
|
|
239
|
+
/** @returns Watched threads (debug, tests) */
|
|
240
|
+
list() {
|
|
241
|
+
return [...this.entries.values()].map((e) => ({ ...e, cards: [...e.cards], recentStates: { ...e.recentStates } }));
|
|
242
|
+
}
|
|
243
|
+
// -------------------------------------------------------------------------
|
|
244
|
+
// Inbound
|
|
245
|
+
// -------------------------------------------------------------------------
|
|
246
|
+
/**
|
|
247
|
+
* The owner wrote in a Slack thread / DM and `agent` must answer.
|
|
248
|
+
*
|
|
249
|
+
* @param input - Where, who must answer, when
|
|
250
|
+
*/
|
|
251
|
+
noteOwnerMessage(input) {
|
|
252
|
+
if (!input.slackChannelId || !input.agent)
|
|
253
|
+
return;
|
|
254
|
+
const at = input.at ?? this.now();
|
|
255
|
+
const entry = this.ensure({ slackChannelId: input.slackChannelId, threadTs: input.threadTs }, input.agent);
|
|
256
|
+
entry.agent = input.agent;
|
|
257
|
+
entry.ownerAt = Math.max(entry.ownerAt ?? 0, at);
|
|
258
|
+
entry.updatedAt = this.now();
|
|
259
|
+
this.persist();
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* An agent posted in Slack. In a watched thread the post resets the
|
|
263
|
+
* status state, and a promise ("~15 min") is remembered and timed.
|
|
264
|
+
*
|
|
265
|
+
* @param input - Who, where, what
|
|
266
|
+
*/
|
|
267
|
+
noteAgentPost(input) {
|
|
268
|
+
const entry = this.find(input);
|
|
269
|
+
if (!entry)
|
|
270
|
+
return;
|
|
271
|
+
// The orchestrator routing or relaying in a thread an agent owns ("Atlas
|
|
272
|
+
// is on it") neither takes the thread over nor settles the agent's promise.
|
|
273
|
+
if (input.agent === ORCHESTRATOR_SESSION_NAME && entry.agent !== ORCHESTRATOR_SESSION_NAME)
|
|
274
|
+
return;
|
|
275
|
+
const at = input.at ?? this.now();
|
|
276
|
+
entry.agent = input.agent;
|
|
277
|
+
entry.agentPostAt = at;
|
|
278
|
+
entry.agentPreview = clip(input.text, 120);
|
|
279
|
+
entry.lastStatus = undefined;
|
|
280
|
+
entry.recentStates = {};
|
|
281
|
+
entry.restartPending = false;
|
|
282
|
+
const promise = detectPromise(input.text) ?? (input.interim ? {} : null);
|
|
283
|
+
if (promise) {
|
|
284
|
+
const minutes = promise.minutes;
|
|
285
|
+
entry.promise = {
|
|
286
|
+
at,
|
|
287
|
+
...(minutes !== undefined ? { minutes, deadlineAt: at + Math.max(C.MIN_DEADLINE_MS, minutes * C.DEADLINE_FACTOR * 60_000) } : {}),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
entry.promise = undefined;
|
|
292
|
+
entry.workOrigin = false;
|
|
293
|
+
}
|
|
294
|
+
entry.updatedAt = at;
|
|
295
|
+
this.persist();
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Something blocked `agent`: post one status line in each owner thread
|
|
299
|
+
* waiting on it (deduped).
|
|
300
|
+
*
|
|
301
|
+
* @param agent - The agent
|
|
302
|
+
* @param event - What happened
|
|
303
|
+
* @returns How many lines were posted
|
|
304
|
+
*/
|
|
305
|
+
async noteBlocking(agent, event) {
|
|
306
|
+
if (!agent)
|
|
307
|
+
return 0;
|
|
308
|
+
if (event.kind === 'card_settled') {
|
|
309
|
+
let changed = false;
|
|
310
|
+
for (const e of this.entries.values()) {
|
|
311
|
+
if (e.agent !== agent)
|
|
312
|
+
continue;
|
|
313
|
+
const before = e.cards.length;
|
|
314
|
+
e.cards = e.cards.filter((c) => c.id !== event.decisionId);
|
|
315
|
+
if (e.reask?.decisionId === event.decisionId)
|
|
316
|
+
e.reask = undefined;
|
|
317
|
+
if (e.cards.length !== before) {
|
|
318
|
+
changed = true;
|
|
319
|
+
e.cardClosedAt = this.now();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (changed)
|
|
323
|
+
this.persist();
|
|
324
|
+
return 0;
|
|
325
|
+
}
|
|
326
|
+
const targets = await this.targetsFor(agent, event);
|
|
327
|
+
if (targets.length === 0)
|
|
328
|
+
return 0;
|
|
329
|
+
const now = this.now();
|
|
330
|
+
let posted = 0;
|
|
331
|
+
for (const entry of targets) {
|
|
332
|
+
if (event.kind === 'card_posted') {
|
|
333
|
+
if (!entry.cards.some((c) => c.id === event.decisionId))
|
|
334
|
+
entry.cards.push({ id: event.decisionId, at: now });
|
|
335
|
+
// The agent asked again: no re-ask nudge.
|
|
336
|
+
if (entry.reask && !entry.reask.done)
|
|
337
|
+
entry.reask = undefined;
|
|
338
|
+
// The card is in this very thread: the card is the status.
|
|
339
|
+
if (event.place && threadKey(event.place) === entry.key) {
|
|
340
|
+
entry.lastStatus = { state: stateOf(event), at: now };
|
|
341
|
+
entry.recentStates[stateOf(event)] = now;
|
|
342
|
+
entry.updatedAt = now;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (event.kind === 'card_expired' || event.kind === 'card_parked') {
|
|
347
|
+
entry.cards = entry.cards.filter((c) => c.id !== event.decisionId);
|
|
348
|
+
entry.cardClosedAt = now;
|
|
349
|
+
if (event.kind === 'card_expired' && event.browser) {
|
|
350
|
+
entry.reask = { decisionId: event.decisionId, dueAt: now + C.REASK_GRACE_MS };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// A card already tells the owner why the agent waits; its answer wakes it.
|
|
354
|
+
if ((event.kind === 'work_blocked' || event.kind === 'stopped') && entry.cards.length > 0)
|
|
355
|
+
continue;
|
|
356
|
+
const text = statusLineFor(event, this.nameOf(agent));
|
|
357
|
+
if (text && (await this.emit(entry, stateOf(event), text, isActionable(event))))
|
|
358
|
+
posted++;
|
|
359
|
+
entry.updatedAt = now;
|
|
360
|
+
}
|
|
361
|
+
this.persist();
|
|
362
|
+
return posted;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Whether the agent has an unanswered owner message or an open promise in
|
|
366
|
+
* an owner thread within RECENT_PROMISE_MS — slot freeing and idle stops
|
|
367
|
+
* spare it when another candidate exists.
|
|
368
|
+
*
|
|
369
|
+
* @param agent - The agent
|
|
370
|
+
* @returns True when it owes an owner thread right now
|
|
371
|
+
*/
|
|
372
|
+
owesRecently(agent) {
|
|
373
|
+
const now = this.now();
|
|
374
|
+
for (const e of this.entries.values()) {
|
|
375
|
+
if (e.agent !== agent)
|
|
376
|
+
continue;
|
|
377
|
+
if (e.ownerAt && (!e.agentPostAt || e.ownerAt > e.agentPostAt) && now - e.ownerAt <= C.RECENT_PROMISE_MS)
|
|
378
|
+
return true;
|
|
379
|
+
if (e.promise && now - e.promise.at <= C.RECENT_PROMISE_MS)
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* The owner thread `agent` owes right now (the newest one active within
|
|
386
|
+
* CARD_THREAD_MAX_AGE_MS), for placing a card it asks.
|
|
387
|
+
*
|
|
388
|
+
* @param agent - The agent
|
|
389
|
+
* @returns The thread, or null
|
|
390
|
+
*/
|
|
391
|
+
ownerThreadFor(agent) {
|
|
392
|
+
const now = this.now();
|
|
393
|
+
const mine = [...this.entries.values()]
|
|
394
|
+
.filter((e) => e.agent === agent && owes(e, now) && now - e.updatedAt <= C.CARD_THREAD_MAX_AGE_MS)
|
|
395
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
396
|
+
const e = mine[0];
|
|
397
|
+
return e ? { slackChannelId: e.slackChannelId, ...(e.threadTs ? { threadTs: e.threadTs } : {}) } : null;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Agents an owner thread waits on: a promised follow-up, an unanswered
|
|
401
|
+
* owner message, or an open card (boot restore treats them as work in hand).
|
|
402
|
+
*
|
|
403
|
+
* @returns Session names
|
|
404
|
+
*/
|
|
405
|
+
owingAgents() {
|
|
406
|
+
const now = this.now();
|
|
407
|
+
return [...new Set([...this.entries.values()].filter((e) => owes(e, now)).map((e) => e.agent))];
|
|
408
|
+
}
|
|
409
|
+
// -------------------------------------------------------------------------
|
|
410
|
+
// Tick
|
|
411
|
+
// -------------------------------------------------------------------------
|
|
412
|
+
/**
|
|
413
|
+
* One pass: restart lines, overdue promises, browser re-asks, pruning.
|
|
414
|
+
*
|
|
415
|
+
* @returns How many lines / nudges went out
|
|
416
|
+
*/
|
|
417
|
+
async tick() {
|
|
418
|
+
if (this.ticking)
|
|
419
|
+
return 0;
|
|
420
|
+
this.ticking = true;
|
|
421
|
+
let acted = 0;
|
|
422
|
+
try {
|
|
423
|
+
const now = this.now();
|
|
424
|
+
for (const entry of [...this.entries.values()]) {
|
|
425
|
+
if (now - entry.updatedAt > C.FORGET_AFTER_MS && entry.cards.length === 0) {
|
|
426
|
+
this.entries.delete(entry.key);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (entry.restartPending) {
|
|
430
|
+
entry.restartPending = false;
|
|
431
|
+
const name = this.nameOf(entry.agent);
|
|
432
|
+
if (await this.emit(entry, 'restarted', `🔄 Crewly restarted while ${name} was on this. Your request is kept; ${name} picks it up when it is back — reply here if you need it sooner.`, false))
|
|
433
|
+
acted++;
|
|
434
|
+
}
|
|
435
|
+
acted += await this.checkDeadline(entry, now);
|
|
436
|
+
acted += await this.checkReask(entry, now);
|
|
437
|
+
}
|
|
438
|
+
this.persist();
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
this.logger.warn('Owner thread sentinel tick failed', { error: err instanceof Error ? err.message : String(err) });
|
|
442
|
+
}
|
|
443
|
+
finally {
|
|
444
|
+
this.ticking = false;
|
|
445
|
+
}
|
|
446
|
+
return acted;
|
|
447
|
+
}
|
|
448
|
+
/** A promise of "~N min" past N×1.5 with no post: say what the agent is doing, nudge it once. */
|
|
449
|
+
async checkDeadline(entry, now) {
|
|
450
|
+
const p = entry.promise;
|
|
451
|
+
if (!p?.deadlineAt || p.firedAt || now < p.deadlineAt)
|
|
452
|
+
return 0;
|
|
453
|
+
if (entry.agentPostAt !== undefined && entry.agentPostAt > p.at)
|
|
454
|
+
return 0;
|
|
455
|
+
// Waiting on the owner (a card is open), or a card just closed / a re-ask
|
|
456
|
+
// is in flight: the owner already has a line about it; the clock waits.
|
|
457
|
+
if (entry.cards.length > 0)
|
|
458
|
+
return 0;
|
|
459
|
+
if (entry.cardClosedAt !== undefined && now - entry.cardClosedAt < C.REPEAT_STATE_MS)
|
|
460
|
+
return 0;
|
|
461
|
+
p.firedAt = now;
|
|
462
|
+
const name = this.nameOf(entry.agent);
|
|
463
|
+
const ago = Math.max(1, Math.round((now - p.at) / 60_000));
|
|
464
|
+
const activity = await this.deps.currentActivity?.(entry.agent).catch(() => null);
|
|
465
|
+
const line = `⏱ ${name} said ~${p.minutes} min, ${ago} min ago, and hasn't posted since. ` +
|
|
466
|
+
`Right now: ${activity ? clip(activity) : `no update from ${name}`}. I've asked ${name} for an update here.`;
|
|
467
|
+
let acted = 0;
|
|
468
|
+
if (await this.emit(entry, `deadline:${p.at}`, line, true))
|
|
469
|
+
acted++;
|
|
470
|
+
const ref = entry.threadTs ? `${entry.slackChannelId}:${entry.threadTs}` : entry.slackChannelId;
|
|
471
|
+
const nudge = `[OWNER THREAD ${ref}] You told the owner "~${p.minutes} min" ${ago} min ago in this Slack thread and have not posted there since. ` +
|
|
472
|
+
`Post a short update there now: reply --thread ${ref} "<what is done, what is left, when>". If you are blocked, say on what.`;
|
|
473
|
+
if (await this.deps.nudgeAgent(entry.agent, nudge).catch(() => false))
|
|
474
|
+
acted++;
|
|
475
|
+
this.logger.info('Owner thread promise overdue — status posted, agent nudged', { key: entry.key, agent: entry.agent, minutes: p.minutes });
|
|
476
|
+
return acted;
|
|
477
|
+
}
|
|
478
|
+
/** A browser approval expired and the agent did not ask again: nudge it once. */
|
|
479
|
+
async checkReask(entry, now) {
|
|
480
|
+
const r = entry.reask;
|
|
481
|
+
if (!r || r.done || now < r.dueAt)
|
|
482
|
+
return 0;
|
|
483
|
+
r.done = true;
|
|
484
|
+
if (!owes(entry, now) && !entry.workOrigin)
|
|
485
|
+
return 0;
|
|
486
|
+
const ref = entry.threadTs ? `${entry.slackChannelId}:${entry.threadTs}` : entry.slackChannelId;
|
|
487
|
+
const text = `[OWNER THREAD ${ref}] Your browser approval card ${r.decisionId} expired before the owner answered, and the owner is still waiting in this thread. ` +
|
|
488
|
+
'Redo the step now (look at the page again, then retry the action) so a new card is posted — or say in the thread why it is no longer needed.';
|
|
489
|
+
const ok = await this.deps.nudgeAgent(entry.agent, text).catch(() => false);
|
|
490
|
+
this.logger.info('Expired browser approval — agent asked to re-ask once', { key: entry.key, agent: entry.agent, decisionId: r.decisionId, delivered: ok });
|
|
491
|
+
return ok ? 1 : 0;
|
|
492
|
+
}
|
|
493
|
+
// -------------------------------------------------------------------------
|
|
494
|
+
// Internals
|
|
495
|
+
// -------------------------------------------------------------------------
|
|
496
|
+
/** Threads that get a line for this event. */
|
|
497
|
+
async targetsFor(agent, event) {
|
|
498
|
+
const now = this.now();
|
|
499
|
+
if (event.kind === 'work_blocked' && event.thread) {
|
|
500
|
+
const e = this.ensure(event.thread, agent);
|
|
501
|
+
e.agent = agent;
|
|
502
|
+
e.workOrigin = true;
|
|
503
|
+
e.updatedAt = now;
|
|
504
|
+
return [e];
|
|
505
|
+
}
|
|
506
|
+
const work = await this.deps.ownerWorkThread?.(agent).catch(() => null);
|
|
507
|
+
if (work?.slackChannelId) {
|
|
508
|
+
const e = this.ensure(work, agent);
|
|
509
|
+
if (e.agent === agent || !owes(e, now)) {
|
|
510
|
+
e.agent = agent;
|
|
511
|
+
e.workOrigin = true;
|
|
512
|
+
e.updatedAt = Math.max(e.updatedAt, now);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return [...this.entries.values()]
|
|
516
|
+
.filter((e) => e.agent === agent && (owes(e, now) || (e.workOrigin && now - e.updatedAt <= C.ACTIVE_WINDOW_MS)))
|
|
517
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
518
|
+
.slice(0, C.MAX_THREADS_PER_EVENT);
|
|
519
|
+
}
|
|
520
|
+
/** Post a line unless dedupe holds it back. */
|
|
521
|
+
async emit(entry, state, text, actionable) {
|
|
522
|
+
const now = this.now();
|
|
523
|
+
if (entry.lastStatus?.state === state)
|
|
524
|
+
return false;
|
|
525
|
+
const last = entry.recentStates[state];
|
|
526
|
+
if (last !== undefined && now - last < C.REPEAT_STATE_MS)
|
|
527
|
+
return false;
|
|
528
|
+
if (!actionable && entry.lastStatus && now - entry.lastStatus.at < C.MIN_INFO_GAP_MS)
|
|
529
|
+
return false;
|
|
530
|
+
let ok = false;
|
|
531
|
+
try {
|
|
532
|
+
ok = await this.deps.postStatus({ slackChannelId: entry.slackChannelId, ...(entry.threadTs ? { threadTs: entry.threadTs } : {}) }, entry.agent, text);
|
|
533
|
+
}
|
|
534
|
+
catch (err) {
|
|
535
|
+
this.logger.warn('Could not post the owner thread status', { key: entry.key, state, error: err instanceof Error ? err.message : String(err) });
|
|
536
|
+
}
|
|
537
|
+
if (!ok)
|
|
538
|
+
return false;
|
|
539
|
+
entry.lastStatus = { state, at: now };
|
|
540
|
+
entry.recentStates[state] = now;
|
|
541
|
+
this.logger.info('Owner thread status posted', { key: entry.key, agent: entry.agent, state });
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
/** The entry for a place, created when missing. */
|
|
545
|
+
ensure(ref, agent) {
|
|
546
|
+
const key = threadKey(ref);
|
|
547
|
+
let entry = this.entries.get(key);
|
|
548
|
+
if (!entry) {
|
|
549
|
+
entry = {
|
|
550
|
+
key,
|
|
551
|
+
slackChannelId: ref.slackChannelId,
|
|
552
|
+
...(ref.threadTs ? { threadTs: ref.threadTs } : {}),
|
|
553
|
+
agent,
|
|
554
|
+
cards: [],
|
|
555
|
+
recentStates: {},
|
|
556
|
+
updatedAt: this.now(),
|
|
557
|
+
};
|
|
558
|
+
this.entries.set(key, entry);
|
|
559
|
+
while (this.entries.size > C.MAX_THREADS) {
|
|
560
|
+
const oldest = this.entries.keys().next().value;
|
|
561
|
+
if (oldest === undefined)
|
|
562
|
+
break;
|
|
563
|
+
this.entries.delete(oldest);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return entry;
|
|
567
|
+
}
|
|
568
|
+
/** The watched entry a post belongs to (a DM's top-level post: that DM's newest entry). */
|
|
569
|
+
find(ref) {
|
|
570
|
+
const exact = this.entries.get(threadKey(ref));
|
|
571
|
+
if (exact)
|
|
572
|
+
return exact;
|
|
573
|
+
if (!ref.threadTs && isDm(ref.slackChannelId)) {
|
|
574
|
+
return [...this.entries.values()]
|
|
575
|
+
.filter((e) => e.slackChannelId === ref.slackChannelId)
|
|
576
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
|
577
|
+
}
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
persist() {
|
|
581
|
+
const file = this.deps.storePath;
|
|
582
|
+
if (!file)
|
|
583
|
+
return;
|
|
584
|
+
try {
|
|
585
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
586
|
+
const tmp = `${file}.tmp`;
|
|
587
|
+
writeFileSync(tmp, JSON.stringify({ entries: [...this.entries.values()] }), { mode: 0o600 });
|
|
588
|
+
renameSync(tmp, file);
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
this.logger.warn('Could not save the owner thread sentinel state', { error: err instanceof Error ? err.message : String(err) });
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
load() {
|
|
595
|
+
const now = this.now();
|
|
596
|
+
for (const e of readEntries(this.deps.storePath)) {
|
|
597
|
+
// Anything active when Crewly went down gets one restart line.
|
|
598
|
+
e.restartPending = owes(e, now) && (!!e.promise || (!!e.ownerAt && (!e.agentPostAt || e.ownerAt > e.agentPostAt)));
|
|
599
|
+
this.entries.set(e.key, e);
|
|
600
|
+
}
|
|
601
|
+
if (this.entries.size > 0)
|
|
602
|
+
this.logger.info('Restored watched owner threads from before the restart', { count: this.entries.size });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Whether a thread waits on its agent: an open card, an unanswered owner
|
|
607
|
+
* message, or a promise — within ACTIVE_WINDOW_MS.
|
|
608
|
+
*
|
|
609
|
+
* @param e - The entry
|
|
610
|
+
* @param now - Clock
|
|
611
|
+
* @returns True while it waits
|
|
612
|
+
*/
|
|
613
|
+
function owes(e, now) {
|
|
614
|
+
if (e.cards.length > 0)
|
|
615
|
+
return true;
|
|
616
|
+
if (e.ownerAt && (!e.agentPostAt || e.ownerAt > e.agentPostAt) && now - e.ownerAt <= C.ACTIVE_WINDOW_MS)
|
|
617
|
+
return true;
|
|
618
|
+
if (e.promise && now - e.promise.at <= C.ACTIVE_WINDOW_MS)
|
|
619
|
+
return true;
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Read persisted entries (tolerates a missing or corrupt file).
|
|
624
|
+
*
|
|
625
|
+
* @param file - Store path
|
|
626
|
+
* @returns Entries
|
|
627
|
+
*/
|
|
628
|
+
function readEntries(file) {
|
|
629
|
+
if (!file || !existsSync(file))
|
|
630
|
+
return [];
|
|
631
|
+
try {
|
|
632
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
633
|
+
return (raw.entries ?? []).filter((e) => e && typeof e.key === 'string' && typeof e.slackChannelId === 'string' && typeof e.agent === 'string').map((e) => ({
|
|
634
|
+
...e,
|
|
635
|
+
cards: Array.isArray(e.cards) ? e.cards : [],
|
|
636
|
+
recentStates: e.recentStates && typeof e.recentStates === 'object' ? e.recentStates : {},
|
|
637
|
+
updatedAt: typeof e.updatedAt === 'number' ? e.updatedAt : 0,
|
|
638
|
+
}));
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return [];
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Agents an owner thread waits on, read straight from the persisted state —
|
|
646
|
+
* for the boot restore, which can run before the sentinel is started.
|
|
647
|
+
*
|
|
648
|
+
* @param file - Store path
|
|
649
|
+
* @param now - Clock
|
|
650
|
+
* @returns Session names
|
|
651
|
+
*/
|
|
652
|
+
export function owingAgentsOnDisk(file, now = Date.now()) {
|
|
653
|
+
return [...new Set(readEntries(file).filter((e) => owes(e, now)).map((e) => e.agent))];
|
|
654
|
+
}
|
|
655
|
+
// ---------------------------------------------------------------------------
|
|
656
|
+
// Singleton
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
let instance = null;
|
|
659
|
+
/** @returns The wired sentinel, or null before it started (and in tests). */
|
|
660
|
+
export function getOwnerThreadSentinel() {
|
|
661
|
+
return instance;
|
|
662
|
+
}
|
|
663
|
+
/** @param service - The sentinel to expose (null to clear) */
|
|
664
|
+
export function setOwnerThreadSentinel(service) {
|
|
665
|
+
instance = service;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Report a blocking event for an agent, if the sentinel runs. Never throws.
|
|
669
|
+
*
|
|
670
|
+
* @param agent - The agent
|
|
671
|
+
* @param event - What happened
|
|
672
|
+
*/
|
|
673
|
+
export function reportOwnerThreadBlocking(agent, event) {
|
|
674
|
+
const s = instance;
|
|
675
|
+
if (!s || !agent)
|
|
676
|
+
return;
|
|
677
|
+
void s.noteBlocking(agent, event).catch(() => undefined);
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Report an agent's Slack post, if the sentinel runs. Never throws.
|
|
681
|
+
*
|
|
682
|
+
* @param input - Who, where, what
|
|
683
|
+
*/
|
|
684
|
+
export function reportOwnerThreadAgentPost(input) {
|
|
685
|
+
try {
|
|
686
|
+
instance?.noteAgentPost(input);
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
/* best-effort */
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
//# sourceMappingURL=owner-thread-sentinel.service.js.map
|