diff options
| author | Agando <roduccomm@gmail.com> | 2026-11-02 20:55:56 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-11-02 20:55:56 +0100 |
| commit | b339bc13199eb9326307905dd635c56974b55f53 (patch) | |
| tree | b8b3c893ef6cae270b656e40a47ffbb872310e1b /modules/test | |
| parent | aeded980e3d998640db8a84ac0c8d189387467b2 (diff) | |
perf(newos4): delegated agent runs no longer serialise; a waiter can't steal a lock
Closes the second half of STEER-20261102-201045-ab08.
- chat() gated the main-turn lock on nothing, so orchestrate-parallel's fan-out
(each sub-task calling chat() on the same instance with its own agentTag) ran
strictly one at a time. Measured 2x300ms delegated runs: 617ms before, 306ms
now; the two 200ms user turns still take 400ms (main path stays serialised).
- the 10s lock timeout called this._chatLock = false and printed "force
released", which cleared a lock the waiter did not own - that released the
main turn's lock while it was still running. A waiter now throws and the owner
is the only writer.
- delegated runs no longer clear the user's cancel flag on entry (an agent start
used to reset this._cancelled, disarming a Stop the user had pressed).
- tool-log entries carry the run they belong to and _recentToolLog(tag, n) is
what feeds the run log line and _postRunReview, so concurrent runs no longer
consume each other's entries.
Known limit, unchanged: _currentTag is a single field, so under true concurrency
an entry can be attributed to the wrong run (telemetry only - the answers are
per-session). Namespacing it properly needs per-call state threading.
Test: modules/test/test-chat-concurrency.js, 7 assertions, RED before
(H1 617ms, H3 force-released, H5 no tags) and GREEN now; maintain 24/24.
Diffstat (limited to 'modules/test')
| -rw-r--r-- | modules/test/test-chat-concurrency.js | 85 |
1 files changed, 85 insertions, 0 deletions
diff --git a/modules/test/test-chat-concurrency.js b/modules/test/test-chat-concurrency.js new file mode 100644 index 0000000..19b7e45 --- /dev/null +++ b/modules/test/test-chat-concurrency.js @@ -0,0 +1,85 @@ +// node modules/test/test-chat-concurrency.js +// Fan-out safety for chat(): delegated runs must not serialise, the main turn must stay +// serialised, a waiter must never clear a lock it does not own, and tool-log entries must +// be attributable to the run that produced them. +const fs = require('fs'), path = require('path'); +const ROOT = __dirname + '/../core'; +const store = {}; +global.localStorage = { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }; +global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } }; +global.window = global; +global.addEventListener = () => {}; + +const AI = new Function(fs.readFileSync(path.join(ROOT, 'module-aiassist.js'), 'utf8') + '\nreturn WebX11AIAssistantModule;')(); + +const stub = { + print: () => {}, log: () => {}, setAiBusy: () => {}, markDirty: () => {}, + setPrompt: () => {}, clearInput: () => {}, scrollToBottom: () => {}, refreshPrompt: () => {}, + ollamaManager: null, + fs: { mkdirRecursive: () => {}, writeFile: () => true, readFile: () => null, exists: () => false, list: () => [] }, + addEventListener: () => {}, removeEventListener: () => {} +}; + +function mk(delay, opts = {}) { + const ai = new AI(); + ai.terminal = stub; + ai._saveContext = () => {}; + ai._archiveSession = () => {}; + ai._messageCount = 0; + if (opts.lockTimeout) ai._chatLockTimeoutMs = opts.lockTimeout; + ai.agenticLoop = async (messages, o) => { + await new Promise(r => setTimeout(r, delay)); + ai._recordToolCall({ name: 'read_file', turn: 1, args: {}, durationMs: delay, ok: true, + returnedBytes: 12000, clippedBytes: 6000, truncated: true, + tag: (o && o.agentTag) || 'main' }); + return { finalAnswer: 'A-' + ((o && o.agentTag) || 'main'), toolTurns: 1 }; + }; + return ai; +} + +let fails = 0, passes = 0; +const ok = (n, c, d) => { if (c) { passes++; return; } fails++; console.log('FAIL ' + n + (d ? ' — ' + d : '')); }; +const msgs = () => [{ role: 'user', content: 'go' }]; +const time = async fn => { const t = Date.now(); await fn(); return Date.now() - t; }; + +(async () => { + // H1: two delegated runs overlap + const ai = mk(300); + const t1 = await time(() => Promise.all([ + ai.chat(msgs(), { agentTag: 'agent-a' }), + ai.chat(msgs(), { agentTag: 'agent-b' }) + ])); + ok('H1 delegated runs run concurrently', t1 < 520, t1 + 'ms for 2x300ms'); + + // H2: the main conversation still queues (user turns must not interleave) + const ai2 = mk(200); + const t2 = await time(() => Promise.all([ai2.chat(msgs()), ai2.chat(msgs())])); + ok('H2 main-path turns stay serialised', t2 >= 380, t2 + 'ms for 2x200ms'); + + // H3: a waiter must not steal/clear a lock it does not own + const ai3 = mk(400, { lockTimeout: 80 }); + const first = ai3.chat(msgs()); // holds the main lock for 400ms + await new Promise(r => setTimeout(r, 40)); + let secondErr = null; + try { await ai3.chat(msgs()); } catch (e) { secondErr = e; } + ok('H3 waiter fails instead of force-releasing', !!secondErr, secondErr ? secondErr.message : 'resolved anyway'); + await first.catch(() => {}); + ok('H4 the owner still released cleanly afterwards', ai3._chatLock === false, String(ai3._chatLock)); + + // H5: tool-log entries are attributable + const ai4 = mk(120); + await Promise.all([ai4.chat(msgs(), { agentTag: 'agent-x' }), ai4.chat(msgs(), { agentTag: 'agent-y' })]); + const tags = ai4._toolCallLog.map(e => e.tag); + ok('H5 concurrent runs tag their own tool entries', tags.includes('agent-x') && tags.includes('agent-y'), JSON.stringify(tags)); + ok('H6 per-run slice returns only that run', ai4._recentToolLog('agent-x', 5).every(e => e.tag === 'agent-x'), + JSON.stringify(ai4._recentToolLog('agent-x', 5).map(e => e.tag))); + + // H7: an agent run must not clear a cancel that the user pressed for the main turn + const ai5 = mk(50); + ai5.cancelAi(); + await ai5.chat(msgs(), { agentTag: 'agent-z' }).catch(() => {}); + ok('H7 agent run leaves the user cancel flag alone', ai5._cancelled === true, String(ai5._cancelled)); + + console.log('test-chat-concurrency: ' + passes + ' passed, ' + fails + ' failed'); + process.exit(fails ? 1 : 0); +})(); |