@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 +1 -1
- package/src/agent/graph.js +15 -7
- package/src/agent/graph.test.js +21 -0
- package/src/commands/slash.js +46 -7
- package/src/commands/slash.test.js +57 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +19 -4
- package/src/core/mcp.test.js +64 -2
- package/src/orchestrator/agentRegistry.js +25 -6
- package/src/orchestrator/agentRegistry.test.js +41 -0
- package/src/orchestrator/scheduler.test.js +76 -0
- package/src/runtime/runner.js +89 -4
package/package.json
CHANGED
package/src/agent/graph.js
CHANGED
|
@@ -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
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
|
|
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) =>
|
|
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))}.
|
|
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.',
|
package/src/agent/graph.test.js
CHANGED
|
@@ -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;
|
package/src/commands/slash.js
CHANGED
|
@@ -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
|
-
|
|
617
|
-
|
|
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
|
|
620
|
-
session.mcp = applyMcpRuntimeStatus(
|
|
621
|
-
session
|
|
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
|
|
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
|
+
|
package/src/core/buildInfo.json
CHANGED
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.
|
|
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:
|
|
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
|
}));
|
package/src/core/mcp.test.js
CHANGED
|
@@ -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
|
|
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, '
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
package/src/runtime/runner.js
CHANGED
|
@@ -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
|
-
|
|
620
|
-
|
|
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
|
};
|