@inetafrica/open-claudia 3.5.1 → 3.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +13 -0
- package/CHANGELOG.md +36 -0
- package/bin/cli.js +13 -5
- package/bin/loopback-client.js +4 -0
- package/bin/schedule.js +7 -2
- package/bot.js +6 -2
- package/channels/kazee/adapter.js +519 -64
- package/channels/kazee/state.js +88 -2
- package/channels/types.js +11 -5
- package/core/actions.js +6 -0
- package/core/approvals.js +9 -0
- package/core/context.js +11 -2
- package/core/handlers.js +161 -59
- package/core/identity.js +33 -3
- package/core/io.js +21 -8
- package/core/jobs.js +3 -0
- package/core/loopback.js +55 -7
- package/core/providers/claude.js +6 -2
- package/core/providers/utility-policy.js +3 -1
- package/core/router.js +25 -13
- package/core/run-context.js +49 -8
- package/core/runner.js +318 -122
- package/core/sandbox-probe.js +78 -0
- package/core/scheduler.js +45 -3
- package/core/side-chat.js +40 -11
- package/core/speaker-attest.js +2 -0
- package/core/state.js +317 -50
- package/core/system-prompt.js +61 -14
- package/core/thread-title.js +237 -0
- package/core/transcripts.js +23 -6
- package/package.json +3 -3
- package/test-approval-cards.js +24 -0
- package/test-call-speech.js +5 -3
- package/test-fixtures/fake-agent-cli.js +52 -1
- package/test-provider-compaction.js +50 -11
- package/test-provider-gateway.js +31 -10
- package/test-provider-language.js +7 -1
- package/test-provider-scheduler.js +77 -20
- package/test-provider-session-commands.js +11 -2
- package/test-provider-side-chat.js +30 -2
- package/test-run-lock.js +122 -39
- package/test-thread-lanes.js +530 -0
- package/core/queue-drain.js +0 -25
- package/test-queue-routing.js +0 -52
|
@@ -26,6 +26,9 @@ const stateApi = require("./core/state");
|
|
|
26
26
|
const runner = require("./core/runner");
|
|
27
27
|
|
|
28
28
|
const state = stateApi.currentState();
|
|
29
|
+
// Compaction runs in ONE conversation lane: outside a chat turn that is the
|
|
30
|
+
// state's project ("alpha" here).
|
|
31
|
+
const alphaLane = () => stateApi.laneFor(state, "alpha");
|
|
29
32
|
|
|
30
33
|
function result(overrides = {}) {
|
|
31
34
|
return {
|
|
@@ -58,9 +61,7 @@ function resetFixture(provider = "claude") {
|
|
|
58
61
|
state.pendingCompaction = null;
|
|
59
62
|
state.completedCompactions = {};
|
|
60
63
|
state.lastCompactedAt = 0;
|
|
61
|
-
state.
|
|
62
|
-
state.runningProcess = null;
|
|
63
|
-
state.preparingRun = false;
|
|
64
|
+
state.lanes?.clear();
|
|
64
65
|
state.isFirstMessage = false;
|
|
65
66
|
stateApi.replaceProjectSessions(state.userId, "alpha", [
|
|
66
67
|
{
|
|
@@ -92,9 +93,9 @@ function serviceWith(runCapture, extra = {}) {
|
|
|
92
93
|
|
|
93
94
|
async function immutableSuccessProbe() {
|
|
94
95
|
resetFixture("claude");
|
|
95
|
-
|
|
96
|
+
alphaLane().isCompacting = true;
|
|
96
97
|
assert.strictEqual(stateApi.acquireRunLock(state), false, "foreground admission stays closed between compaction stages");
|
|
97
|
-
|
|
98
|
+
alphaLane().isCompacting = false;
|
|
98
99
|
const contexts = [];
|
|
99
100
|
const service = serviceWith(async (_prompt, _cwd, options) => {
|
|
100
101
|
assert.strictEqual(options.persist, false, "compaction provider stages have no ordinary capture persistence");
|
|
@@ -152,17 +153,17 @@ async function immutableSuccessProbe() {
|
|
|
152
153
|
const usage = stateApi.getUsageForSession(state, "alpha", "claude", "new-claude");
|
|
153
154
|
assert.strictEqual(usage.turns, 0, "seed is not billed as a user turn");
|
|
154
155
|
assert.strictEqual(usage.compactionCheckpoint.previousSessionId, "old-claude");
|
|
155
|
-
assert.strictEqual(
|
|
156
|
+
assert.strictEqual(alphaLane().isCompacting, false);
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
async function failureProbe() {
|
|
159
160
|
resetFixture("claude");
|
|
160
|
-
|
|
161
|
+
alphaLane().runningProcess = { pid: 123 };
|
|
161
162
|
let busyCalls = 0;
|
|
162
163
|
const busy = await serviceWith(async () => { busyCalls += 1; return result(); }).compact(alphaDir);
|
|
163
164
|
assert.strictEqual(busy.compacted, false);
|
|
164
165
|
assert.strictEqual(busyCalls, 0, "manual compaction never overlaps an active provider run");
|
|
165
|
-
|
|
166
|
+
alphaLane().runningProcess = null;
|
|
166
167
|
|
|
167
168
|
resetFixture("claude");
|
|
168
169
|
let summaryCalls = 0;
|
|
@@ -354,15 +355,15 @@ async function deferredWatchAcrossCompactionProbe() {
|
|
|
354
355
|
},
|
|
355
356
|
});
|
|
356
357
|
|
|
357
|
-
|
|
358
|
+
alphaLane().runningProcess = { pid: 4242 };
|
|
358
359
|
assert.strictEqual((await scheduler.fireJob("watch-long-turn")).deferred, true, "a watch that passes mid-turn is queued");
|
|
359
360
|
for (let i = 0; i < 3; i++) await timers.at(-1).fn();
|
|
360
|
-
|
|
361
|
+
alphaLane().runningProcess = null;
|
|
361
362
|
let calls = 0;
|
|
362
363
|
const compaction = serviceWith(async () => {
|
|
363
364
|
calls += 1;
|
|
364
365
|
if (calls === 1) {
|
|
365
|
-
assert.strictEqual(
|
|
366
|
+
assert.strictEqual(alphaLane().isCompacting, true);
|
|
366
367
|
assert.strictEqual((await timers.at(-1).fn()).deferred, true, "the delivery waits out the compaction");
|
|
367
368
|
return result({ sessionId: "old-claude" });
|
|
368
369
|
}
|
|
@@ -381,6 +382,43 @@ async function deferredWatchAcrossCompactionProbe() {
|
|
|
381
382
|
}
|
|
382
383
|
}
|
|
383
384
|
|
|
385
|
+
// Compaction is per conversation: compacting one lane keeps only THAT lane
|
|
386
|
+
// closed, and the recent turns it seeds from come from that conversation's
|
|
387
|
+
// transcript — not the state's legacy "Workspace" session.
|
|
388
|
+
async function perLaneProbe() {
|
|
389
|
+
resetFixture("claude");
|
|
390
|
+
const { runInChat } = require("./core/context");
|
|
391
|
+
const convo = "telegram:fixture";
|
|
392
|
+
const thread = "telegram:fixture#thread:eeeeeeeeeeeeeeeeeeeeeeee";
|
|
393
|
+
state.currentSession = stateApi.rootSession();
|
|
394
|
+
stateApi.setProviderSession(state, "claude", convo, "old-claude");
|
|
395
|
+
const views = [];
|
|
396
|
+
let lanesDuringCompaction = null;
|
|
397
|
+
const service = serviceWith(async () => {
|
|
398
|
+
if (!lanesDuringCompaction) {
|
|
399
|
+
lanesDuringCompaction = {
|
|
400
|
+
own: stateApi.acquireRunLock(state, convo),
|
|
401
|
+
thread: stateApi.acquireRunLock(state, thread),
|
|
402
|
+
compacting: stateApi.laneFor(state, convo).isCompacting,
|
|
403
|
+
};
|
|
404
|
+
stateApi.laneFor(state, thread).preparingRun = false;
|
|
405
|
+
return result({ sessionId: "old-claude" });
|
|
406
|
+
}
|
|
407
|
+
return result({ sessionId: "lane-new-claude" });
|
|
408
|
+
}, { collectRecentTurns: (view) => { views.push(view); return null; } });
|
|
409
|
+
const compacted = await runInChat({ channelId: "fixture", conversationKey: convo, roomKey: convo, canonicalUserId: state.userId },
|
|
410
|
+
() => service.compact(workspace, { operationId: "compact-lane" }));
|
|
411
|
+
assert.strictEqual(compacted.compacted, true, compacted.reason);
|
|
412
|
+
assert.deepStrictEqual(lanesDuringCompaction, { own: false, thread: true, compacting: true },
|
|
413
|
+
"only the compacting conversation is closed; a thread of the same chat can start");
|
|
414
|
+
assert.strictEqual(stateApi.laneFor(state, convo).isCompacting, false);
|
|
415
|
+
assert.strictEqual(views.length, 1);
|
|
416
|
+
assert.strictEqual(views[0].currentSession.name, convo, "recent turns are read from the conversation's own transcript");
|
|
417
|
+
assert.strictEqual(stateApi.getProviderSession(state, "claude", convo), "lane-new-claude");
|
|
418
|
+
assert.strictEqual(runner.shouldAutoCompact(state, { laneKey: thread }), false, "a thread with no session never auto-compacts");
|
|
419
|
+
state.lanes.clear();
|
|
420
|
+
}
|
|
421
|
+
|
|
384
422
|
function duplicateCommitProbe() {
|
|
385
423
|
resetFixture("claude");
|
|
386
424
|
const transaction = {
|
|
@@ -457,6 +495,7 @@ function restartRecoveryProbe() {
|
|
|
457
495
|
await failureProbe();
|
|
458
496
|
await codexRestrictionProbe();
|
|
459
497
|
await deferredWatchAcrossCompactionProbe();
|
|
498
|
+
await perLaneProbe();
|
|
460
499
|
duplicateCommitProbe();
|
|
461
500
|
restartRecoveryProbe();
|
|
462
501
|
console.log("provider compaction OK");
|
package/test-provider-gateway.js
CHANGED
|
@@ -37,13 +37,12 @@ async function routerProbe() {
|
|
|
37
37
|
claude: { model: "claude-fixture" },
|
|
38
38
|
codex: { model: "codex-fixture" },
|
|
39
39
|
},
|
|
40
|
-
messageQueue: [],
|
|
41
40
|
pendingCodexAuthProcess: null,
|
|
42
41
|
pendingClaudeAuthProcess: null,
|
|
43
42
|
pendingVaultUnlock: false,
|
|
44
43
|
onboardingStep: null,
|
|
45
|
-
lastInputWasVoice: false,
|
|
46
44
|
};
|
|
45
|
+
const lane = { messageQueue: [], lastInputWasVoice: false, pendingVoiceClone: null };
|
|
47
46
|
const admissions = [];
|
|
48
47
|
const runs = [];
|
|
49
48
|
const sends = [];
|
|
@@ -55,6 +54,10 @@ async function routerProbe() {
|
|
|
55
54
|
autoLinkOwnerChannel: () => null,
|
|
56
55
|
getActiveProvider: (s) => (s || state).settings?.backend || null,
|
|
57
56
|
getProjectKey: (s) => (s || state).currentSession?.name || "",
|
|
57
|
+
laneFor: () => lane,
|
|
58
|
+
peekLane: () => lane,
|
|
59
|
+
laneBusy: (l) => !!(l && (l.runningProcess || l.preparingRun)),
|
|
60
|
+
slotBlocker: () => null,
|
|
58
61
|
});
|
|
59
62
|
// The up-front triage gate is pinned in test-enforcer.js; here it must only
|
|
60
63
|
// be CONSULTED with the routed text + provider/project pinning, then let the
|
|
@@ -327,12 +330,16 @@ async function schedulerProbe() {
|
|
|
327
330
|
getProviderSession: (_state, provider, project) => (
|
|
328
331
|
state.activeSessions?.[project]?.[provider] || null
|
|
329
332
|
),
|
|
333
|
+
peekLane: () => null,
|
|
334
|
+
laneBusy: () => false,
|
|
335
|
+
slotBlocker: () => null,
|
|
330
336
|
userStates: new Map(),
|
|
331
337
|
});
|
|
332
338
|
installStub("./core/identity", {
|
|
333
339
|
canonicalForTelegram: () => "fixture-user",
|
|
334
340
|
canonicalForChannel: () => "fixture-user",
|
|
335
341
|
conversationKeyFor: (transport, channelId) => `${transport}:${channelId}`,
|
|
342
|
+
parseConversationKey: (key) => ({ threadId: null, roomKey: String(key || "") }),
|
|
336
343
|
});
|
|
337
344
|
installStub("./core/io", {
|
|
338
345
|
send: async () => {
|
|
@@ -420,10 +427,15 @@ async function queueProbe() {
|
|
|
420
427
|
claude: { model: "claude-fixture", effort: "high" },
|
|
421
428
|
codex: { model: "codex-fixture", effort: "low" },
|
|
422
429
|
},
|
|
430
|
+
};
|
|
431
|
+
// The conversation's lane is busy preparing a turn, so new work queues in it.
|
|
432
|
+
const lane = {
|
|
433
|
+
key: "project-a",
|
|
423
434
|
messageQueue: [],
|
|
424
435
|
preparingRun: true,
|
|
425
436
|
runningProcess: null,
|
|
426
437
|
isCompacting: false,
|
|
438
|
+
activeRunContext: null,
|
|
427
439
|
lastInputWasVoice: false,
|
|
428
440
|
};
|
|
429
441
|
const store = Object.freeze({
|
|
@@ -441,7 +453,15 @@ async function queueProbe() {
|
|
|
441
453
|
recordSession: () => {},
|
|
442
454
|
userOwnsProviderSession: () => false,
|
|
443
455
|
resetSessionUsage: () => {},
|
|
444
|
-
|
|
456
|
+
admitLane: () => ({ ok: false, reason: "busy", lane }),
|
|
457
|
+
laneFor: () => lane,
|
|
458
|
+
laneForRun: () => lane,
|
|
459
|
+
peekLane: () => lane,
|
|
460
|
+
laneBusy: () => true,
|
|
461
|
+
runLaneKey: (runContext) => runContext?.laneKey || runContext?.project?.name || null,
|
|
462
|
+
waitingLanes: () => [],
|
|
463
|
+
slotBlocker: () => null,
|
|
464
|
+
pruneIdleLanes: () => {},
|
|
445
465
|
});
|
|
446
466
|
installStub("./core/context", {
|
|
447
467
|
chatContext: { getStore: () => store, run: (_store, fn) => fn() },
|
|
@@ -449,6 +469,7 @@ async function queueProbe() {
|
|
|
449
469
|
currentAdapter: () => store.adapter,
|
|
450
470
|
currentUserId: () => store.userId,
|
|
451
471
|
currentConversationKey: () => store.conversationKey || null,
|
|
472
|
+
currentThreadId: () => null,
|
|
452
473
|
});
|
|
453
474
|
installStub("./core/io", {
|
|
454
475
|
send: async () => ({ ok: true, messageId: "queued", editable: true }),
|
|
@@ -462,8 +483,8 @@ async function queueProbe() {
|
|
|
462
483
|
const { runAgent } = require("./core/runner");
|
|
463
484
|
const pending = runAgent("queued prompt", projectA, "message-queued", { runId: "queued-run" });
|
|
464
485
|
assert.strictEqual(typeof pending.then, "function");
|
|
465
|
-
assert.strictEqual(
|
|
466
|
-
const queued =
|
|
486
|
+
assert.strictEqual(lane.messageQueue.length, 1);
|
|
487
|
+
const queued = lane.messageQueue[0];
|
|
467
488
|
assert.ok(Object.isFrozen(queued.runContext));
|
|
468
489
|
assert.ok(Object.isFrozen(queued.runContext.project));
|
|
469
490
|
assert.ok(Object.isFrozen(queued.runContext.providerSettings));
|
|
@@ -483,14 +504,14 @@ async function queueProbe() {
|
|
|
483
504
|
store.adapter.liveCall = true;
|
|
484
505
|
store.adapter.callTurn = { signal: obsoleteCall.signal };
|
|
485
506
|
const callPending = runAgent("obsolete call question", projectA, null, { runId: "obsolete-call" });
|
|
486
|
-
assert.strictEqual(
|
|
507
|
+
assert.strictEqual(lane.messageQueue.length, 2);
|
|
487
508
|
obsoleteCall.abort();
|
|
488
|
-
assert.strictEqual(
|
|
509
|
+
assert.strictEqual(lane.messageQueue.length, 1, "superseded call entry is removed immediately, ordinary queued work is preserved");
|
|
489
510
|
assert.strictEqual((await callPending).status, "cancelled");
|
|
490
|
-
assert.strictEqual(
|
|
511
|
+
assert.strictEqual(lane.messageQueue[0], queued);
|
|
491
512
|
const alreadyEnded = await runAgent("late call dispatch", projectA, null, { runId: "late-call" });
|
|
492
513
|
assert.strictEqual(alreadyEnded.status, "cancelled");
|
|
493
|
-
assert.strictEqual(
|
|
514
|
+
assert.strictEqual(lane.messageQueue.length, 1, "an already-ended call never joins the queue");
|
|
494
515
|
delete store.adapter.callTurn;
|
|
495
516
|
delete store.adapter.liveCall;
|
|
496
517
|
console.log("provider gateway immutable queue OK");
|
|
@@ -521,7 +542,7 @@ function staticChecks() {
|
|
|
521
542
|
}
|
|
522
543
|
assert.match(source["core/router.js"], /\badmitRunContext\b/, "media/text ingress captures before preparation awaits");
|
|
523
544
|
assert.match(source["core/scheduler.js"], /result\.ok/, "scheduler consumes terminal RunResult success");
|
|
524
|
-
assert.match(source["core/runner.js"], /
|
|
545
|
+
assert.match(source["core/runner.js"], /queueInLane\(lane, \{ runContext,/, "queued work stores its immutable run context in its lane");
|
|
525
546
|
assert.match(source["core/runner.js"], /executeAdmittedRun\(item\.runContext,/, "queue drain executes the admitted context");
|
|
526
547
|
assert.match(source["bin/cli.js"], /Open Claudia agent task/);
|
|
527
548
|
assert.ok(!source["bin/cli.js"].includes("inside a running Claude task"));
|
|
@@ -519,7 +519,13 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
519
519
|
// own bot (ancestor listener, not started in a staff turn); staff watch checks
|
|
520
520
|
// get an allowlist env and owner checks the bot's env again; a partial chat
|
|
521
521
|
// origin is a non-owner; wider staff shell screen, documented as no boundary.
|
|
522
|
-
|
|
522
|
+
// 3.5.7 approved by Sumeet 2026-09-29 17:37 EAT ("also roll out the open claudia
|
|
523
|
+
// threads feature"): 3.5.5 (Kazee threads + thread naming, approved 12:22 and 16:29
|
|
524
|
+
// EAT) plus the fix that kept it off the fleet: the staff-turn credential scrub
|
|
525
|
+
// only where its sandbox can run (the fleet's containers cannot). 3.5.6 never
|
|
526
|
+
// built (a test needed Claude Code on the build machine); 3.5.7 = 3.5.6 + that
|
|
527
|
+
// test fix. Canary on network-bot + procurement-specialist first, then the fleet.
|
|
528
|
+
assert.strictEqual(pkg.version, "3.5.7", "release version must remain unchanged without explicit approval");
|
|
523
529
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
524
530
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
525
531
|
}
|
|
@@ -392,10 +392,9 @@ function schedulerHarness(createSchedulerService, seed, options = {}) {
|
|
|
392
392
|
newSessionId: "claude-after-compact",
|
|
393
393
|
},
|
|
394
394
|
},
|
|
395
|
-
runningProcess: null,
|
|
396
|
-
preparingRun: false,
|
|
397
395
|
};
|
|
398
396
|
const outcomes = options.outcomes || [];
|
|
397
|
+
const chats = [];
|
|
399
398
|
let occurrenceSequence = 0;
|
|
400
399
|
const service = createSchedulerService({
|
|
401
400
|
jobsStore,
|
|
@@ -416,7 +415,7 @@ function schedulerHarness(createSchedulerService, seed, options = {}) {
|
|
|
416
415
|
crons.push(task);
|
|
417
416
|
return task;
|
|
418
417
|
},
|
|
419
|
-
runInChat: async (
|
|
418
|
+
runInChat: async (ctx, fn) => { chats.push(ctx); return fn(); },
|
|
420
419
|
getUserState: () => state,
|
|
421
420
|
send: async (text) => { sends.push(text); if (options.onSend) await options.onSend(state); return { ok: true }; },
|
|
422
421
|
admitRunContext(prompt, cwd, _replyTo, opts) {
|
|
@@ -449,7 +448,7 @@ function schedulerHarness(createSchedulerService, seed, options = {}) {
|
|
|
449
448
|
missedGraceMs: 60 * 60 * 1000,
|
|
450
449
|
...(options.runWatchCheck ? { runWatchCheck: options.runWatchCheck } : {}),
|
|
451
450
|
});
|
|
452
|
-
return { service, jobsStore, scheduled, crons, admissions, runCalls, sends, state };
|
|
451
|
+
return { service, jobsStore, scheduled, crons, admissions, runCalls, sends, state, chats };
|
|
453
452
|
}
|
|
454
453
|
|
|
455
454
|
async function firetimeSelectionProbe(createSchedulerService) {
|
|
@@ -601,8 +600,6 @@ async function detachedHistoricalBranchProbe(createSchedulerService) {
|
|
|
601
600
|
settings: { backend: "claude" },
|
|
602
601
|
activeSessions: { alpha: { claude: "unrelated-live-session" } },
|
|
603
602
|
completedCompactions: {},
|
|
604
|
-
runningProcess: null,
|
|
605
|
-
preparingRun: false,
|
|
606
603
|
};
|
|
607
604
|
const h = schedulerHarness(createSchedulerService, [baseJob({ id: "historical", occurrenceId: "occ-historical" })], {
|
|
608
605
|
state,
|
|
@@ -625,9 +622,8 @@ async function busyRestartAndMissedProbe(createSchedulerService) {
|
|
|
625
622
|
settings: { backend: "claude" },
|
|
626
623
|
activeSessions: { alpha: { claude: "session" } },
|
|
627
624
|
completedCompactions: {},
|
|
628
|
-
runningProcess: { pid: 1 },
|
|
629
|
-
preparingRun: false,
|
|
630
625
|
};
|
|
626
|
+
jobLane(busyState).runningProcess = { pid: 1 };
|
|
631
627
|
const busy = schedulerHarness(createSchedulerService, [baseJob({ id: "busy", sessionId: "session" })], { state: busyState });
|
|
632
628
|
const deferredBusy = await busy.service.fireJob("busy");
|
|
633
629
|
assert.strictEqual(deferredBusy.deferred, true);
|
|
@@ -648,6 +644,52 @@ async function busyRestartAndMissedProbe(createSchedulerService) {
|
|
|
648
644
|
assert.strictEqual(missedTimers.length, 1, "a missed wakeup gets exactly one startup attempt");
|
|
649
645
|
}
|
|
650
646
|
|
|
647
|
+
// Parallel conversations: a job is busy only when ITS conversation is. A
|
|
648
|
+
// wakeup made in a thread fires while the speaker's main timeline (or another
|
|
649
|
+
// thread) runs, and its scope carries the thread so the notice and the reply
|
|
650
|
+
// land in that thread; the room stays the channel.
|
|
651
|
+
async function threadLaneProbe(createSchedulerService) {
|
|
652
|
+
const THREAD = "cccccccccccccccccccccccc";
|
|
653
|
+
const threadKey = `telegram:fixture-channel#thread:${THREAD}`;
|
|
654
|
+
const h = schedulerHarness(createSchedulerService, [
|
|
655
|
+
baseJob({ id: "in-thread", project: threadKey, threadId: THREAD }),
|
|
656
|
+
baseJob({ id: "legacy-thread", project: threadKey }),
|
|
657
|
+
baseJob({ id: "in-main", project: "telegram:fixture-channel" }),
|
|
658
|
+
baseJob({ id: "thread-waiting", project: threadKey, threadId: THREAD }),
|
|
659
|
+
], { outcomes: [succeededRun(), succeededRun(), succeededRun()] });
|
|
660
|
+
jobLane(h.state, "telegram:fixture-channel").runningProcess = { pid: 1 };
|
|
661
|
+
jobLane(h.state, "telegram:fixture-channel#thread:dddddddddddddddddddddddd").runningProcess = { pid: 2 };
|
|
662
|
+
const fired = await h.service.fireJob("in-thread");
|
|
663
|
+
assert.strictEqual(fired.ok, true, "a thread's wakeup fires while other conversations of the speaker run");
|
|
664
|
+
assert.strictEqual(h.runCalls.length, 1);
|
|
665
|
+
const scope = h.chats.at(-1);
|
|
666
|
+
assert.strictEqual(scope.threadId, THREAD, "the firing scope carries the job's thread");
|
|
667
|
+
assert.strictEqual(scope.conversationKey, threadKey, "and resumes the thread's conversation");
|
|
668
|
+
assert.strictEqual(scope.roomKey, "telegram:fixture-channel", "the room is the channel");
|
|
669
|
+
assert.strictEqual((await h.service.fireJob("legacy-thread")).ok, true);
|
|
670
|
+
assert.strictEqual(h.chats.at(-1).threadId, THREAD, "a job without threadId reads it off its conversation key");
|
|
671
|
+
const blocked = await h.service.fireJob("in-main");
|
|
672
|
+
assert.strictEqual(blocked.deferred, true, "a job whose own conversation runs is deferred");
|
|
673
|
+
assert.strictEqual(h.chats.at(-1).threadId, null, "a main-timeline job has no thread");
|
|
674
|
+
jobLane(h.state, threadKey).messageQueue.push({ queuedAt: 1 });
|
|
675
|
+
assert.strictEqual((await h.service.fireJob("thread-waiting")).deferred, true, "messages waiting in the job's conversation defer it too");
|
|
676
|
+
jobLane(h.state, threadKey).messageQueue.length = 0;
|
|
677
|
+
|
|
678
|
+
// A job whose project is not its chat's key (--project myrepo) still waits
|
|
679
|
+
// for the conversation it replies INTO; its own project lane is free.
|
|
680
|
+
const r = schedulerHarness(createSchedulerService, [
|
|
681
|
+
baseJob({ id: "repo-in-thread", project: "myrepo", threadId: THREAD }),
|
|
682
|
+
baseJob({ id: "repo-in-main", project: "myrepo" }),
|
|
683
|
+
], { outcomes: [succeededRun()] });
|
|
684
|
+
jobLane(r.state, threadKey).runningProcess = { pid: 3 };
|
|
685
|
+
assert.strictEqual((await r.service.fireJob("repo-in-thread")).deferred, true, "a job replying into a busy thread waits for it");
|
|
686
|
+
assert.strictEqual(r.runCalls.length, 0);
|
|
687
|
+
jobLane(r.state, threadKey).runningProcess = null;
|
|
688
|
+
jobLane(r.state, "telegram:fixture-channel").messageQueue.push({ queuedAt: 1 });
|
|
689
|
+
assert.strictEqual((await r.service.fireJob("repo-in-main")).deferred, true, "and one replying into a timeline with messages waiting too");
|
|
690
|
+
jobLane(r.state, "telegram:fixture-channel").messageQueue.length = 0;
|
|
691
|
+
}
|
|
692
|
+
|
|
651
693
|
async function busyWatchNoticeProbe(createSchedulerService) {
|
|
652
694
|
// A watch alarm waiting behind a busy room: silent for 15 minutes, then one
|
|
653
695
|
// plain line, never a second for the same occurrence; the next occurrence
|
|
@@ -658,7 +700,7 @@ async function busyWatchNoticeProbe(createSchedulerService) {
|
|
|
658
700
|
id: "alarm", kind: "cron", schedule: "*/15 * * * *", check: "fixture-check", repeat: true,
|
|
659
701
|
label: "Phone alarm", expiresAt: clock + 24 * 60 * 60 * 1000,
|
|
660
702
|
})], { now: () => clock, runWatchCheck: async () => { checks++; return { code: 0 }; } });
|
|
661
|
-
h.state.runningProcess = { pid: 1 };
|
|
703
|
+
jobLane(h.state).runningProcess = { pid: 1 };
|
|
662
704
|
await h.service.fireJob("alarm"); // triggers and latches
|
|
663
705
|
assert.strictEqual(checks, 1);
|
|
664
706
|
for (const minutes of [5, 10, 14]) {
|
|
@@ -679,7 +721,7 @@ async function busyWatchNoticeProbe(createSchedulerService) {
|
|
|
679
721
|
|
|
680
722
|
// A wakeup waiting behind the same run stays quiet.
|
|
681
723
|
const w = schedulerHarness(createSchedulerService, [baseJob({ id: "wake" })], { now: () => clock });
|
|
682
|
-
w.state.runningProcess = { pid: 1 };
|
|
724
|
+
jobLane(w.state).runningProcess = { pid: 1 };
|
|
683
725
|
clock += 60 * 60_000;
|
|
684
726
|
await w.service.fireJob("wake");
|
|
685
727
|
assert.strictEqual(w.sends.length, 0);
|
|
@@ -701,6 +743,12 @@ async function tick(task) {
|
|
|
701
743
|
for (let i = 0; i < 3; i++) await nextTurn();
|
|
702
744
|
}
|
|
703
745
|
|
|
746
|
+
// The scheduler's busy check looks at the job's OWN conversation lane (its
|
|
747
|
+
// project), not the speaker's whole state: other conversations may run.
|
|
748
|
+
function jobLane(state, project = "alpha") {
|
|
749
|
+
return require("./core/state").laneFor(state, project);
|
|
750
|
+
}
|
|
751
|
+
|
|
704
752
|
function repeatingWatch(overrides = {}) {
|
|
705
753
|
return baseJob({
|
|
706
754
|
kind: "cron",
|
|
@@ -731,7 +779,7 @@ async function pendingDeliveryPollingProbe(createSchedulerService) {
|
|
|
731
779
|
h.service.init();
|
|
732
780
|
assert.strictEqual(h.crons.length, 1);
|
|
733
781
|
const poll = h.crons[0];
|
|
734
|
-
h.state.runningProcess = { pid: 1 };
|
|
782
|
+
jobLane(h.state).runningProcess = { pid: 1 };
|
|
735
783
|
await tick(poll);
|
|
736
784
|
assert.strictEqual(h.jobsStore.get("bus").status, "deferred");
|
|
737
785
|
assert.strictEqual(poll.stopped, false, "a repeating watch keeps its poll schedule while the delivery waits");
|
|
@@ -766,7 +814,7 @@ async function pendingDeliveryPollingProbe(createSchedulerService) {
|
|
|
766
814
|
assert.strictEqual(bus.watchLastClearAt, at(30));
|
|
767
815
|
assert.strictEqual(h.runCalls.length, 0);
|
|
768
816
|
|
|
769
|
-
h.state.runningProcess = null;
|
|
817
|
+
jobLane(h.state).runningProcess = null;
|
|
770
818
|
clock = at(46);
|
|
771
819
|
assert.strictEqual((await h.scheduled.at(-1).fn()).ok, true);
|
|
772
820
|
assert.strictEqual(h.runCalls.length, 1, "one delivery for the whole wait");
|
|
@@ -867,12 +915,12 @@ async function pendingDeliveryPollingProbe(createSchedulerService) {
|
|
|
867
915
|
outcomes: [succeededRun()],
|
|
868
916
|
});
|
|
869
917
|
race.service.init();
|
|
870
|
-
race.state.runningProcess = { pid: 1 };
|
|
918
|
+
jobLane(race.state).runningProcess = { pid: 1 };
|
|
871
919
|
await tick(race.crons[0]);
|
|
872
920
|
release = () => {};
|
|
873
921
|
race.crons[0].callback();
|
|
874
922
|
await nextTurn();
|
|
875
|
-
race.state.runningProcess = null;
|
|
923
|
+
jobLane(race.state).runningProcess = null;
|
|
876
924
|
assert.strictEqual((await race.scheduled.at(-1).fn()).ok, true);
|
|
877
925
|
release({ code: 0 });
|
|
878
926
|
for (let i = 0; i < 3; i++) await nextTurn();
|
|
@@ -886,7 +934,7 @@ async function pendingDeliveryPollingProbe(createSchedulerService) {
|
|
|
886
934
|
runWatchCheck: async () => { onceChecks++; return { code: 0 }; },
|
|
887
935
|
});
|
|
888
936
|
once.service.init();
|
|
889
|
-
once.state.runningProcess = { pid: 1 };
|
|
937
|
+
jobLane(once.state).runningProcess = { pid: 1 };
|
|
890
938
|
await tick(once.crons[0]);
|
|
891
939
|
assert.strictEqual(once.crons[0].stopped, true, "a one-shot watch parks on its retry timer alone");
|
|
892
940
|
await tick(once.crons[0]);
|
|
@@ -944,7 +992,7 @@ async function checkTimeoutAlarmProbe(createSchedulerService) {
|
|
|
944
992
|
now: () => clock,
|
|
945
993
|
runWatchCheck: async () => hungResult,
|
|
946
994
|
});
|
|
947
|
-
hung.state.runningProcess = { pid: 1 };
|
|
995
|
+
jobLane(hung.state).runningProcess = { pid: 1 };
|
|
948
996
|
const raisedAt = clock;
|
|
949
997
|
await hung.service.fireJob("hung");
|
|
950
998
|
clock = raisedAt + 16 * 60_000;
|
|
@@ -1111,12 +1159,12 @@ async function strandedDeliveryProbe(createSchedulerService) {
|
|
|
1111
1159
|
admitError: setup.admitError || null,
|
|
1112
1160
|
};
|
|
1113
1161
|
const h = schedulerHarness(createSchedulerService, [], options);
|
|
1114
|
-
if (setup.busy) h.state.runningProcess = { pid: 1 };
|
|
1162
|
+
if (setup.busy) jobLane(h.state).runningProcess = { pid: 1 };
|
|
1115
1163
|
const first = await h.service.fireJob("w");
|
|
1116
1164
|
assert.strictEqual(faults, 1, `${name}: the write failed`);
|
|
1117
1165
|
assert.strictEqual(first.ok, false, name);
|
|
1118
1166
|
assert.strictEqual(h.scheduled.length, 1, `${name}: a retry stays armed after the failed write`);
|
|
1119
|
-
h.state.runningProcess = null;
|
|
1167
|
+
jobLane(h.state).runningProcess = null;
|
|
1120
1168
|
options.admitError = null;
|
|
1121
1169
|
clock += 60_000;
|
|
1122
1170
|
assert.strictEqual((await h.scheduled.at(-1).fn()).ok, true, `${name}: delivered at the next idle moment`);
|
|
@@ -1168,7 +1216,7 @@ async function durablePendingProbe(createSchedulerService, jobsApi) {
|
|
|
1168
1216
|
jobsStore: store, now: () => clock,
|
|
1169
1217
|
runWatchCheck: async () => { checks++; return { code: kind === "expiry" ? 1 : 0 }; },
|
|
1170
1218
|
});
|
|
1171
|
-
h.state.runningProcess = { pid: 1 };
|
|
1219
|
+
jobLane(h.state).runningProcess = { pid: 1 };
|
|
1172
1220
|
for (let tick = 0; tick < 12; tick++) {
|
|
1173
1221
|
clock += 60 * 60 * 1000;
|
|
1174
1222
|
const result = await h.service.fireJob(kind);
|
|
@@ -1297,8 +1345,10 @@ function creationEnrichmentProbe(createSchedulerService) {
|
|
|
1297
1345
|
settings: { backend: "codex" },
|
|
1298
1346
|
activeSessions: { alpha: { codex: "thread-alpha" } },
|
|
1299
1347
|
providerSettings: { codex: { model: "gpt-scheduled", effort: "high" } },
|
|
1300
|
-
activeRunContext: { runId: "origin-run" },
|
|
1301
1348
|
};
|
|
1349
|
+
// The origin-run fallback reads the job's own conversation only.
|
|
1350
|
+
jobLane(state, "telegram:fixture-channel#thread:bbbbbbbbbbbbbbbbbbbbbbbb").activeRunContext = { runId: "another-thread-run" };
|
|
1351
|
+
jobLane(state, "alpha").activeRunContext = { runId: "origin-run" };
|
|
1302
1352
|
const service = createSchedulerService({
|
|
1303
1353
|
jobsStore: memoryJobsStore([]),
|
|
1304
1354
|
currentState: () => state,
|
|
@@ -1325,6 +1375,12 @@ function creationEnrichmentProbe(createSchedulerService) {
|
|
|
1325
1375
|
});
|
|
1326
1376
|
assert.strictEqual(enriched.originRunId, "origin-run");
|
|
1327
1377
|
assert.strictEqual(enriched.occurrenceId, "origin-occurrence");
|
|
1378
|
+
jobLane(state, "alpha").activeRunContext = null;
|
|
1379
|
+
const unrelated = service.enrichJobSpec({
|
|
1380
|
+
kind: "wakeup", adapter: "telegram-fixture", adapterType: "telegram", channelId: "fixture-channel",
|
|
1381
|
+
canonicalUserId: "fixture-user", project: "alpha", prompt: "later", fireAt: 1,
|
|
1382
|
+
});
|
|
1383
|
+
assert.strictEqual(unrelated.originRunId, null, "another conversation's running turn is never the origin");
|
|
1328
1384
|
}
|
|
1329
1385
|
|
|
1330
1386
|
function loopbackMetadataProbe(runner, loopbackClient) {
|
|
@@ -1576,6 +1632,7 @@ async function main() {
|
|
|
1576
1632
|
await failureAndRetryProbe(schedulerApi.createSchedulerService);
|
|
1577
1633
|
await detachedHistoricalBranchProbe(schedulerApi.createSchedulerService);
|
|
1578
1634
|
await busyRestartAndMissedProbe(schedulerApi.createSchedulerService);
|
|
1635
|
+
await threadLaneProbe(schedulerApi.createSchedulerService);
|
|
1579
1636
|
await durablePendingProbe(schedulerApi.createSchedulerService, jobsApi);
|
|
1580
1637
|
await busyWatchNoticeProbe(schedulerApi.createSchedulerService);
|
|
1581
1638
|
await pendingDeliveryPollingProbe(schedulerApi.createSchedulerService);
|
|
@@ -79,16 +79,25 @@ function newEndAndContinueProbe() {
|
|
|
79
79
|
state.currentSession = { name: "alpha", dir: path.join(workspace, "alpha") };
|
|
80
80
|
state.settings.backend = "codex";
|
|
81
81
|
const settled = [];
|
|
82
|
-
|
|
82
|
+
const otherSettled = [];
|
|
83
|
+
// /new clears the CURRENT conversation's queue only (here the lane of the
|
|
84
|
+
// "alpha" project); another conversation's waiting message survives.
|
|
85
|
+
const lane = stateApi.laneFor(state, "alpha");
|
|
86
|
+
const other = stateApi.laneFor(state, "telegram:other#thread:aaaaaaaaaaaaaaaaaaaaaaaa");
|
|
87
|
+
lane.messageQueue.push({ deferred: { resolve: (value) => settled.push(value) } });
|
|
88
|
+
other.messageQueue.push({ deferred: { resolve: (value) => otherSettled.push(value) } });
|
|
83
89
|
|
|
84
90
|
const fresh = handlers.startNewConversation({ state, persist: false });
|
|
85
91
|
assert.strictEqual(fresh.ok, true);
|
|
86
92
|
assert.strictEqual(stateApi.getProviderSession(state, "codex", "alpha"), null);
|
|
87
93
|
assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "claude-alpha");
|
|
88
94
|
assert.strictEqual(stateApi.userOwnsProviderSession(state.userId, "alpha", "codex", "codex-alpha"), true, "new does not delete history");
|
|
89
|
-
assert.strictEqual(
|
|
95
|
+
assert.strictEqual(lane.messageQueue.length, 0);
|
|
90
96
|
assert.strictEqual(settled.length, 1);
|
|
91
97
|
assert.strictEqual(settled[0].status, "cancelled");
|
|
98
|
+
assert.strictEqual(other.messageQueue.length, 1, "another conversation's queue is untouched");
|
|
99
|
+
assert.strictEqual(otherSettled.length, 0);
|
|
100
|
+
other.messageQueue.length = 0;
|
|
92
101
|
|
|
93
102
|
stateApi.setProviderSession(state, "codex", "alpha", "codex-alpha");
|
|
94
103
|
const continueOptions = handlers.activeContinueOptions(state);
|
|
@@ -279,6 +279,28 @@ async function coordinatorProbe() {
|
|
|
279
279
|
assert.strictEqual(failed.ok, false);
|
|
280
280
|
assert.strictEqual(deliveryAttempts.length, 1, "delivery failure must not trigger a second send");
|
|
281
281
|
assert.strictEqual(failingDelivery.isActive("fixture-user"), false);
|
|
282
|
+
|
|
283
|
+
// Side chats are per (speaker, conversation): a side answer in one thread
|
|
284
|
+
// does not make another conversation "busy", and cancelling one
|
|
285
|
+
// conversation's side chat leaves the other's running.
|
|
286
|
+
const laneGate = deferred();
|
|
287
|
+
const perLane = new SideChatCoordinator({
|
|
288
|
+
tokenFactory: (() => { let n = 0; return () => `lane-token-${++n}`; })(),
|
|
289
|
+
runSideChat: async () => { await laneGate.promise; return { ok: true, text: "lane answer" }; },
|
|
290
|
+
deliver: async () => ({ ok: true }),
|
|
291
|
+
enqueueMain: () => ({ queued: true }),
|
|
292
|
+
});
|
|
293
|
+
const threadRun = Object.freeze({ ...first, laneKey: "telegram:x#thread:aaaaaaaaaaaaaaaaaaaaaaaa", runId: "thread-side" });
|
|
294
|
+
const mainRun = Object.freeze({ ...first, laneKey: "telegram:x", runId: "main-side" });
|
|
295
|
+
const inThread = perLane.respond({ runContext: threadRun, activeRunContext: active });
|
|
296
|
+
const inMain = perLane.respond({ runContext: mainRun, activeRunContext: active });
|
|
297
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
298
|
+
assert.strictEqual(perLane.isActive("fixture-user", "telegram:x#thread:aaaaaaaaaaaaaaaaaaaaaaaa"), true);
|
|
299
|
+
assert.strictEqual(perLane.isActive("fixture-user", "telegram:x"), true, "the main timeline gets its own side answer");
|
|
300
|
+
assert.strictEqual(await perLane.cancel("fixture-user", "telegram:x"), true);
|
|
301
|
+
laneGate.resolve();
|
|
302
|
+
assert.strictEqual((await inMain).status, "cancelled", "the cancelled conversation's side answer is dropped");
|
|
303
|
+
assert.notStrictEqual((await inThread).status, "cancelled", "the other conversation's side answer is delivered");
|
|
282
304
|
}
|
|
283
305
|
|
|
284
306
|
function modeAndLineageProbe() {
|
|
@@ -294,13 +316,19 @@ function modeAndLineageProbe() {
|
|
|
294
316
|
assert.strictEqual(readRuntimeMode(modeDir), "agent");
|
|
295
317
|
|
|
296
318
|
const state = mainState("claude");
|
|
297
|
-
|
|
319
|
+
// The run slot is the conversation's lane (outside a chat turn: the project).
|
|
320
|
+
const lane = require("./core/state").laneFor(state, "project-a");
|
|
321
|
+
lane.runningProcess = { pid: 123 };
|
|
298
322
|
const incoming = captureMainRun(state, "queue me", "lineage-incoming");
|
|
299
323
|
const active = captureMainRun(state, "fresh main", "lineage-main");
|
|
300
|
-
|
|
324
|
+
lane.activeRunContext = active;
|
|
325
|
+
assert.strictEqual(incoming.laneKey, "project-a", "the captured run names its lane");
|
|
301
326
|
assert.strictEqual(shouldStartSideChat(state, incoming, {}, { enabled: () => false }), false);
|
|
302
327
|
assert.strictEqual(shouldStartSideChat(state, incoming, {}, { enabled: () => true }), true);
|
|
303
328
|
assert.strictEqual(shouldStartSideChat(state, incoming, { sideChat: false }, { enabled: () => true }), false);
|
|
329
|
+
const otherConversation = Object.freeze({ ...incoming, laneKey: "project-a#thread:ffffffffffffffffffffffff" });
|
|
330
|
+
assert.strictEqual(shouldStartSideChat(state, otherConversation, {}, { enabled: () => true }), false,
|
|
331
|
+
"another conversation of the speaker is not busy: it runs in its own lane, no side chat");
|
|
304
332
|
|
|
305
333
|
const request = createSideChatRequest({ runContext: incoming, activeRunContext: active, token: "lineage-token" });
|
|
306
334
|
const pending = createSideChatQueueItem(request, { id: "origin" }, {
|