@dotdrelle/wiki-manager 0.15.55 → 0.15.57

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.55",
3
+ "version": "0.15.57",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1026,11 +1026,18 @@ export function connectorConfigurationTarget(session, objective) {
1026
1026
  .filter((message) => message?.role === 'user')
1027
1027
  .map((message) => String(message?.content ?? ''))
1028
1028
  .join(' ');
1029
- const text = `${recentContext} ${String(objective ?? '')}`.trim().toLowerCase();
1030
- // `connect` used to match the noun "connector" as a substring. Production
1031
- // skills mention an optional messaging connector, so that broad match could
1032
- // misclassify a business run as connector setup and reject delegation.
1033
- if (!/(?:configur|\bconnect(?:ed|ing|ion|ions)?\b|authent|oauth|setup|sign[ -]?in|\bpat\b|api[ _-]?token|credential|identifiant|mot de passe|password)/i.test(text)) return null;
1029
+ const objectiveText = String(objective ?? '').trim().toLowerCase();
1030
+ // The configuration keyword must describe the objective being delegated NOW,
1031
+ // not something the user said earlier in the session. Matching it against
1032
+ // recent conversation context made a stale "configurer le CME" message poison
1033
+ // every later delegation — an unrelated `/wiki-ingest` objective was rejected
1034
+ // as connector setup. Recent context is used only to resolve WHICH connector
1035
+ // the current objective refers to. (`connect` used to match the noun
1036
+ // "connector" as a substring, and production skills mention an optional
1037
+ // messaging connector; the narrowed word-boundary form keeps that from
1038
+ // misclassifying a business run as connector setup.)
1039
+ if (!/(?:configur|\bconnect(?:ed|ing|ion|ions)?\b|authent|oauth|setup|sign[ -]?in|\bpat\b|api[ _-]?token|credential|identifiant|mot de passe|password)/i.test(objectiveText)) return null;
1040
+ const contextText = `${recentContext} ${objectiveText}`.trim().toLowerCase();
1034
1041
  for (const [serverName, server] of Object.entries(session?.mcp ?? {})) {
1035
1042
  if (server?.status !== 'connected' || !Array.isArray(server.tools) || server.tools.length === 0) continue;
1036
1043
  const genericAliasParts = new Set([
@@ -1043,7 +1050,7 @@ export function connectorConfigurationTarget(session, objective) {
1043
1050
  ]
1044
1051
  .flatMap((value) => String(value).toLowerCase().split(/[^a-z0-9]+/))
1045
1052
  .filter((part) => part.length >= 3 && !genericAliasParts.has(part));
1046
- if (!aliases.some((alias) => text.includes(alias))) continue;
1053
+ if (!aliases.some((alias) => contextText.includes(alias))) continue;
1047
1054
  const setupTool = server.tools.find((tool) => {
1048
1055
  const name = String(tool?.name ?? '').toLowerCase();
1049
1056
  const description = String(tool?.description ?? '').toLowerCase();
@@ -1182,7 +1189,7 @@ export function buildAgentSystemPrompt(state) {
1182
1189
  skills,
1183
1190
  '</skill_catalog>',
1184
1191
  runningSkillStack.length > 0
1185
- ? `You are already executing the compiled objective of workspace skill ${JSON.stringify(runningSkillStack.at(-1))}. Execute the objective in the current user message with the available direct tools${runningSkillExecution === 'direct' ? ' and stop after its requested direct mutation; delegation and nested skills are forbidden for this workflow' : ' or capability delegation'}. Do not select or call that skill again, with or without a leading slash. A skill run is not successful until its requested mutation has an affirmative tool result; never infer success from the runtime merely becoming idle or done.`
1192
+ ? `You are already executing the compiled objective of workspace skill ${JSON.stringify(runningSkillStack.at(-1))}. The current user message IS that objective: execute it directly${runningSkillExecution === 'direct' ? ' and stop after its requested direct mutation; delegation and nested skills are forbidden for this workflow' : ' by delegating it with runtime__delegate (or a matching direct tool)'}. Do not select or call that skill again, with or without a leading slash — the runtime refuses the re-invocation with skill_recursion_blocked, and that refusal means act on the objective yourself, not report an error. A skill run is not successful until its requested mutation has an affirmative tool result; never infer success from the runtime merely becoming idle or done, and never end the run with an empty reply or a bare "{}".`
1186
1193
  : null,
1187
1194
  'In interactive agent mode, call only tools actually provided to you. Any directly offered tool stays direct; never substitute an orchestration-contract tool yourself.',
1188
1195
  'When the user asks for an action that can be performed with connected MCP tools or safe primitives, do not answer with future intent such as "I will call...", "I am going to run...", or "launching..." unless you also call the tool in the same turn. Either call the tool now, ask for the exact missing required arguments, or explain the concrete blocker.',
@@ -1219,6 +1226,7 @@ export function buildAgentSystemPrompt(state) {
1219
1226
  'When an action fails or is refused for lack of an authorization grant or scope (rather than a missing capability), say exactly that and name the primitive that grants it. Do not describe the feature as unavailable.',
1220
1227
  'For an action with no matching direct tool, call runtime__delegate with the user objective only. The runtime chooses the capability, operation, agent and plan, including a validated single task for executor-only agents. Never choose those identifiers yourself. Never call <provider>__agent_plan, <provider>__agent_execute, legacy production__production_start_job, wiki__plan_set, or wiki__plan_done from interactive chat.',
1221
1228
  'Do not ask the user which sources, files, connectors, or templates to use for an ingest, build, or export: the specialized agent discovers them from the workspace. When the objective is clear (e.g. "lance une ingestion"), delegate it as stated, without a clarifying question.',
1229
+ 'Templates are instruction-only specs and deliverables are regenerated from them. When asked to change what a generated document says, edit the underlying wiki content (wiki_write_page) or the template\'s [[INSTRUCTION: ...]] sections — never write finished prose into a template, because a build copies it verbatim and it can no longer be refreshed from the wiki. template_write refuses prose outside an instruction block, so keep every sentence inside one.',
1222
1230
  'Promise only what the resolved capability actually exposes in its declared contract (the input schema the specialized agent publishes for that capability). When the user requests an execution parameter — a batch or chunk size, a count "N at a time", concurrency, ordering, priority, or any tuning knob — apply it only if that parameter exists in the target capability\'s published input schema. Otherwise do not confirm or promise it: delegate the objective, and if the user explicitly asked for that parameter, say plainly in one line that you started the work but do not control that aspect (the runtime and the specialized agent decide it). Never state or imply a parameter was applied when the agent contract cannot enforce it.',
1223
1231
  'If runtime__delegate returns a blocker or no specialized provider is available, report only that concrete blocker concisely. Never replace the missing execution path with a suggested slash command, skill, MCP tool name, manual file move, administrator escalation, or alternative workflow unless the user explicitly asks for alternatives.',
1224
1232
  'For workspace inventory and page listings, use the connected wiki MCP read tools. Never invent or call a /wiki shell command through shell__run_command. Use /workspace init <name> [path] for low-level non-interactive workspace creation; in the interactive TUI, /new <name> opens the setup wizard.',
@@ -62,6 +62,27 @@ test('a connection problem still routes to connector configuration', () => {
62
62
  assert.deepEqual(target, { serverName: 'acme', setupTool: 'acme_auth' });
63
63
  });
64
64
 
65
+ test('a stale configure-connector message does not poison a later business delegation', () => {
66
+ const target = connectorConfigurationTarget({
67
+ agentProjection: {
68
+ conversation: [
69
+ { role: 'user', content: 'donne moi la config du cme' },
70
+ { role: 'user', content: 'liste les pages wiki configurer pour l’agent cme' },
71
+ ],
72
+ },
73
+ mcp: {
74
+ cme: {
75
+ status: 'connected',
76
+ tools: [
77
+ { name: 'cme_setup', description: 'Configure Confluence credentials.' },
78
+ { name: 'cme_export_run', description: 'Run export.' },
79
+ ],
80
+ },
81
+ },
82
+ }, 'Ingest pending staged Markdown files from raw/untracked into the wiki.');
83
+ assert.equal(target, null);
84
+ });
85
+
65
86
  test('Donna cannot answer an explicit action with manual instructions instead of delegating', async () => {
66
87
  const originalFetch = globalThis.fetch;
67
88
  let delegated = false;
@@ -29,6 +29,7 @@ import { createWorkspace, findWorkspace, listWorkspaces } from '../core/workspac
29
29
  import { findSkill, inspectSkills, listSkills } from '../core/skills.js';
30
30
  import { extractActivity, formatActivityError, formatActivityLine, formatActivitySummary, parseJsonText } from '../core/activity.js';
31
31
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
32
+ import { emitRuntimeLog } from '../runtime/supervisor.js';
32
33
  import {
33
34
  cancelQueueItem,
34
35
  clearFinishedQueueItems,
@@ -612,20 +613,53 @@ function publishDocumentActivity(session, activity) {
612
613
  return publishPayloadActivity(session, { _activity: activity }, { server: 'documents', tool: 'documents_convert_to_markdown' });
613
614
  }
614
615
 
615
- export async function refreshMcpRuntimeStatus(session) {
616
- session.mcp = buildMcpStatus(session);
617
- if (!session.workspacePath) return null;
616
+ export async function refreshMcpRuntimeStatus(session, deps = {}) {
617
+ const getStates = deps.serviceStates ?? serviceStates;
618
+ const discover = deps.discoverMcpTools ?? discoverMcpTools;
619
+ const previousMcp = session.mcp;
620
+ const base = buildMcpStatus(session);
621
+ if (!session.workspacePath) {
622
+ session.mcp = base;
623
+ return null;
624
+ }
625
+ // Build the next status in local variables and assign `session.mcp` only at
626
+ // the end. Assigning `buildMcpStatus` first (status "configured", no tools)
627
+ // and then awaiting `serviceStates`/`discoverMcpTools` exposed a window in
628
+ // which a background re-scan had downgraded `production` to "configured",
629
+ // and the dispatcher — reading `session.mcp` mid-refresh — refused the next
630
+ // `agent_execute` with "MCP is not connected: production" while the agent
631
+ // was actually up.
618
632
  try {
619
- const states = await serviceStates(session);
620
- session.mcp = applyMcpRuntimeStatus(session.mcp, states);
621
- session.mcp = await discoverMcpTools(session.mcp);
633
+ const states = await getStates(session);
634
+ session.mcp = await discover(applyMcpRuntimeStatus(base, states), previousMcp);
635
+ reportNewlyDegradedMcp(session, previousMcp);
622
636
  return states;
623
637
  } catch {
624
- session.mcp = await discoverMcpTools(session.mcp);
638
+ session.mcp = await discover(base, previousMcp);
639
+ reportNewlyDegradedMcp(session, previousMcp);
625
640
  return null;
626
641
  }
627
642
  }
628
643
 
644
+ /**
645
+ * "Degraded" (`discoverMcpTools`, mcp.js) means an endpoint is reporting
646
+ * "connected" only because a prior cycle was, not because Docker or the
647
+ * latest probe confirm it now — the preservation that endpoint's
648
+ * `keepConnected` branch exists for, deliberately not reverted here since a
649
+ * transient probe blip must not flip a live run's endpoint away. But
650
+ * preserving it in silence is exactly the "stale status kept forever"
651
+ * pattern already fixed once in agentRegistry.js: report it, edge-triggered
652
+ * on the transition into the degraded state, so it does not repeat every
653
+ * re-scan while it persists.
654
+ */
655
+ function reportNewlyDegradedMcp(session, previousMcp) {
656
+ for (const [name, entry] of Object.entries(session.mcp ?? {})) {
657
+ if (!entry?.degraded || previousMcp?.[name]?.degraded) continue;
658
+ emitRuntimeLog(session, `mcp: ${name} still reports "connected" only from a prior probe`
659
+ + ` (${entry.toolError ?? 'no detail'}); Docker/the latest probe no longer confirm it.`);
660
+ }
661
+ }
662
+
629
663
  async function statusText(session) {
630
664
  const states = await refreshMcpRuntimeStatus(session);
631
665
  const workspaceStats = collectWorkspaceStats(session);
@@ -1557,7 +1591,12 @@ export async function handleSlashCommand(line, context) {
1557
1591
  const wikiArgs = args.slice(2);
1558
1592
  if (wikiArgs.length === 0) return { output: 'Usage: /wiki run <args...>' };
1559
1593
  step(`Wiki: running ${wikiArgs.join(' ')}…`);
1594
+ // Steps that drive LLM work (taxonomy, build, ingest, export, polish)
1595
+ // legitimately run for minutes: the production agent gives them a
1596
+ // 600s LLM timeout, so the raw hatch must not cut them at 180s.
1597
+ const longRunning = new Set(['taxonomy', 'build', 'ingest', 'export', 'polish', 'pipeline']);
1560
1598
  const output = await runWikiCli(context.session, wikiArgs, {
1599
+ timeout: longRunning.has(wikiArgs[0] ?? '') ? 600_000 : 180_000,
1561
1600
  onOutput: (line) => step(`Wiki: ${line}`),
1562
1601
  });
1563
1602
  const activity = formatActivitySummary('wiki', wikiArgs[0] ?? 'run', output);
@@ -4,7 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import test from 'node:test';
7
- import { agentConcurrencySections, compactBaseUrl, compactMcpStatus, handleSlashCommand, localizedOperationResult } from './slash.js';
7
+ import { agentConcurrencySections, compactBaseUrl, compactMcpStatus, handleSlashCommand, localizedOperationResult, refreshMcpRuntimeStatus } from './slash.js';
8
8
  import { completionContext } from '../shell/repl.js';
9
9
 
10
10
  test('deterministic operation results ask Donna to localize compact facts without leaking commands', () => {
@@ -437,3 +437,59 @@ test('/queue cancel reports unknown ids that are not runtime-managed', async ()
437
437
  });
438
438
  assert.match(result.output ?? '', /Unknown queue item/i);
439
439
  });
440
+
441
+ test('refreshMcpRuntimeStatus does not expose an intermediate configured status', async () => {
442
+ const session = {
443
+ workspacePath: '/tmp/ws',
444
+ workspaceEnv: { PRODUCTION_MCP_PORT: '3202', PRODUCTION_MCP_AUTH_TOKEN: 'token' },
445
+ wikircConfig: {},
446
+ mcp: { production: { status: 'connected', tools: [{ name: 'production__agent_execute' }] } },
447
+ };
448
+ const originalMcp = session.mcp;
449
+ let resolveStates;
450
+ const statesPromise = new Promise((resolve) => { resolveStates = resolve; });
451
+ const pending = refreshMcpRuntimeStatus(session, {
452
+ serviceStates: () => statesPromise,
453
+ discoverMcpTools: async (mcp) => mcp,
454
+ });
455
+ // While serviceStates is still in flight, the dispatcher-facing status must
456
+ // keep the previous connected snapshot — never the fresh "configured" base.
457
+ assert.equal(session.mcp, originalMcp, 'session.mcp reassigned mid-refresh');
458
+ resolveStates({ 'production-mcp': { running: true } });
459
+ await pending;
460
+ assert.equal(session.mcp.production.status, 'connected');
461
+ });
462
+
463
+ test('refreshMcpRuntimeStatus reports a degraded MCP endpoint once, not on every re-scan', async () => {
464
+ const session = {
465
+ workspacePath: '/tmp/ws',
466
+ workspaceEnv: { PRODUCTION_MCP_PORT: '3202', PRODUCTION_MCP_AUTH_TOKEN: 'token' },
467
+ wikircConfig: {},
468
+ mcp: { production: { status: 'connected', tools: [] } },
469
+ };
470
+ const degradedRuns = (n) => async () => ({
471
+ production: { status: 'connected', tools: [], toolError: `probe ${n} failed`, degraded: true },
472
+ });
473
+ const deps = { serviceStates: async () => ({}) };
474
+
475
+ await refreshMcpRuntimeStatus(session, { ...deps, discoverMcpTools: degradedRuns(1) });
476
+ await refreshMcpRuntimeStatus(session, { ...deps, discoverMcpTools: degradedRuns(2) });
477
+ await refreshMcpRuntimeStatus(session, { ...deps, discoverMcpTools: degradedRuns(3) });
478
+
479
+ const reports = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
480
+ && String(event.payload?.message ?? '').includes('mcp: production'));
481
+ assert.equal(reports.length, 1, 'a degraded endpoint is reported once, not once per re-scan');
482
+
483
+ // Recovers, then degrades again: reported a second time.
484
+ await refreshMcpRuntimeStatus(session, {
485
+ ...deps,
486
+ discoverMcpTools: async () => ({ production: { status: 'connected', tools: [], degraded: false } }),
487
+ });
488
+ await refreshMcpRuntimeStatus(session, { ...deps, discoverMcpTools: degradedRuns(4) });
489
+ assert.equal(
490
+ (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
491
+ && String(event.payload?.message ?? '').includes('mcp: production')).length,
492
+ 2,
493
+ );
494
+ });
495
+
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.55",
3
- "commit": "31eddf4"
2
+ "version": "0.15.57",
3
+ "commit": "8c83b75"
4
4
  }
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.15.55';
4
+ const WIKI_MANAGER_VERSION = '0.15.57';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -585,7 +585,7 @@ function retryDelay(ms, signal) {
585
585
  });
586
586
  }
587
587
 
588
- export async function discoverMcpTools(mcpStatus) {
588
+ export async function discoverMcpTools(mcpStatus, previous = null) {
589
589
  const next = {};
590
590
  await Promise.all(Object.entries(mcpStatus ?? {}).map(async ([name, value]) => {
591
591
  if (value.status === 'missing') {
@@ -607,11 +607,26 @@ export async function discoverMcpTools(mcpStatus) {
607
607
  };
608
608
  } catch (err) {
609
609
  const message = err instanceof Error ? err.message : String(err);
610
+ // A transient probe failure during a background re-scan must not degrade
611
+ // a live endpoint. Docker service state (applyMcpRuntimeStatus) is the
612
+ // authority on whether the container is up; this probe is only a tool
613
+ // refresh, so keep last-known-good status and tools instead of flipping
614
+ // an in-flight run's endpoint to "not connected".
615
+ const prior = previous?.[name];
616
+ const freshlyConnected = value.status === 'connected';
617
+ const keepConnected = freshlyConnected || prior?.status === 'connected';
618
+ // `degraded` marks the case Docker itself no longer confirms as
619
+ // connected (freshlyConnected is false) and only prior history keeps
620
+ // this endpoint reporting "connected" — the preservation this whole
621
+ // branch exists for, worth surfacing rather than masking indefinitely.
622
+ // `refreshMcpRuntimeStatus` (slash.js) owns the edge-triggered log, by
623
+ // comparing this flag against the previous cycle's.
610
624
  next[name] = {
611
625
  ...value,
612
- status: value.status === 'connected' ? 'configured' : value.status,
613
- tools: [],
626
+ status: keepConnected ? 'connected' : value.status,
627
+ tools: keepConnected ? (prior?.tools ?? []) : [],
614
628
  toolError: message,
629
+ degraded: keepConnected && !freshlyConnected,
615
630
  };
616
631
  }
617
632
  }));
@@ -501,7 +501,7 @@ test('resolveRetryPolicy supports endpoint and tool overrides', () => {
501
501
  assert.deepEqual(policy, { maxAttempts: 4, backoffMs: 100 });
502
502
  });
503
503
 
504
- test('discoverMcpTools downgrades connected endpoint when tool discovery fails', async () => {
504
+ test('discoverMcpTools keeps a connected endpoint connected when tool discovery fails', async () => {
505
505
  const originalFetch = globalThis.fetch;
506
506
  globalThis.fetch = async () => ({
507
507
  ok: false,
@@ -519,7 +519,7 @@ test('discoverMcpTools downgrades connected endpoint when tool discovery fails',
519
519
  },
520
520
  });
521
521
 
522
- assert.equal(status.wiki.status, 'configured');
522
+ assert.equal(status.wiki.status, 'connected');
523
523
  assert.equal(status.wiki.tools.length, 0);
524
524
  assert.match(status.wiki.toolError, /401/);
525
525
  } finally {
@@ -527,6 +527,68 @@ test('discoverMcpTools downgrades connected endpoint when tool discovery fails',
527
527
  }
528
528
  });
529
529
 
530
+ test('discoverMcpTools preserves last-known-good status and tools across a failed re-probe', async () => {
531
+ const originalFetch = globalThis.fetch;
532
+ globalThis.fetch = async () => ({
533
+ ok: false,
534
+ status: 502,
535
+ headers: { get: () => null },
536
+ text: async () => 'Bad gateway',
537
+ });
538
+
539
+ try {
540
+ const status = await discoverMcpTools(
541
+ {
542
+ production: {
543
+ status: 'configured',
544
+ url: 'http://127.0.0.1:3202/mcp/',
545
+ token: 'token',
546
+ },
547
+ },
548
+ {
549
+ production: {
550
+ status: 'connected',
551
+ tools: [{ name: 'production__agent_execute' }, { name: 'production__agent_status' }],
552
+ },
553
+ },
554
+ );
555
+
556
+ assert.equal(status.production.status, 'connected');
557
+ assert.deepEqual(status.production.tools.map((tool) => tool.name), [
558
+ 'production__agent_execute',
559
+ 'production__agent_status',
560
+ ]);
561
+ assert.match(status.production.toolError, /502/);
562
+ // Docker (`value.status`) no longer confirms this endpoint; only prior
563
+ // history keeps it "connected". `degraded` is what lets the caller
564
+ // (refreshMcpRuntimeStatus) tell this apart from a genuinely fresh probe
565
+ // success, so it can report the preservation instead of masking it.
566
+ assert.equal(status.production.degraded, true);
567
+ } finally {
568
+ globalThis.fetch = originalFetch;
569
+ }
570
+ });
571
+
572
+ test('discoverMcpTools does not mark a fresh probe failure on an already-connected endpoint as degraded', async () => {
573
+ const originalFetch = globalThis.fetch;
574
+ globalThis.fetch = async () => ({
575
+ ok: false,
576
+ status: 401,
577
+ headers: { get: () => null },
578
+ text: async () => '{"error":"invalid or missing bearer token"}',
579
+ });
580
+
581
+ try {
582
+ const status = await discoverMcpTools({
583
+ wiki: { status: 'connected', url: 'http://127.0.0.1:3201/mcp', token: 'token' },
584
+ });
585
+ assert.equal(status.wiki.status, 'connected');
586
+ assert.equal(status.wiki.degraded, false);
587
+ } finally {
588
+ globalThis.fetch = originalFetch;
589
+ }
590
+ });
591
+
530
592
  // A stateful server may reject any cold request, and every SDK words that
531
593
  // rejection differently ("No valid session" for the Node SDK, "Missing session
532
594
  // ID" for the Python one CME runs). The client must never read that prose: it
@@ -62,6 +62,11 @@ export function createAgentRegistry({
62
62
  } = {}) {
63
63
  const agentsByInstance = new Map();
64
64
  const instanceByServer = new Map();
65
+ // Whether the LAST probe of an instance failed. The "did not answer
66
+ // agent_describe" log is edge-triggered: it is emitted once when an instance
67
+ // stops answering, not on every re-scan while it stays down. A stopped agent
68
+ // is not an error to repeat every minute.
69
+ const lastProbeFailed = new Map();
65
70
 
66
71
  return {
67
72
  async discover(session, { signal = null } = {}) {
@@ -70,13 +75,18 @@ export function createAgentRegistry({
70
75
  const activeServers = new Set(endpoints.map(([serverName]) => serverName));
71
76
  for (const [serverName, endpoint] of endpoints) {
72
77
  const agent = await discoverServerAgent(session, serverName, endpoint, { callTool, signal, now });
73
- discovered.push(registerAgent(session, agent, { agentsByInstance, instanceByServer }));
78
+ discovered.push(registerAgent(session, agent, { agentsByInstance, instanceByServer, lastProbeFailed }));
74
79
  }
75
80
  for (const [serverName, instanceId] of instanceByServer) {
76
81
  if (activeServers.has(serverName)) continue;
77
82
  const previous = agentsByInstance.get(instanceId);
78
83
  instanceByServer.delete(serverName);
79
84
  agentsByInstance.delete(instanceId);
85
+ // Same cleanup as the two maps above: without it, a long-running
86
+ // process that sees many renamed/reconnected connectors (the
87
+ // Connectors panel supports exactly this) accumulates one stale
88
+ // entry per retired instance for the process lifetime.
89
+ lastProbeFailed.delete(instanceId);
80
90
  if (previous) dispatchRegistryEvent(session, 'agent.unregistered', {
81
91
  agentInstanceId: instanceId,
82
92
  serverName,
@@ -140,7 +150,7 @@ async function discoverServerAgent(session, serverName, endpoint = {}, { callToo
140
150
  }
141
151
  }
142
152
 
143
- function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
153
+ function registerAgent(session, agent, { agentsByInstance, instanceByServer, lastProbeFailed }) {
144
154
  const previousInstanceId = instanceByServer.get(agent.serverName);
145
155
  const previous = previousInstanceId ? agentsByInstance.get(previousInstanceId) : null;
146
156
 
@@ -166,13 +176,21 @@ function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
166
176
  the blindness — a probe that failed is a fact worth stating, once, where
167
177
  the panels and the shell already read.
168
178
 
179
+ "Once" is the operative word: the re-scan runs every minute, and a stopped
180
+ agent is not an error to repeat each time it is scanned. The log is
181
+ edge-triggered on the transition from answering to not answering.
182
+
169
183
  Deliberately NOT a health change: the endpoint is down but the agent stays
170
184
  usable by design here, and moving `health` would make `capabilityResolver`
171
185
  refuse it — trading a silent loss for a silent refusal.
172
- */
173
- dispatchRuntimeLog(session, `agent-registry: ${agent.serverName} did not answer agent_describe`
174
- + `${agent.error ? ` (${agent.error})` : ''}; keeping its known capabilities`
175
- + ` (${(previous.description?.capabilities ?? []).map((capability) => capability.id).join(', ') || 'none'}).`);
186
+ */
187
+ const wasAnswering = lastProbeFailed.get(previous.agentInstanceId) !== true;
188
+ lastProbeFailed.set(previous.agentInstanceId, true);
189
+ if (wasAnswering) {
190
+ dispatchRuntimeLog(session, `agent-registry: ${agent.serverName} did not answer agent_describe`
191
+ + `${agent.error ? ` (${agent.error})` : ''}; keeping its known capabilities`
192
+ + ` (${(previous.description?.capabilities ?? []).map((capability) => capability.id).join(', ') || 'none'}).`);
193
+ }
176
194
  return cloneAgent(previous);
177
195
  }
178
196
 
@@ -187,6 +205,7 @@ function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
187
205
  }
188
206
  agentsByInstance.set(next.agentInstanceId, next);
189
207
  instanceByServer.set(next.serverName, next.agentInstanceId);
208
+ if (lastProbeFailed) lastProbeFailed.set(next.agentInstanceId, false);
190
209
 
191
210
  if (!previous || previous.agentInstanceId !== next.agentInstanceId) {
192
211
  dispatchRegistryEvent(session, 'agent.registered', { agent: next });
@@ -209,6 +209,47 @@ test('a failed re-discovery keeps a degraded orchestrator agent too', async () =
209
209
  assert.equal(agent.description.capabilities.length, 1);
210
210
  });
211
211
 
212
+ test('a stopped agent is reported once, not on every re-scan', async () => {
213
+ // The re-scan runs every minute; a deliberately stopped agent must not flood
214
+ // the log with the same "did not answer" line. The message is edge-triggered:
215
+ // emitted on the transition from answering to not answering, then silent
216
+ // until it answers again.
217
+ const events = [];
218
+ const session = {
219
+ workspace: 'acpi',
220
+ mcp: { production: { status: 'connected', tools: [{ name: 'agent_describe' }] } },
221
+ _onAgentEvent: (event) => events.push(event),
222
+ };
223
+ let down = false;
224
+ const registry = createAgentRegistry({
225
+ callTool: async () => {
226
+ if (down) throw new Error('fetch failed');
227
+ return { content: [{ type: 'text', text: JSON.stringify(description()) }] };
228
+ },
229
+ });
230
+
231
+ await registry.discover(session);
232
+ down = true;
233
+ await registry.discover(session);
234
+ await registry.discover(session);
235
+ await registry.discover(session);
236
+
237
+ const reports = events.filter((event) => event.type === 'runtime_log'
238
+ && String(event.payload?.message ?? '').includes('agent-registry:'));
239
+ assert.equal(reports.length, 1, 'the down agent is reported once, not once per scan');
240
+
241
+ // It answers again, then drops again: the next failure is reported again.
242
+ down = false;
243
+ await registry.discover(session);
244
+ down = true;
245
+ await registry.discover(session);
246
+ assert.equal(
247
+ events.filter((event) => event.type === 'runtime_log'
248
+ && String(event.payload?.message ?? '').includes('agent-registry:')).length,
249
+ 2,
250
+ );
251
+ });
252
+
212
253
  test('discovery sends the workspace only to agents whose schema declares it', async () => {
213
254
  const seen = {};
214
255
  const registry = createAgentRegistry({
@@ -245,6 +245,82 @@ function task(id, overrides = {}) {
245
245
  };
246
246
  }
247
247
 
248
+ /*
249
+ Cas observé le 2026-08-22 (workspace acpi) : `/wiki-ingest` planifie 13
250
+ ingest_plan (groupe `ingest`) + 13 ingest_apply (groupe `apply`, sérialisés
251
+ sur le lock `workspace-write`, derrière la barrière `ingest`) + 1 taxonomy
252
+ (barrière `apply`). Le grant run-scope émis par le bouton Approve est « nu » :
253
+ `approvalClasses: []`, `planRevision: null`. Après la fin des 13 ingest_plan,
254
+ le scheduler a déclaré `no_ready_plan_task` au lieu de démarrer les apply.
255
+
256
+ Ce test verrouille la couverture du grant nu : les apply `waiting_approval`
257
+ DOIVENT redevenir ready quand le grant run-scope (même sans classes ni
258
+ révision) les couvre.
259
+ */
260
+ test('un grant run-scope « nu » (sans classes ni révision) débloque les apply derrière une barrière', () => {
261
+ const plan = {
262
+ runId: 'run-1',
263
+ workspace: 'acpi',
264
+ planRevision: 1,
265
+ tasks: [
266
+ // 13 ingest_plan du groupe ingest, tous done.
267
+ ...Array.from({ length: 13 }, (_, i) => task(`ingest-plan-${i}`, {
268
+ groupId: 'ingest',
269
+ status: 'done',
270
+ requiredCapability: 'knowledge.update',
271
+ operation: 'ingest_plan',
272
+ })),
273
+ // 13 apply : groupe apply, barrière ingest, lock workspace-write.
274
+ ...Array.from({ length: 13 }, (_, i) => task(`ingest-apply-${i}`, {
275
+ groupId: 'apply',
276
+ dependsOnGroup: 'ingest',
277
+ barrier: true,
278
+ dependsOn: [`ingest-plan-${i}`],
279
+ status: 'waiting_approval',
280
+ requiredCapability: 'knowledge.update',
281
+ operation: 'ingest_apply',
282
+ locks: ['workspace-write'],
283
+ parallelizable: false,
284
+ requiresApproval: true,
285
+ approvalClass: 'mutation',
286
+ priority: i + 1,
287
+ })),
288
+ task('taxonomy', {
289
+ dependsOnGroup: 'apply',
290
+ barrier: true,
291
+ status: 'waiting_approval',
292
+ requiredCapability: 'knowledge.update',
293
+ operation: 'taxonomy',
294
+ requiresApproval: true,
295
+ approvalClass: 'mutation',
296
+ }),
297
+ ],
298
+ };
299
+
300
+ // Sans grant : rien n'est ready.
301
+ assert.deepEqual(readyTasks(plan).map((item) => item.id), []);
302
+
303
+ // Grant run-scope « nu » — exactement ce que le bouton Approve émet.
304
+ // La couverture ne bloque pas : les 13 apply sont tous prêts.
305
+ const ready = readyTasks(plan, {
306
+ approvals: [{
307
+ status: 'approved',
308
+ scope: 'run',
309
+ runId: 'run-1',
310
+ workspaceId: 'acpi',
311
+ planRevision: null,
312
+ approvalClasses: [],
313
+ }],
314
+ });
315
+
316
+ assert.deepEqual(ready.map((item) => item.id), [
317
+ 'ingest-apply-0', 'ingest-apply-1', 'ingest-apply-2', 'ingest-apply-3',
318
+ 'ingest-apply-4', 'ingest-apply-5', 'ingest-apply-6', 'ingest-apply-7',
319
+ 'ingest-apply-8', 'ingest-apply-9', 'ingest-apply-10', 'ingest-apply-11',
320
+ 'ingest-apply-12',
321
+ ]);
322
+ });
323
+
248
324
  /*
249
325
  Cas observé le 2026-08-04 (workspace juno) : une ingestion de dix fichiers,
250
326
  neuf réussis, le dixième en échec sur du JSON malformé. La barrière de groupe
@@ -7,7 +7,7 @@ import { createAssignmentManager } from '../orchestrator/assignmentManager.js';
7
7
  import { createAttemptManager } from '../orchestrator/attemptManager.js';
8
8
  import { createBudgetManager, BudgetExceededError } from '../orchestrator/budgetManager.js';
9
9
  import { createDispatcher } from '../orchestrator/dispatcher.js';
10
- import { approvalRequestForTask } from '../orchestrator/approvalPolicy.js';
10
+ import { approvalCovered, approvalRequestForTask } from '../orchestrator/approvalPolicy.js';
11
11
  import { blockedByFailedDependency, tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
12
12
  import { isFailed, isPending, isSkipped, isSuccessful, isTerminal, isUnknownStatus } from '../orchestrator/taskStatuses.js';
13
13
  import { assertValidatedFragment } from '../orchestrator/planValidator.js';
@@ -616,8 +616,26 @@ export async function runRuntimeParallelPlan(agent, session, input, {
616
616
  // Any genuine approval-only block returned above. Remaining tasks are
617
617
  // unschedulable for another reason.
618
618
  const reason = 'no_ready_plan_task';
619
- emitRuntimeLog(session, `scheduler: stalled (${reason})`);
620
- return { ok: false, stalled: true, reason, completed: sessionActivities(session), failures };
619
+ const diagnostics = stallDiagnostics(session.headlessPlan ?? [], {
620
+ approvals: session.agentProjection?.approvals ?? session.approvals ?? [],
621
+ registry: session.capabilityRegistry ?? null,
622
+ lockManager: attempts,
623
+ runId,
624
+ workspaceId: session.workspace ?? null,
625
+ planRevision: session.planRevision ?? session.agentProjection?.planRevision ?? null,
626
+ });
627
+ emitRuntimeLog(session, `scheduler: stalled (${reason}) — ${diagnostics.length} pending task(s)`);
628
+ for (const line of diagnostics) {
629
+ emitRuntimeLog(session, `scheduler: stalled-detail ${line}`);
630
+ }
631
+ return {
632
+ ok: false,
633
+ stalled: true,
634
+ reason,
635
+ completed: sessionActivities(session),
636
+ failures,
637
+ stallDiagnostics: diagnostics,
638
+ };
621
639
  }
622
640
 
623
641
  const settled = await Promise.race([...active.values()].map((entry) => entry.promise));
@@ -687,6 +705,70 @@ export async function runRuntimeParallelPlan(agent, session, input, {
687
705
  exemple —, on s'arrête. Sans lui, une incohérence de statut se paierait en
688
706
  boucle infinie, c'est-à-dire en run figé : exactement ce qu'on répare.
689
707
  */
708
+ /**
709
+ * Diagnostic au moment d'un stall `no_ready_plan_task`.
710
+ *
711
+ * Le scheduler sait seulement « plus aucune tâche prête ». Pour distinguer
712
+ * « dépendance en attente » de « approbation non couverte » de « lock tenu »
713
+ * de « capability non résolue », on relit ici l'état RÉEL de chaque tâche
714
+ * pendante : statut, statut de chaque dépendance, état de la barrière de
715
+ * groupe, état des locks, couverture d'approbation, présence d'un fournisseur
716
+ * de capability. Une ligne par tâche, lisible dans les logs SSE et reportée
717
+ * dans le message du `run_error`.
718
+ */
719
+ function stallDiagnostics(plan, {
720
+ approvals = [],
721
+ registry = null,
722
+ lockManager = null,
723
+ runId = null,
724
+ workspaceId = null,
725
+ planRevision = null,
726
+ } = {}) {
727
+ const byId = new Map((plan ?? []).map((task) => [String(task.id ?? task.step), task]));
728
+ const lines = [];
729
+ for (const task of plan ?? []) {
730
+ if (!isPending(task.status)) continue;
731
+ const id = shortLogId(String(task.id ?? task.step ?? '?'));
732
+ const parts = [`status=${task.status}`];
733
+
734
+ const deps = Array.isArray(task.dependsOn) ? task.dependsOn : [];
735
+ if (deps.length > 0) {
736
+ parts.push(`dependsOn=${deps.map((dep) => {
737
+ const depTask = byId.get(String(dep));
738
+ return `${shortLogId(String(dep))}=${depTask ? depTask.status : 'missing'}`;
739
+ }).join(',')}`);
740
+ }
741
+
742
+ if (task.dependsOnGroup) {
743
+ const members = (plan ?? []).filter((candidate) => (candidate.groupId ?? candidate.group) === task.dependsOnGroup);
744
+ const terminal = members.filter((candidate) => isTerminal(candidate.status)).length;
745
+ parts.push(`dependsOnGroup=${shortLogId(String(task.dependsOnGroup))}(${terminal}/${members.length} terminal)`);
746
+ }
747
+
748
+ if (Array.isArray(task.locks) && task.locks.length > 0) {
749
+ const free = lockManager && typeof lockManager.canAcquire === 'function'
750
+ ? lockManager.canAcquire(task)
751
+ : 'n/a';
752
+ parts.push(`locks=[${task.locks.join(',')}] free=${free}`);
753
+ }
754
+
755
+ if (task.requiresApproval) {
756
+ const covered = approvalCovered(task, approvals, { runId, workspaceId, planRevision });
757
+ parts.push(`requiresApproval covered=${covered}`);
758
+ }
759
+
760
+ if (task.requiredCapability) {
761
+ const providers = registry && typeof registry.providersFor === 'function'
762
+ ? (registry.providersFor(task.requiredCapability) ?? [])
763
+ : [];
764
+ parts.push(`capability=${task.requiredCapability} providers=${providers.length}`);
765
+ }
766
+
767
+ lines.push(`${id}: ${parts.join(' | ')}`);
768
+ }
769
+ return lines;
770
+ }
771
+
690
772
  export function skipImpossibleTasks(session, runId, { maxPasses = 50 } = {}) {
691
773
  let total = 0;
692
774
  for (let pass = 0; pass < maxPasses; pass += 1) {
@@ -1340,9 +1422,12 @@ function replanTriggerFromLoopResult(result) {
1340
1422
  if (blocking.length > 0 && blocking.every(isBusyFailure)) {
1341
1423
  return null;
1342
1424
  }
1425
+ const diagnostics = Array.isArray(result.stallDiagnostics) && result.stallDiagnostics.length > 0
1426
+ ? ` ${result.stallDiagnostics.join(' · ')}`
1427
+ : '';
1343
1428
  return {
1344
1429
  kind: 'plan_stalled',
1345
- reason: `Plan is stalled: ${result.reason ?? 'no ready task'} (pending steps exist but none have their dependencies satisfied).`,
1430
+ reason: `Plan is stalled: ${result.reason ?? 'no ready task'} (pending steps exist but none have their dependencies satisfied).${diagnostics}`,
1346
1431
  suggestedAction: 'Drop or replace the unsatisfiable dependency.',
1347
1432
  activity: null,
1348
1433
  };