@inetafrica/open-claudia 3.5.16 → 3.5.18
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 +10 -0
- package/CHANGELOG.md +53 -0
- package/README.md +4 -0
- package/bin/cli.js +1 -1
- package/bin/task.js +51 -0
- package/bot.js +26 -0
- package/channels/kazee/adapter.js +130 -21
- package/channels/telegram/adapter.js +4 -2
- package/core/actions.js +8 -0
- package/core/config.js +22 -1
- package/core/cost-report.js +3 -0
- package/core/cron-schedule.js +59 -0
- package/core/dream.js +141 -34
- package/core/guardrail.js +5 -0
- package/core/handlers.js +78 -17
- package/core/lessons.js +11 -5
- package/core/limit-pickup.js +313 -0
- package/core/loopback.js +29 -0
- package/core/providers/claude-events.js +79 -0
- package/core/providers/codex.js +48 -0
- package/core/providers/events.js +4 -1
- package/core/relationship.js +1 -1
- package/core/router.js +16 -1
- package/core/run-context.js +3 -0
- package/core/runner.js +440 -14
- package/core/safe-filename.js +47 -0
- package/core/scheduler.js +170 -38
- package/core/state.js +91 -0
- package/core/system-prompt.js +89 -16
- package/core/tasks.js +208 -6
- package/core/turn-journal.js +508 -0
- package/core/usage-limits.js +321 -0
- package/core/utility-agent.js +17 -1
- package/package.json +4 -4
- package/test-fixtures/fake-agent-cli.js +26 -2
- package/test-guardrail.js +19 -0
- package/test-kazee-context-media.js +1 -1
- package/test-provider-compaction.js +66 -1
- package/test-provider-dream.js +3 -1
- package/test-provider-events.js +106 -0
- package/test-provider-language.js +16 -1
- package/test-provider-pack-review.js +3 -1
- package/test-provider-scheduler.js +72 -1
- package/test-thread-lanes.js +6 -1
- package/test-usage-accounting.js +4 -0
- package/web.js +104 -51
|
@@ -97,9 +97,11 @@ async function immutableSuccessProbe() {
|
|
|
97
97
|
assert.strictEqual(stateApi.acquireRunLock(state), false, "foreground admission stays closed between compaction stages");
|
|
98
98
|
alphaLane().isCompacting = false;
|
|
99
99
|
const contexts = [];
|
|
100
|
-
const
|
|
100
|
+
const prompts = [];
|
|
101
|
+
const service = serviceWith(async (prompt, _cwd, options) => {
|
|
101
102
|
assert.strictEqual(options.persist, false, "compaction provider stages have no ordinary capture persistence");
|
|
102
103
|
contexts.push(options.runContext);
|
|
104
|
+
prompts.push(String(prompt));
|
|
103
105
|
assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "old-claude", "pointer stays old during both provider stages");
|
|
104
106
|
if (contexts.length === 1) {
|
|
105
107
|
state.currentSession = { name: "beta", dir: betaDir };
|
|
@@ -125,6 +127,11 @@ async function immutableSuccessProbe() {
|
|
|
125
127
|
assert.strictEqual(contexts[1].timeoutMs, contexts[0].timeoutMs);
|
|
126
128
|
assert.strictEqual(contexts[0].purpose, "compact-summary");
|
|
127
129
|
assert.strictEqual(contexts[0].recallQuery, null, "summary stage carries no recall query");
|
|
130
|
+
// Claude runs the summary stage with --max-turns 1: a tool call (a transcript
|
|
131
|
+
// search, a status check) uses up the one turn and ends it with no brief, so
|
|
132
|
+
// the compaction fails and its cost is wasted (4 of 23 failed on 7-8 Oct).
|
|
133
|
+
assert.strictEqual(contexts[0].maxTurns, 1);
|
|
134
|
+
assert.ok(prompts[0].includes("Do NOT use any tools"), "the one-turn summary stage forbids tool calls");
|
|
128
135
|
assert.strictEqual(contexts[1].purpose, "compact-seed");
|
|
129
136
|
assert.ok(String(contexts[1].recallQuery || "").includes("A sufficiently detailed condensed continuation"),
|
|
130
137
|
"seed recall query carries the condensed brief, not seed-prompt boilerplate");
|
|
@@ -156,6 +163,63 @@ async function immutableSuccessProbe() {
|
|
|
156
163
|
assert.strictEqual(alphaLane().isCompacting, false);
|
|
157
164
|
}
|
|
158
165
|
|
|
166
|
+
// A scheduled job's own conversation (a branch beside the thread's live
|
|
167
|
+
// session) is compacted in place of the live one: the summary reads the
|
|
168
|
+
// branch, the lineage lands in the history and the index, and the thread's
|
|
169
|
+
// live pointer stays where it is. Only the job follows the new session.
|
|
170
|
+
async function branchCompactionProbe() {
|
|
171
|
+
resetFixture("claude");
|
|
172
|
+
state.usageBySession["alpha\0claude\0job-branch"] = { ...stateApi.freshUsage(), turns: 17, liveContextTokens: 894000 };
|
|
173
|
+
const contexts = [];
|
|
174
|
+
const recorded = [];
|
|
175
|
+
const service = serviceWith(async (_prompt, _cwd, options) => {
|
|
176
|
+
contexts.push(options.runContext);
|
|
177
|
+
return result({ sessionId: contexts.length === 1 ? "job-branch" : "job-branch-new" });
|
|
178
|
+
}, {
|
|
179
|
+
commitBranchCompaction: stateApi.commitBranchCompaction,
|
|
180
|
+
recordCompaction: (entry) => recorded.push(entry),
|
|
181
|
+
});
|
|
182
|
+
const compacted = await service.compact(alphaDir, {
|
|
183
|
+
operationId: "compact-branch", sessionId: "job-branch", provider: "claude", compactReason: "cold-resume",
|
|
184
|
+
});
|
|
185
|
+
assert.strictEqual(compacted.compacted, true, JSON.stringify(compacted));
|
|
186
|
+
assert.strictEqual(compacted.branch, true);
|
|
187
|
+
assert.strictEqual(compacted.oldSessionId, "job-branch");
|
|
188
|
+
assert.strictEqual(contexts[0].sessionId, "job-branch", "the summary stage reads the job's conversation");
|
|
189
|
+
assert.strictEqual(contexts[0].resumeSessionId, "job-branch");
|
|
190
|
+
assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "old-claude", "the thread's live pointer stays");
|
|
191
|
+
const record = stateApi.getProjectSessions(state.userId, "alpha", "claude").find((entry) => entry.id === "job-branch-new");
|
|
192
|
+
assert.deepStrictEqual(record.lineage, {
|
|
193
|
+
type: "compaction",
|
|
194
|
+
operationId: "compact-branch",
|
|
195
|
+
previousSessionId: "job-branch",
|
|
196
|
+
newSessionId: "job-branch-new",
|
|
197
|
+
branch: true,
|
|
198
|
+
});
|
|
199
|
+
assert.strictEqual(state.completedCompactions["compact-branch"].previousSessionId, "job-branch");
|
|
200
|
+
assert.strictEqual(state.completedCompactions["compact-branch"].newSessionId, "job-branch-new");
|
|
201
|
+
const usage = stateApi.getUsageForSession(state, "alpha", "claude", "job-branch-new");
|
|
202
|
+
assert.strictEqual(usage.turns, 0, "the seed is not billed as a turn");
|
|
203
|
+
assert.strictEqual(usage.compactionCheckpoint.previousSessionId, "job-branch");
|
|
204
|
+
assert.ok(usage.lastCompactedAt > 0, "the new session carries the compaction spacing");
|
|
205
|
+
assert.strictEqual(recorded[0].branch, true);
|
|
206
|
+
assert.strictEqual(recorded[0].contextTokensBefore, 894000, "the context it replaced is the branch's, not the thread's");
|
|
207
|
+
assert.strictEqual(runner.recordCompactionUsage({ ...recorded[0] }, { appendUsageRecord: (entry) => entry }).scope, "job", "the ledger marks a job conversation");
|
|
208
|
+
assert.ok(!("scope" in runner.recordCompactionUsage({ compactReason: "threshold", provider: "claude" }, { appendUsageRecord: (entry) => entry })), "and only that");
|
|
209
|
+
assert.strictEqual(alphaLane().isCompacting, false);
|
|
210
|
+
|
|
211
|
+
const again = stateApi.commitBranchCompaction(state, {
|
|
212
|
+
operationId: "compact-branch", canonicalUserId: state.userId, project: "alpha", provider: "claude",
|
|
213
|
+
previousSessionId: "job-branch", newSessionId: "job-branch-new",
|
|
214
|
+
});
|
|
215
|
+
assert.strictEqual(again.duplicate, true, "a repeated commit is a no-op");
|
|
216
|
+
assert.throws(() => stateApi.commitBranchCompaction(state, {
|
|
217
|
+
operationId: "compact-became-live", canonicalUserId: state.userId, project: "alpha", provider: "claude",
|
|
218
|
+
previousSessionId: "old-claude", newSessionId: "other-new",
|
|
219
|
+
}), /became the live session/, "a branch that is now the live session is the thread's to compact");
|
|
220
|
+
assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "old-claude");
|
|
221
|
+
}
|
|
222
|
+
|
|
159
223
|
async function failureProbe() {
|
|
160
224
|
resetFixture("claude");
|
|
161
225
|
alphaLane().runningProcess = { pid: 123 };
|
|
@@ -492,6 +556,7 @@ function restartRecoveryProbe() {
|
|
|
492
556
|
assert.strictEqual(typeof runner.createCompactionService, "function");
|
|
493
557
|
assert.strictEqual(typeof stateApi.commitProviderCompaction, "function");
|
|
494
558
|
await immutableSuccessProbe();
|
|
559
|
+
await branchCompactionProbe();
|
|
495
560
|
await failureProbe();
|
|
496
561
|
await codexRestrictionProbe();
|
|
497
562
|
await deferredWatchAcrossCompactionProbe();
|
package/test-provider-dream.js
CHANGED
|
@@ -267,7 +267,9 @@ async function providerProbe(kind) {
|
|
|
267
267
|
assert.ok(record.argv.includes("plan"), "extra memory access does not expand the read-only boundary");
|
|
268
268
|
}
|
|
269
269
|
} else {
|
|
270
|
-
|
|
270
|
+
// The dream schemas have optional fields, which OpenAI strict mode refuses
|
|
271
|
+
// (6 Oct 2026); Codex gets none and the reply is checked locally.
|
|
272
|
+
assert.ok(!record.argv.includes("--output-schema"), "Codex is not handed a schema OpenAI strict mode would refuse");
|
|
271
273
|
assert.ok(record.argv.some((arg) => /model_reasoning_effort=.*max/.test(arg)), "max effort passes through natively since codex 0.144 supports it");
|
|
272
274
|
}
|
|
273
275
|
}
|
package/test-provider-events.js
CHANGED
|
@@ -25,6 +25,7 @@ assert.deepStrictEqual([...NORMALIZED_EVENT_TYPES].sort(), [
|
|
|
25
25
|
"tool_start",
|
|
26
26
|
"usage",
|
|
27
27
|
"warning",
|
|
28
|
+
"rate_limit",
|
|
28
29
|
].sort());
|
|
29
30
|
|
|
30
31
|
const claudeState = createClaudeParserState();
|
|
@@ -189,6 +190,90 @@ assert.strictEqual(normalizeClaudeEvent({
|
|
|
189
190
|
result: "",
|
|
190
191
|
}, partialState).at(-1).type, "result");
|
|
191
192
|
|
|
193
|
+
// Plan limits. Claude Code 2.1.280 reports each window's use on every fresh
|
|
194
|
+
// run (shape captured live 7 Oct 2026) …
|
|
195
|
+
const rateLimitState = createClaudeParserState();
|
|
196
|
+
assert.deepStrictEqual(normalizeClaudeEvent({
|
|
197
|
+
type: "rate_limit_event",
|
|
198
|
+
rate_limit_info: {
|
|
199
|
+
status: "allowed_warning",
|
|
200
|
+
resetsAt: 1791939600,
|
|
201
|
+
rateLimitType: "seven_day",
|
|
202
|
+
utilization: 0.26,
|
|
203
|
+
isUsingOverage: false,
|
|
204
|
+
unifiedWindows: {
|
|
205
|
+
five_hour: { utilization: 0.57, resetsAt: 1791382800 },
|
|
206
|
+
seven_day: { utilization: 0.26, resetsAt: 1791939600 },
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
uuid: "u-1",
|
|
210
|
+
session_id: "s-1",
|
|
211
|
+
}, rateLimitState), [{
|
|
212
|
+
type: "rate_limit",
|
|
213
|
+
provider: "claude",
|
|
214
|
+
status: "allowed_warning",
|
|
215
|
+
limitType: "seven_day",
|
|
216
|
+
resetsAt: 1791939600000,
|
|
217
|
+
windows: [
|
|
218
|
+
{ id: "five_hour", usedPercent: 57, resetsAt: 1791382800000 },
|
|
219
|
+
{ id: "seven_day", usedPercent: 26, resetsAt: 1791939600000 },
|
|
220
|
+
],
|
|
221
|
+
}]);
|
|
222
|
+
assert.strictEqual(rateLimitState.limitHit, null, "a warning is not a limit hit");
|
|
223
|
+
|
|
224
|
+
// … and a limit hit as status "rejected", a synthetic assistant notice
|
|
225
|
+
// (error "rate_limit") and an error result. The notice is never answer text,
|
|
226
|
+
// and the failure carries the reset time.
|
|
227
|
+
const limitText = "You've hit your session limit · resets 4:10pm (Africa/Nairobi)";
|
|
228
|
+
const limitState = createClaudeParserState();
|
|
229
|
+
assert.strictEqual(normalizeClaudeEvent({
|
|
230
|
+
type: "rate_limit_event",
|
|
231
|
+
rate_limit_info: { status: "rejected", resetsAt: 1791205800, rateLimitType: "five_hour", isUsingOverage: false },
|
|
232
|
+
}, limitState)[0].status, "rejected");
|
|
233
|
+
assert.deepStrictEqual(normalizeClaudeEvent({
|
|
234
|
+
type: "assistant",
|
|
235
|
+
error: "rate_limit",
|
|
236
|
+
message: { model: "<synthetic>", role: "assistant", content: [{ type: "text", text: limitText }] },
|
|
237
|
+
}, limitState), [], "the limit notice must not stream as a reply");
|
|
238
|
+
const limitFailure = normalizeClaudeEvent({ type: "result", subtype: "success", is_error: true, result: limitText }, limitState);
|
|
239
|
+
assert.deepStrictEqual(limitFailure, [{
|
|
240
|
+
type: "error",
|
|
241
|
+
message: limitText,
|
|
242
|
+
authError: false,
|
|
243
|
+
usageLimit: true,
|
|
244
|
+
refusal: false,
|
|
245
|
+
limitResetsAt: 1791205800000,
|
|
246
|
+
limitType: "five_hour",
|
|
247
|
+
}]);
|
|
248
|
+
|
|
249
|
+
// The same notice closed as a "success" (no answer text) still fails.
|
|
250
|
+
const quietLimit = createClaudeParserState();
|
|
251
|
+
normalizeClaudeEvent({
|
|
252
|
+
type: "assistant",
|
|
253
|
+
message: { model: "<synthetic>", content: [{ type: "text", text: "You've hit your weekly limit · resets Oct 9, 5am (UTC)" }] },
|
|
254
|
+
}, quietLimit);
|
|
255
|
+
const quietFailure = normalizeClaudeEvent({ type: "result", subtype: "success", is_error: false, result: "You've hit your weekly limit · resets Oct 9, 5am (UTC)" }, quietLimit);
|
|
256
|
+
assert.strictEqual(quietFailure.at(-1).type, "error");
|
|
257
|
+
assert.strictEqual(quietFailure.at(-1).usageLimit, true);
|
|
258
|
+
|
|
259
|
+
// A rejection Claude Code recovered from (it answered) stays a success.
|
|
260
|
+
const recovered = createClaudeParserState();
|
|
261
|
+
normalizeClaudeEvent({ type: "rate_limit_event", rate_limit_info: { status: "rejected", rateLimitType: "seven_day_opus", resetsAt: 1791939600 } }, recovered);
|
|
262
|
+
normalizeClaudeEvent({ type: "assistant", message: { content: [{ type: "text", text: "Here is the answer." }] } }, recovered);
|
|
263
|
+
assert.strictEqual(normalizeClaudeEvent({ type: "result", subtype: "success", is_error: false, result: "Here is the answer." }, recovered).at(-1).type, "result");
|
|
264
|
+
|
|
265
|
+
// Every wording of a plan limit counts as one, for both providers.
|
|
266
|
+
for (const text of [
|
|
267
|
+
"You've hit your limit · resets 1pm (Africa/Nairobi)",
|
|
268
|
+
"You've hit your session limit · resets 11:30am (Africa/Nairobi)",
|
|
269
|
+
"You've hit your weekly limit · resets Oct 9, 5am (UTC)",
|
|
270
|
+
"You’ve hit your Opus limit · resets Oct 9, 5am (UTC)",
|
|
271
|
+
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Sep 7th, 2026 5:28 AM.",
|
|
272
|
+
]) {
|
|
273
|
+
assert.strictEqual(normalizeClaudeEvent({ type: "result", is_error: true, result: text }, createClaudeParserState())[0].usageLimit, true, text);
|
|
274
|
+
assert.strictEqual(normalizeCodexEvent({ type: "turn.failed", error: { message: text } }, createCodexParserState())[0].usageLimit, true, text);
|
|
275
|
+
}
|
|
276
|
+
|
|
192
277
|
const codexState = createCodexParserState();
|
|
193
278
|
assert.deepStrictEqual(normalizeCodexEvent(samples.codex.session, codexState), [
|
|
194
279
|
{ type: "session", sessionId: "codex-thread-1" },
|
|
@@ -240,4 +325,25 @@ const isolatedB = createClaudeParserState();
|
|
|
240
325
|
normalizeClaudeEvent(samples.claude.session, isolatedA);
|
|
241
326
|
assert.deepStrictEqual(normalizeClaudeEvent(samples.claude.session, isolatedB), [{ type: "session", sessionId: "claude-session-1" }]);
|
|
242
327
|
|
|
328
|
+
// Helper agents (the Agent/Task tool) stream their own messages with
|
|
329
|
+
// parent_tool_use_id set. Their notes, tool calls and refusals are not this
|
|
330
|
+
// turn's reply (6 Oct: an audit's helper narration was posted to the owner as
|
|
331
|
+
// the answer, and one helper's refusal would have marked the turn refused).
|
|
332
|
+
{
|
|
333
|
+
const st = createClaudeParserState();
|
|
334
|
+
const helper = { parent_tool_use_id: "toolu_helper" };
|
|
335
|
+
assert.deepStrictEqual(normalizeClaudeEvent({ ...helper, type: "stream_event", event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Let me look at the server files." } } }, st), [], "helper text delta is not reply text");
|
|
336
|
+
normalizeClaudeEvent({ ...helper, type: "stream_event", event: { type: "message_delta", delta: { stop_reason: "refusal" } } }, st);
|
|
337
|
+
const helperAssistant = normalizeClaudeEvent({ ...helper, type: "assistant", message: { id: "msg_helper", stop_reason: "refusal", content: [{ type: "text", text: "I can't give you the security review." }, { type: "tool_use", id: "toolu_inner", name: "Bash", input: {} }] } }, st);
|
|
338
|
+
assert.deepStrictEqual(helperAssistant.filter((event) => event.type !== "usage"), [], "helper text and tool calls stay out of the reply");
|
|
339
|
+
assert.deepStrictEqual(normalizeClaudeEvent({ ...helper, type: "user", message: { content: [{ type: "tool_result", tool_use_id: "toolu_inner", content: "ok" }] } }, st), [], "helper tool results are not this turn's tool activity");
|
|
340
|
+
assert.strictEqual(st.refusal, null, "a helper's refusal is not this turn's refusal");
|
|
341
|
+
assert.strictEqual(st.sawText, false, "helper text does not count as reply text");
|
|
342
|
+
assert.deepStrictEqual(
|
|
343
|
+
normalizeClaudeEvent({ type: "assistant", parent_tool_use_id: null, message: { id: "msg_main", content: [{ type: "text", text: "Here is the verdict." }] } }, st).filter((event) => event.type !== "usage"),
|
|
344
|
+
[{ type: "text_final", text: "Here is the verdict." }],
|
|
345
|
+
"the turn's own text still flows",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
243
349
|
console.log("provider events OK");
|
|
@@ -571,7 +571,22 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
571
571
|
// bot's own browser (/api/browser/sessions + /api/browser/stream, Browser tab
|
|
572
572
|
// on the dashboard), agent-browser pinned, the Codex hook re-check, and the
|
|
573
573
|
// thread-lanes test fix that unblocks 3.5.15, which this release carries.
|
|
574
|
-
|
|
574
|
+
// 3.5.17 approved by Sumeet 2026-10-07: one combined release (main thread,
|
|
575
|
+
// 09:21 EAT), then "implement this end to end and lets monitor it to see if
|
|
576
|
+
// it improves the token economics over the next few days" (10:05 EAT, with
|
|
577
|
+
// resuming interrupted turns): Release 1 (token diet canary, scheduling and
|
|
578
|
+
// dream fixes, task archive, interrupted turns resume), the 7 Oct security
|
|
579
|
+
// fixes, the 6 Oct dashboard and provider fixes, and the rule-catcher fix.
|
|
580
|
+
// Canary on the owner's Mac bot (one thread on the token diet), then the fleet.
|
|
581
|
+
// 3.5.18 approved by Sumeet 2026-10-08 09:25 EAT ("Yeah, go ahead and then
|
|
582
|
+
// maybe by the end of the day if everything is stable on this bot, then we
|
|
583
|
+
// roll it out to the whole fleet"), answering the 08:19 EAT plan for "Let's
|
|
584
|
+
// roll it out to the fleet if we already seeing savings?": the token diet on
|
|
585
|
+
// by default (fleet pods set no TOKEN_DIET_* env), the compaction summary
|
|
586
|
+
// step without tools, the same cap for scheduled jobs' own conversations,
|
|
587
|
+
// and usage-limit pick-ups with 80/95% warnings. This Mac bot first, then the
|
|
588
|
+
// fleet once it is stable here.
|
|
589
|
+
assert.strictEqual(pkg.version, "3.5.18", "release version must remain unchanged without explicit approval");
|
|
575
590
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
576
591
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
577
592
|
}
|
|
@@ -230,7 +230,9 @@ async function providerProbe(kind) {
|
|
|
230
230
|
if (kind === "claude") assert.ok(captureRecord.argv.includes("plan"), "Claude reviewer is read-only");
|
|
231
231
|
else {
|
|
232
232
|
assert.ok(captureRecord.argv.includes("read-only"), "Codex reviewer is read-only");
|
|
233
|
-
|
|
233
|
+
// The reviewer schema has free-form objects, which OpenAI strict mode refuses
|
|
234
|
+
// (6 Oct 2026); Codex gets none and the reply is checked locally.
|
|
235
|
+
assert.ok(!captureRecord.argv.includes("--output-schema"), "Codex is not handed a schema OpenAI strict mode would refuse");
|
|
234
236
|
}
|
|
235
237
|
|
|
236
238
|
const metrics = JSON.parse(fs.readFileSync(path.join(process.env.OPEN_CLAUDIA_CONFIG_DIR, "recall-metrics-summary.json"), "utf8"));
|
|
@@ -442,6 +442,7 @@ function schedulerHarness(createSchedulerService, seed, options = {}) {
|
|
|
442
442
|
return next && typeof next.then === "function" ? next : next;
|
|
443
443
|
},
|
|
444
444
|
providerStatus: options.providerStatus || (() => ({ availability: { state: "available" }, auth: { state: "authenticated" } })),
|
|
445
|
+
getProjectSessions: options.getProjectSessions || (() => []),
|
|
445
446
|
fallbackProviders: options.fallbackProviders || [],
|
|
446
447
|
retryDelayMs: 1000,
|
|
447
448
|
busyDelayMs: 250,
|
|
@@ -615,6 +616,73 @@ async function detachedHistoricalBranchProbe(createSchedulerService) {
|
|
|
615
616
|
assert.strictEqual(h.admissions[0].opts.attachSession, false, "historical scheduled work must not replace an unrelated live branch");
|
|
616
617
|
}
|
|
617
618
|
|
|
619
|
+
// The completed-compactions index keeps only the last 20, under a day of
|
|
620
|
+
// token-diet compactions, so the conversation's session history is walked too
|
|
621
|
+
// (7 Oct: the PBX thread's wakeup and watch lost their entry and resumed a
|
|
622
|
+
// 790k session compacted that morning). A recurring job then remembers the
|
|
623
|
+
// session it ran on, so it never depends on old lineage records again.
|
|
624
|
+
async function historyLineageProbe(createSchedulerService) {
|
|
625
|
+
// Newest first, as getProjectSessions returns them.
|
|
626
|
+
const history = [
|
|
627
|
+
{ id: "claude-third", provider: "claude", lineage: { type: "compaction", previousSessionId: "claude-second", newSessionId: "claude-third" } },
|
|
628
|
+
{ id: "claude-second", provider: "claude", lineage: { type: "compaction", previousSessionId: "claude-before-compact", newSessionId: "claude-second" } },
|
|
629
|
+
{ id: "claude-before-compact", provider: "claude" },
|
|
630
|
+
];
|
|
631
|
+
const lookups = [];
|
|
632
|
+
const cron = (id, extra = {}) => baseJob({ id, kind: "cron", schedule: "0 * * * *", fireAt: undefined, occurrenceId: `occ-${id}`, ...extra });
|
|
633
|
+
const pruned = schedulerHarness(createSchedulerService, [cron("pruned")], {
|
|
634
|
+
state: {
|
|
635
|
+
userId: "fixture-user",
|
|
636
|
+
currentSession: { name: "other", dir: "/workspace/other" },
|
|
637
|
+
settings: { backend: "claude" },
|
|
638
|
+
activeSessions: { alpha: { claude: "claude-third" } },
|
|
639
|
+
completedCompactions: {},
|
|
640
|
+
},
|
|
641
|
+
getProjectSessions: (userId, project, provider) => { lookups.push([userId, project, provider]); return history; },
|
|
642
|
+
outcomes: [{ ...succeededRun(), sessionId: "claude-third" }],
|
|
643
|
+
});
|
|
644
|
+
await pruned.service.fireJob("pruned");
|
|
645
|
+
assert.deepStrictEqual(lookups[0], ["fixture-user", "alpha", "claude"], "the job's own conversation history, under its provider");
|
|
646
|
+
assert.strictEqual(pruned.admissions[0].opts.resumeSessionId, "claude-third", "a pruned index entry no longer strands the job on its old session");
|
|
647
|
+
assert.strictEqual(pruned.admissions[0].opts.attachSession, true, "it reached the thread's live session");
|
|
648
|
+
assert.strictEqual(pruned.jobsStore.get("pruned").sessionId, "claude-third", "a recurring job remembers the session it ran on");
|
|
649
|
+
|
|
650
|
+
// A job on its own branch whose run moved to a compacted session (cold
|
|
651
|
+
// resume) keeps that session; the thread's live session is not its own.
|
|
652
|
+
const branch = schedulerHarness(createSchedulerService, [cron("branch")], {
|
|
653
|
+
state: {
|
|
654
|
+
userId: "fixture-user",
|
|
655
|
+
currentSession: { name: "other", dir: "/workspace/other" },
|
|
656
|
+
settings: { backend: "claude" },
|
|
657
|
+
activeSessions: { alpha: { claude: "unrelated-live-session" } },
|
|
658
|
+
completedCompactions: {},
|
|
659
|
+
},
|
|
660
|
+
outcomes: [{ ...succeededRun(), sessionId: "claude-branch-compacted" }],
|
|
661
|
+
});
|
|
662
|
+
await branch.service.fireJob("branch");
|
|
663
|
+
assert.strictEqual(branch.admissions[0].opts.resumeSessionId, "claude-before-compact");
|
|
664
|
+
assert.strictEqual(branch.admissions[0].opts.attachSession, false);
|
|
665
|
+
assert.strictEqual(branch.jobsStore.get("branch").sessionId, "claude-branch-compacted", "the job follows its compacted conversation");
|
|
666
|
+
|
|
667
|
+
// A run that did not resume the job's conversation (provider switched:
|
|
668
|
+
// fresh, on another provider) leaves the saved session alone; so does a
|
|
669
|
+
// one-shot, which is removed anyway.
|
|
670
|
+
const switched = schedulerHarness(createSchedulerService, [cron("switched")], {
|
|
671
|
+
state: {
|
|
672
|
+
userId: "fixture-user",
|
|
673
|
+
currentSession: { name: "other", dir: "/workspace/other" },
|
|
674
|
+
settings: { backend: "codex" },
|
|
675
|
+
providerSettings: { codex: { model: "codex-current" } },
|
|
676
|
+
activeSessions: { alpha: { claude: "claude-before-compact", codex: "codex-live" } },
|
|
677
|
+
completedCompactions: {},
|
|
678
|
+
},
|
|
679
|
+
outcomes: [{ ...succeededRun(), sessionId: "codex-fresh-thread" }],
|
|
680
|
+
});
|
|
681
|
+
await switched.service.fireJob("switched");
|
|
682
|
+
assert.strictEqual(switched.admissions[0].opts.fresh, true);
|
|
683
|
+
assert.strictEqual(switched.jobsStore.get("switched").sessionId, "claude-before-compact", "a fresh run on another provider is not the job's conversation");
|
|
684
|
+
}
|
|
685
|
+
|
|
618
686
|
async function busyRestartAndMissedProbe(createSchedulerService) {
|
|
619
687
|
const busyState = {
|
|
620
688
|
userId: "fixture-user",
|
|
@@ -1276,7 +1344,9 @@ async function durablePendingProbe(createSchedulerService, jobsApi) {
|
|
|
1276
1344
|
await retry.service.fireJob("failure");
|
|
1277
1345
|
assert.strictEqual(retry.jobsStore.get("failure").status, "dead-letter", "real failures retain bounded retries");
|
|
1278
1346
|
assert.strictEqual(retry.jobsStore.get("failure").attemptCount, 3);
|
|
1279
|
-
|
|
1347
|
+
await nextTurn();
|
|
1348
|
+
assert.strictEqual(retry.sends.filter((text) => !/gave up/.test(text)).length, 0, "advisory is not repeated across failure/restart");
|
|
1349
|
+
assert.strictEqual(retry.sends.filter((text) => /gave up \(SCHEDULED_RUN_FAILED\)/.test(text)).length, 1, "the dead-lettered watch says so once");
|
|
1280
1350
|
retry.service.stopAll();
|
|
1281
1351
|
} finally {
|
|
1282
1352
|
fs.rmSync(configDir, { recursive: true, force: true });
|
|
@@ -1631,6 +1701,7 @@ async function main() {
|
|
|
1631
1701
|
await duplicateFireProbe(schedulerApi.createSchedulerService);
|
|
1632
1702
|
await failureAndRetryProbe(schedulerApi.createSchedulerService);
|
|
1633
1703
|
await detachedHistoricalBranchProbe(schedulerApi.createSchedulerService);
|
|
1704
|
+
await historyLineageProbe(schedulerApi.createSchedulerService);
|
|
1634
1705
|
await busyRestartAndMissedProbe(schedulerApi.createSchedulerService);
|
|
1635
1706
|
await threadLaneProbe(schedulerApi.createSchedulerService);
|
|
1636
1707
|
await durablePendingProbe(schedulerApi.createSchedulerService, jobsApi);
|
package/test-thread-lanes.js
CHANGED
|
@@ -39,6 +39,11 @@ fs.mkdirSync(configDir, { recursive: true });
|
|
|
39
39
|
fs.mkdirSync(workspace, { recursive: true });
|
|
40
40
|
const OWNER = "owner-chat-777";
|
|
41
41
|
process.env.OPEN_CLAUDIA_TEST = "1";
|
|
42
|
+
// The fake agent CLI is the configured Claude Code, so a run has a provider
|
|
43
|
+
// without the npm test environment (CI runs this file alone).
|
|
44
|
+
process.env.CLAUDE_PATH = path.join(__dirname, "test-fixtures", "fake-agent-cli.js");
|
|
45
|
+
process.env.FAKE_AGENT_KIND = "claude";
|
|
46
|
+
process.env.AGENT_ENV_PASSTHROUGH = "FAKE_AGENT_KIND";
|
|
42
47
|
process.env.OPEN_CLAUDIA_CONFIG_DIR = configDir;
|
|
43
48
|
process.env.WORKSPACE = workspace;
|
|
44
49
|
process.env.TELEGRAM_CHAT_ID = OWNER;
|
|
@@ -504,7 +509,7 @@ async function wiringAndBounds() {
|
|
|
504
509
|
project: { name: conversationKeyFor("telegram", "dedupe-chan", threadId), dir: workspace },
|
|
505
510
|
},
|
|
506
511
|
}));
|
|
507
|
-
const full = (text) => text.includes("## Pending tasks\n");
|
|
512
|
+
const full = (text) => text.includes("## Pending tasks (this conversation)\n");
|
|
508
513
|
assert.ok(full(block(null)), "main timeline: full tree first");
|
|
509
514
|
assert.ok(full(block(T(8))), "thread: full tree first");
|
|
510
515
|
assert.ok(!full(block(null)), "back in the main timeline: only the count (not re-injected)");
|
package/test-usage-accounting.js
CHANGED
|
@@ -4,6 +4,10 @@ const os = require("os");
|
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-usage-accounting-"));
|
|
6
6
|
process.env.OPEN_CLAUDIA_CONFIG_DIR = configDir;
|
|
7
|
+
// These cases pin the classic thresholds (AUTO_COMPACT_TOKENS, Codex's
|
|
8
|
+
// 80%-of-window rule). The token diet, on by default since 3.5.18, has its own
|
|
9
|
+
// test (test-token-diet.js).
|
|
10
|
+
process.env.TOKEN_DIET_CONVERSATIONS = "off";
|
|
7
11
|
const {
|
|
8
12
|
usageParts,
|
|
9
13
|
normalizeCodexUsage,
|