crewly 1.20.227 → 1.20.229
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/skills/agent/core/app-comments/SKILL.md +39 -5
- package/config/skills/agent/core/app-comments/execute.sh +9 -4
- package/config/skills/agent/core/app-comments/execute.test.sh +9 -0
- package/dist/backend/backend/src/constants.d.ts +58 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +58 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/services/apps/app-roster.service.d.ts +86 -0
- package/dist/backend/backend/src/services/apps/app-roster.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/apps/app-roster.service.js +119 -0
- package/dist/backend/backend/src/services/apps/app-roster.service.js.map +1 -0
- package/dist/backend/backend/src/services/apps/app-wake-message.d.ts +24 -1
- package/dist/backend/backend/src/services/apps/app-wake-message.d.ts.map +1 -1
- package/dist/backend/backend/src/services/apps/app-wake-message.js +62 -5
- package/dist/backend/backend/src/services/apps/app-wake-message.js.map +1 -1
- package/dist/backend/backend/src/services/apps/app-wake.service.d.ts +118 -2
- package/dist/backend/backend/src/services/apps/app-wake.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/apps/app-wake.service.js +514 -50
- package/dist/backend/backend/src/services/apps/app-wake.service.js.map +1 -1
- package/dist/backend/backend/src/services/apps/apps-registry.service.d.ts +20 -0
- package/dist/backend/backend/src/services/apps/apps-registry.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/apps/apps-registry.service.js +24 -0
- package/dist/backend/backend/src/services/apps/apps-registry.service.js.map +1 -1
- package/dist/backend/backend/src/services/apps/apps.service.d.ts +42 -0
- package/dist/backend/backend/src/services/apps/apps.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/apps/apps.service.js +52 -5
- package/dist/backend/backend/src/services/apps/apps.service.js.map +1 -1
- package/dist/backend/backend/src/services/apps/apps.wiring.d.ts +7 -0
- package/dist/backend/backend/src/services/apps/apps.wiring.d.ts.map +1 -1
- package/dist/backend/backend/src/services/apps/apps.wiring.js +19 -2
- package/dist/backend/backend/src/services/apps/apps.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/session/input-guard-known-screens.d.ts +8 -0
- package/dist/backend/backend/src/services/session/input-guard-known-screens.d.ts.map +1 -1
- package/dist/backend/backend/src/services/session/input-guard-known-screens.js +100 -0
- package/dist/backend/backend/src/services/session/input-guard-known-screens.js.map +1 -1
- package/dist/backend/backend/src/services/session/tui-input-guard.d.ts +6 -0
- package/dist/backend/backend/src/services/session/tui-input-guard.d.ts.map +1 -1
- package/dist/backend/backend/src/services/session/tui-input-guard.js +24 -5
- package/dist/backend/backend/src/services/session/tui-input-guard.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +5 -3
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +48 -22
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-thread-context.service.d.ts +1 -1
- package/dist/backend/backend/src/services/slack/slack-thread-context.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-thread-context.service.js +9 -4
- package/dist/backend/backend/src/services/slack/slack-thread-context.service.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +58 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +58 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/session/input-guard-known-screens.d.ts +8 -0
- package/dist/cli/backend/src/services/session/input-guard-known-screens.d.ts.map +1 -1
- package/dist/cli/backend/src/services/session/input-guard-known-screens.js +100 -0
- package/dist/cli/backend/src/services/session/input-guard-known-screens.js.map +1 -1
- package/dist/cli/backend/src/services/session/tui-input-guard.d.ts +6 -0
- package/dist/cli/backend/src/services/session/tui-input-guard.d.ts.map +1 -1
- package/dist/cli/backend/src/services/session/tui-input-guard.js +24 -5
- package/dist/cli/backend/src/services/session/tui-input-guard.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +5 -3
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +48 -22
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-thread-context.service.d.ts +1 -1
- package/dist/cli/backend/src/services/slack/slack-thread-context.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-thread-context.service.js +9 -4
- package/dist/cli/backend/src/services/slack/slack-thread-context.service.js.map +1 -1
- package/package.json +1 -1
|
@@ -27,16 +27,74 @@
|
|
|
27
27
|
* - A failed delivery keeps the batch (and the cursor before it) and is
|
|
28
28
|
* retried with backoff; after WAKE_FAILS_BEFORE_ORC_NOTICE failures the
|
|
29
29
|
* orchestrator is told once.
|
|
30
|
+
* - Nothing may stall the loop (2026-10-05, 科技晨报: one poll pass never
|
|
31
|
+
* finished, the self-rescheduling loop stopped for good, and five owner
|
|
32
|
+
* comments sat in Cloud for hours with nothing logged). Each app's poll
|
|
33
|
+
* has a deadline (POLL_APP_DEADLINE_MS; a late result is discarded), a
|
|
34
|
+
* pass that still has not finished after POLL_PASS_STALL_MS is abandoned
|
|
35
|
+
* and a fresh one starts, and a delivery that has not settled after
|
|
36
|
+
* WAKE_DELIVER_TIMEOUT_MS counts as failed and is retried (a late success
|
|
37
|
+
* still settles the batch).
|
|
38
|
+
* - A batch being delivered is detached: changes arriving meanwhile open the
|
|
39
|
+
* next batch instead of being marked delivered without being in the
|
|
40
|
+
* message.
|
|
41
|
+
* - A watchdog on every scheduled pass re-arms a batch whose timer is gone
|
|
42
|
+
* and, once per batch, logs and tells the orchestrator about owner changes
|
|
43
|
+
* that have not reached their agent after WAKE_STUCK_NOTICE_MS.
|
|
44
|
+
* - Owner changes are delivered as owner-authored (`owner: true`, crewly#1105
|
|
45
|
+
* queue priority): a busy agent gets them queued ahead of system traffic,
|
|
46
|
+
* not after it goes idle.
|
|
47
|
+
* - Batches live in memory; the persisted cursor stays before the first
|
|
48
|
+
* change of every batch not yet delivered, so after a restart the same
|
|
49
|
+
* changes are read again and batched again.
|
|
30
50
|
* - An `ask` reaches a named agent only when it is in the publisher's team
|
|
31
51
|
* and already running; it never starts a stopped agent.
|
|
52
|
+
* - @mentions (crewly-services apps/SPEC.md §12.1): every pass also reads
|
|
53
|
+
* this instance's mention inbox (`GET /mentions`, any app of the account,
|
|
54
|
+
* wherever it was published; bounded by POLL_APP_DEADLINE_MS like an app
|
|
55
|
+
* poll) and wakes each mentioned agent that runs here (starting it like
|
|
56
|
+
* the publisher; the orchestrator for "@Orc"), with the same untrusted
|
|
57
|
+
* marking and anchor, through the same batches, delivery timeout, retries,
|
|
58
|
+
* watchdog and owner-priority tag. The publisher still gets the comment
|
|
59
|
+
* from the app feed, noting who was mentioned, unless it was mentioned
|
|
60
|
+
* itself (then the mention is the one message). A mention of an agent that
|
|
61
|
+
* is gone from this machine goes to the orchestrator. The roster Cloud
|
|
62
|
+
* offers the owner is pushed first (`AppRosterService`, only when changed).
|
|
32
63
|
*
|
|
33
64
|
* @module services/apps/app-wake.service
|
|
34
65
|
*/
|
|
35
66
|
import { CREWLY_APPS_CONSTANTS } from '../../constants.js';
|
|
36
67
|
import { LoggerService } from '../core/logger.service.js';
|
|
37
68
|
import { AppsCloudError } from './apps-cloud.client.js';
|
|
38
|
-
import { buildAppWakeMessage, safeAppName } from './app-wake-message.js';
|
|
69
|
+
import { buildAppWakeMessage, mentionsOf, safeAppName } from './app-wake-message.js';
|
|
70
|
+
import { ORCHESTRATOR_SESSION_NAME } from '../../constants.js';
|
|
39
71
|
const C = CREWLY_APPS_CONSTANTS;
|
|
72
|
+
/** Settles like `promise`, or like `onTimeout()` (its value, or what it throws) after `ms` — whichever comes first. */
|
|
73
|
+
function withDeadline(promise, ms, onTimeout) {
|
|
74
|
+
let timer = null;
|
|
75
|
+
const deadline = new Promise((resolve, reject) => {
|
|
76
|
+
timer = setTimeout(() => {
|
|
77
|
+
try {
|
|
78
|
+
resolve(onTimeout());
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
reject(err);
|
|
82
|
+
}
|
|
83
|
+
}, ms);
|
|
84
|
+
timer.unref?.();
|
|
85
|
+
});
|
|
86
|
+
return Promise.race([promise, deadline]).finally(() => {
|
|
87
|
+
if (timer)
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** Thrown when one app's poll misses its deadline. */
|
|
92
|
+
class PollDeadlineError extends Error {
|
|
93
|
+
constructor(ms) {
|
|
94
|
+
super(`poll did not finish within ${Math.round(ms / 1000)} s`);
|
|
95
|
+
this.name = 'PollDeadlineError';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
40
98
|
/** Recipient key for the orchestrator (never a valid session name). */
|
|
41
99
|
export const ORC_RECIPIENT = '\u0000orchestrator';
|
|
42
100
|
/**
|
|
@@ -49,7 +107,16 @@ export class AppWakeService {
|
|
|
49
107
|
timer = null;
|
|
50
108
|
running = false;
|
|
51
109
|
ticking = null;
|
|
110
|
+
/** When the running pass started, and its generation (an abandoned pass stops at its next step) */
|
|
111
|
+
tickStartedAt = 0;
|
|
112
|
+
generation = 0;
|
|
113
|
+
passStallNotified = false;
|
|
114
|
+
/** Current poll of each app; a poll whose token was replaced or dropped discards its result */
|
|
115
|
+
pollTokens = new Map();
|
|
116
|
+
/** Batches collecting changes, by `appId\0recipient` */
|
|
52
117
|
batches = new Map();
|
|
118
|
+
/** Batches handed to `deliver` and not yet confirmed (in flight, or failed and waiting to retry) */
|
|
119
|
+
outgoing = new Map();
|
|
53
120
|
lastWakeAt = new Map();
|
|
54
121
|
/** Highest seq read per app (the registry holds what is safe to persist) */
|
|
55
122
|
fetched = new Map();
|
|
@@ -59,6 +126,13 @@ export class AppWakeService {
|
|
|
59
126
|
/** Visitor-wake cap state per app (the registry holds the persisted copy) */
|
|
60
127
|
visitorWakes = new Map();
|
|
61
128
|
visitorDirty = new Set();
|
|
129
|
+
/** Highest mention-inbox seq read, and seqs above the safe cursor already delivered */
|
|
130
|
+
mentionFetched = null;
|
|
131
|
+
mentionDelivered = null;
|
|
132
|
+
mentionBackoff = null;
|
|
133
|
+
myInstance = undefined;
|
|
134
|
+
/** Current mention-inbox read; one that missed its deadline discards its result */
|
|
135
|
+
mentionToken = null;
|
|
62
136
|
constructor(deps) {
|
|
63
137
|
this.deps = deps;
|
|
64
138
|
this.now = deps.now ?? (() => Date.now());
|
|
@@ -77,10 +151,13 @@ export class AppWakeService {
|
|
|
77
151
|
if (this.timer)
|
|
78
152
|
clearTimeout(this.timer);
|
|
79
153
|
this.timer = null;
|
|
80
|
-
|
|
154
|
+
this.generation++;
|
|
155
|
+
for (const b of [...this.batches.values(), ...this.outgoing.values()])
|
|
81
156
|
if (b.timer)
|
|
82
157
|
clearTimeout(b.timer);
|
|
83
158
|
this.batches.clear();
|
|
159
|
+
this.outgoing.clear();
|
|
160
|
+
this.pollTokens.clear();
|
|
84
161
|
}
|
|
85
162
|
/**
|
|
86
163
|
* Per-app backoff state (tests).
|
|
@@ -97,38 +174,74 @@ export class AppWakeService {
|
|
|
97
174
|
return;
|
|
98
175
|
this.timer = setTimeout(() => {
|
|
99
176
|
this.timer = null;
|
|
100
|
-
|
|
177
|
+
this.watchdog();
|
|
178
|
+
// The loop never waits on a pass for longer than the stall limit: the
|
|
179
|
+
// next scheduled pass then abandons it and starts afresh.
|
|
180
|
+
void withDeadline(this.tick(), C.POLL_PASS_STALL_MS, () => undefined)
|
|
181
|
+
.catch((err) => this.logger.warn('App change poll pass failed', { error: err instanceof Error ? err.message : String(err) }))
|
|
182
|
+
.finally(() => this.schedule());
|
|
101
183
|
}, C.POLL_INTERVAL_MS);
|
|
102
184
|
this.timer.unref?.();
|
|
103
185
|
}
|
|
104
186
|
/**
|
|
105
187
|
* One pass over the apps that are due. Exposed for tests and for an immediate poll.
|
|
188
|
+
* A pass still running after POLL_PASS_STALL_MS is abandoned and a fresh one starts.
|
|
106
189
|
*/
|
|
107
190
|
async tick() {
|
|
108
|
-
if (this.ticking)
|
|
109
|
-
|
|
110
|
-
|
|
191
|
+
if (this.ticking) {
|
|
192
|
+
const runningFor = this.now() - this.tickStartedAt;
|
|
193
|
+
if (runningFor < C.POLL_PASS_STALL_MS)
|
|
194
|
+
return this.ticking;
|
|
195
|
+
this.logger.warn('App change poll pass did not finish; abandoning it and starting a fresh one', { runningForMs: runningFor });
|
|
196
|
+
if (!this.passStallNotified) {
|
|
197
|
+
this.passStallNotified = true;
|
|
198
|
+
this.notifyOrc(`[APP CHANGES] Crewly's poll of app changes stalled for ${Math.round(runningFor / 60_000)} min and was restarted. ` +
|
|
199
|
+
'Owner changes made in that time are being read again now. If this repeats, the backend may need a restart.');
|
|
200
|
+
}
|
|
201
|
+
this.generation++;
|
|
111
202
|
this.ticking = null;
|
|
203
|
+
}
|
|
204
|
+
const gen = this.generation;
|
|
205
|
+
this.tickStartedAt = this.now();
|
|
206
|
+
const pass = this.runTick(gen).finally(() => {
|
|
207
|
+
if (this.ticking === pass)
|
|
208
|
+
this.ticking = null;
|
|
112
209
|
});
|
|
113
|
-
|
|
210
|
+
this.ticking = pass;
|
|
211
|
+
await pass;
|
|
212
|
+
if (gen === this.generation)
|
|
213
|
+
this.passStallNotified = false;
|
|
114
214
|
}
|
|
115
|
-
async runTick() {
|
|
215
|
+
async runTick(gen) {
|
|
116
216
|
if (!this.deps.client.isAvailable())
|
|
117
217
|
return;
|
|
218
|
+
if (this.deps.roster)
|
|
219
|
+
await withDeadline(this.deps.roster.pushIfChanged().catch(() => false), C.POLL_APP_DEADLINE_MS, () => false);
|
|
118
220
|
const now = this.now();
|
|
119
|
-
const
|
|
221
|
+
const all = await withDeadline(this.deps.registry.list(), C.POLL_APP_DEADLINE_MS, () => null);
|
|
222
|
+
if (!all) {
|
|
223
|
+
this.logger.warn('Reading the apps registry did not finish; skipping this poll pass');
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const due = all.filter((e) => !e.deleted && (this.backoff.get(e.appId)?.nextAt ?? 0) <= now);
|
|
120
227
|
let next = 0;
|
|
121
228
|
const worker = async () => {
|
|
122
|
-
while (next < due.length) {
|
|
229
|
+
while (gen === this.generation && next < due.length) {
|
|
123
230
|
const app = due[next++];
|
|
124
231
|
await this.pollOne(app);
|
|
125
232
|
}
|
|
126
233
|
};
|
|
127
234
|
await Promise.all(Array.from({ length: Math.min(C.POLL_CONCURRENCY, due.length) }, worker));
|
|
235
|
+
if (gen === this.generation)
|
|
236
|
+
await this.pollMentionsSafely(gen);
|
|
128
237
|
}
|
|
129
238
|
async pollOne(app) {
|
|
239
|
+
const token = Symbol(app.appId);
|
|
240
|
+
this.pollTokens.set(app.appId, token);
|
|
130
241
|
try {
|
|
131
|
-
await this.pollApp(app)
|
|
242
|
+
await withDeadline(this.pollApp(app, token), C.POLL_APP_DEADLINE_MS, () => {
|
|
243
|
+
throw new PollDeadlineError(C.POLL_APP_DEADLINE_MS);
|
|
244
|
+
});
|
|
132
245
|
this.backoff.delete(app.appId);
|
|
133
246
|
}
|
|
134
247
|
catch (err) {
|
|
@@ -143,6 +256,14 @@ export class AppWakeService {
|
|
|
143
256
|
this.backoff.set(app.appId, { failures, nextAt: this.now() + wait });
|
|
144
257
|
this.logger.warn('Polling app changes failed', { appId: app.appId, failures, retryInMs: wait, error: err instanceof Error ? err.message : String(err) });
|
|
145
258
|
}
|
|
259
|
+
finally {
|
|
260
|
+
// A poll that missed its deadline may still finish later: its token is gone, so it changes nothing.
|
|
261
|
+
if (this.pollTokens.get(app.appId) === token)
|
|
262
|
+
this.pollTokens.delete(app.appId);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
isCurrentPoll(appId, token) {
|
|
266
|
+
return this.pollTokens.get(appId) === token;
|
|
146
267
|
}
|
|
147
268
|
deliveredSet(app) {
|
|
148
269
|
let set = this.delivered.get(app.appId);
|
|
@@ -152,11 +273,13 @@ export class AppWakeService {
|
|
|
152
273
|
}
|
|
153
274
|
return set;
|
|
154
275
|
}
|
|
155
|
-
async pollApp(app) {
|
|
276
|
+
async pollApp(app, token) {
|
|
156
277
|
const opts = { timeoutMs: C.POLL_REQUEST_TIMEOUT_MS };
|
|
157
278
|
if (app.cursor === null || app.cursor === undefined) {
|
|
158
279
|
// Never polled: start from now, never replay history.
|
|
159
280
|
const head = await this.deps.client.request('GET', `/apps/${app.appId}/changes`, opts);
|
|
281
|
+
if (!this.isCurrentPoll(app.appId, token))
|
|
282
|
+
return;
|
|
160
283
|
this.fetched.set(app.appId, head.seq);
|
|
161
284
|
await this.deps.registry.setProgress(app.appId, head.seq, []);
|
|
162
285
|
return;
|
|
@@ -166,6 +289,9 @@ export class AppWakeService {
|
|
|
166
289
|
let since = Math.max(app.cursor, this.fetched.get(app.appId) ?? app.cursor);
|
|
167
290
|
for (let page = 0; page < C.POLL_MAX_PAGES; page++) {
|
|
168
291
|
const res = await this.deps.client.request('GET', `/apps/${app.appId}/changes`, { ...opts, query: { since, wait: 0 } });
|
|
292
|
+
// Missed its deadline (or a newer poll of this app started): discard.
|
|
293
|
+
if (!this.isCurrentPoll(app.appId, token))
|
|
294
|
+
return;
|
|
169
295
|
const changes = Array.isArray(res.changes) ? res.changes : [];
|
|
170
296
|
for (const change of changes) {
|
|
171
297
|
if (typeof change.seq === 'number' && change.seq > since && !done.has(change.seq))
|
|
@@ -188,7 +314,7 @@ export class AppWakeService {
|
|
|
188
314
|
if (fetched === undefined)
|
|
189
315
|
return;
|
|
190
316
|
let safe = fetched;
|
|
191
|
-
for (const b of this.batches.values()) {
|
|
317
|
+
for (const b of [...this.batches.values(), ...this.outgoing.values()]) {
|
|
192
318
|
if (b.appId === appId)
|
|
193
319
|
safe = Math.min(safe, b.firstSeq - 1);
|
|
194
320
|
}
|
|
@@ -243,7 +369,7 @@ export class AppWakeService {
|
|
|
243
369
|
return;
|
|
244
370
|
const session = app.agentSession ?? null;
|
|
245
371
|
const key = `${app.appId}\u0000${session ?? ORC_RECIPIENT}`;
|
|
246
|
-
if (this.batches.has(key))
|
|
372
|
+
if (this.batches.has(key) || this.outgoing.has(key))
|
|
247
373
|
return;
|
|
248
374
|
const batch = this.newBatch(app.appId, session, false, Number.POSITIVE_INFINITY);
|
|
249
375
|
batch.skippedNotice = true;
|
|
@@ -270,8 +396,167 @@ export class AppWakeService {
|
|
|
270
396
|
failures: 0,
|
|
271
397
|
orcNotified: false,
|
|
272
398
|
skippedNotice: false,
|
|
399
|
+
openedAt: this.now(),
|
|
400
|
+
inFlight: false,
|
|
401
|
+
reportSkipped: null,
|
|
402
|
+
stuckNotified: false,
|
|
403
|
+
mentioned: false,
|
|
404
|
+
mentionSeqs: [],
|
|
405
|
+
mentionFirstSeq: Number.POSITIVE_INFINITY,
|
|
406
|
+
goneMentions: [],
|
|
273
407
|
};
|
|
274
408
|
}
|
|
409
|
+
// -------------------------------------------------------------------------
|
|
410
|
+
// @mention inbox
|
|
411
|
+
// -------------------------------------------------------------------------
|
|
412
|
+
async instance() {
|
|
413
|
+
if (this.myInstance === undefined || this.myInstance === null) {
|
|
414
|
+
this.myInstance = this.deps.instanceId ? await this.deps.instanceId().catch(() => null) : null;
|
|
415
|
+
}
|
|
416
|
+
return this.myInstance;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Whether a comment change @mentions `session` on this instance (an agent
|
|
420
|
+
* with the same session on another machine is someone else).
|
|
421
|
+
*/
|
|
422
|
+
async mentionsHere(change, session) {
|
|
423
|
+
const list = mentionsOf(change);
|
|
424
|
+
if (list.length === 0)
|
|
425
|
+
return false;
|
|
426
|
+
const mine = await this.instance();
|
|
427
|
+
return list.some((m) => m.session === session && (!m.instanceId || !mine || m.instanceId === mine));
|
|
428
|
+
}
|
|
429
|
+
async pollMentionsSafely(gen) {
|
|
430
|
+
if (this.mentionBackoff && this.mentionBackoff.nextAt > this.now())
|
|
431
|
+
return;
|
|
432
|
+
try {
|
|
433
|
+
// Bounded like an app poll: a hung inbox read never holds the pass.
|
|
434
|
+
const token = Symbol('mentions');
|
|
435
|
+
this.mentionToken = token;
|
|
436
|
+
await withDeadline(this.pollMentions(gen, token), C.POLL_APP_DEADLINE_MS, () => {
|
|
437
|
+
if (this.mentionToken === token)
|
|
438
|
+
this.mentionToken = null;
|
|
439
|
+
throw new PollDeadlineError(C.POLL_APP_DEADLINE_MS);
|
|
440
|
+
});
|
|
441
|
+
this.mentionBackoff = null;
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
const failures = (this.mentionBackoff?.failures ?? 0) + 1;
|
|
445
|
+
// An older Cloud without the inbox answers 404: check back rarely.
|
|
446
|
+
const wait = err instanceof AppsCloudError && err.status === 404 ? C.POLL_MAX_BACKOFF_MS : Math.min(C.POLL_INTERVAL_MS * 2 ** failures, C.POLL_MAX_BACKOFF_MS);
|
|
447
|
+
this.mentionBackoff = { failures, nextAt: this.now() + wait };
|
|
448
|
+
if (!(err instanceof AppsCloudError && err.status === 404)) {
|
|
449
|
+
this.logger.warn('Reading the app mention inbox failed', { failures, retryInMs: wait, error: err instanceof Error ? err.message : String(err) });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Mention-inbox backoff state (tests).
|
|
455
|
+
*
|
|
456
|
+
* @returns Failures in a row and when it is next due, or null when healthy
|
|
457
|
+
*/
|
|
458
|
+
mentionsBackoff() {
|
|
459
|
+
return this.mentionBackoff ? { ...this.mentionBackoff } : null;
|
|
460
|
+
}
|
|
461
|
+
async pollMentions(gen, token) {
|
|
462
|
+
const stale = () => gen !== this.generation || this.mentionToken !== token;
|
|
463
|
+
const opts = { timeoutMs: C.POLL_REQUEST_TIMEOUT_MS };
|
|
464
|
+
const progress = await this.deps.registry.getMentionProgress();
|
|
465
|
+
if (!this.mentionDelivered)
|
|
466
|
+
this.mentionDelivered = new Set(progress.delivered);
|
|
467
|
+
if (progress.cursor === null && this.mentionFetched === null) {
|
|
468
|
+
// Never read: start from now, never replay history.
|
|
469
|
+
const head = await this.deps.client.request('GET', '/mentions', opts);
|
|
470
|
+
if (stale())
|
|
471
|
+
return;
|
|
472
|
+
const seq = typeof head.seq === 'number' ? head.seq : 0;
|
|
473
|
+
this.mentionFetched = seq;
|
|
474
|
+
await this.deps.registry.setMentionProgress(seq, []);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const done = this.mentionDelivered;
|
|
478
|
+
let since = Math.max(progress.cursor ?? 0, this.mentionFetched ?? 0);
|
|
479
|
+
for (let page = 0; page < C.MENTIONS.MAX_PAGES; page++) {
|
|
480
|
+
const res = await this.deps.client.request('GET', '/mentions', { ...opts, query: { since } });
|
|
481
|
+
// Missed its deadline, or the pass was abandoned: discard.
|
|
482
|
+
if (stale())
|
|
483
|
+
return;
|
|
484
|
+
const items = Array.isArray(res.mentions) ? res.mentions : [];
|
|
485
|
+
for (const item of items) {
|
|
486
|
+
if (typeof item?.seq === 'number' && item.seq > since && !done.has(item.seq))
|
|
487
|
+
await this.handleMention(item);
|
|
488
|
+
}
|
|
489
|
+
since = Math.max(since, typeof res.seq === 'number' ? res.seq : since);
|
|
490
|
+
this.mentionFetched = since;
|
|
491
|
+
if (items.length < C.MENTIONS.PAGE)
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
await this.persistMentionProgress();
|
|
495
|
+
}
|
|
496
|
+
async persistMentionProgress() {
|
|
497
|
+
if (this.mentionFetched === null)
|
|
498
|
+
return;
|
|
499
|
+
let safe = this.mentionFetched;
|
|
500
|
+
for (const b of [...this.batches.values(), ...this.outgoing.values()])
|
|
501
|
+
safe = Math.min(safe, b.mentionFirstSeq - 1);
|
|
502
|
+
const set = this.mentionDelivered ?? new Set();
|
|
503
|
+
for (const seq of set)
|
|
504
|
+
if (seq <= safe)
|
|
505
|
+
set.delete(seq);
|
|
506
|
+
while (set.size > C.MAX_DELIVERED_SEQS)
|
|
507
|
+
set.delete(Math.min(...set));
|
|
508
|
+
this.mentionDelivered = set;
|
|
509
|
+
await this.deps.registry.setMentionProgress(safe, [...set]);
|
|
510
|
+
}
|
|
511
|
+
async handleMention(item) {
|
|
512
|
+
const done = this.mentionDelivered ?? new Set();
|
|
513
|
+
this.mentionDelivered = done;
|
|
514
|
+
// The comment or the app is gone: nothing to say.
|
|
515
|
+
if (!item.thread || typeof item.appId !== 'string' || typeof item.commentId !== 'string') {
|
|
516
|
+
done.add(item.seq);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const isOrc = item.session === ORCHESTRATOR_SESSION_NAME;
|
|
520
|
+
const here = isOrc || (this.deps.isLocalAgent ? await this.deps.isLocalAgent(item.session).catch(() => false) : true);
|
|
521
|
+
const session = isOrc || !here ? null : item.session;
|
|
522
|
+
const recipient = session ?? ORC_RECIPIENT;
|
|
523
|
+
const key = `${item.appId}\u0000${recipient}`;
|
|
524
|
+
const change = {
|
|
525
|
+
seq: item.seq,
|
|
526
|
+
kind: 'comment',
|
|
527
|
+
comment: { id: item.commentId, op: item.op, ...(item.replyId ? { replyId: item.replyId } : {}), thread: item.thread },
|
|
528
|
+
actor: { kind: 'owner' },
|
|
529
|
+
...(item.at ? { at: item.at } : {}),
|
|
530
|
+
};
|
|
531
|
+
let batch = this.batches.get(key);
|
|
532
|
+
if (!batch) {
|
|
533
|
+
const app = await this.deps.registry.get(item.appId);
|
|
534
|
+
const last = this.lastWakeAt.get(key) ?? app?.wakes?.[recipient] ?? -Infinity;
|
|
535
|
+
const delay = Math.max(C.BATCH_WINDOW_MS, last + C.COOLDOWN_MS - this.now());
|
|
536
|
+
batch = this.newBatch(item.appId, session, session !== null, Number.POSITIVE_INFINITY);
|
|
537
|
+
this.batches.set(key, batch);
|
|
538
|
+
this.arm(key, batch, delay);
|
|
539
|
+
}
|
|
540
|
+
else if (session !== null) {
|
|
541
|
+
batch.activate = true;
|
|
542
|
+
}
|
|
543
|
+
batch.mentioned = true;
|
|
544
|
+
if (typeof item.appName === 'string' && item.appName)
|
|
545
|
+
batch.appName = item.appName;
|
|
546
|
+
if (!here && !isOrc) {
|
|
547
|
+
const name = typeof item.name === 'string' && item.name ? item.name : item.session;
|
|
548
|
+
if (!batch.goneMentions.includes(name))
|
|
549
|
+
batch.goneMentions.push(name);
|
|
550
|
+
}
|
|
551
|
+
batch.mentionSeqs.push(item.seq);
|
|
552
|
+
if (batch.mentionSeqs.length > C.MAX_DELIVERED_SEQS)
|
|
553
|
+
batch.mentionSeqs.shift();
|
|
554
|
+
batch.mentionFirstSeq = Math.min(batch.mentionFirstSeq, item.seq);
|
|
555
|
+
batch.commentsTotal++;
|
|
556
|
+
batch.comments.push(change);
|
|
557
|
+
if (batch.comments.length > C.COMMENTS.MAX_PER_WAKE)
|
|
558
|
+
batch.comments.shift();
|
|
559
|
+
}
|
|
275
560
|
async recipientFor(app, change) {
|
|
276
561
|
const publisher = app.agentSession ?? null;
|
|
277
562
|
const named = change.kind === 'event' && change.event?.type === 'ask' ? change.event.agent : undefined;
|
|
@@ -318,6 +603,9 @@ export class AppWakeService {
|
|
|
318
603
|
}
|
|
319
604
|
const recipientInfo = await this.recipientFor(app, change);
|
|
320
605
|
const session = recipientInfo.session;
|
|
606
|
+
// The recipient was @mentioned in this comment: the mention inbox delivers it (one message, not two).
|
|
607
|
+
if (change.kind === 'comment' && (await this.mentionsHere(change, session ?? ORCHESTRATOR_SESSION_NAME)))
|
|
608
|
+
return;
|
|
321
609
|
// A visitor never starts a stopped agent; only the owner's changes may.
|
|
322
610
|
const activate = visitor ? false : recipientInfo.activate;
|
|
323
611
|
const recipient = session ?? ORC_RECIPIENT;
|
|
@@ -332,8 +620,8 @@ export class AppWakeService {
|
|
|
332
620
|
}
|
|
333
621
|
else {
|
|
334
622
|
batch.activate = batch.activate || activate;
|
|
335
|
-
|
|
336
|
-
|
|
623
|
+
// A batch opened by a skipped-visitor notice or a mention has no app seq yet.
|
|
624
|
+
batch.firstSeq = Math.min(batch.firstSeq, change.seq);
|
|
337
625
|
}
|
|
338
626
|
batch.seqs.push(change.seq);
|
|
339
627
|
if (batch.seqs.length > C.MAX_DELIVERED_SEQS)
|
|
@@ -366,12 +654,44 @@ export class AppWakeService {
|
|
|
366
654
|
arm(key, batch, delay) {
|
|
367
655
|
if (batch.timer)
|
|
368
656
|
clearTimeout(batch.timer);
|
|
369
|
-
batch.timer = setTimeout(() =>
|
|
657
|
+
batch.timer = setTimeout(() => {
|
|
658
|
+
batch.timer = null;
|
|
659
|
+
if (this.outgoing.get(key) === batch)
|
|
660
|
+
void this.retry(key);
|
|
661
|
+
else
|
|
662
|
+
void this.flush(key);
|
|
663
|
+
}, Math.max(0, delay));
|
|
370
664
|
batch.timer.unref?.();
|
|
371
665
|
}
|
|
666
|
+
/** Whether a batch holds only visitor submissions (or only a skipped-submissions notice). */
|
|
667
|
+
static visitorOnly(batch) {
|
|
668
|
+
return batch.dataTotal === 0 && batch.eventsTotal === 0 && batch.commentsTotal === 0;
|
|
669
|
+
}
|
|
670
|
+
/** Fold `from` (newer) into `into` (older): one message covers both. */
|
|
671
|
+
static merge(into, from) {
|
|
672
|
+
const keep = (list, max) => (list.length > max ? list.slice(list.length - max) : list);
|
|
673
|
+
into.activate = into.activate || from.activate;
|
|
674
|
+
into.dataChanges = keep([...into.dataChanges, ...from.dataChanges], C.MAX_BATCH_DATA_CHANGES);
|
|
675
|
+
into.events = keep([...into.events, ...from.events], C.MAX_EVENTS_PER_WAKE);
|
|
676
|
+
into.visitorChanges = keep([...into.visitorChanges, ...from.visitorChanges], C.MAX_BATCH_DATA_CHANGES);
|
|
677
|
+
into.comments = keep([...into.comments, ...from.comments], C.COMMENTS.MAX_PER_WAKE);
|
|
678
|
+
into.dataTotal += from.dataTotal;
|
|
679
|
+
into.eventsTotal += from.eventsTotal;
|
|
680
|
+
into.visitorTotal += from.visitorTotal;
|
|
681
|
+
into.commentsTotal += from.commentsTotal;
|
|
682
|
+
into.seqs = keep([...into.seqs, ...from.seqs], C.MAX_DELIVERED_SEQS);
|
|
683
|
+
into.firstSeq = Math.min(into.firstSeq, from.firstSeq);
|
|
684
|
+
into.skippedNotice = into.skippedNotice && from.skippedNotice;
|
|
685
|
+
into.openedAt = Math.min(into.openedAt, from.openedAt);
|
|
686
|
+
into.mentioned = into.mentioned || from.mentioned;
|
|
687
|
+
into.mentionSeqs = keep([...into.mentionSeqs, ...from.mentionSeqs], C.MAX_DELIVERED_SEQS);
|
|
688
|
+
into.mentionFirstSeq = Math.min(into.mentionFirstSeq, from.mentionFirstSeq);
|
|
689
|
+
into.appName = into.appName ?? from.appName;
|
|
690
|
+
into.goneMentions = [...new Set([...into.goneMentions, ...from.goneMentions])];
|
|
691
|
+
}
|
|
372
692
|
/**
|
|
373
|
-
* Send one batch now (timer callback; tests). On failure the
|
|
374
|
-
* pending and is retried with backoff.
|
|
693
|
+
* Send one collecting batch now (timer callback; tests). On failure the
|
|
694
|
+
* batch stays pending and is retried with backoff.
|
|
375
695
|
*
|
|
376
696
|
* @param key - Batch key
|
|
377
697
|
* @returns Whether a message was delivered
|
|
@@ -383,29 +703,79 @@ export class AppWakeService {
|
|
|
383
703
|
if (batch.timer)
|
|
384
704
|
clearTimeout(batch.timer);
|
|
385
705
|
batch.timer = null;
|
|
386
|
-
|
|
387
|
-
|
|
706
|
+
// The previous batch for this agent is still out: fold this one into it
|
|
707
|
+
// when it is waiting to retry (its retry carries both), else check again
|
|
708
|
+
// shortly.
|
|
709
|
+
const out = this.outgoing.get(key);
|
|
710
|
+
if (out) {
|
|
711
|
+
if (out.inFlight) {
|
|
712
|
+
this.arm(key, batch, C.WAKE_INFLIGHT_RECHECK_MS);
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
this.batches.delete(key);
|
|
716
|
+
AppWakeService.merge(out, batch);
|
|
717
|
+
if (!out.timer)
|
|
718
|
+
this.arm(key, out, 0);
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
388
721
|
// Visitor submissions alone never start the agent: wait until it runs.
|
|
389
|
-
|
|
390
|
-
if (visitorOnly && batch.session !== null && this.deps.isRunning && !this.deps.isRunning(batch.session)) {
|
|
722
|
+
if (AppWakeService.visitorOnly(batch) && batch.session !== null && this.deps.isRunning && !this.deps.isRunning(batch.session)) {
|
|
391
723
|
if (this.batches.get(key) === batch)
|
|
392
724
|
this.arm(key, batch, C.VISITOR_WAKE.PENDING_RECHECK_MS);
|
|
393
725
|
return false;
|
|
394
726
|
}
|
|
727
|
+
// Detach: changes arriving while this one is delivered open the next batch.
|
|
728
|
+
this.batches.delete(key);
|
|
729
|
+
this.outgoing.set(key, batch);
|
|
730
|
+
return this.send(key, batch);
|
|
731
|
+
}
|
|
732
|
+
/** Retry an outgoing batch (its retry timer), folding in whatever collected meanwhile. */
|
|
733
|
+
async retry(key) {
|
|
734
|
+
const batch = this.outgoing.get(key);
|
|
735
|
+
if (!batch || batch.inFlight)
|
|
736
|
+
return false;
|
|
737
|
+
if (batch.timer)
|
|
738
|
+
clearTimeout(batch.timer);
|
|
739
|
+
batch.timer = null;
|
|
740
|
+
const newer = this.batches.get(key);
|
|
741
|
+
if (newer) {
|
|
742
|
+
if (newer.timer)
|
|
743
|
+
clearTimeout(newer.timer);
|
|
744
|
+
this.batches.delete(key);
|
|
745
|
+
AppWakeService.merge(batch, newer);
|
|
746
|
+
}
|
|
747
|
+
return this.send(key, batch);
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Deliver an outgoing batch once, bounded by WAKE_DELIVER_TIMEOUT_MS.
|
|
751
|
+
*
|
|
752
|
+
* @param key - Batch key
|
|
753
|
+
* @param batch - The batch (already in `outgoing`)
|
|
754
|
+
* @returns Whether it was delivered
|
|
755
|
+
*/
|
|
756
|
+
async send(key, batch) {
|
|
757
|
+
batch.inFlight = true;
|
|
758
|
+
const app = await this.deps.registry.get(batch.appId).catch(() => null);
|
|
395
759
|
// Skipped visitor submissions are reported in the next message to the
|
|
396
760
|
// agent visitors wake (the publisher, else the orchestrator).
|
|
397
|
-
const isVisitorRecipient = (app
|
|
761
|
+
const isVisitorRecipient = !!app && (app.agentSession ?? null) === batch.session;
|
|
398
762
|
const vstate = app && isVisitorRecipient ? this.visitorState(app) : null;
|
|
399
|
-
|
|
400
|
-
|
|
763
|
+
if (batch.reportSkipped === null)
|
|
764
|
+
batch.reportSkipped = vstate?.skipped ?? 0;
|
|
765
|
+
const skipped = batch.reportSkipped;
|
|
766
|
+
if (AppWakeService.visitorOnly(batch) && batch.visitorTotal === 0 && skipped === 0) {
|
|
401
767
|
// A notice-only batch with nothing left to report.
|
|
402
|
-
|
|
768
|
+
batch.inFlight = false;
|
|
769
|
+
if (this.outgoing.get(key) === batch)
|
|
770
|
+
this.outgoing.delete(key);
|
|
403
771
|
return false;
|
|
404
772
|
}
|
|
405
773
|
const text = buildAppWakeMessage({
|
|
406
774
|
appId: batch.appId,
|
|
407
|
-
appName: app?.name ?? batch.appId,
|
|
408
|
-
isPublisher: (app
|
|
775
|
+
appName: app?.name ?? batch.appName ?? batch.appId,
|
|
776
|
+
isPublisher: !!app && !app.deleted && (app.agentSession ?? null) === batch.session,
|
|
777
|
+
mentioned: batch.mentioned,
|
|
778
|
+
goneMentions: batch.goneMentions,
|
|
409
779
|
dataChanges: batch.dataChanges,
|
|
410
780
|
events: batch.events,
|
|
411
781
|
dataTotal: batch.dataTotal,
|
|
@@ -417,32 +787,68 @@ export class AppWakeService {
|
|
|
417
787
|
visitorSkipped: skipped,
|
|
418
788
|
skillsPath: this.deps.skillsPath,
|
|
419
789
|
});
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
790
|
+
const owner = !AppWakeService.visitorOnly(batch);
|
|
791
|
+
const seqs = batch.seqs.filter((n) => Number.isFinite(n));
|
|
792
|
+
const mseqs = batch.mentionSeqs.filter((n) => Number.isFinite(n));
|
|
793
|
+
const refParts = [
|
|
794
|
+
...(seqs.length > 0 ? [`${Math.min(...seqs)}-${Math.max(...seqs)}:${seqs.length}`] : []),
|
|
795
|
+
...(mseqs.length > 0 ? [`m${Math.min(...mseqs)}-${Math.max(...mseqs)}:${mseqs.length}`] : []),
|
|
796
|
+
];
|
|
797
|
+
const ref = refParts.length > 0 ? `app:${batch.appId}:${refParts.join(':')}` : undefined;
|
|
798
|
+
const sentSeqs = new Set(batch.seqs);
|
|
799
|
+
const sentMentions = new Set(batch.mentionSeqs);
|
|
800
|
+
const attempt = Promise.resolve()
|
|
801
|
+
.then(() => this.deps.deliver(batch.session, text, { activate: batch.activate, ...(owner ? { owner: true } : {}), ...(ref ? { ref } : {}) }))
|
|
802
|
+
.catch((err) => {
|
|
425
803
|
this.logger.warn('App change wake failed', { appId: batch.appId, session: batch.session, error: err instanceof Error ? err.message : String(err) });
|
|
426
|
-
}
|
|
427
|
-
if (!ok) {
|
|
428
|
-
batch.failures++;
|
|
429
|
-
const retry = Math.min(C.WAKE_RETRY_BASE_MS * 2 ** (batch.failures - 1), C.WAKE_RETRY_MAX_MS);
|
|
430
|
-
this.logger.warn('App change wake was not delivered; will retry', { appId: batch.appId, session: batch.session ?? 'orchestrator', failures: batch.failures, retryInMs: retry });
|
|
431
|
-
if (batch.failures >= C.WAKE_FAILS_BEFORE_ORC_NOTICE && !batch.orcNotified && batch.session !== null) {
|
|
432
|
-
batch.orcNotified = true;
|
|
433
|
-
const notice = `[APP CHANGES] Changes in app "${safeAppName(app?.name ?? batch.appId)}" (${batch.appId}) could not be delivered to ` +
|
|
434
|
-
`${batch.session} after ${batch.failures} tries. Crewly keeps retrying. Check whether that agent is stuck or signed out.`;
|
|
435
|
-
await this.deps.deliver(null, notice, { activate: false }).catch(() => false);
|
|
436
|
-
}
|
|
437
|
-
if (this.batches.get(key) === batch)
|
|
438
|
-
this.arm(key, batch, retry);
|
|
439
804
|
return false;
|
|
805
|
+
});
|
|
806
|
+
const outcome = await withDeadline(attempt.then((ok) => (ok ? 'ok' : 'failed')), C.WAKE_DELIVER_TIMEOUT_MS, () => 'timeout');
|
|
807
|
+
batch.inFlight = false;
|
|
808
|
+
if (this.outgoing.get(key) !== batch)
|
|
809
|
+
return false; // stopped meanwhile
|
|
810
|
+
if (outcome === 'ok')
|
|
811
|
+
return this.succeeded(key, batch, app, vstate, skipped);
|
|
812
|
+
if (outcome === 'timeout') {
|
|
813
|
+
this.logger.warn('App change wake did not finish in time; will retry', {
|
|
814
|
+
appId: batch.appId,
|
|
815
|
+
session: batch.session ?? 'orchestrator',
|
|
816
|
+
timeoutMs: C.WAKE_DELIVER_TIMEOUT_MS,
|
|
817
|
+
});
|
|
818
|
+
// A late success still settles the batch, unless a retry is already out
|
|
819
|
+
// or the batch has grown since (then the retry carries everything).
|
|
820
|
+
void attempt.then((ok) => {
|
|
821
|
+
if (!ok || this.outgoing.get(key) !== batch || batch.inFlight)
|
|
822
|
+
return;
|
|
823
|
+
if (!batch.seqs.every((n) => sentSeqs.has(n)) || !batch.mentionSeqs.every((n) => sentMentions.has(n)))
|
|
824
|
+
return;
|
|
825
|
+
this.logger.info('A timed-out app change wake arrived after all', { appId: batch.appId, session: batch.session ?? 'orchestrator' });
|
|
826
|
+
if (batch.timer)
|
|
827
|
+
clearTimeout(batch.timer);
|
|
828
|
+
batch.timer = null;
|
|
829
|
+
void this.succeeded(key, batch, app, vstate, skipped);
|
|
830
|
+
});
|
|
440
831
|
}
|
|
441
|
-
|
|
832
|
+
batch.failures++;
|
|
833
|
+
const retry = Math.min(C.WAKE_RETRY_BASE_MS * 2 ** (batch.failures - 1), C.WAKE_RETRY_MAX_MS);
|
|
834
|
+
this.logger.warn('App change wake was not delivered; will retry', { appId: batch.appId, session: batch.session ?? 'orchestrator', failures: batch.failures, retryInMs: retry });
|
|
835
|
+
if (batch.failures >= C.WAKE_FAILS_BEFORE_ORC_NOTICE && !batch.orcNotified && batch.session !== null) {
|
|
836
|
+
batch.orcNotified = true;
|
|
837
|
+
this.notifyOrc(`[APP CHANGES] Changes in app "${safeAppName(app?.name ?? batch.appId)}" (${batch.appId}) could not be delivered to ` +
|
|
838
|
+
`${batch.session} after ${batch.failures} tries. Crewly keeps retrying. Check whether that agent is stuck or signed out.`);
|
|
839
|
+
}
|
|
840
|
+
this.arm(key, batch, retry);
|
|
841
|
+
return false;
|
|
842
|
+
}
|
|
843
|
+
async succeeded(key, batch, app, vstate, skipped) {
|
|
844
|
+
if (this.outgoing.get(key) !== batch)
|
|
845
|
+
return false;
|
|
846
|
+
this.outgoing.delete(key);
|
|
847
|
+
const recipient = batch.session ?? ORC_RECIPIENT;
|
|
442
848
|
const at = this.now();
|
|
443
849
|
this.lastWakeAt.set(key, at);
|
|
444
850
|
await this.deps.registry.setLastWake(batch.appId, recipient, at).catch(() => undefined);
|
|
445
|
-
if (vstate) {
|
|
851
|
+
if (vstate && app) {
|
|
446
852
|
if (batch.visitorTotal > 0 || batch.skippedNotice)
|
|
447
853
|
vstate.count++;
|
|
448
854
|
vstate.skipped = Math.max(0, vstate.skipped - skipped);
|
|
@@ -453,6 +859,12 @@ export class AppWakeService {
|
|
|
453
859
|
for (const seq of batch.seqs)
|
|
454
860
|
set.add(seq);
|
|
455
861
|
this.delivered.set(batch.appId, set);
|
|
862
|
+
if (batch.mentionSeqs.length > 0) {
|
|
863
|
+
const mset = this.mentionDelivered ?? new Set();
|
|
864
|
+
for (const seq of batch.mentionSeqs)
|
|
865
|
+
mset.add(seq);
|
|
866
|
+
this.mentionDelivered = mset;
|
|
867
|
+
}
|
|
456
868
|
this.logger.info('Woke agent for app changes', {
|
|
457
869
|
appId: batch.appId,
|
|
458
870
|
session: batch.session ?? 'orchestrator',
|
|
@@ -460,14 +872,66 @@ export class AppWakeService {
|
|
|
460
872
|
events: batch.eventsTotal,
|
|
461
873
|
comments: batch.commentsTotal,
|
|
462
874
|
visitorSubmissions: batch.visitorTotal,
|
|
875
|
+
...(batch.mentioned ? { mentioned: true } : {}),
|
|
463
876
|
...(skipped > 0 ? { visitorSkippedReported: skipped } : {}),
|
|
877
|
+
...(batch.failures > 0 ? { afterFailures: batch.failures } : {}),
|
|
464
878
|
});
|
|
879
|
+
// Changes that arrived during the delivery wait for the cooldown it started.
|
|
880
|
+
const next = this.batches.get(key);
|
|
881
|
+
if (next)
|
|
882
|
+
this.arm(key, next, C.COOLDOWN_MS);
|
|
465
883
|
await this.persistProgress(batch.appId).catch(() => undefined);
|
|
884
|
+
if (batch.mentionSeqs.length > 0)
|
|
885
|
+
await this.persistMentionProgress().catch(() => undefined);
|
|
466
886
|
return true;
|
|
467
887
|
}
|
|
888
|
+
/** Tell the orchestrator something, without waiting on it for long. */
|
|
889
|
+
notifyOrc(text) {
|
|
890
|
+
void withDeadline(Promise.resolve()
|
|
891
|
+
.then(() => this.deps.deliver(null, text, { activate: false }))
|
|
892
|
+
.catch(() => false), C.WAKE_DELIVER_TIMEOUT_MS, () => false);
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Runs before every scheduled pass: a batch with no timer and no delivery
|
|
896
|
+
* running is re-armed (it would otherwise wait forever), and owner changes
|
|
897
|
+
* that have not reached their agent after WAKE_STUCK_NOTICE_MS are logged
|
|
898
|
+
* and reported to the orchestrator, once per batch. Visitor-only batches
|
|
899
|
+
* waiting for their agent to run are expected to wait and are left alone.
|
|
900
|
+
*/
|
|
901
|
+
watchdog() {
|
|
902
|
+
const now = this.now();
|
|
903
|
+
for (const [key, batch] of [...this.batches.entries(), ...this.outgoing.entries()]) {
|
|
904
|
+
if (!batch.timer && !batch.inFlight) {
|
|
905
|
+
this.logger.warn('App change batch had no timer; re-armed', { appId: batch.appId, session: batch.session ?? 'orchestrator' });
|
|
906
|
+
this.arm(key, batch, 0);
|
|
907
|
+
}
|
|
908
|
+
if (AppWakeService.visitorOnly(batch) || batch.stuckNotified)
|
|
909
|
+
continue;
|
|
910
|
+
const ageMs = now - batch.openedAt;
|
|
911
|
+
if (ageMs < C.WAKE_STUCK_NOTICE_MS)
|
|
912
|
+
continue;
|
|
913
|
+
batch.stuckNotified = true;
|
|
914
|
+
const minutes = Math.round(ageMs / 60_000);
|
|
915
|
+
this.logger.warn('Owner changes in an app have not reached their agent', {
|
|
916
|
+
appId: batch.appId,
|
|
917
|
+
session: batch.session ?? 'orchestrator',
|
|
918
|
+
minutes,
|
|
919
|
+
inFlight: batch.inFlight,
|
|
920
|
+
failures: batch.failures,
|
|
921
|
+
comments: batch.commentsTotal,
|
|
922
|
+
dataChanges: batch.dataTotal,
|
|
923
|
+
events: batch.eventsTotal,
|
|
924
|
+
});
|
|
925
|
+
if (batch.session !== null) {
|
|
926
|
+
this.notifyOrc(`[APP CHANGES] The owner's changes in an app (${batch.appId}: ${batch.commentsTotal} comment(s), ${batch.dataTotal} data change(s), ` +
|
|
927
|
+
`${batch.eventsTotal} message(s)) have waited ${minutes} min to reach ${batch.session}. Crewly keeps trying. ` +
|
|
928
|
+
'Check whether that agent is stuck.');
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
468
932
|
/** @returns Keys of batches waiting to be sent (tests) */
|
|
469
933
|
pendingKeys() {
|
|
470
|
-
return [...this.batches.keys()];
|
|
934
|
+
return [...new Set([...this.batches.keys(), ...this.outgoing.keys()])];
|
|
471
935
|
}
|
|
472
936
|
}
|
|
473
937
|
//# sourceMappingURL=app-wake.service.js.map
|