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.
Files changed (69) hide show
  1. package/config/skills/agent/core/app-comments/SKILL.md +39 -5
  2. package/config/skills/agent/core/app-comments/execute.sh +9 -4
  3. package/config/skills/agent/core/app-comments/execute.test.sh +9 -0
  4. package/dist/backend/backend/src/constants.d.ts +58 -0
  5. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  6. package/dist/backend/backend/src/constants.js +58 -0
  7. package/dist/backend/backend/src/constants.js.map +1 -1
  8. package/dist/backend/backend/src/services/apps/app-roster.service.d.ts +86 -0
  9. package/dist/backend/backend/src/services/apps/app-roster.service.d.ts.map +1 -0
  10. package/dist/backend/backend/src/services/apps/app-roster.service.js +119 -0
  11. package/dist/backend/backend/src/services/apps/app-roster.service.js.map +1 -0
  12. package/dist/backend/backend/src/services/apps/app-wake-message.d.ts +24 -1
  13. package/dist/backend/backend/src/services/apps/app-wake-message.d.ts.map +1 -1
  14. package/dist/backend/backend/src/services/apps/app-wake-message.js +62 -5
  15. package/dist/backend/backend/src/services/apps/app-wake-message.js.map +1 -1
  16. package/dist/backend/backend/src/services/apps/app-wake.service.d.ts +118 -2
  17. package/dist/backend/backend/src/services/apps/app-wake.service.d.ts.map +1 -1
  18. package/dist/backend/backend/src/services/apps/app-wake.service.js +514 -50
  19. package/dist/backend/backend/src/services/apps/app-wake.service.js.map +1 -1
  20. package/dist/backend/backend/src/services/apps/apps-registry.service.d.ts +20 -0
  21. package/dist/backend/backend/src/services/apps/apps-registry.service.d.ts.map +1 -1
  22. package/dist/backend/backend/src/services/apps/apps-registry.service.js +24 -0
  23. package/dist/backend/backend/src/services/apps/apps-registry.service.js.map +1 -1
  24. package/dist/backend/backend/src/services/apps/apps.service.d.ts +42 -0
  25. package/dist/backend/backend/src/services/apps/apps.service.d.ts.map +1 -1
  26. package/dist/backend/backend/src/services/apps/apps.service.js +52 -5
  27. package/dist/backend/backend/src/services/apps/apps.service.js.map +1 -1
  28. package/dist/backend/backend/src/services/apps/apps.wiring.d.ts +7 -0
  29. package/dist/backend/backend/src/services/apps/apps.wiring.d.ts.map +1 -1
  30. package/dist/backend/backend/src/services/apps/apps.wiring.js +19 -2
  31. package/dist/backend/backend/src/services/apps/apps.wiring.js.map +1 -1
  32. package/dist/backend/backend/src/services/session/input-guard-known-screens.d.ts +8 -0
  33. package/dist/backend/backend/src/services/session/input-guard-known-screens.d.ts.map +1 -1
  34. package/dist/backend/backend/src/services/session/input-guard-known-screens.js +100 -0
  35. package/dist/backend/backend/src/services/session/input-guard-known-screens.js.map +1 -1
  36. package/dist/backend/backend/src/services/session/tui-input-guard.d.ts +6 -0
  37. package/dist/backend/backend/src/services/session/tui-input-guard.d.ts.map +1 -1
  38. package/dist/backend/backend/src/services/session/tui-input-guard.js +24 -5
  39. package/dist/backend/backend/src/services/session/tui-input-guard.js.map +1 -1
  40. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +5 -3
  41. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  42. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +48 -22
  43. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  44. package/dist/backend/backend/src/services/slack/slack-thread-context.service.d.ts +1 -1
  45. package/dist/backend/backend/src/services/slack/slack-thread-context.service.d.ts.map +1 -1
  46. package/dist/backend/backend/src/services/slack/slack-thread-context.service.js +9 -4
  47. package/dist/backend/backend/src/services/slack/slack-thread-context.service.js.map +1 -1
  48. package/dist/backend/build-info.json +2 -2
  49. package/dist/cli/backend/src/constants.d.ts +58 -0
  50. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  51. package/dist/cli/backend/src/constants.js +58 -0
  52. package/dist/cli/backend/src/constants.js.map +1 -1
  53. package/dist/cli/backend/src/services/session/input-guard-known-screens.d.ts +8 -0
  54. package/dist/cli/backend/src/services/session/input-guard-known-screens.d.ts.map +1 -1
  55. package/dist/cli/backend/src/services/session/input-guard-known-screens.js +100 -0
  56. package/dist/cli/backend/src/services/session/input-guard-known-screens.js.map +1 -1
  57. package/dist/cli/backend/src/services/session/tui-input-guard.d.ts +6 -0
  58. package/dist/cli/backend/src/services/session/tui-input-guard.d.ts.map +1 -1
  59. package/dist/cli/backend/src/services/session/tui-input-guard.js +24 -5
  60. package/dist/cli/backend/src/services/session/tui-input-guard.js.map +1 -1
  61. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +5 -3
  62. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  63. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +48 -22
  64. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  65. package/dist/cli/backend/src/services/slack/slack-thread-context.service.d.ts +1 -1
  66. package/dist/cli/backend/src/services/slack/slack-thread-context.service.d.ts.map +1 -1
  67. package/dist/cli/backend/src/services/slack/slack-thread-context.service.js +9 -4
  68. package/dist/cli/backend/src/services/slack/slack-thread-context.service.js.map +1 -1
  69. 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
- for (const b of this.batches.values())
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
- void this.tick().finally(() => this.schedule());
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
- return this.ticking;
110
- this.ticking = this.runTick().finally(() => {
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
- return this.ticking;
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 due = (await this.deps.registry.list()).filter((e) => !e.deleted && (this.backoff.get(e.appId)?.nextAt ?? 0) <= now);
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
- if (batch.skippedNotice)
336
- batch.firstSeq = Math.min(batch.firstSeq, change.seq);
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(() => void this.flush(key), delay);
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 batch stays
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
- const app = await this.deps.registry.get(batch.appId);
387
- const recipient = batch.session ?? ORC_RECIPIENT;
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
- const visitorOnly = batch.dataTotal === 0 && batch.eventsTotal === 0 && batch.commentsTotal === 0;
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?.agentSession ?? null) === batch.session;
761
+ const isVisitorRecipient = !!app && (app.agentSession ?? null) === batch.session;
398
762
  const vstate = app && isVisitorRecipient ? this.visitorState(app) : null;
399
- const skipped = vstate?.skipped ?? 0;
400
- if (visitorOnly && batch.visitorTotal === 0 && skipped === 0) {
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
- this.batches.delete(key);
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?.agentSession ?? null) === batch.session,
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
- let ok = false;
421
- try {
422
- ok = await this.deps.deliver(batch.session, text, { activate: batch.activate });
423
- }
424
- catch (err) {
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
- this.batches.delete(key);
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