@dotdrelle/wiki-manager 0.15.101 → 0.16.0

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 (42) hide show
  1. package/mcp.endpoints.example.json +1 -1
  2. package/package.json +2 -2
  3. package/src/agent/graph.js +63 -13
  4. package/src/agent/graph.test.js +125 -1
  5. package/src/agent/llm.js +13 -4
  6. package/src/agent/llm.test.js +59 -0
  7. package/src/cli/wiki-manager.js +43 -3
  8. package/src/commands/slash.js +2 -0
  9. package/src/core/agentEvents.js +12 -2
  10. package/src/core/buildInfo.json +2 -2
  11. package/src/core/env.js +11 -2
  12. package/src/core/env.test.js +22 -6
  13. package/src/core/llmCapabilities.js +31 -0
  14. package/src/core/llmCapabilities.test.js +27 -0
  15. package/src/core/logLabel.js +9 -0
  16. package/src/core/logLabel.test.js +12 -0
  17. package/src/core/mcp.js +2 -2
  18. package/src/core/toolLoop.js +222 -20
  19. package/src/core/toolLoop.test.js +324 -0
  20. package/src/core/wikiPresearch.js +58 -0
  21. package/src/core/wikirc.js +61 -0
  22. package/src/core/wikirc.test.js +40 -1
  23. package/src/core/workflow.js +4 -1
  24. package/src/orchestrator/attemptManager.js +21 -5
  25. package/src/orchestrator/attemptManager.test.js +19 -0
  26. package/src/orchestrator/dispatcher.js +49 -8
  27. package/src/orchestrator/dispatcher.test.js +33 -1
  28. package/src/orchestrator/lockManager.js +40 -5
  29. package/src/orchestrator/resultAggregator.js +12 -1
  30. package/src/orchestrator/resultAggregator.test.js +29 -0
  31. package/src/runtime/controlClassify.test.js +85 -1
  32. package/src/runtime/conversationCompact.js +39 -0
  33. package/src/runtime/conversationCompaction.test.js +72 -0
  34. package/src/runtime/runner.e2e.test.js +49 -0
  35. package/src/runtime/runner.js +65 -1
  36. package/src/runtime/server.js +83 -75
  37. package/src/runtime/server.test.js +121 -0
  38. package/src/runtime/store.js +17 -1
  39. package/src/runtime/store.test.js +22 -0
  40. package/src/runtime/workspaceIsolation.test.js +21 -12
  41. package/src/shell/repl.js +148 -27
  42. package/src/shell/repl.test.js +182 -1
@@ -30,6 +30,7 @@ import { reconcileControlQueue } from './controlDrain.js';
30
30
  import { cancelControlChain, cancelQueuedControlItem } from './controlCancellation.js';
31
31
  import { generateSkillAcknowledgment, runSkillChain } from './skillRun.js';
32
32
  import { emitRuntimeLog } from './supervisor.js';
33
+ import { summarizeCompactedConversation } from './conversationCompact.js';
33
34
  import { findSkill, listSkills } from '../core/skills.js';
34
35
  import {
35
36
  enrollment,
@@ -587,6 +588,10 @@ export function startRuntimeServer({
587
588
  // line pushed into the thread.
588
589
  input = runtimeStatusSynthesisPrompt(input, controlStatus(context, store));
589
590
  readOnlyChat = true;
591
+ } else if (actsDuringRun(classification)) {
592
+ // A new action: Donna takes the turn with her direct tools and
593
+ // performs it now when one fits (write a template, a page, run a
594
+ // read), or queues it after the run herself.
590
595
  } else if (classification.kind !== 'converse') {
591
596
  const result = await handleControlMessage(context, store, input, {
592
597
  intent: body.intent,
@@ -619,6 +624,8 @@ export function startRuntimeServer({
619
624
  if (classification.kind === 'observe') {
620
625
  input = runtimeStatusSynthesisPrompt(input, controlStatus(context, store));
621
626
  readOnlyChat = true;
627
+ } else if (actsDuringRun(classification)) {
628
+ // Same as above: Donna acts now or queues it herself.
622
629
  } else if (classification.kind !== 'converse') {
623
630
  const result = await handleControlMessage(context, store, input, {
624
631
  intent: body.intent,
@@ -1023,6 +1030,16 @@ export function startRuntimeServer({
1023
1030
  context?.currentAbortController?.abort();
1024
1031
  await cancel?.(context);
1025
1032
  }
1033
+ // A purge must wait for the aborted run to finish dispatching its terminal
1034
+ // events, or those events re-create the run/plan after the wipe (see
1035
+ // currentRunPromise above). Bounded: a run that ignores its abort signal
1036
+ // must not freeze the reset.
1037
+ if (purge && !targetRunId && typeof context?.currentRunPromise?.then === 'function') {
1038
+ await Promise.race([
1039
+ Promise.resolve(context.currentRunPromise).catch(() => {}),
1040
+ new Promise((resolve) => setTimeout(resolve, 5000)),
1041
+ ]);
1042
+ }
1026
1043
  const runs = typeof store.interruptRuns === 'function'
1027
1044
  ? store.interruptRuns({ workspace: targetWorkspace, runId: targetRunId, reason: 'Runtime run killed by user.' })
1028
1045
  : 0;
@@ -1085,6 +1102,12 @@ export function startRuntimeServer({
1085
1102
  proactiveRuns.set(runId, body.proactiveReview);
1086
1103
  }
1087
1104
  const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
1105
+ // Keep a handle so a purge can wait for the aborted run to finish
1106
+ // dispatching its terminal events BEFORE it wipes the event log. Without
1107
+ // this, `run_cancelled`/`plan_step_updated` emitted during the unwind
1108
+ // landed after `clearWorkspaceState` and re-created the very run/plan the
1109
+ // user just reset — the "reset restarts at 47%" symptom.
1110
+ context.currentRunPromise = runPromise;
1088
1111
  runPromise
1089
1112
  .catch((err) => {
1090
1113
  rejectReady?.(err);
@@ -1100,6 +1123,7 @@ export function startRuntimeServer({
1100
1123
  proactiveScheduler.release(proactive.workspace);
1101
1124
  }
1102
1125
  if (context.session?._proactiveReview?.runId === runId) context.session._proactiveReview = null;
1126
+ if (context.currentRunPromise === runPromise) context.currentRunPromise = null;
1103
1127
  context.running = false;
1104
1128
  context.currentAbortController = null;
1105
1129
  context.currentRunId = null;
@@ -1729,44 +1753,6 @@ async function handleControlMessage(context, store, input, { intent = null, star
1729
1753
  falls back to the deterministic English catalog when no LLM is configured or
1730
1754
  the call fails. The fallback is what keeps the lane deterministic-under-failure.
1731
1755
  */
1732
- const CONVERSATION_SUMMARY_TIMEOUT_MS = 20_000;
1733
- const CONVERSATION_SUMMARY_MAX_INPUT_CHARS = 8_000;
1734
-
1735
- /*
1736
- A compact does not just cut older turns from conversationSeed — it replaces
1737
- them with a short rolling summary, so a decision made 20 messages ago is not
1738
- gone from Donna's grounding entirely, only condensed. Best-effort: no LLM
1739
- configured, an empty reply, or a call failure all fall back to keeping
1740
- whatever summary already existed (never worse than before this compact),
1741
- the same deterministic-under-failure shape as generateControlAcknowledgment.
1742
- */
1743
- async function summarizeCompactedConversation(session, { previousSummary, segment }) {
1744
- const llm = session?.llm;
1745
- const transcript = (Array.isArray(segment) ? segment : [])
1746
- .filter((message) => ['user', 'assistant'].includes(message?.role) && String(message?.content ?? '').trim())
1747
- .map((message) => `${message.role === 'user' ? 'User' : 'Assistant'}: ${String(message.content).trim()}`)
1748
- .join('\n')
1749
- .slice(0, CONVERSATION_SUMMARY_MAX_INPUT_CHARS);
1750
- if (!transcript) return previousSummary || null;
1751
- if (!(llm && typeof llm.complete === 'function')) return previousSummary || null;
1752
- try {
1753
- const reply = await llm.complete({
1754
- system: 'You maintain a compact working memory for Donna, a workspace assistant. You are shown an optional PREVIOUS SUMMARY and a NEW SEGMENT of conversation about to leave the assistant\'s context window. Write ONE updated summary that preserves the facts, decisions, open questions and user preferences that still matter for future turns. Be concise: well under 200 words. Return only the summary text — no preamble, no meta-commentary, no headings.',
1755
- input: [
1756
- previousSummary ? `PREVIOUS SUMMARY:\n${previousSummary}` : null,
1757
- `NEW SEGMENT:\n${transcript}`,
1758
- ].filter(Boolean).join('\n\n'),
1759
- signal: AbortSignal.timeout(CONVERSATION_SUMMARY_TIMEOUT_MS),
1760
- });
1761
- const text = String(reply ?? '').trim();
1762
- if (text) return text;
1763
- emitRuntimeLog(session, 'conversation-compact: LLM returned an empty summary, keeping the previous one');
1764
- } catch (err) {
1765
- emitRuntimeLog(session, `conversation-compact: summary LLM call failed, keeping the previous summary — ${err instanceof Error ? err.message : String(err)}`);
1766
- }
1767
- return previousSummary || null;
1768
- }
1769
-
1770
1756
  async function generateControlAcknowledgment(session, { kind, input }) {
1771
1757
  const language = String(session?.language ?? '').trim().toLowerCase() || 'en';
1772
1758
  const llm = session?.llm;
@@ -2031,16 +2017,46 @@ function asksForRunStatus(input) {
2031
2017
  return statusWord.test(text) && runNoun.test(text);
2032
2018
  }
2033
2019
 
2034
- // Classifier for the control lane's free-text messages. The classification is
2035
- // LLM-backed: the only deterministic matches left are the runtime's own
2036
- // control verbs (cancel, an explicit "later/queue", status and plan-change
2037
- // wording). Deciding "is this a NEW task to queue vs plain conversation" is a
2038
- // semantic judgement about the workspace's domain, so it is never a keyword
2039
- // list here — it goes to the model, bounded, and falls back to the choice menu
2040
- // (`ambiguous`) rather than guessing when no model is available.
2020
+ // Classifier for the control lane's free-text messages, used by `/turn` (serve,
2021
+ // ShellUI) and `/control message` (legacy shell) while a run is active.
2022
+ //
2023
+ // The model decides. The only deterministic rules left match a WHOLE message
2024
+ // that can mean one thing: a bare cancel command, a bare status question, a
2025
+ // bare confirmation, an explicit "queue it". Keywords inside a sentence used
2026
+ // to decide instead, and ordinary questions typed during an ingest were
2027
+ // misrouted (measured 2026-09-25, plan-demandes-pendant-run.md): "explique /
2028
+ // montre" answered with the run status, "après / plan" proposed a patch of the
2029
+ // running plan, "modifie la page" too — and "quand est-ce qu'on stop le
2030
+ // support de X ?" cancelled the run. Every fallback is read-only
2031
+ // conversation: a wrong triage can only answer, never cancel or mutate.
2032
+ const CANCEL_COMMAND = /^\s*(?:stop|cancel|abort|annule[rz]?|arr[eê]te[rz]?|interromps)(?:\s+(?:tout|all|everything|it|[çc]a|(?:le|ce|the|this)\s+(?:run|job|build|export|traitement|pipeline)|l['’]\s?(?:ingestion|export)|la\s+t[aâ]che))?\s*[.!…]*\s*$/i;
2033
+ const STATUS_ONLY = /^\s*(?:\/status|status|statut|progress(?:ion)?|avancement|logs?|quoi de neuf|o[uù] en (?:est|es-tu|sommes-nous)(?:[- ]?(?:on|il|elle|ce|[çc]a))?)\s*[?!.…]*\s*$/i;
2034
+ const CONFIRMATION_ONLY = /^\s*(?:oui|yes|yep|ok|okay|vas[- ]?y|d['’]accord|daccord|entendu)\s*[.!…]*\s*$/i;
2035
+ const EXPLICIT_ENQUEUE = /\b(?:enqueue|mets(?:-le)? en file|met en file|apr[eè]s ce run|[aà] la fin (?:du|de ce) run|next run|after this run)\b/i;
2036
+
2037
+ // A NEW action typed during a run (the model said "action", not an explicit
2038
+ // "mets en file"). It used to be queued straight away, so a template to write
2039
+ // waited for a whole ingest. It now goes to an agent turn instead: Donna holds
2040
+ // her direct tools there — never a job starter (isOrchestrationBypassTool) —
2041
+ // and either performs it now or queues it with runtime__enqueue. The engine
2042
+ // refuses a write that would race with the running job, and says so
2043
+ // (plan-demandes-pendant-run.md, lot 3).
2044
+ export function actsDuringRun(classification) {
2045
+ return classification?.kind === 'enqueue_run' && classification?.reason === 'llm_classified_action';
2046
+ }
2047
+
2048
+ const CONTROL_CATEGORIES = {
2049
+ question: { kind: 'converse', reason: 'llm_classified_question' },
2050
+ conversation: { kind: 'converse', reason: 'llm_classified_question' },
2051
+ status: { kind: 'observe', reason: 'llm_classified_status' },
2052
+ action: { kind: 'enqueue_run', reason: 'llm_classified_action' },
2053
+ plan_change: { kind: 'modify_run', reason: 'llm_classified_plan_change' },
2054
+ cancel: { kind: 'cancel', reason: 'llm_classified_cancel' },
2055
+ };
2056
+
2041
2057
  export async function classifyControlMessage(input, status, { forcedIntent = null, llm = null, session = null } = {}) {
2042
2058
  // Caller (the /control message route) already trims and rejects empty input.
2043
- const lower = String(input ?? '').toLowerCase();
2059
+ const text = String(input ?? '');
2044
2060
  const intent = forcedIntent ? String(forcedIntent).toLowerCase() : null;
2045
2061
  const explicit = {
2046
2062
  observe: 'observe',
@@ -2056,55 +2072,47 @@ export async function classifyControlMessage(input, status, { forcedIntent = nul
2056
2072
  if (explicit) {
2057
2073
  return { kind: explicit, confidence: 1, reason: 'explicit_intent' };
2058
2074
  }
2059
- // Cancel stays a keyword: it is a runtime control verb, and an abort must not
2060
- // wait on a model round-trip.
2061
- if (/\b(cancel|annule|stop|arr[eê]te|interromps|abort)\b/i.test(lower)) {
2062
- return { kind: 'cancel', confidence: 0.86, reason: 'cancel_request' };
2063
- }
2064
- if (/\b(plus tard|later|ensuite|apr[eè]s ce run|enqueue|mets en file|met en file|futur|next run|future run)\b/i.test(lower)) {
2065
- return { kind: 'enqueue_run', confidence: 0.8, reason: 'future_run_request' };
2075
+ // A bare cancel command stays deterministic: an abort must not wait on a
2076
+ // model round-trip. The whole message has to BE the command.
2077
+ if (CANCEL_COMMAND.test(text)) {
2078
+ return { kind: 'cancel', confidence: 0.9, reason: 'cancel_command' };
2066
2079
  }
2067
- if (/\b(o[uù] en es[t-]|status|statut|progress|progression|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
2068
- return { kind: 'observe', confidence: 0.86, reason: 'status_or_explanation_request' };
2080
+ if (STATUS_ONLY.test(text) || asksForRunStatus(text)) {
2081
+ return { kind: 'observe', confidence: 0.86, reason: 'status_request' };
2069
2082
  }
2070
2083
  // A bare "yes" answers the runtime's own last prompt (the launch
2071
2084
  // acknowledgement used to end on "check progress or cancel?"). While a run is
2072
- // active, the only thing the runtime can act on is a status check: treating
2073
- // the word as ordinary conversation made the read-only chat fallback lecture
2074
- // the user about switching modes instead of answering.
2075
- // Anchored at BOTH ends: "oui" is a confirmation, "oui, ajoute une étape de
2076
- // polish" is a plan change. Without the end anchor this branch shadowed
2077
- // modify_run and enqueue_run for every message merely STARTING on a yes.
2078
- if (status.running && /^\s*(oui|yes|yep|ok|okay|vas[- ]?y|d'accord|daccord|entendu)\s*[.!…]*\s*$/i.test(lower)) {
2085
+ // active, the only thing the runtime can act on is a status check. Anchored
2086
+ // at BOTH ends: "oui, ajoute une étape de polish" is not a confirmation.
2087
+ if (status.running && CONFIRMATION_ONLY.test(text)) {
2079
2088
  return { kind: 'observe', confidence: 0.7, reason: 'confirmation_of_runtime_prompt' };
2080
2089
  }
2081
- if (status.running && /\b(ajoute|add|change|modifie|modify|remplace|replace|retire|remove|skip|ignore|apr[eè]s|before|after|chaque|each|plan|step|t[aâ]che)\b/i.test(lower)) {
2082
- return { kind: 'modify_run', confidence: 0.78, reason: 'active_run_change_request' };
2090
+ if (EXPLICIT_ENQUEUE.test(text)) {
2091
+ return { kind: 'enqueue_run', confidence: 0.8, reason: 'explicit_enqueue_request' };
2083
2092
  }
2084
2093
  if (!status.running) return { kind: 'converse', confidence: 0.62, reason: 'plain_conversation' };
2085
- // A run is active and none of the runtime control verbs matched. The message
2086
- // is either a request to perform a NEW mutating task (→ queue it to run
2087
- // after the current one) or ordinary conversation — that is a judgement about
2088
- // the workspace's domain, so the model decides it, never a keyword list.
2089
2094
  if (llm && typeof llm.complete === 'function') {
2090
2095
  try {
2091
2096
  const reply = await llm.complete({
2092
- system: 'You classify one user message typed while a run is already active. Return exactly one word, nothing else.',
2097
+ system: 'You classify one user message typed while a run is already active in their workspace. Return exactly one word, nothing else.',
2093
2098
  input: [
2094
2099
  `The user typed this while a run is active: "${input}"`,
2095
2100
  '',
2096
2101
  'Choose ONE of:',
2097
- '- "action" — a request to perform a NEW task (generate, create, ingest, build, export, convert, send, publish, produce…), which must run after the current run.',
2098
- '- "conversation" — ordinary conversation, a question, or an unrelated remark.',
2102
+ '- "question" — a question or search about the workspace content or the product, an explanation request, or ordinary conversation. Words such as "plan", "after", "show", "explain" or "stop" inside a question do not make it anything else.',
2103
+ '- "status" — a question about the progress or state of the run currently executing.',
2104
+ '- "action" — a request to perform a NEW task: create, edit or write a file or page, ingest, build, export, convert, curate, run a doctor, rebuild an index, send, publish…',
2105
+ '- "plan_change" — a request to change the run currently executing: add or skip one of its steps, change its target.',
2106
+ '- "cancel" — a request to stop or cancel the run currently executing.',
2099
2107
  '',
2100
2108
  'Return only that one word.',
2101
2109
  ].join('\n'),
2102
2110
  signal: AbortSignal.timeout(8_000),
2103
2111
  });
2104
- const kind = String(reply ?? '').trim().toLowerCase();
2105
- if (kind.startsWith('action')) return { kind: 'enqueue_run', confidence: 0.85, reason: 'llm_classified_action' };
2106
- if (kind.startsWith('conversation')) return { kind: 'converse', confidence: 0.85, reason: 'llm_classified_conversation' };
2107
- emitRuntimeLog(session, `control-classify: LLM returned an unrecognized reply, answering as read-only conversation — ${JSON.stringify(kind).slice(0, 200)}`);
2112
+ const word = String(reply ?? '').trim().toLowerCase().replace(/[^a-z_]/g, '');
2113
+ const category = Object.keys(CONTROL_CATEGORIES).find((name) => word.startsWith(name));
2114
+ if (category) return { ...CONTROL_CATEGORIES[category], confidence: 0.85 };
2115
+ emitRuntimeLog(session, `control-classify: LLM returned an unrecognized reply, answering as read-only conversation — ${JSON.stringify(word).slice(0, 200)}`);
2108
2116
  } catch (err) {
2109
2117
  // A degradation must announce itself: silently falling through here
2110
2118
  // hides the difference between "no LLM configured" (expected) and "the
@@ -840,6 +840,77 @@ test('runtime server state exposes active run identity while running', async (t)
840
840
  }
841
841
  });
842
842
 
843
+ // A purge used to wipe the event log while the aborted run was still emitting
844
+ // its terminal events; those landed after the wipe and re-created the run/plan
845
+ // ("reset restarts at 47%"). The purge must wait for the run to settle first.
846
+ test('a purge waits for the aborted run to settle before wiping state', async (t) => {
847
+ const order = [];
848
+ const context = {
849
+ workspace: 'acme',
850
+ session: { workspace: 'acme' },
851
+ running: false,
852
+ currentAbortController: null,
853
+ currentRunId: null,
854
+ };
855
+ let handle;
856
+ try {
857
+ handle = await startRuntimeServer({
858
+ host: '127.0.0.1',
859
+ port: 0,
860
+ store: {
861
+ dbPath: ':memory:',
862
+ getState: () => ({ status: 'idle', plan: [] }),
863
+ listEvents: () => [],
864
+ interruptRuns: () => 0,
865
+ cancelActiveTasksForInterruptedRuns: () => 0,
866
+ clearWorkspaceState: () => {
867
+ order.push('clear');
868
+ return { runs: 0, events: 0, queue: 0 };
869
+ },
870
+ },
871
+ getContext: async () => context,
872
+ run: async (_context, _body, { signal }) => {
873
+ await new Promise((resolve) => {
874
+ if (signal.aborted) {
875
+ order.push('abort');
876
+ resolve();
877
+ return;
878
+ }
879
+ signal.addEventListener('abort', () => {
880
+ order.push('abort');
881
+ resolve();
882
+ });
883
+ });
884
+ order.push('settled');
885
+ },
886
+ });
887
+ } catch (err) {
888
+ if (err?.code === 'EPERM') {
889
+ t.skip('network listen is not permitted in this sandbox');
890
+ return;
891
+ }
892
+ throw err;
893
+ }
894
+
895
+ try {
896
+ const runResponse = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, {
897
+ method: 'POST',
898
+ headers: { 'Content-Type': 'application/json' },
899
+ body: JSON.stringify({ input: 'build' }),
900
+ });
901
+ assert.equal(runResponse.status, 202);
902
+ const kill = await fetch(`http://127.0.0.1:${handle.port}/kill?workspace=acme`, {
903
+ method: 'POST',
904
+ headers: { 'Content-Type': 'application/json' },
905
+ body: JSON.stringify({ purge: true }),
906
+ });
907
+ assert.equal(kill.status, 202);
908
+ assert.deepEqual(order, ['abort', 'settled', 'clear']);
909
+ } finally {
910
+ await handle.close();
911
+ }
912
+ });
913
+
843
914
  test('runtime server isolates active runs by workspace', async (t) => {
844
915
  const releases = new Map();
845
916
  const runWorkspaces = [];
@@ -1351,6 +1422,8 @@ test('runtime server control message records active plan mutation as a proposal'
1351
1422
  const session = {
1352
1423
  workspace: 'acme',
1353
1424
  controlQueue: [],
1425
+ // The model decides a plan change; no keyword does (plan-demandes-pendant-run.md).
1426
+ llm: { complete: async () => 'plan_change' },
1354
1427
  };
1355
1428
  dispatchAgentEvent(session, createAgentEvent('run_started', {
1356
1429
  origin: 'runtime',
@@ -1899,6 +1972,54 @@ test('POST /turn treats a bare confirmation during a run as a status check', asy
1899
1972
  }
1900
1973
  });
1901
1974
 
1975
+ // plan-demandes-pendant-run.md, lot 3: a new action typed during a run used to
1976
+ // be queued straight away, so a template waited for a whole ingest. It is an
1977
+ // agent turn now (Donna's direct tools, or runtime__enqueue from her), while a
1978
+ // question stays a read-only chat turn and nothing lands in the control queue.
1979
+ test('POST /turn hands a new action typed during a run to an agent turn, not the queue', async (t) => {
1980
+ const session = { workspace: 'acme', controlQueue: [], llm: null };
1981
+ const context = { workspace: 'acme', session, running: true, currentAbortController: null };
1982
+ const status = {
1983
+ status: 'pending_approval', running: true,
1984
+ plan: [{ step: 1, description: 'Ingest pending sources', status: 'waiting_approval' }],
1985
+ queue: [], controlQueue: [], approvals: [], conversation: [],
1986
+ };
1987
+ const turns = [];
1988
+ let handle;
1989
+ try {
1990
+ handle = await startRuntimeServer({
1991
+ host: '127.0.0.1', port: 0,
1992
+ store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
1993
+ getContext: async () => context,
1994
+ run: async () => new Promise(() => {}),
1995
+ turn: async (_context, options) => { turns.push(options); return { ok: true }; },
1996
+ });
1997
+ } catch (err) {
1998
+ if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
1999
+ throw err;
2000
+ }
2001
+ try {
2002
+ for (const [input, word] of [['crée un template pour la note de synthèse', 'action'], ['que dit le wiki sur SISBA ?', 'question']]) {
2003
+ session.llm = { complete: async () => word };
2004
+ const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
2005
+ method: 'POST', headers: { 'content-type': 'application/json' },
2006
+ body: JSON.stringify({ input, mode: 'agent' }),
2007
+ });
2008
+ assert.equal(response.status, 202);
2009
+ assert.equal((await response.json()).kind, 'turn');
2010
+ }
2011
+ await new Promise((resolve) => setTimeout(resolve, 25));
2012
+ assert.deepEqual(turns.map((options) => [options.input, options.mode]), [
2013
+ ['crée un template pour la note de synthèse', 'agent'],
2014
+ ['que dit le wiki sur SISBA ?', 'chat'],
2015
+ ]);
2016
+ assert.equal(session.controlQueue.length, 0, 'nothing was queued behind the run');
2017
+ } finally {
2018
+ context.currentAbortController?.abort();
2019
+ await handle.close();
2020
+ }
2021
+ });
2022
+
1902
2023
  test('POST /turn answers the reserved /status command itself, never the homonymous skill', async (t) => {
1903
2024
  // A workspace skill named `status` exists precisely to prove the built-in
1904
2025
  // wins: `/status` was handed to the model, which ran that skill (English
@@ -249,7 +249,23 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
249
249
  INSERT OR IGNORE INTO events (sequence, id, ts, type, run_id, turn_id, task_id, workspace, origin, payload)
250
250
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
251
251
  `);
252
- const nextEventSequenceStatement = db.prepare('SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence FROM events');
252
+ // A sequence is a CURSOR clients hold ("events after N"): it must never be
253
+ // handed out twice. MAX(sequence)+1 alone reused the numbers a purge or a
254
+ // truncation had just deleted — observed: after a purge the next run's
255
+ // events took 3503…3540 again, a served chat whose cursor stood at 3536
256
+ // never received them, and it stayed on the failed run (strip at 0 %,
257
+ // inspector on the old task) while the ShellUI showed the new one done.
258
+ // sqlite_sequence keeps the high-water mark of an AUTOINCREMENT table
259
+ // across deletions; a legacy table without it falls back to MAX.
260
+ const hasSqliteSequence = Boolean(db.prepare(
261
+ "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'",
262
+ ).get());
263
+ const nextEventSequenceStatement = db.prepare(hasSqliteSequence
264
+ ? `SELECT MAX(
265
+ COALESCE((SELECT MAX(sequence) FROM events), 0),
266
+ COALESCE((SELECT seq FROM sqlite_sequence WHERE name = 'events'), 0)
267
+ ) + 1 AS next_sequence`
268
+ : 'SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence FROM events');
253
269
  const listEventsStatement = db.prepare(`
254
270
  SELECT sequence, id, ts, type, run_id, turn_id, task_id, workspace, origin, payload
255
271
  FROM events
@@ -1174,3 +1174,25 @@ test('un agent restauré par hydrateSession n’est plus routable tant qu’aucu
1174
1174
  [],
1175
1175
  );
1176
1176
  });
1177
+
1178
+ test('a purge never lets the next event reuse a sequence a client may hold as its cursor', () => {
1179
+ // Observed on acpi: after a purge the next run's events took the numbers
1180
+ // just deleted, and a served chat whose cursor stood past them never
1181
+ // received them — it stayed on the failed run while the ShellUI showed done.
1182
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
1183
+ const store = openRuntimeStore({ stateDir });
1184
+ const session = { activities: {}, headlessPlan: null };
1185
+ const persist = () => store.persistEvent(dispatchAgentEvent(session, createAgentEvent('run_started', {
1186
+ origin: 'test', workspace: 'acpi', payload: { input: 'x', workspace: 'acpi' },
1187
+ })));
1188
+ for (let i = 0; i < 5; i += 1) persist();
1189
+ const cursor = Math.max(...store.listEvents().map((e) => e.sequence));
1190
+ store.clearWorkspaceState({ workspace: 'acpi' });
1191
+ const next = persist();
1192
+ assert.ok(next.sequence > cursor, `sequence ${next.sequence} must be past the cursor ${cursor}`);
1193
+ store.close();
1194
+ const reopened = openRuntimeStore({ stateDir });
1195
+ const after = reopened.persistEvent(createAgentEvent('run_started', { origin: 'test', workspace: 'acpi', payload: {} }));
1196
+ assert.ok(after.sequence > next.sequence, 'the high-water mark survives a restart');
1197
+ reopened.close();
1198
+ });
@@ -161,18 +161,27 @@ test('every client subscribes with the workspace it is scoped to', () => {
161
161
  assert.match(shell, /runtimeStreamAbort\?\.abort\(\);\s*\n\s*syncRuntimeState\(\);\s*\n\s*void subscribeRuntimeEvents\(\);/);
162
162
  });
163
163
 
164
- test('locks are per run, so one workspace never blocks another', async () => {
165
- // `ingest_apply` est sérialisé par construction. Si le gestionnaire de
166
- // verrous était global au processus, deux workspaces ingérant en parallèle
167
- // se bloqueraient mutuellement — pas une fuite, mais une contention
168
- // invisible et très difficile à diagnostiquer.
169
- const { createLockManager } = await import('../orchestrator/lockManager.js');
170
- const runA = createLockManager();
171
- const runB = createLockManager();
172
-
173
- assert.ok(runA.acquire({ locks: ['ingest_apply'] }));
174
- assert.ok(runB.acquire({ locks: ['ingest_apply'] }), 'deux runs distincts ne partagent pas leurs verrous');
164
+ test('locks are per workspace: shared by its runs, never across workspaces', async () => {
165
+ // `ingest_apply` est sérialisé par construction. Si le registre de verrous
166
+ // était global au processus, deux workspaces ingérant en parallèle se
167
+ // bloqueraient mutuellement — une contention invisible. Il est attaché à la
168
+ // session du workspace : partagé par ses runs et ses actions directes
169
+ // (plan-demandes-pendant-run.md, lot 2), jamais d'un workspace à l'autre.
170
+ const { workspaceLockRegistry } = await import('../orchestrator/lockManager.js');
171
+ const { createAttemptManager } = await import('../orchestrator/attemptManager.js');
172
+ const acme = { workspace: 'acme' };
173
+ const juno = { workspace: 'juno' };
174
+ const manager = (session, owner) => {
175
+ const registry = workspaceLockRegistry(session);
176
+ return createAttemptManager({ locks: registry.locks, owners: registry.owners, owner });
177
+ };
178
+
179
+ assert.ok(manager(acme, 'run-a1').reserve({ id: 't1', locks: ['workspace-write'] }));
180
+ assert.ok(manager(juno, 'run-j1').reserve({ id: 't1', locks: ['workspace-write'] }), 'two workspaces never share their locks');
181
+ const second = manager(acme, 'run-a2');
182
+ assert.equal(second.reserve({ id: 't2', locks: ['workspace-write'] }), null, 'two runs of one workspace do');
183
+ assert.deepEqual(second.foreignHolders({ locks: ['workspace-write'] }), [{ lock: 'workspace-write', owner: 'run-a1' }]);
175
184
 
176
185
  const runner = readFileSync(new URL('./runner.js', import.meta.url), 'utf8');
177
- assert.match(runner, /const attempts = attemptManager \?\? createAttemptManager\(\);/);
186
+ assert.match(runner, /const lockRegistry = workspaceLockRegistry\(session\);/);
178
187
  });