@inetafrica/open-claudia 3.4.5 → 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 CHANGED
@@ -1,5 +1,15 @@
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
+
7
+ ## v3.4.6 — Codex runs again: the gate stopped asking for the impossible
8
+
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.
10
+ - **Why it is safe to stop requiring it off.** Probed live on 0.153.4 (2026-09-05): a shell command run through unified exec still arrives at the PreToolUse hook as tool_name `Bash` with `tool_input.command`, and the real deny gate caught the probe command (deny audited, marker file never written). Unified exec is a covered transport, not an uncovered one. Plugins stay forced off and hooks stay forced on; project trust stays untrusted.
11
+ - **What changed.** `core/providers/codex-hook.js` no longer emits the `features.unified_exec=false` override nor checks its state. Fixtures and the tool-hook tests now model Codex as it is (`unified_exec stable true`) and assert no unified_exec override is generated. Fleet pods are affected identically (the 3.4.4 and 3.4.5 images carry Codex 0.153.x), so Codex on pods works only from this release onward.
12
+
3
13
  ## v3.4.5 — Astra on the picker, two shortcuts fewer
4
14
 
5
15
  - **gpt-6-astra is offered by the codex provider.** OpenAI rolled GPT-6 (Astra) out to ChatGPT-plan Codex accounts on 2026-09-05 — the earlier 400 (`not supported when using Codex with a ChatGPT account`) was a staged rollout, not client staleness. It sits first in `CODEX_MODELS`, so it appears as a `/model` button; the typed form `/model gpt-6-astra` already worked because the typed path never validated against the list. The default model is unchanged (`gpt-5.6-sol` unless `CODEX_MODEL` is set).
@@ -38,7 +38,12 @@ function buildCodexHookOverrides({ cwd, nodePath, cliPath } = {}) {
38
38
  return Object.freeze({
39
39
  featuresHooks: "features.hooks=true",
40
40
  featuresPlugins: "features.plugins=false",
41
- featuresUnifiedExec: "features.unified_exec=false",
41
+ // unified_exec is deliberately NOT forced off. Probed live 2026-09-05 on
42
+ // Codex 0.153.4 (0.153.2 and 0.153.3 behave the same): `-c
43
+ // features.unified_exec=false` is silently ignored — the feature is locked
44
+ // on — while unified-exec shell calls still reach PreToolUse as tool_name
45
+ // "Bash" (the deny gate observed the probe command, marker not executed).
46
+ // Requiring it off made every Codex turn fail MANDATORY_TOOL_HOOK_UNAVAILABLE.
42
47
  // A path is a dynamic TOML map key. Current Codex `-c` parsing rejects a
43
48
  // quoted path inside dotted-key notation, so replace the isolated projects
44
49
  // table with one explicit inline table (user config is ignored below).
@@ -58,7 +63,6 @@ function hookArgs(overrides) {
58
63
  "--strict-config",
59
64
  "-c", overrides.featuresHooks,
60
65
  "-c", overrides.featuresPlugins,
61
- "-c", overrides.featuresUnifiedExec,
62
66
  "-c", overrides.projectTrust,
63
67
  "-c", overrides.preToolUse,
64
68
  "--dangerously-bypass-hook-trust",
@@ -150,15 +154,13 @@ function checkCodexHookCompatibility({ binary, runCommand, env, cwd, prepared =
150
154
  "features",
151
155
  "-c", o.featuresHooks,
152
156
  "-c", o.featuresPlugins,
153
- "-c", o.featuresUnifiedExec,
154
157
  "-c", o.projectTrust,
155
158
  "-c", o.preToolUse,
156
159
  "list",
157
160
  ], opts);
158
161
  const configured = features.ok
159
162
  && featureState(features.output, "hooks", true)
160
- && featureState(features.output, "plugins", false)
161
- && featureState(features.output, "unified_exec", false);
163
+ && featureState(features.output, "plugins", false);
162
164
  result = configured
163
165
  ? { ok: true, code: null, reason: null }
164
166
  : {
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: dead ? "dead-letter" : "deferred",
350
- disabled: dead,
349
+ status: "deferred",
350
+ disabled: false,
351
351
  busyDeferralCount,
352
- nextAttemptAt: dead ? null : now() + busyDelayMs,
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
- if (!dead) armRetry(updated, busyDelayMs);
357
- else unscheduleJob(job.id);
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
- try {
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
- logWatch(job, "triggered (check passed) — waking agent");
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
- try { await send(`${advisory}${providerNote}`); } catch (_) { /* advisory only */ }
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 or failed occurrences retain their occurrence ID and bounded attempt state across restart; exhaustion and wakeups outside the missed-fire grace window remain visible as disabled dead letters. 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.
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.
@@ -246,7 +246,9 @@ Verified-OK pathways (no change needed): scheduler re-runs stamp
246
246
  `OC_CHANNEL_USER_ID` (file gate stays live on approved external re-runs);
247
247
  Spaces turns flow through the common router (triage covered); sub-agents
248
248
  spawn read-only and inherit channel env; codex transport confinement
249
- (hooks on, plugins/unified_exec off, untrusted trust) is mandatory-or-refuse.
249
+ (hooks on, plugins off, untrusted trust) is mandatory-or-refuse. unified_exec is
250
+ no longer forced off (2026-09-05): Codex 0.153.x locks it on and its shell calls
251
+ still reach the PreToolUse hook as "Bash".
250
252
 
251
253
  G3 closed with P3 (test-approval-cards.js pins the `apr:` handler). `tool run`
252
254
  remains the one authorised channel reachable on external turns — by design:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.4.5",
3
+ "version": "3.4.7",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -143,7 +143,7 @@ function handleCompatibilityCommand() {
143
143
  process.stdout.write([
144
144
  `hooks stable ${overrides.includes("features.hooks=true")}`,
145
145
  `plugins stable ${!overrides.includes("features.plugins=false")}`,
146
- `unified_exec stable ${!overrides.includes("features.unified_exec=false")}`,
146
+ "unified_exec stable true",
147
147
  ].join("\n") + "\n");
148
148
  return true;
149
149
  }
@@ -160,7 +160,6 @@ function runHookProbe() {
160
160
  && !argv.includes("--profile")
161
161
  && overrides.includes("features.hooks=true")
162
162
  && overrides.includes("features.plugins=false")
163
- && overrides.includes("features.unified_exec=false")
164
163
  && overrides.some((value) => /^projects=\{.+trust_level="untrusted".+\}$/.test(value))
165
164
  && hookOverrides.length === 1
166
165
  && /matcher\s*=\s*"\^\(Bash\|apply_patch\)\$"/.test(hookOverrides[0]);
@@ -34,7 +34,7 @@ if (same("--version")) {
34
34
  ? "--config --json --sandbox --model --skip-git-repo-check --strict-config --ignore-user-config --dangerously-bypass-hook-trust\n"
35
35
  : "--config --json --sandbox --image --model --skip-git-repo-check --strict-config --ignore-user-config --dangerously-bypass-hook-trust\n")});
36
36
  } else if (${JSON.stringify(kind)} === "codex" && args[0] === "features" && args.at(-1) === "list") {
37
- process.stdout.write("hooks stable true\\nplugins stable false\\nunified_exec stable false\\n");
37
+ process.stdout.write("hooks stable true\\nplugins stable false\\nunified_exec stable true\\n");
38
38
  } else if (${JSON.stringify(kind)} === "codex" && same("login", "status")) {
39
39
  if (${JSON.stringify(mode)} === "unauthenticated") {
40
40
  process.stderr.write("not logged in\\n");
@@ -456,7 +456,15 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
456
456
  // joins CODEX_MODELS first; the /claude and /codex shortcuts go — /backend and
457
457
  // the /model picker already switch providers; Codex CLI bumped to 0.153.4 on
458
458
  // the operator host and the image (unpinned install) picks it up at build.
459
- assert.strictEqual(pkg.version, "3.4.5", "release version must remain unchanged without explicit approval");
459
+ // 3.4.6 prepared 2026-09-05 after Sumeet sent a screenshot of the first Codex
460
+ // turn on 3.4.5 failing ("Codex rejected the isolated PreToolUse configuration
461
+ // or could not disable uncovered tool transports"): Codex 0.153.x ignores
462
+ // `-c features.unified_exec=false` (locked on), so the hook gate refused every
463
+ // Codex turn. Live probe: unified-exec shell calls still reach PreToolUse as
464
+ // tool_name "Bash" and the deny gate caught the probe, so the gate no longer
465
+ // requires unified_exec off. Push approved by Sumeet before release.
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");
460
468
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
461
469
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
462
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);
@@ -59,7 +59,7 @@ function compatibleCodexCommand(_binary, args) {
59
59
  output: [
60
60
  "hooks stable true",
61
61
  "plugins stable false",
62
- "unified_exec stable false",
62
+ "unified_exec stable true",
63
63
  ].join("\n"),
64
64
  code: 0,
65
65
  };
@@ -137,12 +137,15 @@ try {
137
137
  assert.ok(defaultClaudeInvocation.args.includes("--settings"), "Claude factories enforce the generated hook by default");
138
138
 
139
139
  // Codex uses only transient, generated CLI overrides. User config is ignored,
140
- // the current project is explicitly untrusted, plugin hooks and unified_exec
141
- // are disabled, and hook trust is bypassed only after exact verification.
140
+ // the current project is explicitly untrusted, plugin hooks are disabled, and
141
+ // hook trust is bypassed only after exact verification. unified_exec is NOT
142
+ // forced off: Codex 0.153.x ignores the override (locked on) and its shell
143
+ // calls still arrive as tool_name "Bash" (probed live 2026-09-05).
142
144
  const overrides = buildCodexHookOverrides({ cwd: projectDir });
143
145
  assert.strictEqual(overrides.featuresHooks, "features.hooks=true");
144
146
  assert.strictEqual(overrides.featuresPlugins, "features.plugins=false");
145
- assert.strictEqual(overrides.featuresUnifiedExec, "features.unified_exec=false");
147
+ assert.strictEqual(overrides.featuresUnifiedExec, undefined, "unified_exec is no longer forced off (Codex ignores it)");
148
+ assert.ok(!Object.values(overrides).some((value) => String(value).includes("unified_exec")), "no unified_exec override is generated");
146
149
  assert.match(overrides.projectTrust, /^projects=\{.+trust_level="untrusted".+\}$/);
147
150
  assert.match(overrides.preToolUse, /^hooks\.PreToolUse=/);
148
151
  assert.match(overrides.preToolUse, /deny-gate-hook/);
@@ -158,6 +161,7 @@ try {
158
161
  assert.ok(prepared.args.includes("--ignore-user-config"));
159
162
  assert.ok(prepared.args.includes("--strict-config"));
160
163
  assert.ok(prepared.args.includes("--dangerously-bypass-hook-trust"));
164
+ assert.ok(!prepared.args.some((value) => String(value).includes("features.unified_exec")), "argv carries no unified_exec override");
161
165
  assert.ok(!prepared.args.includes("--profile"), "a merging Codex profile is never trusted");
162
166
  const tampered = Object.freeze({ ...prepared, args: Object.freeze([...prepared.args, "--profile", "project-hooks"]) });
163
167
  assert.strictEqual(verifyPreparedCodexHook(tampered), false, "trust bypass rejects non-generated argv");