@inetafrica/open-claudia 3.4.23 → 3.5.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.
- package/CHANGELOG.md +50 -0
- package/bin/agent.js +4 -0
- package/bin/cli.js +10 -1
- package/bin/entity.js +17 -15
- package/bin/ideas.js +10 -1
- package/bin/keyring.js +19 -18
- package/bin/lessons.js +10 -1
- package/bin/loopback-client.js +46 -1
- package/bin/owner-gate.js +38 -0
- package/bin/pack.js +11 -1
- package/bin/schedule.js +45 -11
- package/bin/tool.js +77 -49
- package/bin/transcript-search.js +42 -8
- package/bin/transcript-window.js +31 -2
- package/core/actions.js +15 -4
- package/core/approvals.js +28 -2
- package/core/grants.js +156 -27
- package/core/handlers.js +8 -2
- package/core/loopback.js +237 -71
- package/core/providers/claude-hook.js +5 -0
- package/core/runner.js +47 -2
- package/core/scheduler.js +307 -37
- package/core/speaker-attest.js +82 -1
- package/core/tool-guard.js +380 -11
- package/package.json +2 -2
- package/project-transcripts.js +25 -0
- package/test-approval-async.js +22 -0
- package/test-approval-cards.js +40 -0
- package/test-provider-compaction.js +93 -0
- package/test-provider-language.js +15 -1
- package/test-provider-pack-review.js +25 -2
- package/test-provider-parity-e2e.js +5 -3
- package/test-provider-scheduler.js +504 -3
- package/test-provider-tool-hooks.js +11 -0
- package/test-run-lock.js +2 -0
- package/test-tool-guard.js +12 -3
- package/test-tool-manifest.js +31 -0
package/core/scheduler.js
CHANGED
|
@@ -48,6 +48,18 @@ const BUSY_DELAY_MS = 30 * 1000;
|
|
|
48
48
|
const BUSY_NOTICE_AFTER_MS = 15 * 60 * 1000;
|
|
49
49
|
// A watch's `check` command is capped so a hung poll can't wedge the tick.
|
|
50
50
|
const WATCH_CHECK_TIMEOUT_MS = 30 * 1000;
|
|
51
|
+
// A hanging check is itself a signal (24 Sep: the Phone health check timed out
|
|
52
|
+
// twice and was only logged idle). This many timeouts in a row wake the agent
|
|
53
|
+
// once, marked "check timing out"; the watch keeps polling, and any completed
|
|
54
|
+
// check resets the count.
|
|
55
|
+
const WATCH_TIMEOUT_ALARM_AFTER = 3;
|
|
56
|
+
// setTimeout holds at most 2^31-1 ms (~24.8 days); a longer delay overflows
|
|
57
|
+
// and fires at once (17 Sep: a 2028 reminder fired 61 s after it was set).
|
|
58
|
+
// Far wakeups sleep in chunks of this size and re-plan until due.
|
|
59
|
+
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1 - 1000;
|
|
60
|
+
// schedule-wakeup refuses a fire time further out than this: past it the
|
|
61
|
+
// <when> is a typo or a mis-parse (one wakeup was set 1273 years out).
|
|
62
|
+
const MAX_WAKEUP_HORIZON_MS = 10 * 365.25 * 24 * 60 * 60 * 1000;
|
|
51
63
|
// Every watch carries a hard expiry so none can idle-poll forever: when the
|
|
52
64
|
// deadline lapses with the check still failing, the next poll tick fires the
|
|
53
65
|
// wake anyway (marked expired) instead of skipping silently.
|
|
@@ -61,6 +73,84 @@ function uniqueId(prefix = "occ") {
|
|
|
61
73
|
: `${prefix}-${Date.now()}-${crypto.randomBytes(6).toString("hex")}`;
|
|
62
74
|
}
|
|
63
75
|
|
|
76
|
+
// ── watch-check environment ────────────────────────────────────────────────
|
|
77
|
+
// A watch's `check` runs a shell command. It used to inherit `env:
|
|
78
|
+
// process.env` — every secret the bot process holds (provider credentials,
|
|
79
|
+
// bot tokens, the loopback token), so a staff member's check could read them
|
|
80
|
+
// all. Build the env explicitly instead: keep the harmless shell essentials
|
|
81
|
+
// and the Open Claudia config dir (so `open-claudia ...` resolves its config),
|
|
82
|
+
// then drop anything that looks like a credential (a broad denylist pattern)
|
|
83
|
+
// plus the known-secret names the provider env-isolation layer already
|
|
84
|
+
// strips. A short-lived, creator-scoped run is stamped on top (below) so the
|
|
85
|
+
// check's reusable tools still attribute to whoever created the watch.
|
|
86
|
+
const WATCH_CHECK_ENV_KEEP = new Set([
|
|
87
|
+
"PATH", "HOME", "USER", "LOGNAME", "SHELL",
|
|
88
|
+
"LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ",
|
|
89
|
+
"TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR",
|
|
90
|
+
"TMPDIR", "TMP", "TEMP",
|
|
91
|
+
"OPEN_CLAUDIA_CONFIG_DIR",
|
|
92
|
+
"SystemRoot", "ComSpec", "PATHEXT", "WINDIR",
|
|
93
|
+
]);
|
|
94
|
+
const WATCH_CHECK_SECRET_PATTERN = /(TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|APIKEY|OAUTH|CREDENTIAL|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|CLIENT[_-]?SECRET|SESSION[_-]?KEY|BEARER)/i;
|
|
95
|
+
let KNOWN_SECRET_ENV_KEYS = null;
|
|
96
|
+
function knownSecretEnvKeys() {
|
|
97
|
+
if (KNOWN_SECRET_ENV_KEYS) return KNOWN_SECRET_ENV_KEYS;
|
|
98
|
+
const set = new Set();
|
|
99
|
+
try { for (const key of require("./providers/env").ALWAYS_STRIPPED_KEYS) set.add(key); } catch (_) {}
|
|
100
|
+
KNOWN_SECRET_ENV_KEYS = set;
|
|
101
|
+
return set;
|
|
102
|
+
}
|
|
103
|
+
function isSecretCheckEnvKey(key) {
|
|
104
|
+
if (WATCH_CHECK_ENV_KEEP.has(key)) return false;
|
|
105
|
+
if (knownSecretEnvKeys().has(key)) return true;
|
|
106
|
+
return WATCH_CHECK_SECRET_PATTERN.test(key);
|
|
107
|
+
}
|
|
108
|
+
function buildWatchCheckEnv(source, stamp = {}) {
|
|
109
|
+
const env = {};
|
|
110
|
+
for (const [key, value] of Object.entries(source || {})) {
|
|
111
|
+
if (value == null) continue;
|
|
112
|
+
if (isSecretCheckEnvKey(key)) continue;
|
|
113
|
+
env[key] = String(value);
|
|
114
|
+
}
|
|
115
|
+
for (const [key, value] of Object.entries(stamp)) {
|
|
116
|
+
if (value == null || value === "") continue;
|
|
117
|
+
env[key] = String(value);
|
|
118
|
+
}
|
|
119
|
+
return env;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The principal a watch-check's short-lived run acts for: the job's creator,
|
|
123
|
+
// resolved from its stored channel/user the same way runPrincipal resolves a
|
|
124
|
+
// turn's speaker. Owner → the check attributes as the owner (unchanged); a
|
|
125
|
+
// staff creator → the check runs at their level. Fail closed to non-owner.
|
|
126
|
+
function watchCheckPrincipal(job) {
|
|
127
|
+
const adapterType = job && job.adapterType ? String(job.adapterType) : null;
|
|
128
|
+
const channelId = job && job.channelId != null ? String(job.channelId) : null;
|
|
129
|
+
const userId = job && job.userId != null ? String(job.userId) : null;
|
|
130
|
+
let owner = false;
|
|
131
|
+
let name = null;
|
|
132
|
+
let room = null;
|
|
133
|
+
if (adapterType && channelId) {
|
|
134
|
+
try {
|
|
135
|
+
const sp = require("./relationship").speakerFor(adapterType, channelId, userId);
|
|
136
|
+
owner = !!(sp && sp.isOwner === true);
|
|
137
|
+
name = (sp && sp.name) || null;
|
|
138
|
+
room = (sp && sp.room) || null;
|
|
139
|
+
} catch (_) { owner = false; }
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
kind: "watch-check",
|
|
143
|
+
owner,
|
|
144
|
+
canonicalUserId: job && job.canonicalUserId ? String(job.canonicalUserId) : null,
|
|
145
|
+
name,
|
|
146
|
+
adapterId: job && job.adapter ? String(job.adapter) : null,
|
|
147
|
+
adapterType,
|
|
148
|
+
channelId,
|
|
149
|
+
userId,
|
|
150
|
+
room,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
64
154
|
function parseFallbacks(value) {
|
|
65
155
|
if (Array.isArray(value)) return [...new Set(value.map((item) => String(item).trim().toLowerCase()).filter(Boolean))];
|
|
66
156
|
return [...new Set(String(value || "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean))];
|
|
@@ -75,9 +165,25 @@ function terminalRunSucceeded(result) {
|
|
|
75
165
|
|
|
76
166
|
// A one-shot fires exactly once then is removed after a successful run: a
|
|
77
167
|
// wakeup, or a watch (a cron carrying a shell `check`) created without repeat
|
|
78
|
-
// polling. A repeat watch stays pending and keeps polling like any cron
|
|
168
|
+
// polling. A repeat watch stays pending and keeps polling like any cron, and
|
|
169
|
+
// so does any watch whose fire only reported a hanging check.
|
|
79
170
|
function isOneShot(job) {
|
|
80
|
-
return job.kind === "wakeup" || (!!job.check && !job.repeat);
|
|
171
|
+
return job.kind === "wakeup" || (!!job.check && !job.repeat && !job.watchCheckTimingOut);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A repeating watch keeps polling on its schedule while a triggered delivery
|
|
175
|
+
// waits for a busy room, waits for a retry after a failed or /stop-killed run,
|
|
176
|
+
// or runs. New hits fold into that one delivery. Everything else parks on its
|
|
177
|
+
// retry timer alone (24 Sep: the event-bus watch went unpolled for 7.5 h).
|
|
178
|
+
function pollsWhilePending(job) {
|
|
179
|
+
return !!job?.check && !!job.repeat;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The watch's trigger is latched and its delivery has not completed.
|
|
183
|
+
function deliveryPending(job) {
|
|
184
|
+
return pollsWhilePending(job)
|
|
185
|
+
&& job.watchTriggeredOccurrenceId === job.occurrenceId
|
|
186
|
+
&& ["deferred", "failed", "running"].includes(job.status);
|
|
81
187
|
}
|
|
82
188
|
|
|
83
189
|
function providerIsRunnable(status) {
|
|
@@ -127,23 +233,49 @@ function createSchedulerService(dependencies = {}) {
|
|
|
127
233
|
const timers = new Map();
|
|
128
234
|
const checksInFlight = new Set();
|
|
129
235
|
const firesInFlight = new Set();
|
|
236
|
+
// Jobs already on disk before the creator-binding fix carry no `createdBy`.
|
|
237
|
+
// We keep running them AS THEY ARE (their stored channel/user is what the
|
|
238
|
+
// fire path resolves the speaker from — the owner's own watches and wakeups
|
|
239
|
+
// must not silently break), but log the first time each fires so an audit
|
|
240
|
+
// can spot a pre-fix job that outranks a non-owner. Logged once per id/boot.
|
|
241
|
+
const loggedLegacyCreators = new Set();
|
|
242
|
+
function noteLegacyCreatorOnce(job) {
|
|
243
|
+
if (!job || job.createdBy || loggedLegacyCreators.has(job.id)) return;
|
|
244
|
+
loggedLegacyCreators.add(job.id);
|
|
245
|
+
try {
|
|
246
|
+
console.warn(`[scheduler] job ${job.id} (${job.kind}${job.check ? "/watch" : ""}) predates creator binding — running with its stored identity ${job.canonicalUserId || "?"} in ${job.adapterType || "?"}:${job.channelId || "?"}`);
|
|
247
|
+
} catch (_) { /* logging is best-effort */ }
|
|
248
|
+
}
|
|
130
249
|
// Poll a watch's `check`: exit 0 → the watched condition is met, wake the
|
|
131
250
|
// agent; non-zero → still idle, skip silently. Runs in the job's project
|
|
132
|
-
// dir with
|
|
251
|
+
// dir with a HARDENED env — the bot's secrets are dropped (buildWatchCheckEnv)
|
|
252
|
+
// and a short-lived run is registered for the job's creator so reusable
|
|
253
|
+
// tools attribute correctly — output discarded (exit code is the contract),
|
|
133
254
|
// hard-capped by WATCH_CHECK_TIMEOUT_MS. Injectable so tests stay hermetic.
|
|
134
255
|
const runWatchCheck = dependencies.runWatchCheck || ((command, job) => new Promise((resolve) => {
|
|
135
256
|
const cwd = (job && projectDescriptor(job).dir) || workspace || process.cwd();
|
|
257
|
+
const attest = require("./speaker-attest");
|
|
258
|
+
const checkRunId = uniqueId("watch-check");
|
|
259
|
+
let runAttest = "";
|
|
260
|
+
try { runAttest = attest.registerRun(checkRunId, watchCheckPrincipal(job)); } catch (_) { runAttest = ""; }
|
|
261
|
+
let lb = null;
|
|
262
|
+
try { lb = require("./loopback").info(); } catch (_) { lb = null; }
|
|
263
|
+
const stamp = { OC_RUN_ID: checkRunId, OC_RUN_ATTEST: runAttest };
|
|
264
|
+
if (lb && lb.url) { stamp.OC_SEND_URL = lb.url; stamp.OC_SEND_TOKEN = lb.token; }
|
|
265
|
+
const env = buildWatchCheckEnv(process.env, stamp);
|
|
136
266
|
let settled = false;
|
|
137
267
|
const finish = (code, timedOut) => {
|
|
138
268
|
if (settled) return;
|
|
139
269
|
settled = true;
|
|
140
270
|
try { clearTimer(timer); } catch (_) {}
|
|
271
|
+
try { attest.endRun(checkRunId); } catch (_) {}
|
|
141
272
|
resolve({ code, timedOut: !!timedOut });
|
|
142
273
|
};
|
|
143
274
|
let child;
|
|
144
275
|
try {
|
|
145
|
-
child = spawn("sh", ["-c", String(command)], { cwd, env
|
|
276
|
+
child = spawn("sh", ["-c", String(command)], { cwd, env, stdio: "ignore" });
|
|
146
277
|
} catch (error) {
|
|
278
|
+
try { attest.endRun(checkRunId); } catch (_) {}
|
|
147
279
|
return resolve({ code: 127, timedOut: false });
|
|
148
280
|
}
|
|
149
281
|
const timer = setTimer(() => { try { child.kill("SIGKILL"); } catch (_) {} finish(124, true); }, WATCH_CHECK_TIMEOUT_MS);
|
|
@@ -288,16 +420,37 @@ function createSchedulerService(dependencies = {}) {
|
|
|
288
420
|
return null;
|
|
289
421
|
}
|
|
290
422
|
|
|
423
|
+
// What a repeating watch's polls saw while its delivery waited.
|
|
424
|
+
function pendingPollNote(job) {
|
|
425
|
+
const hits = Number(job.watchExtraHits) || 0;
|
|
426
|
+
const lastHitAt = Number(job.watchLastHitAt) || 0;
|
|
427
|
+
const lastClearAt = Number(job.watchLastClearAt) || 0;
|
|
428
|
+
if (!hits && !lastClearAt) return null;
|
|
429
|
+
const at = (ms) => new Date(ms).toISOString();
|
|
430
|
+
const seen = [];
|
|
431
|
+
if (hits) seen.push(`the check passed ${hits} more time${hits === 1 ? "" : "s"}, the latest at ${at(lastHitAt)}`);
|
|
432
|
+
seen.push(lastClearAt > lastHitAt
|
|
433
|
+
? `its latest run, at ${at(lastClearAt)}, did not pass, so the condition may have cleared`
|
|
434
|
+
: "the condition was still true at the latest check");
|
|
435
|
+
return `[This watch kept polling while this delivery waited (queued at ${at(Number(job.watchTriggeredAt) || now())}): ${seen.join("; ")}.]`;
|
|
436
|
+
}
|
|
437
|
+
|
|
291
438
|
function wrappedPrompt(job, target) {
|
|
292
439
|
const firedAt = new Date(now()).toISOString();
|
|
293
440
|
const expired = !!(job.check && job.watchExpired);
|
|
441
|
+
const timingOut = !!(job.check && !expired && job.watchCheckTimingOut);
|
|
294
442
|
const header = job.check
|
|
295
|
-
? (expired ? "Watch expired" : "Watch triggered")
|
|
443
|
+
? (expired ? "Watch expired" : timingOut ? "Watch check timing out" : "Watch triggered")
|
|
296
444
|
: job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
|
|
297
445
|
const lines = [`[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`];
|
|
298
446
|
if (expired) {
|
|
299
447
|
lines.push("[This watch reached its timeout without its check ever passing — the watched condition did NOT happen. This is a deadline wake-up, not a trigger: investigate why the condition never became true, and arm a new watch if it is still worth waiting for.]");
|
|
300
448
|
}
|
|
449
|
+
if (timingOut) {
|
|
450
|
+
lines.push(`[This watch's check timed out ${WATCH_TIMEOUT_ALARM_AFTER} times in a row (each run is capped at ${WATCH_CHECK_TIMEOUT_MS / 1000} s), so the watched condition could not be read. That is itself a signal: find out why the check hangs. This is not a trigger, and the watch keeps polling.]`);
|
|
451
|
+
}
|
|
452
|
+
const polled = job.check ? pendingPollNote(job) : null;
|
|
453
|
+
if (polled) lines.push(polled);
|
|
301
454
|
if (target.fallbackFrom) {
|
|
302
455
|
lines.push(`[Archived provider-neutral brief: the originating ${target.fallbackFrom} provider is unavailable. Its native session is not being resumed; this explicitly configured fallback starts fresh.]`);
|
|
303
456
|
} else if (target.revertedFrom) {
|
|
@@ -317,14 +470,36 @@ function createSchedulerService(dependencies = {}) {
|
|
|
317
470
|
timers.delete(id);
|
|
318
471
|
}
|
|
319
472
|
|
|
473
|
+
// Drop only a job's delivery (retry) timer; a watch's poll schedule stays.
|
|
474
|
+
function clearDeliveryTimer(id) {
|
|
475
|
+
const timer = timers.get(id);
|
|
476
|
+
if (!timer?.timeout) return;
|
|
477
|
+
try { clearTimer(timer.timeout); } catch (_) {}
|
|
478
|
+
if (timer.stop) timers.set(id, { stop: timer.stop });
|
|
479
|
+
else timers.delete(id);
|
|
480
|
+
}
|
|
481
|
+
|
|
320
482
|
function armRetry(job, delay = null) {
|
|
321
|
-
unscheduleJob(job.id);
|
|
322
|
-
if (job
|
|
483
|
+
if (job.disabled || job.status === "dead-letter" || job.status === "succeeded") return unscheduleJob(job.id);
|
|
484
|
+
if (pollsWhilePending(job)) clearDeliveryTimer(job.id);
|
|
485
|
+
else unscheduleJob(job.id);
|
|
323
486
|
const wait = delay === null
|
|
324
487
|
? Math.max(0, Number(job.nextAttemptAt || now()) - now())
|
|
325
488
|
: Math.max(0, delay);
|
|
326
489
|
const timeout = setTimer(() => fireJob(job.id, { reason: "retry" }), wait);
|
|
327
|
-
timers.set(job.id, { timeout });
|
|
490
|
+
timers.set(job.id, { ...timers.get(job.id), timeout });
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// A jobs-store write that throws (a full disk, a torn journal) must not
|
|
494
|
+
// strand a queued delivery: the retry stays armed in memory and the next
|
|
495
|
+
// attempt re-reads the store. Such a throw used to return with no timer,
|
|
496
|
+
// so the delivery was never tried again (21 and 23 Sep: watch alarms that
|
|
497
|
+
// triggered during long turns were never delivered).
|
|
498
|
+
function keepRetrying(job, error, delay = retryDelayMs) {
|
|
499
|
+
const failure = stableFailure(error);
|
|
500
|
+
try { armRetry(job, delay); } catch (_) {}
|
|
501
|
+
if (job.check) logWatch(job, `delivery bookkeeping failed (${failure.code}) — retrying in ${Math.round(delay / 1000)} s`);
|
|
502
|
+
return { ok: false, status: "failed", error: failure };
|
|
328
503
|
}
|
|
329
504
|
|
|
330
505
|
function markFailure(job, failure, options = {}) {
|
|
@@ -367,7 +542,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
367
542
|
armRetry(updated, busyDelayMs);
|
|
368
543
|
if (noticeDue) {
|
|
369
544
|
const minutes = Math.floor(waited / 60000);
|
|
370
|
-
const what = updated.watchExpired ? "timed out" : "triggered";
|
|
545
|
+
const what = updated.watchExpired ? "timed out" : updated.watchCheckTimingOut ? "raised a check-timeout alarm" : "triggered";
|
|
371
546
|
try {
|
|
372
547
|
Promise.resolve(send(`⏳ Watch "${updated.label}" ${what} ${minutes} min ago and is waiting for the current run to end. /stop ends that run so the watch can go.`)).catch(() => {});
|
|
373
548
|
} catch (_) { /* a notice only */ }
|
|
@@ -416,6 +591,10 @@ function createSchedulerService(dependencies = {}) {
|
|
|
416
591
|
watchTriggeredOccurrenceId: null,
|
|
417
592
|
watchTriggeredAt: null,
|
|
418
593
|
watchExpired: false,
|
|
594
|
+
watchCheckTimingOut: false,
|
|
595
|
+
watchExtraHits: 0,
|
|
596
|
+
watchLastHitAt: null,
|
|
597
|
+
watchLastClearAt: null,
|
|
419
598
|
advisoryOccurrenceId: null,
|
|
420
599
|
});
|
|
421
600
|
if (reset && timers.get(job.id)?.timeout) scheduleJob(reset);
|
|
@@ -440,6 +619,10 @@ function createSchedulerService(dependencies = {}) {
|
|
|
440
619
|
return { ok: false, status: job.status, skipped: true };
|
|
441
620
|
}
|
|
442
621
|
if (job.status === "running" && Number(job.nextAttemptAt || 0) > now()) {
|
|
622
|
+
// fireJob already turned away a fire in flight here, so this lease was
|
|
623
|
+
// left by an earlier process: look again when it lapses. Returning with
|
|
624
|
+
// no timer stranded the job until the next restart.
|
|
625
|
+
armRetry(job);
|
|
443
626
|
return { ok: false, status: "running", skipped: true, inFlight: true };
|
|
444
627
|
}
|
|
445
628
|
if (job.status === "succeeded" && isOneShot(job)) {
|
|
@@ -469,15 +652,28 @@ function createSchedulerService(dependencies = {}) {
|
|
|
469
652
|
// persisted before expiry existed (no expiresAt) keep polling forever.
|
|
470
653
|
const expiresAt = Number(job.expiresAt || 0);
|
|
471
654
|
if (!(expiresAt > 0 && now() >= expiresAt)) {
|
|
472
|
-
|
|
473
|
-
|
|
655
|
+
// Timeouts in a row; a completed check resets the count. Only the
|
|
656
|
+
// WATCH_TIMEOUT_ALARM_AFTER-th wakes the agent, so a check that stays
|
|
657
|
+
// hung alarms once rather than on every later poll.
|
|
658
|
+
const timedOut = !!check?.timedOut;
|
|
659
|
+
const previous = Number(job.watchTimeoutStreak) || 0;
|
|
660
|
+
const streak = timedOut ? previous + 1 : 0;
|
|
661
|
+
if (streak !== WATCH_TIMEOUT_ALARM_AFTER) {
|
|
662
|
+
if (streak !== previous) {
|
|
663
|
+
try { jobsStore.update(job.id, { watchTimeoutStreak: streak }); } catch (_) { /* best effort */ }
|
|
664
|
+
}
|
|
665
|
+
logWatch(job, timedOut ? `idle (check timed out, ${streak} in a row)` : `idle (check exit ${check ? check.code : "?"})`);
|
|
666
|
+
return { ok: false, status: "watch-idle", skipped: true };
|
|
667
|
+
}
|
|
668
|
+
job = { ...job, watchExpired: false, watchCheckTimingOut: true, watchTimeoutStreak: streak };
|
|
669
|
+
} else {
|
|
670
|
+
// Expiry ends the watch's window: one-shot removal even for --repeat
|
|
671
|
+
// (the woken agent re-arms if still wanted). Persisted so a retry of a
|
|
672
|
+
// failed expiry fire keeps the same semantics across restarts.
|
|
673
|
+
job = { ...job, repeat: false, watchExpired: true, watchCheckTimingOut: false };
|
|
474
674
|
}
|
|
475
|
-
// Expiry ends the watch's window: one-shot removal even for --repeat
|
|
476
|
-
// (the woken agent re-arms if still wanted). Persisted so a retry of a
|
|
477
|
-
// failed expiry fire keeps the same semantics across restarts.
|
|
478
|
-
job = { ...job, repeat: false, watchExpired: true };
|
|
479
675
|
} else {
|
|
480
|
-
job = { ...job, watchExpired: false };
|
|
676
|
+
job = { ...job, watchExpired: false, watchCheckTimingOut: false, watchTimeoutStreak: 0 };
|
|
481
677
|
}
|
|
482
678
|
// Latch the condition before admission or notification. Delivery retries
|
|
483
679
|
// and restarts must not run the check again or change an expiry's meaning.
|
|
@@ -487,6 +683,8 @@ function createSchedulerService(dependencies = {}) {
|
|
|
487
683
|
nextAttemptAt: now(),
|
|
488
684
|
repeat: job.repeat,
|
|
489
685
|
watchExpired: job.watchExpired,
|
|
686
|
+
watchCheckTimingOut: job.watchCheckTimingOut,
|
|
687
|
+
watchTimeoutStreak: Number(job.watchTimeoutStreak) || 0,
|
|
490
688
|
watchTriggeredOccurrenceId: job.occurrenceId,
|
|
491
689
|
watchTriggeredAt: now(),
|
|
492
690
|
});
|
|
@@ -495,13 +693,15 @@ function createSchedulerService(dependencies = {}) {
|
|
|
495
693
|
} catch (error) {
|
|
496
694
|
return { ok: false, status: "failed", error: stableFailure(error) };
|
|
497
695
|
}
|
|
498
|
-
logWatch(job, job.watchExpired ? "expired without firing — delivery queued"
|
|
696
|
+
logWatch(job, job.watchExpired ? "expired without firing — delivery queued"
|
|
697
|
+
: job.watchCheckTimingOut ? `check timed out ${WATCH_TIMEOUT_ALARM_AFTER} times in a row — delivery queued`
|
|
698
|
+
: "triggered (check passed) — delivery queued");
|
|
499
699
|
}
|
|
500
700
|
|
|
501
701
|
const adapter = resolveAdapter(job);
|
|
502
702
|
if (!adapter) {
|
|
503
703
|
try { return markFailure(job, { code: "SCHEDULED_ADAPTER_UNAVAILABLE", message: "Saved channel adapter is unavailable" }); }
|
|
504
|
-
catch (error) { return
|
|
704
|
+
catch (error) { return keepRetrying(job, error); }
|
|
505
705
|
}
|
|
506
706
|
const canonicalUserId = job.canonicalUserId || canonicalForChannel(adapter.type, job.channelId);
|
|
507
707
|
const userId = String(job.userId || (adapter.type === "telegram" ? job.channelId : canonicalUserId || job.channelId || ""));
|
|
@@ -528,10 +728,11 @@ function createSchedulerService(dependencies = {}) {
|
|
|
528
728
|
};
|
|
529
729
|
|
|
530
730
|
return runInChat(chat, async () => {
|
|
731
|
+
noteLegacyCreatorOnce(job);
|
|
531
732
|
const state = stateForUser(canonicalUserId);
|
|
532
733
|
if (state.runningProcess || state.preparingRun || state.isCompacting) {
|
|
533
734
|
try { return markBusy(job); }
|
|
534
|
-
catch (error) { return
|
|
735
|
+
catch (error) { return keepRetrying(job, error, busyDelayMs); }
|
|
535
736
|
}
|
|
536
737
|
|
|
537
738
|
const target = executionTarget(job, state);
|
|
@@ -541,7 +742,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
541
742
|
code: "SCHEDULED_PROVIDER_UNAVAILABLE",
|
|
542
743
|
message: `Saved provider ${job.provider} is unavailable and no explicit fallback is configured`,
|
|
543
744
|
});
|
|
544
|
-
} catch (error) { return
|
|
745
|
+
} catch (error) { return keepRetrying(job, error); }
|
|
545
746
|
}
|
|
546
747
|
|
|
547
748
|
const project = projectDescriptor(job);
|
|
@@ -559,8 +760,14 @@ function createSchedulerService(dependencies = {}) {
|
|
|
559
760
|
scheduledJobId: job.id,
|
|
560
761
|
originRunId: job.originRunId || null,
|
|
561
762
|
};
|
|
562
|
-
// Admission is synchronous and precedes all notification delivery.
|
|
563
|
-
|
|
763
|
+
// Admission is synchronous and precedes all notification delivery. A
|
|
764
|
+
// refusal is a failed attempt, never a rejection that drops the job.
|
|
765
|
+
let runContext;
|
|
766
|
+
try { runContext = admit(prompt, project.dir, null, runOptions); }
|
|
767
|
+
catch (error) {
|
|
768
|
+
try { return markFailure(job, error); }
|
|
769
|
+
catch (persistError) { return keepRetrying(job, persistError); }
|
|
770
|
+
}
|
|
564
771
|
|
|
565
772
|
let running;
|
|
566
773
|
try {
|
|
@@ -575,11 +782,13 @@ function createSchedulerService(dependencies = {}) {
|
|
|
575
782
|
});
|
|
576
783
|
if (!running) throw new Error("scheduled attempt could not be persisted");
|
|
577
784
|
} catch (error) {
|
|
578
|
-
return
|
|
785
|
+
return keepRetrying(job, error);
|
|
579
786
|
}
|
|
580
787
|
|
|
581
788
|
const advisory = job.check
|
|
582
|
-
? (job.watchExpired ? `⏰ Watch "${job.label}" timed out without triggering — waking anyway`
|
|
789
|
+
? (job.watchExpired ? `⏰ Watch "${job.label}" timed out without triggering — waking anyway`
|
|
790
|
+
: job.watchCheckTimingOut ? `⚠️ Watch "${job.label}": its check timed out ${WATCH_TIMEOUT_ALARM_AFTER} times in a row`
|
|
791
|
+
: `🔔 Watch "${job.label}" triggered`)
|
|
583
792
|
: `Wakeup: ${job.label}`;
|
|
584
793
|
// Say so when the run is NOT on the provider this room is set to: an
|
|
585
794
|
// unexplained error from a provider the room never selected is worse than
|
|
@@ -601,16 +810,70 @@ function createSchedulerService(dependencies = {}) {
|
|
|
601
810
|
try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
|
|
602
811
|
catch (error) {
|
|
603
812
|
try { return markFailure(running, error, { attemptAlreadyCounted: true }); }
|
|
604
|
-
catch (persistError) { return
|
|
813
|
+
catch (persistError) { return keepRetrying(running, persistError); }
|
|
605
814
|
}
|
|
606
815
|
if (!terminalRunSucceeded(result)) {
|
|
607
816
|
try { return markFailure(running, result, { attemptAlreadyCounted: true }); }
|
|
608
|
-
catch (error) { return {
|
|
817
|
+
catch (error) { return { ...keepRetrying(running, error), result }; }
|
|
609
818
|
}
|
|
610
819
|
return settleSuccess(running, result, target);
|
|
611
820
|
});
|
|
612
821
|
}
|
|
613
822
|
|
|
823
|
+
// Poll a repeating watch whose delivery is still pending. Nothing new is
|
|
824
|
+
// queued: a hit is counted into that delivery (its prompt says so), a
|
|
825
|
+
// completed miss is noted as a possible clear, a timeout is only logged.
|
|
826
|
+
async function pollPendingWatch(job) {
|
|
827
|
+
if (!watchesEnabled()) {
|
|
828
|
+
logWatch(job, "skipped (watches disabled globally)");
|
|
829
|
+
return { ok: false, status: "watch-disabled", skipped: true };
|
|
830
|
+
}
|
|
831
|
+
if (checksInFlight.has(job.id)) return { ok: false, status: "watch-checking", skipped: true };
|
|
832
|
+
checksInFlight.add(job.id);
|
|
833
|
+
let check;
|
|
834
|
+
try { check = await runWatchCheck(job.check, job); }
|
|
835
|
+
catch (_) { check = { code: 1, timedOut: false }; }
|
|
836
|
+
finally { checksInFlight.delete(job.id); }
|
|
837
|
+
try {
|
|
838
|
+
const latest = jobsStore.get(job.id);
|
|
839
|
+
// The delivery may have finished while the check ran; the result then
|
|
840
|
+
// belongs to no pending occurrence.
|
|
841
|
+
if (!latest || latest.occurrenceId !== job.occurrenceId || !deliveryPending(latest)) {
|
|
842
|
+
return { ok: false, status: "watch-idle", skipped: true };
|
|
843
|
+
}
|
|
844
|
+
if (check?.timedOut) {
|
|
845
|
+
logWatch(latest, "idle (check timed out) — delivery still pending");
|
|
846
|
+
return { ok: false, status: "watch-idle", skipped: true };
|
|
847
|
+
}
|
|
848
|
+
const hit = check?.code === 0;
|
|
849
|
+
const extraHits = (Number(latest.watchExtraHits) || 0) + (hit ? 1 : 0);
|
|
850
|
+
jobsStore.update(job.id, {
|
|
851
|
+
...(hit ? { watchExtraHits: extraHits, watchLastHitAt: now() } : { watchLastClearAt: now() }),
|
|
852
|
+
...(latest.watchTimeoutStreak ? { watchTimeoutStreak: 0 } : {}),
|
|
853
|
+
});
|
|
854
|
+
logWatch(latest, hit
|
|
855
|
+
? `triggered again (check passed) — folded into the pending delivery (${extraHits} more)`
|
|
856
|
+
: `idle (check exit ${check ? check.code : "?"}) — delivery still pending`);
|
|
857
|
+
return { ok: false, status: hit ? "watch-folded" : "watch-idle", skipped: true, extraHits };
|
|
858
|
+
} catch (error) {
|
|
859
|
+
return { ok: false, status: "failed", error: stableFailure(error) };
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// A cron's schedule. A pending job fires on each tick; a repeating watch
|
|
864
|
+
// whose delivery is still pending is only polled.
|
|
865
|
+
function armCron(job) {
|
|
866
|
+
if (!validateCron(job.schedule)) return false;
|
|
867
|
+
const task = scheduleCron(job.schedule, () => {
|
|
868
|
+
const current = jobsStore.get(job.id);
|
|
869
|
+
if (!current || current.disabled) return;
|
|
870
|
+
if (current.status === "pending") fireJob(current.id, { reason: "cron" });
|
|
871
|
+
else if (deliveryPending(current)) pollPendingWatch(current);
|
|
872
|
+
});
|
|
873
|
+
timers.set(job.id, { stop: () => task.stop?.() });
|
|
874
|
+
return true;
|
|
875
|
+
}
|
|
876
|
+
|
|
614
877
|
function scheduleJob(job) {
|
|
615
878
|
unscheduleJob(job.id);
|
|
616
879
|
if (!job || job.disabled || ["archived", "dead-letter"].includes(job.status)) return false;
|
|
@@ -619,6 +882,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
619
882
|
return false;
|
|
620
883
|
}
|
|
621
884
|
if (["failed", "deferred", "running"].includes(job.status)) {
|
|
885
|
+
if (pollsWhilePending(job)) armCron(job);
|
|
622
886
|
armRetry(job, Math.max(0, Number(job.nextAttemptAt || now()) - now()));
|
|
623
887
|
return true;
|
|
624
888
|
}
|
|
@@ -636,20 +900,20 @@ function createSchedulerService(dependencies = {}) {
|
|
|
636
900
|
} catch (_) {}
|
|
637
901
|
return false;
|
|
638
902
|
}
|
|
639
|
-
const
|
|
903
|
+
const wait = Math.max(0, fireAt - now());
|
|
904
|
+
// Too far out for one timer: sleep a chunk, then re-plan from the store.
|
|
905
|
+
const timeout = wait > MAX_TIMER_DELAY_MS
|
|
906
|
+
? setTimer(() => {
|
|
907
|
+
try {
|
|
908
|
+
const current = jobsStore.get(job.id);
|
|
909
|
+
if (current) scheduleJob(current);
|
|
910
|
+
} catch (_) { /* init() re-plans on the next boot */ }
|
|
911
|
+
}, MAX_TIMER_DELAY_MS)
|
|
912
|
+
: setTimer(() => fireJob(job.id, { reason: fireAt < now() ? "missed" : "timer" }), wait);
|
|
640
913
|
timers.set(job.id, { timeout });
|
|
641
914
|
return true;
|
|
642
915
|
}
|
|
643
|
-
if (job.kind === "cron")
|
|
644
|
-
if (!validateCron(job.schedule)) return false;
|
|
645
|
-
const task = scheduleCron(job.schedule, () => {
|
|
646
|
-
const current = jobsStore.get(job.id);
|
|
647
|
-
if (!current || current.disabled || current.status !== "pending") return;
|
|
648
|
-
fireJob(current.id, { reason: "cron" });
|
|
649
|
-
});
|
|
650
|
-
timers.set(job.id, { stop: () => task.stop?.() });
|
|
651
|
-
return true;
|
|
652
|
-
}
|
|
916
|
+
if (job.kind === "cron") return armCron(job);
|
|
653
917
|
return false;
|
|
654
918
|
}
|
|
655
919
|
|
|
@@ -809,9 +1073,15 @@ module.exports = {
|
|
|
809
1073
|
RETRY_DELAY_MS,
|
|
810
1074
|
BUSY_DELAY_MS,
|
|
811
1075
|
DEFAULT_WATCH_TIMEOUT_MS,
|
|
1076
|
+
WATCH_TIMEOUT_ALARM_AFTER,
|
|
1077
|
+
MAX_TIMER_DELAY_MS,
|
|
1078
|
+
MAX_WAKEUP_HORIZON_MS,
|
|
812
1079
|
createSchedulerService,
|
|
813
1080
|
parseFallbacks,
|
|
814
1081
|
terminalRunSucceeded,
|
|
1082
|
+
buildWatchCheckEnv,
|
|
1083
|
+
isSecretCheckEnvKey,
|
|
1084
|
+
watchCheckPrincipal,
|
|
815
1085
|
setAdapters,
|
|
816
1086
|
initScheduler,
|
|
817
1087
|
scheduleJob: service.scheduleJob,
|
package/core/speaker-attest.js
CHANGED
|
@@ -53,4 +53,85 @@ function verify(canonicalUserId, attest) {
|
|
|
53
53
|
try { return crypto.timingSafeEqual(a, b); } catch (e) { return false; }
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// ── Run attestation ─────────────────────────────────────────────────────────
|
|
57
|
+
// The id attest above says "the runner stamped this canonical id". It is not
|
|
58
|
+
// bound to a run: a copy lifted from another process's environment stays valid
|
|
59
|
+
// for the whole boot. And the CLIs that decided the speaker locally (tool,
|
|
60
|
+
// keyring, entity) read OC_CHANNEL_* from their own env, so a shell that
|
|
61
|
+
// simply unset or rewrote those variables was treated as the owner.
|
|
62
|
+
//
|
|
63
|
+
// A run attestation binds a LIVE run to the principal it acts for, decided in
|
|
64
|
+
// the bot process: the runner registers every agent run it spawns
|
|
65
|
+
// (runId -> principal) and stamps OC_RUN_ID + OC_RUN_ATTEST into the run's
|
|
66
|
+
// env; the loopback answers "who is this?" only for a registered, still-live
|
|
67
|
+
// run whose attest matches. Anything else (no attest, a wrong one, a run that
|
|
68
|
+
// has ended, a detached process that outlived its turn) is UNATTRIBUTED, and
|
|
69
|
+
// unattributed never carries owner powers.
|
|
70
|
+
const RUN_TTL_MS = 12 * 60 * 60 * 1000;
|
|
71
|
+
const runs = new Map();
|
|
72
|
+
|
|
73
|
+
function runSignature(runId) {
|
|
74
|
+
return crypto.createHmac("sha256", secret()).update(`run\0${runId}`).digest("hex");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function principalOf(principal = {}) {
|
|
78
|
+
// Bot-internal work with no speaker (compaction, owner jobs) acts for the
|
|
79
|
+
// owner; a run is guarded exactly when it acts for someone who is not.
|
|
80
|
+
const owner = principal.owner === true;
|
|
81
|
+
const text = (value) => (value == null || value === "" ? null : String(value));
|
|
82
|
+
return {
|
|
83
|
+
kind: text(principal.kind) || "turn",
|
|
84
|
+
owner,
|
|
85
|
+
guarded: !owner,
|
|
86
|
+
canonicalUserId: text(principal.canonicalUserId),
|
|
87
|
+
name: text(principal.name),
|
|
88
|
+
adapterId: text(principal.adapterId),
|
|
89
|
+
adapterType: text(principal.adapterType),
|
|
90
|
+
channelId: text(principal.channelId),
|
|
91
|
+
userId: text(principal.userId),
|
|
92
|
+
room: text(principal.room),
|
|
93
|
+
purpose: text(principal.purpose),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function pruneRuns(now = Date.now()) {
|
|
98
|
+
for (const [id, record] of runs) {
|
|
99
|
+
if (now - record.registeredAt > RUN_TTL_MS) runs.delete(id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Returns the attest to stamp as OC_RUN_ATTEST. Re-registering a run (a retry
|
|
104
|
+
// of the same runId) replaces its principal.
|
|
105
|
+
function registerRun(runId, principal = {}) {
|
|
106
|
+
const id = String(runId || "");
|
|
107
|
+
if (!id) return "";
|
|
108
|
+
pruneRuns();
|
|
109
|
+
runs.set(id, { ...principalOf(principal), runId: id, registeredAt: Date.now() });
|
|
110
|
+
return runSignature(id);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function endRun(runId) {
|
|
114
|
+
runs.delete(String(runId || ""));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The principal of a live run whose attest matches, or null (fail closed).
|
|
118
|
+
function verifyRun(runId, attest) {
|
|
119
|
+
const id = String(runId || "");
|
|
120
|
+
const sig = String(attest || "");
|
|
121
|
+
if (!id || !sig) return null;
|
|
122
|
+
const record = runs.get(id);
|
|
123
|
+
if (!record) return null;
|
|
124
|
+
if (Date.now() - record.registeredAt > RUN_TTL_MS) { runs.delete(id); return null; }
|
|
125
|
+
const a = Buffer.from(sig, "utf-8");
|
|
126
|
+
const b = Buffer.from(runSignature(id), "utf-8");
|
|
127
|
+
if (a.length !== b.length) return null;
|
|
128
|
+
try { if (!crypto.timingSafeEqual(a, b)) return null; } catch (e) { return null; }
|
|
129
|
+
return { ...record };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function liveRuns() {
|
|
133
|
+
pruneRuns();
|
|
134
|
+
return Array.from(runs.values(), (record) => ({ ...record }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { mint, verify, registerRun, endRun, verifyRun, liveRuns, principalOf };
|