@dotdrelle/wiki-manager 0.15.38 → 0.15.41

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 (42) hide show
  1. package/README.md +5 -0
  2. package/docker-compose.yml +1 -1
  3. package/package.json +2 -2
  4. package/src/activity/activityAggregator.js +3 -3
  5. package/src/activity/progressCalculator.js +3 -4
  6. package/src/agent/graph.js +40 -1
  7. package/src/cli/wiki-manager.js +34 -39
  8. package/src/commands/slash.js +11 -1
  9. package/src/core/activity.js +3 -2
  10. package/src/core/agentEvents.js +46 -11
  11. package/src/core/agentEvents.test.js +76 -0
  12. package/src/core/agentLoop.js +32 -6
  13. package/src/core/buildInfo.json +2 -2
  14. package/src/core/dockerCompose.test.js +8 -0
  15. package/src/core/jobQueue.js +9 -0
  16. package/src/core/mcp.js +1 -1
  17. package/src/core/plan.js +3 -2
  18. package/src/core/planPatch.js +2 -1
  19. package/src/core/workflow.js +11 -2
  20. package/src/graph/graphVisibilityPolicy.js +2 -2
  21. package/src/orchestrator/agentRegistry.js +50 -0
  22. package/src/orchestrator/agentRegistry.test.js +76 -1
  23. package/src/orchestrator/approvalPolicy.js +2 -2
  24. package/src/orchestrator/dependencyResolver.js +52 -12
  25. package/src/orchestrator/dispatcher.js +6 -7
  26. package/src/orchestrator/dispatcher.test.js +33 -0
  27. package/src/orchestrator/planIntegrator.js +5 -5
  28. package/src/orchestrator/resultAggregator.js +2 -1
  29. package/src/orchestrator/scheduler.test.js +62 -1
  30. package/src/orchestrator/taskStatuses.js +99 -0
  31. package/src/orchestrator/taskStatuses.test.js +112 -0
  32. package/src/runtime/delegation.js +158 -0
  33. package/src/runtime/delegation.test.js +281 -0
  34. package/src/runtime/recoveryManager.js +2 -5
  35. package/src/runtime/recoveryManager.test.js +5 -1
  36. package/src/runtime/runner.js +129 -15
  37. package/src/runtime/runner.test.js +153 -6
  38. package/src/runtime/server.js +24 -1
  39. package/src/runtime/server.test.js +113 -0
  40. package/src/runtime/store.js +16 -1
  41. package/src/runtime/store.test.js +50 -0
  42. package/src/shell/repl.js +4 -5
package/README.md CHANGED
@@ -771,6 +771,11 @@ capabilityRouting:
771
771
  allowedAgents: [connectors]
772
772
  ```
773
773
 
774
+ The production agent also advertises `workspace.restore` for Git-backed
775
+ rollback. It is workspace-scoped and remains subject to the normal runtime
776
+ approval and lock checks; it can be pinned in the same way when several agents
777
+ provide that capability.
778
+
774
779
  #### Compose overrides — optional agents, proxies, local fixes
775
780
 
776
781
  Two override files sit under **`.wiki/compose/`**, one per stack:
@@ -124,7 +124,7 @@ services:
124
124
  - WORKSPACE_NAME=${WORKSPACE_NAME:-workspace}
125
125
  - WIKI_WORKSPACE_PATH=/workspace
126
126
  - WIKI_CONFIG_PATH=${WIKI_CONFIG_PATH:-}
127
- - PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,build,export,polish,pipeline}
127
+ - PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,build,export,polish,restore,pipeline}
128
128
  - PRODUCTION_REQUIRE_CONFIRMATION=${PRODUCTION_REQUIRE_CONFIRMATION:-false}
129
129
  # Parallelism levers — effective concurrency ≈ recommendedConcurrency.
130
130
  # Intermediate defaults (4/8). Low profile 2/4, high profile 8/16.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.38",
3
+ "version": "0.15.41",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "start": "bun ./bin/wiki-manager.js",
14
- "test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
14
+ "test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
15
15
  "check-versions": "node scripts/check-versions.js",
16
16
  "prepack": "node scripts/check-versions.js",
17
17
  "prepublishOnly": "node scripts/check-versions.js",
@@ -1,8 +1,8 @@
1
1
  import { calculateWeightedProgress } from './progressCalculator.js';
2
2
  import { deduplicateActivities } from './activityDeduplicator.js';
3
3
  import { initialSynthesisFromState } from './runSynthesis.js';
4
+ import { isSuccessful } from '../orchestrator/taskStatuses.js';
4
5
 
5
- const DONE = new Set(['done', 'complete', 'completed', 'success', 'succeeded']);
6
6
  const ACTIVE = new Set(['running', 'starting', 'queued']);
7
7
 
8
8
  export function aggregateActivity(state = {}, events = []) {
@@ -67,7 +67,7 @@ function groupLabel(task) {
67
67
 
68
68
  function groupLine(group, activities) {
69
69
  const total = group.tasks.length;
70
- const done = group.tasks.filter((task) => DONE.has(statusOf(task))).length;
70
+ const done = group.tasks.filter((task) => isSuccessful(statusOf(task))).length;
71
71
  const running = group.tasks.filter((task) => ACTIVE.has(statusOf(task)));
72
72
  const failed = group.tasks.find((task) => statusOf(task) === 'failed');
73
73
  const waitingApproval = group.tasks.some((task) => ['pending_approval', 'waiting_approval'].includes(statusOf(task)));
@@ -76,7 +76,7 @@ function groupLine(group, activities) {
76
76
  // the whole group as "validation 0%" while a worker visibly runs at 35%.
77
77
  const activePair = group.tasks
78
78
  .map((task) => ({ task, activity: activityForTask(task, activities) }))
79
- .find(({ activity }) => activity && !activity.terminal && !DONE.has(statusOf(activity)));
79
+ .find(({ activity }) => activity && !activity.terminal && !isSuccessful(statusOf(activity)));
80
80
  const activeTask = activePair?.task ?? running[0] ?? null;
81
81
  const activeActivity = activePair?.activity
82
82
  ?? running.map((task) => activityForTask(task, activities)).find(Boolean);
@@ -1,5 +1,4 @@
1
- const TERMINAL_DONE = new Set(['done', 'complete', 'completed', 'success', 'succeeded']);
2
- const TERMINAL_ANY = new Set([...TERMINAL_DONE, 'failed', 'cancelled', 'canceled', 'error']);
1
+ import { isSuccessful, isTerminal } from '../orchestrator/taskStatuses.js';
3
2
 
4
3
  export function calculateWeightedProgress(tasks = [], activities = []) {
5
4
  const items = Array.isArray(tasks) ? tasks : [];
@@ -10,10 +9,10 @@ export function calculateWeightedProgress(tasks = [], activities = []) {
10
9
  for (const task of items) {
11
10
  const weight = taskWeight(task);
12
11
  const status = normalizeStatus(task.status);
13
- if (TERMINAL_DONE.has(status)) {
12
+ if (isSuccessful(status)) {
14
13
  completedWeight += weight;
15
14
  done += 1;
16
- } else if (!TERMINAL_ANY.has(status)) {
15
+ } else if (!isTerminal(status)) {
17
16
  completedWeight += weight * taskProgressRatio(task, activities);
18
17
  }
19
18
  }
@@ -1,3 +1,13 @@
1
+ /**
2
+ * @statuses-vocabulary
3
+ *
4
+ * JSON schema enumeration exposed to models. A schema declares what it
5
+ * accepts, including values the orchestrator no longer produces.
6
+ *
7
+ * Declared here rather than in a central exception list so the waiver
8
+ * travels with the code it excuses (see orchestrator/taskStatuses.test.js).
9
+ */
10
+ import { isTerminal } from '../orchestrator/taskStatuses.js';
1
11
  import { join } from 'node:path';
2
12
  import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
3
13
  import {
@@ -640,7 +650,7 @@ function rememberProductionProgress(session, payload, label) {
640
650
  jobId: jobId ?? session.productionActivity?.jobId ?? null,
641
651
  status,
642
652
  label: label ?? `Production: ${status}`,
643
- terminal: ['done', 'failed', 'cancelled'].includes(String(status)),
653
+ terminal: isTerminal(status),
644
654
  updatedAt: new Date().toISOString(),
645
655
  };
646
656
  }
@@ -804,6 +814,35 @@ async function handleRuntimeControlTool(session, tool, args = {}) {
804
814
  if (connectorConfig) {
805
815
  return `Delegation rejected: ${connectorConfig.serverName} advertises no setup or authentication tool. Do not call an unrelated data tool and do not delegate to export. Explain conversationally that authentication must be completed outside MCP, using only configuration instructions already available in the current context.`;
806
816
  }
817
+ /*
818
+ Un run ne passe pas par le réseau pour se déléguer à lui-même.
819
+
820
+ Donna délègue DEPUIS l'intérieur du run conversationnel qui vient d'être
821
+ marqué actif. Passer par POST /delegate revenait à demander au runtime
822
+ l'autorisation de démarrer un run alors qu'un run tourne déjà — le sien —
823
+ et l'endpoint répondait 409, à juste titre de son point de vue. Le run
824
+ refusait sa propre délégation.
825
+
826
+ Déléguer n'est pas démarrer un second run : c'est faire passer celui-ci
827
+ de la décision à l'exécution. Quand ce chemin interne existe (agent
828
+ exécuté dans le processus du runtime), on l'emprunte : même `runId`,
829
+ aucun run concurrent, aucun 409. Le chemin HTTP reste pour les appelants
830
+ réellement extérieurs — le Shell, un client tiers —, et c'est là que le
831
+ 409 garde tout son sens.
832
+ */
833
+ if (typeof session?._delegateWithinRun === 'function') {
834
+ try {
835
+ const inRun = await session._delegateWithinRun(objective);
836
+ return JSON.stringify({
837
+ delegated: true,
838
+ runId: inRun.runId,
839
+ summary: inRun.summary ?? null,
840
+ message: `Action lancée (${String(inRun.runId).slice(0, 8)}) après validation du plan réel : ${inRun.summary?.tasks ?? 0} tâche(s), ${inRun.summary?.agent ?? 'agent résolu'}. Exécution en cours.`,
841
+ });
842
+ } catch (err) {
843
+ return `Délégation refusée : ${err instanceof Error ? err.message : String(err)}`;
844
+ }
845
+ }
807
846
  const result = await postRuntimeDelegate(objective, { url, workspace });
808
847
  return result?.runId
809
848
  ? JSON.stringify({
@@ -1,3 +1,12 @@
1
+ /**
2
+ * @statuses-vocabulary
3
+ *
4
+ * JOB statuses as reported by headless polling, mapped to a process exit
5
+ * code — a different contract from the orchestrator's task vocabulary.
6
+ *
7
+ * Declared here rather than in a central exception list so the waiver
8
+ * travels with the code it excuses (see orchestrator/taskStatuses.test.js).
9
+ */
1
10
  import { randomUUID } from 'node:crypto';
2
11
  import { spawnSync } from 'node:child_process';
3
12
  import { readFileSync } from 'node:fs';
@@ -317,17 +326,7 @@ export async function forwardRuntimeApproval(getWorkspaceContext, request = {})
317
326
  return context.approvalManager?.approve(request) ?? { approved: false };
318
327
  }
319
328
 
320
- export function resolvePreparedDelegationApproval({
321
- autoApprove = false,
322
- approvalManager = null,
323
- runId,
324
- } = {}) {
325
- if (autoApprove !== true || typeof approvalManager?.approve !== 'function') {
326
- return { approved: false, awaitingApproval: true };
327
- }
328
- const result = approvalManager.approve({ scope: 'run', runId });
329
- return { approved: true, awaitingApproval: false, result };
330
- }
329
+ export { resolvePreparedDelegationApproval } from '../runtime/delegation.js';
331
330
 
332
331
  function timestampForFile() {
333
332
  return new Date().toISOString().replace(/[:.]/g, '-');
@@ -1168,39 +1167,33 @@ async function runRuntime(argv, agent) {
1168
1167
  : undefined;
1169
1168
  supervisor?.setRunSignal(signal);
1170
1169
  session._onStep = (message) => emitRuntimeLog(session, message);
1171
- if (body.preparedDelegation?.fragment) {
1172
- const { integrate } = await import('../orchestrator/planIntegrator.js');
1173
- const prepared = body.preparedDelegation;
1174
- const integrated = integrate(runId, prepared.fragment, {
1170
+ session._delegateWithinRun = async (objective) => {
1171
+ const { delegateWithinRun } = await import('../runtime/delegation.js');
1172
+ return delegateWithinRun(session, objective, {
1173
+ prepare: ({ objective: goal }) => prepareDelegation(context, { objective: goal }),
1175
1174
  registry: capabilityRegistryForSession(session),
1176
- session,
1177
1175
  store,
1178
- workspace: session.workspace ?? null,
1179
- enforceApprovalCoverage: true,
1176
+ approvalManager: context.approvalManager,
1177
+ autoApprove: body.autoApprove === true,
1180
1178
  });
1181
- if (!integrated.ok) {
1182
- throw new Error(`Delegated plan integration failed: ${(integrated.errors ?? []).map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
1183
- }
1184
- emitRuntimeLog(session, `delegation: ${prepared.fragment.tasks.length} validated task(s) integrated from ${prepared.provider.serverName}.agent_plan (${prepared.capability}/${prepared.operation})`);
1179
+ };
1180
+ if (body.preparedDelegation?.fragment) {
1181
+ const { integratePreparedDelegation } = await import('../runtime/delegation.js');
1182
+ const prepared = body.preparedDelegation;
1185
1183
  // Real approval gate (opt-out): a directly-delegated run only skips the
1186
1184
  // human approval step when the caller explicitly opts in via
1187
1185
  // `autoApprove` (e.g. headless/CI, or a future "trust this run" toggle).
1188
- // By default the run WAITS: integrate() above created the per-task
1189
- // approval requests, and the scheduler's approvalCovered() filter blocks
1190
- // the mutating tasks until a run-scope grant arrives (/approve or
1191
- // "valide tout"). This keeps a visible pending_approval window instead of
1192
- // resolving it programmatically ~30ms after launch, which no polled UI
1193
- // could ever render.
1194
- const approval = resolvePreparedDelegationApproval({
1195
- autoApprove: body.autoApprove,
1196
- approvalManager: context.approvalManager,
1186
+ // By default the run WAITS, so the pending_approval window stays
1187
+ // visible instead of being resolved ~30ms after launch.
1188
+ integratePreparedDelegation({
1189
+ session,
1190
+ store,
1197
1191
  runId,
1192
+ prepared,
1193
+ registry: capabilityRegistryForSession(session),
1194
+ approvalManager: context.approvalManager,
1195
+ autoApprove: body.autoApprove === true,
1198
1196
  });
1199
- if (approval.approved) {
1200
- emitRuntimeLog(session, `approval: run ${runId} auto-approved (autoApprove opt-in)`);
1201
- } else {
1202
- emitRuntimeLog(session, `approval: run ${runId} awaiting explicit approval before mutations (/approve or « valide tout »)`);
1203
- }
1204
1197
  body._planReady?.resolve?.({ runId, planRevision: session.agentProjection?.planRevision ?? 0 });
1205
1198
  }
1206
1199
  // Deterministic capability run (/ingest): ask the capable agent for its
@@ -1231,9 +1224,11 @@ async function runRuntime(argv, agent) {
1231
1224
  ),
1232
1225
  requireApprovalForMutations: body.capabilityPlan.requireApproval !== false,
1233
1226
  },
1234
- ...(Array.isArray(body.capabilityPlan.inputs) && body.capabilityPlan.inputs.length > 0
1235
- ? { arguments: { inputs: body.capabilityPlan.inputs } }
1236
- : {}),
1227
+ ...(body.capabilityPlan.arguments && typeof body.capabilityPlan.arguments === 'object'
1228
+ ? { arguments: body.capabilityPlan.arguments }
1229
+ : Array.isArray(body.capabilityPlan.inputs) && body.capabilityPlan.inputs.length > 0
1230
+ ? { arguments: { inputs: body.capabilityPlan.inputs } }
1231
+ : {}),
1237
1232
  })));
1238
1233
  if (!Array.isArray(fragment?.tasks) || fragment.tasks.length === 0) {
1239
1234
  dispatchAgentEvent(session, createAgentEvent('assistant_message', {
@@ -1,3 +1,13 @@
1
+ /**
2
+ * @statuses-vocabulary
3
+ *
4
+ * CONTROL QUEUE item statuses, which include `expired`. A control request
5
+ * is not a task and does not share its lifecycle.
6
+ *
7
+ * Declared here rather than in a central exception list so the waiver
8
+ * travels with the code it excuses (see orchestrator/taskStatuses.test.js).
9
+ */
10
+ import { isTerminal } from '../orchestrator/taskStatuses.js';
1
11
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
12
  import { openExternalUrl } from '../shell/openExternal.js';
3
13
  import { classifyCommandFailure, failureHint, rawFailureText } from '../core/commandFailure.js';
@@ -850,7 +860,7 @@ function formatRuntimeRunStatus(state) {
850
860
  ? state.controlQueue.filter((item) => item.status === 'queued').length
851
861
  : 0;
852
862
  const tasks = Array.isArray(state?.workflow?.nodes)
853
- ? state.workflow.nodes.filter((node) => node.type === 'task' && !['done', 'failed', 'cancelled'].includes(String(node.status))).length
863
+ ? state.workflow.nodes.filter((node) => node.type === 'task' && !isTerminal(node.status)).length
854
864
  : 0;
855
865
  return `runtime: ${status}${runId} · queued=${queued} · activeTasks=${tasks}`;
856
866
  }
@@ -1,4 +1,5 @@
1
1
  import { validateContractInDev } from '../contracts/schemas.js';
2
+ import { isTerminal, isUnsuccessfulTerminal } from '../orchestrator/taskStatuses.js';
2
3
 
3
4
  export function parseJsonText(text) {
4
5
  try {
@@ -13,7 +14,7 @@ function basename(value) {
13
14
  }
14
15
 
15
16
  function terminalStatus(status) {
16
- return ['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'succeeded', 'error'].includes(String(status ?? '').toLowerCase());
17
+ return isTerminal(status);
17
18
  }
18
19
 
19
20
  export function activityKey(activity) {
@@ -251,7 +252,7 @@ export function newNonTerminalActivities(snapshotBefore, session) {
251
252
 
252
253
  export function terminalFailures(activities) {
253
254
  return activities.filter(
254
- (a) => a.terminal && ['failed', 'error', 'cancelled', 'canceled'].includes(String(a.status).toLowerCase()),
255
+ (a) => a.terminal && isUnsuccessfulTerminal(a.status),
255
256
  );
256
257
  }
257
258
 
@@ -4,6 +4,7 @@ import { applyPlanPatch, normalizePlanPatch, normalizePlanRevision, rebasePlanPa
4
4
  import { formatRuntimeLogPayload } from './runtimeLog.js';
5
5
  import { projectWorkflow } from './workflow.js';
6
6
  import { validateContractInDev } from '../contracts/schemas.js';
7
+ import { isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
7
8
 
8
9
  const SESSION_PROJECTION_EVENTS = new Set([
9
10
  'run_started',
@@ -343,7 +344,13 @@ function applyEvent(state, event) {
343
344
  state.planRevision = normalizePlanRevision(event.payload?.planRevision ?? state.planRevision + 1);
344
345
  return;
345
346
  case 'plan_step_updated':
346
- updatePlanStep(state.plan, event.payload ?? {});
347
+ {
348
+ // L'anomalie remonte par la valeur de retour plutôt que par une
349
+ // référence au state : `updatePlanStep` reste une fonction sur un
350
+ // plan, et le journal reste la responsabilité de l'appelant.
351
+ const anomaly = updatePlanStep(state.plan, event.payload ?? {});
352
+ if (anomaly) state.logs.push(anomaly);
353
+ }
347
354
  return;
348
355
  case 'control_message_received':
349
356
  state.logs.push(`Control message: ${String(event.payload?.input ?? '')}`);
@@ -556,6 +563,7 @@ function applyEvent(state, event) {
556
563
  status: 'queued',
557
564
  createdAt: event.payload?.createdAt ?? event.ts,
558
565
  updatedAt: event.ts,
566
+ ...(event.payload?.capabilityPlan !== undefined ? { capabilityPlan: event.payload.capabilityPlan } : {}),
559
567
  });
560
568
  return;
561
569
  case 'control_started':
@@ -724,7 +732,7 @@ function markCoveredApprovalsApproved(approvals, grant, ts) {
724
732
 
725
733
  function cancelPendingPlanSteps(plan) {
726
734
  for (const step of plan ?? []) {
727
- if (!['done', 'failed', 'cancelled'].includes(String(step.status ?? ''))) step.status = 'cancelled';
735
+ if (!isTerminal(step.status)) step.status = 'cancelled';
728
736
  }
729
737
  }
730
738
 
@@ -851,24 +859,51 @@ function normalizePlanTask(raw, index, { owner = 'orchestrator', ownerActivityKe
851
859
  }
852
860
 
853
861
  function updatePlanStep(plan, payload) {
854
- if (!plan) return;
862
+ let anomaly = null;
863
+ if (!plan) return anomaly;
855
864
  const requestedTaskId = payload.taskId ?? payload.id ?? payload.targetTaskId;
856
865
  const step = requestedTaskId != null
857
866
  ? plan.find((item) => String(item.id ?? item.step) === String(requestedTaskId))
858
867
  : plan.find((item) => item.step === Number(payload.step));
859
- if (!step) return;
860
- if (payload.status === 'failed') step.status = 'failed';
861
- else if (payload.status === 'running') step.status = 'running';
862
- else if (payload.status === 'pending') step.status = 'pending';
863
- else if (payload.status === 'pending_approval') step.status = 'pending_approval';
864
- else if (payload.status === 'waiting_approval') step.status = 'waiting_approval';
865
- else if (payload.status === 'cancelled') step.status = 'cancelled';
866
- else step.status = 'done';
868
+ if (!step) return anomaly;
869
+ /*
870
+ Un statut inconnu ne vaut pas « réussi ».
871
+
872
+ La cascade se terminait par `else step.status = 'done'` : tout statut non
873
+ énuméré — `skipped`, par exemple — était projeté en succès. Le runner
874
+ marquait bien une tâche ignorée, la projection la déclarait faite, et le
875
+ résumé de run comptait une réussite qui n'a jamais eu lieu. Le défaut le
876
+ plus dangereux est celui qui transforme une inconnue en bonne nouvelle.
877
+
878
+ Trois cas, et un seul mène à `done` :
879
+
880
+ - un statut reconnu par le vocabulaire commun est repris tel quel, alias
881
+ compris (`succeeded` → `done`, `error` → `failed`) ;
882
+ - l'ABSENCE de statut garde le contrat historique — un événement de fin
883
+ sans précision signifie « terminé » ;
884
+ - un statut présent mais incompréhensible laisse l'étape dans l'état où
885
+ elle était, et signale une anomalie de projection. On ne sait pas ce qui
886
+ s'est passé : le dire est plus utile que d'inventer une réponse.
887
+ */
888
+ const canonical = normalizeTaskStatus(payload.status);
889
+ if (canonical) {
890
+ step.status = canonical;
891
+ } else if (isUnknownStatus(payload.status)) {
892
+ anomaly = `projection: unknown status "${String(payload.status)}" for plan step ${String(requestedTaskId ?? payload.step ?? '?')} — kept "${String(step.status ?? 'pending')}"`;
893
+ } else {
894
+ step.status = 'done';
895
+ }
896
+ // Le motif d'un abandon est la seule chose qui le rende actionnable :
897
+ // « ignorée » sans « parce que » n'apprend rien à qui relance.
898
+ if (step.status === 'skipped' && payload.reason && !step.error) {
899
+ step.error = { code: 'dependency_failed', message: String(payload.reason) };
900
+ }
867
901
  if (payload.activityKey) step.activityKey = payload.activityKey;
868
902
  if (Array.isArray(payload.outputRefs)) step.outputRefs = payload.outputRefs.map(cloneRef);
869
903
  if (payload.result) step.result = cloneJson(payload.result);
870
904
  if (payload.retryState) step.retryState = cloneJson(payload.retryState);
871
905
  if (payload.retryAssignment) step.retryAssignment = cloneJson(payload.retryAssignment);
906
+ return anomaly;
872
907
  }
873
908
 
874
909
  function formatPlanErrors(errors) {
@@ -364,6 +364,38 @@ test('reduceAgentEvents: control queue is event sourced and follows run status',
364
364
  assert.equal(projection.controlQueue[1].status, 'cancelled');
365
365
  });
366
366
 
367
+ test('reduceAgentEvents: control_enqueued preserves a structured capabilityPlan across replay', () => {
368
+ const capabilityPlan = {
369
+ capability: 'workspace.restore',
370
+ operation: 'restore',
371
+ arguments: { run: 'abc123' },
372
+ requireApproval: true,
373
+ };
374
+ const projection = reduceAgentEvents([
375
+ createAgentEvent('control_enqueued', {
376
+ origin: 'runtime',
377
+ workspace: 'docs',
378
+ payload: {
379
+ id: 'control-restore',
380
+ workspace: 'docs',
381
+ input: 'restore',
382
+ createdAt: '2026-01-01T00:00:00.000Z',
383
+ capabilityPlan,
384
+ },
385
+ }),
386
+ createAgentEvent('control_started', {
387
+ origin: 'runtime',
388
+ runId: 'run-control-restore',
389
+ workspace: 'docs',
390
+ payload: { id: 'control-restore', runId: 'run-control-restore' },
391
+ }),
392
+ ]);
393
+
394
+ const item = projection.controlQueue.find((entry) => entry.id === 'control-restore');
395
+ assert.deepEqual(item.capabilityPlan, capabilityPlan);
396
+ assert.equal(item.status, 'running');
397
+ });
398
+
367
399
  test('reduceAgentEvents: activity-owned plan is used when no orchestrator plan exists', () => {
368
400
  const projection = reduceAgentEvents([
369
401
  createAgentEvent('activity_upserted', {
@@ -515,3 +547,47 @@ test('run_error cancels pending plan steps and active activities (no ghosts at r
515
547
  assert.equal(activity.status, 'cancelled');
516
548
  assert.equal(activity.terminal, true);
517
549
  });
550
+
551
+ /*
552
+ La cascade de statuts se terminait par `else step.status = 'done'` : un statut
553
+ non énuméré était projeté en succès. Le runner marquait une tâche `skipped`
554
+ faute de dépendance, la projection la déclarait faite, et le résumé comptait
555
+ une réussite qui n'avait jamais eu lieu — le pire des défauts, celui qui
556
+ transforme une inconnue en bonne nouvelle.
557
+ */
558
+ test('reduceAgentEvents: un statut de plan inconnu ne devient pas un succès', () => {
559
+ const projection = reduceAgentEvents([
560
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Ingest a', 'Ingest b'] } }),
561
+ createAgentEvent('plan_step_updated', {
562
+ origin: 'runtime',
563
+ payload: { step: 1, status: 'skipped', reason: 'dependency_failed:convert' },
564
+ }),
565
+ createAgentEvent('plan_step_updated', {
566
+ origin: 'runtime',
567
+ payload: { step: 2, status: 'brouette' },
568
+ }),
569
+ ]);
570
+
571
+ assert.equal(projection.plan[0].status, 'skipped');
572
+ assert.equal(projection.plan[0].error?.code, 'dependency_failed');
573
+ assert.match(projection.plan[0].error.message, /convert/);
574
+ // Un statut incompréhensible laisse l'étape où elle était et se signale :
575
+ // on ne sait pas ce qui s'est passé, le dire vaut mieux que d'inventer.
576
+ assert.equal(projection.plan[1].status, 'pending');
577
+ assert.equal(projection.logs.some((line) => /unknown status "brouette"/.test(line)), true);
578
+ });
579
+
580
+ test('reduceAgentEvents: les alias de statut tombent sur le canonique', () => {
581
+ const projection = reduceAgentEvents([
582
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['A', 'B', 'C'] } }),
583
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'succeeded' } }),
584
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 2, status: 'error' } }),
585
+ // Contrat historique : un événement de fin sans statut vaut « terminé ».
586
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 3 } }),
587
+ ]);
588
+
589
+ assert.equal(projection.plan[0].status, 'done');
590
+ assert.equal(projection.plan[1].status, 'failed');
591
+ assert.equal(projection.plan[2].status, 'done');
592
+ assert.equal(projection.logs.some((line) => /unknown status/.test(line)), false);
593
+ });
@@ -3,6 +3,11 @@ import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
3
3
  import { activitySnapshot, newNonTerminalActivities } from './activity.js';
4
4
  import { formatCompletedActivities, formatPlanStatus } from './plan.js';
5
5
  import { formatReadyTaskPrompt, nextReadyPlanTask, readyPlanTasks, sanitizePlanForExecution } from './planPatch.js';
6
+ import { isActive, isPending, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
7
+
8
+ // Les deux formes d'attente d'un accord humain. Distinguées de `pending` :
9
+ // approuver ne les rend pas prêtes de la même manière.
10
+ const isPendingApproval = (status) => ['pending_approval', 'waiting_approval'].includes(normalizeTaskStatus(status));
6
11
 
7
12
  export function abortError(message = 'Agent run cancelled.') {
8
13
  const err = new Error(message);
@@ -111,19 +116,40 @@ export async function runAgenticLoop(agent, session, initialInput, {
111
116
  // integrated agent_plan fragment. Prose stays prose.
112
117
  sanitizeSessionPlan(session, { runId });
113
118
 
119
+ /*
120
+ Une décision prise arrête la conversation.
121
+
122
+ Dès qu'un fragment structuré est intégré pendant ce tour — Donna vient de
123
+ déléguer —, la boucle conversationnelle n'a plus rien à décider : le plan
124
+ existe, validé par l'agent qui l'exécutera. Elle rendait pourtant la main
125
+ au modèle, parce que le seul critère de bascule était « au moins deux
126
+ tâches PRÊTES » et qu'un plan intégralement en attente d'approbation n'en
127
+ compte aucune. Le modèle repartait donc, l'évaluateur jugeait le plan
128
+ incomplet, le replanificateur relançait une délégation : cinq tâches,
129
+ puis dix, puis quinze, à l'identique.
130
+
131
+ L'attente d'approbation et l'exécution appartiennent au planificateur
132
+ parallèle, qui sait faire les deux. On lui rend la main tout de suite.
133
+ */
134
+ if (parallelHandoff && session._structuredPlanIntegrated) {
135
+ session._structuredPlanIntegrated = false;
136
+ return { ok: true, handoff: true };
137
+ }
138
+
114
139
  const newPending = newNonTerminalActivities(snapshot, session);
115
140
  if (newPending.length === 0) {
116
- // pending_approval is unfinished work too (a request_approval patch op
117
- // sets it) — it must not be treated as "nothing left to do" just
118
- // because no step is literally 'pending' anymore.
119
- const pending = (session.headlessPlan ?? []).filter((step) => step.status === 'pending' || step.status === 'pending_approval');
141
+ // Toute attente est du travail inachevé — approbation comprise. La liste
142
+ // énumérait `pending` et `pending_approval` mais oubliait
143
+ // `waiting_approval`, que produit justement l'intégration d'un fragment
144
+ // délégué : un plan entier en attente se lisait « plus rien à faire ».
145
+ const pending = (session.headlessPlan ?? []).filter((step) => isPending(step.status));
120
146
  const pendingSteps = readyPlanTasks(session.headlessPlan);
121
147
  if (pending.length === 0) {
122
148
  onComplete?.();
123
149
  return { ok: true };
124
150
  }
125
151
  if (pendingSteps.length === 0) {
126
- const reason = pending.every((step) => step.status === 'pending_approval') ? 'awaiting_approval' : 'no_ready_plan_task';
152
+ const reason = pending.every((step) => isPendingApproval(step.status)) ? 'awaiting_approval' : 'no_ready_plan_task';
127
153
  onPendingSteps?.({ pendingSteps: pending, blocked: true });
128
154
  return { ok: false, stalled: true, reason };
129
155
  }
@@ -150,7 +176,7 @@ export async function runAgenticLoop(agent, session, initialInput, {
150
176
  const summary = formatCompletedActivities(completed);
151
177
  onActivitiesCompleted?.({ completed, summary });
152
178
  const unfinished = (session.headlessPlan ?? []).some((step) =>
153
- ['pending', 'pending_approval', 'running', 'starting', 'queued'].includes(String(step.status ?? '').toLowerCase()));
179
+ isPending(step.status) || isActive(step.status));
154
180
  if (deterministicTerminalSummary && !unfinished) {
155
181
  return { ok: true, completed, summary, deterministicSummary: true };
156
182
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.38",
3
- "commit": "abf8356"
2
+ "version": "0.15.41",
3
+ "commit": "d0180aa"
4
4
  }
@@ -19,6 +19,14 @@ test('workspace compose does not start a per-workspace agent runtime', async ()
19
19
  );
20
20
  });
21
21
 
22
+ test('workspace production agent enables restore by default', async () => {
23
+ const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
24
+ const compose = YAML.parse(raw);
25
+ const allowed = compose.services['production-mcp'].environment
26
+ .find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS='));
27
+ assert.match(String(allowed), /(?:^|,)restore(?:,|})/);
28
+ });
29
+
22
30
  test('shipped compose files never carry a build context', async () => {
23
31
  // Ces deux fichiers partent dans le paquet npm, où les dépôts frères
24
32
  // (`../agent-external/…`) n'existent pas : un `build:` y rend toute commande
@@ -1,3 +1,12 @@
1
+ /**
2
+ * @statuses-vocabulary
3
+ *
4
+ * JOB queue statuses. A queued job has its own lifecycle, independent of
5
+ * the task that may have enqueued it.
6
+ *
7
+ * Declared here rather than in a central exception list so the waiver
8
+ * travels with the code it excuses (see orchestrator/taskStatuses.test.js).
9
+ */
1
10
  import { randomUUID } from 'node:crypto';
2
11
  import { isAbsolute, relative, resolve, sep } from 'node:path';
3
12
  import { extractActivity, parseJsonText, sessionActivities } from './activity.js';
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.38';
4
+ const WIKI_MANAGER_VERSION = '0.15.41';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
package/src/core/plan.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isUnsuccessfulTerminal } from '../orchestrator/taskStatuses.js';
1
2
  export function ensurePlanFromActivity(session, activity) {
2
3
  if (!activity) return;
3
4
  const actKey = activity.key ?? null;
@@ -57,7 +58,7 @@ export function syncActivitiesToPlan(plan, activities) {
57
58
  const contractTaskPlan = isContractTaskPlan(plan);
58
59
  for (const activity of activities ?? []) {
59
60
  const terminal = Boolean(activity.terminal);
60
- const failed = ['failed', 'error', 'cancelled', 'canceled'].includes(String(activity.status).toLowerCase());
61
+ const failed = isUnsuccessfulTerminal(activity.status);
61
62
  const actKey = activity.key ?? activity.id ?? activity.jobId ?? null;
62
63
  const structuredMatch = findMatchingPlanStepByStructure(plan, activity);
63
64
  const matched = structuredMatch ?? findMatchingPlanStep(plan, activity);
@@ -244,7 +245,7 @@ export function attachActivityToExistingPlan(plan, activity) {
244
245
  if (!matched) return;
245
246
  matched.activityKey = actKey;
246
247
  if (!matched.ownerActivityKey) matched.ownerActivityKey = actKey;
247
- const failed = ['failed', 'error', 'cancelled', 'canceled'].includes(String(activity.status).toLowerCase());
248
+ const failed = isUnsuccessfulTerminal(activity.status);
248
249
  if (activity.terminal) {
249
250
  matched.status = failed ? 'failed' : 'done';
250
251
  return;