@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.
Files changed (44) hide show
  1. package/.env.example +13 -0
  2. package/CHANGELOG.md +36 -0
  3. package/bin/cli.js +13 -5
  4. package/bin/loopback-client.js +4 -0
  5. package/bin/schedule.js +7 -2
  6. package/bot.js +6 -2
  7. package/channels/kazee/adapter.js +519 -64
  8. package/channels/kazee/state.js +88 -2
  9. package/channels/types.js +11 -5
  10. package/core/actions.js +6 -0
  11. package/core/approvals.js +9 -0
  12. package/core/context.js +11 -2
  13. package/core/handlers.js +161 -59
  14. package/core/identity.js +33 -3
  15. package/core/io.js +21 -8
  16. package/core/jobs.js +3 -0
  17. package/core/loopback.js +55 -7
  18. package/core/providers/claude.js +6 -2
  19. package/core/providers/utility-policy.js +3 -1
  20. package/core/router.js +25 -13
  21. package/core/run-context.js +49 -8
  22. package/core/runner.js +318 -122
  23. package/core/sandbox-probe.js +78 -0
  24. package/core/scheduler.js +45 -3
  25. package/core/side-chat.js +40 -11
  26. package/core/speaker-attest.js +2 -0
  27. package/core/state.js +317 -50
  28. package/core/system-prompt.js +61 -14
  29. package/core/thread-title.js +237 -0
  30. package/core/transcripts.js +23 -6
  31. package/package.json +3 -3
  32. package/test-approval-cards.js +24 -0
  33. package/test-call-speech.js +5 -3
  34. package/test-fixtures/fake-agent-cli.js +52 -1
  35. package/test-provider-compaction.js +50 -11
  36. package/test-provider-gateway.js +31 -10
  37. package/test-provider-language.js +7 -1
  38. package/test-provider-scheduler.js +77 -20
  39. package/test-provider-session-commands.js +11 -2
  40. package/test-provider-side-chat.js +30 -2
  41. package/test-run-lock.js +122 -39
  42. package/test-thread-lanes.js +530 -0
  43. package/core/queue-drain.js +0 -25
  44. 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.isCompacting = false;
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
- state.isCompacting = true;
96
+ alphaLane().isCompacting = true;
96
97
  assert.strictEqual(stateApi.acquireRunLock(state), false, "foreground admission stays closed between compaction stages");
97
- state.isCompacting = false;
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(state.isCompacting, false);
156
+ assert.strictEqual(alphaLane().isCompacting, false);
156
157
  }
157
158
 
158
159
  async function failureProbe() {
159
160
  resetFixture("claude");
160
- state.runningProcess = { pid: 123 };
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
- state.runningProcess = null;
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
- state.runningProcess = { pid: 4242 };
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
- state.runningProcess = null;
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(state.isCompacting, true);
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");
@@ -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
- acquireRunLock: () => false,
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(state.messageQueue.length, 1);
466
- const queued = state.messageQueue[0];
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(state.messageQueue.length, 2);
507
+ assert.strictEqual(lane.messageQueue.length, 2);
487
508
  obsoleteCall.abort();
488
- assert.strictEqual(state.messageQueue.length, 1, "superseded call entry is removed immediately, ordinary queued work is preserved");
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(state.messageQueue[0], queued);
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(state.messageQueue.length, 1, "an already-ended call never joins the queue");
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"], /messageQueue\.push\(\{ runContext,/, "queued work stores its immutable run context");
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
- assert.strictEqual(pkg.version, "3.5.1", "release version must remain unchanged without explicit approval");
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 (_ctx, fn) => fn(),
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
- state.messageQueue = [{ deferred: { resolve: (value) => settled.push(value) } }];
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(state.messageQueue.length, 0);
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
- state.runningProcess = { pid: 123 };
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
- state.activeRunContext = active;
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" }, {