@inetafrica/open-claudia 3.5.1 → 3.5.4

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.
@@ -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" }, {
package/test-run-lock.js CHANGED
@@ -1,63 +1,146 @@
1
- // P0-3: single-flight run-lock regression test. Under unified identity the
2
- // owner's Telegram and Kazee turns resolve to ONE canonical user and therefore
3
- // share ONE in-memory state object. If two of those turns could both pass the
4
- // "is a run in progress?" check they would race the shared state and the JSON
5
- // files behind it. acquireRunLock() closes that window by arming the lock
6
- // SYNCHRONOUSLY (no await between the check and the arm), so the second turn is
7
- // always refused and queued. This test pins that contract on the raw helper.
1
+ // P0-3: single-flight run-lock regression test, per conversation LANE. A lane
2
+ // is one conversation (the channel's main timeline, or one chat thread): turns
3
+ // inside a lane are strictly one at a time, so two turns of one conversation
4
+ // can never both pass the "is a run in progress?" check and race its provider
5
+ // session and the JSON files behind it. acquireRunLock() closes that window by
6
+ // arming the lane SYNCHRONOUSLY (no await between the check and the arm).
7
+ //
8
+ // Deliberate change (parallel conversations): under unified identity the
9
+ // owner's Telegram DM and Kazee chat still resolve to ONE canonical state, but
10
+ // they are DIFFERENT conversations (conversation keys), so they are separate
11
+ // lanes and may now run at the same time, bounded by the per-speaker cap
12
+ // (OC_MAX_PARALLEL_TURNS, default 4) and the global cap. The old test pinned
13
+ // that the second channel was refused; what must hold now is that the SAME
14
+ // conversation is refused, and that the caps bound the rest.
8
15
 
9
16
  const assert = require("assert");
10
17
  process.env.OPEN_CLAUDIA_TEST = "1";
11
18
 
12
- const { acquireRunLock } = require("./core/state");
19
+ const stateApi = require("./core/state");
20
+ const { acquireRunLock, admitLane, laneFor, peekLane, anyTurnActive, waitingLanes } = stateApi;
21
+
22
+ const MAIN = "telegram:owner";
23
+ const KAZEE = "kazee:owner-chat";
24
+ const THREAD = "kazee:owner-chat#thread:aaaaaaaaaaaaaaaaaaaaaaaa";
25
+ // Fixture states join the global slot count once they hold a lane; each block
26
+ // releases its own so the global cap never carries over between blocks.
27
+ const fixtures = [];
28
+ const fresh = () => { const s = { userId: `owner-${Math.random()}` }; fixtures.push(s); return s; };
29
+ const releaseAll = () => { for (const s of fixtures) { s.lanes?.clear(); stateApi.pruneIdleLanes(s); } };
13
30
 
14
31
  // A run-lock must be synchronous — its whole point is that no await can
15
32
  // interleave between the check and the arm. If this ever returned a Promise the
16
33
  // guarantee would silently evaporate.
17
34
  {
18
- const r = acquireRunLock({ runningProcess: null, preparingRun: false });
35
+ const r = acquireRunLock(fresh(), MAIN);
19
36
  assert.strictEqual(typeof r, "boolean", "acquireRunLock must be synchronous (return a boolean, not a Promise)");
20
37
  }
21
38
 
22
- // Idle → acquires and arms preparingRun.
39
+ // Idle lane → acquires and arms the lane's preparingRun, nothing on the state.
23
40
  {
24
- const s = { runningProcess: null, preparingRun: false };
25
- assert.strictEqual(acquireRunLock(s), true, "idle state acquires");
26
- assert.strictEqual(s.preparingRun, true, "acquiring arms preparingRun");
41
+ const s = fresh();
42
+ assert.strictEqual(acquireRunLock(s, MAIN), true, "idle lane acquires");
43
+ assert.strictEqual(laneFor(s, MAIN).preparingRun, true, "acquiring arms the lane's preparingRun");
44
+ assert.strictEqual(s.preparingRun, undefined, "the speaker's state holds no run slot of its own");
45
+ assert.ok(!Object.keys(s).includes("lanes"), "lanes are not enumerable (never cloned or serialized)");
27
46
  }
28
47
 
29
48
  // Already preparing (armed but not yet spawned) → refused, lock left intact.
30
49
  {
31
- const s = { runningProcess: null, preparingRun: true };
32
- assert.strictEqual(acquireRunLock(s), false, "preparing state is refused");
33
- assert.strictEqual(s.preparingRun, true, "refusal leaves the existing lock armed");
50
+ const s = fresh();
51
+ laneFor(s, MAIN).preparingRun = true;
52
+ const refused = admitLane(s, MAIN);
53
+ assert.strictEqual(refused.ok, false, "preparing lane is refused");
54
+ assert.strictEqual(refused.reason, "busy");
55
+ assert.strictEqual(laneFor(s, MAIN).preparingRun, true, "refusal leaves the existing lock armed");
56
+ }
57
+
58
+ // A process is actively running, or the lane is settling/compacting → refused.
59
+ for (const field of ["runningProcess", "settling", "isCompacting"]) {
60
+ const s = fresh();
61
+ laneFor(s, MAIN)[field] = field === "runningProcess" ? { pid: 123 } : true;
62
+ assert.strictEqual(acquireRunLock(s, MAIN), false, `${field} lane is refused`);
34
63
  }
35
64
 
36
- // A process is actively running → refused.
65
+ // THE guarantee: two turns of ONE conversation. Exactly one may acquire; the
66
+ // other must be refused until the first fully releases.
37
67
  {
38
- const s = { runningProcess: { pid: 123 }, preparingRun: false };
39
- assert.strictEqual(acquireRunLock(s), false, "running process is refused");
68
+ const shared = fresh();
69
+ const first = acquireRunLock(shared, KAZEE);
70
+ const second = acquireRunLock(shared, KAZEE);
71
+ assert.strictEqual(first, true, "first turn wins the lane");
72
+ assert.strictEqual(second, false, "second turn of the same conversation is refused (no double-acquire)");
73
+ const lane = laneFor(shared, KAZEE);
74
+ lane.preparingRun = false;
75
+ lane.runningProcess = { pid: 1 };
76
+ assert.strictEqual(acquireRunLock(shared, KAZEE), false, "still refused while the process runs");
77
+ lane.runningProcess = null;
78
+ assert.strictEqual(acquireRunLock(shared, KAZEE), true, "lane is re-acquirable once the turn fully releases");
40
79
  }
41
80
 
42
- // THE guarantee: two turns sharing ONE state object (owner's Telegram + Kazee
43
- // under unified identity). Exactly one may acquire; the other must be refused
44
- // until the first fully releases.
81
+ releaseAll();
82
+
83
+ // Unified identity: the owner's channels and a thread are separate lanes of
84
+ // ONE state and run in parallel.
45
85
  {
46
- const shared = { runningProcess: null, preparingRun: false };
86
+ const owner = fresh();
87
+ assert.strictEqual(acquireRunLock(owner, MAIN), true, "Telegram DM lane");
88
+ assert.strictEqual(acquireRunLock(owner, KAZEE), true, "Kazee chat lane runs beside it");
89
+ assert.strictEqual(acquireRunLock(owner, THREAD), true, "a thread of that chat is its own lane too");
90
+ assert.strictEqual(laneFor(owner, THREAD).threadId, "aaaaaaaaaaaaaaaaaaaaaaaa", "a thread lane knows its thread");
91
+ assert.strictEqual(laneFor(owner, KAZEE).threadId, null, "the main timeline has no thread");
92
+ assert.strictEqual(anyTurnActive(), true, "anyTurnActive sees every lane of every state");
93
+ releaseAll();
94
+ assert.strictEqual(anyTurnActive(), false, "and nothing once they are released");
95
+ }
47
96
 
48
- const first = acquireRunLock(shared); // Telegram turn arrives
49
- const second = acquireRunLock(shared); // Kazee turn arrives before the first releases
50
- assert.strictEqual(first, true, "first turn wins the lock");
51
- assert.strictEqual(second, false, "second concurrent turn is refused (no double-acquire)");
97
+ releaseAll();
52
98
 
53
- // First turn spawns its process, then finishes and clears both flags.
54
- shared.preparingRun = false;
55
- shared.runningProcess = { pid: 1 }; // spawned
56
- assert.strictEqual(acquireRunLock(shared), false, "still refused while the process runs");
57
- shared.runningProcess = null; // turn complete
99
+ // Caps: a speaker may run OC_MAX_PARALLEL_TURNS lanes; the next waits ("cap"),
100
+ // and a lane that waits keeps new messages of its conversation behind it.
101
+ {
102
+ const prior = process.env.OC_MAX_PARALLEL_TURNS;
103
+ process.env.OC_MAX_PARALLEL_TURNS = "2";
104
+ try {
105
+ const s = fresh();
106
+ assert.strictEqual(acquireRunLock(s, "k:1"), true);
107
+ assert.strictEqual(acquireRunLock(s, "k:2"), true);
108
+ const third = admitLane(s, "k:3");
109
+ assert.strictEqual(third.ok, false, "the third lane of the speaker waits");
110
+ assert.strictEqual(third.reason, "cap");
111
+ assert.strictEqual(third.running, 2, "the notice can say how many are running");
112
+ assert.strictEqual(third.lane.preparingRun, false, "a waiting lane is not armed");
113
+ third.lane.messageQueue.push({ queuedAt: 1, seq: 1 });
114
+ assert.strictEqual(admitLane(s, "k:3").reason, "waiting", "a waiting lane queues its next message behind the first");
115
+ assert.deepStrictEqual(waitingLanes().filter((w) => w.state === s).map((w) => w.lane.key), ["k:3"]);
116
+ const other = fresh();
117
+ assert.strictEqual(acquireRunLock(other, "k:1"), true, "another speaker has slots of its own");
118
+ laneFor(s, "k:1").preparingRun = false;
119
+ assert.strictEqual(admitLane(s, "k:4").reason, "cap", "a lane that did not wait still sees the cap while k:3 waits for the freed slot");
120
+ } finally {
121
+ releaseAll();
122
+ if (prior === undefined) delete process.env.OC_MAX_PARALLEL_TURNS;
123
+ else process.env.OC_MAX_PARALLEL_TURNS = prior;
124
+ }
125
+ }
58
126
 
59
- // Now the queued second turn (drained from messageQueue) can proceed.
60
- assert.strictEqual(acquireRunLock(shared), true, "lock is re-acquirable once the turn fully releases");
127
+ // The global cap bounds every speaker together.
128
+ {
129
+ const prior = process.env.OC_MAX_PARALLEL_TURNS_GLOBAL;
130
+ process.env.OC_MAX_PARALLEL_TURNS_GLOBAL = "1";
131
+ try {
132
+ const a = fresh();
133
+ const b = fresh();
134
+ assert.strictEqual(acquireRunLock(a, "g:1"), true);
135
+ const blocked = admitLane(b, "g:2");
136
+ assert.strictEqual(blocked.ok, false, "another speaker waits when the bot-wide cap is reached");
137
+ assert.strictEqual(blocked.scope, "global");
138
+ assert.strictEqual(anyTurnActive(), true, "anyTurnActive sees a running lane of any state");
139
+ } finally {
140
+ releaseAll();
141
+ if (prior === undefined) delete process.env.OC_MAX_PARALLEL_TURNS_GLOBAL;
142
+ else process.env.OC_MAX_PARALLEL_TURNS_GLOBAL = prior;
143
+ }
61
144
  }
62
145
 
63
146
  // A /stop flag names the run it stops. Only that run (or a legacy bare `true`)
@@ -65,7 +148,7 @@ const { acquireRunLock } = require("./core/state");
65
148
  // "before the provider started" (24 Sep, three messages lost after one /stop).
66
149
  {
67
150
  const { cancelFlagFor, cancelTargets } = require("./core/state");
68
- assert.strictEqual(cancelFlagFor({ activeRunContext: { runId: "run-a" } }), "run-a", "the flag names the active run");
151
+ assert.strictEqual(cancelFlagFor({ activeRunContext: { runId: "run-a" } }), "run-a", "the flag names the lane's active run");
69
152
  assert.strictEqual(cancelFlagFor({ activeRunContext: null }), true, "no known run: a bare flag");
70
153
  assert.strictEqual(cancelTargets("run-a", { runId: "run-a" }), true, "the stopped run sees its stop");
71
154
  assert.strictEqual(cancelTargets("run-a", { runId: "run-b" }), false, "another run never does");
@@ -82,13 +165,13 @@ const { acquireRunLock } = require("./core/state");
82
165
  assert.strictEqual(queuedNotice(null, t0), "Queued.");
83
166
  assert.strictEqual(queuedNotice({ admittedAt: t0, userPrompt: "hi" }, t0 + 60_000), "Queued.", "a short wait stays terse");
84
167
  assert.strictEqual(queuedNotice({ admittedAt: t0, userPrompt: "hi" }, t0 + 5 * 60_000),
85
- "Queued behind an earlier message (running 5 min). /stop cancels it.");
168
+ "Queued behind an earlier message (running 5 min). /stop cancels it (and drops this message).");
86
169
  assert.strictEqual(queuedNotice({ admittedAt: t0, userPrompt: '[Watch triggered "Phone alarm" fired at x; occurrence y]\nbody' }, t0 + 42 * 60_000),
87
- 'Queued behind the watch "Phone alarm" (running 42 min). /stop cancels it.');
170
+ 'Queued behind the watch "Phone alarm" (running 42 min). /stop cancels it (and drops this message).');
88
171
  assert.strictEqual(queuedNotice({ admittedAt: t0, userPrompt: '[Scheduled wakeup "Check CI" fired at x]' }, t0 + 3 * 60_000),
89
- 'Queued behind the wakeup "Check CI" (running 3 min). /stop cancels it.');
172
+ 'Queued behind the wakeup "Check CI" (running 3 min). /stop cancels it (and drops this message).');
90
173
  assert.strictEqual(queuedNotice({ admittedAt: t0, userPrompt: '[Watch check timing out "Phone health" fired at x; occurrence y]' }, t0 + 20 * 60_000),
91
- 'Queued behind the watch "Phone health" (running 20 min). /stop cancels it.', "a check-timeout alarm is a watch run too");
174
+ 'Queued behind the watch "Phone health" (running 20 min). /stop cancels it (and drops this message).', "a check-timeout alarm is a watch run too");
92
175
  const long = queuedNotice({ admittedAt: t0, userPrompt: `[Watch triggered "${"x".repeat(90)}" fired at x]` }, t0 + 3 * 60_000);
93
176
  assert.ok(long.includes(`"${"x".repeat(59)}…"`), "a long label is shortened to 60 characters");
94
177
  }