@dotdrelle/wiki-manager 0.12.0 → 0.12.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +2 -2
  2. package/src/activity/activityAggregator.js +34 -3
  3. package/src/activity/activityAggregator.test.js +32 -0
  4. package/src/agent/graph.js +307 -29
  5. package/src/agent/graph.test.js +404 -1
  6. package/src/cli/wiki-manager.js +24 -1
  7. package/src/commands/slash.js +74 -1
  8. package/src/commands/slash.test.js +36 -0
  9. package/src/contracts/schemas.test.js +2 -2
  10. package/src/core/activity.js +4 -0
  11. package/src/core/activity.test.js +9 -1
  12. package/src/core/agentEvents.js +30 -0
  13. package/src/core/agentEvents.test.js +42 -1
  14. package/src/core/buildInfo.js +58 -0
  15. package/src/core/buildInfo.json +4 -0
  16. package/src/core/buildInfo.test.js +14 -0
  17. package/src/core/mcp.js +50 -3
  18. package/src/core/mcp.test.js +94 -1
  19. package/src/core/runtimeLog.js +6 -1
  20. package/src/core/runtimeLog.test.js +3 -1
  21. package/src/runtime/client.js +22 -0
  22. package/src/runtime/controlMessages.js +43 -0
  23. package/src/runtime/controlMessages.test.js +21 -0
  24. package/src/runtime/donna-contract.test.js +3 -3
  25. package/src/runtime/recoveryManager.js +54 -0
  26. package/src/runtime/recoveryManager.test.js +73 -0
  27. package/src/runtime/runner.js +49 -13
  28. package/src/runtime/runner.test.js +196 -0
  29. package/src/runtime/server.js +73 -13
  30. package/src/runtime/server.test.js +284 -67
  31. package/src/runtime/store.js +48 -3
  32. package/src/runtime/store.test.js +56 -24
  33. package/src/runtime/supervisor.js +77 -1
  34. package/src/shell/RightPane.tsx +124 -31
  35. package/src/shell/SetupWizard.tsx +13 -1
  36. package/src/shell/repl.js +81 -11
  37. package/src/shell/repl.test.js +115 -2
  38. package/src/shell/tui.tsx +4 -1
  39. package/src/shell/useAgent.ts +37 -4
  40. package/src/shell/useSession.ts +51 -10
@@ -39,6 +39,7 @@ const SESSION_PROJECTION_EVENTS = new Set([
39
39
  'approval.rejected',
40
40
  'control_enqueued',
41
41
  'control_started',
42
+ 'control_cancelled',
42
43
  'agent.registered',
43
44
  'agent.health_changed',
44
45
  'run_done',
@@ -479,6 +480,11 @@ function applyEvent(state, event) {
479
480
  case 'run_cancelled':
480
481
  state.status = 'cancelled';
481
482
  state.logs.push(String(event.payload?.message ?? 'Agent run cancelled.'));
483
+ // A cancelled run must not leave its plan steps "running/pending" and
484
+ // its activities spinning in the panels: mark every non-terminal one
485
+ // cancelled so the display reflects reality immediately.
486
+ cancelPendingPlanSteps(state.plan);
487
+ cancelActiveActivities(state.activities, event.ts);
482
488
  finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'cancelled', event.ts);
483
489
  return;
484
490
  case 'run_error':
@@ -505,6 +511,14 @@ function applyEvent(state, event) {
505
511
  updatedAt: event.ts,
506
512
  });
507
513
  return;
514
+ case 'control_cancelled':
515
+ upsertControlItem(state.controlQueue, {
516
+ id: event.payload?.id ?? null,
517
+ status: 'cancelled',
518
+ finishedAt: event.payload?.finishedAt ?? event.ts,
519
+ updatedAt: event.ts,
520
+ });
521
+ return;
508
522
  case 'agent.registered':
509
523
  upsertAgent(state, event.payload?.agent, event.ts);
510
524
  return;
@@ -652,6 +666,22 @@ function markCoveredApprovalsApproved(approvals, grant, ts) {
652
666
  }
653
667
  }
654
668
 
669
+ function cancelPendingPlanSteps(plan) {
670
+ for (const step of plan ?? []) {
671
+ if (!['done', 'failed', 'cancelled'].includes(String(step.status ?? ''))) step.status = 'cancelled';
672
+ }
673
+ }
674
+
675
+ function cancelActiveActivities(activities, ts) {
676
+ for (const activity of Object.values(activities ?? {})) {
677
+ if (activity && activity.terminal !== true) {
678
+ activity.status = 'cancelled';
679
+ activity.terminal = true;
680
+ activity.updatedAt = ts ?? activity.updatedAt;
681
+ }
682
+ }
683
+ }
684
+
655
685
  function finishPendingPlanSteps(plan) {
656
686
  for (const step of plan ?? []) {
657
687
  if (step.status === 'running' || step.status === 'pending') {
@@ -279,6 +279,16 @@ test('reduceAgentEvents: control queue is event sourced and follows run status',
279
279
  createdAt: '2026-01-01T00:00:00.000Z',
280
280
  },
281
281
  }),
282
+ createAgentEvent('control_enqueued', {
283
+ origin: 'runtime',
284
+ workspace: 'docs',
285
+ payload: {
286
+ id: 'control-2',
287
+ workspace: 'docs',
288
+ input: 'Never run',
289
+ createdAt: '2026-01-01T00:00:01.000Z',
290
+ },
291
+ }),
282
292
  createAgentEvent('control_started', {
283
293
  origin: 'runtime',
284
294
  runId: 'run-control-1',
@@ -290,12 +300,19 @@ test('reduceAgentEvents: control queue is event sourced and follows run status',
290
300
  runId: 'run-control-1',
291
301
  workspace: 'docs',
292
302
  }),
303
+ createAgentEvent('control_cancelled', {
304
+ origin: 'runtime',
305
+ workspace: 'docs',
306
+ payload: { id: 'control-2' },
307
+ }),
293
308
  ]);
294
309
 
295
- assert.equal(projection.controlQueue.length, 1);
310
+ assert.equal(projection.controlQueue.length, 2);
296
311
  assert.equal(projection.controlQueue[0].id, 'control-1');
297
312
  assert.equal(projection.controlQueue[0].status, 'done');
298
313
  assert.equal(projection.controlQueue[0].runId, 'run-control-1');
314
+ assert.equal(projection.controlQueue[1].id, 'control-2');
315
+ assert.equal(projection.controlQueue[1].status, 'cancelled');
299
316
  });
300
317
 
301
318
  test('reduceAgentEvents: activity-owned plan is used when no orchestrator plan exists', () => {
@@ -399,3 +416,27 @@ test('reduceAgentEvents: plan patches are proposed, approved and applied with re
399
416
  assert.equal(projection.plan[1].status, 'pending');
400
417
  assert.equal(projection.planPatches[0].status, 'applied');
401
418
  });
419
+
420
+ test('streamed narration split across tool iterations yields separate conversation entries', () => {
421
+ // graph.js finalizes the streaming entry (assistant_message content:'')
422
+ // before each tool batch so per-iteration narrations do not glue together
423
+ // into one wall of text.
424
+ const session = {};
425
+ dispatchAgentEvent(session, createAgentEvent('assistant_delta', { origin: 'llm', payload: { delta: 'Analyse des jobs récents.' } }));
426
+ dispatchAgentEvent(session, createAgentEvent('assistant_message', { origin: 'llm', payload: { content: '' } }));
427
+ dispatchAgentEvent(session, createAgentEvent('assistant_delta', { origin: 'llm', payload: { delta: 'Voyons les logs.' } }));
428
+ dispatchAgentEvent(session, createAgentEvent('assistant_message', { origin: 'llm', payload: { content: 'Voyons les logs.' } }));
429
+
430
+ const conversation = session.agentProjection.conversation;
431
+ assert.equal(conversation.length, 2);
432
+ assert.equal(conversation[0].content, 'Analyse des jobs récents.');
433
+ assert.equal(conversation[0].streaming ?? false, false);
434
+ assert.equal(conversation[1].content, 'Voyons les logs.');
435
+ });
436
+
437
+ test('empty assistant_message finalize is a no-op without a streaming entry', () => {
438
+ const session = {};
439
+ dispatchAgentEvent(session, createAgentEvent('assistant_message', { origin: 'llm', payload: { content: 'Réponse finale.' } }));
440
+ dispatchAgentEvent(session, createAgentEvent('assistant_message', { origin: 'llm', payload: { content: '' } }));
441
+ assert.equal(session.agentProjection.conversation.length, 1);
442
+ });
@@ -0,0 +1,58 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+ const packageRoot = resolve(here, '..', '..');
8
+
9
+ let cachedCommit;
10
+
11
+ // Short git commit identifying the code actually running. Resolution order:
12
+ // 1. Live git HEAD when running from the development repository — accurate
13
+ // even between releases (dirty trees still show the base commit).
14
+ // 2. buildInfo.json generated at pack time (scripts/check-versions.js) —
15
+ // what a published/global install carries.
16
+ // 3. null — displayed as "+dev" so an untraceable build is visible at a
17
+ // glance instead of silently pretending to match the repo.
18
+ export function buildCommit() {
19
+ if (cachedCommit !== undefined) return cachedCommit;
20
+ cachedCommit = liveGitCommit() ?? packagedCommit();
21
+ return cachedCommit;
22
+ }
23
+
24
+ export function versionWithBuild(packageJson) {
25
+ const version = String(packageJson?.version ?? '').trim();
26
+ const commit = buildCommit();
27
+ return commit ? `${version}+${commit}` : `${version}+dev`;
28
+ }
29
+
30
+ function liveGitCommit() {
31
+ try {
32
+ // Guard against walking up into an unrelated parent repository when the
33
+ // package is installed under a directory that happens to be git-tracked.
34
+ const toplevel = git(['rev-parse', '--show-toplevel']);
35
+ if (!toplevel || resolve(toplevel) !== packageRoot) return null;
36
+ return git(['rev-parse', '--short', 'HEAD']);
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function git(args) {
43
+ const output = execFileSync('git', args, {
44
+ cwd: packageRoot,
45
+ stdio: ['ignore', 'pipe', 'ignore'],
46
+ timeout: 2000,
47
+ }).toString().trim();
48
+ return output || null;
49
+ }
50
+
51
+ function packagedCommit() {
52
+ try {
53
+ const info = JSON.parse(readFileSync(join(here, 'buildInfo.json'), 'utf8'));
54
+ return info?.commit ? String(info.commit) : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "version": "0.12.7",
3
+ "commit": "0bab10f"
4
+ }
@@ -0,0 +1,14 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { buildCommit, versionWithBuild } from './buildInfo.js';
4
+
5
+ test('versionWithBuild always exposes a provenance suffix', () => {
6
+ const formatted = versionWithBuild({ version: '9.9.9' });
7
+ // From the development repo the live git short sha is used; from a packed
8
+ // install the buildInfo.json commit; '+dev' only when neither is available.
9
+ assert.match(formatted, /^9\.9\.9\+(?:[0-9a-f]{4,40}|dev)$/);
10
+ });
11
+
12
+ test('buildCommit is stable across calls (cached)', () => {
13
+ assert.equal(buildCommit(), buildCommit());
14
+ });
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.12.0';
4
+ const WIKI_MANAGER_VERSION = '0.12.7';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -174,7 +174,7 @@ function clarifyToolDescription(serverName, toolName, description) {
174
174
  if (serverName === 'production' && toolName === 'production_start_job') {
175
175
  return compactDescription([
176
176
  base,
177
- 'Production export means wiki deliverable/publication export only. Do not use type=export for Confluence/CME/source export; use cme_export_run instead.',
177
+ 'Production export means wiki deliverable/publication export only. Do not use type=export for Confluence/CME/source export; use cme__cme_export_run instead.',
178
178
  ].filter(Boolean).join(' '));
179
179
  }
180
180
  return base;
@@ -302,6 +302,26 @@ export function formatMcpToolResult(result) {
302
302
  .trim() || 'No result.';
303
303
  }
304
304
 
305
+ const DEFAULT_TOOL_RESULT_MAX_CHARS = 16000;
306
+
307
+ function toolResultMaxChars() {
308
+ const parsed = Number(process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS);
309
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : DEFAULT_TOOL_RESULT_MAX_CHARS;
310
+ }
311
+
312
+ // Bound what a tool result injects into the LLM context and the conversation
313
+ // display. Apply this ONLY at those two exit points — never before payload
314
+ // parsing (extractActivity/_activity detection needs the full text).
315
+ // Head + tail are kept because errors and job ids often live at either end.
316
+ export function truncateToolResult(text, maxChars = toolResultMaxChars()) {
317
+ const full = String(text ?? '');
318
+ if (full.length <= maxChars) return full;
319
+ const headLength = Math.floor(maxChars * 0.7);
320
+ const tailLength = Math.floor(maxChars * 0.2);
321
+ const omitted = full.length - headLength - tailLength;
322
+ return `${full.slice(0, headLength)}\n\n[… ${omitted} caractères tronqués — résultat complet dans les logs runtime …]\n\n${full.slice(-tailLength)}`;
323
+ }
324
+
305
325
  let _cachedEnvRetryPolicy = null;
306
326
  function getEnvRetryPolicy() {
307
327
  if (!_cachedEnvRetryPolicy) {
@@ -469,7 +489,9 @@ export function formatMcpToolsForAgent(mcpStatus) {
469
489
  sections.push(`${name}: connected, tools not discovered yet`);
470
490
  continue;
471
491
  }
472
- sections.push(`${name}: ${tools.map((tool) => tool.name).join(', ')}`);
492
+ // Always advertise the qualified call name (server__tool): showing bare
493
+ // tool names here is what teaches the model to emit unqualified calls.
494
+ sections.push(`${name}: ${tools.map((tool) => `${name}__${tool.name}`).join(', ')}`);
473
495
  }
474
496
  return sections.length > 0 ? sections.join('\n') : 'No connected MCP tools discovered yet.';
475
497
  }
@@ -498,6 +520,31 @@ export function parseToolCallName(name) {
498
520
  return { server: name.slice(0, sep), tool: name.slice(sep + 2) };
499
521
  }
500
522
 
523
+ // Deterministic recovery for unqualified tool-call names emitted by the LLM
524
+ // (e.g. "cme_status" instead of "cme__cme_status"). Exact-name match only:
525
+ // if exactly one connected server (or extra pseudo-server) exposes the bare
526
+ // tool name, route to it and report `normalized: true`; otherwise return
527
+ // `server: null` with the list of candidate servers so the caller can raise
528
+ // an explicit error. This is name normalization, never fuzzy matching — do
529
+ // not extend it to description/similarity-based selection (plan directeur
530
+ // §20 forbids that).
531
+ export function resolveToolCallName(mcpStatus, name, extraServers = {}) {
532
+ const parsed = parseToolCallName(name);
533
+ if (parsed.server) return { ...parsed, normalized: false, candidates: [] };
534
+ const candidates = [];
535
+ for (const [serverName, toolNames] of Object.entries(extraServers)) {
536
+ if (toolNames.includes(parsed.tool)) candidates.push(serverName);
537
+ }
538
+ for (const [serverName, value] of Object.entries(mcpStatus ?? {})) {
539
+ if (value.status !== 'connected') continue;
540
+ if ((value.tools ?? []).some((tool) => tool.name === parsed.tool)) candidates.push(serverName);
541
+ }
542
+ if (candidates.length === 1) {
543
+ return { server: candidates[0], tool: parsed.tool, normalized: true, candidates };
544
+ }
545
+ return { server: null, tool: parsed.tool, normalized: false, candidates };
546
+ }
547
+
501
548
  export function mcpStatusMarker(status) {
502
549
  if (status === 'connected') return '●';
503
550
  if (status === 'configured') return '◐';
@@ -3,7 +3,76 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtemp, writeFile } from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import { buildMcpStatus, callMcpTool, discoverMcpTools, resolveRetryPolicy } from './mcp.js';
6
+ import {
7
+ buildMcpStatus,
8
+ callMcpTool,
9
+ discoverMcpTools,
10
+ formatMcpToolsForAgent,
11
+ resolveRetryPolicy,
12
+ resolveToolCallName,
13
+ truncateToolResult,
14
+ } from './mcp.js';
15
+
16
+ const resolveFixtureStatus = {
17
+ production: {
18
+ status: 'connected',
19
+ tools: [{ name: 'production_start_job' }, { name: 'agent_status' }],
20
+ },
21
+ cme: {
22
+ status: 'connected',
23
+ tools: [{ name: 'cme_status' }, { name: 'agent_status' }],
24
+ },
25
+ documents: {
26
+ status: 'configured', // not connected: must never be a candidate
27
+ tools: [{ name: 'cme_status' }],
28
+ },
29
+ };
30
+
31
+ test('resolveToolCallName passes qualified names through untouched', () => {
32
+ const resolved = resolveToolCallName(resolveFixtureStatus, 'cme__cme_status');
33
+ assert.deepEqual(
34
+ { server: resolved.server, tool: resolved.tool, normalized: resolved.normalized },
35
+ { server: 'cme', tool: 'cme_status', normalized: false },
36
+ );
37
+ });
38
+
39
+ test('resolveToolCallName normalizes a bare name with exactly one connected match', () => {
40
+ const resolved = resolveToolCallName(resolveFixtureStatus, 'cme_status');
41
+ assert.deepEqual(
42
+ { server: resolved.server, tool: resolved.tool, normalized: resolved.normalized },
43
+ { server: 'cme', tool: 'cme_status', normalized: true },
44
+ );
45
+ });
46
+
47
+ test('resolveToolCallName refuses ambiguous bare names and reports candidates', () => {
48
+ const resolved = resolveToolCallName(resolveFixtureStatus, 'agent_status');
49
+ assert.equal(resolved.server, null);
50
+ assert.equal(resolved.normalized, false);
51
+ assert.deepEqual([...resolved.candidates].sort(), ['cme', 'production']);
52
+ });
53
+
54
+ test('resolveToolCallName returns no server for unknown bare names', () => {
55
+ const resolved = resolveToolCallName(resolveFixtureStatus, 'does_not_exist');
56
+ assert.equal(resolved.server, null);
57
+ assert.deepEqual(resolved.candidates, []);
58
+ });
59
+
60
+ test('resolveToolCallName resolves internal pseudo-server tools via extraServers', () => {
61
+ const resolved = resolveToolCallName(resolveFixtureStatus, 'plan_set', { wiki: ['plan_set', 'plan_done'] });
62
+ assert.deepEqual(
63
+ { server: resolved.server, tool: resolved.tool, normalized: resolved.normalized },
64
+ { server: 'wiki', tool: 'plan_set', normalized: true },
65
+ );
66
+ });
67
+
68
+ test('formatMcpToolsForAgent advertises qualified server__tool names only', () => {
69
+ const listing = formatMcpToolsForAgent(resolveFixtureStatus);
70
+ assert.match(listing, /cme__cme_status/);
71
+ assert.match(listing, /production__production_start_job/);
72
+ // No bare tool name outside a qualified form.
73
+ assert.doesNotMatch(listing, /(?<![\w])cme_status(?![\w])/);
74
+ assert.doesNotMatch(listing, /(?<![\w])production_start_job(?![\w])/);
75
+ });
7
76
 
8
77
  test('buildMcpStatus reads external MCP endpoints from mcp.endpoints.json', async () => {
9
78
  const originalCwd = process.cwd();
@@ -448,3 +517,27 @@ test('callMcpTool parses SSE responses after keepalive comments', async () => {
448
517
  globalThis.fetch = originalFetch;
449
518
  }
450
519
  });
520
+
521
+ test('truncateToolResult keeps short results intact and bounds long ones head+tail', () => {
522
+ assert.equal(truncateToolResult('short result', 100), 'short result');
523
+
524
+ const long = `START-${'x'.repeat(50000)}-END`;
525
+ const bounded = truncateToolResult(long, 1000);
526
+ assert.ok(bounded.length < 1200, `bounded length ${bounded.length} should stay near the cap`);
527
+ assert.match(bounded, /^START-/);
528
+ assert.match(bounded, /-END$/);
529
+ assert.match(bounded, /caractères tronqués/);
530
+ });
531
+
532
+ test('truncateToolResult honours WIKI_MANAGER_TOOL_RESULT_MAX_CHARS', () => {
533
+ const previous = process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS;
534
+ process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS = '500';
535
+ try {
536
+ const bounded = truncateToolResult('y'.repeat(5000));
537
+ assert.ok(bounded.length < 700);
538
+ assert.match(bounded, /caractères tronqués/);
539
+ } finally {
540
+ if (previous === undefined) delete process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS;
541
+ else process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS = previous;
542
+ }
543
+ });
@@ -61,7 +61,12 @@ export function normalizeRuntimeLog(input, { session = null } = {}) {
61
61
  }
62
62
 
63
63
  export function formatRuntimeLogPayload(payload = {}, ts = null) {
64
- if (payload?.message != null && !payload.event) return String(payload.message);
64
+ // Plain messages get the same time prefix as structured events: untimed
65
+ // lines ended up visually glued at the bottom of Logs/Trace, out of
66
+ // chronology with the shell's own timestamped lines.
67
+ if (payload?.message != null && !payload.event) {
68
+ return [timeLabel(ts), String(payload.message)].filter(Boolean).join(' ');
69
+ }
65
70
  const time = timeLabel(ts);
66
71
  const event = eventLabel(payload.event);
67
72
  const fields = ORDERED_FIELDS
@@ -80,5 +80,7 @@ test('emitRuntimeLog accepts structured payloads and preserves legacy strings',
80
80
  assert.match(session.agentProjection.logs[0], /ASSIGNED/);
81
81
  assert.match(session.agentProjection.logs[0], /run=run-structured/);
82
82
  assert.match(session.agentProjection.logs[0], /workspace=docs/);
83
- assert.equal(session.agentProjection.logs[1], 'legacy line');
83
+ // Legacy plain messages now carry the same HH:MM:SS prefix as structured
84
+ // events so the Logs/Trace panel stays chronologically readable.
85
+ assert.match(session.agentProjection.logs[1], /^\d{2}:\d{2}:\d{2} legacy line$/);
84
86
  });
@@ -10,6 +10,14 @@ function runtimeEndpoint(url, path, workspace = null) {
10
10
  return endpoint.toString();
11
11
  }
12
12
 
13
+ function runtimeEndpointWithParams(url, path, params = {}) {
14
+ const endpoint = new URL(`${base(url)}${path}`);
15
+ for (const [key, value] of Object.entries(params)) {
16
+ if (value != null && value !== '') endpoint.searchParams.set(key, String(value));
17
+ }
18
+ return endpoint.toString();
19
+ }
20
+
13
21
  export function runtimeUrlFromEnv() {
14
22
  return process.env.WIKI_MANAGER_RUNTIME_URL ?? 'http://127.0.0.1:7788';
15
23
  }
@@ -97,6 +105,20 @@ export async function postRuntimeCancel({
97
105
  return response.json();
98
106
  }
99
107
 
108
+ export async function postRuntimeKill({
109
+ url = runtimeUrlFromEnv(),
110
+ token = runtimeToken(),
111
+ workspace = null,
112
+ runId = null,
113
+ } = {}) {
114
+ const response = await fetch(runtimeEndpointWithParams(url, '/kill', { workspace, runId }), {
115
+ method: 'POST',
116
+ headers: runtimeHeaders(token),
117
+ });
118
+ if (!response.ok && response.status !== 501) throw new Error(`Runtime kill failed: HTTP ${response.status}`);
119
+ return response.json();
120
+ }
121
+
100
122
  export async function postRuntimeShutdown({
101
123
  url = runtimeUrlFromEnv(),
102
124
  token = runtimeToken(),
@@ -0,0 +1,43 @@
1
+ // Deterministic, localized messages for the runtime control lane.
2
+ //
3
+ // Control-lane acknowledgements (run queued, ambiguous input, conversation
4
+ // fallback…) are intentionally NOT generated by Donna: spending an LLM turn
5
+ // to say "your request is queued" would reintroduce exactly the per-message
6
+ // cost the orchestration refactor removed. But they are user-facing, so they
7
+ // must follow the session's configured reply language. This catalog is the
8
+ // single source for those strings — never hardcode a control-lane message in
9
+ // the shell or the server directly.
10
+
11
+ const CONTROL_MESSAGES = {
12
+ queued_for_future_run: {
13
+ en: 'Request added to the queue — it will start automatically after the current run.',
14
+ fr: 'Demande ajoutée à la file — elle démarrera automatiquement à la fin du run en cours.',
15
+ },
16
+ plan_patch_proposed: {
17
+ en: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
18
+ fr: 'Modification de plan proposée. Approuvez-la explicitement pour l’appliquer au plan actif.',
19
+ },
20
+ ambiguous_control: {
21
+ en: 'A run is already active, and this looks like a new action. Say "queue it" to run it after the current run, "modify the run" to change the active plan, "cancel" to stop the current run first — or wait for it to finish.',
22
+ fr: 'Un run est déjà actif et ta demande ressemble à une nouvelle action. Dis « mets en file » pour l\'exécuter après le run en cours, « modifie le run » pour changer le plan actif, « annule » pour arrêter le run actuel — ou attends la fin.',
23
+ },
24
+ converse_while_running: {
25
+ en: 'Runtime run is still active. This message was treated as conversation and did not create a queued run.',
26
+ fr: 'Un run est toujours actif. Ce message a été traité comme conversation et n’a pas créé de run en file.',
27
+ },
28
+ converse_while_idle: {
29
+ en: 'Runtime is idle. This message was treated as conversation and did not create a run.',
30
+ fr: 'Le runtime est inactif. Ce message a été traité comme conversation et n’a pas créé de run.',
31
+ },
32
+ };
33
+
34
+ export function controlLanguage(session) {
35
+ const raw = String(session?.language ?? 'en').toLowerCase();
36
+ return raw.startsWith('fr') ? 'fr' : 'en';
37
+ }
38
+
39
+ export function controlMessage(session, key) {
40
+ const entry = CONTROL_MESSAGES[key];
41
+ if (!entry) throw new Error(`Unknown control message key: ${key}`);
42
+ return entry[controlLanguage(session)] ?? entry.en;
43
+ }
@@ -0,0 +1,21 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { controlLanguage, controlMessage } from './controlMessages.js';
4
+
5
+ test('controlLanguage maps fr locales to fr and everything else to en', () => {
6
+ assert.equal(controlLanguage({ language: 'fr-FR' }), 'fr');
7
+ assert.equal(controlLanguage({ language: 'fr' }), 'fr');
8
+ assert.equal(controlLanguage({ language: 'en-US' }), 'en');
9
+ assert.equal(controlLanguage({ language: null }), 'en');
10
+ assert.equal(controlLanguage(null), 'en');
11
+ });
12
+
13
+ test('controlMessage returns the localized queued acknowledgement', () => {
14
+ assert.match(controlMessage({ language: 'fr-FR' }, 'queued_for_future_run'), /ajoutée à la file/);
15
+ assert.match(controlMessage({ language: 'en-US' }, 'queued_for_future_run'), /added to the queue/);
16
+ });
17
+
18
+ test('controlMessage falls back to en for unknown locales and throws on unknown keys', () => {
19
+ assert.match(controlMessage({ language: 'de-DE' }, 'queued_for_future_run'), /added to the queue/);
20
+ assert.throws(() => controlMessage({ language: 'fr-FR' }, 'nope'), /Unknown control message key/);
21
+ });
@@ -531,7 +531,7 @@ function toolResult(payload) {
531
531
  }
532
532
 
533
533
  test('Recipe #7 — "où en es-tu ?" during an active run: status in conversation, run continues, no new run', async (t) => {
534
- const session = { workspace: 'juno', controlQueue: [] };
534
+ const session = { workspace: 'acme', controlQueue: [] };
535
535
  let runCount = 0;
536
536
  let handle;
537
537
  try {
@@ -550,7 +550,7 @@ test('Recipe #7 — "où en es-tu ?" during an active run: status in conversatio
550
550
  listEvents: () => [],
551
551
  },
552
552
  getContext: async () => ({
553
- workspace: 'juno',
553
+ workspace: 'acme',
554
554
  session,
555
555
  running: true,
556
556
  currentAbortController: new AbortController(),
@@ -566,7 +566,7 @@ test('Recipe #7 — "où en es-tu ?" during an active run: status in conversatio
566
566
  }
567
567
 
568
568
  try {
569
- const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
569
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
570
570
  method: 'POST',
571
571
  headers: { 'Content-Type': 'application/json' },
572
572
  body: JSON.stringify({ action: 'message', input: 'où en es-tu ?' }),
@@ -1,6 +1,7 @@
1
1
  import { parseJsonText } from '../core/activity.js';
2
2
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
3
  import { formatMcpToolResult, callMcpTool as defaultCallMcpTool } from '../core/mcp.js';
4
+ import { createCapabilityRegistry } from '../orchestrator/capabilityRegistry.js';
4
5
  import { accept as acceptResult } from '../orchestrator/resultAggregator.js';
5
6
 
6
7
  const ACTIVE_TASK_STATUSES = new Set(['running', 'queued', 'starting', 'assigned']);
@@ -24,9 +25,11 @@ export async function recoverActiveRuns({
24
25
  for (const run of runs) {
25
26
  const tasks = store.listTasks?.({ runId: run.id }) ?? [];
26
27
  const activeTasks = tasks.filter((task) => ACTIVE_TASK_STATUSES.has(String(task.status ?? '').toLowerCase()));
28
+ const runOutcomes = [];
27
29
  for (const task of activeTasks) {
28
30
  try {
29
31
  const outcome = await recoverTask({ store, session, run, task, callTool, resultAggregator });
32
+ runOutcomes.push(outcome ?? null);
30
33
  if (outcome?.status === 'recovered') recovered.push(outcome);
31
34
  else if (outcome?.status === 'rescheduled') rescheduled.push(outcome);
32
35
  else if (outcome?.status === 'interrupted') interrupted.push(outcome);
@@ -34,6 +37,21 @@ export async function recoverActiveRuns({
34
37
  errors.push({ runId: run.id, taskId: task.id, error: error instanceof Error ? error.message : String(error) });
35
38
  }
36
39
  }
40
+ // A run in which nothing was recovered or rescheduled can never progress:
41
+ // leaving it 'running' in the store would re-attach it as a zombie on
42
+ // every subsequent boot. Close it for good.
43
+ if (activeTasks.length > 0 && runOutcomes.length === activeTasks.length
44
+ && runOutcomes.every((outcome) => outcome?.status === 'interrupted')) {
45
+ const changed = store.interruptRuns?.({ workspace: run.workspace ?? null, runId: run.id, reason: 'Recovery found no recoverable task.' }) ?? 0;
46
+ if (changed > 0) {
47
+ dispatch(session, store, 'runtime_log', {
48
+ origin: 'recovery_manager',
49
+ runId: run.id,
50
+ workspace: run.workspace ?? workspaceFromSession(session),
51
+ payload: { message: `recovery: run ${run.id} interrupted (no recoverable task)` },
52
+ });
53
+ }
54
+ }
37
55
  }
38
56
 
39
57
  return {
@@ -46,6 +64,28 @@ export async function recoverActiveRuns({
46
64
  }
47
65
 
48
66
  async function recoverTask({ store, session, run, task, callTool, resultAggregator }) {
67
+ // A task whose capability no longer resolves can never be dispatched:
68
+ // re-attaching it would recreate the forever-waiting queue it came from.
69
+ // Fail it explicitly instead. No registry information at all (discovery
70
+ // not run yet) keeps the current behavior.
71
+ if (task.requiredCapability && !capabilityResolvable(session, task.requiredCapability)) {
72
+ dispatch(session, store, 'plan_step_updated', {
73
+ origin: 'recovery_manager',
74
+ runId: run.id,
75
+ taskId: task.id,
76
+ workspace: run.workspace ?? workspaceFromSession(session),
77
+ payload: {
78
+ taskId: task.id,
79
+ status: 'failed',
80
+ recovery: {
81
+ reason: 'unresolvable_capability',
82
+ capability: task.requiredCapability,
83
+ },
84
+ },
85
+ });
86
+ return interruptTask({ store, session, run, task, reason: `unresolvable capability: ${task.requiredCapability}` });
87
+ }
88
+
49
89
  const attempt = latestAttempt(store.listTaskAttempts?.({ taskId: task.id }) ?? []);
50
90
  const assignment = latestAssignment(store.listTaskAssignments?.({ taskId: task.id }) ?? [], attempt?.attemptId);
51
91
  if (!attempt?.jobId || !assignment?.agentInstanceId) {
@@ -126,6 +166,20 @@ function isTerminal(status) {
126
166
  return TERMINAL_STATUSES.has(String(status ?? '').toLowerCase());
127
167
  }
128
168
 
169
+ function capabilityResolvable(session, capability) {
170
+ const registry = session.capabilityRegistry
171
+ ?? ((session.agentRegistrySnapshot ?? []).length > 0
172
+ ? createCapabilityRegistry({ agents: session.agentRegistrySnapshot })
173
+ : null);
174
+ if (!registry || typeof registry.providersFor !== 'function') return true;
175
+ // Only trust a registry that actually knows about capabilities. An empty
176
+ // one (discovery not finished, or agents described without capability
177
+ // lists) cannot distinguish "nothing provides X" from "no information".
178
+ const snapshot = typeof registry.snapshot === 'function' ? registry.snapshot() : {};
179
+ if (Object.keys(snapshot ?? {}).length === 0) return true;
180
+ return registry.providersFor(capability).length > 0;
181
+ }
182
+
129
183
  function parseToolPayload(result) {
130
184
  if (result && typeof result === 'object' && !Array.isArray(result) && !Array.isArray(result.content)) return result;
131
185
  return parseJsonText(formatMcpToolResult(result)) ?? {};