@inetafrica/open-claudia 3.5.0 → 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.
- package/CHANGELOG.md +32 -2
- package/bin/cli.js +13 -5
- package/bin/loopback-client.js +23 -5
- package/bin/owner-gate.js +2 -1
- package/bin/schedule.js +7 -2
- package/bin/tool.js +2 -2
- package/bot.js +6 -2
- package/channels/kazee/adapter.js +462 -64
- package/channels/kazee/state.js +78 -2
- package/channels/types.js +11 -5
- package/core/actions.js +6 -0
- package/core/approvals.js +9 -0
- package/core/bot-endpoint.js +194 -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/router.js +25 -13
- package/core/run-context.js +49 -8
- package/core/runner.js +313 -126
- package/core/scheduler.js +64 -32
- 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/tool-guard.js +15 -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 +15 -1
- package/test-provider-parity-e2e.js +8 -2
- 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
|
@@ -459,7 +459,9 @@ function runProviderChild(kind) {
|
|
|
459
459
|
function runSupportSuites(provider) {
|
|
460
460
|
assert.ok(["claude", "codex"].includes(provider));
|
|
461
461
|
const passed = new Set();
|
|
462
|
-
|
|
462
|
+
// 180 s per suite by default: on a loaded CI runner a suite that takes 20 s
|
|
463
|
+
// locally overran 90 s (canary-v3.5.0 build job 44463; the retry passed).
|
|
464
|
+
for (const [key, [script, marker, suiteTimeoutMs = 180000]] of Object.entries(SUPPORT_SUITES)) {
|
|
463
465
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `open-claudia-parity-suite-${key}-`));
|
|
464
466
|
try {
|
|
465
467
|
fs.mkdirSync(path.join(root, "home"));
|
|
@@ -471,7 +473,11 @@ function runSupportSuites(provider) {
|
|
|
471
473
|
timeout: suiteTimeoutMs,
|
|
472
474
|
maxBuffer: 8 * 1024 * 1024,
|
|
473
475
|
});
|
|
474
|
-
|
|
476
|
+
if (result.error) {
|
|
477
|
+
// Name the suite: a bare "spawnSync ETIMEDOUT" does not say which one.
|
|
478
|
+
const tail = `${result.stdout || ""}\n${result.stderr || ""}`.trim().split(/\r?\n/).slice(-20).join("\n");
|
|
479
|
+
assert.fail(`${key} (${script}) did not finish: ${result.error.code || result.error.message} after ${suiteTimeoutMs} ms\n${tail}`);
|
|
480
|
+
}
|
|
475
481
|
assert.strictEqual(result.status, 0, `${script}\n${result.stderr || result.stdout}`);
|
|
476
482
|
assert.ok(`${result.stdout}\n${result.stderr}`.includes(marker), `${script}: missing ${marker}`);
|
|
477
483
|
passed.add(key);
|
|
@@ -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" }, {
|
package/test-run-lock.js
CHANGED
|
@@ -1,63 +1,146 @@
|
|
|
1
|
-
// P0-3: single-flight run-lock regression test
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// "is a run in progress?" check
|
|
5
|
-
// files behind it. acquireRunLock() closes that window by
|
|
6
|
-
// SYNCHRONOUSLY (no await between the check and the arm)
|
|
7
|
-
//
|
|
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
|
|
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(
|
|
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 =
|
|
25
|
-
assert.strictEqual(acquireRunLock(s), true, "idle
|
|
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 =
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
//
|
|
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
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
}
|