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.
- package/dist/src/agent/alert-escalation.js +5 -1
- package/dist/src/agent/alert-escalation.js.map +1 -1
- package/dist/src/browser/web-ai/notifications.js +2 -0
- package/dist/src/browser/web-ai/notifications.js.map +1 -1
- package/dist/src/manager/reminders/dispatcher.js +5 -1
- package/dist/src/manager/reminders/dispatcher.js.map +1 -1
- package/dist/src/memory/heartbeat-destination.js +9 -3
- package/dist/src/memory/heartbeat-destination.js.map +1 -1
- package/dist/src/memory/heartbeat-mention-watch.js +14 -0
- package/dist/src/memory/heartbeat-mention-watch.js.map +1 -1
- package/dist/src/memory/heartbeat-run-record.js +56 -0
- package/dist/src/memory/heartbeat-run-record.js.map +1 -0
- package/dist/src/memory/heartbeat.js +320 -24
- package/dist/src/memory/heartbeat.js.map +1 -1
- package/dist/src/messaging/send.js +10 -5
- package/dist/src/messaging/send.js.map +1 -1
- package/dist/src/routes/heartbeat.js +12 -2
- package/dist/src/routes/heartbeat.js.map +1 -1
- package/dist/src/slack/action-runtime.js +15 -8
- package/dist/src/slack/action-runtime.js.map +1 -1
- package/dist/src/slack/mention-watch.js +8 -2
- package/dist/src/slack/mention-watch.js.map +1 -1
- package/dist/src/slack/quote.js +11 -4
- package/dist/src/slack/quote.js.map +1 -1
- package/package.json +1 -1
- package/scripts/promote-to-main.sh +147 -49
- package/scripts/promotion-state.mjs +133 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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;
|