@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.
- package/.env.example +30 -4
- package/CHANGELOG.md +21 -0
- package/README.md +87 -66
- package/bin/agent.js +44 -18
- package/bin/cli.js +30 -28
- package/bin/dream.js +5 -11
- package/bin/loopback-client.js +29 -5
- package/bin/schedule.js +19 -5
- package/bin/tool.js +23 -5
- package/bot-agent.js +5 -2350
- package/bot.js +105 -7
- package/channels/telegram/adapter.js +6 -1
- package/channels/voice/adapter.js +1 -0
- package/channels/voice/manage.js +43 -5
- package/config-dir.js +3 -11
- package/core/actions.js +149 -40
- package/core/approvals.js +136 -29
- package/core/auth-flow.js +103 -19
- package/core/config-dir.js +19 -0
- package/core/config.js +127 -69
- package/core/doctor.js +111 -57
- package/core/dream.js +219 -62
- package/core/enforcer.js +0 -0
- package/core/entities.js +2 -1
- package/core/fsutil.js +55 -0
- package/core/handlers.js +503 -217
- package/core/ideas.js +2 -1
- package/core/identity.js +8 -11
- package/core/io.js +24 -3
- package/core/jobs.js +589 -72
- package/core/lessons.js +3 -2
- package/core/loopback.js +45 -8
- package/core/memory-mutation-queue.js +241 -0
- package/core/migration-backup.js +1060 -0
- package/core/pack-review.js +295 -88
- package/core/packs.js +4 -3
- package/core/persona.js +2 -1
- package/core/process-tree.js +19 -2
- package/core/provider-migration.js +39 -0
- package/core/provider-status.js +80 -0
- package/core/providers/claude-events.js +117 -0
- package/core/providers/claude-hook.js +114 -0
- package/core/providers/claude.js +296 -0
- package/core/providers/codex-events.js +121 -0
- package/core/providers/codex-hook.js +280 -0
- package/core/providers/codex.js +286 -0
- package/core/providers/contract.js +153 -0
- package/core/providers/env.js +148 -0
- package/core/providers/events.js +170 -0
- package/core/providers/hook-command.js +22 -0
- package/core/providers/index.js +240 -0
- package/core/providers/utility-policy.js +269 -0
- package/core/recall/classic.js +2 -2
- package/core/recall/discoverer.js +71 -22
- package/core/recall/metrics.js +27 -1
- package/core/recall/tuning.js +4 -1
- package/core/recall/warm-walker.js +151 -108
- package/core/recall-filter.js +86 -61
- package/core/router.js +79 -7
- package/core/run-context.js +185 -0
- package/core/runner.js +1415 -1282
- package/core/scheduler.js +515 -210
- package/core/side-chat.js +346 -0
- package/core/state.js +1096 -95
- package/core/subagent.js +72 -98
- package/core/system-prompt.js +462 -279
- package/core/tool-guard.js +73 -39
- package/core/tools.js +22 -5
- package/core/transcripts.js +30 -1
- package/core/usage-log.js +19 -4
- package/core/utility-agent.js +654 -0
- package/core/web-auth.js +29 -13
- package/core/web-sessions.js +78 -0
- package/docs/CHANNEL_DESIGN.md +181 -0
- package/docs/MULTI_CHANNEL_PLAN.md +254 -0
- package/docs/PROVIDER_MIGRATION.md +67 -0
- package/health.js +50 -39
- package/package.json +51 -4
- package/setup.js +198 -38
- package/soul.md +39 -0
- package/test-ability-extraction.js +5 -0
- package/test-approval-async.js +63 -4
- package/test-cursor-removal.js +334 -0
- package/test-delivery-contract.js +88 -0
- package/test-enforcer.js +70 -27
- package/test-fixtures/current-provider-parity.json +132 -0
- package/test-fixtures/fake-agent-cli.js +477 -0
- package/test-fixtures/migrations/claude-only/jobs.json +3 -0
- package/test-fixtures/migrations/claude-only/sessions.json +5 -0
- package/test-fixtures/migrations/claude-only/state.json +5 -0
- package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
- package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
- package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
- package/test-fixtures/migrations/cursor-selected/state.json +6 -0
- package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
- package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
- package/test-fixtures/migrations/dual-provider/state.json +10 -0
- package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
- package/test-fixtures/migrations/malformed-partial/state.json +1 -0
- package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
- package/test-fixtures/migrations/missing-project/jobs.json +3 -0
- package/test-fixtures/migrations/missing-project/sessions.json +3 -0
- package/test-fixtures/migrations/missing-project/state.json +4 -0
- package/test-fixtures/migrations/multi-user/jobs.json +4 -0
- package/test-fixtures/migrations/multi-user/sessions.json +4 -0
- package/test-fixtures/migrations/multi-user/state.json +6 -0
- package/test-fixtures/provider-event-samples.json +17 -0
- package/test-learning-e2e.js +5 -0
- package/test-memory-mutation-queue.js +191 -0
- package/test-provider-boot-matrix.js +399 -0
- package/test-provider-bootstrap.js +158 -0
- package/test-provider-capabilities.js +232 -0
- package/test-provider-claude.js +206 -0
- package/test-provider-codex.js +228 -0
- package/test-provider-compaction.js +371 -0
- package/test-provider-core-prompt.js +264 -0
- package/test-provider-coupling-audit.js +90 -0
- package/test-provider-dream.js +312 -0
- package/test-provider-enforcer.js +252 -0
- package/test-provider-env-isolation.js +150 -0
- package/test-provider-events.js +141 -0
- package/test-provider-fixture.js +332 -0
- package/test-provider-gateway.js +508 -0
- package/test-provider-language.js +89 -0
- package/test-provider-migration-backup.js +424 -0
- package/test-provider-pack-review.js +436 -0
- package/test-provider-parity-e2e.js +537 -0
- package/test-provider-prompt-parity.js +89 -0
- package/test-provider-recall.js +271 -0
- package/test-provider-registry.js +251 -0
- package/test-provider-resume-parity.js +41 -0
- package/test-provider-run-lifecycle.js +349 -0
- package/test-provider-runner.js +271 -0
- package/test-provider-scheduler.js +689 -0
- package/test-provider-session-commands.js +185 -0
- package/test-provider-session-history.js +205 -0
- package/test-provider-side-chat.js +337 -0
- package/test-provider-state-migration.js +316 -0
- package/test-provider-stream-decoder.js +69 -0
- package/test-provider-subagent.js +228 -0
- package/test-provider-tool-hooks.js +360 -0
- package/test-recall-discoverer.js +1 -0
- package/test-recall-engine.js +3 -3
- package/test-recall-evolution.js +18 -0
- package/test-run-lock.js +63 -0
- package/test-runner-watchdog-static.js +16 -8
- package/test-single-runtime.js +35 -0
- package/test-telegram-poll-recovery.js +56 -0
- package/test-tool-guard.js +56 -0
- package/test-tools.js +19 -0
- package/test-unified-identity.js +3 -1
- package/test-usage-accounting.js +92 -20
- package/test-utility-provider-policy.js +486 -0
- package/test-web-sessions.js +74 -0
- package/web.js +159 -45
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const assert = require("assert");
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
|
|
10
|
+
const root = __dirname;
|
|
11
|
+
const fixturesRoot = path.join(root, "test-fixtures", "migrations");
|
|
12
|
+
|
|
13
|
+
function readFixture(name, file = "jobs.json") {
|
|
14
|
+
return JSON.parse(fs.readFileSync(path.join(fixturesRoot, name, file), "utf8"));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function deferred() {
|
|
18
|
+
let resolve;
|
|
19
|
+
let reject;
|
|
20
|
+
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
21
|
+
return { promise, resolve, reject };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function nextTurn() {
|
|
25
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function migrationProbe(jobsApi) {
|
|
29
|
+
const defaults = {
|
|
30
|
+
defaultCanonicalUserId: "telegram:fixture",
|
|
31
|
+
defaultChannelId: "fixture-channel",
|
|
32
|
+
defaultAdapter: "telegram-fixture",
|
|
33
|
+
defaultAdapterType: "telegram",
|
|
34
|
+
now: () => 1_720_000_000_000,
|
|
35
|
+
occurrenceIdFactory: (job) => `occ-${job.id}`,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const claudeLegacy = readFixture("claude-only").map((job) => ({ ...job, fireAt: 1_720_000_100_000 }));
|
|
39
|
+
const claude = jobsApi.migrateJobsDocument(claudeLegacy, defaults);
|
|
40
|
+
assert.strictEqual(claude.schemaVersion, jobsApi.JOBS_SCHEMA_VERSION);
|
|
41
|
+
assert.strictEqual(claude.jobs.length, 1);
|
|
42
|
+
assert.strictEqual(claude.jobs[0].provider, "claude");
|
|
43
|
+
assert.strictEqual(claude.jobs[0].sessionId, "claude-session-alpha");
|
|
44
|
+
assert.strictEqual(claude.jobs[0].project, "alpha");
|
|
45
|
+
assert.strictEqual(claude.jobs[0].canonicalUserId, "telegram:fixture");
|
|
46
|
+
assert.strictEqual(claude.jobs[0].occurrenceId, "occ-job-claude");
|
|
47
|
+
assert.ok(!Object.prototype.hasOwnProperty.call(claude.jobs[0], "sessionKey"));
|
|
48
|
+
|
|
49
|
+
const codexLegacy = readFixture("dual-provider").map((job) => ({ ...job, schedule: "0 9 * * 1-5" }));
|
|
50
|
+
const codex = jobsApi.migrateJobsDocument(codexLegacy, defaults);
|
|
51
|
+
assert.strictEqual(codex.jobs[0].provider, "codex");
|
|
52
|
+
assert.strictEqual(codex.jobs[0].sessionId, "codex-thread-parity");
|
|
53
|
+
|
|
54
|
+
const cursor = jobsApi.migrateJobsDocument(readFixture("cursor-selected"), defaults);
|
|
55
|
+
assert.deepStrictEqual(cursor.jobs, []);
|
|
56
|
+
assert.strictEqual(cursor.archived.length, 1);
|
|
57
|
+
assert.strictEqual(cursor.archived[0].id, "job-cursor");
|
|
58
|
+
assert.strictEqual(cursor.archived[0].provider, "cursor");
|
|
59
|
+
assert.strictEqual(cursor.archived[0].sessionId, "cursor-session-unresolved");
|
|
60
|
+
assert.strictEqual(cursor.archived[0].status, "archived");
|
|
61
|
+
assert.strictEqual(cursor.archived[0].disabled, true);
|
|
62
|
+
assert.match(cursor.archived[0].archiveReason, /removed provider/i);
|
|
63
|
+
assert.ok(!cursor.archived.some((job) => ["claude", "codex"].includes(job.provider)), "Cursor work must not be reassigned");
|
|
64
|
+
|
|
65
|
+
const missing = jobsApi.migrateJobsDocument(readFixture("missing-project"), defaults);
|
|
66
|
+
assert.deepStrictEqual(missing.jobs, []);
|
|
67
|
+
assert.strictEqual(missing.archived[0].id, "orphan-job");
|
|
68
|
+
assert.match(missing.archived[0].archiveReason, /project/i);
|
|
69
|
+
|
|
70
|
+
const multi = jobsApi.migrateJobsDocument(readFixture("multi-user").map((job) => (
|
|
71
|
+
job.kind === "wakeup" ? { ...job, fireAt: 1_720_000_100_000 } : { ...job, schedule: "0 9 * * 1-5" }
|
|
72
|
+
)), defaults);
|
|
73
|
+
assert.deepStrictEqual(multi.jobs.map((job) => job.canonicalUserId), ["telegram:100", "person@example.test"]);
|
|
74
|
+
|
|
75
|
+
const malformed = jobsApi.migrateJobsDocument([], {
|
|
76
|
+
...defaults,
|
|
77
|
+
malformedSource: { path: "jobs.json", size: 42, sha256: "fixture-hash" },
|
|
78
|
+
});
|
|
79
|
+
assert.strictEqual(malformed.archived.length, 1);
|
|
80
|
+
assert.match(malformed.archived[0].archiveReason, /malformed/i);
|
|
81
|
+
assert.deepStrictEqual(malformed.archived[0].sourceSnapshot, { path: "jobs.json", size: 42, sha256: "fixture-hash" });
|
|
82
|
+
|
|
83
|
+
const rerun = jobsApi.migrateJobsDocument(cursor, defaults);
|
|
84
|
+
assert.deepStrictEqual(rerun, cursor, "jobs migration must be idempotent");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function migrationIntegrationProbe(jobsApi, migrationApi) {
|
|
88
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-jobs-migration-"));
|
|
89
|
+
try {
|
|
90
|
+
const configDir = path.join(temp, "config");
|
|
91
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
92
|
+
fs.cpSync(path.join(fixturesRoot, "cursor-selected"), configDir, { recursive: true });
|
|
93
|
+
const jobsFile = path.join(configDir, "jobs.json");
|
|
94
|
+
const originalJobs = fs.readFileSync(jobsFile);
|
|
95
|
+
const sources = migrationApi.defaultMigrationSources({ configDir });
|
|
96
|
+
const snapshot = migrationApi.ensureMigrationSnapshot({ configDir, sources });
|
|
97
|
+
const store = jobsApi.createJobsStore({
|
|
98
|
+
jobsFile,
|
|
99
|
+
configDir,
|
|
100
|
+
sources,
|
|
101
|
+
now: () => 1_720_000_000_000,
|
|
102
|
+
occurrenceIdFactory: (job) => `occ-${job.id}`,
|
|
103
|
+
defaults: {
|
|
104
|
+
canonicalUserId: "telegram:fixture",
|
|
105
|
+
channelId: "fixture-channel",
|
|
106
|
+
adapter: "telegram-fixture",
|
|
107
|
+
adapterType: "telegram",
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
const migrated = store.ensureMigrated();
|
|
111
|
+
assert.strictEqual(migrated.schemaVersion, jobsApi.JOBS_SCHEMA_VERSION);
|
|
112
|
+
assert.strictEqual(store.listAll().length, 0);
|
|
113
|
+
assert.strictEqual(store.listArchived()[0].id, "job-cursor");
|
|
114
|
+
assert.ok(store.listArchived().some((job) => job.id === "legacy-cron" && job.disabled === true),
|
|
115
|
+
"legacy crons are archived by jobs migration before readiness activation");
|
|
116
|
+
assert.deepStrictEqual(migrated.legacyCronMigration.recordCount, 1);
|
|
117
|
+
assert.strictEqual(migrated.legacyCronMigration.status, "archived");
|
|
118
|
+
assert.ok(migrationApi.readMigrationJournal(configDir).components.jobs);
|
|
119
|
+
assert.deepStrictEqual(
|
|
120
|
+
fs.readFileSync(path.join(snapshot.snapshotDir, "originals", "jobs.json")),
|
|
121
|
+
originalJobs,
|
|
122
|
+
"verified snapshot must retain the byte-for-byte legacy jobs source",
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
assert.ok(store.listArchived().every((job) => !job.announcedAt), "archived jobs start unannounced");
|
|
126
|
+
const announcedCount = store.markArchivedAnnounced(store.listArchived().map((job) => job.id));
|
|
127
|
+
assert.ok(announcedCount >= 2, "boot notice marks every archived job announced");
|
|
128
|
+
assert.ok(store.listArchived().every((job) => Number.isFinite(job.announcedAt)),
|
|
129
|
+
"announcedAt must persist on archived records");
|
|
130
|
+
assert.strictEqual(store.markArchivedAnnounced(store.listArchived().map((job) => job.id)), 0,
|
|
131
|
+
"already-announced jobs are not re-marked");
|
|
132
|
+
assert.ok(store.ensureMigrated().archived.every((job) => Number.isFinite(job.announcedAt)),
|
|
133
|
+
"announcedAt must survive a re-migration pass");
|
|
134
|
+
|
|
135
|
+
const fresh = store.add({
|
|
136
|
+
id: "fresh-origin-job",
|
|
137
|
+
kind: "wakeup",
|
|
138
|
+
adapter: "telegram-fixture",
|
|
139
|
+
adapterType: "telegram",
|
|
140
|
+
channelId: "fixture-channel",
|
|
141
|
+
canonicalUserId: "telegram:fixture",
|
|
142
|
+
provider: "claude",
|
|
143
|
+
providerSettings: { model: "scheduled-model" },
|
|
144
|
+
project: "alpha",
|
|
145
|
+
projectDir: "/workspace/alpha",
|
|
146
|
+
sessionId: null,
|
|
147
|
+
originRunId: "fresh-origin-run",
|
|
148
|
+
sessionLineage: { originRunId: "fresh-origin-run", originSessionId: null },
|
|
149
|
+
prompt: "bind me after the fresh turn closes",
|
|
150
|
+
fireAt: 1_720_000_100_000,
|
|
151
|
+
occurrenceId: "fresh-origin-occurrence",
|
|
152
|
+
});
|
|
153
|
+
assert.strictEqual(fresh.sessionId, null);
|
|
154
|
+
assert.strictEqual(store.bindOriginRunSession("fresh-origin-run", "claude", "alpha", "fresh-native-session"), 1);
|
|
155
|
+
assert.strictEqual(store.get("fresh-origin-job").sessionId, "fresh-native-session");
|
|
156
|
+
assert.strictEqual(store.bindOriginRunSession("fresh-origin-run", "codex", "alpha", "wrong-provider"), 0);
|
|
157
|
+
} finally {
|
|
158
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const interruptedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-jobs-interrupt-"));
|
|
162
|
+
try {
|
|
163
|
+
const configDir = path.join(interruptedRoot, "config");
|
|
164
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
165
|
+
const original = `${JSON.stringify([{
|
|
166
|
+
id: "legacy-interrupted",
|
|
167
|
+
kind: "wakeup",
|
|
168
|
+
adapter: "telegram-fixture",
|
|
169
|
+
adapterType: "telegram",
|
|
170
|
+
channelId: "fixture-channel",
|
|
171
|
+
canonicalUserId: "telegram:fixture",
|
|
172
|
+
project: "alpha",
|
|
173
|
+
sessionKey: "lastSessionId",
|
|
174
|
+
sessionId: "legacy-session",
|
|
175
|
+
prompt: "recover migration",
|
|
176
|
+
fireAt: 1_720_000_100_000,
|
|
177
|
+
}], null, 2)}\n`;
|
|
178
|
+
fs.writeFileSync(path.join(configDir, "jobs.json"), original);
|
|
179
|
+
const store = jobsApi.createJobsStore({
|
|
180
|
+
jobsFile: path.join(configDir, "jobs.json"),
|
|
181
|
+
configDir,
|
|
182
|
+
defaults: {},
|
|
183
|
+
now: () => 1_720_000_000_000,
|
|
184
|
+
occurrenceIdFactory: (job) => `occ-${job.id}`,
|
|
185
|
+
});
|
|
186
|
+
assert.throws(() => store.ensureMigrated({
|
|
187
|
+
faultInjector(point) {
|
|
188
|
+
if (point === "after-jobs-write") throw Object.assign(new Error("fixture interruption"), { code: "FIXTURE_INTERRUPTION" });
|
|
189
|
+
},
|
|
190
|
+
}), (error) => error.code === "FIXTURE_INTERRUPTION");
|
|
191
|
+
const pending = migrationApi.readMigrationJournal(configDir);
|
|
192
|
+
assert.ok(pending.pendingComponents.jobs);
|
|
193
|
+
assert.strictEqual(pending.components.jobs, null);
|
|
194
|
+
const recovered = store.ensureMigrated();
|
|
195
|
+
assert.strictEqual(recovered.jobs[0].provider, "claude");
|
|
196
|
+
assert.ok(migrationApi.readMigrationJournal(configDir).components.jobs);
|
|
197
|
+
} finally {
|
|
198
|
+
fs.rmSync(interruptedRoot, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const malformedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-jobs-malformed-"));
|
|
202
|
+
try {
|
|
203
|
+
const configDir = path.join(malformedRoot, "config");
|
|
204
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
205
|
+
const malformedBytes = fs.readFileSync(path.join(fixturesRoot, "malformed-partial", "jobs.json"));
|
|
206
|
+
fs.writeFileSync(path.join(configDir, "jobs.json"), malformedBytes);
|
|
207
|
+
const store = jobsApi.createJobsStore({ jobsFile: path.join(configDir, "jobs.json"), configDir, now: () => 1_720_000_000_000 });
|
|
208
|
+
const migrated = store.ensureMigrated();
|
|
209
|
+
assert.strictEqual(migrated.jobs.length, 0);
|
|
210
|
+
assert.match(migrated.archived[0].archiveReason, /malformed/i);
|
|
211
|
+
const journal = migrationApi.readMigrationJournal(configDir);
|
|
212
|
+
const snapshotDir = path.join(migrationApi.migrationRoot(configDir), ...journal.snapshot.split("/"));
|
|
213
|
+
assert.deepStrictEqual(fs.readFileSync(path.join(snapshotDir, "originals", "jobs.json")), malformedBytes);
|
|
214
|
+
} finally {
|
|
215
|
+
fs.rmSync(malformedRoot, { recursive: true, force: true });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function memoryJobsStore(seed) {
|
|
220
|
+
const records = new Map(seed.map((job) => [job.id, structuredClone(job)]));
|
|
221
|
+
const archived = [];
|
|
222
|
+
const calls = { update: [], remove: [] };
|
|
223
|
+
return {
|
|
224
|
+
calls,
|
|
225
|
+
records,
|
|
226
|
+
archived,
|
|
227
|
+
ensureMigrated() { return { schemaVersion: 2, jobs: [...records.values()], archived }; },
|
|
228
|
+
listAll() { return [...records.values()].map((job) => structuredClone(job)); },
|
|
229
|
+
listArchived() { return archived.map((job) => structuredClone(job)); },
|
|
230
|
+
get(id) { const value = records.get(id); return value ? structuredClone(value) : null; },
|
|
231
|
+
add(job) { records.set(job.id, structuredClone(job)); return structuredClone(job); },
|
|
232
|
+
update(id, patch) {
|
|
233
|
+
calls.update.push({ id, patch: structuredClone(patch) });
|
|
234
|
+
const current = records.get(id);
|
|
235
|
+
if (!current) return null;
|
|
236
|
+
const next = { ...current, ...structuredClone(patch) };
|
|
237
|
+
records.set(id, next);
|
|
238
|
+
return structuredClone(next);
|
|
239
|
+
},
|
|
240
|
+
remove(id) {
|
|
241
|
+
calls.remove.push(id);
|
|
242
|
+
const value = records.get(id);
|
|
243
|
+
if (!value) return null;
|
|
244
|
+
records.delete(id);
|
|
245
|
+
return structuredClone(value);
|
|
246
|
+
},
|
|
247
|
+
archive(job) { archived.push(structuredClone(job)); records.delete(job.id); return job; },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function baseJob(overrides = {}) {
|
|
252
|
+
return {
|
|
253
|
+
id: "wake-1",
|
|
254
|
+
kind: "wakeup",
|
|
255
|
+
adapter: "telegram-fixture",
|
|
256
|
+
adapterType: "telegram",
|
|
257
|
+
channelId: "fixture-channel",
|
|
258
|
+
canonicalUserId: "fixture-user",
|
|
259
|
+
userId: "fixture-user",
|
|
260
|
+
provider: "claude",
|
|
261
|
+
providerSettings: { model: "claude-scheduled", effort: "high" },
|
|
262
|
+
project: "alpha",
|
|
263
|
+
sessionId: "claude-before-compact",
|
|
264
|
+
sessionLineage: {
|
|
265
|
+
originRunId: "origin-run",
|
|
266
|
+
originSessionId: "claude-before-compact",
|
|
267
|
+
},
|
|
268
|
+
prompt: "scheduled prompt",
|
|
269
|
+
label: "fixture wakeup",
|
|
270
|
+
source: "agent",
|
|
271
|
+
fireAt: 1_720_000_000_000,
|
|
272
|
+
occurrenceId: "occ-wake-1",
|
|
273
|
+
status: "pending",
|
|
274
|
+
attemptCount: 0,
|
|
275
|
+
maxAttempts: 3,
|
|
276
|
+
disabled: false,
|
|
277
|
+
...overrides,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function schedulerHarness(createSchedulerService, seed, options = {}) {
|
|
282
|
+
const jobsStore = options.jobsStore || memoryJobsStore(seed);
|
|
283
|
+
const scheduled = [];
|
|
284
|
+
const admissions = [];
|
|
285
|
+
const runCalls = [];
|
|
286
|
+
const sends = [];
|
|
287
|
+
const state = options.state || {
|
|
288
|
+
userId: "fixture-user",
|
|
289
|
+
currentSession: { name: "other-project", dir: "/workspace/other-project" },
|
|
290
|
+
settings: { backend: "codex" },
|
|
291
|
+
activeSessions: { alpha: { claude: "claude-after-compact" } },
|
|
292
|
+
completedCompactions: {
|
|
293
|
+
compacted: {
|
|
294
|
+
project: "alpha",
|
|
295
|
+
provider: "claude",
|
|
296
|
+
previousSessionId: "claude-before-compact",
|
|
297
|
+
newSessionId: "claude-after-compact",
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
runningProcess: null,
|
|
301
|
+
preparingRun: false,
|
|
302
|
+
};
|
|
303
|
+
const outcomes = options.outcomes || [];
|
|
304
|
+
let occurrenceSequence = 0;
|
|
305
|
+
const service = createSchedulerService({
|
|
306
|
+
jobsStore,
|
|
307
|
+
adapters: [{ id: "telegram-fixture", type: "telegram" }],
|
|
308
|
+
workspace: "/workspace",
|
|
309
|
+
now: options.now || (() => 1_720_000_000_500),
|
|
310
|
+
occurrenceIdFactory: () => `next-occ-${++occurrenceSequence}`,
|
|
311
|
+
setTimer(fn, delay) { scheduled.push({ fn, delay }); return { fn, delay }; },
|
|
312
|
+
clearTimer() {},
|
|
313
|
+
runInChat: async (_ctx, fn) => fn(),
|
|
314
|
+
getUserState: () => state,
|
|
315
|
+
send: async (text) => { sends.push(text); if (options.onSend) await options.onSend(state); return { ok: true }; },
|
|
316
|
+
admitRunContext(prompt, cwd, _replyTo, opts) {
|
|
317
|
+
const context = Object.freeze({
|
|
318
|
+
runId: `scheduled-${admissions.length + 1}`,
|
|
319
|
+
provider: opts.provider,
|
|
320
|
+
project: Object.freeze({ ...opts.project }),
|
|
321
|
+
sessionId: opts.fresh ? null : (opts.resumeSessionId || null),
|
|
322
|
+
providerSettings: Object.freeze({ ...(opts.providerSettings || {}) }),
|
|
323
|
+
occurrenceId: opts.occurrenceId || null,
|
|
324
|
+
scheduledJobId: opts.scheduledJobId || null,
|
|
325
|
+
userPrompt: prompt,
|
|
326
|
+
cwd,
|
|
327
|
+
requiredDelivery: true,
|
|
328
|
+
});
|
|
329
|
+
admissions.push({ context, opts: structuredClone(opts) });
|
|
330
|
+
return context;
|
|
331
|
+
},
|
|
332
|
+
async runAgent(prompt, cwd, _replyTo, opts) {
|
|
333
|
+
runCalls.push({ prompt, cwd, opts });
|
|
334
|
+
const next = outcomes.shift();
|
|
335
|
+
return next && typeof next.then === "function" ? next : next;
|
|
336
|
+
},
|
|
337
|
+
providerStatus: options.providerStatus || (() => ({ availability: { state: "available" }, auth: { state: "authenticated" } })),
|
|
338
|
+
fallbackProviders: options.fallbackProviders || [],
|
|
339
|
+
retryDelayMs: 1000,
|
|
340
|
+
busyDelayMs: 250,
|
|
341
|
+
missedGraceMs: 60 * 60 * 1000,
|
|
342
|
+
});
|
|
343
|
+
return { service, jobsStore, scheduled, admissions, runCalls, sends, state };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function immutableTerminalProbe(createSchedulerService) {
|
|
347
|
+
const terminal = deferred();
|
|
348
|
+
const h = schedulerHarness(createSchedulerService, [baseJob()], {
|
|
349
|
+
outcomes: [terminal.promise],
|
|
350
|
+
onSend(state) {
|
|
351
|
+
state.settings.backend = "codex";
|
|
352
|
+
state.currentSession = { name: "switched", dir: "/workspace/switched" };
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
const firing = h.service.fireJob("wake-1");
|
|
356
|
+
await nextTurn();
|
|
357
|
+
assert.strictEqual(h.admissions.length, 1);
|
|
358
|
+
assert.strictEqual(h.admissions[0].opts.provider, "claude");
|
|
359
|
+
assert.strictEqual(h.admissions[0].opts.project.name, "alpha");
|
|
360
|
+
assert.strictEqual(h.admissions[0].opts.resumeSessionId, "claude-after-compact", "compaction lineage should rebase the saved session");
|
|
361
|
+
assert.deepStrictEqual(h.admissions[0].opts.providerSettings, { model: "claude-scheduled", effort: "high" });
|
|
362
|
+
assert.strictEqual(h.admissions[0].opts.occurrenceId, "occ-wake-1");
|
|
363
|
+
assert.strictEqual(h.admissions[0].opts.scheduledJobId, "wake-1");
|
|
364
|
+
assert.strictEqual(h.admissions[0].opts.attachSession, true);
|
|
365
|
+
assert.strictEqual(h.admissions[0].opts.requiredDelivery, true);
|
|
366
|
+
assert.strictEqual(h.jobsStore.calls.remove.length, 0, "one-shot must survive until terminal settlement");
|
|
367
|
+
terminal.resolve({
|
|
368
|
+
ok: true,
|
|
369
|
+
status: "succeeded",
|
|
370
|
+
persistence: { transcript: { ok: true }, usage: { ok: true }, session: { ok: true } },
|
|
371
|
+
delivery: { ok: true },
|
|
372
|
+
});
|
|
373
|
+
const result = await firing;
|
|
374
|
+
assert.strictEqual(result.ok, true);
|
|
375
|
+
assert.deepStrictEqual(h.jobsStore.calls.remove, ["wake-1"]);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function duplicateFireProbe(createSchedulerService) {
|
|
379
|
+
const terminal = deferred();
|
|
380
|
+
const h = schedulerHarness(createSchedulerService, [baseJob({ id: "single-flight" })], {
|
|
381
|
+
outcomes: [terminal.promise],
|
|
382
|
+
});
|
|
383
|
+
const first = h.service.fireJob("single-flight");
|
|
384
|
+
await nextTurn();
|
|
385
|
+
const duplicate = await h.service.fireJob("single-flight");
|
|
386
|
+
assert.deepStrictEqual(
|
|
387
|
+
{ status: duplicate.status, skipped: duplicate.skipped, inFlight: duplicate.inFlight },
|
|
388
|
+
{ status: "running", skipped: true, inFlight: true },
|
|
389
|
+
"a persisted in-flight occurrence cannot be admitted twice",
|
|
390
|
+
);
|
|
391
|
+
assert.strictEqual(h.admissions.length, 1);
|
|
392
|
+
assert.strictEqual(h.runCalls.length, 1);
|
|
393
|
+
terminal.resolve({
|
|
394
|
+
ok: true,
|
|
395
|
+
status: "succeeded",
|
|
396
|
+
persistence: { transcript: { ok: true }, usage: { ok: true }, session: { ok: true } },
|
|
397
|
+
delivery: { ok: true },
|
|
398
|
+
});
|
|
399
|
+
assert.strictEqual((await first).ok, true);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function failureAndRetryProbe(createSchedulerService) {
|
|
403
|
+
const failureResults = [
|
|
404
|
+
{ ok: false, status: "failed", exitCode: 23, error: { code: "PROVIDER_EXIT_NONZERO", message: "code 23" }, delivery: { ok: true } },
|
|
405
|
+
{ ok: true, status: "succeeded", persistence: { transcript: { ok: false } }, delivery: { ok: true } },
|
|
406
|
+
{ ok: true, status: "succeeded", persistence: { transcript: { ok: true } }, delivery: { ok: false } },
|
|
407
|
+
];
|
|
408
|
+
for (const [index, outcome] of failureResults.entries()) {
|
|
409
|
+
const id = `failed-${index}`;
|
|
410
|
+
const h = schedulerHarness(createSchedulerService, [baseJob({ id, occurrenceId: `occ-${id}` })], { outcomes: [outcome] });
|
|
411
|
+
const fired = await h.service.fireJob(id);
|
|
412
|
+
assert.strictEqual(fired.ok, false);
|
|
413
|
+
assert.ok(h.jobsStore.get(id), `${id}: failed one-shot remains durable`);
|
|
414
|
+
assert.strictEqual(h.jobsStore.calls.remove.length, 0);
|
|
415
|
+
assert.strictEqual(h.jobsStore.get(id).status, "failed");
|
|
416
|
+
assert.strictEqual(h.jobsStore.get(id).attemptCount, 1);
|
|
417
|
+
assert.strictEqual(h.jobsStore.get(id).occurrenceId, `occ-${id}`, "retry retains stable occurrence id");
|
|
418
|
+
assert.strictEqual(h.scheduled.length, 1, "retry is durably re-armed");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const exhausted = schedulerHarness(createSchedulerService, [baseJob({ id: "exhausted", occurrenceId: "occ-exhausted", maxAttempts: 2 })], {
|
|
422
|
+
outcomes: [
|
|
423
|
+
{ ok: false, status: "failed", error: { message: "first" }, delivery: { ok: true } },
|
|
424
|
+
{ ok: false, status: "failed", error: { message: "second" }, delivery: { ok: true } },
|
|
425
|
+
],
|
|
426
|
+
});
|
|
427
|
+
await exhausted.service.fireJob("exhausted");
|
|
428
|
+
await exhausted.service.fireJob("exhausted");
|
|
429
|
+
const dead = exhausted.jobsStore.get("exhausted");
|
|
430
|
+
assert.strictEqual(dead.status, "dead-letter");
|
|
431
|
+
assert.strictEqual(dead.disabled, true);
|
|
432
|
+
assert.strictEqual(dead.attemptCount, 2);
|
|
433
|
+
assert.strictEqual(exhausted.jobsStore.calls.remove.length, 0);
|
|
434
|
+
|
|
435
|
+
const persistenceStore = memoryJobsStore([baseJob({ id: "success-persist-fails", occurrenceId: "occ-persist-fails" })]);
|
|
436
|
+
const normalUpdate = persistenceStore.update.bind(persistenceStore);
|
|
437
|
+
persistenceStore.update = (id, patch) => {
|
|
438
|
+
if (patch.status === "succeeded") throw new Error("jobs success marker failed");
|
|
439
|
+
return normalUpdate(id, patch);
|
|
440
|
+
};
|
|
441
|
+
const persistence = schedulerHarness(createSchedulerService, [], {
|
|
442
|
+
jobsStore: persistenceStore,
|
|
443
|
+
outcomes: [{
|
|
444
|
+
ok: true,
|
|
445
|
+
status: "succeeded",
|
|
446
|
+
persistence: { transcript: { ok: true }, usage: { ok: true }, session: { ok: true } },
|
|
447
|
+
delivery: { ok: true },
|
|
448
|
+
}],
|
|
449
|
+
});
|
|
450
|
+
const notLost = await persistence.service.fireJob("success-persist-fails");
|
|
451
|
+
assert.strictEqual(notLost.ok, false);
|
|
452
|
+
assert.strictEqual(persistenceStore.calls.remove.length, 0, "job persistence failure cannot delete a successful one-shot");
|
|
453
|
+
assert.ok(persistenceStore.get("success-persist-fails"));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function detachedHistoricalBranchProbe(createSchedulerService) {
|
|
457
|
+
const state = {
|
|
458
|
+
userId: "fixture-user",
|
|
459
|
+
currentSession: { name: "other", dir: "/workspace/other" },
|
|
460
|
+
settings: { backend: "codex" },
|
|
461
|
+
activeSessions: { alpha: { claude: "unrelated-live-session" } },
|
|
462
|
+
completedCompactions: {},
|
|
463
|
+
runningProcess: null,
|
|
464
|
+
preparingRun: false,
|
|
465
|
+
};
|
|
466
|
+
const h = schedulerHarness(createSchedulerService, [baseJob({ id: "historical", occurrenceId: "occ-historical" })], {
|
|
467
|
+
state,
|
|
468
|
+
outcomes: [{
|
|
469
|
+
ok: true,
|
|
470
|
+
status: "succeeded",
|
|
471
|
+
persistence: { transcript: { ok: true }, usage: { ok: true }, session: { ok: true } },
|
|
472
|
+
delivery: { ok: true },
|
|
473
|
+
}],
|
|
474
|
+
});
|
|
475
|
+
await h.service.fireJob("historical");
|
|
476
|
+
assert.strictEqual(h.admissions[0].opts.resumeSessionId, "claude-before-compact");
|
|
477
|
+
assert.strictEqual(h.admissions[0].opts.attachSession, false, "historical scheduled work must not replace an unrelated live branch");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function busyRestartAndMissedProbe(createSchedulerService) {
|
|
481
|
+
const busyState = {
|
|
482
|
+
userId: "fixture-user",
|
|
483
|
+
currentSession: { name: "alpha", dir: "/workspace/alpha" },
|
|
484
|
+
settings: { backend: "claude" },
|
|
485
|
+
activeSessions: { alpha: { claude: "session" } },
|
|
486
|
+
completedCompactions: {},
|
|
487
|
+
runningProcess: { pid: 1 },
|
|
488
|
+
preparingRun: false,
|
|
489
|
+
};
|
|
490
|
+
const busy = schedulerHarness(createSchedulerService, [baseJob({ id: "busy", sessionId: "session" })], { state: busyState });
|
|
491
|
+
const deferredBusy = await busy.service.fireJob("busy");
|
|
492
|
+
assert.strictEqual(deferredBusy.deferred, true);
|
|
493
|
+
assert.strictEqual(busy.runCalls.length, 0);
|
|
494
|
+
assert.strictEqual(busy.jobsStore.get("busy").status, "deferred");
|
|
495
|
+
assert.strictEqual(busy.jobsStore.get("busy").attemptCount, 0, "busy deferral is not a provider attempt");
|
|
496
|
+
assert.strictEqual(busy.scheduled.length, 1);
|
|
497
|
+
|
|
498
|
+
const restartNow = 1_720_000_000_500;
|
|
499
|
+
const restart = schedulerHarness(createSchedulerService, [
|
|
500
|
+
baseJob({ id: "retry-after-restart", status: "failed", attemptCount: 1, nextAttemptAt: restartNow + 5000 }),
|
|
501
|
+
baseJob({ id: "missed-once", fireAt: restartNow - 500, status: "pending" }),
|
|
502
|
+
], { now: () => restartNow });
|
|
503
|
+
restart.service.init();
|
|
504
|
+
const retryTimers = restart.scheduled.filter((entry) => entry.delay === 5000);
|
|
505
|
+
const missedTimers = restart.scheduled.filter((entry) => entry.delay === 0);
|
|
506
|
+
assert.strictEqual(retryTimers.length, 1, "restart re-arms a pending retry once");
|
|
507
|
+
assert.strictEqual(missedTimers.length, 1, "a missed wakeup gets exactly one startup attempt");
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function unavailableAndFallbackProbe(createSchedulerService) {
|
|
511
|
+
const noFallback = schedulerHarness(createSchedulerService, [baseJob({ id: "unavailable" })], {
|
|
512
|
+
providerStatus: () => ({ availability: { state: "missing" }, auth: { state: "not_checked" } }),
|
|
513
|
+
});
|
|
514
|
+
const unavailable = await noFallback.service.fireJob("unavailable");
|
|
515
|
+
assert.strictEqual(unavailable.ok, false);
|
|
516
|
+
assert.strictEqual(noFallback.runCalls.length, 0);
|
|
517
|
+
assert.strictEqual(noFallback.jobsStore.get("unavailable").status, "failed");
|
|
518
|
+
|
|
519
|
+
const fallback = schedulerHarness(createSchedulerService, [baseJob({ id: "fallback" })], {
|
|
520
|
+
fallbackProviders: ["codex"],
|
|
521
|
+
providerStatus(provider) {
|
|
522
|
+
return provider === "claude"
|
|
523
|
+
? { availability: { state: "missing" }, auth: { state: "not_checked" } }
|
|
524
|
+
: { availability: { state: "available" }, auth: { state: "authenticated" } };
|
|
525
|
+
},
|
|
526
|
+
outcomes: [{
|
|
527
|
+
ok: true,
|
|
528
|
+
status: "succeeded",
|
|
529
|
+
persistence: { transcript: { ok: true }, usage: { ok: true }, session: { ok: true } },
|
|
530
|
+
delivery: { ok: true },
|
|
531
|
+
}],
|
|
532
|
+
});
|
|
533
|
+
const succeeded = await fallback.service.fireJob("fallback");
|
|
534
|
+
assert.strictEqual(succeeded.ok, true);
|
|
535
|
+
assert.strictEqual(fallback.admissions[0].opts.provider, "codex");
|
|
536
|
+
assert.strictEqual(fallback.admissions[0].opts.fresh, true);
|
|
537
|
+
assert.ok(!fallback.admissions[0].opts.resumeSessionId, "fallback cannot receive another provider's native session id");
|
|
538
|
+
assert.match(fallback.runCalls[0].prompt, /archived provider-neutral brief/i);
|
|
539
|
+
assert.match(fallback.runCalls[0].prompt, /claude/i);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function cronOccurrenceProbe(createSchedulerService) {
|
|
543
|
+
const h = schedulerHarness(createSchedulerService, [baseJob({
|
|
544
|
+
id: "cron-1",
|
|
545
|
+
kind: "cron",
|
|
546
|
+
schedule: "0 9 * * 1-5",
|
|
547
|
+
fireAt: undefined,
|
|
548
|
+
occurrenceId: "cron-occurrence",
|
|
549
|
+
})], {
|
|
550
|
+
outcomes: [{
|
|
551
|
+
ok: true,
|
|
552
|
+
status: "succeeded",
|
|
553
|
+
persistence: { transcript: { ok: true }, usage: { ok: true }, session: { ok: true } },
|
|
554
|
+
delivery: { ok: true },
|
|
555
|
+
}],
|
|
556
|
+
});
|
|
557
|
+
await h.service.fireJob("cron-1");
|
|
558
|
+
const cron = h.jobsStore.get("cron-1");
|
|
559
|
+
assert.ok(cron);
|
|
560
|
+
assert.strictEqual(cron.status, "pending");
|
|
561
|
+
assert.strictEqual(cron.attemptCount, 0);
|
|
562
|
+
assert.strictEqual(cron.occurrenceId, "next-occ-1", "next cron fire gets a new stable occurrence id");
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function creationEnrichmentProbe(createSchedulerService) {
|
|
566
|
+
const state = {
|
|
567
|
+
userId: "fixture-user",
|
|
568
|
+
currentSession: { name: "alpha", dir: "/workspace/alpha" },
|
|
569
|
+
settings: { backend: "codex" },
|
|
570
|
+
activeSessions: { alpha: { codex: "thread-alpha" } },
|
|
571
|
+
providerSettings: { codex: { model: "gpt-scheduled", effort: "high" } },
|
|
572
|
+
activeRunContext: { runId: "origin-run" },
|
|
573
|
+
};
|
|
574
|
+
const service = createSchedulerService({
|
|
575
|
+
jobsStore: memoryJobsStore([]),
|
|
576
|
+
currentState: () => state,
|
|
577
|
+
occurrenceIdFactory: () => "origin-occurrence",
|
|
578
|
+
setTimer: () => ({}),
|
|
579
|
+
clearTimer: () => {},
|
|
580
|
+
});
|
|
581
|
+
const enriched = service.enrichJobSpec({
|
|
582
|
+
kind: "cron",
|
|
583
|
+
adapter: "telegram-fixture",
|
|
584
|
+
adapterType: "telegram",
|
|
585
|
+
channelId: "fixture-channel",
|
|
586
|
+
canonicalUserId: "fixture-user",
|
|
587
|
+
project: "alpha",
|
|
588
|
+
prompt: "direct cron",
|
|
589
|
+
schedule: "0 9 * * 1-5",
|
|
590
|
+
});
|
|
591
|
+
assert.strictEqual(enriched.provider, "codex");
|
|
592
|
+
assert.strictEqual(enriched.project, "alpha");
|
|
593
|
+
assert.strictEqual(enriched.projectDir, "/workspace/alpha");
|
|
594
|
+
assert.strictEqual(enriched.sessionId, "thread-alpha");
|
|
595
|
+
assert.deepStrictEqual(enriched.providerSettings, {
|
|
596
|
+
model: "gpt-scheduled", effort: "high", budget: null, permissionMode: null, worktree: false,
|
|
597
|
+
});
|
|
598
|
+
assert.strictEqual(enriched.originRunId, "origin-run");
|
|
599
|
+
assert.strictEqual(enriched.occurrenceId, "origin-occurrence");
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function loopbackMetadataProbe(runner, loopbackClient) {
|
|
603
|
+
const invocation = runner.decorateForegroundInvocation({
|
|
604
|
+
binary: "/fixture/provider",
|
|
605
|
+
args: [],
|
|
606
|
+
env: {},
|
|
607
|
+
stdin: null,
|
|
608
|
+
parserState: {},
|
|
609
|
+
}, {
|
|
610
|
+
runId: "run-metadata",
|
|
611
|
+
provider: "codex",
|
|
612
|
+
project: { name: "alpha", dir: "/workspace/alpha" },
|
|
613
|
+
sessionId: "thread-alpha",
|
|
614
|
+
admittedSessionId: "thread-alpha",
|
|
615
|
+
previousSessionId: "thread-before",
|
|
616
|
+
canonicalUserId: "fixture-user",
|
|
617
|
+
channelId: "fixture-channel",
|
|
618
|
+
origin: { adapterId: "telegram-fixture", userId: "fixture-user" },
|
|
619
|
+
purpose: "foreground",
|
|
620
|
+
}, { transcriptPaths: {} });
|
|
621
|
+
assert.strictEqual(invocation.env.OC_PROVIDER, "codex");
|
|
622
|
+
assert.strictEqual(invocation.env.OC_RUN_ID, "run-metadata");
|
|
623
|
+
assert.strictEqual(invocation.env.OC_PROJECT, "alpha");
|
|
624
|
+
assert.strictEqual(invocation.env.OC_PROJECT_DIR, "/workspace/alpha");
|
|
625
|
+
assert.strictEqual(invocation.env.OC_SESSION_ID, "thread-alpha");
|
|
626
|
+
assert.strictEqual(invocation.env.OC_PREVIOUS_SESSION_ID, "thread-before");
|
|
627
|
+
|
|
628
|
+
const previous = { ...process.env };
|
|
629
|
+
try {
|
|
630
|
+
Object.assign(process.env, invocation.env);
|
|
631
|
+
const context = loopbackClient.context();
|
|
632
|
+
assert.strictEqual(context.provider, "codex");
|
|
633
|
+
assert.strictEqual(context.project, "alpha");
|
|
634
|
+
assert.strictEqual(context.projectDir, "/workspace/alpha");
|
|
635
|
+
assert.strictEqual(context.runId, "run-metadata");
|
|
636
|
+
assert.strictEqual(context.sessionId, "thread-alpha");
|
|
637
|
+
assert.strictEqual(context.previousSessionId, "thread-before");
|
|
638
|
+
} finally {
|
|
639
|
+
for (const key of Object.keys(process.env)) {
|
|
640
|
+
if (!Object.prototype.hasOwnProperty.call(previous, key)) delete process.env[key];
|
|
641
|
+
}
|
|
642
|
+
Object.assign(process.env, previous);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function staticSchedulingProbe() {
|
|
647
|
+
const files = [
|
|
648
|
+
"core/jobs.js",
|
|
649
|
+
"core/scheduler.js",
|
|
650
|
+
"core/loopback.js",
|
|
651
|
+
"bin/loopback-client.js",
|
|
652
|
+
"bin/schedule.js",
|
|
653
|
+
"bin/cli.js",
|
|
654
|
+
];
|
|
655
|
+
const source = Object.fromEntries(files.map((file) => [file, fs.readFileSync(path.join(root, file), "utf8")]));
|
|
656
|
+
for (const file of ["core/jobs.js", "core/scheduler.js", "core/loopback.js", "bin/loopback-client.js", "bin/schedule.js"]) {
|
|
657
|
+
if (file === "core/jobs.js") continue;
|
|
658
|
+
assert.ok(!/\bsessionKey\b/.test(source[file]), `${file} must not use the removed sessionKey scheduling field`);
|
|
659
|
+
}
|
|
660
|
+
assert.match(source["bin/cli.js"], /selected provider session/i);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function main() {
|
|
664
|
+
const jobsApi = require("./core/jobs");
|
|
665
|
+
const migrationApi = require("./core/migration-backup");
|
|
666
|
+
const schedulerApi = require("./core/scheduler");
|
|
667
|
+
assert.strictEqual(typeof jobsApi.migrateJobsDocument, "function");
|
|
668
|
+
assert.strictEqual(typeof jobsApi.createJobsStore, "function");
|
|
669
|
+
assert.strictEqual(typeof schedulerApi.createSchedulerService, "function");
|
|
670
|
+
|
|
671
|
+
migrationProbe(jobsApi);
|
|
672
|
+
migrationIntegrationProbe(jobsApi, migrationApi);
|
|
673
|
+
await immutableTerminalProbe(schedulerApi.createSchedulerService);
|
|
674
|
+
await duplicateFireProbe(schedulerApi.createSchedulerService);
|
|
675
|
+
await failureAndRetryProbe(schedulerApi.createSchedulerService);
|
|
676
|
+
await detachedHistoricalBranchProbe(schedulerApi.createSchedulerService);
|
|
677
|
+
await busyRestartAndMissedProbe(schedulerApi.createSchedulerService);
|
|
678
|
+
await unavailableAndFallbackProbe(schedulerApi.createSchedulerService);
|
|
679
|
+
await cronOccurrenceProbe(schedulerApi.createSchedulerService);
|
|
680
|
+
creationEnrichmentProbe(schedulerApi.createSchedulerService);
|
|
681
|
+
loopbackMetadataProbe(require("./core/runner"), require("./bin/loopback-client"));
|
|
682
|
+
staticSchedulingProbe();
|
|
683
|
+
console.log("provider scheduler OK");
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
main().catch((error) => {
|
|
687
|
+
console.error(error.stack || error.message);
|
|
688
|
+
process.exit(1);
|
|
689
|
+
});
|