@nanhara/hara 0.122.2 → 0.122.4
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 +108 -0
- package/README.md +15 -2
- package/dist/agent/limits.js +51 -0
- package/dist/agent/loop.js +367 -112
- package/dist/agent/repeat-guard.js +49 -12
- package/dist/checkpoints.js +9 -0
- package/dist/cli.js +2 -1
- package/dist/config.js +10 -2
- package/dist/context/agents-md.js +21 -2
- package/dist/context/mentions.js +84 -1
- package/dist/context/workspace-scope.js +49 -0
- package/dist/cron/deliver.js +61 -38
- package/dist/cron/runner.js +423 -65
- package/dist/cron/schedule.js +23 -7
- package/dist/cron/store.js +709 -43
- package/dist/fs-walk.js +279 -7
- package/dist/fs-write.js +9 -0
- package/dist/gateway/feishu.js +351 -64
- package/dist/gateway/flows-pending.js +31 -17
- package/dist/gateway/flows.js +306 -31
- package/dist/gateway/outbound-files.js +20 -6
- package/dist/gateway/runtime-state.js +1485 -0
- package/dist/gateway/serve.js +550 -242
- package/dist/gateway/sessions.js +3 -3
- package/dist/gateway/telegram.js +279 -29
- package/dist/gateway/tts.js +182 -38
- package/dist/hooks.js +22 -22
- package/dist/index.js +466 -158
- package/dist/memory/store.js +8 -5
- package/dist/org/planner.js +11 -6
- package/dist/org/projects.js +3 -3
- package/dist/process-identity.js +52 -0
- package/dist/providers/bounded-turn.js +42 -0
- package/dist/recall.js +69 -1
- package/dist/runtime.js +24 -0
- package/dist/sandbox.js +54 -4
- package/dist/search/embed.js +13 -2
- package/dist/search/hybrid.js +6 -3
- package/dist/search/semindex.js +87 -5
- package/dist/security/guardian.js +3 -15
- package/dist/security/permissions.js +11 -0
- package/dist/serve/server.js +70 -42
- package/dist/serve/sessions.js +4 -3
- package/dist/sync-sleep.js +46 -0
- package/dist/tools/agent.js +1 -1
- package/dist/tools/ask_user.js +5 -1
- package/dist/tools/builtin.js +5 -2
- package/dist/tools/codebase.js +67 -18
- package/dist/tools/computer.js +149 -68
- package/dist/tools/cron.js +72 -18
- package/dist/tools/edit.js +3 -2
- package/dist/tools/external_agent.js +66 -12
- package/dist/tools/memory.js +25 -7
- package/dist/tools/patch.js +11 -2
- package/dist/tools/registry.js +16 -1
- package/dist/tools/search.js +68 -9
- package/dist/tools/send.js +1 -1
- package/dist/tools/skill.js +1 -1
- package/dist/tools/task.js +3 -3
- package/dist/tools/web.js +43 -13
- package/dist/tui/App.js +93 -25
- package/dist/vision.js +5 -6
- package/package.json +2 -2
- package/runtime-bootstrap.cjs +22 -0
package/dist/cron/store.js
CHANGED
|
@@ -3,7 +3,22 @@
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
|
-
import { chmodSync,
|
|
6
|
+
import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, linkSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, unlinkSync, utimesSync, writeFileSync, } from "node:fs";
|
|
7
|
+
import { cronMatches, parseCron, validTz } from "./schedule.js";
|
|
8
|
+
import { compareProcessIdentity, defaultProcessIdentity } from "../process-identity.js";
|
|
9
|
+
import { sleepSync } from "../sync-sleep.js";
|
|
10
|
+
/** Hard persistence bounds: outages must apply backpressure instead of growing jobs.json forever. */
|
|
11
|
+
export const MAX_CRON_PENDING_NOTIFICATIONS = 64;
|
|
12
|
+
const CRON_RUN_NOTIFICATION_RESERVE = 2;
|
|
13
|
+
const MAX_CRON_JOBS = 4_096;
|
|
14
|
+
export const MAX_CRON_STORE_BYTES = 32 * 1024 * 1024;
|
|
15
|
+
export class CronStoreCorruptError extends Error {
|
|
16
|
+
code = "HARA_CRON_STORE_CORRUPT";
|
|
17
|
+
constructor(detail) {
|
|
18
|
+
super(`cron job store is invalid (${detail}); refusing to overwrite it. Inspect ${jobsPath()} and ${join(cronDir(), "store-error.log")}`);
|
|
19
|
+
this.name = "CronStoreCorruptError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
7
22
|
export function cronDir() {
|
|
8
23
|
return join(homedir(), ".hara", "cron");
|
|
9
24
|
}
|
|
@@ -13,40 +28,471 @@ export function jobsPath() {
|
|
|
13
28
|
export function logPath(id) {
|
|
14
29
|
return join(cronDir(), "logs", `${id}.log`);
|
|
15
30
|
}
|
|
31
|
+
const STORE_LOCK_ATTEMPTS = 200;
|
|
32
|
+
const STORE_LOCK_WAIT_MS = 10;
|
|
33
|
+
const STORE_MALFORMED_POISON_MS = 500;
|
|
34
|
+
// Store mutations are synchronous and normally hold the lock for milliseconds. A generous 30-second lease
|
|
35
|
+
// recovers a crashed lock whose PID has since been reused; the old owner is fenced at the commit guard below.
|
|
36
|
+
const STORE_LOCK_LEASE_MS = 30_000;
|
|
37
|
+
const MAX_STORE_LOCK_BYTES = 512;
|
|
38
|
+
function storeLockPath() {
|
|
39
|
+
return join(cronDir(), ".jobs.lock");
|
|
40
|
+
}
|
|
41
|
+
function readStoreLockSnapshot(path) {
|
|
42
|
+
try {
|
|
43
|
+
const before = lstatSync(path);
|
|
44
|
+
let raw = null;
|
|
45
|
+
if (before.isFile() && before.size <= MAX_STORE_LOCK_BYTES) {
|
|
46
|
+
const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
47
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
|
|
48
|
+
try {
|
|
49
|
+
const opened = fstatSync(fd);
|
|
50
|
+
if (opened.isFile() && opened.dev === before.dev && opened.ino === before.ino && opened.size <= MAX_STORE_LOCK_BYTES) {
|
|
51
|
+
const buffer = Buffer.alloc(opened.size);
|
|
52
|
+
let offset = 0;
|
|
53
|
+
while (offset < buffer.length) {
|
|
54
|
+
const bytes = readSync(fd, buffer, offset, buffer.length - offset, offset);
|
|
55
|
+
if (!bytes)
|
|
56
|
+
break;
|
|
57
|
+
offset += bytes;
|
|
58
|
+
}
|
|
59
|
+
raw = buffer.subarray(0, offset).toString("utf8");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
closeSync(fd);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const after = lstatSync(path);
|
|
67
|
+
if (before.dev !== after.dev
|
|
68
|
+
|| before.ino !== after.ino
|
|
69
|
+
|| before.size !== after.size
|
|
70
|
+
|| before.mtimeMs !== after.mtimeMs
|
|
71
|
+
|| before.ctimeMs !== after.ctimeMs)
|
|
72
|
+
return null;
|
|
73
|
+
return { raw, dev: after.dev, ino: after.ino, size: after.size, mtimeMs: after.mtimeMs, ctimeMs: after.ctimeMs };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function parseStoreLock(snapshot) {
|
|
80
|
+
if (snapshot?.raw === null || snapshot?.raw === undefined)
|
|
81
|
+
return null;
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(snapshot.raw);
|
|
84
|
+
return Number.isSafeInteger(parsed?.pid) && parsed.pid > 0
|
|
85
|
+
&& typeof parsed?.token === "string" && parsed.token.length > 0 && parsed.token.length <= 128
|
|
86
|
+
&& (parsed.birthIdentity === undefined
|
|
87
|
+
|| (typeof parsed.birthIdentity === "string" && /^[\x20-\x7e]{1,256}$/.test(parsed.birthIdentity)))
|
|
88
|
+
? {
|
|
89
|
+
pid: parsed.pid,
|
|
90
|
+
token: parsed.token,
|
|
91
|
+
...(parsed.birthIdentity ? { birthIdentity: parsed.birthIdentity } : {}),
|
|
92
|
+
}
|
|
93
|
+
: null;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function readStoreLock(path) {
|
|
100
|
+
return parseStoreLock(readStoreLockSnapshot(path));
|
|
101
|
+
}
|
|
102
|
+
function sameStoreLock(left, right) {
|
|
103
|
+
return !!right
|
|
104
|
+
&& left.raw === right.raw
|
|
105
|
+
&& left.dev === right.dev
|
|
106
|
+
&& left.ino === right.ino
|
|
107
|
+
&& left.size === right.size
|
|
108
|
+
&& left.mtimeMs === right.mtimeMs
|
|
109
|
+
&& left.ctimeMs === right.ctimeMs;
|
|
110
|
+
}
|
|
111
|
+
function malformedStoreLockStale(snapshot, now = Date.now()) {
|
|
112
|
+
return !parseStoreLock(snapshot) && now - snapshot.mtimeMs >= STORE_MALFORMED_POISON_MS;
|
|
113
|
+
}
|
|
114
|
+
function validStoreLockLeaseExpired(snapshot, now = Date.now()) {
|
|
115
|
+
return !!parseStoreLock(snapshot) && now - snapshot.mtimeMs >= STORE_LOCK_LEASE_MS;
|
|
116
|
+
}
|
|
117
|
+
function processAlive(pid) {
|
|
118
|
+
try {
|
|
119
|
+
process.kill(pid, 0);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return error?.code === "EPERM";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const CURRENT_PROCESS_BIRTH_IDENTITY = defaultProcessIdentity(process.pid);
|
|
127
|
+
function newStoreLockRecord() {
|
|
128
|
+
const birthIdentity = CURRENT_PROCESS_BIRTH_IDENTITY;
|
|
129
|
+
return {
|
|
130
|
+
pid: process.pid,
|
|
131
|
+
token: randomUUID(),
|
|
132
|
+
...(birthIdentity ? { birthIdentity } : {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function storeLockOwnerAlive(record) {
|
|
136
|
+
if (!processAlive(record.pid))
|
|
137
|
+
return false;
|
|
138
|
+
if (!record.birthIdentity)
|
|
139
|
+
return true;
|
|
140
|
+
const current = record.pid === process.pid ? CURRENT_PROCESS_BIRTH_IDENTITY : defaultProcessIdentity(record.pid);
|
|
141
|
+
return compareProcessIdentity(record.birthIdentity, current) !== "different";
|
|
142
|
+
}
|
|
143
|
+
function writeStoreLockExclusive(path, record) {
|
|
144
|
+
// Never expose a partial lock record at the contested pathname. A process may be suspended indefinitely
|
|
145
|
+
// between open and write; publish the fully written+fsynced inode with an atomic create-if-absent link.
|
|
146
|
+
const staging = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
147
|
+
let fd;
|
|
148
|
+
try {
|
|
149
|
+
fd = openSync(staging, "wx", 0o600);
|
|
150
|
+
writeFileSync(fd, JSON.stringify(record), "utf8");
|
|
151
|
+
fsyncSync(fd);
|
|
152
|
+
closeSync(fd);
|
|
153
|
+
fd = undefined;
|
|
154
|
+
linkSync(staging, path);
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
if (fd !== undefined)
|
|
158
|
+
closeSync(fd);
|
|
159
|
+
try {
|
|
160
|
+
unlinkSync(staging);
|
|
161
|
+
}
|
|
162
|
+
catch { /* a crash may leave only an inert, uniquely named staging file */ }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function removeOwnedStoreLock(path, owner) {
|
|
166
|
+
const current = readStoreLock(path);
|
|
167
|
+
if (current?.pid === owner.pid && current.token === owner.token)
|
|
168
|
+
rmSync(path, { force: true });
|
|
169
|
+
}
|
|
170
|
+
/** A valid reclaim guard protects the tiny verify+commit critical section. New records use OS birth identity
|
|
171
|
+
* to distinguish a live owner from PID reuse. A live legacy identity-less guard fails closed: age alone cannot
|
|
172
|
+
* fence an owner suspended after its final token check but before rename, and this lock format was unpublished. */
|
|
173
|
+
function clearDeadOrPoisonedStoreGuard(path) {
|
|
174
|
+
const observed = readStoreLockSnapshot(path);
|
|
175
|
+
if (!observed)
|
|
176
|
+
return true;
|
|
177
|
+
const record = parseStoreLock(observed);
|
|
178
|
+
const reclaimable = record ? !storeLockOwnerAlive(record) : malformedStoreLockStale(observed);
|
|
179
|
+
if (!reclaimable)
|
|
180
|
+
return false;
|
|
181
|
+
const current = readStoreLockSnapshot(path);
|
|
182
|
+
const currentRecord = parseStoreLock(current);
|
|
183
|
+
const stillReclaimable = record
|
|
184
|
+
? currentRecord?.pid === record.pid
|
|
185
|
+
&& currentRecord.token === record.token
|
|
186
|
+
&& !!current
|
|
187
|
+
&& !storeLockOwnerAlive(currentRecord)
|
|
188
|
+
: !!current && malformedStoreLockStale(current);
|
|
189
|
+
if (!sameStoreLock(observed, current) || !stillReclaimable)
|
|
190
|
+
return false;
|
|
191
|
+
rmSync(path, { force: true });
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
function withStoreCommitFence(lock, reclaim, claim, fn) {
|
|
195
|
+
let guard;
|
|
196
|
+
for (let attempt = 0; attempt < STORE_LOCK_ATTEMPTS; attempt++) {
|
|
197
|
+
if (!clearDeadOrPoisonedStoreGuard(reclaim)) {
|
|
198
|
+
sleepSync(STORE_LOCK_WAIT_MS);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const candidate = newStoreLockRecord();
|
|
202
|
+
try {
|
|
203
|
+
writeStoreLockExclusive(reclaim, candidate);
|
|
204
|
+
guard = candidate;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (error?.code !== "EEXIST")
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
sleepSync(STORE_LOCK_WAIT_MS);
|
|
212
|
+
}
|
|
213
|
+
if (!guard)
|
|
214
|
+
throw new Error("cron job store commit fence is busy; retry the operation");
|
|
215
|
+
try {
|
|
216
|
+
const currentGuard = readStoreLock(reclaim);
|
|
217
|
+
if (currentGuard?.pid !== guard.pid || currentGuard.token !== guard.token) {
|
|
218
|
+
throw new Error("cron job store commit guard was replaced before commit; retry the operation");
|
|
219
|
+
}
|
|
220
|
+
const current = readStoreLock(lock);
|
|
221
|
+
if (current?.pid !== claim.pid || current.token !== claim.token) {
|
|
222
|
+
throw new Error("cron job store lease was replaced before commit; retry the operation");
|
|
223
|
+
}
|
|
224
|
+
// Renew both names immediately before the synchronous namespace commit. A stale-owner reaper that
|
|
225
|
+
// observed either snapshot sees this renewal or loses its snapshot CAS; a resumed owner whose guard was
|
|
226
|
+
// already replaced fails above before it can publish stale jobs.json contents.
|
|
227
|
+
const now = new Date();
|
|
228
|
+
utimesSync(reclaim, now, now);
|
|
229
|
+
utimesSync(lock, now, now);
|
|
230
|
+
return fn();
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
removeOwnedStoreLock(reclaim, guard);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** All cron read-modify-write operations share this bounded O_EXCL mutex. Dead owners are reclaimed only
|
|
237
|
+
* under a second exclusive guard and after token revalidation, so a reaper can never unlink a successor. */
|
|
238
|
+
function withStoreLock(fn) {
|
|
239
|
+
mkdirSync(cronDir(), { recursive: true, mode: 0o700 });
|
|
240
|
+
const lock = storeLockPath();
|
|
241
|
+
const reclaim = `${lock}.reclaim`;
|
|
242
|
+
let claim;
|
|
243
|
+
for (let attempt = 0; attempt < STORE_LOCK_ATTEMPTS; attempt++) {
|
|
244
|
+
const reclaimObserved = readStoreLockSnapshot(reclaim);
|
|
245
|
+
if (reclaimObserved) {
|
|
246
|
+
if (clearDeadOrPoisonedStoreGuard(reclaim))
|
|
247
|
+
continue;
|
|
248
|
+
sleepSync(STORE_LOCK_WAIT_MS);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const candidate = newStoreLockRecord();
|
|
252
|
+
try {
|
|
253
|
+
writeStoreLockExclusive(lock, candidate);
|
|
254
|
+
// A reaper may have installed its guard after our preflight but before link(2). Never enter a
|
|
255
|
+
// transaction under that guard: remove only our token and retry after the transition completes.
|
|
256
|
+
if (existsSync(reclaim)) {
|
|
257
|
+
removeOwnedStoreLock(lock, candidate);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
claim = candidate;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
if (error?.code !== "EEXIST")
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
const observed = readStoreLockSnapshot(lock);
|
|
269
|
+
const held = parseStoreLock(observed);
|
|
270
|
+
if (observed && ((held && (!storeLockOwnerAlive(held) || validStoreLockLeaseExpired(observed))) || malformedStoreLockStale(observed))) {
|
|
271
|
+
const guard = newStoreLockRecord();
|
|
272
|
+
try {
|
|
273
|
+
writeStoreLockExclusive(reclaim, guard);
|
|
274
|
+
const current = readStoreLockSnapshot(lock);
|
|
275
|
+
const currentRecord = parseStoreLock(current);
|
|
276
|
+
const stillReclaimable = held
|
|
277
|
+
? currentRecord?.pid === held.pid
|
|
278
|
+
&& currentRecord.token === held.token
|
|
279
|
+
&& !!current
|
|
280
|
+
&& (!storeLockOwnerAlive(currentRecord) || validStoreLockLeaseExpired(current))
|
|
281
|
+
: !!current && malformedStoreLockStale(current);
|
|
282
|
+
if (sameStoreLock(observed, current) && stillReclaimable)
|
|
283
|
+
rmSync(lock);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// Another writer won reclamation, or the evidence is malformed. Never fail open.
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
const currentGuard = readStoreLock(reclaim);
|
|
290
|
+
if (currentGuard?.pid === process.pid && currentGuard.token === guard.token)
|
|
291
|
+
rmSync(reclaim, { force: true });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
sleepSync(STORE_LOCK_WAIT_MS);
|
|
295
|
+
}
|
|
296
|
+
if (!claim)
|
|
297
|
+
throw new Error("cron job store is busy; retry the operation");
|
|
298
|
+
const lease = {
|
|
299
|
+
commit: (commit) => withStoreCommitFence(lock, reclaim, claim, commit),
|
|
300
|
+
};
|
|
301
|
+
try {
|
|
302
|
+
return fn(lease);
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
removeOwnedStoreLock(lock, claim);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function validFinite(value) {
|
|
309
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
310
|
+
}
|
|
311
|
+
function validSchedule(value) {
|
|
312
|
+
if (!value || typeof value !== "object")
|
|
313
|
+
return false;
|
|
314
|
+
const schedule = value;
|
|
315
|
+
if (schedule.kind === "cron")
|
|
316
|
+
return typeof schedule.expr === "string" && !!parseCron(schedule.expr);
|
|
317
|
+
if (schedule.kind === "every")
|
|
318
|
+
return validFinite(schedule.everyMs) && schedule.everyMs > 0 && typeof schedule.display === "string";
|
|
319
|
+
return schedule.kind === "once" && validFinite(schedule.runAt) && typeof schedule.display === "string";
|
|
320
|
+
}
|
|
321
|
+
function validPendingNotification(value) {
|
|
322
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
323
|
+
return false;
|
|
324
|
+
const notification = value;
|
|
325
|
+
return typeof notification.id === "string" && notification.id.length > 0 && notification.id.length <= 256
|
|
326
|
+
&& (notification.kind === "outcome" || notification.kind === "alert")
|
|
327
|
+
&& typeof notification.target === "string" && notification.target.length > 0 && notification.target.length <= 4_096
|
|
328
|
+
&& typeof notification.text === "string" && notification.text.length <= 8_192
|
|
329
|
+
&& validFinite(notification.createdAt)
|
|
330
|
+
&& typeof notification.attempts === "number" && Number.isInteger(notification.attempts)
|
|
331
|
+
&& notification.attempts >= 0 && notification.attempts <= Number.MAX_SAFE_INTEGER
|
|
332
|
+
&& validFinite(notification.nextAttemptAt)
|
|
333
|
+
&& (notification.lastError === undefined || (typeof notification.lastError === "string" && notification.lastError.length <= 4_096));
|
|
334
|
+
}
|
|
335
|
+
function validPendingNotifications(value) {
|
|
336
|
+
if (value === undefined)
|
|
337
|
+
return true;
|
|
338
|
+
if (!Array.isArray(value) || value.length > MAX_CRON_PENDING_NOTIFICATIONS)
|
|
339
|
+
return false;
|
|
340
|
+
const ids = new Set();
|
|
341
|
+
let alerts = 0;
|
|
342
|
+
for (const notification of value) {
|
|
343
|
+
if (!validPendingNotification(notification) || ids.has(notification.id))
|
|
344
|
+
return false;
|
|
345
|
+
ids.add(notification.id);
|
|
346
|
+
if (notification.kind === "alert" && ++alerts > 1)
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
function validCronJob(value) {
|
|
352
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
353
|
+
return false;
|
|
354
|
+
const job = value;
|
|
355
|
+
return typeof job.id === "string" && !!job.id && job.id.length <= 128
|
|
356
|
+
&& typeof job.name === "string" && job.name.length <= 1_000
|
|
357
|
+
&& validSchedule(job.schedule)
|
|
358
|
+
&& typeof job.task === "string"
|
|
359
|
+
&& (job.mode === "print" || job.mode === "org" || job.mode === "command")
|
|
360
|
+
&& typeof job.cwd === "string" && !!job.cwd
|
|
361
|
+
&& typeof job.enabled === "boolean"
|
|
362
|
+
&& validFinite(job.createdAt)
|
|
363
|
+
&& (job.pendingDueAt === undefined || (job.schedule.kind === "cron" && validFinite(job.pendingDueAt)))
|
|
364
|
+
&& (job.tz === undefined || (typeof job.tz === "string" && validTz(job.tz)))
|
|
365
|
+
&& (job.deliver === undefined || (typeof job.deliver === "string" && job.deliver.length > 0 && job.deliver.length <= 4_096))
|
|
366
|
+
&& (job.alertAfter === undefined || (Number.isInteger(job.alertAfter) && job.alertAfter >= 1 && job.alertAfter <= 1_000))
|
|
367
|
+
&& (job.lastRunAt === undefined || validFinite(job.lastRunAt))
|
|
368
|
+
&& (job.runningSince === undefined || validFinite(job.runningSince))
|
|
369
|
+
&& (job.runningPid === undefined || (Number.isInteger(job.runningPid) && job.runningPid > 0))
|
|
370
|
+
&& (job.runningToken === undefined || (typeof job.runningToken === "string" && job.runningToken.length > 0 && job.runningToken.length <= 128))
|
|
371
|
+
&& (job.lastDurationMs === undefined || validFinite(job.lastDurationMs))
|
|
372
|
+
&& (job.lastError === undefined || typeof job.lastError === "string")
|
|
373
|
+
&& (job.consecutiveErrors === undefined || (Number.isInteger(job.consecutiveErrors) && job.consecutiveErrors >= 0))
|
|
374
|
+
&& (job.lastAlertAt === undefined || validFinite(job.lastAlertAt))
|
|
375
|
+
&& validPendingNotifications(job.pendingNotifications)
|
|
376
|
+
&& (job.lastStatus === undefined || ["ok", "error", "running", "timed_out"].includes(job.lastStatus));
|
|
377
|
+
}
|
|
378
|
+
function recordStoreError(detail) {
|
|
379
|
+
try {
|
|
380
|
+
mkdirSync(cronDir(), { recursive: true, mode: 0o700 });
|
|
381
|
+
const path = join(cronDir(), "store-error.log");
|
|
382
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
383
|
+
writeFileSync(temp, `${new Date().toISOString()} ${detail}\n`, { mode: 0o600 });
|
|
384
|
+
renameSync(temp, path);
|
|
385
|
+
try {
|
|
386
|
+
chmodSync(path, 0o600);
|
|
387
|
+
}
|
|
388
|
+
catch { /* best effort */ }
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// Diagnostics must never turn a parse failure into destructive recovery.
|
|
392
|
+
}
|
|
393
|
+
}
|
|
16
394
|
export function loadJobs() {
|
|
17
395
|
const p = jobsPath();
|
|
18
396
|
if (!existsSync(p))
|
|
19
397
|
return [];
|
|
398
|
+
const snapshot = lstatSync(p);
|
|
399
|
+
if (!snapshot.isFile() || snapshot.size > MAX_CRON_STORE_BYTES) {
|
|
400
|
+
const detail = !snapshot.isFile()
|
|
401
|
+
? "jobs.json is not a regular file"
|
|
402
|
+
: `jobs.json exceeds the ${MAX_CRON_STORE_BYTES}-byte safety limit`;
|
|
403
|
+
recordStoreError(detail);
|
|
404
|
+
throw new CronStoreCorruptError(detail);
|
|
405
|
+
}
|
|
406
|
+
let parsed;
|
|
20
407
|
try {
|
|
21
|
-
|
|
22
|
-
return Array.isArray(j) ? j : [];
|
|
408
|
+
parsed = JSON.parse(readFileSync(p, "utf8"));
|
|
23
409
|
}
|
|
24
410
|
catch {
|
|
25
|
-
|
|
411
|
+
const detail = "JSON could not be parsed";
|
|
412
|
+
recordStoreError(detail);
|
|
413
|
+
throw new CronStoreCorruptError(detail);
|
|
414
|
+
}
|
|
415
|
+
if (!Array.isArray(parsed)) {
|
|
416
|
+
const detail = "top level is not an array";
|
|
417
|
+
recordStoreError(detail);
|
|
418
|
+
throw new CronStoreCorruptError(detail);
|
|
26
419
|
}
|
|
420
|
+
if (parsed.length > MAX_CRON_JOBS) {
|
|
421
|
+
const detail = `top level exceeds the ${MAX_CRON_JOBS}-job safety limit`;
|
|
422
|
+
recordStoreError(detail);
|
|
423
|
+
throw new CronStoreCorruptError(detail);
|
|
424
|
+
}
|
|
425
|
+
const ids = new Set();
|
|
426
|
+
for (let index = 0; index < parsed.length; index++) {
|
|
427
|
+
const job = parsed[index];
|
|
428
|
+
if (!validCronJob(job)) {
|
|
429
|
+
const detail = `entry ${index + 1} has an invalid schema`;
|
|
430
|
+
recordStoreError(detail);
|
|
431
|
+
throw new CronStoreCorruptError(detail);
|
|
432
|
+
}
|
|
433
|
+
if (ids.has(job.id)) {
|
|
434
|
+
const detail = `entry ${index + 1} duplicates job id ${job.id}`;
|
|
435
|
+
recordStoreError(detail);
|
|
436
|
+
throw new CronStoreCorruptError(detail);
|
|
437
|
+
}
|
|
438
|
+
ids.add(job.id);
|
|
439
|
+
}
|
|
440
|
+
return parsed;
|
|
27
441
|
}
|
|
28
|
-
/** Persist the
|
|
29
|
-
|
|
442
|
+
/** Persist while the caller owns the store lock. The final rename is fenced against an expired owner that
|
|
443
|
+
* resumes after another process reclaimed its primary lease. */
|
|
444
|
+
function saveJobsUnlocked(jobs, lease) {
|
|
30
445
|
mkdirSync(cronDir(), { recursive: true, mode: 0o700 });
|
|
31
446
|
try {
|
|
32
447
|
chmodSync(cronDir(), 0o700);
|
|
33
448
|
}
|
|
34
449
|
catch { /* best effort */ }
|
|
450
|
+
if (jobs.length > MAX_CRON_JOBS || jobs.some((job) => !validCronJob(job))) {
|
|
451
|
+
throw new Error("refusing to persist cron jobs outside the bounded store schema");
|
|
452
|
+
}
|
|
453
|
+
const serialized = JSON.stringify(jobs, null, 2) + "\n";
|
|
454
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_CRON_STORE_BYTES) {
|
|
455
|
+
throw new Error(`refusing to persist cron jobs larger than ${MAX_CRON_STORE_BYTES} bytes`);
|
|
456
|
+
}
|
|
35
457
|
const p = jobsPath();
|
|
36
|
-
const tmp = `${p}.${process.pid}.tmp`;
|
|
37
|
-
|
|
38
|
-
|
|
458
|
+
const tmp = `${p}.${process.pid}.${randomUUID()}.tmp`;
|
|
459
|
+
try {
|
|
460
|
+
writeFileSync(tmp, serialized, { encoding: "utf8", mode: 0o600 });
|
|
461
|
+
lease.commit(() => renameSync(tmp, p));
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
rmSync(tmp, { force: true });
|
|
465
|
+
}
|
|
39
466
|
try {
|
|
40
467
|
chmodSync(p, 0o600);
|
|
41
468
|
}
|
|
42
469
|
catch { /* best effort */ }
|
|
43
470
|
}
|
|
471
|
+
/** Replace the complete list under the same mutex used by granular mutations. Prefer add/remove/set APIs
|
|
472
|
+
* for concurrent callers; this is retained for migrations and explicit whole-store replacement. */
|
|
473
|
+
export function saveJobs(jobs) {
|
|
474
|
+
withStoreLock((lease) => saveJobsUnlocked(jobs, lease));
|
|
475
|
+
}
|
|
476
|
+
function mutateJobs(mutate) {
|
|
477
|
+
return withStoreLock((lease) => {
|
|
478
|
+
const jobs = loadJobs();
|
|
479
|
+
const result = mutate(jobs);
|
|
480
|
+
saveJobsUnlocked(jobs, lease);
|
|
481
|
+
return result;
|
|
482
|
+
});
|
|
483
|
+
}
|
|
44
484
|
export function addJob(j) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
485
|
+
return mutateJobs((jobs) => {
|
|
486
|
+
const enabled = j.enabled ?? true;
|
|
487
|
+
const job = { ...j, id: randomUUID().slice(0, 8), enabled };
|
|
488
|
+
delete job.pendingDueAt;
|
|
489
|
+
delete job.pendingNotifications;
|
|
490
|
+
if (enabled && job.schedule.kind === "cron" && cronMatches(job.schedule.expr, new Date(job.createdAt), job.tz)) {
|
|
491
|
+
job.pendingDueAt = job.createdAt;
|
|
492
|
+
}
|
|
493
|
+
jobs.push(job);
|
|
494
|
+
return job;
|
|
495
|
+
});
|
|
50
496
|
}
|
|
51
497
|
/** Resolve an id or unique id-prefix to a single job. Exact id wins; otherwise the prefix must match
|
|
52
498
|
* EXACTLY one job — an ambiguous prefix returns "ambiguous" (never silently picks one, since callers
|
|
@@ -66,40 +512,260 @@ export function findJob(idOrPrefix) {
|
|
|
66
512
|
}
|
|
67
513
|
/** Delete a job by EXACT id (callers resolve the prefix first via resolveJob). */
|
|
68
514
|
export function removeJob(id) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
515
|
+
return mutateJobs((jobs) => {
|
|
516
|
+
const index = jobs.findIndex((x) => x.id === id);
|
|
517
|
+
if (index < 0)
|
|
518
|
+
return false;
|
|
519
|
+
jobs.splice(index, 1);
|
|
520
|
+
return true;
|
|
521
|
+
});
|
|
74
522
|
}
|
|
75
523
|
/** Enable/disable a job by EXACT id. */
|
|
76
524
|
export function setEnabled(id, on) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
525
|
+
return mutateJobs((jobs) => {
|
|
526
|
+
const job = jobs.find((x) => x.id === id);
|
|
527
|
+
if (!job)
|
|
528
|
+
return false;
|
|
529
|
+
job.enabled = on;
|
|
530
|
+
// Disabling explicitly abandons an unconsumed creation-minute occurrence. Enabling later must resume
|
|
531
|
+
// from the schedule at that time, never replay a stale trigger inferred from job creation.
|
|
532
|
+
if (!on)
|
|
533
|
+
delete job.pendingDueAt;
|
|
534
|
+
return true;
|
|
535
|
+
});
|
|
84
536
|
}
|
|
85
|
-
/**
|
|
86
|
-
export function
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
537
|
+
/** Persist a run before any child process/provider work starts. */
|
|
538
|
+
export function recordRunStart(id, at, requireEnabled = false) {
|
|
539
|
+
return mutateJobs((jobs) => {
|
|
540
|
+
const job = jobs.find((x) => x.id === id);
|
|
541
|
+
if (!job || (requireEnabled && !job.enabled))
|
|
542
|
+
return null;
|
|
543
|
+
// Never overwrite an unresolved attempt merely because its recorded parent PID is gone. The parent may
|
|
544
|
+
// have launched a detached child before crashing, so a second manual run could duplicate file/network
|
|
545
|
+
// side effects. Scheduler ticks call `recoverInterruptedRuns` first; manual callers receive a focused
|
|
546
|
+
// diagnostic and must explicitly retry only after that recovery has disabled the ambiguous attempt.
|
|
547
|
+
if (job.lastStatus === "running")
|
|
548
|
+
return null;
|
|
549
|
+
const pendingCount = job.pendingNotifications?.length ?? 0;
|
|
550
|
+
if (pendingCount > MAX_CRON_PENDING_NOTIFICATIONS - CRON_RUN_NOTIFICATION_RESERVE) {
|
|
551
|
+
const error = `cron delivery backlog has ${pendingCount}/${MAX_CRON_PENDING_NOTIFICATIONS} pending notifications; job disabled before launch to keep jobs.json bounded — restore delivery, let the queue drain, then re-enable the job`;
|
|
552
|
+
job.enabled = false;
|
|
553
|
+
delete job.pendingDueAt;
|
|
554
|
+
job.lastStatus = "error";
|
|
555
|
+
delete job.lastDurationMs;
|
|
556
|
+
job.lastError = error;
|
|
557
|
+
const alertAlreadyPending = job.pendingNotifications?.some((notification) => notification.kind === "alert") ?? false;
|
|
558
|
+
if (job.deliver && !alertAlreadyPending) {
|
|
559
|
+
enqueueNotification(job, "alert", `🚨 ${job.name} was disabled before launch: ${error}`, at, `delivery-backlog-${Math.trunc(at)}`);
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
const token = randomUUID();
|
|
564
|
+
job.lastRunAt = at;
|
|
565
|
+
delete job.pendingDueAt;
|
|
566
|
+
job.lastStatus = "running";
|
|
567
|
+
job.runningSince = at;
|
|
568
|
+
job.runningPid = process.pid;
|
|
569
|
+
job.runningToken = token;
|
|
570
|
+
delete job.lastDurationMs;
|
|
571
|
+
delete job.lastError;
|
|
572
|
+
return token;
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
const ALERT_COOLDOWN_MS = 6 * 3_600_000;
|
|
576
|
+
export const MAX_CRON_RUNNING_AGE_MS = 24 * 60 * 60_000 + 5 * 60_000;
|
|
577
|
+
function notificationText(value, max) {
|
|
578
|
+
return String(value ?? "").replace(/\0/g, "").slice(0, max);
|
|
579
|
+
}
|
|
580
|
+
function enqueueNotification(job, kind, text, nowMs, attemptKey) {
|
|
581
|
+
const id = `cron:${job.id}:${attemptKey}:${kind}`;
|
|
582
|
+
const pending = (job.pendingNotifications ??= []);
|
|
583
|
+
if (pending.some((notification) => notification.id === id))
|
|
584
|
+
return id;
|
|
585
|
+
let compacted = false;
|
|
586
|
+
if (pending.length >= MAX_CRON_PENDING_NOTIFICATIONS) {
|
|
587
|
+
// Alerts are never discarded. Under the validated schema there is at most one, so a full queue always
|
|
588
|
+
// has an older outcome that can be represented by an explicit compaction marker on the new critical fact.
|
|
589
|
+
const sameAttemptOutcome = kind === "alert" ? `cron:${job.id}:${attemptKey}:outcome` : undefined;
|
|
590
|
+
let oldestOutcome = -1;
|
|
591
|
+
for (let index = 0; index < pending.length; index++) {
|
|
592
|
+
if (pending[index].kind !== "outcome" || pending[index].id === sameAttemptOutcome)
|
|
593
|
+
continue;
|
|
594
|
+
if (oldestOutcome < 0 || pending[index].createdAt < pending[oldestOutcome].createdAt)
|
|
595
|
+
oldestOutcome = index;
|
|
596
|
+
}
|
|
597
|
+
if (oldestOutcome < 0)
|
|
598
|
+
return null;
|
|
599
|
+
pending.splice(oldestOutcome, 1);
|
|
600
|
+
compacted = true;
|
|
601
|
+
}
|
|
602
|
+
const safeText = compacted
|
|
603
|
+
? `⚠️ Delivery backlog reached ${MAX_CRON_PENDING_NOTIFICATIONS}; one older outcome was compacted without delivery.\n${text}`
|
|
604
|
+
: text;
|
|
605
|
+
pending.push({
|
|
606
|
+
id,
|
|
607
|
+
kind,
|
|
608
|
+
target: job.deliver,
|
|
609
|
+
text: notificationText(safeText, 8_192),
|
|
610
|
+
createdAt: nowMs,
|
|
611
|
+
attempts: 0,
|
|
612
|
+
nextAttemptAt: nowMs,
|
|
613
|
+
});
|
|
614
|
+
return id;
|
|
615
|
+
}
|
|
616
|
+
/** Queue outcome + threshold alarm while already under the jobs transaction. `lastAlertAt` is deliberately
|
|
617
|
+
* NOT changed here: cooldown begins only after the alert transport is confirmed and acknowledged. */
|
|
618
|
+
function enqueueOutcomeForJob(job, result, nowMs, attemptKey) {
|
|
619
|
+
if (!job.deliver)
|
|
620
|
+
return [];
|
|
621
|
+
const queued = [];
|
|
622
|
+
const snippet = notificationText(result.output, 1_500).trim();
|
|
623
|
+
const safeError = notificationText(result.error || "failed", 1_000);
|
|
624
|
+
const head = result.ok ? `⏰ ${job.name} ✓` : `⏰ ${job.name} ✗ ${safeError}`;
|
|
625
|
+
const outcomeId = enqueueNotification(job, "outcome", snippet ? `${head}\n${snippet}` : head, nowMs, attemptKey);
|
|
626
|
+
if (outcomeId)
|
|
627
|
+
queued.push(outcomeId);
|
|
628
|
+
if (!result.ok) {
|
|
629
|
+
const count = job.consecutiveErrors ?? 0;
|
|
630
|
+
const threshold = job.alertAfter ?? 3;
|
|
631
|
+
const cooled = !job.lastAlertAt || nowMs - job.lastAlertAt > ALERT_COOLDOWN_MS;
|
|
632
|
+
const alertAlreadyPending = job.pendingNotifications?.some((notification) => notification.kind === "alert") ?? false;
|
|
633
|
+
if (count >= threshold && cooled && !alertAlreadyPending) {
|
|
634
|
+
const alertId = enqueueNotification(job, "alert", `🚨 ${job.name} has failed ${count}× in a row — latest: ${safeError}. Log: ${logPath(job.id)}`, nowMs, attemptKey);
|
|
635
|
+
if (alertId)
|
|
636
|
+
queued.push(alertId);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return queued;
|
|
640
|
+
}
|
|
641
|
+
/** Compatibility/public helper for callers that recorded state separately. Production tick code passes the
|
|
642
|
+
* outcome to `recordRun`, making state + delivery intent one atomic jobs.json commit. */
|
|
643
|
+
export function enqueueOutcomeNotifications(id, result, nowMs = Date.now(), attemptKey = randomUUID()) {
|
|
644
|
+
return mutateJobs((jobs) => {
|
|
645
|
+
const job = jobs.find((candidate) => candidate.id === id);
|
|
646
|
+
return job ? enqueueOutcomeForJob(job, result, nowMs, attemptKey) : [];
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
/** Record a run's terminal outcome. Timeouts are errors for streak/alert purposes but stay distinguishable
|
|
650
|
+
* in persisted state and `cron list`. `at` is the finish time; lastRunAt remains the actual start. When
|
|
651
|
+
* `delivery` is supplied, terminal state and its transport effects commit atomically. */
|
|
652
|
+
export function recordRun(id, at, status, error, durationMs, runningToken, delivery) {
|
|
653
|
+
return mutateJobs((jobs) => {
|
|
654
|
+
const job = jobs.find((x) => x.id === id);
|
|
655
|
+
if (!job || (runningToken !== undefined && job.runningToken !== runningToken))
|
|
656
|
+
return false;
|
|
657
|
+
const attemptKey = runningToken ?? job.runningToken ?? randomUUID();
|
|
658
|
+
const startedAt = job.runningSince;
|
|
659
|
+
job.lastRunAt = startedAt ?? at;
|
|
660
|
+
job.lastStatus = status;
|
|
661
|
+
delete job.runningSince;
|
|
662
|
+
delete job.runningPid;
|
|
663
|
+
delete job.runningToken;
|
|
664
|
+
const measuredDuration = durationMs ?? (startedAt === undefined ? undefined : Math.max(0, at - startedAt));
|
|
665
|
+
if (measuredDuration === undefined)
|
|
666
|
+
delete job.lastDurationMs;
|
|
667
|
+
else
|
|
668
|
+
job.lastDurationMs = Math.max(0, Math.trunc(measuredDuration));
|
|
669
|
+
if (status === "ok" || !error)
|
|
670
|
+
delete job.lastError;
|
|
671
|
+
else
|
|
672
|
+
job.lastError = error;
|
|
673
|
+
job.consecutiveErrors = status === "ok" ? 0 : (job.consecutiveErrors ?? 0) + 1;
|
|
674
|
+
if (delivery)
|
|
675
|
+
enqueueOutcomeForJob(job, delivery, at, attemptKey);
|
|
676
|
+
return true;
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
/** Return due effects only, alert-first, with a hard item cap so a long outage cannot consume a whole tick. */
|
|
680
|
+
export function listPendingNotifications(nowMs = Date.now(), limit = 8, jobId) {
|
|
681
|
+
const bounded = Math.max(0, Math.min(64, Math.trunc(limit)));
|
|
682
|
+
if (!bounded)
|
|
683
|
+
return [];
|
|
684
|
+
return loadJobs()
|
|
685
|
+
.filter((job) => !jobId || job.id === jobId)
|
|
686
|
+
.flatMap((job) => (job.pendingNotifications ?? []).map((notification) => ({ ...notification, jobId: job.id })))
|
|
687
|
+
.filter((notification) => notification.nextAttemptAt <= nowMs)
|
|
688
|
+
.sort((left, right) => {
|
|
689
|
+
if (left.kind !== right.kind)
|
|
690
|
+
return left.kind === "alert" ? -1 : 1;
|
|
691
|
+
return left.createdAt - right.createdAt;
|
|
692
|
+
})
|
|
693
|
+
.slice(0, bounded);
|
|
694
|
+
}
|
|
695
|
+
/** Confirm one transport effect. Alert cooldown is recorded in the same commit that removes its pending
|
|
696
|
+
* record, so a crash cannot acknowledge delivery without also suppressing an immediate duplicate alert. */
|
|
697
|
+
export function acknowledgePendingNotification(jobId, notificationId, at = Date.now()) {
|
|
698
|
+
return mutateJobs((jobs) => {
|
|
699
|
+
const job = jobs.find((candidate) => candidate.id === jobId);
|
|
700
|
+
const pending = job?.pendingNotifications;
|
|
701
|
+
const index = pending?.findIndex((notification) => notification.id === notificationId) ?? -1;
|
|
702
|
+
if (!job || !pending || index < 0)
|
|
703
|
+
return false;
|
|
704
|
+
const [notification] = pending.splice(index, 1);
|
|
705
|
+
if (notification.kind === "alert")
|
|
706
|
+
job.lastAlertAt = at;
|
|
707
|
+
if (!pending.length)
|
|
708
|
+
delete job.pendingNotifications;
|
|
709
|
+
return true;
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
/** Persist an attempt failure and exponential backoff. The effect itself remains unchanged, including its
|
|
713
|
+
* stable idempotency key, until a later tick receives a confirmed transport success. */
|
|
714
|
+
export function deferPendingNotification(jobId, notificationId, error, at = Date.now()) {
|
|
715
|
+
return mutateJobs((jobs) => {
|
|
716
|
+
const notification = jobs
|
|
717
|
+
.find((candidate) => candidate.id === jobId)
|
|
718
|
+
?.pendingNotifications?.find((candidate) => candidate.id === notificationId);
|
|
719
|
+
if (!notification)
|
|
720
|
+
return false;
|
|
721
|
+
notification.attempts = Math.min(Number.MAX_SAFE_INTEGER, notification.attempts + 1);
|
|
722
|
+
const exponent = Math.min(10, Math.max(0, notification.attempts - 1));
|
|
723
|
+
const delay = Math.min(6 * 3_600_000, 30_000 * (2 ** exponent));
|
|
724
|
+
notification.nextAttemptAt = at + delay;
|
|
725
|
+
notification.lastError = notificationText(error || "transport request failed", 4_096);
|
|
726
|
+
return true;
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
/** Close `running` records whose owner is gone OR whose age exceeds every supported job timeout plus grace.
|
|
730
|
+
* The age fence handles PID reuse and a live-but-wedged legacy parent. The attempt remains consumed and the
|
|
731
|
+
* job is disabled: a detached child may still exist, so recovery never kills or automatically replays it. */
|
|
732
|
+
export function recoverInterruptedRuns(at, jobId) {
|
|
733
|
+
return mutateJobs((jobs) => {
|
|
734
|
+
const recovered = [];
|
|
735
|
+
for (const job of jobs) {
|
|
736
|
+
if (jobId && job.id !== jobId)
|
|
737
|
+
continue;
|
|
738
|
+
if (job.lastStatus !== "running")
|
|
739
|
+
continue;
|
|
740
|
+
const startedAt = job.runningSince ?? job.lastRunAt ?? at;
|
|
741
|
+
const tooOld = at - startedAt >= MAX_CRON_RUNNING_AGE_MS;
|
|
742
|
+
if (job.runningPid && processAlive(job.runningPid) && !tooOld)
|
|
743
|
+
continue;
|
|
744
|
+
const attemptKey = job.runningToken ?? randomUUID();
|
|
745
|
+
const error = tooOld
|
|
746
|
+
? "cron running state exceeded the 24h maximum job lifetime plus cleanup grace; job disabled because the recorded PID may be reused or its child may still be running — verify, then re-enable or run it manually"
|
|
747
|
+
: "previous Hara process exited before recording a terminal cron result; job disabled because an orphaned child may still be running — verify, then re-enable or run it manually";
|
|
748
|
+
job.lastRunAt = startedAt;
|
|
749
|
+
job.lastStatus = "error";
|
|
750
|
+
job.enabled = false;
|
|
751
|
+
delete job.pendingDueAt;
|
|
752
|
+
delete job.runningSince;
|
|
753
|
+
delete job.runningPid;
|
|
754
|
+
delete job.runningToken;
|
|
755
|
+
job.lastDurationMs = Math.max(0, Math.trunc(at - startedAt));
|
|
756
|
+
job.lastError = error;
|
|
757
|
+
job.consecutiveErrors = (job.consecutiveErrors ?? 0) + 1;
|
|
758
|
+
enqueueOutcomeForJob(job, { ok: false, error, output: "" }, at, attemptKey);
|
|
759
|
+
recovered.push({ job: { ...job }, error });
|
|
760
|
+
}
|
|
761
|
+
return recovered;
|
|
762
|
+
});
|
|
96
763
|
}
|
|
97
764
|
/** Stamp the failure-alert time (cooldown gate). */
|
|
98
765
|
export function recordAlert(id, at) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
saveJobs(jobs);
|
|
766
|
+
mutateJobs((jobs) => {
|
|
767
|
+
const job = jobs.find((x) => x.id === id);
|
|
768
|
+
if (job)
|
|
769
|
+
job.lastAlertAt = at;
|
|
770
|
+
});
|
|
105
771
|
}
|