@jungjaehoon/mama-os 0.32.2 → 0.33.0

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 (61) hide show
  1. package/dist/cli/commands/start.d.ts.map +1 -1
  2. package/dist/cli/commands/start.js +58 -24
  3. package/dist/cli/commands/start.js.map +1 -1
  4. package/dist/cli/runtime/api-routes-init.d.ts +2 -0
  5. package/dist/cli/runtime/api-routes-init.d.ts.map +1 -1
  6. package/dist/cli/runtime/api-routes-init.js +61 -0
  7. package/dist/cli/runtime/api-routes-init.js.map +1 -1
  8. package/dist/gateways/message-router.d.ts +4 -0
  9. package/dist/gateways/message-router.d.ts.map +1 -1
  10. package/dist/gateways/message-router.js +62 -13
  11. package/dist/gateways/message-router.js.map +1 -1
  12. package/dist/gateways/owner-report-inbox.d.ts +40 -0
  13. package/dist/gateways/owner-report-inbox.d.ts.map +1 -0
  14. package/dist/gateways/owner-report-inbox.js +128 -0
  15. package/dist/gateways/owner-report-inbox.js.map +1 -0
  16. package/dist/gateways/report-projection-v1.d.ts +41 -0
  17. package/dist/gateways/report-projection-v1.d.ts.map +1 -0
  18. package/dist/gateways/report-projection-v1.js +160 -0
  19. package/dist/gateways/report-projection-v1.js.map +1 -0
  20. package/dist/gateways/session-store.d.ts +22 -0
  21. package/dist/gateways/session-store.d.ts.map +1 -1
  22. package/dist/gateways/session-store.js +76 -3
  23. package/dist/gateways/session-store.js.map +1 -1
  24. package/dist/gateways/telegram-message-ledger.d.ts +10 -0
  25. package/dist/gateways/telegram-message-ledger.d.ts.map +1 -1
  26. package/dist/gateways/telegram-message-ledger.js +23 -2
  27. package/dist/gateways/telegram-message-ledger.js.map +1 -1
  28. package/dist/gateways/telegram-report-context-store.d.ts +193 -0
  29. package/dist/gateways/telegram-report-context-store.d.ts.map +1 -0
  30. package/dist/gateways/telegram-report-context-store.js +612 -0
  31. package/dist/gateways/telegram-report-context-store.js.map +1 -0
  32. package/dist/gateways/telegram.d.ts +9 -0
  33. package/dist/gateways/telegram.d.ts.map +1 -1
  34. package/dist/gateways/telegram.js +100 -0
  35. package/dist/gateways/telegram.js.map +1 -1
  36. package/dist/gateways/types.d.ts +12 -0
  37. package/dist/gateways/types.d.ts.map +1 -1
  38. package/dist/operator/operator-trigger-loop.d.ts +39 -7
  39. package/dist/operator/operator-trigger-loop.d.ts.map +1 -1
  40. package/dist/operator/operator-trigger-loop.js +282 -89
  41. package/dist/operator/operator-trigger-loop.js.map +1 -1
  42. package/dist/operator/report-delivery-coordinator.d.ts +82 -0
  43. package/dist/operator/report-delivery-coordinator.d.ts.map +1 -0
  44. package/dist/operator/report-delivery-coordinator.js +123 -0
  45. package/dist/operator/report-delivery-coordinator.js.map +1 -0
  46. package/dist/operator/situation-report.d.ts +7 -0
  47. package/dist/operator/situation-report.d.ts.map +1 -1
  48. package/dist/operator/situation-report.js +12 -0
  49. package/dist/operator/situation-report.js.map +1 -1
  50. package/dist/operator/trigger-author.d.ts +10 -2
  51. package/dist/operator/trigger-author.d.ts.map +1 -1
  52. package/dist/operator/trigger-author.js +112 -39
  53. package/dist/operator/trigger-author.js.map +1 -1
  54. package/dist/operator/trigger-registry.d.ts +21 -0
  55. package/dist/operator/trigger-registry.d.ts.map +1 -1
  56. package/dist/operator/trigger-registry.js +161 -1
  57. package/dist/operator/trigger-registry.js.map +1 -1
  58. package/dist/operator/trigger-review.d.ts.map +1 -1
  59. package/dist/operator/trigger-review.js +56 -10
  60. package/dist/operator/trigger-review.js.map +1 -1
  61. package/package.json +1 -1
@@ -27,8 +27,15 @@ const leg_cadence_js_1 = require("./leg-cadence.js");
27
27
  class OperatorTriggerLoop {
28
28
  deps;
29
29
  tickCount = 0;
30
+ maintenanceTickCount = 0;
31
+ authorWindowGeneration = 0;
32
+ authoredWindowGeneration = 0;
30
33
  recentEvents = [];
31
34
  running = false;
35
+ maintenancePending = false;
36
+ schedulerActive = false;
37
+ stopping = false;
38
+ activeRunPromise = null;
32
39
  nudgeTimer = null;
33
40
  digest;
34
41
  fullReporter;
@@ -37,6 +44,14 @@ class OperatorTriggerLoop {
37
44
  pendingReportExpectation;
38
45
  pendingReportLegacyLoaded = false;
39
46
  constructor(deps) {
47
+ // Design Decision 1: exactly one delivery executor. The legacy OutputSink
48
+ // report path cannot run beside the coordinator port.
49
+ if (deps.reportDelivery && deps.output) {
50
+ throw new Error('OperatorTriggerLoop: legacy report output cannot run beside ReportDeliveryPort');
51
+ }
52
+ if (deps.reportDelivery && !deps.reportTarget) {
53
+ throw new Error('OperatorTriggerLoop: reportDelivery requires reportTarget');
54
+ }
40
55
  this.deps = deps;
41
56
  // G2 success signal: when a sent report cites fired triggers (USED_TRIGGERS
42
57
  // trailer, window-validated), record 'succeeded' on each. Uncited fires stay
@@ -75,7 +90,7 @@ class OperatorTriggerLoop {
75
90
  }
76
91
  /** Reloads one durable outcome so recovery can hydrate this live loop before report work. */
77
92
  refreshPendingReportState() {
78
- if (!this.deps.output) {
93
+ if (!this.hasReportSink()) {
79
94
  return 'unavailable';
80
95
  }
81
96
  const store = this.deps.pendingReportStore;
@@ -120,7 +135,7 @@ class OperatorTriggerLoop {
120
135
  return this.refreshPendingReportState() === 'quarantined';
121
136
  }
122
137
  persistPendingReports() {
123
- if (!this.deps.output)
138
+ if (!this.hasReportSink())
124
139
  return false;
125
140
  if (this.refreshPendingReportState() === 'quarantined')
126
141
  return false;
@@ -144,6 +159,10 @@ class OperatorTriggerLoop {
144
159
  reporterFor(mode) {
145
160
  return mode === 'full' ? this.fullReporter : this.digest;
146
161
  }
162
+ /** A report sink exists: either the coordinator port or the legacy test sink. */
163
+ hasReportSink() {
164
+ return Boolean(this.deps.reportDelivery ?? this.deps.output);
165
+ }
147
166
  deliveryIdFor(occurrence) {
148
167
  if (occurrence.kind === 'scheduled_full' && occurrence.hourKey) {
149
168
  return `operator-report:scheduled:${occurrence.hourKey}`;
@@ -151,7 +170,7 @@ class OperatorTriggerLoop {
151
170
  return `operator-report:${occurrence.kind}:${(0, node_crypto_1.randomUUID)()}`;
152
171
  }
153
172
  requireOutputTarget() {
154
- const target = this.deps.output?.target;
173
+ const target = this.deps.reportTarget ?? this.deps.output?.target;
155
174
  if (target?.source !== 'telegram' ||
156
175
  target.channelId.length === 0 ||
157
176
  target.channelId.trim() !== target.channelId) {
@@ -181,20 +200,56 @@ class OperatorTriggerLoop {
181
200
  }
182
201
  async deliverPendingReport(recovered) {
183
202
  const pending = this.pendingDelivery;
184
- const output = this.deps.output;
185
- if (!pending || !output) {
203
+ if (!pending || !this.hasReportSink()) {
186
204
  return null;
187
205
  }
188
206
  this.assertPendingDeliveryBinding(pending);
189
- await this.reporterFor(pending.mode).deliverPrepared(pending, output);
190
- if (pending.mode === 'full' && this.deps.reportScheduler) {
191
- if (pending.occurrence.hourKey) {
192
- this.deps.reportScheduler.markFired(pending.occurrence.hourKey);
207
+ const port = this.deps.reportDelivery;
208
+ if (port) {
209
+ // TG-05/TG-06: submit the persisted artifact and observe the typed
210
+ // outcome. Only `delivered` advances scheduler success, trigger credit,
211
+ // and pending-file cleanup; retry/rejection retain the artifact for the
212
+ // coordinator to drive to terminal; cancellation cleans up with an
213
+ // audit record and no credit.
214
+ const outcome = await port.deliverPrepared(pending);
215
+ if (outcome.status === 'delivered') {
216
+ this.reporterFor(pending.mode).markDeliveredOutcome(pending.citedTriggerIds);
217
+ this.markScheduledFullOutcome(pending);
218
+ this.pendingDelivery = undefined;
219
+ this.persistPendingReports();
220
+ if (recovered) {
221
+ this.deps.log(`[trigger-loop] recovered pending ${pending.mode} owner report delivery=${pending.deliveryId}`);
222
+ }
223
+ return pending;
193
224
  }
194
- if (pending.occurrence.firedAtIso) {
195
- this.deps.reportScheduler.markSuccess(pending.occurrence.firedAtIso);
225
+ if (outcome.status === 'cancelled') {
226
+ this.pendingDelivery = undefined;
227
+ this.persistPendingReports();
228
+ this.deps.log(`[trigger-loop] owner report delivery=${pending.deliveryId} cancelled without credit: ${outcome.reason}`);
229
+ return null;
196
230
  }
231
+ if (outcome.status === 'capacity_full') {
232
+ // Loud degradation, never a wedge: the durable artifact is retained,
233
+ // the tick continues, and recovery only needs consumption or an
234
+ // explicit operator archive to reopen capacity.
235
+ this.persistPendingReports();
236
+ this.deps.log(`[trigger-loop] owner report delivery=${pending.deliveryId} BLOCKED by report-context capacity: ${outcome.reason}`);
237
+ return null;
238
+ }
239
+ this.persistPendingReports();
240
+ this.deps.log(outcome.status === 'retry_scheduled'
241
+ ? `[trigger-loop] owner report delivery=${pending.deliveryId} retry scheduled at ${outcome.nextAttemptAt}`
242
+ : `[trigger-loop] owner report delivery=${pending.deliveryId} definitively rejected: ${outcome.reason}`);
243
+ return null;
244
+ }
245
+ // LEGACY OutputSink path - V2 compatibility tests only; the constructor
246
+ // guard keeps it out of any assembly that wires the coordinator port.
247
+ const output = this.deps.output;
248
+ if (!output) {
249
+ return null;
197
250
  }
251
+ await this.reporterFor(pending.mode).deliverPrepared(pending, output);
252
+ this.markScheduledFullOutcome(pending);
198
253
  this.pendingDelivery = undefined;
199
254
  this.persistPendingReports();
200
255
  if (recovered) {
@@ -202,8 +257,19 @@ class OperatorTriggerLoop {
202
257
  }
203
258
  return pending;
204
259
  }
260
+ markScheduledFullOutcome(pending) {
261
+ if (pending.mode !== 'full' || !this.deps.reportScheduler) {
262
+ return;
263
+ }
264
+ if (pending.occurrence.hourKey) {
265
+ this.deps.reportScheduler.markFired(pending.occurrence.hourKey);
266
+ }
267
+ if (pending.occurrence.firedAtIso) {
268
+ this.deps.reportScheduler.markSuccess(pending.occurrence.firedAtIso);
269
+ }
270
+ }
205
271
  async prepareAndDeliverReport(askAgent, mode, occurrence) {
206
- if (!this.deps.output || this.isPendingReportWorkBlocked()) {
272
+ if (!this.hasReportSink() || this.isPendingReportWorkBlocked()) {
207
273
  return false;
208
274
  }
209
275
  if (this.pendingDelivery) {
@@ -236,7 +302,7 @@ class OperatorTriggerLoop {
236
302
  }
237
303
  async preparePendingRequest() {
238
304
  const request = this.pendingRequest;
239
- if (!request || !this.deps.output || this.isPendingReportWorkBlocked())
305
+ if (!request || !this.hasReportSink() || this.isPendingReportWorkBlocked())
240
306
  return false;
241
307
  if (this.pendingDelivery) {
242
308
  throw new Error('A pending owner report delivery must be recovered before its request');
@@ -278,42 +344,61 @@ class OperatorTriggerLoop {
278
344
  }
279
345
  return recovered;
280
346
  }
281
- async tick() {
347
+ async tick(options = {}) {
282
348
  const { delta, memory, registry, askAgent, review, config, log } = this.deps;
283
- const { output, reportScheduler } = this.deps;
284
- const fullLegOn = Boolean(output && reportScheduler);
349
+ const { reportScheduler } = this.deps;
350
+ const reportSink = this.hasReportSink();
351
+ const fullLegOn = Boolean(reportSink && reportScheduler);
285
352
  this.tickCount += 1;
286
353
  const tick = this.tickCount;
354
+ const maintenanceTick = options.advanceMaintenance === false ? null : (this.maintenanceTickCount += 1);
355
+ let fires = 0;
356
+ let authored = 0;
357
+ let reviewed = 0;
358
+ let reported = false;
359
+ let fullReported = false;
360
+ const result = (drained = 0) => ({
361
+ tick,
362
+ drained,
363
+ fires,
364
+ authored,
365
+ reviewed,
366
+ reported,
367
+ fullReported,
368
+ });
287
369
  // Outbox recovery is the first effect in a tick. It reuses the persisted
288
370
  // delivery id, allowing Telegram's confirmed-chunk ledger to suppress a
289
371
  // send that was accepted just before the prior daemon stopped.
290
372
  this.refreshPendingReportState();
291
373
  const recoveredPendingWork = await this.recoverPendingReportWork();
374
+ if (this.stopping)
375
+ return result();
292
376
  // 1. Drain new deltas (commit AFTER processing - at-least-once).
293
377
  const events = delta.drainNew(config.drainLimit);
294
- const reportEvents = output
378
+ const reportEvents = reportSink
295
379
  ? events.filter((event) => !this.digest.hasRecordedEvent(event))
296
380
  : events;
297
381
  if (events.length > 0) {
298
382
  log(`[trigger-loop] tick ${tick}: drained ${events.length} events`);
299
383
  }
300
384
  // 2. Match + fire + recordFire, folding fire activity into the report accumulators.
301
- let fires = 0;
302
385
  for (const event of reportEvents) {
303
386
  const signals = (0, trigger_matcher_js_1.matchTriggers)(event, registry);
304
387
  for (const signal of signals) {
305
- const result = await (0, trigger_fire_js_1.fireTrigger)(signal, memory);
388
+ const fireResult = await (0, trigger_fire_js_1.fireTrigger)(signal, memory);
389
+ if (this.stopping)
390
+ return result(events.length);
306
391
  fires += 1;
307
392
  if (signal.triggerId) {
308
393
  registry.recordFire(signal.triggerId);
309
394
  }
310
395
  // Carry the recalled {topic, content} (agent-authored memoryQuery drove it) into the report.
311
- if (output) {
396
+ if (reportSink) {
312
397
  this.digest.recordFire({
313
398
  triggerId: signal.triggerId ?? signal.detector,
314
399
  kind: signal.kind,
315
400
  channelId: signal.channelId,
316
- recalled: result.recalled,
401
+ recalled: fireResult.recalled,
317
402
  });
318
403
  }
319
404
  if (fullLegOn) {
@@ -321,23 +406,24 @@ class OperatorTriggerLoop {
321
406
  triggerId: signal.triggerId ?? signal.detector,
322
407
  kind: signal.kind,
323
408
  channelId: signal.channelId,
324
- recalled: result.recalled,
409
+ recalled: fireResult.recalled,
325
410
  });
326
411
  }
327
412
  log(`[trigger-loop] tick ${tick}: fire trigger=${signal.triggerId ?? signal.detector} ` +
328
- `recalled=${result.recalled.length} channel=${signal.channelId}`);
413
+ `recalled=${fireResult.recalled.length} channel=${signal.channelId}`);
329
414
  }
330
415
  }
331
416
  // Persist the report window BEFORE advancing the connector cursor. A daemon
332
417
  // crash may repeat an event, but it cannot silently lose an owner update.
333
418
  if (reportEvents.length > 0) {
334
- if (output) {
419
+ if (reportSink) {
335
420
  this.digest.recordWindow(reportEvents);
336
421
  if (fullLegOn) {
337
422
  this.fullReporter.recordWindow(reportEvents);
338
423
  }
339
424
  }
340
425
  this.recentEvents = [...this.recentEvents, ...reportEvents].slice(-config.authorWindowSize);
426
+ this.authorWindowGeneration += 1;
341
427
  this.persistPendingReports();
342
428
  }
343
429
  // Group per channel ONCE, before the cursor moves: the conductor inbox
@@ -404,46 +490,96 @@ class OperatorTriggerLoop {
404
490
  }
405
491
  }
406
492
  // 3. Agent authors new triggers from the recent window.
407
- let authored = 0;
408
- if (tick % config.authorEveryNTicks === 0 && this.recentEvents.length > 0) {
409
- const created = await (0, trigger_author_js_1.authorTriggers)(this.recentEvents, registry, askAgent, {
410
- note: `authored at tick ${tick}`,
411
- });
412
- authored = created.length;
413
- if (authored > 0) {
414
- if (output) {
415
- this.digest.recordAuthored(authored);
493
+ if (maintenanceTick !== null &&
494
+ maintenanceTick % config.authorEveryNTicks === 0 &&
495
+ this.recentEvents.length > 0 &&
496
+ this.authorWindowGeneration > this.authoredWindowGeneration) {
497
+ if (registry.canAttemptAuthor()) {
498
+ try {
499
+ const created = await (0, trigger_author_js_1.authorTriggers)(this.recentEvents, registry, askAgent, {
500
+ note: `authored at tick ${tick}`,
501
+ });
502
+ if (this.stopping)
503
+ return result(events.length);
504
+ authored = created.length;
505
+ registry.clearAuthorFailure();
506
+ this.authoredWindowGeneration = this.authorWindowGeneration;
507
+ if (authored > 0) {
508
+ if (reportSink) {
509
+ this.digest.recordAuthored(authored);
510
+ }
511
+ if (fullLegOn) {
512
+ this.fullReporter.recordAuthored(authored);
513
+ }
514
+ this.persistPendingReports();
515
+ }
516
+ log(`[trigger-loop] tick ${tick}: author pass created ${authored} trigger(s)`);
416
517
  }
417
- if (fullLegOn) {
418
- this.fullReporter.recordAuthored(authored);
518
+ catch (error) {
519
+ if (this.stopping) {
520
+ log(`[trigger-loop] tick ${tick}: author pass cancelled by shutdown`);
521
+ return result(events.length);
522
+ }
523
+ registry.recordAuthorFailure(authorWindowFingerprint(this.recentEvents));
524
+ log(`[trigger-loop] tick ${tick}: author pass FAILED; backed off: ${error instanceof Error ? error.message : String(error)}`);
419
525
  }
420
- this.persistPendingReports();
421
526
  }
422
- log(`[trigger-loop] tick ${tick}: author pass created ${authored} trigger(s)`);
423
527
  }
424
528
  // 4. Agent reviews triggers that have actually fired.
425
- let reviewed = 0;
426
- if (tick % config.reviewEveryNTicks === 0) {
427
- const firedTriggers = registry.listActive().filter((t) => t.stats.fired > 0);
428
- const context = this.recentEvents.map((e) => `[${e.channelId}] ${e.content}`);
529
+ if (this.stopping)
530
+ return result(events.length);
531
+ if (maintenanceTick !== null && maintenanceTick % config.reviewEveryNTicks === 0) {
532
+ const configuredReviewLimit = config.reviewBatchLimit;
533
+ const reviewBatchLimit = typeof configuredReviewLimit === 'number' &&
534
+ Number.isInteger(configuredReviewLimit) &&
535
+ configuredReviewLimit > 0
536
+ ? configuredReviewLimit
537
+ : 1;
538
+ const firedTriggers = registry.listReviewCandidates(reviewBatchLimit);
539
+ const context = boundedReviewContext(this.recentEvents);
429
540
  for (const trigger of firedTriggers) {
430
- const decision = await review(trigger, context);
431
- const action = (0, trigger_review_js_1.applyReview)(decision, trigger.id, registry);
432
- reviewed += 1;
433
- log(`[trigger-loop] tick ${tick}: review trigger=${trigger.id} -> ${action}`);
541
+ try {
542
+ const decision = await review(trigger, context);
543
+ if (this.stopping)
544
+ return result(events.length);
545
+ const action = (0, trigger_review_js_1.applyReview)(decision, trigger.id, registry);
546
+ if (action === 'kept') {
547
+ registry.markReviewed(trigger.id, trigger.stats.fired);
548
+ }
549
+ reviewed += 1;
550
+ log(`[trigger-loop] tick ${tick}: review trigger=${trigger.id} -> ${action}`);
551
+ }
552
+ catch (error) {
553
+ if (this.stopping) {
554
+ log(`[trigger-loop] tick ${tick}: review trigger=${trigger.id} cancelled by shutdown`);
555
+ return result(events.length);
556
+ }
557
+ let backoffNote = 'backed off';
558
+ try {
559
+ registry.recordReviewFailure(trigger.id);
560
+ }
561
+ catch (stateError) {
562
+ backoffNote = `state changed before backoff: ${stateError instanceof Error ? stateError.message : String(stateError)}`;
563
+ }
564
+ log(`[trigger-loop] tick ${tick}: review trigger=${trigger.id} FAILED; ${backoffNote}: ${error instanceof Error ? error.message : String(error)}`);
565
+ }
434
566
  }
435
567
  }
436
568
  // 5. Situational digest (M1.5 cadence, M2 window-aware): the agent composes it from the
437
569
  // window + fire activity + recalled memory; the sink delivers it. Agent may reply NOTHING.
438
- let reported = false;
570
+ if (this.stopping)
571
+ return result(events.length);
439
572
  const reportAsk = this.deps.reportAsk ?? askAgent;
440
573
  const reportEvery = config.reportEveryNTicks ?? 0;
441
574
  if (!this.isPendingReportWorkBlocked() &&
442
- output &&
575
+ !this.pendingDelivery &&
576
+ reportSink &&
443
577
  reportEvery > 0 &&
444
578
  tick % reportEvery === 0 &&
445
579
  this.digest.hasActivity()) {
446
580
  reported = await this.prepareAndDeliverReport(reportAsk, 'digest', { kind: 'digest' });
581
+ if (this.stopping)
582
+ return result(events.length);
447
583
  log(`[trigger-loop] tick ${tick}: owner digest ${reported ? 'SENT' : 'suppressed by agent'}`);
448
584
  }
449
585
  // 6. Scheduled full report (M2): fires at configured LOCAL hours - even on a completely
@@ -451,8 +587,15 @@ class OperatorTriggerLoop {
451
587
  // rely on the scheduled report arriving). Fires once per hour (markFired persists the
452
588
  // hour key -> restart-safe). Send failure throws (no-fallback) WITHOUT marking the hour,
453
589
  // so the next tick retries with the buffer intact.
454
- let fullReported = false;
455
- if (!recoveredPendingWork && !this.isPendingReportWorkBlocked() && output && reportScheduler) {
590
+ if (!this.stopping &&
591
+ !recoveredPendingWork &&
592
+ !this.isPendingReportWorkBlocked() &&
593
+ // An outstanding delivery (retry backoff or definite rejection) parks
594
+ // the scheduled leg WITHOUT consuming the hour: the coordinator drives
595
+ // the pending artifact to terminal, then the next due tick fires.
596
+ !this.pendingDelivery &&
597
+ reportSink &&
598
+ reportScheduler) {
456
599
  const { fire, hourKey } = reportScheduler.shouldFire(new Date());
457
600
  if (fire) {
458
601
  // On-demand merge suppression (plan v6 S1-T3): an owner-requested full
@@ -478,11 +621,13 @@ class OperatorTriggerLoop {
478
621
  hourKey,
479
622
  firedAtIso,
480
623
  });
624
+ if (this.stopping)
625
+ return result(events.length);
481
626
  log(`[trigger-loop] tick ${tick}: full report ${fullReported ? 'SENT' : 'suppressed by agent'} (${hourKey})`);
482
627
  }
483
628
  }
484
629
  }
485
- return { tick, drained: events.length, fires, authored, reviewed, reported, fullReported };
630
+ return result(events.length);
486
631
  }
487
632
  /**
488
633
  * M2.4 freshness nudge: wake the loop to tick ~nudgeDebounceMs from now instead of waiting for the
@@ -493,8 +638,8 @@ class OperatorTriggerLoop {
493
638
  * a quiet window arms one timer; further nudges while it is armed are ignored, so a burst of poll
494
639
  * batches collapses to a single extra tick. Busy-safe (agent-awareness.ts:343-346 mechanism): if a
495
640
  * tick is in flight when the timer fires, the nudge is skipped - never concurrent ticks; the
496
- * uncommitted deltas simply wait for the next tick. Pure timing assist: it only changes WHEN an
497
- * existing tick runs, never WHAT it does.
641
+ * uncommitted deltas simply wait for the next tick. The extra tick drains and reports, but does
642
+ * not advance author/review maintenance cadence.
498
643
  */
499
644
  /**
500
645
  * On-demand full report (plan v6 S1-T3): the owner's "give me the full
@@ -508,9 +653,10 @@ class OperatorTriggerLoop {
508
653
  * the delta anchor exactly like a scheduled run.
509
654
  */
510
655
  startFullReport() {
511
- const output = this.deps.output;
656
+ if (this.stopping)
657
+ return { accepted: false, reason: 'unavailable' };
512
658
  const reportScheduler = this.deps.reportScheduler;
513
- if (!output || this.isPendingReportWorkBlocked()) {
659
+ if (!this.hasReportSink() || this.isPendingReportWorkBlocked()) {
514
660
  return { accepted: false, reason: 'unavailable' };
515
661
  }
516
662
  if (this.running || this.pendingDelivery || this.pendingRequest) {
@@ -553,20 +699,14 @@ class OperatorTriggerLoop {
553
699
  this.deps.log(`[trigger-loop] on-demand full report could not be accepted durably: ${error instanceof Error ? error.message : String(error)}`);
554
700
  return { accepted: false, reason: 'unavailable' };
555
701
  }
556
- this.running = true;
557
- void this.preparePendingRequest()
558
- .then((sent) => {
702
+ this.launchRun(this.preparePendingRequest().then((sent) => {
559
703
  this.deps.log(`[trigger-loop] on-demand full report ${sent ? 'SENT' : 'suppressed by agent'}`);
560
- })
561
- .catch((error) => {
562
- this.deps.log(`[trigger-loop] on-demand full report FAILED: ${error instanceof Error ? error.message : String(error)}`);
563
- })
564
- .finally(() => {
565
- this.running = false;
566
- });
704
+ }), 'on-demand full report FAILED');
567
705
  return { accepted: true };
568
706
  }
569
707
  nudge() {
708
+ if (this.stopping)
709
+ return;
570
710
  if (this.nudgeTimer)
571
711
  return; // already armed - debounce collapses the burst
572
712
  const configured = this.deps.config.nudgeDebounceMs;
@@ -579,14 +719,7 @@ class OperatorTriggerLoop {
579
719
  this.deps.log('[trigger-loop] nudge: tick already running - skipped (deltas wait for next tick)');
580
720
  return;
581
721
  }
582
- this.running = true;
583
- void this.tick()
584
- .catch((error) => {
585
- this.deps.log(`[trigger-loop] nudge tick failed: ${error instanceof Error ? error.message : String(error)}`);
586
- })
587
- .finally(() => {
588
- this.running = false;
589
- });
722
+ this.launchRun(this.tick({ advanceMaintenance: false }), 'nudge tick failed');
590
723
  }, debounceMs);
591
724
  this.nudgeTimer.unref?.();
592
725
  }
@@ -597,15 +730,10 @@ class OperatorTriggerLoop {
597
730
  */
598
731
  start() {
599
732
  const { config, log } = this.deps;
733
+ this.stopping = false;
734
+ this.schedulerActive = true;
600
735
  if ((this.pendingDelivery || this.pendingRequest) && !this.running) {
601
- this.running = true;
602
- void this.recoverPendingReportWork()
603
- .catch((error) => {
604
- log(`[trigger-loop] startup report recovery failed: ${error instanceof Error ? error.message : String(error)}`);
605
- })
606
- .finally(() => {
607
- this.running = false;
608
- });
736
+ this.launchRun(this.recoverPendingReportWork(), 'startup report recovery failed');
609
737
  }
610
738
  const handle = setInterval(() => {
611
739
  // The INTERVAL is the leg: it beats even while a long tick (full
@@ -613,29 +741,94 @@ class OperatorTriggerLoop {
613
741
  // on live. A loop mid-tick is alive, not silent.
614
742
  (0, leg_cadence_js_1.getLegCadence)()?.beat('trigger-loop');
615
743
  if (this.running) {
744
+ this.maintenancePending = true;
616
745
  log('[trigger-loop] tick skipped: previous tick still running');
617
746
  return;
618
747
  }
619
- this.running = true;
620
- void this.tick()
621
- .catch((error) => {
622
- log(`[trigger-loop] tick failed: ${error instanceof Error ? error.message : String(error)}`);
623
- })
624
- .finally(() => {
625
- this.running = false;
626
- });
748
+ this.launchRun(this.tick(), 'tick failed');
627
749
  }, config.tickMs);
628
750
  handle.unref?.();
629
751
  log(`[trigger-loop] started (tick every ${config.tickMs}ms)`);
752
+ let stopPromise;
630
753
  return () => {
754
+ if (stopPromise)
755
+ return stopPromise;
756
+ this.stopping = true;
757
+ this.schedulerActive = false;
758
+ this.maintenancePending = false;
631
759
  clearInterval(handle);
632
760
  if (this.nudgeTimer) {
633
761
  clearTimeout(this.nudgeTimer);
634
762
  this.nudgeTimer = null;
635
763
  }
636
- log('[trigger-loop] stopped');
764
+ stopPromise = (async () => {
765
+ const activeRun = this.activeRunPromise;
766
+ if (activeRun)
767
+ await activeRun;
768
+ log('[trigger-loop] stopped');
769
+ })();
770
+ return stopPromise;
637
771
  };
638
772
  }
773
+ launchRun(work, failureLabel) {
774
+ this.running = true;
775
+ const tracked = work
776
+ .then(() => undefined)
777
+ .catch((error) => {
778
+ this.deps.log(`[trigger-loop] ${failureLabel}: ${error instanceof Error ? error.message : String(error)}`);
779
+ })
780
+ .finally(() => {
781
+ if (this.activeRunPromise === tracked)
782
+ this.activeRunPromise = null;
783
+ this.finishRun();
784
+ });
785
+ this.activeRunPromise = tracked;
786
+ }
787
+ finishRun() {
788
+ this.running = false;
789
+ if (!this.schedulerActive || !this.maintenancePending)
790
+ return;
791
+ this.maintenancePending = false;
792
+ this.launchRun(this.tick(), 'deferred maintenance tick failed');
793
+ }
639
794
  }
640
795
  exports.OperatorTriggerLoop = OperatorTriggerLoop;
796
+ const REVIEW_CONTEXT_EVENT_CHARS = 500;
797
+ const REVIEW_CONTEXT_TOTAL_CHARS = 8_000;
798
+ function boundedReviewContext(events) {
799
+ const lines = [];
800
+ let used = 0;
801
+ for (const event of [...events].reverse()) {
802
+ const content = event.content
803
+ .trim()
804
+ .replace(/[\r\n]+/g, ' ')
805
+ .slice(0, REVIEW_CONTEXT_EVENT_CHARS);
806
+ const line = `[${event.channelId}] ${content}`;
807
+ if (used + line.length + 1 > REVIEW_CONTEXT_TOTAL_CHARS)
808
+ break;
809
+ lines.push(line);
810
+ used += line.length + 1;
811
+ }
812
+ const omitted = events.length - lines.length;
813
+ lines.reverse();
814
+ if (omitted > 0)
815
+ lines.unshift(`[... ${omitted} event(s) omitted by review input budget]`);
816
+ return lines;
817
+ }
818
+ function authorWindowFingerprint(events) {
819
+ const hash = (0, node_crypto_1.createHash)('sha256');
820
+ for (const event of events) {
821
+ hash.update(event.channel);
822
+ hash.update('\0');
823
+ hash.update(event.channelId);
824
+ hash.update('\0');
825
+ hash.update(String(event.eventIndexId ?? event.id));
826
+ hash.update('\0');
827
+ hash.update(String(event.createdAt));
828
+ hash.update('\0');
829
+ hash.update(event.content);
830
+ hash.update('\0');
831
+ }
832
+ return hash.digest('hex');
833
+ }
641
834
  //# sourceMappingURL=operator-trigger-loop.js.map