@inetafrica/open-claudia 3.4.6 → 3.4.7
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 +4 -0
- package/core/scheduler.js +46 -26
- package/docs/PROVIDER_MIGRATION.md +5 -1
- package/package.json +1 -1
- package/test-provider-language.js +2 -1
- package/test-provider-scheduler.js +83 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.4.7 — Keep scheduled follow-ups queued while the chat is busy
|
|
4
|
+
|
|
5
|
+
- Scheduler: busy chats keep wakeups queued without consuming execution retries. Watch triggers and expiries are saved once, survive restart, and stop polling while delivery is pending. Concurrent callbacks cannot start overlapping runs; advisory notifications are attempted once per occurrence. Real execution failures retain bounded retries.
|
|
6
|
+
|
|
3
7
|
## v3.4.6 — Codex runs again: the gate stopped asking for the impossible
|
|
4
8
|
|
|
5
9
|
- **Every Codex turn since Codex CLI 0.153.x failed with `OpenAI Codex is incompatible: Codex rejected the isolated PreToolUse configuration or could not disable uncovered tool transports`.** The mandatory tool-hook gate ran `codex features list` with our overrides and required `unified_exec` to read `false`. Codex 0.153.2, 0.153.3 and 0.153.4 all ignore `-c features.unified_exec=false` (the feature is locked on and `codex features list` reports `unified_exec stable true` regardless), so the gate refused the provider on every turn — the failure Sumeet hit right after switching to gpt-6-astra on 3.4.5.
|
package/core/scheduler.js
CHANGED
|
@@ -120,6 +120,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
120
120
|
const admit = dependencies.admitRunContext || ((...args) => require("./runner").admitRunContext(...args));
|
|
121
121
|
const timers = new Map();
|
|
122
122
|
const checksInFlight = new Set();
|
|
123
|
+
const firesInFlight = new Set();
|
|
123
124
|
// Poll a watch's `check`: exit 0 → the watched condition is met, wake the
|
|
124
125
|
// agent; non-zero → still idle, skip silently. Runs in the job's project
|
|
125
126
|
// dir with the process env, output discarded (exit code is the contract),
|
|
@@ -344,18 +345,15 @@ function createSchedulerService(dependencies = {}) {
|
|
|
344
345
|
function markBusy(job) {
|
|
345
346
|
const latest = jobsStore.get(job.id) || job;
|
|
346
347
|
const busyDeferralCount = (latest.busyDeferralCount || 0) + 1;
|
|
347
|
-
const dead = busyDeferralCount >= (latest.maxAttempts || 1);
|
|
348
348
|
const patch = {
|
|
349
|
-
status:
|
|
350
|
-
disabled:
|
|
349
|
+
status: "deferred",
|
|
350
|
+
disabled: false,
|
|
351
351
|
busyDeferralCount,
|
|
352
|
-
nextAttemptAt:
|
|
353
|
-
lastError: dead ? { code: "SCHEDULED_BUSY_EXHAUSTED", message: "Channel remained busy through every bounded deferral" } : null,
|
|
352
|
+
nextAttemptAt: now() + busyDelayMs,
|
|
354
353
|
};
|
|
355
354
|
const updated = jobsStore.update(job.id, patch) || { ...latest, ...patch };
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
return { ok: false, deferred: !dead, status: updated.status, job: updated };
|
|
355
|
+
armRetry(updated, busyDelayMs);
|
|
356
|
+
return { ok: false, deferred: true, status: updated.status, job: updated };
|
|
359
357
|
}
|
|
360
358
|
|
|
361
359
|
async function settleSuccess(job, result, target) {
|
|
@@ -396,6 +394,10 @@ function createSchedulerService(dependencies = {}) {
|
|
|
396
394
|
attemptCount: 0,
|
|
397
395
|
busyDeferralCount: 0,
|
|
398
396
|
nextAttemptAt: null,
|
|
397
|
+
watchTriggeredOccurrenceId: null,
|
|
398
|
+
watchTriggeredAt: null,
|
|
399
|
+
watchExpired: false,
|
|
400
|
+
advisoryOccurrenceId: null,
|
|
399
401
|
});
|
|
400
402
|
if (reset && timers.get(job.id)?.timeout) scheduleJob(reset);
|
|
401
403
|
return reset
|
|
@@ -404,6 +406,14 @@ function createSchedulerService(dependencies = {}) {
|
|
|
404
406
|
}
|
|
405
407
|
|
|
406
408
|
async function fireJob(idOrJob, options = {}) {
|
|
409
|
+
const id = typeof idOrJob === "string" ? idOrJob : idOrJob?.id;
|
|
410
|
+
if (firesInFlight.has(id)) return { ok: false, status: "running", skipped: true, inFlight: true };
|
|
411
|
+
firesInFlight.add(id);
|
|
412
|
+
try { return await executeJob(idOrJob, options); }
|
|
413
|
+
finally { firesInFlight.delete(id); }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function executeJob(idOrJob, options = {}) {
|
|
407
417
|
const requested = typeof idOrJob === "string" ? jobsStore.get(idOrJob) : idOrJob;
|
|
408
418
|
let job = requested?.id && jobsStore.get(requested.id) ? jobsStore.get(requested.id) : requested;
|
|
409
419
|
if (!job) return { ok: false, status: "missing" };
|
|
@@ -423,7 +433,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
423
433
|
// runs the check and only wakes the agent when it exits 0. A non-zero exit
|
|
424
434
|
// is the normal idle case: skip silently, mark nothing, leave the job
|
|
425
435
|
// pending so the next tick polls again. Never marks failure; never chats.
|
|
426
|
-
if (job.check) {
|
|
436
|
+
if (job.check && job.watchTriggeredOccurrenceId !== job.occurrenceId) {
|
|
427
437
|
if (!watchesEnabled()) {
|
|
428
438
|
logWatch(job, "skipped (watches disabled globally)");
|
|
429
439
|
return { ok: false, status: "watch-disabled", skipped: true };
|
|
@@ -443,27 +453,30 @@ function createSchedulerService(dependencies = {}) {
|
|
|
443
453
|
logWatch(job, check && check.timedOut ? "idle (check timed out)" : `idle (check exit ${check ? check.code : "?"})`);
|
|
444
454
|
return { ok: false, status: "watch-idle", skipped: true };
|
|
445
455
|
}
|
|
446
|
-
logWatch(job, `expired without firing (check exit ${check ? check.code : "?"}) — waking agent`);
|
|
447
456
|
// Expiry ends the watch's window: one-shot removal even for --repeat
|
|
448
457
|
// (the woken agent re-arms if still wanted). Persisted so a retry of a
|
|
449
458
|
// failed expiry fire keeps the same semantics across restarts.
|
|
450
|
-
|
|
451
|
-
const marked = jobsStore.update(job.id, { repeat: false, watchExpired: true });
|
|
452
|
-
if (marked) job = marked;
|
|
453
|
-
else { job.repeat = false; job.watchExpired = true; }
|
|
454
|
-
} catch (_) { job.repeat = false; job.watchExpired = true; }
|
|
459
|
+
job = { ...job, repeat: false, watchExpired: true };
|
|
455
460
|
} else {
|
|
456
|
-
|
|
457
|
-
if (job.watchExpired) {
|
|
458
|
-
// A prior expiry fire failed and the condition came true before the
|
|
459
|
-
// retry: report it as a real trigger, not an expiry.
|
|
460
|
-
try {
|
|
461
|
-
const cleared = jobsStore.update(job.id, { watchExpired: false });
|
|
462
|
-
if (cleared) job = cleared;
|
|
463
|
-
else job.watchExpired = false;
|
|
464
|
-
} catch (_) { job.watchExpired = false; }
|
|
465
|
-
}
|
|
461
|
+
job = { ...job, watchExpired: false };
|
|
466
462
|
}
|
|
463
|
+
// Latch the condition before admission or notification. Delivery retries
|
|
464
|
+
// and restarts must not run the check again or change an expiry's meaning.
|
|
465
|
+
try {
|
|
466
|
+
const marked = jobsStore.update(job.id, {
|
|
467
|
+
status: "deferred",
|
|
468
|
+
nextAttemptAt: now(),
|
|
469
|
+
repeat: job.repeat,
|
|
470
|
+
watchExpired: job.watchExpired,
|
|
471
|
+
watchTriggeredOccurrenceId: job.occurrenceId,
|
|
472
|
+
watchTriggeredAt: now(),
|
|
473
|
+
});
|
|
474
|
+
if (!marked) throw new Error("watch trigger could not be persisted");
|
|
475
|
+
job = marked;
|
|
476
|
+
} catch (error) {
|
|
477
|
+
return { ok: false, status: "failed", error: stableFailure(error) };
|
|
478
|
+
}
|
|
479
|
+
logWatch(job, job.watchExpired ? "expired without firing — delivery queued" : "triggered (check passed) — delivery queued");
|
|
467
480
|
}
|
|
468
481
|
|
|
469
482
|
const adapter = resolveAdapter(job);
|
|
@@ -557,7 +570,14 @@ function createSchedulerService(dependencies = {}) {
|
|
|
557
570
|
: target.fallbackFrom
|
|
558
571
|
? `\n⚠️ ${target.fallbackFrom} is unavailable — running on fallback ${target.provider}.`
|
|
559
572
|
: "";
|
|
560
|
-
|
|
573
|
+
if (job.advisoryOccurrenceId !== job.occurrenceId) {
|
|
574
|
+
// At-most-once advisory: persist before sending so a restart cannot
|
|
575
|
+
// repeat it. A crash between these steps may omit this optional notice.
|
|
576
|
+
try {
|
|
577
|
+
if (!jobsStore.update(job.id, { advisoryOccurrenceId: job.occurrenceId })) throw new Error("advisory marker could not be persisted");
|
|
578
|
+
await send(`${advisory}${providerNote}`);
|
|
579
|
+
} catch (_) { /* advisory only */ }
|
|
580
|
+
}
|
|
561
581
|
let result;
|
|
562
582
|
try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
|
|
563
583
|
catch (error) {
|
|
@@ -64,4 +64,8 @@ The pointer, provider-tagged history lineage, and tuple-scoped usage checkpoint
|
|
|
64
64
|
|
|
65
65
|
Scheduled work uses a versioned `{schemaVersion, jobs, archived}` document. Every runnable occurrence stores its canonical user, channel, project, provider, provider-settings snapshot, native session lineage, origin run, and stable occurrence ID. A fresh foreground turn can create a job before its native session is known; terminal session persistence binds that job by origin run ID. Compaction descendants are followed only when recorded lineage proves the relationship, and an older scheduled branch cannot replace an unrelated live session pointer.
|
|
66
66
|
|
|
67
|
-
Wakeups and crons wait for terminal provider completion, transcript/usage/session persistence, and required delivery. A one-shot receives a durable success marker before deletion. Busy
|
|
67
|
+
Wakeups and crons wait for terminal provider completion, transcript/usage/session persistence, and required delivery. A one-shot receives a durable success marker before deletion. Busy occurrences retain their occurrence ID across restart and wait until the chat is idle, without consuming execution attempts or expiring under the missed-fire grace window. Genuine execution failures still have bounded retries; exhaustion and wakeups never admitted within the missed-fire grace window remain visible as disabled dead letters.
|
|
68
|
+
|
|
69
|
+
A watch persists its trigger (or expiry) before delivery and does not poll its check again while that occurrence is pending. Repeat watches resume polling only after successful delivery advances the occurrence. An in-process guard prevents overlapping callbacks from starting the same occurrence, even when a run lasts longer than its retry delay. Advisory notifications are marked before sending and attempted at most once per occurrence across retries/restarts; a crash between marking and sending can omit this optional notice. An interrupted provider run may still retry with the same occurrence ID if no durable success marker exists; this is not an exactly-once guarantee for external side effects. Previously dead-lettered jobs are not automatically revived because their queued actions may now be stale.
|
|
70
|
+
|
|
71
|
+
Provider fallback is empty by default. When `PROVIDER_FALLBACKS` is explicitly configured, a fallback starts a fresh provider session with an archived provider-neutral brief and never receives another provider's native session ID. Legacy `crons.json` remains byte-for-byte untouched after its ambiguous records are copied into the disabled archive.
|
package/package.json
CHANGED
|
@@ -463,7 +463,8 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
463
463
|
// Codex turn. Live probe: unified-exec shell calls still reach PreToolUse as
|
|
464
464
|
// tool_name "Bash" and the deny gate caught the probe, so the gate no longer
|
|
465
465
|
// requires unified_exec off. Push approved by Sumeet before release.
|
|
466
|
-
|
|
466
|
+
// 3.4.7: owner approved scheduler fix and push on 2026-09-05.
|
|
467
|
+
assert.strictEqual(pkg.version, "3.4.7", "release version must remain unchanged without explicit approval");
|
|
467
468
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
468
469
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
469
470
|
}
|
|
@@ -633,6 +633,88 @@ async function busyRestartAndMissedProbe(createSchedulerService) {
|
|
|
633
633
|
assert.strictEqual(missedTimers.length, 1, "a missed wakeup gets exactly one startup attempt");
|
|
634
634
|
}
|
|
635
635
|
|
|
636
|
+
async function durablePendingProbe(createSchedulerService, jobsApi) {
|
|
637
|
+
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-pending-restart-"));
|
|
638
|
+
let clock = 1_720_000_000_500;
|
|
639
|
+
const openStore = () => jobsApi.createJobsStore({ configDir, jobsFile: path.join(configDir, "jobs.json"), now: () => clock });
|
|
640
|
+
const success = { ok: true, status: "succeeded", delivery: { ok: true } };
|
|
641
|
+
try {
|
|
642
|
+
for (const kind of ["wakeup", "watch", "expiry"]) {
|
|
643
|
+
const store = openStore();
|
|
644
|
+
store.add(baseJob({ id: kind, ...(kind === "wakeup" ? {} : {
|
|
645
|
+
kind: "cron", schedule: "* * * * *", check: "fixture-check", repeat: true,
|
|
646
|
+
expiresAt: kind === "expiry" ? clock - 1 : clock + 1000,
|
|
647
|
+
}) }));
|
|
648
|
+
let checks = 0;
|
|
649
|
+
const h = schedulerHarness(createSchedulerService, [], {
|
|
650
|
+
jobsStore: store, now: () => clock,
|
|
651
|
+
runWatchCheck: async () => { checks++; return { code: kind === "expiry" ? 1 : 0 }; },
|
|
652
|
+
});
|
|
653
|
+
h.state.runningProcess = { pid: 1 };
|
|
654
|
+
for (let tick = 0; tick < 12; tick++) {
|
|
655
|
+
clock += 60 * 60 * 1000;
|
|
656
|
+
const result = await h.service.fireJob(kind);
|
|
657
|
+
assert.strictEqual(result.deferred, true, "hours of busy time must remain queued");
|
|
658
|
+
}
|
|
659
|
+
assert.strictEqual(store.get(kind).attemptCount, 0);
|
|
660
|
+
assert.strictEqual(store.get(kind).disabled, false);
|
|
661
|
+
assert.strictEqual(checks, kind === "wakeup" ? 0 : 1);
|
|
662
|
+
assert.strictEqual(h.sends.length, 0);
|
|
663
|
+
h.service.stopAll();
|
|
664
|
+
|
|
665
|
+
// Reopen the real on-disk store and construct a new scheduler, as boot does.
|
|
666
|
+
const reloaded = openStore();
|
|
667
|
+
const terminal = deferred();
|
|
668
|
+
const restarted = schedulerHarness(createSchedulerService, [], {
|
|
669
|
+
jobsStore: reloaded, now: () => clock, outcomes: [terminal.promise, success],
|
|
670
|
+
runWatchCheck: async () => { throw new Error("pending watch must not poll after restart"); },
|
|
671
|
+
});
|
|
672
|
+
restarted.service.init();
|
|
673
|
+
const firing = restarted.scheduled[0].fn();
|
|
674
|
+
await nextTurn();
|
|
675
|
+
clock += 60 * 60 * 1000;
|
|
676
|
+
assert.strictEqual((await restarted.service.fireJob(kind)).inFlight, true, "long runs cannot re-enter after retry delay");
|
|
677
|
+
assert.strictEqual(restarted.runCalls.length, 1);
|
|
678
|
+
assert.strictEqual(restarted.sends.length, 1);
|
|
679
|
+
if (kind === "expiry") assert.match(restarted.runCalls[0].prompt, /Watch expired/);
|
|
680
|
+
terminal.resolve(success);
|
|
681
|
+
await firing;
|
|
682
|
+
if (kind === "watch") {
|
|
683
|
+
assert.strictEqual(reloaded.get(kind).status, "pending");
|
|
684
|
+
assert.strictEqual(reloaded.get(kind).watchTriggeredOccurrenceId, null);
|
|
685
|
+
restarted.service.removeJob(kind);
|
|
686
|
+
} else assert.strictEqual(reloaded.get(kind), null);
|
|
687
|
+
restarted.service.stopAll();
|
|
688
|
+
const finalBoot = schedulerHarness(createSchedulerService, [], { jobsStore: openStore(), now: () => clock });
|
|
689
|
+
finalBoot.service.init();
|
|
690
|
+
assert.strictEqual(finalBoot.scheduled.length, 0, "successful one-shot cannot replay on restart");
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const store = openStore();
|
|
694
|
+
store.add(baseJob({ id: "failure", kind: "cron", schedule: "* * * * *", check: "fixture" }));
|
|
695
|
+
const failed = schedulerHarness(createSchedulerService, [], {
|
|
696
|
+
jobsStore: store, now: () => clock,
|
|
697
|
+
runWatchCheck: async () => ({ code: 0 }), outcomes: [{ ok: false, error: { code: "FIXTURE_FAILED" } }],
|
|
698
|
+
});
|
|
699
|
+
await failed.service.fireJob("failure");
|
|
700
|
+
failed.service.stopAll();
|
|
701
|
+
clock += 2000;
|
|
702
|
+
const retry = schedulerHarness(createSchedulerService, [], {
|
|
703
|
+
jobsStore: openStore(), now: () => clock,
|
|
704
|
+
runWatchCheck: async () => { throw new Error("do not recheck"); },
|
|
705
|
+
outcomes: [{ ok: false }, { ok: false }],
|
|
706
|
+
});
|
|
707
|
+
await retry.service.fireJob("failure");
|
|
708
|
+
await retry.service.fireJob("failure");
|
|
709
|
+
assert.strictEqual(retry.jobsStore.get("failure").status, "dead-letter", "real failures retain bounded retries");
|
|
710
|
+
assert.strictEqual(retry.jobsStore.get("failure").attemptCount, 3);
|
|
711
|
+
assert.strictEqual(retry.sends.length, 0, "advisory is not repeated across failure/restart");
|
|
712
|
+
retry.service.stopAll();
|
|
713
|
+
} finally {
|
|
714
|
+
fs.rmSync(configDir, { recursive: true, force: true });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
636
718
|
async function unavailableAndFallbackProbe(createSchedulerService) {
|
|
637
719
|
const noFallback = schedulerHarness(createSchedulerService, [baseJob({ id: "unavailable" })], {
|
|
638
720
|
providerStatus: () => ({ availability: { state: "missing" }, auth: { state: "not_checked" } }),
|
|
@@ -960,6 +1042,7 @@ async function main() {
|
|
|
960
1042
|
await failureAndRetryProbe(schedulerApi.createSchedulerService);
|
|
961
1043
|
await detachedHistoricalBranchProbe(schedulerApi.createSchedulerService);
|
|
962
1044
|
await busyRestartAndMissedProbe(schedulerApi.createSchedulerService);
|
|
1045
|
+
await durablePendingProbe(schedulerApi.createSchedulerService, jobsApi);
|
|
963
1046
|
await unavailableAndFallbackProbe(schedulerApi.createSchedulerService);
|
|
964
1047
|
await cronOccurrenceProbe(schedulerApi.createSchedulerService);
|
|
965
1048
|
await watchGateProbe(schedulerApi.createSchedulerService);
|