@inetafrica/open-claudia 2.14.8 → 3.0.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.
Files changed (157) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +87 -66
  4. package/bin/agent.js +44 -18
  5. package/bin/cli.js +30 -28
  6. package/bin/dream.js +5 -11
  7. package/bin/loopback-client.js +29 -5
  8. package/bin/schedule.js +19 -5
  9. package/bin/tool.js +23 -5
  10. package/bot-agent.js +5 -2350
  11. package/bot.js +105 -7
  12. package/channels/telegram/adapter.js +6 -1
  13. package/channels/voice/adapter.js +1 -0
  14. package/channels/voice/manage.js +43 -5
  15. package/config-dir.js +3 -11
  16. package/core/actions.js +149 -40
  17. package/core/approvals.js +136 -29
  18. package/core/auth-flow.js +103 -19
  19. package/core/config-dir.js +19 -0
  20. package/core/config.js +127 -69
  21. package/core/doctor.js +111 -57
  22. package/core/dream.js +219 -62
  23. package/core/enforcer.js +0 -0
  24. package/core/entities.js +2 -1
  25. package/core/fsutil.js +55 -0
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/identity.js +8 -11
  29. package/core/io.js +24 -3
  30. package/core/jobs.js +589 -72
  31. package/core/lessons.js +3 -2
  32. package/core/loopback.js +45 -8
  33. package/core/memory-mutation-queue.js +241 -0
  34. package/core/migration-backup.js +1060 -0
  35. package/core/pack-review.js +295 -88
  36. package/core/packs.js +4 -3
  37. package/core/persona.js +2 -1
  38. package/core/process-tree.js +19 -2
  39. package/core/provider-migration.js +39 -0
  40. package/core/provider-status.js +80 -0
  41. package/core/providers/claude-events.js +117 -0
  42. package/core/providers/claude-hook.js +114 -0
  43. package/core/providers/claude.js +296 -0
  44. package/core/providers/codex-events.js +121 -0
  45. package/core/providers/codex-hook.js +280 -0
  46. package/core/providers/codex.js +286 -0
  47. package/core/providers/contract.js +153 -0
  48. package/core/providers/env.js +148 -0
  49. package/core/providers/events.js +170 -0
  50. package/core/providers/hook-command.js +22 -0
  51. package/core/providers/index.js +240 -0
  52. package/core/providers/utility-policy.js +269 -0
  53. package/core/recall/classic.js +2 -2
  54. package/core/recall/discoverer.js +71 -22
  55. package/core/recall/metrics.js +27 -1
  56. package/core/recall/tuning.js +4 -1
  57. package/core/recall/warm-walker.js +151 -108
  58. package/core/recall-filter.js +86 -61
  59. package/core/router.js +79 -7
  60. package/core/run-context.js +185 -0
  61. package/core/runner.js +1415 -1282
  62. package/core/scheduler.js +515 -210
  63. package/core/side-chat.js +346 -0
  64. package/core/state.js +1096 -95
  65. package/core/subagent.js +72 -98
  66. package/core/system-prompt.js +462 -279
  67. package/core/tool-guard.js +73 -39
  68. package/core/tools.js +22 -5
  69. package/core/transcripts.js +30 -1
  70. package/core/usage-log.js +19 -4
  71. package/core/utility-agent.js +654 -0
  72. package/core/web-auth.js +29 -13
  73. package/core/web-sessions.js +78 -0
  74. package/docs/CHANNEL_DESIGN.md +181 -0
  75. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  76. package/docs/PROVIDER_MIGRATION.md +67 -0
  77. package/health.js +50 -39
  78. package/package.json +51 -4
  79. package/setup.js +198 -38
  80. package/soul.md +39 -0
  81. package/test-ability-extraction.js +5 -0
  82. package/test-approval-async.js +63 -4
  83. package/test-cursor-removal.js +334 -0
  84. package/test-delivery-contract.js +88 -0
  85. package/test-enforcer.js +70 -27
  86. package/test-fixtures/current-provider-parity.json +132 -0
  87. package/test-fixtures/fake-agent-cli.js +477 -0
  88. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  89. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  90. package/test-fixtures/migrations/claude-only/state.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  92. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  93. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  94. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  95. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  96. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  97. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  98. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  100. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  101. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  102. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  103. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  104. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  105. package/test-fixtures/migrations/missing-project/state.json +4 -0
  106. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  107. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  108. package/test-fixtures/migrations/multi-user/state.json +6 -0
  109. package/test-fixtures/provider-event-samples.json +17 -0
  110. package/test-learning-e2e.js +5 -0
  111. package/test-memory-mutation-queue.js +191 -0
  112. package/test-provider-boot-matrix.js +399 -0
  113. package/test-provider-bootstrap.js +158 -0
  114. package/test-provider-capabilities.js +232 -0
  115. package/test-provider-claude.js +206 -0
  116. package/test-provider-codex.js +228 -0
  117. package/test-provider-compaction.js +371 -0
  118. package/test-provider-core-prompt.js +264 -0
  119. package/test-provider-coupling-audit.js +90 -0
  120. package/test-provider-dream.js +312 -0
  121. package/test-provider-enforcer.js +252 -0
  122. package/test-provider-env-isolation.js +150 -0
  123. package/test-provider-events.js +141 -0
  124. package/test-provider-fixture.js +332 -0
  125. package/test-provider-gateway.js +508 -0
  126. package/test-provider-language.js +89 -0
  127. package/test-provider-migration-backup.js +424 -0
  128. package/test-provider-pack-review.js +436 -0
  129. package/test-provider-parity-e2e.js +537 -0
  130. package/test-provider-prompt-parity.js +89 -0
  131. package/test-provider-recall.js +271 -0
  132. package/test-provider-registry.js +251 -0
  133. package/test-provider-resume-parity.js +41 -0
  134. package/test-provider-run-lifecycle.js +349 -0
  135. package/test-provider-runner.js +271 -0
  136. package/test-provider-scheduler.js +689 -0
  137. package/test-provider-session-commands.js +185 -0
  138. package/test-provider-session-history.js +205 -0
  139. package/test-provider-side-chat.js +337 -0
  140. package/test-provider-state-migration.js +316 -0
  141. package/test-provider-stream-decoder.js +69 -0
  142. package/test-provider-subagent.js +228 -0
  143. package/test-provider-tool-hooks.js +360 -0
  144. package/test-recall-discoverer.js +1 -0
  145. package/test-recall-engine.js +3 -3
  146. package/test-recall-evolution.js +18 -0
  147. package/test-run-lock.js +63 -0
  148. package/test-runner-watchdog-static.js +16 -8
  149. package/test-single-runtime.js +35 -0
  150. package/test-telegram-poll-recovery.js +56 -0
  151. package/test-tool-guard.js +56 -0
  152. package/test-tools.js +19 -0
  153. package/test-unified-identity.js +3 -1
  154. package/test-usage-accounting.js +92 -20
  155. package/test-utility-provider-policy.js +486 -0
  156. package/test-web-sessions.js +74 -0
  157. package/web.js +159 -45
package/core/scheduler.js CHANGED
@@ -1,258 +1,563 @@
1
- // Unified scheduler owns every timer the bot has running. Replaces
2
- // the old core/cron.js. Loads jobs.json on boot, schedules wakeups
3
- // (setTimeout) and crons (node-cron), and fires each one in the right
4
- // chat context with the right session pinned.
5
- //
6
- // When a wakeup fires the agent wakes up inside the SAME Claude session
7
- // it was in when it scheduled the wakeup (per design choice a/i). If
8
- // the channel is busy with a live turn the fire is deferred 30s and
9
- // retried. One-shot wakeups self-delete; crons persist.
1
+ // Provider-aware durable scheduler. Every occurrence executes the immutable
2
+ // user/project/provider/settings/session tuple captured when it was created.
3
+ // One-shots are removed only after a terminal successful RunResult and a
4
+ // durable success marker; failures retain the same occurrence ID for bounded
5
+ // retry and eventually become explicit dead letters.
10
6
 
11
- const path = require("path");
7
+ "use strict";
8
+
9
+ const crypto = require("crypto");
12
10
  const fs = require("fs");
11
+ const path = require("path");
13
12
  const cron = require("node-cron");
14
13
 
15
- const { CRONS_FILE, WORKSPACE, CHAT_ID } = require("./config");
16
- const jobs = require("./jobs");
17
- const { runInChat } = require("./context");
18
- const { getUserState, userStates } = require("./state");
19
- const { canonicalForTelegram, canonicalForChannel } = require("./identity");
20
- const { send } = require("./io");
14
+ const {
15
+ CRONS_FILE,
16
+ WORKSPACE,
17
+ config,
18
+ } = require("./config");
19
+ const defaultJobsStore = require("./jobs");
20
+ const { runInChat: defaultRunInChat } = require("./context");
21
+ const {
22
+ currentState,
23
+ getActiveProvider,
24
+ getProjectKey,
25
+ getProviderSession,
26
+ getProviderSettings,
27
+ getUserState,
28
+ } = require("./state");
29
+ const { canonicalForChannel } = require("./identity");
30
+ const { send: defaultSend } = require("./io");
31
+ const { providerStatus: defaultProviderStatus } = require("./providers");
32
+ const { redactSensitive } = require("./redact");
21
33
 
22
34
  const MISSED_WAKEUP_GRACE_MS = 60 * 60 * 1000;
23
- const DEFER_BUSY_MS = 30 * 1000;
35
+ const RETRY_DELAY_MS = 30 * 1000;
36
+ const BUSY_DELAY_MS = 30 * 1000;
24
37
 
25
- const timers = new Map();
26
- let adapters = [];
27
-
28
- function setAdapters(list) {
29
- adapters = Array.isArray(list) ? list.slice() : [];
38
+ function uniqueId(prefix = "occ") {
39
+ return typeof crypto.randomUUID === "function"
40
+ ? `${prefix}-${crypto.randomUUID()}`
41
+ : `${prefix}-${Date.now()}-${crypto.randomBytes(6).toString("hex")}`;
30
42
  }
31
43
 
32
- function resolveAdapter(job) {
33
- if (job.adapter) {
34
- const byId = adapters.find((a) => a.id === job.adapter);
35
- if (byId) return byId;
36
- }
37
- if (job.adapterType) {
38
- const byType = adapters.find((a) => a.type === job.adapterType);
39
- if (byType) return byType;
40
- }
41
- return adapters[0] || null;
44
+ function parseFallbacks(value) {
45
+ if (Array.isArray(value)) return [...new Set(value.map((item) => String(item).trim().toLowerCase()).filter(Boolean))];
46
+ return [...new Set(String(value || "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean))];
42
47
  }
43
48
 
44
- function projectDir(job) {
45
- if (!job.project) return null;
46
- if (path.isAbsolute(job.project)) return job.project;
47
- if (!WORKSPACE) return null;
48
- return path.join(WORKSPACE, job.project);
49
+ function terminalRunSucceeded(result) {
50
+ if (!result || result.ok !== true || (result.status && result.status !== "succeeded")) return false;
51
+ if (result.persistence && Object.values(result.persistence).some((stage) => stage && stage.ok === false)) return false;
52
+ if (!result.delivery || result.delivery.ok !== true) return false;
53
+ return true;
49
54
  }
50
55
 
51
- function deriveCanonicalUserId(adapter, job) {
52
- if (job.canonicalUserId) return job.canonicalUserId;
53
- if (!adapter) return null;
54
- return canonicalForChannel(adapter.type, job.channelId);
56
+ function providerIsRunnable(status) {
57
+ if (!status || status.availability?.state !== "available") return false;
58
+ if (status.compatibility?.state === "incompatible") return false;
59
+ if (status.auth?.state === "unauthenticated") return false;
60
+ return true;
55
61
  }
56
62
 
57
- function deriveUserId(adapter, job) {
58
- if (job.userId) return String(job.userId);
59
- if (adapter && adapter.type === "telegram") return String(job.channelId);
60
- return String(job.canonicalUserId || job.channelId || "");
63
+ function stableFailure(errorOrResult) {
64
+ const error = errorOrResult?.error || errorOrResult;
65
+ const code = String(error?.code || errorOrResult?.status || "SCHEDULED_RUN_FAILED").slice(0, 80);
66
+ const message = redactSensitive(String(
67
+ error?.message
68
+ || errorOrResult?.diagnostic
69
+ || (Number.isFinite(errorOrResult?.exitCode) ? `Provider exited with code ${errorOrResult.exitCode}` : "Scheduled provider run failed"),
70
+ )).slice(0, 500);
71
+ return { code, message };
61
72
  }
62
73
 
63
- async function fireJob(job, retry = 0) {
64
- const adapter = resolveAdapter(job);
65
- if (!adapter) {
66
- console.error(`scheduler: no adapter for job ${job.id} (${job.label || job.kind})`);
67
- return;
74
+ function createSchedulerService(dependencies = {}) {
75
+ const jobsStore = dependencies.jobsStore || defaultJobsStore;
76
+ const workspace = dependencies.workspace === undefined ? WORKSPACE : dependencies.workspace;
77
+ const now = dependencies.now || Date.now;
78
+ const setTimer = dependencies.setTimer || setTimeout;
79
+ const clearTimer = dependencies.clearTimer || clearTimeout;
80
+ const scheduleCron = dependencies.scheduleCron || ((expression, callback) => cron.schedule(expression, callback, { noOverlap: true }));
81
+ const validateCron = dependencies.validateCron || cron.validate;
82
+ const runInChat = dependencies.runInChat || defaultRunInChat;
83
+ const stateForUser = dependencies.getUserState || getUserState;
84
+ const send = dependencies.send || defaultSend;
85
+ const providerStatus = dependencies.providerStatus || defaultProviderStatus;
86
+ const fallbackProviders = parseFallbacks(
87
+ dependencies.fallbackProviders === undefined ? config.PROVIDER_FALLBACKS : dependencies.fallbackProviders,
88
+ );
89
+ const retryDelayMs = Number.isFinite(dependencies.retryDelayMs) ? dependencies.retryDelayMs : RETRY_DELAY_MS;
90
+ const busyDelayMs = Number.isFinite(dependencies.busyDelayMs) ? dependencies.busyDelayMs : BUSY_DELAY_MS;
91
+ const missedGraceMs = Number.isFinite(dependencies.missedGraceMs) ? dependencies.missedGraceMs : MISSED_WAKEUP_GRACE_MS;
92
+ const occurrenceIdFactory = dependencies.occurrenceIdFactory || (() => uniqueId("occ"));
93
+ const legacyCronsFile = dependencies.legacyCronsFile === undefined
94
+ ? (dependencies.jobsStore ? null : CRONS_FILE)
95
+ : dependencies.legacyCronsFile;
96
+ const runInProvider = dependencies.runAgent || ((...args) => require("./runner").runAgent(...args));
97
+ const admit = dependencies.admitRunContext || ((...args) => require("./runner").admitRunContext(...args));
98
+ const timers = new Map();
99
+ let adapters = Array.isArray(dependencies.adapters) ? dependencies.adapters.slice() : [];
100
+
101
+ function setAdapters(list) {
102
+ adapters = Array.isArray(list) ? list.slice() : [];
68
103
  }
69
- const canonicalUserId = deriveCanonicalUserId(adapter, job);
70
- const userId = deriveUserId(adapter, job);
71
-
72
- const ctx = {
73
- adapter,
74
- channelId: String(job.channelId),
75
- canonicalUserId,
76
- userId,
77
- transport: adapter.type,
78
- raw: null,
79
- };
80
104
 
81
- let deferred = false;
82
- await runInChat(ctx, async () => {
83
- const state = getUserState(canonicalUserId);
84
- if (state.runningProcess) {
85
- if (retry < 4) {
86
- console.log(`scheduler: ${job.id} busy, deferring ${DEFER_BUSY_MS / 1000}s (retry ${retry + 1})`);
87
- deferred = true;
88
- setTimeout(() => fireJob(job, retry + 1), DEFER_BUSY_MS);
89
- return;
90
- }
91
- console.warn(`scheduler: ${job.id} still busy after retries, skipping`);
92
- jobs.update(job.id, { lastFireAt: Date.now(), lastFireOk: false });
93
- return;
105
+ function resolveAdapter(job) {
106
+ if (job.adapter) {
107
+ const exact = adapters.find((adapter) => adapter.id === job.adapter);
108
+ if (exact) return exact;
109
+ }
110
+ if (job.adapterType) {
111
+ const byType = adapters.find((adapter) => adapter.type === job.adapterType);
112
+ if (byType) return byType;
94
113
  }
114
+ return null;
115
+ }
116
+
117
+ function projectDescriptor(job) {
118
+ const explicitDir = job.projectDir && path.isAbsolute(job.projectDir) ? job.projectDir : null;
119
+ const legacyAbsolute = job.project && path.isAbsolute(job.project) ? job.project : null;
120
+ const name = legacyAbsolute ? path.basename(legacyAbsolute) : String(job.project || "");
121
+ const dir = explicitDir || legacyAbsolute || (workspace && name ? path.join(workspace, name) : null);
122
+ return { name, dir: dir || workspace || process.cwd() };
123
+ }
95
124
 
96
- const cwd = projectDir(job) || (state.currentSession && state.currentSession.dir) || WORKSPACE || process.cwd();
97
- const label = job.label || (job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup");
98
-
99
- try {
100
- await send(`Wakeup: ${label}`);
101
- } catch (e) { /* non-fatal */ }
102
-
103
- const wrappedPrompt = job.kind === "cron"
104
- ? `[Scheduled cron \"${label}\" fired at ${new Date().toISOString()}]\n${job.prompt}`
105
- : `[Scheduled wakeup \"${label}\" fired at ${new Date().toISOString()}]\n${job.prompt}`;
106
-
107
- const { runClaude } = require("./runner");
108
- const runOpts = {};
109
- // Join the channel's live conversation. Only fall back to the wakeup's
110
- // frozen sessionId when there's no live pointer (cold start) — otherwise
111
- // a stale, compacted-away id would yank the channel onto a dead thread.
112
- if (!state.lastSessionId && job.sessionId) runOpts.resumeSessionId = job.sessionId;
113
- try {
114
- await runClaude(wrappedPrompt, cwd, null, runOpts);
115
- jobs.update(job.id, { lastFireAt: Date.now(), lastFireOk: true });
116
- } catch (e) {
117
- console.error(`scheduler: ${job.id} run error:`, e.message);
118
- jobs.update(job.id, { lastFireAt: Date.now(), lastFireOk: false });
119
- try { await send(`Wakeup "${label}" failed: ${e.message}`); } catch (_) {}
125
+ function resolveCompactionLineage(state, job) {
126
+ let sessionId = job.sessionId || null;
127
+ if (!sessionId) return { sessionId: null, attachSession: true };
128
+ const seen = new Set();
129
+ while (sessionId && !seen.has(sessionId)) {
130
+ seen.add(sessionId);
131
+ const successor = Object.values(state?.completedCompactions || {}).find((entry) => (
132
+ entry?.provider === job.provider
133
+ && entry?.project === job.project
134
+ && entry?.previousSessionId === sessionId
135
+ && entry?.newSessionId
136
+ ));
137
+ if (!successor) break;
138
+ sessionId = successor.newSessionId;
120
139
  }
121
- });
122
-
123
- // Only delete a one-shot wakeup once it actually ran (or was skipped).
124
- // Removing it while a busy-deferral is pending would leave the retry
125
- // living only in an in-memory setTimeout a restart during that
126
- // window (e.g. /upgrade) would silently lose the wakeup.
127
- if (job.kind === "wakeup" && !deferred) {
128
- jobs.remove(job.id);
129
- timers.delete(job.id);
140
+ const live = getProviderSession(state, job.provider, job.project);
141
+ return {
142
+ sessionId,
143
+ // A historical scheduled branch may run, but it must not replace an
144
+ // unrelated live branch. A proven compaction successor can advance it.
145
+ attachSession: !live || live === sessionId,
146
+ };
130
147
  }
131
- }
132
148
 
133
- function scheduleJob(job) {
134
- if (timers.has(job.id)) {
135
- const prev = timers.get(job.id);
136
- if (prev.stop) prev.stop();
137
- if (prev.timeout) clearTimeout(prev.timeout);
138
- timers.delete(job.id);
149
+ function providerStatusSafe(provider) {
150
+ try { return providerStatus(provider); }
151
+ catch (error) {
152
+ return {
153
+ availability: { state: "missing", reason: error.message },
154
+ compatibility: { state: "not_checked" },
155
+ auth: { state: "not_checked" },
156
+ };
157
+ }
139
158
  }
140
- if (job.kind === "wakeup") {
141
- const delay = Math.max(0, (job.fireAt || 0) - Date.now());
142
- const timeout = setTimeout(() => fireJob(job), delay);
159
+
160
+ function executionTarget(job, state) {
161
+ if (providerIsRunnable(providerStatusSafe(job.provider))) {
162
+ const lineage = resolveCompactionLineage(state, job);
163
+ return {
164
+ provider: job.provider,
165
+ providerSettings: job.providerSettings || {},
166
+ sessionId: lineage.sessionId,
167
+ attachSession: lineage.attachSession,
168
+ fallbackFrom: null,
169
+ fresh: !lineage.sessionId,
170
+ };
171
+ }
172
+ for (const fallback of fallbackProviders) {
173
+ if (fallback === job.provider || !["claude", "codex"].includes(fallback)) continue;
174
+ if (!providerIsRunnable(providerStatusSafe(fallback))) continue;
175
+ return {
176
+ provider: fallback,
177
+ providerSettings: {},
178
+ sessionId: null,
179
+ attachSession: false,
180
+ fallbackFrom: job.provider,
181
+ fresh: true,
182
+ };
183
+ }
184
+ return null;
185
+ }
186
+
187
+ function wrappedPrompt(job, target) {
188
+ const firedAt = new Date(now()).toISOString();
189
+ const header = job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
190
+ if (target.fallbackFrom) {
191
+ return [
192
+ `[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`,
193
+ `[Archived provider-neutral brief: the originating ${target.fallbackFrom} provider is unavailable. Its native session is not being resumed; this explicitly configured fallback starts fresh.]`,
194
+ job.prompt,
195
+ ].join("\n");
196
+ }
197
+ return `[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]\n${job.prompt}`;
198
+ }
199
+
200
+ function unscheduleJob(id) {
201
+ const timer = timers.get(id);
202
+ if (!timer) return;
203
+ try { timer.stop?.(); } catch (_) {}
204
+ try { if (timer.timeout) clearTimer(timer.timeout); } catch (_) {}
205
+ timers.delete(id);
206
+ }
207
+
208
+ function armRetry(job, delay = null) {
209
+ unscheduleJob(job.id);
210
+ if (job.disabled || job.status === "dead-letter" || job.status === "succeeded") return;
211
+ const wait = delay === null
212
+ ? Math.max(0, Number(job.nextAttemptAt || now()) - now())
213
+ : Math.max(0, delay);
214
+ const timeout = setTimer(() => fireJob(job.id, { reason: "retry" }), wait);
143
215
  timers.set(job.id, { timeout });
144
- } else if (job.kind === "cron") {
145
- if (!cron.validate(job.schedule)) {
146
- console.error(`scheduler: invalid cron schedule for ${job.id}: ${job.schedule}`);
147
- return;
216
+ }
217
+
218
+ function markFailure(job, failure, options = {}) {
219
+ const latest = jobsStore.get(job.id) || job;
220
+ const attemptCount = options.attemptAlreadyCounted
221
+ ? Math.max(1, latest.attemptCount || 0)
222
+ : (latest.attemptCount || 0) + 1;
223
+ const dead = attemptCount >= (latest.maxAttempts || 1);
224
+ const patch = {
225
+ status: dead ? "dead-letter" : "failed",
226
+ disabled: dead,
227
+ attemptCount,
228
+ nextAttemptAt: dead ? null : now() + retryDelayMs,
229
+ lastFireAt: now(),
230
+ lastFireOk: false,
231
+ lastError: stableFailure(failure),
232
+ };
233
+ const updated = jobsStore.update(job.id, patch) || { ...latest, ...patch };
234
+ if (!dead) armRetry(updated);
235
+ else unscheduleJob(job.id);
236
+ return { ok: false, status: updated.status, job: updated, error: updated.lastError };
237
+ }
238
+
239
+ function markBusy(job) {
240
+ const latest = jobsStore.get(job.id) || job;
241
+ const busyDeferralCount = (latest.busyDeferralCount || 0) + 1;
242
+ const dead = busyDeferralCount >= (latest.maxAttempts || 1);
243
+ const patch = {
244
+ status: dead ? "dead-letter" : "deferred",
245
+ disabled: dead,
246
+ busyDeferralCount,
247
+ nextAttemptAt: dead ? null : now() + busyDelayMs,
248
+ lastError: dead ? { code: "SCHEDULED_BUSY_EXHAUSTED", message: "Channel remained busy through every bounded deferral" } : null,
249
+ };
250
+ const updated = jobsStore.update(job.id, patch) || { ...latest, ...patch };
251
+ if (!dead) armRetry(updated, busyDelayMs);
252
+ else unscheduleJob(job.id);
253
+ return { ok: false, deferred: !dead, status: updated.status, job: updated };
254
+ }
255
+
256
+ async function settleSuccess(job, result, target) {
257
+ const successPatch = {
258
+ status: "succeeded",
259
+ disabled: false,
260
+ nextAttemptAt: null,
261
+ lastFireAt: now(),
262
+ lastFireOk: true,
263
+ lastError: null,
264
+ completedOccurrenceId: job.occurrenceId,
265
+ lastProvider: target.provider,
266
+ fallbackFromProvider: target.fallbackFrom,
267
+ };
268
+ let marked;
269
+ try { marked = jobsStore.update(job.id, successPatch); }
270
+ catch (error) { return { ok: false, status: "failed", error: stableFailure(error), result }; }
271
+ if (!marked) return { ok: false, status: "failed", error: { code: "JOB_PERSIST_FAILED", message: "Scheduled success marker was not persisted" }, result };
272
+
273
+ if (job.kind === "wakeup") {
274
+ try {
275
+ const removed = jobsStore.remove(job.id);
276
+ if (!removed) throw new Error("successful one-shot could not be removed after its durable success marker");
277
+ unscheduleJob(job.id);
278
+ } catch (error) {
279
+ // The succeeded marker prevents a restart from executing this
280
+ // occurrence twice; init() will retry only the final removal.
281
+ return { ok: false, status: "cleanup-pending", error: stableFailure(error), result };
282
+ }
283
+ return { ok: true, status: "succeeded", result };
148
284
  }
149
- const task = cron.schedule(job.schedule, () => fireJob(job));
150
- timers.set(job.id, { stop: () => task.stop() });
285
+
286
+ const nextOccurrence = occurrenceIdFactory(job);
287
+ const reset = jobsStore.update(job.id, {
288
+ status: "pending",
289
+ disabled: false,
290
+ occurrenceId: nextOccurrence,
291
+ attemptCount: 0,
292
+ busyDeferralCount: 0,
293
+ nextAttemptAt: null,
294
+ });
295
+ if (reset && timers.get(job.id)?.timeout) scheduleJob(reset);
296
+ return reset
297
+ ? { ok: true, status: "succeeded", result, job: reset }
298
+ : { ok: false, status: "failed", error: { code: "JOB_PERSIST_FAILED", message: "Recurring job occurrence was not advanced" }, result };
151
299
  }
152
- }
153
300
 
154
- function unscheduleJob(id) {
155
- const t = timers.get(id);
156
- if (!t) return;
157
- if (t.stop) t.stop();
158
- if (t.timeout) clearTimeout(t.timeout);
159
- timers.delete(id);
160
- }
301
+ async function fireJob(idOrJob, options = {}) {
302
+ const requested = typeof idOrJob === "string" ? jobsStore.get(idOrJob) : idOrJob;
303
+ const job = requested?.id && jobsStore.get(requested.id) ? jobsStore.get(requested.id) : requested;
304
+ if (!job) return { ok: false, status: "missing" };
305
+ if (job.disabled || job.status === "dead-letter" || job.status === "archived") {
306
+ return { ok: false, status: job.status, skipped: true };
307
+ }
308
+ if (job.status === "running" && Number(job.nextAttemptAt || 0) > now()) {
309
+ return { ok: false, status: "running", skipped: true, inFlight: true };
310
+ }
311
+ if (job.status === "succeeded" && job.kind === "wakeup") {
312
+ try { jobsStore.remove(job.id); } catch (_) {}
313
+ unscheduleJob(job.id);
314
+ return { ok: true, status: "succeeded", finalized: true };
315
+ }
161
316
 
162
- function migrateLegacyCrons() {
163
- try {
164
- if (!CRONS_FILE || !fs.existsSync(CRONS_FILE)) return 0;
165
- const raw = JSON.parse(fs.readFileSync(CRONS_FILE, "utf-8"));
166
- if (!Array.isArray(raw) || raw.length === 0) return 0;
167
- const existing = new Set(jobs.listAll().map((j) => j.id));
168
- const tg = adapters.find((a) => a.type === "telegram");
169
- const adapterId = tg ? tg.id : (adapters[0] && adapters[0].id);
170
- const adapterType = tg ? "telegram" : (adapters[0] && adapters[0].type);
171
- const channelId = tg ? (CHAT_ID || "") : "";
172
- const canonicalUserId = adapterType === "telegram"
173
- ? canonicalForTelegram(CHAT_ID || "")
174
- : canonicalForChannel(adapterType || "", channelId);
175
- let migrated = 0;
176
- for (const c of raw) {
177
- if (!c || !c.schedule || !c.prompt) continue;
178
- if (c.id && existing.has(c.id)) continue;
179
- jobs.add({
180
- id: c.id || jobs.nextId("cron"),
181
- kind: "cron",
182
- adapter: adapterId,
183
- adapterType,
184
- channelId: String(channelId),
185
- canonicalUserId,
186
- project: c.project || null,
187
- prompt: c.prompt,
188
- label: c.label || (c.prompt || "").slice(0, 50),
189
- source: "user",
190
- schedule: c.schedule,
191
- sessionKey: null,
192
- sessionId: null,
193
- });
194
- migrated++;
317
+ const adapter = resolveAdapter(job);
318
+ if (!adapter) {
319
+ try { return markFailure(job, { code: "SCHEDULED_ADAPTER_UNAVAILABLE", message: "Saved channel adapter is unavailable" }); }
320
+ catch (error) { return { ok: false, status: "failed", error: stableFailure(error) }; }
195
321
  }
196
- if (migrated > 0) {
197
- const backup = `${CRONS_FILE}.migrated`;
198
- try { fs.renameSync(CRONS_FILE, backup); } catch (e) {}
199
- console.log(`scheduler: migrated ${migrated} legacy cron(s) to jobs.json (backup: ${backup})`);
322
+ const canonicalUserId = job.canonicalUserId || canonicalForChannel(adapter.type, job.channelId);
323
+ const userId = String(job.userId || (adapter.type === "telegram" ? job.channelId : canonicalUserId || job.channelId || ""));
324
+ const chat = {
325
+ adapter,
326
+ channelId: String(job.channelId),
327
+ canonicalUserId,
328
+ userId,
329
+ transport: adapter.type,
330
+ raw: null,
331
+ };
332
+
333
+ return runInChat(chat, async () => {
334
+ const state = stateForUser(canonicalUserId);
335
+ if (state.runningProcess || state.preparingRun || state.isCompacting) {
336
+ try { return markBusy(job); }
337
+ catch (error) { return { ok: false, status: "failed", error: stableFailure(error) }; }
338
+ }
339
+
340
+ const target = executionTarget(job, state);
341
+ if (!target) {
342
+ try {
343
+ return markFailure(job, {
344
+ code: "SCHEDULED_PROVIDER_UNAVAILABLE",
345
+ message: `Saved provider ${job.provider} is unavailable and no explicit fallback is configured`,
346
+ });
347
+ } catch (error) { return { ok: false, status: "failed", error: stableFailure(error) }; }
348
+ }
349
+
350
+ const project = projectDescriptor(job);
351
+ const prompt = wrappedPrompt(job, target);
352
+ const runOptions = {
353
+ purpose: "scheduled",
354
+ provider: target.provider,
355
+ providerSettings: target.providerSettings,
356
+ project,
357
+ fresh: target.fresh,
358
+ resumeSessionId: target.sessionId || null,
359
+ attachSession: target.attachSession,
360
+ requiredDelivery: true,
361
+ occurrenceId: job.occurrenceId,
362
+ scheduledJobId: job.id,
363
+ originRunId: job.originRunId || null,
364
+ };
365
+ // Admission is synchronous and precedes all notification delivery.
366
+ const runContext = admit(prompt, project.dir, null, runOptions);
367
+
368
+ let running;
369
+ try {
370
+ running = jobsStore.update(job.id, {
371
+ status: "running",
372
+ disabled: false,
373
+ attemptCount: (job.attemptCount || 0) + 1,
374
+ lastAttemptAt: now(),
375
+ nextAttemptAt: now() + retryDelayMs,
376
+ lastProvider: target.provider,
377
+ fallbackFromProvider: target.fallbackFrom,
378
+ });
379
+ if (!running) throw new Error("scheduled attempt could not be persisted");
380
+ } catch (error) {
381
+ return { ok: false, status: "failed", error: stableFailure(error) };
382
+ }
383
+
384
+ try { await send(`Wakeup: ${job.label}`); } catch (_) { /* advisory only */ }
385
+ let result;
386
+ try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
387
+ catch (error) {
388
+ try { return markFailure(running, error, { attemptAlreadyCounted: true }); }
389
+ catch (persistError) { return { ok: false, status: "failed", error: stableFailure(persistError) }; }
390
+ }
391
+ if (!terminalRunSucceeded(result)) {
392
+ try { return markFailure(running, result, { attemptAlreadyCounted: true }); }
393
+ catch (error) { return { ok: false, status: "failed", error: stableFailure(error), result }; }
394
+ }
395
+ return settleSuccess(running, result, target);
396
+ });
397
+ }
398
+
399
+ function scheduleJob(job) {
400
+ unscheduleJob(job.id);
401
+ if (!job || job.disabled || ["archived", "dead-letter"].includes(job.status)) return false;
402
+ if (job.status === "succeeded" && job.kind === "wakeup") {
403
+ try { jobsStore.remove(job.id); } catch (_) {}
404
+ return false;
200
405
  }
201
- return migrated;
202
- } catch (e) {
203
- console.error("scheduler: legacy migration failed:", e.message);
204
- return 0;
406
+ if (["failed", "deferred", "running"].includes(job.status)) {
407
+ armRetry(job, Math.max(0, Number(job.nextAttemptAt || now()) - now()));
408
+ return true;
409
+ }
410
+ if (job.kind === "wakeup") {
411
+ const fireAt = Number(job.fireAt || 0);
412
+ const overdue = now() - fireAt;
413
+ if (overdue > missedGraceMs) {
414
+ try {
415
+ jobsStore.update(job.id, {
416
+ status: "dead-letter",
417
+ disabled: true,
418
+ nextAttemptAt: null,
419
+ lastError: { code: "MISSED_WAKEUP_EXPIRED", message: "Wakeup exceeded the missed-fire grace window" },
420
+ });
421
+ } catch (_) {}
422
+ return false;
423
+ }
424
+ const timeout = setTimer(() => fireJob(job.id, { reason: fireAt < now() ? "missed" : "timer" }), Math.max(0, fireAt - now()));
425
+ timers.set(job.id, { timeout });
426
+ return true;
427
+ }
428
+ if (job.kind === "cron") {
429
+ if (!validateCron(job.schedule)) return false;
430
+ const task = scheduleCron(job.schedule, () => {
431
+ const current = jobsStore.get(job.id);
432
+ if (!current || current.disabled || current.status !== "pending") return;
433
+ fireJob(current.id, { reason: "cron" });
434
+ });
435
+ timers.set(job.id, { stop: () => task.stop?.() });
436
+ return true;
437
+ }
438
+ return false;
205
439
  }
206
- }
207
440
 
208
- function fireMissedWakeupsOnce() {
209
- const now = Date.now();
210
- for (const job of jobs.listAll()) {
211
- if (job.kind !== "wakeup") continue;
212
- if (!job.fireAt) continue;
213
- if (job.fireAt >= now) continue;
214
- if (now - job.fireAt > MISSED_WAKEUP_GRACE_MS) {
215
- jobs.remove(job.id);
216
- continue;
441
+ function archiveLegacyCrons() {
442
+ if (!legacyCronsFile || !fs.existsSync(legacyCronsFile)) return 0;
443
+ let raw;
444
+ try { raw = JSON.parse(fs.readFileSync(legacyCronsFile, "utf8")); }
445
+ catch (_) { return 0; }
446
+ if (!Array.isArray(raw)) return 0;
447
+ const existing = new Set([
448
+ ...jobsStore.listAll().map((job) => job.id),
449
+ ...(jobsStore.listArchived ? jobsStore.listArchived().map((job) => job.id) : []),
450
+ ]);
451
+ let count = 0;
452
+ for (const legacy of raw) {
453
+ if (!legacy || !legacy.schedule || !legacy.prompt) continue;
454
+ const id = legacy.id || `legacy-cron-${crypto.createHash("sha256").update(JSON.stringify(legacy)).digest("hex").slice(0, 16)}`;
455
+ if (existing.has(id)) continue;
456
+ jobsStore.archive({ ...legacy, id, kind: "cron" }, "Legacy cron had no saved provider/session identity and is disabled");
457
+ existing.add(id);
458
+ count += 1;
217
459
  }
218
- setTimeout(() => fireJob({ ...job, label: `${job.label || "wakeup"} (missed)` }), 1000);
460
+ return count;
219
461
  }
220
- }
221
462
 
222
- function initScheduler(adapterList) {
223
- setAdapters(adapterList);
224
- migrateLegacyCrons();
225
- for (const job of jobs.listAll()) {
226
- try { scheduleJob(job); }
227
- catch (e) { console.error(`scheduler: failed to schedule ${job.id}:`, e.message); }
463
+ function init() {
464
+ jobsStore.ensureMigrated?.();
465
+ archiveLegacyCrons();
466
+ for (const job of jobsStore.listAll()) scheduleJob(job);
467
+ return jobsStore.listAll().length;
228
468
  }
229
- fireMissedWakeupsOnce();
230
- console.log(`scheduler: loaded ${jobs.listAll().length} job(s)`);
231
- }
232
469
 
233
- function addJob(spec) {
234
- const job = jobs.add(spec);
235
- try { scheduleJob(job); }
236
- catch (e) { console.error(`scheduler: failed to schedule new ${job.id}:`, e.message); }
237
- return job;
238
- }
470
+ function enrichJobSpec(spec) {
471
+ const state = dependencies.currentState ? dependencies.currentState() : currentState();
472
+ const provider = spec.provider || getActiveProvider(state);
473
+ const project = spec.project || getProjectKey(state);
474
+ const projectDir = spec.projectDir || (state.currentSession?.name === project ? state.currentSession?.dir : null);
475
+ const sessionId = Object.prototype.hasOwnProperty.call(spec, "sessionId")
476
+ ? spec.sessionId
477
+ : getProviderSession(state, provider, project);
478
+ const originRunId = spec.originRunId || state.activeRunContext?.runId || null;
479
+ const providerSettings = spec.providerSettings || getProviderSettings(state, provider);
480
+ return {
481
+ ...spec,
482
+ id: spec.id || defaultJobsStore.nextId(spec.kind || "job"),
483
+ adapter: spec.adapter || dependencies.defaultAdapter || null,
484
+ adapterType: spec.adapterType || dependencies.defaultAdapterType || null,
485
+ channelId: spec.channelId == null ? null : String(spec.channelId),
486
+ canonicalUserId: spec.canonicalUserId || state.userId || null,
487
+ userId: spec.userId || null,
488
+ provider,
489
+ providerSettings: structuredClone(providerSettings || {}),
490
+ project,
491
+ projectDir,
492
+ sessionId: sessionId || null,
493
+ previousSessionId: spec.previousSessionId || sessionId || null,
494
+ originRunId,
495
+ sessionLineage: {
496
+ ...(spec.sessionLineage || {}),
497
+ originRunId,
498
+ originSessionId: spec.sessionLineage?.originSessionId || sessionId || null,
499
+ },
500
+ occurrenceId: spec.occurrenceId || occurrenceIdFactory(spec),
501
+ status: "pending",
502
+ disabled: false,
503
+ attemptCount: 0,
504
+ };
505
+ }
239
506
 
240
- function removeJob(id) {
241
- unscheduleJob(id);
242
- return jobs.remove(id);
507
+ function addJob(spec) {
508
+ const job = jobsStore.add(enrichJobSpec(spec));
509
+ scheduleJob(job);
510
+ return job;
511
+ }
512
+
513
+ function removeJob(id) {
514
+ unscheduleJob(id);
515
+ return jobsStore.remove(id);
516
+ }
517
+
518
+ function stopAll() {
519
+ for (const id of [...timers.keys()]) unscheduleJob(id);
520
+ }
521
+
522
+ return {
523
+ setAdapters,
524
+ init,
525
+ scheduleJob,
526
+ unscheduleJob,
527
+ addJob,
528
+ removeJob,
529
+ stopAll,
530
+ fireJob,
531
+ archiveLegacyCrons,
532
+ enrichJobSpec,
533
+ resolveCompactionLineage,
534
+ timers,
535
+ };
243
536
  }
244
537
 
245
- function stopAll() {
246
- for (const [id] of timers) unscheduleJob(id);
538
+ const service = createSchedulerService();
539
+
540
+ function setAdapters(list) { return service.setAdapters(list); }
541
+ function initScheduler(adapterList) {
542
+ service.setAdapters(adapterList);
543
+ const count = service.init();
544
+ console.log(`scheduler: loaded ${count} job(s)`);
545
+ return count;
247
546
  }
248
547
 
249
548
  module.exports = {
549
+ MISSED_WAKEUP_GRACE_MS,
550
+ RETRY_DELAY_MS,
551
+ BUSY_DELAY_MS,
552
+ createSchedulerService,
553
+ parseFallbacks,
554
+ terminalRunSucceeded,
250
555
  setAdapters,
251
556
  initScheduler,
252
- scheduleJob,
253
- unscheduleJob,
254
- addJob,
255
- removeJob,
256
- stopAll,
257
- fireJob,
557
+ scheduleJob: service.scheduleJob,
558
+ unscheduleJob: service.unscheduleJob,
559
+ addJob: service.addJob,
560
+ removeJob: service.removeJob,
561
+ stopAll: service.stopAll,
562
+ fireJob: service.fireJob,
258
563
  };