@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
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
  }