@dotdrelle/wiki-manager 0.14.16 → 0.14.20
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/.env.example +18 -4
- package/README.md +19 -0
- package/docker-compose.yml +5 -0
- package/package.json +1 -1
- package/src/activity/activityAggregator.js +7 -3
- package/src/activity/activityAggregator.test.js +16 -2
- package/src/cli/wiki-manager.js +30 -8
- package/src/cli/wiki-manager.test.js +40 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +1 -1
- package/src/core/workflow.js +72 -0
- package/src/core/workflow.test.js +57 -0
- package/src/orchestrator/scheduler.js +27 -7
- package/src/orchestrator/scheduler.test.js +23 -0
- package/src/runtime/runner.js +30 -3
- package/src/runtime/store.js +54 -0
- package/src/runtime/store.test.js +42 -0
- package/src/shell/RightPane.tsx +22 -3
- package/src/shell/repl.test.js +13 -0
- package/src/shell/tui.tsx +6 -1
- package/src/shell/useSession.ts +47 -0
package/.env.example
CHANGED
|
@@ -74,10 +74,24 @@ DOCUMENTS_MCP_AUTH_TOKEN=
|
|
|
74
74
|
# WIKI_MANAGER_RUNTIME_HOST=0.0.0.0
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
77
|
+
# ── Parallelism & throughput ───────────────────────────────────────────────────
|
|
78
|
+
# Effective concurrency = MIN(agent recommendedConcurrency, agent maxConcurrency,
|
|
79
|
+
# this ceiling, per-task limits). So the PRIMARY levers live on the production
|
|
80
|
+
# agent (PRODUCTION_RECOMMENDED_CONCURRENCY / PRODUCTION_MAX_CONCURRENCY); this
|
|
81
|
+
# manager variable can only LOWER the result, never raise it. Full explanation
|
|
82
|
+
# and low/high profiles in docs/configuration.md § "Parallelism & throughput".
|
|
83
|
+
#
|
|
84
|
+
# Manager ceiling — leave unset to let the agent decide. Set to constrain:
|
|
85
|
+
# WIKI_MANAGER_CAPABILITY_CONCURRENCY=4
|
|
86
|
+
#
|
|
87
|
+
# Production agent capacity (passed through by docker-compose). Intermediate
|
|
88
|
+
# defaults are 4/8 (effective ≈ 4 parallel). Profiles:
|
|
89
|
+
# low → PRODUCTION_RECOMMENDED_CONCURRENCY=2 PRODUCTION_MAX_CONCURRENCY=4
|
|
90
|
+
# high → PRODUCTION_RECOMMENDED_CONCURRENCY=8 PRODUCTION_MAX_CONCURRENCY=16
|
|
91
|
+
# The wiki LLM backend must accept this many concurrent requests, and
|
|
92
|
+
# ingest_apply stays serialized regardless (global workspace-write lock).
|
|
93
|
+
# PRODUCTION_RECOMMENDED_CONCURRENCY=4
|
|
94
|
+
# PRODUCTION_MAX_CONCURRENCY=8
|
|
81
95
|
|
|
82
96
|
# ── MCP retry policy (optional) ────────────────────────────────────────────────
|
|
83
97
|
|
package/README.md
CHANGED
|
@@ -550,6 +550,25 @@ or the shell command `/approve item <id>`. The approval timeout defaults to 10
|
|
|
550
550
|
minutes and can be changed with `WIKI_MANAGER_APPROVAL_TIMEOUT_MS` or
|
|
551
551
|
`approvalTimeoutMs` in the `/run` body.
|
|
552
552
|
|
|
553
|
+
Directly-launched capability runs (ingest, pipeline) now **wait for approval by
|
|
554
|
+
default** before their mutating tasks: reply "valide tout", run `/approve`, or
|
|
555
|
+
click Approve in either UI (Shell right-pane banner, or the `serve` banner above
|
|
556
|
+
the composer). Auto-approval only happens when the run is started with
|
|
557
|
+
`autoApprove: true` (headless/CI).
|
|
558
|
+
|
|
559
|
+
### Parallelism & throughput
|
|
560
|
+
|
|
561
|
+
The number of tasks that run at once is `MIN(agent recommendedConcurrency, agent
|
|
562
|
+
maxConcurrency, WIKI_MANAGER_CAPABILITY_CONCURRENCY, per-task limits)` — a
|
|
563
|
+
minimum, so the manager ceiling can only lower it. The production agent ships
|
|
564
|
+
intermediate defaults (`PRODUCTION_RECOMMENDED_CONCURRENCY=4` /
|
|
565
|
+
`PRODUCTION_MAX_CONCURRENCY=8`, ≈ 4 parallel); locks then cap real parallelism
|
|
566
|
+
per phase (`ingest_apply` stays serial). The resolved value is shown in both
|
|
567
|
+
UIs' run summary and on the run node of the execution graph, with an amber
|
|
568
|
+
"(ceiling)" marker when the manager ceiling binds. Low/high profiles, the lock
|
|
569
|
+
model and the LLM-backend caveat are in
|
|
570
|
+
[docs/configuration.md § "Parallelism & throughput"](docs/configuration.md).
|
|
571
|
+
|
|
553
572
|
While a run is active, `GET`/`POST /control` still answers without waiting for
|
|
554
573
|
it to finish: `{"action":"status"}` returns the current run/plan/queue state,
|
|
555
574
|
`{"action":"explain"}` adds a one-line plain-language summary, and
|
package/docker-compose.yml
CHANGED
|
@@ -110,6 +110,11 @@ services:
|
|
|
110
110
|
- WIKI_CONFIG_PATH=${WIKI_CONFIG_PATH:-}
|
|
111
111
|
- PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,build,export,polish,pipeline}
|
|
112
112
|
- PRODUCTION_REQUIRE_CONFIRMATION=${PRODUCTION_REQUIRE_CONFIRMATION:-false}
|
|
113
|
+
# Parallelism levers — effective concurrency ≈ recommendedConcurrency.
|
|
114
|
+
# Intermediate defaults (4/8). Low profile 2/4, high profile 8/16.
|
|
115
|
+
# See docs/configuration.md § "Parallelism & throughput".
|
|
116
|
+
- PRODUCTION_RECOMMENDED_CONCURRENCY=${PRODUCTION_RECOMMENDED_CONCURRENCY:-4}
|
|
117
|
+
- PRODUCTION_MAX_CONCURRENCY=${PRODUCTION_MAX_CONCURRENCY:-8}
|
|
113
118
|
- PRODUCTION_JOBS_DIR=${PRODUCTION_JOBS_DIR:-/workspace/.wiki/production-jobs}
|
|
114
119
|
- PRODUCTION_LOCKS_DIR=${PRODUCTION_LOCKS_DIR:-/workspace/.wiki/production-jobs/locks}
|
|
115
120
|
ports:
|
package/package.json
CHANGED
|
@@ -93,17 +93,21 @@ function groupLine(group, activities) {
|
|
|
93
93
|
// still progressing. Show the live worker as running; surface the group
|
|
94
94
|
// failure once no work remains. Otherwise its business label was rendered
|
|
95
95
|
// red even though that exact task was healthy and advancing.
|
|
96
|
+
// Glyphs mirror the Shell PlanPanel so the two never disagree: done is a
|
|
97
|
+
// check (not a cross — "[x]" read as a failure X), failure is a distinct
|
|
98
|
+
// cross, and an approval wait gets its own pause glyph instead of reusing the
|
|
99
|
+
// failure "[!]".
|
|
96
100
|
if (running.length > 0 || activeActivity) {
|
|
97
101
|
icon = '[...]';
|
|
98
102
|
status = activeProgress != null ? `${Math.round(activeProgress)} %` : `${done}/${total}`;
|
|
99
103
|
} else if (failed) {
|
|
100
|
-
icon = '[
|
|
104
|
+
icon = '[✗]';
|
|
101
105
|
status = 'error';
|
|
102
106
|
} else if (done === total) {
|
|
103
|
-
icon = '[
|
|
107
|
+
icon = '[✓]';
|
|
104
108
|
status = 'done';
|
|
105
109
|
} else if (waitingApproval) {
|
|
106
|
-
icon = '[
|
|
110
|
+
icon = '[⏸]';
|
|
107
111
|
status = 'validation';
|
|
108
112
|
} else if (done > 0) {
|
|
109
113
|
icon = '[...]';
|
|
@@ -5,6 +5,20 @@ import { aggregateActivity } from './activityAggregator.js';
|
|
|
5
5
|
import { visibleActivityEvents } from './activityDeduplicator.js';
|
|
6
6
|
import { calculateWeightedProgress } from './progressCalculator.js';
|
|
7
7
|
|
|
8
|
+
test('a group awaiting approval renders a distinct pause glyph, not a failure', () => {
|
|
9
|
+
const aggregated = aggregateActivity({
|
|
10
|
+
plan: [
|
|
11
|
+
{ id: 'publish', label: 'Publication', groupId: 'publish', status: 'pending_approval', progressWeight: 1 },
|
|
12
|
+
],
|
|
13
|
+
activities: [],
|
|
14
|
+
});
|
|
15
|
+
const line = aggregated.lines.find((item) => /publish|Publication/.test(item.label));
|
|
16
|
+
assert.ok(line, 'the awaiting-approval group is present');
|
|
17
|
+
assert.match(line.label, /\[⏸\]/, 'uses the pause glyph');
|
|
18
|
+
assert.doesNotMatch(line.label, /\[!\]|\[✗\]/, 'never reuses the failure glyph');
|
|
19
|
+
assert.equal(line.status, 'validation');
|
|
20
|
+
});
|
|
21
|
+
|
|
8
22
|
test('activityDeduplicator keeps one visible entry for repeated 2 percent polls', () => {
|
|
9
23
|
const events = Array.from({ length: 50 }, () => ({
|
|
10
24
|
type: 'activity_upserted',
|
|
@@ -54,7 +68,7 @@ test('aggregateActivity exposes initial synthesis and grouped display lines', ()
|
|
|
54
68
|
|
|
55
69
|
assert.deepEqual(activity.initialSynthesis, ['120 sources detectees', '6 traitements simultanes recommandes']);
|
|
56
70
|
assert.equal(activity.progress.percent, 54);
|
|
57
|
-
assert.ok(activity.lines.some((line) => /\[
|
|
71
|
+
assert.ok(activity.lines.some((line) => /\[✓\] collect - done/.test(line.label)));
|
|
58
72
|
assert.ok(activity.lines.some((line) => /\[\.\.\.\] customer-data\.enrich - 63 %/.test(line.label)));
|
|
59
73
|
const enrichLine = activity.lines.find((line) => /customer-data\.enrich/.test(line.label));
|
|
60
74
|
assert.equal(enrichLine.progress.label, 'Export rapport.md');
|
|
@@ -92,7 +106,7 @@ test('aggregateActivity keeps activities not attached to any plan task visible',
|
|
|
92
106
|
|
|
93
107
|
const aggregated = aggregateActivity(state, []);
|
|
94
108
|
const labels = aggregated.lines.map((line) => line.label).join('\n');
|
|
95
|
-
assert.match(labels, /\[
|
|
109
|
+
assert.match(labels, /\[✓\] .* done/, 'the done plan group stays visible');
|
|
96
110
|
assert.match(labels, /Ingest b87acaf6/, 'the unattached running ingest must appear');
|
|
97
111
|
const ingestLine = aggregated.lines.find((line) => /Ingest/.test(line.label));
|
|
98
112
|
assert.equal(ingestLine.status, 'running');
|
package/src/cli/wiki-manager.js
CHANGED
|
@@ -114,6 +114,18 @@ export async function forwardRuntimeApproval(getWorkspaceContext, request = {})
|
|
|
114
114
|
return context.approvalManager?.approve(request) ?? { approved: false };
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
export function resolvePreparedDelegationApproval({
|
|
118
|
+
autoApprove = false,
|
|
119
|
+
approvalManager = null,
|
|
120
|
+
runId,
|
|
121
|
+
} = {}) {
|
|
122
|
+
if (autoApprove !== true || typeof approvalManager?.approve !== 'function') {
|
|
123
|
+
return { approved: false, awaitingApproval: true };
|
|
124
|
+
}
|
|
125
|
+
const result = approvalManager.approve({ scope: 'run', runId });
|
|
126
|
+
return { approved: true, awaitingApproval: false, result };
|
|
127
|
+
}
|
|
128
|
+
|
|
117
129
|
function timestampForFile() {
|
|
118
130
|
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
119
131
|
}
|
|
@@ -923,14 +935,24 @@ async function runRuntime(argv, agent) {
|
|
|
923
935
|
throw new Error(`Delegated plan integration failed: ${(integrated.errors ?? []).map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
|
|
924
936
|
}
|
|
925
937
|
emitRuntimeLog(session, `delegation: ${prepared.fragment.tasks.length} validated task(s) integrated from ${prepared.provider.serverName}.agent_plan (${prepared.capability}/${prepared.operation})`);
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
938
|
+
// Real approval gate (opt-out): a directly-delegated run only skips the
|
|
939
|
+
// human approval step when the caller explicitly opts in via
|
|
940
|
+
// `autoApprove` (e.g. headless/CI, or a future "trust this run" toggle).
|
|
941
|
+
// By default the run WAITS: integrate() above created the per-task
|
|
942
|
+
// approval requests, and the scheduler's approvalCovered() filter blocks
|
|
943
|
+
// the mutating tasks until a run-scope grant arrives (/approve or
|
|
944
|
+
// "valide tout"). This keeps a visible pending_approval window instead of
|
|
945
|
+
// resolving it programmatically ~30ms after launch, which no polled UI
|
|
946
|
+
// could ever render.
|
|
947
|
+
const approval = resolvePreparedDelegationApproval({
|
|
948
|
+
autoApprove: body.autoApprove,
|
|
949
|
+
approvalManager: context.approvalManager,
|
|
950
|
+
runId,
|
|
951
|
+
});
|
|
952
|
+
if (approval.approved) {
|
|
953
|
+
emitRuntimeLog(session, `approval: run ${runId} auto-approved (autoApprove opt-in)`);
|
|
954
|
+
} else {
|
|
955
|
+
emitRuntimeLog(session, `approval: run ${runId} awaiting explicit approval before mutations (/approve or « valide tout »)`);
|
|
934
956
|
}
|
|
935
957
|
body._planReady?.resolve?.({ runId, planRevision: session.agentProjection?.planRevision ?? 0 });
|
|
936
958
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
forwardRuntimeApproval,
|
|
5
|
+
resolvePreparedDelegationApproval,
|
|
6
|
+
} from './wiki-manager.js';
|
|
4
7
|
|
|
5
8
|
test('runtime approval bridge preserves the complete run-scoped grant', async () => {
|
|
6
9
|
let forwarded = null;
|
|
@@ -26,3 +29,39 @@ test('runtime approval bridge preserves the complete run-scoped grant', async ()
|
|
|
26
29
|
assert.deepEqual(forwarded, request);
|
|
27
30
|
assert.deepEqual(result, { approved: true });
|
|
28
31
|
});
|
|
32
|
+
|
|
33
|
+
test('prepared delegation waits for explicit approval by default', () => {
|
|
34
|
+
let calls = 0;
|
|
35
|
+
const result = resolvePreparedDelegationApproval({
|
|
36
|
+
runId: 'run-gated',
|
|
37
|
+
approvalManager: {
|
|
38
|
+
approve() {
|
|
39
|
+
calls += 1;
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
assert.equal(calls, 0);
|
|
45
|
+
assert.deepEqual(result, { approved: false, awaitingApproval: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('prepared delegation only approves when autoApprove is explicitly true', () => {
|
|
49
|
+
let forwarded = null;
|
|
50
|
+
const result = resolvePreparedDelegationApproval({
|
|
51
|
+
autoApprove: true,
|
|
52
|
+
runId: 'run-headless',
|
|
53
|
+
approvalManager: {
|
|
54
|
+
approve(request) {
|
|
55
|
+
forwarded = request;
|
|
56
|
+
return { approved: true };
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
assert.deepEqual(forwarded, { scope: 'run', runId: 'run-headless' });
|
|
62
|
+
assert.deepEqual(result, {
|
|
63
|
+
approved: true,
|
|
64
|
+
awaitingApproval: false,
|
|
65
|
+
result: { approved: true },
|
|
66
|
+
});
|
|
67
|
+
});
|
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.14.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.14.20';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
package/src/core/workflow.js
CHANGED
|
@@ -107,11 +107,83 @@ export function projectWorkflow(state = {}, events = []) {
|
|
|
107
107
|
activity,
|
|
108
108
|
waitingReasons,
|
|
109
109
|
warnings,
|
|
110
|
+
usage: summarizeTokenUsage(events),
|
|
111
|
+
timingByTask: summarizeTaskTiming(events),
|
|
110
112
|
};
|
|
111
113
|
projected.graph = aggregateGraph(projected, events);
|
|
112
114
|
return projected;
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
// Per-task wall-clock timing, derived from the lifecycle events. Start = the
|
|
118
|
+
// first assigned/started event, finish = the last terminal event. Display-only
|
|
119
|
+
// (the inspector shows duration + orders tasks into a temporal flow); never used
|
|
120
|
+
// for scheduling.
|
|
121
|
+
function summarizeTaskTiming(events = []) {
|
|
122
|
+
const byTask = {};
|
|
123
|
+
const START_EVENTS = new Set(['task.assigned', 'task.started']);
|
|
124
|
+
const END_EVENTS = new Set(['task.completed', 'task.failed', 'task.result_returned']);
|
|
125
|
+
for (const event of events) {
|
|
126
|
+
if (!START_EVENTS.has(event.type) && !END_EVENTS.has(event.type)) continue;
|
|
127
|
+
const taskId = String(event.taskId ?? event.payload?.taskId ?? '').replace(/^task:/, '');
|
|
128
|
+
if (!taskId) continue;
|
|
129
|
+
const ts = Number(new Date(event.ts).getTime());
|
|
130
|
+
if (!Number.isFinite(ts)) continue;
|
|
131
|
+
const entry = byTask[taskId] ?? (byTask[taskId] = { startedAt: null, finishedAt: null, durationMs: null });
|
|
132
|
+
if (START_EVENTS.has(event.type)) {
|
|
133
|
+
entry.startedAt = entry.startedAt == null ? ts : Math.min(entry.startedAt, ts);
|
|
134
|
+
} else {
|
|
135
|
+
entry.finishedAt = entry.finishedAt == null ? ts : Math.max(entry.finishedAt, ts);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const entry of Object.values(byTask)) {
|
|
139
|
+
if (entry.startedAt != null && entry.finishedAt != null && entry.finishedAt >= entry.startedAt) {
|
|
140
|
+
entry.durationMs = entry.finishedAt - entry.startedAt;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return byTask;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function summarizeTokenUsage(events = []) {
|
|
147
|
+
let inputTokens = 0;
|
|
148
|
+
let outputTokens = 0;
|
|
149
|
+
let totalTokens = 0;
|
|
150
|
+
let inputKnown = false;
|
|
151
|
+
let outputKnown = false;
|
|
152
|
+
let totalKnown = false;
|
|
153
|
+
const byTask = {};
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
for (const event of events) {
|
|
156
|
+
if (!['task.result_returned', 'task.completed', 'task.failed'].includes(event.type)) continue;
|
|
157
|
+
const result = event.payload?.result ?? {};
|
|
158
|
+
const metrics = result.metrics ?? event.payload?.metrics ?? {};
|
|
159
|
+
const attemptId = result.attemptId ?? event.payload?.attemptId ?? event.id;
|
|
160
|
+
if (attemptId && seen.has(attemptId)) continue;
|
|
161
|
+
if (attemptId) seen.add(attemptId);
|
|
162
|
+
const input = metricNumber(metrics.inputTokens ?? metrics.promptTokens ?? metrics.prompt_tokens);
|
|
163
|
+
const output = metricNumber(metrics.outputTokens ?? metrics.completionTokens ?? metrics.completion_tokens);
|
|
164
|
+
const total = metricNumber(metrics.totalTokens ?? metrics.total_tokens ?? metrics.tokens);
|
|
165
|
+
if (input != null) { inputTokens += input; inputKnown = true; }
|
|
166
|
+
if (output != null) { outputTokens += output; outputKnown = true; }
|
|
167
|
+
if (total != null) { totalTokens += total; totalKnown = true; }
|
|
168
|
+
else if (input != null || output != null) { totalTokens += (input ?? 0) + (output ?? 0); totalKnown = true; }
|
|
169
|
+
const taskId = String(event.taskId ?? event.payload?.taskId ?? '');
|
|
170
|
+
if (taskId && (input != null || output != null || total != null)) {
|
|
171
|
+
const current = byTask[taskId] ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0, inputKnown: false, outputKnown: false, totalKnown: false };
|
|
172
|
+
if (input != null) { current.inputTokens += input; current.inputKnown = true; }
|
|
173
|
+
if (output != null) { current.outputTokens += output; current.outputKnown = true; }
|
|
174
|
+
current.totalTokens += total ?? ((input ?? 0) + (output ?? 0));
|
|
175
|
+
current.totalKnown = true;
|
|
176
|
+
byTask[taskId] = current;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { inputTokens, outputTokens, totalTokens, inputKnown, outputKnown, totalKnown, byTask };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function metricNumber(value) {
|
|
183
|
+
const number = Number(value);
|
|
184
|
+
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
115
187
|
function currentRun(state, events) {
|
|
116
188
|
const runId = state.runId ?? state.runs?.find((run) => isActiveStatus(run.status))?.id ?? events.findLast?.((event) => event.runId)?.runId ?? null;
|
|
117
189
|
if (!runId && !state.status) return null;
|
|
@@ -64,3 +64,60 @@ test('projectWorkflow reports approval and queue waiting reasons', () => {
|
|
|
64
64
|
assert.ok(workflow.waitingReasons.includes('approval:approval-1'));
|
|
65
65
|
assert.ok(workflow.waitingReasons.includes('queue:queued-1'));
|
|
66
66
|
});
|
|
67
|
+
|
|
68
|
+
test('projectWorkflow aggregates input and output tokens once per attempt', () => {
|
|
69
|
+
const state = {
|
|
70
|
+
status: 'done',
|
|
71
|
+
runId: 'run-usage',
|
|
72
|
+
plan: [{ id: 'build', description: 'Build', status: 'done' }],
|
|
73
|
+
activities: [],
|
|
74
|
+
queue: [],
|
|
75
|
+
approvals: [],
|
|
76
|
+
};
|
|
77
|
+
const result = {
|
|
78
|
+
attemptId: 'attempt-1',
|
|
79
|
+
metrics: { inputTokens: 1200, outputTokens: 300, totalTokens: 1500 },
|
|
80
|
+
};
|
|
81
|
+
const workflow = projectWorkflow(state, [
|
|
82
|
+
{ id: 'event-1', type: 'task.result_returned', runId: 'run-usage', taskId: 'build', payload: { result } },
|
|
83
|
+
{ id: 'event-2', type: 'task.completed', runId: 'run-usage', taskId: 'build', payload: { result } },
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
assert.deepEqual(workflow.usage, {
|
|
87
|
+
inputTokens: 1200,
|
|
88
|
+
outputTokens: 300,
|
|
89
|
+
totalTokens: 1500,
|
|
90
|
+
inputKnown: true,
|
|
91
|
+
outputKnown: true,
|
|
92
|
+
totalKnown: true,
|
|
93
|
+
byTask: {
|
|
94
|
+
build: {
|
|
95
|
+
inputTokens: 1200,
|
|
96
|
+
outputTokens: 300,
|
|
97
|
+
totalTokens: 1500,
|
|
98
|
+
inputKnown: true,
|
|
99
|
+
outputKnown: true,
|
|
100
|
+
totalKnown: true,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('projectWorkflow derives per-task timing (start, finish, duration) from lifecycle events', () => {
|
|
107
|
+
const state = {
|
|
108
|
+
status: 'done',
|
|
109
|
+
runId: 'run-timing',
|
|
110
|
+
plan: [{ id: 'ingest', description: 'Ingest', status: 'done' }],
|
|
111
|
+
activities: [],
|
|
112
|
+
queue: [],
|
|
113
|
+
approvals: [],
|
|
114
|
+
};
|
|
115
|
+
const workflow = projectWorkflow(state, [
|
|
116
|
+
{ id: 'e1', type: 'task.started', runId: 'run-timing', taskId: 'ingest', ts: '2026-07-23T10:00:00.000Z', payload: {} },
|
|
117
|
+
{ id: 'e2', type: 'task.completed', runId: 'run-timing', taskId: 'ingest', ts: '2026-07-23T10:00:12.500Z', payload: {} },
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
assert.equal(workflow.timingByTask.ingest.startedAt, Date.parse('2026-07-23T10:00:00.000Z'));
|
|
121
|
+
assert.equal(workflow.timingByTask.ingest.finishedAt, Date.parse('2026-07-23T10:00:12.500Z'));
|
|
122
|
+
assert.equal(workflow.timingByTask.ingest.durationMs, 12500);
|
|
123
|
+
});
|
|
@@ -9,7 +9,11 @@ export function resolveSchedulerConcurrency(value = process.env.WIKI_MANAGER_SCH
|
|
|
9
9
|
: DEFAULT_SCHEDULER_CONCURRENCY;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// Display-only breakdown of the SAME computation resolvePlanConcurrency uses.
|
|
13
|
+
// resolvePlanConcurrency delegates to this so the number surfaced to the UIs can
|
|
14
|
+
// never diverge from the number the scheduler actually enforces. Never used to
|
|
15
|
+
// gate scheduling — only `.limit` feeds startReadyTasks.
|
|
16
|
+
export function describePlanConcurrency({ plan = [], agents = [], configured = null } = {}) {
|
|
13
17
|
const capabilities = new Set(plan.map((task) => task?.requiredCapability).filter(Boolean).map(String));
|
|
14
18
|
const assignedAgents = new Set(plan.map((task) => task?.agentInstanceId).filter(Boolean).map(String));
|
|
15
19
|
const relevantAgents = agents.filter((agent) => {
|
|
@@ -17,12 +21,28 @@ export function resolvePlanConcurrency({ plan = [], agents = [], configured = nu
|
|
|
17
21
|
if (id && assignedAgents.has(id)) return true;
|
|
18
22
|
return (agent?.description?.capabilities ?? []).some((capability) => capabilities.has(String(capability?.id ?? '')));
|
|
19
23
|
});
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
const ceiling = positiveInteger(configured);
|
|
25
|
+
const agentValues = relevantAgents.flatMap(concurrencyValues).filter(Boolean);
|
|
26
|
+
const taskValues = plan.flatMap(concurrencyValues).filter(Boolean);
|
|
27
|
+
const values = [ceiling, ...taskValues, ...agentValues].filter(Boolean);
|
|
28
|
+
const limit = values.length > 0 ? Math.max(1, Math.min(...values)) : DEFAULT_SCHEDULER_CONCURRENCY;
|
|
29
|
+
const otherMin = [...taskValues, ...agentValues].length > 0
|
|
30
|
+
? Math.min(...taskValues, ...agentValues)
|
|
31
|
+
: null;
|
|
32
|
+
// The manager ceiling "bit" when it is the (uniquely) binding constraint: it
|
|
33
|
+
// is set, equals the resolved limit, and is strictly below every other input.
|
|
34
|
+
const cappedByCeiling = ceiling != null && limit === ceiling && (otherMin == null || ceiling < otherMin);
|
|
35
|
+
return {
|
|
36
|
+
limit,
|
|
37
|
+
ceiling: ceiling ?? null,
|
|
38
|
+
agentLimit: agentValues.length > 0 ? Math.min(...agentValues) : null,
|
|
39
|
+
taskLimit: taskValues.length > 0 ? Math.min(...taskValues) : null,
|
|
40
|
+
cappedByCeiling,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolvePlanConcurrency(options = {}) {
|
|
45
|
+
return describePlanConcurrency(options).limit;
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
export function resolveCapabilityConcurrency(agent = null, ...constraints) {
|
|
@@ -7,6 +7,7 @@ import { createBudgetManager } from './budgetManager.js';
|
|
|
7
7
|
import { readyTasks, tasksAwaitingApproval } from './dependencyResolver.js';
|
|
8
8
|
import { createLockManager } from './lockManager.js';
|
|
9
9
|
import {
|
|
10
|
+
describePlanConcurrency,
|
|
10
11
|
effectiveConcurrency,
|
|
11
12
|
resolveCapabilityConcurrency,
|
|
12
13
|
resolvePlanConcurrency,
|
|
@@ -141,6 +142,28 @@ test('scheduler uses the relevant agent declaration instead of hard-capping plan
|
|
|
141
142
|
assert.equal(resolvePlanConcurrency({ plan, agents, configured: 20 }), 10);
|
|
142
143
|
});
|
|
143
144
|
|
|
145
|
+
test('describePlanConcurrency mirrors resolvePlanConcurrency and flags the ceiling', () => {
|
|
146
|
+
const plan = [{ id: 'task-1', requiredCapability: 'ingest' }];
|
|
147
|
+
const agents = [{ description: { capabilities: [{ id: 'ingest' }], limits: { recommendedConcurrency: 10, maxConcurrency: 12 } } }];
|
|
148
|
+
|
|
149
|
+
// The number must never diverge from what the scheduler enforces.
|
|
150
|
+
for (const configured of [undefined, 3, 20]) {
|
|
151
|
+
const opts = configured === undefined ? { plan, agents } : { plan, agents, configured };
|
|
152
|
+
assert.equal(describePlanConcurrency(opts).limit, resolvePlanConcurrency(opts));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Ceiling binds → flagged.
|
|
156
|
+
const capped = describePlanConcurrency({ plan, agents, configured: 3 });
|
|
157
|
+
assert.equal(capped.limit, 3);
|
|
158
|
+
assert.equal(capped.ceiling, 3);
|
|
159
|
+
assert.equal(capped.cappedByCeiling, true);
|
|
160
|
+
|
|
161
|
+
// Ceiling above the agent declaration → does not bind, not flagged.
|
|
162
|
+
const loose = describePlanConcurrency({ plan, agents, configured: 20 });
|
|
163
|
+
assert.equal(loose.limit, 10);
|
|
164
|
+
assert.equal(loose.cappedByCeiling, false);
|
|
165
|
+
});
|
|
166
|
+
|
|
144
167
|
test('scheduler ignores unrelated agents and falls back to three without declarations', () => {
|
|
145
168
|
const plan = [{ id: 'task-1', requiredCapability: 'ingest' }];
|
|
146
169
|
const agents = [{
|
package/src/runtime/runner.js
CHANGED
|
@@ -11,7 +11,7 @@ import { approvalRequestForTask } from '../orchestrator/approvalPolicy.js';
|
|
|
11
11
|
import { PENDING_STATUSES, tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
|
|
12
12
|
import { assertValidatedFragment } from '../orchestrator/planValidator.js';
|
|
13
13
|
import { createResultAggregator } from '../orchestrator/resultAggregator.js';
|
|
14
|
-
import {
|
|
14
|
+
import { describePlanConcurrency, drainActive, startReadyTasks } from '../orchestrator/scheduler.js';
|
|
15
15
|
import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
|
|
16
16
|
|
|
17
17
|
// 0 by default: automatic replans turn evaluator/replanner TEXT into
|
|
@@ -363,11 +363,24 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
363
363
|
const configuredConcurrency = Number(concurrency) > 0
|
|
364
364
|
? Number(concurrency)
|
|
365
365
|
: Number(process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY || process.env.WIKI_MANAGER_SCHEDULER_CONCURRENCY);
|
|
366
|
-
const
|
|
366
|
+
const concurrencyDetail = describePlanConcurrency({
|
|
367
367
|
plan: session.headlessPlan ?? [],
|
|
368
368
|
agents,
|
|
369
369
|
configured: configuredConcurrency,
|
|
370
370
|
});
|
|
371
|
+
const limit = concurrencyDetail.limit;
|
|
372
|
+
// Publish the RESOLVED concurrency so both UIs show the real dispatch cap
|
|
373
|
+
// (not a value re-derived from the fragment). Display-only: scheduling still
|
|
374
|
+
// uses `limit` exactly as before. NOTE: this is a plain field assignment — do
|
|
375
|
+
// NOT emit a runtime_log here (it runs before ensurePlanProjection and would
|
|
376
|
+
// clobber session.headlessPlan via applyAgentProjectionToSession). The audit
|
|
377
|
+
// line is folded into the existing "parallel plan enabled" log below.
|
|
378
|
+
session._runConcurrency = {
|
|
379
|
+
limit,
|
|
380
|
+
ceiling: concurrencyDetail.ceiling,
|
|
381
|
+
agentLimit: concurrencyDetail.agentLimit,
|
|
382
|
+
cappedByCeiling: concurrencyDetail.cappedByCeiling,
|
|
383
|
+
};
|
|
371
384
|
const active = new Map();
|
|
372
385
|
const attempts = attemptManager ?? createAttemptManager();
|
|
373
386
|
const assigner = assignmentManager ?? createAssignmentManager({ session });
|
|
@@ -392,7 +405,7 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
392
405
|
};
|
|
393
406
|
sanitizeSessionPlanForExecution(session, runId);
|
|
394
407
|
ensurePlanProjection(session, runId);
|
|
395
|
-
emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit})`);
|
|
408
|
+
emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit}; agent=${concurrencyDetail.agentLimit ?? 'n/a'}, ceiling=${concurrencyDetail.ceiling ?? 'none'}${concurrencyDetail.cappedByCeiling ? ' → capped by manager ceiling' : ''})`);
|
|
396
409
|
// Interactive approvals do NOT expire: the user has /approve, "valide
|
|
397
410
|
// tout", /cancel and /run kill — an arbitrary timer only created mystery
|
|
398
411
|
// failures. A deadline exists only when explicitly configured (headless
|
|
@@ -534,6 +547,20 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
534
547
|
workspace: session.workspace ?? null,
|
|
535
548
|
payload: request,
|
|
536
549
|
}));
|
|
550
|
+
// Reflect the block on the task's plan step so the UIs can render it
|
|
551
|
+
// distinctly (amber "[⏸]" in the Shell, banner/badge in serve)
|
|
552
|
+
// instead of leaving it as a neutral "pending" indistinguishable
|
|
553
|
+
// from a not-started step. Only promote a plain pending task — never
|
|
554
|
+
// overwrite a status the planner already set (e.g. waiting_approval).
|
|
555
|
+
const currentStatus = String(task.status ?? '').toLowerCase();
|
|
556
|
+
if (!['pending_approval', 'waiting_approval'].includes(currentStatus)) {
|
|
557
|
+
dispatchAgentEvent(session, createAgentEvent('plan_step_updated', {
|
|
558
|
+
origin: 'runtime',
|
|
559
|
+
runId,
|
|
560
|
+
taskId,
|
|
561
|
+
payload: { taskId, status: 'pending_approval' },
|
|
562
|
+
}));
|
|
563
|
+
}
|
|
537
564
|
}
|
|
538
565
|
if (newlyRequested.length > 0) {
|
|
539
566
|
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
package/src/runtime/store.js
CHANGED
|
@@ -541,6 +541,12 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
541
541
|
const listApprovalGrantsByRunStatement = db.prepare(`
|
|
542
542
|
SELECT * FROM approval_grants WHERE run_id = ? ORDER BY created_at ASC, id ASC
|
|
543
543
|
`);
|
|
544
|
+
const listPendingApprovalGrantsByRunStatement = db.prepare(`
|
|
545
|
+
SELECT * FROM approval_grants WHERE run_id = ? AND status = 'pending_approval'
|
|
546
|
+
`);
|
|
547
|
+
const markApprovalGrantApprovedStatement = db.prepare(`
|
|
548
|
+
UPDATE approval_grants SET status = 'approved', granted_at = ? WHERE id = ?
|
|
549
|
+
`);
|
|
544
550
|
|
|
545
551
|
function persistEvent(event) {
|
|
546
552
|
if (NON_PERSISTED_EVENT_TYPES.has(event.type)) return event;
|
|
@@ -687,10 +693,12 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
687
693
|
const delResults = db.prepare('DELETE FROM task_results WHERE task_id IN (SELECT id FROM tasks WHERE run_id = ?)');
|
|
688
694
|
const delAssignments = db.prepare('DELETE FROM task_assignments WHERE task_id IN (SELECT id FROM tasks WHERE run_id = ?)');
|
|
689
695
|
const delAttempts = db.prepare('DELETE FROM task_attempts WHERE run_id = ?');
|
|
696
|
+
const delApprovals = db.prepare('DELETE FROM approval_grants WHERE run_id = ?');
|
|
690
697
|
for (const runId of runIds) {
|
|
691
698
|
delResults.run(runId);
|
|
692
699
|
delAssignments.run(runId);
|
|
693
700
|
delAttempts.run(runId);
|
|
701
|
+
delApprovals.run(runId);
|
|
694
702
|
}
|
|
695
703
|
const events = (workspace
|
|
696
704
|
? db.prepare('DELETE FROM events WHERE workspace = ?').run(workspace)
|
|
@@ -1020,6 +1028,29 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
1020
1028
|
status === 'approved' ? (payload.grantedAt ?? event.ts) : null,
|
|
1021
1029
|
status === 'rejected' ? (payload.rejectedAt ?? event.ts) : null,
|
|
1022
1030
|
);
|
|
1031
|
+
// Mirror the in-memory projection's markCoveredApprovalsApproved into the
|
|
1032
|
+
// persisted table: a broad (run/group) grant covers the per-task
|
|
1033
|
+
// pending_approval requests, so mark them approved here too. Without this,
|
|
1034
|
+
// the scheduler proceeds (the projection knows they're covered) but the
|
|
1035
|
+
// approval_grants table keeps them pending_approval forever — orphaned rows
|
|
1036
|
+
// that pile up (32 per run observed) and survive run purges.
|
|
1037
|
+
if (status === 'approved') {
|
|
1038
|
+
const grantedAt = payload.grantedAt ?? event.ts;
|
|
1039
|
+
const grant = {
|
|
1040
|
+
scope: String(payload.scope ?? 'run'),
|
|
1041
|
+
runId: String(runId),
|
|
1042
|
+
workspaceId: event.workspace ?? payload.workspaceId ?? payload.workspace ?? null,
|
|
1043
|
+
planRevision: payload.planRevision == null ? null : Number(payload.planRevision),
|
|
1044
|
+
groupId: payload.groupId ?? null,
|
|
1045
|
+
approvalClasses: normalizeApprovalClasses(payload.approvalClasses ?? payload.approvalClass),
|
|
1046
|
+
ids: new Set([payload.taskId, payload.itemId, payload.approvalId, payload.id, event.taskId]
|
|
1047
|
+
.filter(Boolean).map(String)),
|
|
1048
|
+
};
|
|
1049
|
+
for (const row of listPendingApprovalGrantsByRunStatement.all(String(runId))) {
|
|
1050
|
+
if (String(row.id) === String(id)) continue;
|
|
1051
|
+
if (persistedGrantCovers(grant, row)) markApprovalGrantApprovedStatement.run(grantedAt, row.id);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1023
1054
|
}
|
|
1024
1055
|
|
|
1025
1056
|
function lifecycleTaskId(event) {
|
|
@@ -1177,6 +1208,10 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
1177
1208
|
return {
|
|
1178
1209
|
...baseState,
|
|
1179
1210
|
runs,
|
|
1211
|
+
// Resolved scheduler concurrency for the live run (display-only). Null for
|
|
1212
|
+
// replayed/historical state — the UIs then fall back to the plan-derived
|
|
1213
|
+
// value. Never consumed by scheduling.
|
|
1214
|
+
concurrency: session?._runConcurrency ?? null,
|
|
1180
1215
|
workflow: projectWorkflow({ ...baseState, runs, workspace }, events ?? session?.agentEvents ?? []),
|
|
1181
1216
|
};
|
|
1182
1217
|
}
|
|
@@ -1281,9 +1316,11 @@ function purgeOldTerminalRuns(db, now = new Date()) {
|
|
|
1281
1316
|
db.exec('BEGIN');
|
|
1282
1317
|
try {
|
|
1283
1318
|
const deleteEvents = db.prepare('DELETE FROM events WHERE run_id = ? OR json_extract(payload, \'$.runId\') = ?');
|
|
1319
|
+
const deleteApprovals = db.prepare('DELETE FROM approval_grants WHERE run_id = ?');
|
|
1284
1320
|
const deleteRun = db.prepare('DELETE FROM runs WHERE id = ?');
|
|
1285
1321
|
for (const runId of oldRuns) {
|
|
1286
1322
|
deleteEvents.run(runId, runId);
|
|
1323
|
+
deleteApprovals.run(runId);
|
|
1287
1324
|
deleteRun.run(runId);
|
|
1288
1325
|
}
|
|
1289
1326
|
db.exec('COMMIT');
|
|
@@ -1403,6 +1440,23 @@ function normalizeApprovalClasses(value) {
|
|
|
1403
1440
|
return [...new Set(values.map((item) => String(item).trim()).filter(Boolean))];
|
|
1404
1441
|
}
|
|
1405
1442
|
|
|
1443
|
+
// Persisted-table twin of markCoveredApprovalsApproved (core/agentEvents.js):
|
|
1444
|
+
// does `grant` (already-approved) cover the pending approval_grants `row`?
|
|
1445
|
+
// Kept in sync with that reducer so the DB never disagrees with the projection.
|
|
1446
|
+
function persistedGrantCovers(grant, row) {
|
|
1447
|
+
if (grant.runId != null && row.run_id != null && String(grant.runId) !== String(row.run_id)) return false;
|
|
1448
|
+
if (grant.workspaceId != null && row.workspace_id != null && String(grant.workspaceId) !== String(row.workspace_id)) return false;
|
|
1449
|
+
if (grant.planRevision != null && row.plan_revision != null && Number(grant.planRevision) !== Number(row.plan_revision)) return false;
|
|
1450
|
+
const rowClasses = row.approval_classes ? normalizeApprovalClasses(parseJsonMaybe(row.approval_classes)) : [];
|
|
1451
|
+
if (grant.approvalClasses.length > 0 && rowClasses.length > 0 && !rowClasses.some((item) => grant.approvalClasses.includes(item))) return false;
|
|
1452
|
+
if (grant.scope === 'group' && String(grant.groupId ?? '') !== String(row.group_id ?? '')) return false;
|
|
1453
|
+
if (grant.scope === 'task' || grant.scope === 'tool') {
|
|
1454
|
+
const rowIds = [row.task_id, row.id].filter(Boolean).map(String);
|
|
1455
|
+
if (!rowIds.some((id) => grant.ids.has(id))) return false;
|
|
1456
|
+
}
|
|
1457
|
+
return true;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1406
1460
|
function parseJsonMaybe(value) {
|
|
1407
1461
|
try {
|
|
1408
1462
|
return JSON.parse(value);
|
|
@@ -540,6 +540,48 @@ test('runtime store persists bounded approval grants', () => {
|
|
|
540
540
|
store.close();
|
|
541
541
|
});
|
|
542
542
|
|
|
543
|
+
test('runtime store: a run-scope grant marks covered task grants approved (no orphans)', () => {
|
|
544
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
545
|
+
const store = openRuntimeStore({ stateDir });
|
|
546
|
+
// Two per-task approval requests for the same run.
|
|
547
|
+
for (const taskId of ['run-x:ingest-a', 'run-x:ingest-b']) {
|
|
548
|
+
store.persistEvent(createAgentEvent('approval.requested', {
|
|
549
|
+
origin: 'runtime',
|
|
550
|
+
runId: 'run-x',
|
|
551
|
+
workspace: 'docs',
|
|
552
|
+
taskId,
|
|
553
|
+
payload: { id: `approval:${taskId}`, scope: 'task', runId: 'run-x', workspaceId: 'docs', taskId },
|
|
554
|
+
}));
|
|
555
|
+
}
|
|
556
|
+
assert.equal(store.listApprovalGrants({ runId: 'run-x' }).filter((g) => g.status === 'pending_approval').length, 2);
|
|
557
|
+
|
|
558
|
+
// A single run-scope grant covers both.
|
|
559
|
+
store.persistEvent(createAgentEvent('approval.granted', {
|
|
560
|
+
origin: 'runtime',
|
|
561
|
+
runId: 'run-x',
|
|
562
|
+
workspace: 'docs',
|
|
563
|
+
payload: { id: 'grant-run-x', scope: 'run', runId: 'run-x', workspaceId: 'docs' },
|
|
564
|
+
}));
|
|
565
|
+
const grants = store.listApprovalGrants({ runId: 'run-x' });
|
|
566
|
+
assert.equal(grants.filter((g) => g.status === 'pending_approval').length, 0, 'no task grant left pending');
|
|
567
|
+
assert.equal(grants.filter((g) => g.status === 'approved').length, 3, 'both tasks + run grant approved');
|
|
568
|
+
store.close();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test('runtime store: clearWorkspaceState removes approval grants', () => {
|
|
572
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
573
|
+
const store = openRuntimeStore({ stateDir });
|
|
574
|
+
store.persistEvent(createAgentEvent('run_started', { origin: 'runtime', runId: 'run-y', workspace: 'docs', payload: { runId: 'run-y' } }));
|
|
575
|
+
store.persistEvent(createAgentEvent('approval.requested', {
|
|
576
|
+
origin: 'runtime', runId: 'run-y', workspace: 'docs', taskId: 'run-y:t',
|
|
577
|
+
payload: { id: 'approval:run-y:t', scope: 'task', runId: 'run-y', workspaceId: 'docs', taskId: 'run-y:t' },
|
|
578
|
+
}));
|
|
579
|
+
assert.equal(store.listApprovalGrants({ runId: 'run-y' }).length, 1);
|
|
580
|
+
store.clearWorkspaceState({ workspace: 'docs' });
|
|
581
|
+
assert.equal(store.listApprovalGrants({ runId: 'run-y' }).length, 0);
|
|
582
|
+
store.close();
|
|
583
|
+
});
|
|
584
|
+
|
|
543
585
|
test('runtime store orders events by durable sequence', () => {
|
|
544
586
|
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
545
587
|
const store = openRuntimeStore({ stateDir });
|
package/src/shell/RightPane.tsx
CHANGED
|
@@ -145,11 +145,16 @@ function activityPercentBadge(activity: any): { text: string; bg: string; fg: st
|
|
|
145
145
|
// pending icon). Shared by planStepColor and PlanPanel's icon().
|
|
146
146
|
const DONE_STATUSES = ['done', 'complete', 'completed', 'success', 'succeeded'];
|
|
147
147
|
const FAILED_STATUSES = ['failed', 'error'];
|
|
148
|
+
// A task blocked on a human decision — distinct from both running and pending,
|
|
149
|
+
// so it must not render as a neutral gray "[ ]" (indistinguishable from
|
|
150
|
+
// not-started) nor as a running step.
|
|
151
|
+
const APPROVAL_STATUSES = ['pending_approval', 'waiting_approval'];
|
|
148
152
|
|
|
149
153
|
function planStepColor(step: PlanStep, firstPendingStep: number | null) {
|
|
150
154
|
const status = String(step.status ?? '').toLowerCase();
|
|
151
155
|
if (DONE_STATUSES.includes(status)) return '#8BD5CA';
|
|
152
156
|
if (FAILED_STATUSES.includes(status)) return '#F38BA8';
|
|
157
|
+
if (APPROVAL_STATUSES.includes(status)) return '#FBBF24';
|
|
153
158
|
if (status === 'running') return '#89B4FA';
|
|
154
159
|
if (step.step === firstPendingStep) return '#89B4FA';
|
|
155
160
|
return '#7F8C8D';
|
|
@@ -175,7 +180,7 @@ function queueSummary(item: QueueItem) {
|
|
|
175
180
|
return parts.join(' ');
|
|
176
181
|
}
|
|
177
182
|
|
|
178
|
-
export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: string }) {
|
|
183
|
+
export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: string; summary?: string | null; spinnerFrame?: string }) {
|
|
179
184
|
// Keep one column for the native vertical scrollbar when the plan is long.
|
|
180
185
|
const lineWidth = () => Math.max(8, props.width - 3);
|
|
181
186
|
const firstPending = () => props.plan.find((s) => s.status === 'pending')?.step ?? null;
|
|
@@ -183,7 +188,11 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
|
|
|
183
188
|
const status = String(rawStatus ?? '').toLowerCase();
|
|
184
189
|
if (DONE_STATUSES.includes(status)) return '[✓]';
|
|
185
190
|
if (FAILED_STATUSES.includes(status)) return '[✗]';
|
|
186
|
-
|
|
191
|
+
if (APPROVAL_STATUSES.includes(status)) return '[⏸]';
|
|
192
|
+
// Animated braille spinner on the running step: a long production job (LLM
|
|
193
|
+
// building a template) otherwise leaves the whole plan visually frozen. Same
|
|
194
|
+
// frames as the chat spinner. Falls back to a static glyph if not supplied.
|
|
195
|
+
return status === 'running' ? `[${props.spinnerFrame ?? '…'}]` : '[ ]';
|
|
187
196
|
};
|
|
188
197
|
const visualRows = createMemo(() => props.plan.reduce((total, step) =>
|
|
189
198
|
total + wrapLine(`${icon(step.status)} ${step.step}. ${step.description}`, lineWidth()).slice(0, 2).length, 0));
|
|
@@ -192,9 +201,17 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
|
|
|
192
201
|
return visualRows() > PLAN_VIEWPORT_ROWS ? `${label} (${props.plan.length}) · scroll` : label;
|
|
193
202
|
};
|
|
194
203
|
const viewportRows = () => Math.min(PLAN_VIEWPORT_ROWS, Math.max(1, visualRows()));
|
|
204
|
+
const summaryLines = () => props.summary ? wrapLine(props.summary, lineWidth()).slice(0, 2) : [];
|
|
195
205
|
return (
|
|
196
206
|
<box flexShrink={0} flexDirection="column" padding={1}>
|
|
197
207
|
<text width={lineWidth()} fg="#D6DEE8" content={fit(title(), lineWidth())} />
|
|
208
|
+
<Show when={summaryLines().length > 0}>
|
|
209
|
+
<box flexShrink={0} flexDirection="column">
|
|
210
|
+
<Index each={summaryLines()}>
|
|
211
|
+
{(line) => <text width={lineWidth()} fg="#89B4FA" content={line()} />}
|
|
212
|
+
</Index>
|
|
213
|
+
</box>
|
|
214
|
+
</Show>
|
|
198
215
|
<scrollbox
|
|
199
216
|
height={viewportRows()}
|
|
200
217
|
focusable={false}
|
|
@@ -469,6 +486,7 @@ export function RightPane(props: {
|
|
|
469
486
|
activities: any[];
|
|
470
487
|
logs: string[];
|
|
471
488
|
plan: PlanStep[] | null;
|
|
489
|
+
runSummary?: string | null;
|
|
472
490
|
queueItems: QueueItem[];
|
|
473
491
|
queueInfo: QueueInfo;
|
|
474
492
|
activeTab: 'plan' | 'queue';
|
|
@@ -476,6 +494,7 @@ export function RightPane(props: {
|
|
|
476
494
|
pendingApprovals: any[];
|
|
477
495
|
onApprove: () => void;
|
|
478
496
|
onTabClick: (tab: 'plan' | 'queue') => void;
|
|
497
|
+
spinnerFrame?: string;
|
|
479
498
|
}) {
|
|
480
499
|
const planJobName = () => activityJobName(
|
|
481
500
|
[...props.activities].reverse().find((activity) => !activity.terminal) ?? props.activities.at(-1),
|
|
@@ -501,7 +520,7 @@ export function RightPane(props: {
|
|
|
501
520
|
<Show when={props.activeTab === 'queue'} fallback={(
|
|
502
521
|
<>
|
|
503
522
|
<Show when={props.plan && props.plan.length > 0}>
|
|
504
|
-
<PlanPanel width={props.width} plan={props.plan!} jobName={planJobName()} />
|
|
523
|
+
<PlanPanel width={props.width} plan={props.plan!} jobName={planJobName()} summary={props.runSummary} spinnerFrame={props.spinnerFrame} />
|
|
505
524
|
</Show>
|
|
506
525
|
<ActivityPanel width={props.width} activities={props.activities} />
|
|
507
526
|
</>
|
package/src/shell/repl.test.js
CHANGED
|
@@ -100,6 +100,19 @@ test('ShellUI lets the right pane extend to the terminal edge', async () => {
|
|
|
100
100
|
assert.doesNotMatch(pane, /height="100%" flexDirection="column" padding=\{1\}/);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
test('ShellUI shows the canonical run summary above the plan', async () => {
|
|
104
|
+
const session = await readFile(new URL('./useSession.ts', import.meta.url), 'utf8');
|
|
105
|
+
const pane = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
|
|
106
|
+
const tui = await readFile(new URL('./tui.tsx', import.meta.url), 'utf8');
|
|
107
|
+
assert.match(session, /const runSummary = createMemo/);
|
|
108
|
+
assert.match(session, /agent\$\{agents\.size === 1/);
|
|
109
|
+
assert.match(session, /parallel \$\{activeParallel\}\/×\$\{maxParallel\}/);
|
|
110
|
+
assert.match(session, /usage\.inputKnown/);
|
|
111
|
+
assert.match(session, /usage\.outputKnown/);
|
|
112
|
+
assert.match(pane, /summary=\{props\.runSummary\}/);
|
|
113
|
+
assert.match(tui, /runSummary=\{state\.runSummary\(\)\}/);
|
|
114
|
+
});
|
|
115
|
+
|
|
103
116
|
test('Flow/Trace does not repeat the runtime source prefix on every line', async () => {
|
|
104
117
|
const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
|
|
105
118
|
const entryRenderer = source.slice(
|
package/src/shell/tui.tsx
CHANGED
|
@@ -287,7 +287,10 @@ function App(props: {
|
|
|
287
287
|
});
|
|
288
288
|
|
|
289
289
|
const spinnerTimer = setInterval(() => {
|
|
290
|
-
|
|
290
|
+
// Advance while the chat is streaming OR a runtime run is executing. Without
|
|
291
|
+
// executionActive(), the plan spinner froze on frame 0 during a scheduler-
|
|
292
|
+
// driven run (no conversation turn), so it looked static.
|
|
293
|
+
if (state.busy() || state.executionActive()) setSpinnerIndex((value) => (value + 1) % 10);
|
|
291
294
|
}, 90);
|
|
292
295
|
onCleanup(() => {
|
|
293
296
|
clearInterval(spinnerTimer);
|
|
@@ -392,6 +395,7 @@ function App(props: {
|
|
|
392
395
|
activities={state.activities()}
|
|
393
396
|
logs={state.logs()}
|
|
394
397
|
plan={state.plan()}
|
|
398
|
+
runSummary={state.runSummary()}
|
|
395
399
|
queueItems={state.queueItems()}
|
|
396
400
|
queueInfo={state.queueInfo()}
|
|
397
401
|
activeTab={state.rightTab()}
|
|
@@ -399,6 +403,7 @@ function App(props: {
|
|
|
399
403
|
pendingApprovals={state.pendingApprovals()}
|
|
400
404
|
onApprove={() => { void state.submitInput('/approve'); }}
|
|
401
405
|
onTabClick={state.selectRightTab}
|
|
406
|
+
spinnerFrame={SPINNER_FRAMES[spinnerIndex()] ?? SPINNER_FRAMES[0]}
|
|
402
407
|
/>
|
|
403
408
|
<SlashDialog context={state.activeEditor() ? null : state.slash()} />
|
|
404
409
|
<FileEditorDialog
|
package/src/shell/useSession.ts
CHANGED
|
@@ -321,6 +321,52 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
321
321
|
? lastVisiblePlan.map((step) => ({ ...step }))
|
|
322
322
|
: current;
|
|
323
323
|
});
|
|
324
|
+
const runSummary = createMemo(() => {
|
|
325
|
+
version();
|
|
326
|
+
const workflow = runtimeState()?.workflow;
|
|
327
|
+
const tasks = Array.isArray(workflow?.nodes)
|
|
328
|
+
? workflow.nodes.filter((node: any) => node.type === 'task')
|
|
329
|
+
: [];
|
|
330
|
+
if (tasks.length === 0) return null;
|
|
331
|
+
|
|
332
|
+
const graphNodes = Array.isArray(workflow?.graph?.nodes) ? workflow.graph.nodes : [];
|
|
333
|
+
const graphEdges = Array.isArray(workflow?.graph?.edges) ? workflow.graph.edges : [];
|
|
334
|
+
const agents = new Set<string>();
|
|
335
|
+
for (const task of tasks) {
|
|
336
|
+
if (task.executor) agents.add(String(task.executor));
|
|
337
|
+
const assignmentIds = graphEdges
|
|
338
|
+
.filter((edge: any) => edge.type === 'assigned_to' && edge.from === task.id)
|
|
339
|
+
.map((edge: any) => edge.to);
|
|
340
|
+
for (const assignmentId of assignmentIds) {
|
|
341
|
+
graphEdges
|
|
342
|
+
.filter((edge: any) => edge.type === 'uses_agent' && edge.from === assignmentId)
|
|
343
|
+
.forEach((edge: any) => agents.add(String(edge.to).replace(/^agent:/, '')));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const activeParallel = tasks.filter((task: any) => String(task.status) === 'running').length;
|
|
348
|
+
const groupConcurrency = graphNodes
|
|
349
|
+
.filter((node: any) => node.type === 'task_group')
|
|
350
|
+
.map((node: any) => Number(node.raw?.recommendedConcurrency))
|
|
351
|
+
.filter((value: number) => Number.isFinite(value) && value > 0);
|
|
352
|
+
// Authoritative resolved concurrency published by the runtime; fall back to
|
|
353
|
+
// the plan-derived value for replayed/historical runs.
|
|
354
|
+
const resolved = runtimeState()?.concurrency;
|
|
355
|
+
const maxParallel = Number.isFinite(Number(resolved?.limit))
|
|
356
|
+
? Number(resolved.limit)
|
|
357
|
+
: Math.max(1, activeParallel, ...groupConcurrency);
|
|
358
|
+
const done = tasks.filter((task: any) => String(task.status) === 'done').length;
|
|
359
|
+
const usage = workflow?.usage ?? {};
|
|
360
|
+
const tokens = (known: unknown, value: unknown) =>
|
|
361
|
+
known ? new Intl.NumberFormat().format(Number(value) || 0) : '—';
|
|
362
|
+
|
|
363
|
+
return [
|
|
364
|
+
`${agents.size} agent${agents.size === 1 ? '' : 's'}`,
|
|
365
|
+
`parallel ${activeParallel}/×${maxParallel}${resolved?.cappedByCeiling ? ' (ceiling)' : ''}`,
|
|
366
|
+
`${done}/${tasks.length} tasks`,
|
|
367
|
+
`${tokens(usage.inputKnown, usage.inputTokens)} in · ${tokens(usage.outputKnown, usage.outputTokens)} out`,
|
|
368
|
+
].join(' · ');
|
|
369
|
+
});
|
|
324
370
|
const visibleLogs = createMemo(() => {
|
|
325
371
|
version();
|
|
326
372
|
const runtimeLogs = runtimeState()?.logs;
|
|
@@ -728,6 +774,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
728
774
|
toggleRightTab,
|
|
729
775
|
selectRightTab,
|
|
730
776
|
plan,
|
|
777
|
+
runSummary,
|
|
731
778
|
pendingApprovals,
|
|
732
779
|
conversationScroll,
|
|
733
780
|
scrollConversation,
|