@dotdrelle/wiki-manager 0.15.84 → 0.15.91

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 (38) hide show
  1. package/agent-runtimes.example.json +11 -1
  2. package/agents.docker-compose.yml +6 -0
  3. package/docker-compose.yml +1 -1
  4. package/mcp.endpoints.example.json +1 -1
  5. package/package.json +1 -1
  6. package/src/agent/graph.js +46 -7
  7. package/src/agent/graph.test.js +17 -0
  8. package/src/cli/wiki-manager.js +33 -5
  9. package/src/commands/slash.js +6 -6
  10. package/src/core/agentEvents.js +48 -0
  11. package/src/core/agentEvents.test.js +36 -0
  12. package/src/core/buildInfo.json +2 -2
  13. package/src/core/env.js +3 -3
  14. package/src/core/env.test.js +17 -0
  15. package/src/core/mcp.js +1 -1
  16. package/src/core/openWikiPages.js +17 -0
  17. package/src/core/progressNotes.js +17 -2
  18. package/src/core/runtimeEventAdapter.js +5 -2
  19. package/src/core/runtimeEventAdapter.test.js +7 -2
  20. package/src/core/skillChainView.js +5 -5
  21. package/src/core/skillChainView.test.js +3 -2
  22. package/src/core/skillCompiler.js +19 -1
  23. package/src/core/skillCompiler.test.js +34 -2
  24. package/src/core/skills.js +28 -0
  25. package/src/core/workflow.js +31 -2
  26. package/src/core/workflow.test.js +31 -0
  27. package/src/orchestrator/dispatcher.js +47 -10
  28. package/src/orchestrator/dispatcher.test.js +90 -1
  29. package/src/orchestrator/providers/dispatcherExternalRuntime.test.js +2 -2
  30. package/src/orchestrator/providers/runtimeProviders.test.js +3 -1
  31. package/src/orchestrator/resultAggregator.js +67 -0
  32. package/src/runtime/server.js +53 -25
  33. package/src/runtime/skillChain.e2e.test.js +20 -2
  34. package/src/runtime/skillRun.js +41 -0
  35. package/src/shell/repl.js +73 -20
  36. package/src/shell/repl.test.js +91 -2
  37. package/src/shell/useSession.ts +1 -1
  38. package/wiki-workspace +7 -1
@@ -1,3 +1,4 @@
1
+ import { objectiveForResolution } from '../orchestrator/objectiveResolver.js';
1
2
  const OPTIONAL_RE = /^(?:si disponible|si possible|optionnellement|if available|if possible|optionally)\b[\s,:-]*/i;
2
3
  const STRONG_CONNECTOR_RE = /\n\s*(?=(?:puis|ensuite|après cela|après .{0,80}?terminé|then|next|after .{0,80}?complete|si disponible|si possible|optionnellement|if available|if possible|optionally)\b)/gi;
3
4
  const FORBIDDEN_FIELDS = /\b(?:agent|capability|capabilityPlan|MCP|tool(?: name)?)\s*:/i;
@@ -117,8 +118,25 @@ function objectiveFromText(raw) {
117
118
  };
118
119
  }
119
120
 
121
+ // A guardrail is not an intention. "It never ingests", "Never ask which source
122
+ // to export", "It never builds, exports or publishes" all name an action the
123
+ // skill must NOT take — and counting them made a body MORE ambiguous the more
124
+ // carefully its boundaries were written. Three of wiki-sync's five triggers
125
+ // were guardrails, which is what pushed the best-documented skill in the
126
+ // scaffold over the threshold and handed its split to the LLM.
127
+ // objectiveResolver already strips negative guardrails before resolving; the
128
+ // ambiguity count has to agree with it, or the two read the same sentence as
129
+ // opposite things.
120
130
  function looksAmbiguous(text) {
121
- return (text.match(/(?:^|[.!?]\s+)[A-ZÀ-Ý][^.!?]{0,80}\b(?:export|ingest|build|send|create|delete|sync|publish|diagnos|analyse|constru|envoi|cré|supprim)/gi)?.length ?? 0) > 2;
131
+ // Count what the resolver will actually resolve, not the raw prose. A second
132
+ // guardrail regex living here drifted from objectiveResolver's within one
133
+ // edit: "Check the sources without asking, then export and build." was
134
+ // dropped by one and kept whole by the other, so the two read the same
135
+ // sentence as opposite things. Reusing objectiveForResolution makes them
136
+ // agree by construction — there is one definition of "this clause is a
137
+ // constraint, not an intention", and it lives with the resolver.
138
+ const resolvable = objectiveForResolution(text);
139
+ return (resolvable.match(/(?:^|[.!?]\s+)[A-ZÀ-Ý][^.!?]{0,80}\b(?:export|ingest|build|send|create|delete|sync|publish|diagnos|analyse|constru|envoi|cré|supprim)/gi)?.length ?? 0) > 2;
122
140
  }
123
141
 
124
142
  function normalizeFallback(value) {
@@ -30,12 +30,44 @@ test('validation rejects technical routing details', () => {
30
30
  assert.throws(() => validateCompiledObjectives([{ text: 'agent: cme' }]), { code: 'skill_compile_failed' });
31
31
  });
32
32
 
33
- test('every shipped scaffold skill compiles to a single intention', async () => {
33
+ test('every shipped scaffold skill compiles to a single intention, deterministically', async () => {
34
34
  const expected = { pipeline: 1, 'wiki-sync': 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1 };
35
+ // Passing no llmFallback used to make this test assert the one path
36
+ // production never takes: an ambiguous body silently returns the safe
37
+ // mono-intention fallback, so the count was 1 and the test was green while
38
+ // production called the LLM and got 3. A shipped skill reaching the LLM
39
+ // splitter is a build-time defect, not a runtime coin flip — so the fallback
40
+ // here throws, and the deterministic pass must never need it.
41
+ const llmFallback = () => { throw new Error('a shipped skill must not need the LLM splitter'); };
35
42
  for (const [name, count] of Object.entries(expected)) {
36
43
  const raw = readFileSync(resolve('../llm-wiki/scaffold/workspace/.wiki/skills', `${name}.md`), 'utf8');
37
44
  const { meta, body } = parseFrontmatter(raw);
38
- assert.equal((await compileSkillObjectives({ ...meta, body })).length, count, name);
45
+ assert.equal(deterministicObjectives(body).ambiguous, false, `${name} is ambiguous for the deterministic pass`);
46
+ assert.equal((await compileSkillObjectives({ ...meta, body }, {}, { llmFallback })).length, count, name);
47
+ }
48
+ });
49
+
50
+ test('every orchestrated scaffold skill declares the capability it targets', () => {
51
+ // Without a declaration the capability is inferred from the body's prose by
52
+ // alias matching, which any runtime added to agent-runtimes.json can break by
53
+ // declaring a bare English word as an alias. Declared, the run is routed by
54
+ // registry lookup and no text is matched at all.
55
+ // Only the skills whose declaration is actually APPLIED, and only where the
56
+ // target agent accepts it. The list is deliberately short:
57
+ // - parameterised skills are dropped by skillRun (the capabilityPlan route
58
+ // skips the argument extraction a selector like <template> needs);
59
+ // - pipeline keeps text resolution until an E2E test can assert its agent
60
+ // still plans its own DAG;
61
+ // - diagnose declared `workspace.diagnose/doctor` and BROKE: agent_plan's
62
+ // operation allow-list has no `doctor`, so the plan was refused, the
63
+ // refusal swallowed, and the run reported done without diagnosing
64
+ // anything. Declaring a capability the executor cannot plan is worse than
65
+ // not declaring one.
66
+ const orchestrated = ['wiki-sync'];
67
+ for (const name of orchestrated) {
68
+ const raw = readFileSync(resolve('../llm-wiki/scaffold/workspace/.wiki/skills', `${name}.md`), 'utf8');
69
+ const { meta } = parseFrontmatter(raw);
70
+ assert.match(String(meta.capability ?? ''), /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9_-]*)+$/, `${name} declares no capability`);
39
71
  }
40
72
  });
41
73
 
@@ -5,6 +5,8 @@ const SKILL_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
5
5
  const SKILL_PARAM_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
6
6
  const DANGEROUS_PARAM_NAMES = new Set(['__proto__', 'prototype', 'constructor']);
7
7
  const DEFAULT_UI_SKILL_DIR = '.wiki/skills';
8
+ const SKILL_CAPABILITY_RE = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9_-]*)+$/;
9
+ const SKILL_OPERATION_RE = /^[a-z][a-z0-9_-]{0,63}$/;
8
10
  // The CSI branch must come FIRST. `[` is 0x5B, inside the `[@-_]` range, so the
9
11
  // two-character alternative would otherwise consume `ESC [` alone and leave the
10
12
  // parameter bytes behind as literal text: "\x1B[31m" would become "31m".
@@ -71,6 +73,30 @@ function inspectSkillFile(filePath, fallbackName, scope, root) {
71
73
  return { rejected: { relativePath, name, reason: 'invalid_param' } };
72
74
  }
73
75
  const description = descriptionMetadata(meta.description);
76
+ // A skill may DECLARE the capability it targets. Without it, the capability
77
+ // is inferred from the body's prose by alias matching in objectiveResolver —
78
+ // which is text-similarity executor selection under another name, the very
79
+ // thing this repo removed once and must not reintroduce. Worse, the aliases
80
+ // come from `agent-runtimes.json`, user-editable config: adding any runtime
81
+ // whose alias is a bare English word ("report", "check") makes two aliases
82
+ // hit at once, and `aliasHits.length > 1` abandons the deterministic path for
83
+ // the LLM resolver — silently, for every shipped skill at once.
84
+ //
85
+ // Declaring it in FRONTMATTER, never in the body, keeps both rules intact:
86
+ // the body stays a business intention naming no agent, tool or server
87
+ // (skillCompiler's FORBIDDEN_FIELDS still enforces that), while routing
88
+ // targets a capability — the same abstraction plans already target.
89
+ const capability = String(meta.capability || '').trim();
90
+ if (capability && !SKILL_CAPABILITY_RE.test(capability)) {
91
+ return { rejected: { relativePath, name, reason: 'invalid_capability' } };
92
+ }
93
+ const operation = String(meta.operation || '').trim();
94
+ if (operation && !SKILL_OPERATION_RE.test(operation)) {
95
+ return { rejected: { relativePath, name, reason: 'invalid_operation' } };
96
+ }
97
+ if (operation && !capability) {
98
+ return { rejected: { relativePath, name, reason: 'operation_without_capability' } };
99
+ }
74
100
  const execution = String(meta.execution || 'orchestrated').trim().toLowerCase();
75
101
  if (!['orchestrated', 'direct'].includes(execution)) {
76
102
  return { rejected: { relativePath, name, reason: 'invalid_execution' } };
@@ -83,6 +109,8 @@ function inspectSkillFile(filePath, fallbackName, scope, root) {
83
109
  execution,
84
110
  scope,
85
111
  path: filePath,
112
+ ...(capability ? { capability } : {}),
113
+ ...(operation ? { operation } : {}),
86
114
  };
87
115
  const warnings = [];
88
116
  if (description.missing) warnings.push({ relativePath, name, reason: 'missing_description' });
@@ -38,7 +38,22 @@ export function projectWorkflow(state = {}, events = []) {
38
38
  const approvalNodes = approvals.map(approvalNode);
39
39
  nodes.push(...planNodes, ...activityNodes, ...queueNodes, ...approvalNodes);
40
40
 
41
- for (const node of [...planNodes, ...activityNodes, ...queueNodes, ...approvalNodes]) {
41
+ // The external runtime's collective (lot 2): each named subagent becomes a
42
+ // child node of the run node, so the Canvas shows the run's internal
43
+ // timeline instead of burying the roles in log lines.
44
+ const subagentNodes = (Array.isArray(state.subagents) ? state.subagents : [])
45
+ .map((entry, index) => ({
46
+ id: `subagent:${String(entry.subagent ?? 'subagent')}:${index}`,
47
+ type: 'subagent',
48
+ label: String(entry.subagent ?? 'subagent'),
49
+ status: entry.status === 'done' ? 'done' : 'running',
50
+ startedAt: entry.startedAt ?? null,
51
+ finishedAt: entry.finishedAt ?? null,
52
+ subagent: String(entry.subagent ?? 'subagent'),
53
+ }));
54
+ nodes.push(...subagentNodes);
55
+
56
+ for (const node of [...planNodes, ...activityNodes, ...queueNodes, ...approvalNodes, ...subagentNodes]) {
42
57
  if (run) relations.push({ type: 'contains', from: run.id, to: node.id });
43
58
  }
44
59
 
@@ -191,6 +206,15 @@ function metricNumber(value) {
191
206
  return Number.isFinite(number) && number >= 0 ? number : null;
192
207
  }
193
208
 
209
+
210
+ // The Plan panel names a run; it does not reproduce it. One line, bounded.
211
+ const RUN_LABEL_MAX = 80;
212
+ function runLabel(value) {
213
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
214
+ if (!text) return 'Runtime run';
215
+ return text.length > RUN_LABEL_MAX ? `${text.slice(0, RUN_LABEL_MAX - 1)}…` : text;
216
+ }
217
+
194
218
  function currentRun(state, events) {
195
219
  const runId = state.runId ?? state.runs?.find((run) => isActiveStatus(run.status))?.id ?? events.findLast?.((event) => event.runId)?.runId ?? null;
196
220
  if (!runId && !state.status) return null;
@@ -198,7 +222,12 @@ function currentRun(state, events) {
198
222
  id: runId ? `run:${runId}` : 'run:current',
199
223
  type: 'run',
200
224
  runId,
201
- label: state.summary || state.input || 'Runtime run',
225
+ // A skill run's `input` is the COMPILED objective — the private body's
226
+ // business intention, often a full paragraph. Printing it whole turned the
227
+ // Plan panel into a prompt dump, where the reader wanted the run's identity
228
+ // and its status. Prefer the summary, then the public invocation the
229
+ // control item already carries, and cap whatever is left.
230
+ label: runLabel(state.summary || state.publicInput || state.input),
202
231
  status: normalizeStatus(state.status ?? 'idle'),
203
232
  workspace: state.workspace ?? null,
204
233
  startedAt: state.startedAt ?? state.runs?.find((run) => run.id === runId)?.createdAt ?? null,
@@ -121,3 +121,34 @@ test('projectWorkflow derives per-task timing (start, finish, duration) from lif
121
121
  assert.equal(workflow.timingByTask.ingest.finishedAt, Date.parse('2026-07-23T10:00:12.500Z'));
122
122
  assert.equal(workflow.timingByTask.ingest.durationMs, 12500);
123
123
  });
124
+
125
+ test('projectWorkflow renders the collective subagents as child nodes of the run', () => {
126
+ const workflow = projectWorkflow({
127
+ status: 'running',
128
+ runId: 'run-1',
129
+ workspace: 'docs',
130
+ plan: [],
131
+ activities: [],
132
+ queue: [],
133
+ approvals: [],
134
+ subagents: [
135
+ { subagent: 'scout', status: 'done', startedAt: '2026-09-09T10:00:00.000Z', finishedAt: '2026-09-09T10:00:04.000Z' },
136
+ { subagent: 'critique', status: 'running', startedAt: '2026-09-09T10:00:05.000Z' },
137
+ ],
138
+ });
139
+
140
+ const subagentNodes = workflow.nodes.filter((node) => node.type === 'subagent');
141
+ assert.equal(subagentNodes.length, 2);
142
+ assert.equal(subagentNodes[0].label, 'scout');
143
+ assert.equal(subagentNodes[0].status, 'done');
144
+ assert.equal(subagentNodes[1].label, 'critique');
145
+ assert.equal(subagentNodes[1].status, 'running');
146
+
147
+ const run = workflow.nodes.find((node) => node.type === 'run');
148
+ for (const node of subagentNodes) {
149
+ assert.ok(
150
+ workflow.relations.some((rel) => rel.type === 'contains' && rel.from === run.id && rel.to === node.id),
151
+ `${node.label} hangs off the run node`,
152
+ );
153
+ }
154
+ });
@@ -2,12 +2,24 @@ import { normalizeActivity, parseJsonText } from '../core/activity.js';
2
2
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
3
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
4
4
  import { loadWorkspaceProfile } from '../core/profile.js';
5
+ import { containerReachableUrl } from '../core/wikiSetup.js';
5
6
  import { mapRuntimeEvent } from '../core/runtimeEventAdapter.js';
6
7
  import { emitRuntimeLog, pollActivitiesOnce } from '../runtime/supervisor.js';
7
8
  import { APPROVAL_DEFAULT_CLASS, approvalCovered } from './approvalPolicy.js';
8
9
  import { isSuccessful, isTerminal } from './taskStatuses.js';
9
10
 
10
11
 
12
+ // Distinguishes "the manager process is shutting down" from a genuine task
13
+ // cancellation (/run cancel, /stop, /control cancel) on the SAME shared
14
+ // AbortController + signal (server.js's context.currentAbortController) —
15
+ // the dispatcher otherwise cannot tell the two apart, and calling agent_cancel
16
+ // on a shutdown-abort was cancelling a still-healthy job the very recovery
17
+ // mechanism (recoveryManager.js's idempotency requeue) exists to reattach to
18
+ // on the next boot. A shutdown must still abort the poll loop — the `finally`
19
+ // block's lock release has to run before the process exits — it must just not
20
+ // tell the agent to give up on real work.
21
+ export const RUNTIME_SHUTDOWN_ABORT_REASON = 'runtime_shutdown';
22
+
11
23
  export function createDispatcher({
12
24
  session = null,
13
25
  callTool = callMcpTool,
@@ -66,7 +78,7 @@ export async function execute(task, assignment, {
66
78
  session.mcp,
67
79
  serverName,
68
80
  executeTool,
69
- executeRequest(task, session, runId),
81
+ executeRequest(task, session, runId, assignment),
70
82
  signal,
71
83
  ));
72
84
  if (accepted?.accepted === false || accepted?.ok === false) {
@@ -155,7 +167,7 @@ export async function execute(task, assignment, {
155
167
  }
156
168
  }
157
169
  } catch (error) {
158
- if (isAbortError(error) && jobId) {
170
+ if (isAbortError(error) && jobId && error.reason !== RUNTIME_SHUTDOWN_ABORT_REASON) {
159
171
  await callTool(session.mcp, serverName, cancelTool, { jobId }, null).catch(() => null);
160
172
  }
161
173
  throw error;
@@ -407,7 +419,20 @@ function dispatchExternalRuntimeActivity(session, task, assignment, runtimeRunId
407
419
  }));
408
420
  }
409
421
 
410
- function executeRequest(task, session, runId) {
422
+ function executeRequest(task, session, runId, assignment) {
423
+ // The assignment's `capability` field is the capability ID (a string) for
424
+ // MCP agents; the OBJECT with the inputSchema lives on the registry agent's
425
+ // description. Resolve it properly — reading inputSchema off the string is
426
+ // what silently disabled the configPath injection below.
427
+ const capabilityId = task?.requiredCapability;
428
+ const capabilityObject = (assignment?.capability && typeof assignment.capability === 'object'
429
+ ? assignment.capability
430
+ : null)
431
+ ?? assignment?.agent?.description?.capabilities?.find(
432
+ (capability) => String(capability?.id ?? '') === String(capabilityId ?? ''),
433
+ )
434
+ ?? null;
435
+ const schemaProperties = capabilityObject?.inputSchema?.properties ?? {};
411
436
  return {
412
437
  taskId: String(task.id ?? task.step),
413
438
  ...(runId ? { runId: String(runId) } : {}),
@@ -428,6 +453,17 @@ function executeRequest(task, session, runId) {
428
453
  // first E2E ingest plan dispatched by the deep agent's
429
454
  // planExpansionRequest failed exactly that way, 19 tasks in one batch.
430
455
  ...(task.requiresApproval === true ? { confirm: true } : {}),
456
+ // The ACTIVE profile must reach the job: a task planned without an
457
+ // explicit configPath (the common case) otherwise runs on the workspace
458
+ // default .wikirc, so /config use <profile> changes the runtime's own
459
+ // LLM but silently not the production job's. The dispatcher is the one
460
+ // place every executor sees — inject the session's current profile when
461
+ // the capability declares the field and the task did not set one.
462
+ ...(task.arguments?.configPath === undefined
463
+ && 'configPath' in schemaProperties
464
+ && session?.wikirc?.fileName
465
+ ? { configPath: session.wikirc.fileName }
466
+ : {}),
431
467
  },
432
468
  constraints: {
433
469
  requireApprovalForMutations: task.requiresApproval === true,
@@ -448,7 +484,7 @@ function workspaceRequest(session) {
448
484
  function activeProfileModel(session) {
449
485
  const llm = session?.wikircConfig?.llm ?? {};
450
486
  const model = {
451
- ...(llm.baseUrl ? { baseUrl: String(llm.baseUrl) } : {}),
487
+ ...(llm.baseUrl ? { baseUrl: containerReachableUrl(String(llm.baseUrl)).url } : {}),
452
488
  ...(llm.model ? { model: String(llm.model) } : {}),
453
489
  ...(llm.apiKey ? { apiKey: String(llm.apiKey) } : {}),
454
490
  };
@@ -521,7 +557,7 @@ export function activeProfileMcp(session) {
521
557
  };
522
558
  blocks.push({
523
559
  name: 'wiki',
524
- url: String(wiki.url),
560
+ url: containerReachableUrl(String(wiki.url)).url,
525
561
  ...(Object.keys(headers).length > 0 ? { headers } : {}),
526
562
  tools,
527
563
  });
@@ -542,7 +578,7 @@ export function activeProfileMcp(session) {
542
578
  if (tools.length === 0) continue;
543
579
  blocks.push({
544
580
  name,
545
- url: String(entry.url),
581
+ url: containerReachableUrl(String(entry.url)).url,
546
582
  ...(entry.headers && typeof entry.headers === 'object' ? { headers: entry.headers } : {}),
547
583
  tools,
548
584
  });
@@ -739,24 +775,25 @@ function taskLogPayload(event, task, assignment, {
739
775
  function delay(ms, signal) {
740
776
  return new Promise((resolve, reject) => {
741
777
  if (signal?.aborted) {
742
- reject(abortError());
778
+ reject(abortError(signal));
743
779
  return;
744
780
  }
745
781
  const timer = setTimeout(resolve, Math.max(0, Number(ms) || 0));
746
782
  signal?.addEventListener('abort', () => {
747
783
  clearTimeout(timer);
748
- reject(abortError());
784
+ reject(abortError(signal));
749
785
  }, { once: true });
750
786
  });
751
787
  }
752
788
 
753
789
  function throwIfAborted(signal) {
754
- if (signal?.aborted) throw abortError();
790
+ if (signal?.aborted) throw abortError(signal);
755
791
  }
756
792
 
757
- function abortError() {
793
+ function abortError(signal) {
758
794
  const error = new Error('Runtime run cancelled.');
759
795
  error.name = 'AbortError';
796
+ error.reason = signal?.reason;
760
797
  return error;
761
798
  }
762
799
 
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { activeProfileMcp, createDispatcher, normalizeTaskError } from './dispatcher.js';
3
+ import { activeProfileMcp, createDispatcher, normalizeTaskError, RUNTIME_SHUTDOWN_ABORT_REASON } from './dispatcher.js';
4
4
 
5
5
  test('activeProfileMcp forwards only the read-only wiki tools to the external runtime', () => {
6
6
  const session = {
@@ -91,6 +91,29 @@ test('activeProfileMcp tolerates namespaced tool names', () => {
91
91
  assert.deepEqual(activeProfileMcp(session)[0].tools, ['wiki__wiki_read_page']);
92
92
  });
93
93
 
94
+ test('activeProfileMcp rewrites loopback URLs for the container the runtime runs in', () => {
95
+ const session = {
96
+ mcp: {
97
+ wiki: {
98
+ url: 'http://127.0.0.1:3201/mcp', status: 'connected',
99
+ tools: [{ name: 'wiki_read_page' }],
100
+ },
101
+ exa: {
102
+ url: 'http://localhost:9999/mcp/', status: 'connected', external: true,
103
+ tools: [{ name: 'web_search_exa' }],
104
+ },
105
+ hosted: {
106
+ url: 'https://mcp.exa.ai/mcp', status: 'connected', external: true,
107
+ tools: [{ name: 'web_search_exa' }],
108
+ },
109
+ },
110
+ };
111
+ const pool = activeProfileMcp(session);
112
+ assert.equal(pool.find((block) => block.name === 'wiki').url, 'http://host.docker.internal:3201/mcp');
113
+ assert.equal(pool.find((block) => block.name === 'exa').url, 'http://host.docker.internal:9999/mcp');
114
+ assert.equal(pool.find((block) => block.name === 'hosted').url, 'https://mcp.exa.ai/mcp');
115
+ });
116
+
94
117
  test('dispatcher returns a retryable logical failure when agent_execute reports workspace_busy', async () => {
95
118
  const session = {
96
119
  workspace: 'test',
@@ -203,6 +226,72 @@ test('dispatcher completes when an executor-only agent reports succeeded', async
203
226
  assert.equal(result.jobId, 'job-connectors');
204
227
  });
205
228
 
229
+ test('dispatcher cancels the agent job when genuinely aborted mid-poll', async () => {
230
+ const session = {
231
+ workspace: 'test',
232
+ mcp: { production: { status: 'connected', tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }] } },
233
+ activities: {},
234
+ };
235
+ const calledTools = [];
236
+ const dispatcher = createDispatcher({
237
+ session,
238
+ pollIntervalMs: 5,
239
+ callTool: async (_mcp, _server, tool) => {
240
+ calledTools.push(tool);
241
+ if (tool === 'agent_execute') return { accepted: true, jobId: 'job-1', status: 'queued' };
242
+ if (tool === 'agent_cancel') return { ok: true };
243
+ // agent_status: never terminal, forces the poll loop to keep waiting
244
+ // until the abort fires.
245
+ return { jobId: 'job-1', status: 'running' };
246
+ },
247
+ });
248
+ const controller = new AbortController();
249
+ setTimeout(() => controller.abort(), 10);
250
+
251
+ await assert.rejects(
252
+ dispatcher.execute(
253
+ { id: 'analyze-doc', label: 'Analyze doc', requiredCapability: 'knowledge.update', operation: 'ingest_plan', arguments: {} },
254
+ { serverName: 'production', agentInstanceId: 'production-main' },
255
+ { attempt: { attemptId: 'analyze-doc:attempt-1', locks: [], release() {} }, signal: controller.signal },
256
+ ),
257
+ );
258
+ assert.ok(calledTools.includes('agent_cancel'), 'a genuine cancel/abort must still cancel the agent job');
259
+ });
260
+
261
+ test('dispatcher does NOT cancel the agent job when the manager is only shutting down', async () => {
262
+ // The exact scenario that caused a real incident: restarting the runtime
263
+ // mid-ingest orphaned in-flight sources because this path cancelled their
264
+ // still-healthy jobs instead of leaving them for recoveryManager.js's
265
+ // idempotency requeue to reattach to on the next boot.
266
+ const session = {
267
+ workspace: 'test',
268
+ mcp: { production: { status: 'connected', tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }] } },
269
+ activities: {},
270
+ };
271
+ const calledTools = [];
272
+ const dispatcher = createDispatcher({
273
+ session,
274
+ pollIntervalMs: 5,
275
+ callTool: async (_mcp, _server, tool) => {
276
+ calledTools.push(tool);
277
+ if (tool === 'agent_execute') return { accepted: true, jobId: 'job-2', status: 'queued' };
278
+ if (tool === 'agent_cancel') return { ok: true };
279
+ return { jobId: 'job-2', status: 'running' };
280
+ },
281
+ });
282
+ const controller = new AbortController();
283
+ setTimeout(() => controller.abort(RUNTIME_SHUTDOWN_ABORT_REASON), 10);
284
+
285
+ await assert.rejects(
286
+ dispatcher.execute(
287
+ { id: 'analyze-doc', label: 'Analyze doc', requiredCapability: 'knowledge.update', operation: 'ingest_plan', arguments: {} },
288
+ { serverName: 'production', agentInstanceId: 'production-main' },
289
+ { attempt: { attemptId: 'analyze-doc:attempt-1', locks: [], release() {} }, signal: controller.signal },
290
+ ),
291
+ );
292
+ assert.ok(!calledTools.includes('agent_cancel'), 'a shutdown-reason abort must leave the still-healthy agent job running');
293
+ });
294
+
206
295
  test('dispatcher normalizes a bare string error reported on a terminal agent_status', async () => {
207
296
  const session = {
208
297
  workspace: 'test',
@@ -298,7 +298,7 @@ test('dispatcher sends the active profile model with the run', async () => {
298
298
  assert.equal(requests[0].language, 'fr', 'the workspace language travels with the run');
299
299
  assert.deepEqual(requests[0].mcp, [{
300
300
  name: 'wiki',
301
- url: 'http://localhost:3335/mcp/',
301
+ url: 'http://host.docker.internal:3335/mcp',
302
302
  headers: { Authorization: 'Bearer wiki-token' },
303
303
  tools: ['wiki_search_context', 'wiki_read_page'],
304
304
  }], 'the wiki MCP travels per run, read tools only — write tools never leave');
@@ -378,7 +378,7 @@ test('dispatcher sends the active profile model with the run', async () => {
378
378
  );
379
379
 
380
380
  assert.equal(received[0].model.model, 'openai/gpt-test');
381
- assert.equal(received[0].model.baseUrl, 'http://127.0.0.1:9/v1');
381
+ assert.equal(received[0].model.baseUrl, 'http://host.docker.internal:9/v1');
382
382
  assert.equal(received[0].model.apiKey, 'k');
383
383
  assert.equal(received[0].model.temperature, 0.2);
384
384
  });
@@ -157,8 +157,10 @@ test('resolveRuntimeProviders maps enabled entries and skips unknown types', ()
157
157
  test('loadAgentRuntimesConfig reads both array and object forms', () => {
158
158
  const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
159
159
  try {
160
+ // env: {} — the machine's manager .env (GATEWAY_ENABLED) must not leak
161
+ // into this parse-shape test through the implied-gateway path.
160
162
  writeFileSync(join(dir, 'agent-runtimes.json'), JSON.stringify({ runtimes: [{ id: 'a', type: 'fake' }] }));
161
- assert.deepEqual(loadAgentRuntimesConfig({ stateDir: dir }), [{ id: 'a', type: 'fake' }]);
163
+ assert.deepEqual(loadAgentRuntimesConfig({ stateDir: dir, env: {} }), [{ id: 'a', type: 'fake' }]);
162
164
  } finally {
163
165
  rmSync(dir, { recursive: true, force: true });
164
166
  }
@@ -1,3 +1,5 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import { validateContract } from '../contracts/schemas.js';
2
4
  import { parseJsonText } from '../core/activity.js';
3
5
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
@@ -63,6 +65,26 @@ export async function accept(result, {
63
65
  taskId,
64
66
  payload,
65
67
  })));
68
+ // A worktree proposal is a review item, not a log line: persist it into the
69
+ // workspace review queue (.wiki/agent-proposals/) where the served review
70
+ // surface reads it, and announce it — a proposal nobody is told about is a
71
+ // proposal nobody merges, and a merge is the approval.
72
+ const worktreePersisted = persistWorktreeProposal(session, result, { runId, taskId, ok, status });
73
+ if (worktreePersisted.error) {
74
+ persistDispatch(store, dispatchAgentEvent(session, createAgentEvent('runtime_log', {
75
+ origin: 'result_aggregator',
76
+ runId,
77
+ taskId,
78
+ payload: { message: `agent-proposal: could not persist the worktree proposal for ${taskId}: ${worktreePersisted.error}` },
79
+ })));
80
+ } else if (worktreePersisted.path) {
81
+ persistDispatch(store, dispatchAgentEvent(session, createAgentEvent('runtime_log', {
82
+ origin: 'result_aggregator',
83
+ runId,
84
+ taskId,
85
+ payload: { message: `agent-proposal: ${taskId} is waiting for review — ${worktreePersisted.path}` },
86
+ })));
87
+ }
66
88
  persistDispatch(store, dispatchAgentEvent(session, createAgentEvent('plan_step_updated', {
67
89
  origin: 'result_aggregator',
68
90
  runId,
@@ -227,6 +249,51 @@ function persistDispatch(store, event) {
227
249
  store?.persistEvent?.(event);
228
250
  }
229
251
 
252
+ /**
253
+ * Worktree proposals (agent.curate): the external runtime's run result carries
254
+ * `worktreeProposal` — the confined branch's changed files, their new content
255
+ * and the unified diff. The manager writes it into the workspace review queue
256
+ * (`.wiki/agent-proposals/<id>.json`, gitignored state) where the served
257
+ * review surface reads it; the MERGE happens there, through the engine's own
258
+ * write machinery — this function only records, it never touches wiki content.
259
+ */
260
+ function persistWorktreeProposal(session, result, { runId, taskId }) {
261
+ const proposal = result?.result?.worktreeProposal ?? result?.worktreeProposal;
262
+ if (!proposal || typeof proposal !== 'object') return { path: null };
263
+ const changes = Array.isArray(proposal.changes) ? proposal.changes : [];
264
+ if (changes.length === 0) return { path: null };
265
+ const workspacePath = session?.workspacePath;
266
+ if (!workspacePath || typeof workspacePath !== 'string') {
267
+ return { error: 'no workspace path on the session — the proposal stays in the run result only' };
268
+ }
269
+ const record = {
270
+ id: String(taskId),
271
+ runId: String(runId ?? ''),
272
+ workspace: String(proposal.workspace ?? session.workspace ?? ''),
273
+ branch: String(proposal.branch ?? ''),
274
+ worktreePath: String(proposal.worktreePath ?? ''),
275
+ worktreeRelativePath: String(proposal.worktreeRelativePath ?? ''),
276
+ createdAt: new Date().toISOString(),
277
+ justification: String(proposal.justification ?? ''),
278
+ ...(Array.isArray(proposal.objections) && proposal.objections.length > 0
279
+ ? { objections: proposal.objections }
280
+ : {}),
281
+ changedFiles: Array.isArray(proposal.changedFiles) ? proposal.changedFiles : [],
282
+ changes,
283
+ diff: String(proposal.diff ?? ''),
284
+ };
285
+ try {
286
+ const safeId = String(taskId).replace(/[^a-zA-Z0-9._-]/g, '_');
287
+ const dir = join(workspacePath, '.wiki', 'agent-proposals');
288
+ mkdirSync(dir, { recursive: true });
289
+ const path = join(dir, `${safeId}.json`);
290
+ writeFileSync(path, JSON.stringify(record, null, 2));
291
+ return { path };
292
+ } catch (error) {
293
+ return { error: error instanceof Error ? error.message : String(error) };
294
+ }
295
+ }
296
+
230
297
  function agentPlanRequest(request, session) {
231
298
  return {
232
299
  capability: request.capability,