@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.
- package/mcp.endpoints.example.json +1 -1
- package/package.json +2 -2
- package/src/agent/graph.js +63 -13
- package/src/agent/graph.test.js +125 -1
- package/src/agent/llm.js +13 -4
- package/src/agent/llm.test.js +59 -0
- package/src/cli/wiki-manager.js +43 -3
- package/src/commands/slash.js +2 -0
- package/src/core/agentEvents.js +12 -2
- package/src/core/buildInfo.json +2 -2
- package/src/core/env.js +11 -2
- package/src/core/env.test.js +22 -6
- package/src/core/llmCapabilities.js +31 -0
- package/src/core/llmCapabilities.test.js +27 -0
- package/src/core/logLabel.js +9 -0
- package/src/core/logLabel.test.js +12 -0
- package/src/core/mcp.js +2 -2
- package/src/core/toolLoop.js +222 -20
- package/src/core/toolLoop.test.js +324 -0
- package/src/core/wikiPresearch.js +58 -0
- package/src/core/wikirc.js +61 -0
- package/src/core/wikirc.test.js +40 -1
- package/src/core/workflow.js +4 -1
- package/src/orchestrator/attemptManager.js +21 -5
- package/src/orchestrator/attemptManager.test.js +19 -0
- package/src/orchestrator/dispatcher.js +49 -8
- package/src/orchestrator/dispatcher.test.js +33 -1
- package/src/orchestrator/lockManager.js +40 -5
- package/src/orchestrator/resultAggregator.js +12 -1
- package/src/orchestrator/resultAggregator.test.js +29 -0
- package/src/runtime/controlClassify.test.js +85 -1
- package/src/runtime/conversationCompact.js +39 -0
- package/src/runtime/conversationCompaction.test.js +72 -0
- package/src/runtime/runner.e2e.test.js +49 -0
- package/src/runtime/runner.js +65 -1
- package/src/runtime/server.js +83 -75
- package/src/runtime/server.test.js +121 -0
- package/src/runtime/store.js +17 -1
- package/src/runtime/store.test.js +22 -0
- package/src/runtime/workspaceIsolation.test.js +21 -12
- package/src/shell/repl.js +148 -27
- package/src/shell/repl.test.js +182 -1
package/src/runtime/server.js
CHANGED
|
@@ -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
|
|
2035
|
-
//
|
|
2036
|
-
//
|
|
2037
|
-
//
|
|
2038
|
-
//
|
|
2039
|
-
//
|
|
2040
|
-
//
|
|
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
|
|
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
|
-
//
|
|
2060
|
-
//
|
|
2061
|
-
if (
|
|
2062
|
-
return { kind: 'cancel', confidence: 0.
|
|
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 (
|
|
2068
|
-
return { kind: 'observe', confidence: 0.86, reason: '
|
|
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
|
|
2073
|
-
//
|
|
2074
|
-
|
|
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 (
|
|
2082
|
-
return { kind: '
|
|
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
|
-
'- "
|
|
2098
|
-
'- "
|
|
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
|
|
2105
|
-
|
|
2106
|
-
if (
|
|
2107
|
-
emitRuntimeLog(session, `control-classify: LLM returned an unrecognized reply, answering as read-only conversation — ${JSON.stringify(
|
|
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
|
package/src/runtime/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
165
|
-
// `ingest_apply` est sérialisé par construction. Si le
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
186
|
+
assert.match(runner, /const lockRegistry = workspaceLockRegistry\(session\);/);
|
|
178
187
|
});
|