cli-jaw 2.17.50 → 2.17.51

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.
@@ -42,16 +42,77 @@ const HEARTBEAT_SCOPE = 'default';
42
42
  * if it were the mention. A lane nobody else submits to keeps the background turn
43
43
  * unsteerable while the session id still puts the answer in the right history. */
44
44
  const MENTION_WATCH_SCOPE_PREFIX = 'mention-watch:';
45
+ /** How long one tick may spend ANSWERING mentions.
46
+ *
47
+ * A mention-watch tick is a LOOP of orchestrator turns — `maxHits` of them, each
48
+ * bounded only by an idle timeout — and `heartbeatBusy` is held across all of it,
49
+ * so one busy morning queues every other job in the home behind it.
50
+ *
51
+ * Ten minutes matches the script runner's own ceiling at the `execFile` call below,
52
+ * so no single tick outlasts the slowest bounded runner in this file by design. It
53
+ * clocks the answering phase only: the scan can legitimately sleep for minutes on
54
+ * pacing, and charging that to the answer allowance would let a slow scan end a tick
55
+ * having answered nothing. */
56
+ const MENTION_WATCH_ANSWER_BUDGET_MS = 10 * 60_000;
45
57
  import { applyOutputPolicy, loadPolicyHooksConfig } from '../core/policy-hooks.js';
46
58
  import { setRecordPending } from '../core/policy-flags.js';
47
59
  import { parseHeartbeatReport } from './heartbeat-report.js';
60
+ import { foldRunRecord, isHeartbeatJobFailing, } from './heartbeat-run-record.js';
48
61
  import { describeHeartbeatSchedule, formatHeartbeatNow, getHeartbeatMinuteSlotKey, getHeartbeatScheduleTimeZone, matchesHeartbeatCron, normalizeHeartbeatSchedule, startHeartbeatCronLoop, validateHeartbeatCron, } from './heartbeat-schedule.js';
49
62
  const heartbeatTimers = new Map();
50
63
  const heartbeatCronSlots = new Map();
64
+ const intervalAnchors = new Map();
51
65
  let heartbeatWatcher = null;
52
66
  let heartbeatBusy = false;
67
+ // One job, one run. `heartbeatBusy` already stops two try-bodies from
68
+ // interleaving, because it is set synchronously before the first await, so this
69
+ // is not about concurrency. It is about the SAME job being executed twice in a
70
+ // row: a tick deferred during an active PABCD cycle stays queued, a later tick
71
+ // for that job runs directly once the cycle ends, and the finishing run then
72
+ // drains the copy it left behind. Two identical reports, seconds apart. The
73
+ // queue dedupe below cannot see this because the second arrival never queues.
74
+ const inFlightJobs = new Set();
75
+ // Bumped whenever the schedule is torn down, which includes every settings
76
+ // reload and every PUT through the API. A run that was admitted under an older
77
+ // generation has been superseded: its job object, destination and prompt may all
78
+ // have been replaced on disk, so it must not send, write an anchor, or drain.
79
+ let heartbeatGeneration = 0;
80
+ let heartbeatAbort = null;
53
81
  const pendingJobs = [];
54
82
  const liveDestinationHolds = new Map();
83
+ // What each job's last admitted tick did. Process-local on purpose for now: the
84
+ // durable home is the anchor table and that schema work is its own unit. A record
85
+ // that forgets on restart still beats today's nothing, and the API says so.
86
+ const runRecords = new Map();
87
+ // Jobs observed running without destination-bound authority. Set membership is
88
+ // what makes the notice once-per-job instead of once-per-tick; a warning on every
89
+ // five-minute tick is noise an operator learns to skip past.
90
+ const unenforcedDestinationJobs = new Set();
91
+ /** Environment names the heartbeat script runner must not inherit.
92
+ *
93
+ * The caller's whole design is to hand the child ONE scoped secret, the Slack tool
94
+ * grant activated further down. Spreading `process.env` underneath it handed the
95
+ * same child the raw bot token whenever a channel was configured through the
96
+ * environment, which this repository explicitly supports — and that makes the
97
+ * scoped grant decoration.
98
+ *
99
+ * Matched by PREFIX so a channel variable added later is excluded by default
100
+ * rather than leaked until someone remembers. Deliberately slightly broad:
101
+ * allowlists like SLACK_CHANNEL_IDS go too. A script that genuinely needs one can
102
+ * be given it explicitly; inheriting a credential by accident is what stops. */
103
+ const CHANNEL_SECRET_ENV_PREFIXES = ['SLACK_', 'TELEGRAM_', 'DISCORD_'];
104
+ export function heartbeatScriptEnv(source, extra) {
105
+ const env = {};
106
+ for (const [key, value] of Object.entries(source)) {
107
+ if (value === undefined)
108
+ continue;
109
+ if (CHANNEL_SECRET_ENV_PREFIXES.some(prefix => key.startsWith(prefix)))
110
+ continue;
111
+ env[key] = value;
112
+ }
113
+ // Applied AFTER the filter, so the grant the script actually needs survives it.
114
+ return { ...env, ...extra };
115
+ }
55
116
  function heartbeatJobKey(job) {
56
117
  return String(job.id ?? job.name ?? '');
57
118
  }
@@ -136,8 +197,97 @@ function queueHeartbeatJob(job, reason, policy) {
136
197
  });
137
198
  return true;
138
199
  }
200
+ /** Drop any queued copy of a job that is about to run, or has just run. */
201
+ function forgetPendingJob(jobId) {
202
+ for (let i = pendingJobs.length - 1; i >= 0; i -= 1) {
203
+ if (String(pendingJobs[i]?.job["id"] ?? "") === jobId)
204
+ pendingJobs.splice(i, 1);
205
+ }
206
+ }
207
+ /** The signal a live tick should observe; null until the first run arms one. */
208
+ export function heartbeatRunSignal() {
209
+ return heartbeatAbort?.signal;
210
+ }
139
211
  export function getHeartbeatRuntimeState() {
140
- return pendingSnapshot();
212
+ return {
213
+ ...pendingSnapshot(),
214
+ // Jobs whose last ticks all failed or all refused. They keep their timers —
215
+ // this is visibility, not a kill switch.
216
+ failing: [...runRecords.entries()]
217
+ .filter(([, record]) => isHeartbeatJobFailing(record))
218
+ .map(([jobId]) => jobId),
219
+ // Jobs whose destination-bound Slack grant does not exist for their
220
+ // transport. Reported rather than implied: a Discord or Telegram heartbeat
221
+ // otherwise looks exactly like a guarded Slack one.
222
+ unenforcedDestinations: [...unenforcedDestinationJobs],
223
+ };
224
+ }
225
+ /** The last admitted tick's outcome for a job, or undefined if it has not run in
226
+ * this process. */
227
+ export function getHeartbeatRunRecord(jobId) {
228
+ return runRecords.get(jobId);
229
+ }
230
+ /** Milliseconds until the next boundary of `periodMs` measured from `anchor`.
231
+ *
232
+ * Always inside `(0, periodMs]`. Landing exactly on a boundary returns a FULL
233
+ * period rather than zero, so an arm that happens to coincide with one does not
234
+ * fire immediately and then again a moment later.
235
+ *
236
+ * A clock that stepped BACKWARDS is clamped to one period instead of returning
237
+ * the raw distance to the anchor. The raw distance is unbounded: a machine whose
238
+ * clock jumps back a month would arm a timer for a month, which is not a schedule
239
+ * and is something `setInterval` could never express. */
240
+ export function nextIntervalDelay(anchor, periodMs, now) {
241
+ const elapsed = now - anchor;
242
+ if (elapsed < 0)
243
+ return Math.min(-elapsed, periodMs);
244
+ const remainder = elapsed % periodMs;
245
+ return remainder === 0 ? periodMs : periodMs - remainder;
246
+ }
247
+ /** The instant a job's interval grid is measured from.
248
+ *
249
+ * Exported because the anchor is otherwise unobservable, and "a save did not
250
+ * reset the phase" is exactly the property worth asserting. */
251
+ export function getHeartbeatIntervalAnchor(jobId) {
252
+ return intervalAnchors.get(jobId)?.anchor;
253
+ }
254
+ /** Arm an `every` job on a grid that survives a rebuild.
255
+ *
256
+ * `setInterval` measured from the arm, and `startHeartbeat` re-arms on boot
257
+ * (`server.ts`), on every `PUT /api/heartbeat`, on every `heartbeat.json` write
258
+ * the watcher sees, and on a mention-watch fresh start. A home saved more often
259
+ * than a job's period therefore never reached that job's first fire, and nothing
260
+ * logged it because each arm looked correct on its own.
261
+ *
262
+ * The anchor fixes that: the grid is measured from a fixed instant, so a rebuild
263
+ * resumes the schedule instead of restarting the wait.
264
+ *
265
+ * A period CHANGE re-anchors. Keeping the old origin under a new period would let
266
+ * the next boundary land milliseconds away, so editing 60m to 61m could tick at
267
+ * once — something the old `setInterval` could not do, and not a regression worth
268
+ * trading for this. Same period keeps the grid; a different one starts a new one. */
269
+ function scheduleIntervalJob(job, periodMs) {
270
+ const jobId = String(job["id"]);
271
+ const existing = intervalAnchors.get(jobId);
272
+ const anchor = existing && existing.periodMs === periodMs ? existing.anchor : Date.now();
273
+ intervalAnchors.set(jobId, { anchor, periodMs });
274
+ // Captured, not read live: `runHeartbeatJob` is async, so a teardown can land
275
+ // while it is awaiting. The cron loop needs no equivalent because its
276
+ // `runCurrent` is synchronous and its re-arm cannot be interleaved.
277
+ const generation = heartbeatGeneration;
278
+ const arm = () => {
279
+ const timer = setTimeout(() => {
280
+ if (generation !== heartbeatGeneration)
281
+ return;
282
+ // Re-arm from the same anchor BEFORE running, so a slow turn cannot
283
+ // push the next boundary out.
284
+ arm();
285
+ void runHeartbeatJob(job);
286
+ }, nextIntervalDelay(anchor, periodMs, Date.now()));
287
+ timer.unref?.();
288
+ heartbeatTimers.set(jobId, timer);
289
+ };
290
+ arm();
141
291
  }
142
292
  export function startHeartbeat() {
143
293
  stopHeartbeat();
@@ -171,19 +321,55 @@ export function startHeartbeat() {
171
321
  scheduleCronJob(job);
172
322
  continue;
173
323
  }
174
- const ms = schedule.minutes * 60_000;
175
- const timer = setInterval(() => runHeartbeatJob(job), ms);
176
- timer.unref?.();
177
- heartbeatTimers.set(job.id, timer);
324
+ scheduleIntervalJob(job, schedule.minutes * 60_000);
178
325
  }
326
+ // Per-job maps outlive their jobs otherwise. Keyed off ABSENCE FROM THE FILE
327
+ // rather than `enabled`, because a disabled job that is re-enabled should keep
328
+ // its cadence. `liveDestinationHolds` is keyed by id-or-name, so its live set
329
+ // uses the same derivation instead of assuming every job carries an id.
330
+ const liveIds = new Set(jobs.map(job => String(job?.id ?? '')).filter(Boolean));
331
+ const liveKeys = new Set(jobs.map(job => heartbeatJobKey(job)).filter(Boolean));
332
+ for (const id of [...intervalAnchors.keys()])
333
+ if (!liveIds.has(id))
334
+ intervalAnchors.delete(id);
335
+ for (const id of [...heartbeatCronSlots.keys()])
336
+ if (!liveIds.has(id))
337
+ heartbeatCronSlots.delete(id);
338
+ for (const id of [...runRecords.keys()])
339
+ if (!liveIds.has(id))
340
+ runRecords.delete(id);
341
+ for (const id of [...unenforcedDestinationJobs])
342
+ if (!liveIds.has(id))
343
+ unenforcedDestinationJobs.delete(id);
344
+ for (const key of [...liveDestinationHolds.keys()])
345
+ if (!liveKeys.has(key))
346
+ liveDestinationHolds.delete(key);
179
347
  const n = heartbeatTimers.size;
180
348
  log.info(`[heartbeat] ${n} job${n !== 1 ? 's' : ''} active`);
181
349
  }
182
350
  export function stopHeartbeat() {
351
+ // Tearing down timers was never enough. A tick already inside its try-body
352
+ // kept the job object it was admitted with and went on to send, anchor and
353
+ // write the mention-watch ledger, and the queue it left behind was still
354
+ // drained by whatever finished next, including from the external callers in
355
+ // orchestrator/pipeline, routes/orchestrate, cli/handlers-runtime and
356
+ // agent/spawn/queue. Bump the generation and abort first so an in-flight run
357
+ // stops at its next checkpoint, then drop the queue it would have replayed.
358
+ heartbeatGeneration += 1;
359
+ heartbeatAbort?.abort();
360
+ heartbeatAbort = null;
361
+ pendingJobs.length = 0;
183
362
  for (const timer of heartbeatTimers.values())
184
363
  clearTimeout(timer);
185
364
  heartbeatTimers.clear();
186
- heartbeatCronSlots.clear();
365
+ // Cron slots deliberately survive. startHeartbeatCronLoop runs the current
366
+ // minute immediately on arm, so clearing the map here let a save plus the
367
+ // file watcher rebuild the schedule and fire the same minute again. Keeping
368
+ // the slot means the immediate tick recognises work it already did.
369
+ //
370
+ // Interval anchors survive for the same reason, one level up: they exist so a
371
+ // rebuild resumes a job's grid rather than restarting its wait, and clearing
372
+ // them here would reinstate exactly the defect they close.
187
373
  }
188
374
  export function decideHeartbeatReport(report, policy) {
189
375
  if (policy === 'silent')
@@ -204,7 +390,7 @@ export function runHeartbeatScript(command, extraEnv = {}) {
204
390
  execFile(file, args, {
205
391
  timeout: 10 * 60_000,
206
392
  maxBuffer: 64 * 1024,
207
- env: { ...process.env, ...extraEnv },
393
+ env: heartbeatScriptEnv(process.env, extraEnv),
208
394
  }, (error, stdout, stderr) => {
209
395
  const code = error && typeof error === 'object' && 'code' in error && typeof error.code === 'number' ? error.code : error ? 1 : 0;
210
396
  resolve(parseHeartbeatReport([stdout, stderr].filter(Boolean).join('\n'), code));
@@ -299,7 +485,13 @@ async function runMentionWatchJob(job, watch) {
299
485
  token,
300
486
  selfUserId: getSlackSelfUserId(),
301
487
  allowlist: readSlackAllowlist(sc["channelIds"]),
488
+ answerBudgetMs: MENTION_WATCH_ANSWER_BUDGET_MS,
302
489
  log: (message) => log.info(`[heartbeat:${job["name"]}] ${message}`),
490
+ // The tick already knows how to stop: it checks deps.signal before each
491
+ // answer and reports stoppedBecause 'aborted'. Nothing ever handed it one,
492
+ // so a scan that started before a settings reload kept answering and kept
493
+ // writing receipts under the old configuration.
494
+ ...(heartbeatRunSignal() ? { signal: heartbeatRunSignal() } : {}),
303
495
  // Yield to anything a person is waiting on. Re-read per item because the
304
496
  // previous answer may have taken minutes.
305
497
  yieldNow: (hit) => {
@@ -326,17 +518,31 @@ async function runMentionWatchJob(job, watch) {
326
518
  // now is the session minted: doing it in the guard would create a
327
519
  // permanent, undeletable row for every thread merely looked at.
328
520
  const placement = mentionThreadPlacement(hit, 'mint');
329
- const collected = await sessionLanes.runDetachedTurn(placement.scope, () => orchestrateAndCollectData(prompt, {
330
- origin: 'heartbeat', requestId: crypto.randomUUID(),
331
- scope: placement.scope, chatSessionId: placement.chatSessionId,
332
- }));
333
- const text = applyOutputPolicy(String(collected.text), { scope: 'heartbeat', channel: 'slack' }).text;
334
- const quietConfig = loadPolicyHooksConfig()?.flags?.heartbeatQuietOk;
335
- const extraQuietMarkers = quietConfig?.enabled ? (quietConfig.markers || []) : [];
336
- if (!text.trim() || isHeartbeatQuietOutput(text, extraQuietMarkers))
337
- return null;
338
- answerAnchors.set(hit.channelId + '/' + hit.ts, anchor);
339
- return text;
521
+ const requestId = crypto.randomUUID();
522
+ const target = slackThreadTarget(hit);
523
+ const release = await reserveHeartbeatDestinationGrant({ state: 'bound', target, verification: 'unverified' }, requestId, { scope: placement.scope, chatSessionId: placement.chatSessionId });
524
+ if (!release) {
525
+ log.error(`[heartbeat:${job["name"]}] refuse: slack_grant_unavailable mention-watch answer not run`);
526
+ throw new Error('slack_grant_unavailable');
527
+ }
528
+ try {
529
+ const collected = await sessionLanes.runDetachedTurn(placement.scope, () => orchestrateAndCollectData(prompt, {
530
+ origin: 'heartbeat', requestId,
531
+ scope: placement.scope, chatSessionId: placement.chatSessionId,
532
+ remoteKey: placement.remoteKey,
533
+ target,
534
+ }));
535
+ const text = applyOutputPolicy(String(collected.text), { scope: 'heartbeat', channel: 'slack' }).text;
536
+ const quietConfig = loadPolicyHooksConfig()?.flags?.heartbeatQuietOk;
537
+ const extraQuietMarkers = quietConfig?.enabled ? (quietConfig.markers || []) : [];
538
+ if (!text.trim() || isHeartbeatQuietOutput(text, extraQuietMarkers))
539
+ return null;
540
+ answerAnchors.set(hit.channelId + '/' + hit.ts, anchor);
541
+ return text;
542
+ }
543
+ finally {
544
+ release();
545
+ }
340
546
  },
341
547
  send: async (hit, text) => {
342
548
  const key = hit.channelId + '/' + hit.ts;
@@ -379,7 +585,17 @@ async function runMentionWatchJob(job, watch) {
379
585
  },
380
586
  });
381
587
  const stopped = outcome.stoppedBecause ? ' (stopped: ' + outcome.stoppedBecause + ')' : '';
382
- log.info(`[heartbeat:${job["name"]}] mention watch: ${outcome.answered} answered, ${outcome.quiet} quiet, ${outcome.failed} failed${stopped}`);
588
+ // Counts alone cannot tell a caught-up watch from one still draining, which is
589
+ // the distinction the scanner computes and this line used to discard. Neither
590
+ // flag means backlog on its own: `scanIncomplete` also covers a 429 or an
591
+ // aborted walk, and `hitCapReached` is the case where `scanIncomplete` stays
592
+ // false while whole channels went unread.
593
+ const drain = [
594
+ outcome.scanIncomplete ? 'scan incomplete' : '',
595
+ outcome.hitCapReached ? 'hit cap reached' : '',
596
+ ].filter(Boolean).join(', ');
597
+ log.info(`[heartbeat:${job["name"]}] mention watch: ${outcome.answered} answered, ${outcome.quiet} quiet, `
598
+ + `${outcome.failed} failed${stopped}${drain ? ` — ${drain}` : ' — caught up'}`);
383
599
  return true;
384
600
  }
385
601
  /** Where the answer goes: the thread that carried the mention.
@@ -478,7 +694,7 @@ function buildMentionWatchPrompt(job, watch, hit) {
478
694
  job["prompt"] || '',
479
695
  ].join('\n');
480
696
  }
481
- async function reserveHeartbeatDestinationGrant(binding, requestId) {
697
+ async function reserveHeartbeatDestinationGrant(binding, requestId, activation = { scope: HEARTBEAT_SCOPE, chatSessionId: 'default' }) {
482
698
  if (binding.target.channel !== 'slack')
483
699
  return () => { };
484
700
  const token = String(settings["slack"]?.botToken ?? '').trim();
@@ -491,11 +707,19 @@ async function reserveHeartbeatDestinationGrant(binding, requestId) {
491
707
  destination: binding.target,
492
708
  credentialKey: slackCredentialKey(token),
493
709
  enforceDestination: true,
494
- }, { requestId, scope: HEARTBEAT_SCOPE, chatSessionId: 'default' });
710
+ }, { requestId, scope: activation.scope, chatSessionId: activation.chatSessionId });
495
711
  return reserved ? () => revokeSlackToolGrant(requestId) : null;
496
712
  }
497
713
  export async function runHeartbeatJob(job, deps = {}) {
498
714
  const runner = job["runner"] || 'main';
715
+ const jobId = String(job["id"] ?? job["name"] ?? '');
716
+ // Checked before every other guard. A job already executing must be skipped
717
+ // outright, not queued: queueing it is what produced the back-to-back double
718
+ // report, because the finishing run drains what the queue still holds.
719
+ if (jobId && inFlightJobs.has(jobId)) {
720
+ log.info(`[heartbeat:${job["name"]}] already running, skip`);
721
+ return;
722
+ }
499
723
  if (runner === 'main' && getState('default') !== 'IDLE') {
500
724
  const queued = queueHeartbeatJob(job, 'pabcd_active', 'defer');
501
725
  log.info(`[heartbeat:${job["name"]}] ${queued ? 'deferred' : 'already deferred'} during active PABCD (${pendingJobs.length} pending)`);
@@ -516,6 +740,21 @@ export async function runHeartbeatJob(job, deps = {}) {
516
740
  return;
517
741
  }
518
742
  heartbeatBusy = true;
743
+ // Admission point. From here the run owns this id, and the queue must not
744
+ // keep a copy of it: a deferred entry for this job was satisfied by this very
745
+ // tick. The generation is captured so the finally block can tell whether the
746
+ // schedule was torn down underneath it.
747
+ const generation = heartbeatGeneration;
748
+ if (jobId) {
749
+ inFlightJobs.add(jobId);
750
+ forgetPendingJob(jobId);
751
+ }
752
+ if (!heartbeatAbort)
753
+ heartbeatAbort = new AbortController();
754
+ const startedAt = Date.now();
755
+ // Deliberately `error`: a path that leaves without naming its outcome is a bug,
756
+ // and a record saying so is more useful than one quietly claiming success.
757
+ let outcome = { execution: 'error', delivery: 'not_requested', reason: 'no outcome recorded' };
519
758
  try {
520
759
  // A mention watch replaces the prompt path entirely: its prompt describes
521
760
  // how to answer a message that has not been found yet, so running it bare
@@ -525,9 +764,17 @@ export async function runHeartbeatJob(job, deps = {}) {
525
764
  if (watch != null) {
526
765
  if (!isHeartbeatMentionWatch(watch)) {
527
766
  log.error(`[heartbeat:${job["name"]}] invalid mention watch — not run`);
767
+ outcome = { execution: 'skipped', delivery: 'not_requested', reason: 'invalid_mention_watch' };
528
768
  return;
529
769
  }
530
- await runMentionWatchJob(job, watch);
770
+ // The boolean was already returned and already discarded. It is the only
771
+ // thing that separates a watch that ran from one that refused for a
772
+ // reason of its own — a disabled Slack, a quarantined ledger, an
773
+ // unverifiable workspace — so it stops being thrown away here.
774
+ const ran = await runMentionWatchJob(job, watch);
775
+ outcome = ran
776
+ ? { execution: 'ok', delivery: 'not_requested' }
777
+ : { execution: 'skipped', delivery: 'not_requested', reason: 'mention_watch_not_runnable' };
531
778
  return;
532
779
  }
533
780
  // Resolve and, for a Slack thread, prove the destination BEFORE spending
@@ -541,9 +788,19 @@ export async function runHeartbeatJob(job, deps = {}) {
541
788
  if (destinationBinding.state === 'held') {
542
789
  updateHeartbeatLiveDestinationHold(job, destinationBinding.reason);
543
790
  log.error(`[heartbeat:${job["name"]}] refuse: ${destinationBinding.reason} — ${heartbeatHoldMessage(destinationBinding.reason)}`);
791
+ outcome = { execution: 'skipped', delivery: 'not_requested', reason: destinationBinding.reason };
544
792
  return;
545
793
  }
546
794
  updateHeartbeatLiveDestinationHold(job, null);
795
+ // `reserveHeartbeatDestinationGrant` returns a bare releaser for any
796
+ // non-Slack target, which reads at the call site exactly like a successful
797
+ // reservation. The public contract already says the grant is Slack's; the
798
+ // runtime said nothing, so a Discord tick looked guarded in the logs.
799
+ if (jobId && destinationBinding.target.channel !== 'slack' && !unenforcedDestinationJobs.has(jobId)) {
800
+ unenforcedDestinationJobs.add(jobId);
801
+ log.warn(`[heartbeat:${job["name"]}] ${destinationBinding.target.channel} destination runs without `
802
+ + `destination-bound authority — the enforceDestination grant exists only for Slack`);
803
+ }
547
804
  const schedule = normalizeHeartbeatSchedule(job["schedule"]);
548
805
  const timeZone = getHeartbeatScheduleTimeZone(schedule);
549
806
  const now = formatHeartbeatNow(schedule);
@@ -570,6 +827,7 @@ export async function runHeartbeatJob(job, deps = {}) {
570
827
  const guarded = await withDestinationGuard(requestId => runEmployee(job, prompt, requestId, destinationBinding.target).then(report => report.raw));
571
828
  if (!guarded.ok) {
572
829
  log.error(`[heartbeat:${job["name"]}] refuse: slack_grant_unavailable — employee authority could not be reserved`);
830
+ outcome = { execution: 'skipped', delivery: 'not_requested', reason: 'slack_grant_unavailable' };
573
831
  return;
574
832
  }
575
833
  rawResult = guarded.value;
@@ -588,6 +846,7 @@ export async function runHeartbeatJob(job, deps = {}) {
588
846
  });
589
847
  if (!guarded.ok) {
590
848
  log.error(`[heartbeat:${job["name"]}] refuse: slack_grant_unavailable — script authority could not be reserved`);
849
+ outcome = { execution: 'skipped', delivery: 'not_requested', reason: 'slack_grant_unavailable' };
591
850
  return;
592
851
  }
593
852
  const scriptReport = guarded.value;
@@ -609,6 +868,7 @@ export async function runHeartbeatJob(job, deps = {}) {
609
868
  const first = await collect();
610
869
  if (!first) {
611
870
  log.error(`[heartbeat:${job["name"]}] refuse: slack_grant_unavailable — destination-bound Slack authority could not be reserved`);
871
+ outcome = { execution: 'skipped', delivery: 'not_requested', reason: 'slack_grant_unavailable' };
612
872
  return;
613
873
  }
614
874
  const collected = first.data.agyPlannerOnly === true
@@ -616,6 +876,7 @@ export async function runHeartbeatJob(job, deps = {}) {
616
876
  : first;
617
877
  if (!collected) {
618
878
  log.error(`[heartbeat:${job["name"]}] refuse: slack_grant_unavailable — retry authority could not be reserved`);
879
+ outcome = { execution: 'skipped', delivery: 'not_requested', reason: 'slack_grant_unavailable' };
619
880
  return;
620
881
  }
621
882
  rawResult = String(collected.text);
@@ -625,6 +886,7 @@ export async function runHeartbeatJob(job, deps = {}) {
625
886
  const extraQuietMarkers = quietConfig?.enabled ? (quietConfig.markers || []) : [];
626
887
  if (isHeartbeatQuietOutput(result, extraQuietMarkers)) {
627
888
  log.info(`[heartbeat:${job["name"]}] silent`);
889
+ outcome = { execution: 'ok', delivery: 'suppressed' };
628
890
  return;
629
891
  }
630
892
  const report = parseHeartbeatReport(result);
@@ -642,7 +904,11 @@ export async function runHeartbeatJob(job, deps = {}) {
642
904
  const sendResult = !decision.send
643
905
  ? { ok: true }
644
906
  : await sendChannelOutput({ channel: destinationBinding.target.channel, type: 'text', text: formatted,
645
- target: destinationBinding.target, allowActiveFallback: false });
907
+ target: destinationBinding.target, allowActiveFallback: false })
908
+ // A transport that THROWS is still a delivery failure, not a failed
909
+ // job. Without this it lands in the catch below beside a crashed
910
+ // runner and counts against the execution streak.
911
+ .catch((error) => ({ ok: false, error: error.message }));
646
912
  if (!sendResult.ok) {
647
913
  log.error(`[heartbeat:${job["name"]}] send failed: ${sendResult.error}`);
648
914
  }
@@ -656,18 +922,48 @@ export async function runHeartbeatJob(job, deps = {}) {
656
922
  log.error(`[heartbeat:${job["name"]}] anchor save failed:`, e.message);
657
923
  }
658
924
  }
925
+ // Delivery is decided here, from the decision and the result, rather than
926
+ // hung on a line further up. A job whose policy says stay quiet synthesizes
927
+ // `{ ok: true }` WITHOUT sending, so reading success off that would file a
928
+ // tick that posted nothing as delivered.
929
+ outcome = {
930
+ execution: 'ok',
931
+ delivery: !decision.send ? 'not_requested' : sendResult.ok ? 'delivered' : 'not_delivered',
932
+ ...(sendResult.ok ? {} : { reason: 'send_failed' }),
933
+ };
659
934
  }
660
935
  catch (err) {
661
936
  log.error(`[heartbeat:${job["name"]}] error:`, err.message);
937
+ outcome = { execution: 'error', delivery: 'not_requested', reason: err.message };
662
938
  }
663
939
  finally {
664
940
  heartbeatBusy = false;
665
- await drainPending();
941
+ if (jobId)
942
+ inFlightJobs.delete(jobId);
943
+ if (jobId) {
944
+ const record = foldRunRecord(runRecords.get(jobId), {
945
+ jobId, startedAt, finishedAt: Date.now(),
946
+ superseded: generation !== heartbeatGeneration,
947
+ ...outcome,
948
+ });
949
+ runRecords.set(jobId, record);
950
+ broadcast('heartbeat_run', record);
951
+ }
952
+ // A superseded run must not hand work to the next one. Draining here after
953
+ // stopHeartbeat would replay jobs from a configuration the operator has
954
+ // already replaced, which is exactly what made stop look advisory.
955
+ if (generation === heartbeatGeneration)
956
+ await drainPending();
666
957
  }
667
958
  }
668
959
  export async function drainPending() {
669
960
  if (pendingJobs.length === 0)
670
961
  return;
962
+ // Reachable from orchestrator/pipeline, routes/orchestrate, cli/handlers-runtime
963
+ // and agent/spawn/queue, none of which know whether the schedule is still
964
+ // armed. Without this a stopped heartbeat kept starting jobs.
965
+ if (heartbeatTimers.size === 0)
966
+ return;
671
967
  if (isAgentBusy(HEARTBEAT_SCOPE) || messageQueue.length > 0 || hasPendingWorkerReplays(HEARTBEAT_SCOPE))
672
968
  return;
673
969
  const next = pendingJobs.shift()?.job;