@dotdrelle/wiki-manager 0.11.9 → 0.12.0
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/README.md +32 -7
- package/agents.docker-compose.yml +3 -0
- package/docker-compose.yml +1 -0
- package/package.json +3 -2
- package/src/activity/activityAggregator.js +109 -0
- package/src/activity/activityAggregator.test.js +60 -0
- package/src/activity/activityDeduplicator.js +50 -0
- package/src/activity/progressCalculator.js +61 -0
- package/src/activity/runSynthesis.js +15 -0
- package/src/agent/graph.js +65 -30
- package/src/agent/graph.test.js +28 -5
- package/src/cli/wiki-manager.js +22 -0
- package/src/contracts/schemas.js +249 -13
- package/src/contracts/schemas.test.js +145 -0
- package/src/core/activity.js +8 -5
- package/src/core/agentEvents.js +260 -23
- package/src/core/agentEvents.test.js +53 -0
- package/src/core/compose.js +6 -1
- package/src/core/dockerCompose.test.js +12 -0
- package/src/core/documentIntake.js +33 -38
- package/src/core/documentIntake.test.js +25 -0
- package/src/core/env.js +11 -1
- package/src/core/jobQueue.js +26 -2
- package/src/core/mcp.js +1 -1
- package/src/core/plan.js +46 -4
- package/src/core/plan.test.js +16 -1
- package/src/core/planPatch.js +64 -3
- package/src/core/planPatch.test.js +110 -1
- package/src/core/queueStore.test.js +21 -0
- package/src/core/runtimeLog.js +119 -0
- package/src/core/runtimeLog.test.js +84 -0
- package/src/core/wikirc.js +22 -0
- package/src/core/wikirc.test.js +49 -1
- package/src/core/workflow.js +14 -3
- package/src/graph/graphAggregator.js +5 -0
- package/src/graph/graphPatch.js +8 -0
- package/src/graph/graphSnapshot.js +14 -0
- package/src/graph/graphVisibilityPolicy.js +40 -0
- package/src/graph/runGraphProjector.js +87 -0
- package/src/graph/runGraphProjector.test.js +111 -0
- package/src/orchestrator/agentRegistry.js +172 -0
- package/src/orchestrator/agentRegistry.test.js +114 -0
- package/src/orchestrator/approvalPolicy.js +126 -0
- package/src/orchestrator/approvalPolicy.test.js +78 -0
- package/src/orchestrator/assignmentManager.js +80 -0
- package/src/orchestrator/attemptManager.js +168 -0
- package/src/orchestrator/attemptManager.test.js +160 -0
- package/src/orchestrator/budgetManager.js +127 -0
- package/src/orchestrator/capabilityRegistry.js +64 -0
- package/src/orchestrator/capabilityRegistry.test.js +59 -0
- package/src/orchestrator/capabilityResolver.js +151 -0
- package/src/orchestrator/capabilityResolver.test.js +127 -0
- package/src/orchestrator/dependencyResolver.js +112 -0
- package/src/orchestrator/dispatcher.js +302 -0
- package/src/orchestrator/lockManager.js +51 -0
- package/src/orchestrator/planIntegrator.js +268 -0
- package/src/orchestrator/planIntegrator.test.js +213 -0
- package/src/orchestrator/planValidator.js +534 -0
- package/src/orchestrator/planValidator.test.js +262 -0
- package/src/orchestrator/resultAggregator.js +248 -0
- package/src/orchestrator/resultAggregator.test.js +211 -0
- package/src/orchestrator/scheduler.js +103 -0
- package/src/orchestrator/scheduler.test.js +138 -0
- package/src/runtime/approvals.js +130 -1
- package/src/runtime/donna-contract.test.js +293 -30
- package/src/runtime/recoveryManager.js +176 -0
- package/src/runtime/recoveryManager.test.js +162 -0
- package/src/runtime/runner.e2e.test.js +98 -12
- package/src/runtime/runner.js +261 -227
- package/src/runtime/runner.test.js +249 -452
- package/src/runtime/server.js +137 -20
- package/src/runtime/server.test.js +137 -7
- package/src/runtime/store.js +837 -2
- package/src/runtime/store.test.js +245 -4
- package/src/runtime/supervisor.js +33 -1
- package/src/runtime/supervisor.test.js +47 -1
- package/src/shell/RightPane.tsx +8 -5
- package/src/shell/repl.js +4 -9
- package/src/shell/repl.test.js +25 -4
- package/src/shell/tui.tsx +1 -0
- package/src/shell/useSession.ts +38 -10
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
5
|
+
import { accept } from './resultAggregator.js';
|
|
6
|
+
|
|
7
|
+
test('resultAggregator expands a plan after a task result and rewires insertion after the task', async () => {
|
|
8
|
+
const session = sessionWithPlan();
|
|
9
|
+
session.approvals = [{ id: 'approval-run', scope: 'run', status: 'approved', runId: 'run-expansion' }];
|
|
10
|
+
const calls = [];
|
|
11
|
+
|
|
12
|
+
const result = await accept(taskResultWithExpansion(), {
|
|
13
|
+
session,
|
|
14
|
+
runId: 'run-expansion',
|
|
15
|
+
task: session.headlessPlan[0],
|
|
16
|
+
assignment: { agentInstanceId: 'production-main', serverName: 'production' },
|
|
17
|
+
registry: expansionRegistry(),
|
|
18
|
+
callTool: async (_mcp, serverName, toolName, args) => {
|
|
19
|
+
calls.push({ serverName, toolName, args });
|
|
20
|
+
assert.equal(serverName, 'production');
|
|
21
|
+
assert.equal(toolName, 'agent_plan');
|
|
22
|
+
return expansionFragment();
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
assert.equal(result.ok, true);
|
|
27
|
+
assert.equal(result.expansion.ok, true);
|
|
28
|
+
assert.deepEqual(calls.map((call) => call.args.capability), ['document.build']);
|
|
29
|
+
const plan = Object.fromEntries(session.headlessPlan.map((task) => [task.id, task]));
|
|
30
|
+
assert.deepEqual(plan['run-expansion:build-x'].dependsOn, ['run-expansion:a']);
|
|
31
|
+
assert.deepEqual(plan['run-expansion:build-y'].dependsOn, ['run-expansion:build-x']);
|
|
32
|
+
assert.deepEqual(plan['run-expansion:b'].dependsOn, ['run-expansion:build-y']);
|
|
33
|
+
assert.equal(plan['run-expansion:build-x'].status, 'pending');
|
|
34
|
+
assert.equal(session.planRevision, 1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('resultAggregator marks expanded tasks waiting_approval when approval is not covered', async () => {
|
|
38
|
+
const session = sessionWithPlan();
|
|
39
|
+
|
|
40
|
+
const result = await accept(taskResultWithExpansion(), {
|
|
41
|
+
session,
|
|
42
|
+
runId: 'run-expansion',
|
|
43
|
+
task: session.headlessPlan[0],
|
|
44
|
+
assignment: { agentInstanceId: 'production-main', serverName: 'production' },
|
|
45
|
+
registry: expansionRegistry(),
|
|
46
|
+
callTool: async () => expansionFragment(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
assert.equal(result.expansion.ok, true);
|
|
50
|
+
const created = session.headlessPlan.filter((task) => ['run-expansion:build-x', 'run-expansion:build-y'].includes(task.id));
|
|
51
|
+
assert.deepEqual(created.map((task) => task.status), ['waiting_approval', 'waiting_approval']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('resultAggregator rejects an expansion with an unknown capability and preserves task success', async () => {
|
|
55
|
+
const session = sessionWithPlan();
|
|
56
|
+
|
|
57
|
+
const result = await accept({
|
|
58
|
+
ok: true,
|
|
59
|
+
taskId: 'run-expansion:a',
|
|
60
|
+
status: 'succeeded',
|
|
61
|
+
outputRefs: [],
|
|
62
|
+
planExpansionRequest: {
|
|
63
|
+
capability: 'unknown.capability',
|
|
64
|
+
operation: 'expand',
|
|
65
|
+
arguments: {},
|
|
66
|
+
insertAfterTasks: ['run-expansion:a'],
|
|
67
|
+
},
|
|
68
|
+
}, {
|
|
69
|
+
session,
|
|
70
|
+
runId: 'run-expansion',
|
|
71
|
+
task: session.headlessPlan[0],
|
|
72
|
+
assignment: { agentInstanceId: 'production-main', serverName: 'production' },
|
|
73
|
+
registry: expansionRegistry(),
|
|
74
|
+
callTool: async () => assert.fail('agent_plan must not be called for an unknown capability'),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
assert.equal(result.ok, true);
|
|
78
|
+
assert.equal(result.expansion.ok, false);
|
|
79
|
+
assert.equal(result.expansion.errors[0].code, 'capability_unavailable');
|
|
80
|
+
assert.deepEqual(session.headlessPlan.map((task) => task.id), ['run-expansion:a', 'run-expansion:b']);
|
|
81
|
+
assert.equal(session.headlessPlan[0].status, 'done');
|
|
82
|
+
assert.ok(session.agentEvents.some((event) => event.type === 'plan.rejected'));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
function sessionWithPlan() {
|
|
86
|
+
const session = {
|
|
87
|
+
mcp: {
|
|
88
|
+
production: {
|
|
89
|
+
tools: [{ name: 'agent_plan' }],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
agentEvents: [],
|
|
93
|
+
activities: {},
|
|
94
|
+
workspace: 'docs',
|
|
95
|
+
headlessPlan: [
|
|
96
|
+
{
|
|
97
|
+
id: 'run-expansion:a',
|
|
98
|
+
step: 1,
|
|
99
|
+
label: 'Task A',
|
|
100
|
+
description: 'Task A',
|
|
101
|
+
status: 'running',
|
|
102
|
+
dependsOn: [],
|
|
103
|
+
outputRefs: [],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: 'run-expansion:b',
|
|
107
|
+
step: 2,
|
|
108
|
+
label: 'Task B',
|
|
109
|
+
description: 'Task B',
|
|
110
|
+
status: 'pending',
|
|
111
|
+
dependsOn: ['run-expansion:a'],
|
|
112
|
+
outputRefs: [],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
dispatchAgentEvent(session, createAgentEvent('plan_set', {
|
|
117
|
+
origin: 'test',
|
|
118
|
+
runId: 'run-expansion',
|
|
119
|
+
payload: { steps: session.headlessPlan, planRevision: 0 },
|
|
120
|
+
}));
|
|
121
|
+
session.agentEvents = [];
|
|
122
|
+
return session;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function taskResultWithExpansion() {
|
|
126
|
+
return {
|
|
127
|
+
ok: true,
|
|
128
|
+
taskId: 'run-expansion:a',
|
|
129
|
+
status: 'succeeded',
|
|
130
|
+
outputRefs: [{ type: 'file', ref: 'out/a.json' }],
|
|
131
|
+
planExpansionRequest: {
|
|
132
|
+
capability: 'document.build',
|
|
133
|
+
operation: 'build',
|
|
134
|
+
objective: 'Build downstream generated documents.',
|
|
135
|
+
arguments: { templates: ['templates/x.md'] },
|
|
136
|
+
insertAfterTasks: ['run-expansion:a'],
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function expansionFragment() {
|
|
142
|
+
return {
|
|
143
|
+
contractVersion: '1',
|
|
144
|
+
agentInstanceId: 'production-main',
|
|
145
|
+
capability: 'document.build',
|
|
146
|
+
summary: {
|
|
147
|
+
label: 'Build generated documents',
|
|
148
|
+
initialSynthesis: ['Build two generated documents.'],
|
|
149
|
+
estimatedTasks: 2,
|
|
150
|
+
},
|
|
151
|
+
groups: [{ id: 'build', label: 'Build', recommendedConcurrency: 1 }],
|
|
152
|
+
tasks: [
|
|
153
|
+
plannedBuildTask('build-x', []),
|
|
154
|
+
plannedBuildTask('build-y', ['build-x']),
|
|
155
|
+
],
|
|
156
|
+
expectedOutputs: [{ type: 'file', ref: 'deliverables/y.md' }],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function plannedBuildTask(id, dependsOn) {
|
|
161
|
+
return {
|
|
162
|
+
id,
|
|
163
|
+
label: `Build ${id}`,
|
|
164
|
+
requiredCapability: 'document.build',
|
|
165
|
+
operation: 'build',
|
|
166
|
+
arguments: { templates: [`templates/${id}.md`] },
|
|
167
|
+
groupId: 'build',
|
|
168
|
+
dependsOn,
|
|
169
|
+
parallelizable: true,
|
|
170
|
+
inputRefs: [{ type: 'file', ref: `templates/${id}.md` }],
|
|
171
|
+
expectedOutputRefs: [{ type: 'file', ref: `deliverables/${id}.md` }],
|
|
172
|
+
locks: [`deliverable:${id}.md`],
|
|
173
|
+
requiresApproval: true,
|
|
174
|
+
idempotencyKey: `idem-${id}`,
|
|
175
|
+
progressWeight: 1,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function expansionRegistry() {
|
|
180
|
+
return {
|
|
181
|
+
providersFor(capability) {
|
|
182
|
+
if (capability !== 'document.build') return [];
|
|
183
|
+
return [{
|
|
184
|
+
agentInstanceId: 'production-main',
|
|
185
|
+
serverName: 'production',
|
|
186
|
+
health: 'available',
|
|
187
|
+
capability: {
|
|
188
|
+
id: 'document.build',
|
|
189
|
+
version: '1',
|
|
190
|
+
description: 'Build documents',
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: 'object',
|
|
193
|
+
required: ['templates'],
|
|
194
|
+
additionalProperties: true,
|
|
195
|
+
properties: {
|
|
196
|
+
templates: { type: 'array', items: { type: 'string' } },
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
outputSchema: {},
|
|
200
|
+
supportedOperations: ['build'],
|
|
201
|
+
mutationClass: 'workspace',
|
|
202
|
+
defaultRequiresApproval: true,
|
|
203
|
+
},
|
|
204
|
+
description: { contractVersion: '1' },
|
|
205
|
+
}];
|
|
206
|
+
},
|
|
207
|
+
isCompatible(contractVersion) {
|
|
208
|
+
return String(contractVersion) === '1';
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readyTasks } from './dependencyResolver.js';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_SCHEDULER_CONCURRENCY = 3;
|
|
4
|
+
|
|
5
|
+
export function resolveSchedulerConcurrency(value = process.env.WIKI_MANAGER_SCHEDULER_CONCURRENCY) {
|
|
6
|
+
const parsed = Number(value);
|
|
7
|
+
return Number.isFinite(parsed) && parsed > 0
|
|
8
|
+
? Math.max(1, Math.floor(parsed))
|
|
9
|
+
: DEFAULT_SCHEDULER_CONCURRENCY;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function effectiveConcurrency(group = null, agent = null, donna = null, provider = null) {
|
|
13
|
+
const limits = [
|
|
14
|
+
...concurrencyValues(donna),
|
|
15
|
+
...concurrencyValues(group),
|
|
16
|
+
...concurrencyValues(provider),
|
|
17
|
+
...concurrencyValues(agent),
|
|
18
|
+
].filter((value) => value != null && value > 0);
|
|
19
|
+
return Math.max(1, Math.min(...(limits.length ? limits : [DEFAULT_SCHEDULER_CONCURRENCY])));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function drainActive(active, attemptManager = null) {
|
|
23
|
+
if (active.size === 0) return;
|
|
24
|
+
const entries = [...active.values()];
|
|
25
|
+
await Promise.all(entries.map((entry) => entry.promise));
|
|
26
|
+
for (const entry of entries) entry.cleanup?.();
|
|
27
|
+
active.clear();
|
|
28
|
+
attemptManager?.clear?.();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function startReadyTasks({
|
|
32
|
+
plan,
|
|
33
|
+
active,
|
|
34
|
+
attemptManager,
|
|
35
|
+
startTask,
|
|
36
|
+
limit,
|
|
37
|
+
session = null,
|
|
38
|
+
registry = null,
|
|
39
|
+
lockManager = null,
|
|
40
|
+
budgetManager = null,
|
|
41
|
+
approvals = [],
|
|
42
|
+
onDuplicateTask = null,
|
|
43
|
+
onTaskReady = null,
|
|
44
|
+
onAttemptCreated = null,
|
|
45
|
+
onTaskStarting = null,
|
|
46
|
+
} = {}) {
|
|
47
|
+
let started = 0;
|
|
48
|
+
const seenTaskIds = new Set(active.keys());
|
|
49
|
+
const ready = readyTasks(plan, {
|
|
50
|
+
registry,
|
|
51
|
+
lockManager,
|
|
52
|
+
budgetManager,
|
|
53
|
+
approvals,
|
|
54
|
+
activeTaskIds: active.keys(),
|
|
55
|
+
});
|
|
56
|
+
for (const task of ready) {
|
|
57
|
+
if (active.size >= limit) break;
|
|
58
|
+
const taskId = planTaskId(task);
|
|
59
|
+
if (active.has(taskId)) continue;
|
|
60
|
+
if (seenTaskIds.has(taskId)) {
|
|
61
|
+
onDuplicateTask?.(taskId, task);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
seenTaskIds.add(taskId);
|
|
65
|
+
onTaskReady?.(task);
|
|
66
|
+
const attempt = attemptManager.reserve(task);
|
|
67
|
+
if (!attempt) continue;
|
|
68
|
+
onAttemptCreated?.(task, attempt);
|
|
69
|
+
budgetManager?.recordTaskStart?.(task);
|
|
70
|
+
onTaskStarting?.(task, attempt);
|
|
71
|
+
const entry = startTask(task, attempt);
|
|
72
|
+
active.set(taskId, { taskId, ...entry });
|
|
73
|
+
started += 1;
|
|
74
|
+
}
|
|
75
|
+
return started;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function concurrencyValues(source) {
|
|
79
|
+
if (source == null) return [];
|
|
80
|
+
if (typeof source === 'number') return [positiveInteger(source)].filter(Boolean);
|
|
81
|
+
const candidates = [
|
|
82
|
+
source,
|
|
83
|
+
source.limits,
|
|
84
|
+
source.description?.limits,
|
|
85
|
+
source.capability?.limits,
|
|
86
|
+
].filter(Boolean);
|
|
87
|
+
return candidates.flatMap((limits) => [
|
|
88
|
+
limits.effectiveConcurrency,
|
|
89
|
+
limits.maxConcurrency,
|
|
90
|
+
limits.recommendedConcurrency,
|
|
91
|
+
limits.concurrency,
|
|
92
|
+
limits.schedulerConcurrency,
|
|
93
|
+
]).map(positiveInteger).filter(Boolean);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function positiveInteger(value) {
|
|
97
|
+
const number = Number(value);
|
|
98
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function planTaskId(task) {
|
|
102
|
+
return String(task?.id ?? task?.step);
|
|
103
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
|
|
6
|
+
import { createBudgetManager } from './budgetManager.js';
|
|
7
|
+
import { readyTasks } from './dependencyResolver.js';
|
|
8
|
+
import { createLockManager } from './lockManager.js';
|
|
9
|
+
import { effectiveConcurrency, startReadyTasks } from './scheduler.js';
|
|
10
|
+
|
|
11
|
+
test('dependencyResolver holds a barrier task until its dependsOnGroup is done', () => {
|
|
12
|
+
const plan = [
|
|
13
|
+
task('ingest-a', { groupId: 'ingest', status: 'done' }),
|
|
14
|
+
task('ingest-b', { groupId: 'ingest', status: 'pending' }),
|
|
15
|
+
task('consolidate', { dependsOnGroup: 'ingest', priority: 1 }),
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
assert.deepEqual(readyTasks(plan).map((item) => item.id), ['ingest-b']);
|
|
19
|
+
plan[1].status = 'done';
|
|
20
|
+
assert.deepEqual(readyTasks(plan).map((item) => item.id), ['consolidate']);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('dependencyResolver orders ready tasks by priority before step', () => {
|
|
24
|
+
const plan = [
|
|
25
|
+
task('low', { step: 1, priority: 20 }),
|
|
26
|
+
task('high', { step: 2, priority: 5 }),
|
|
27
|
+
task('none', { step: 0 }),
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
assert.deepEqual(readyTasks(plan).map((item) => item.id), ['high', 'low', 'none']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('dependencyResolver skips tasks whose locks are not free and keeps other ready work moving', () => {
|
|
34
|
+
const lockManager = createLockManager();
|
|
35
|
+
const held = lockManager.acquire(['deliverable:a.md']);
|
|
36
|
+
const plan = [
|
|
37
|
+
task('blocked', { locks: ['deliverable:a.md'], priority: 1 }),
|
|
38
|
+
task('free', { locks: ['deliverable:b.md'], priority: 2 }),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
assert.deepEqual(readyTasks(plan, { lockManager }).map((item) => item.id), ['free']);
|
|
42
|
+
held.release();
|
|
43
|
+
assert.deepEqual(readyTasks(plan, { lockManager }).map((item) => item.id), ['blocked', 'free']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('budgetManager blocks ready tasks once the run budget is exceeded', () => {
|
|
47
|
+
const budgetManager = createBudgetManager({ budgets: { maxTasks: 0 }, runId: 'run-budget' });
|
|
48
|
+
|
|
49
|
+
assert.deepEqual(readyTasks([task('a')], { budgetManager }), []);
|
|
50
|
+
assert.equal(budgetManager.exceeded().reason, 'max_tasks_exceeded');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('budgetManager tracks attempts depth duration and tokens per run', () => {
|
|
54
|
+
const budgetManager = createBudgetManager({
|
|
55
|
+
budgets: { maxAttempts: 1, maxDepth: 2, maxDurationMs: 5, maxTokens: 10 },
|
|
56
|
+
runId: 'run-budget-counters',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
budgetManager.recordTaskStart(task('a', { depth: 2 }));
|
|
60
|
+
assert.equal(budgetManager.canStartTask(task('b')), false);
|
|
61
|
+
assert.equal(budgetManager.exceeded().reason, 'max_attempts_exceeded');
|
|
62
|
+
assert.throws(
|
|
63
|
+
() => budgetManager.recordTaskResult({ metrics: { durationMs: 6, totalTokens: 9 } }),
|
|
64
|
+
/max_duration_exceeded/,
|
|
65
|
+
);
|
|
66
|
+
assert.equal(budgetManager.snapshot().durationMs, 6);
|
|
67
|
+
assert.equal(budgetManager.snapshot().tokens, 9);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('scheduler.effectiveConcurrency returns the minimum effective concurrency', () => {
|
|
71
|
+
const group = { recommendedConcurrency: 4 };
|
|
72
|
+
const agent = { description: { limits: { recommendedConcurrency: 3, maxConcurrency: 8 } } };
|
|
73
|
+
const donna = { schedulerConcurrency: 6 };
|
|
74
|
+
const provider = { capability: { limits: { maxConcurrency: 2 } }, maxConcurrency: 5 };
|
|
75
|
+
|
|
76
|
+
assert.equal(effectiveConcurrency(group, agent, donna, provider), 2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('startReadyTasks starts only ready tasks and respects lock starvation', () => {
|
|
80
|
+
const active = new Map();
|
|
81
|
+
const lockManager = createLockManager();
|
|
82
|
+
const attemptManager = {
|
|
83
|
+
reserve(item) {
|
|
84
|
+
const reservation = lockManager.acquire(item);
|
|
85
|
+
if (!reservation) return null;
|
|
86
|
+
return { attemptId: `${item.id}:attempt-1`, release: reservation.release };
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
const started = [];
|
|
90
|
+
const count = startReadyTasks({
|
|
91
|
+
plan: [
|
|
92
|
+
task('blocked', { locks: ['workspace:write'], priority: 1 }),
|
|
93
|
+
task('free', { locks: ['deliverable:a.md'], priority: 2 }),
|
|
94
|
+
],
|
|
95
|
+
active,
|
|
96
|
+
attemptManager,
|
|
97
|
+
lockManager,
|
|
98
|
+
limit: 2,
|
|
99
|
+
startTask(item) {
|
|
100
|
+
started.push(item.id);
|
|
101
|
+
return { promise: Promise.resolve({ ok: true, taskId: item.id }) };
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
assert.equal(count, 2);
|
|
106
|
+
assert.deepEqual(started, ['blocked', 'free']);
|
|
107
|
+
assert.deepEqual(lockManager.snapshot(), ['deliverable:a.md', 'workspace:write']);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('orchestrator modules do not import business-specific packages', () => {
|
|
111
|
+
const banned = /\b(?:production|cme|commercial|documents|wiki)\b/;
|
|
112
|
+
const orchestratorDir = new URL('.', import.meta.url).pathname;
|
|
113
|
+
const offenders = readdirSync(orchestratorDir)
|
|
114
|
+
.filter((file) => file.endsWith('.js') && !file.endsWith('.test.js'))
|
|
115
|
+
.flatMap((file) => {
|
|
116
|
+
const content = readFileSync(join(orchestratorDir, file), 'utf8');
|
|
117
|
+
return content
|
|
118
|
+
.split('\n')
|
|
119
|
+
.filter((line) => /^\s*import\s/.test(line) && banned.test(line))
|
|
120
|
+
.map((line) => `${file}: ${line.trim()}`);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
assert.deepEqual(offenders, []);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
function task(id, overrides = {}) {
|
|
127
|
+
return {
|
|
128
|
+
id,
|
|
129
|
+
step: overrides.step ?? 1,
|
|
130
|
+
status: overrides.status ?? 'pending',
|
|
131
|
+
dependsOn: overrides.dependsOn ?? [],
|
|
132
|
+
requiredCapability: 'workspace.diagnose',
|
|
133
|
+
operation: 'doctor',
|
|
134
|
+
locks: [],
|
|
135
|
+
requiresApproval: false,
|
|
136
|
+
...overrides,
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/runtime/approvals.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
3
|
+
import { normalizeApprovalGrant } from '../orchestrator/approvalPolicy.js';
|
|
3
4
|
|
|
4
5
|
const DEFAULT_APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
5
6
|
|
|
@@ -12,6 +13,9 @@ export function createApprovalManager(session, {
|
|
|
12
13
|
const scope = request.scope === 'tool' ? 'tool' : 'run';
|
|
13
14
|
const approvalId = request.approvalId ?? randomUUID();
|
|
14
15
|
const runId = request.runId ?? session._currentRunIdentity?.runId ?? null;
|
|
16
|
+
const workspaceId = request.workspaceId ?? request.workspace ?? session._currentRunIdentity?.workspace ?? session.workspace ?? null;
|
|
17
|
+
const planRevision = request.planRevision ?? session.planRevision ?? null;
|
|
18
|
+
const approvalClasses = normalizeClasses(request.approvalClasses ?? request.approvalClass);
|
|
15
19
|
const timeoutMs = Number.isFinite(Number(request.timeoutMs))
|
|
16
20
|
? Math.max(1, Number(request.timeoutMs))
|
|
17
21
|
: defaultTimeoutMs;
|
|
@@ -31,6 +35,25 @@ export function createApprovalManager(session, {
|
|
|
31
35
|
timeoutMs,
|
|
32
36
|
},
|
|
33
37
|
}));
|
|
38
|
+
dispatchAgentEvent(session, createAgentEvent('approval.requested', {
|
|
39
|
+
origin: 'runtime',
|
|
40
|
+
runId,
|
|
41
|
+
taskId: request.taskId ?? request.itemId ?? null,
|
|
42
|
+
workspace: workspaceId,
|
|
43
|
+
payload: {
|
|
44
|
+
id: approvalId,
|
|
45
|
+
approvalId,
|
|
46
|
+
scope: normalizeGrantScope(request.scope),
|
|
47
|
+
runId,
|
|
48
|
+
workspaceId,
|
|
49
|
+
planRevision,
|
|
50
|
+
taskId: request.taskId ?? request.itemId ?? null,
|
|
51
|
+
itemId: request.itemId ?? request.taskId ?? null,
|
|
52
|
+
groupId: request.groupId ?? null,
|
|
53
|
+
approvalClasses,
|
|
54
|
+
reason: request.reason ?? null,
|
|
55
|
+
},
|
|
56
|
+
}));
|
|
34
57
|
|
|
35
58
|
await new Promise((resolve, reject) => {
|
|
36
59
|
const timer = setTimeout(() => {
|
|
@@ -53,8 +76,15 @@ export function createApprovalManager(session, {
|
|
|
53
76
|
pending.set(approvalId, {
|
|
54
77
|
approvalId,
|
|
55
78
|
scope,
|
|
79
|
+
grantScope: normalizeGrantScope(request.scope),
|
|
56
80
|
runId,
|
|
81
|
+
workspaceId,
|
|
82
|
+
planRevision,
|
|
57
83
|
itemId: request.itemId ?? null,
|
|
84
|
+
taskId: request.taskId ?? request.itemId ?? null,
|
|
85
|
+
groupId: request.groupId ?? null,
|
|
86
|
+
approvalClasses,
|
|
87
|
+
reason: request.reason ?? null,
|
|
58
88
|
resolve: () => {
|
|
59
89
|
pending.delete(approvalId);
|
|
60
90
|
cleanup();
|
|
@@ -77,10 +107,59 @@ export function createApprovalManager(session, {
|
|
|
77
107
|
itemId: request.itemId ?? null,
|
|
78
108
|
},
|
|
79
109
|
}));
|
|
110
|
+
const grant = normalizeApprovalGrant({
|
|
111
|
+
id: approvalId,
|
|
112
|
+
approvalId,
|
|
113
|
+
scope: normalizeGrantScope(request.scope),
|
|
114
|
+
runId,
|
|
115
|
+
workspaceId,
|
|
116
|
+
planRevision,
|
|
117
|
+
taskId: request.taskId ?? request.itemId ?? null,
|
|
118
|
+
itemId: request.itemId ?? request.taskId ?? null,
|
|
119
|
+
groupId: request.groupId ?? null,
|
|
120
|
+
approvalClasses,
|
|
121
|
+
status: 'approved',
|
|
122
|
+
reason: request.reason ?? null,
|
|
123
|
+
});
|
|
124
|
+
dispatchAgentEvent(session, createAgentEvent('approval.granted', {
|
|
125
|
+
origin: 'runtime',
|
|
126
|
+
runId,
|
|
127
|
+
taskId: grant.taskId,
|
|
128
|
+
workspace: workspaceId,
|
|
129
|
+
payload: grant,
|
|
130
|
+
}));
|
|
80
131
|
return { approved: true, approvalId, runId, itemId: request.itemId ?? null };
|
|
81
132
|
}
|
|
82
133
|
|
|
83
|
-
function approve(
|
|
134
|
+
function approve(request = {}) {
|
|
135
|
+
const { approvalId = null, runId = null, itemId = null } = request;
|
|
136
|
+
if (request.scope || request.planRevision != null || request.approvalClasses || request.approvalClass || request.taskId || request.groupId) {
|
|
137
|
+
const grant = normalizeApprovalGrant({
|
|
138
|
+
...request,
|
|
139
|
+
id: request.id ?? request.approvalId ?? randomUUID(),
|
|
140
|
+
workspaceId: request.workspaceId ?? request.workspace ?? session.workspace ?? null,
|
|
141
|
+
scope: normalizeGrantScope(request.scope),
|
|
142
|
+
itemId: request.itemId ?? request.taskId ?? null,
|
|
143
|
+
status: 'approved',
|
|
144
|
+
});
|
|
145
|
+
dispatchAgentEvent(session, createAgentEvent('approval.granted', {
|
|
146
|
+
origin: 'runtime',
|
|
147
|
+
runId: grant.runId,
|
|
148
|
+
taskId: grant.taskId,
|
|
149
|
+
workspace: grant.workspaceId,
|
|
150
|
+
payload: grant,
|
|
151
|
+
}));
|
|
152
|
+
resolveMatchingPending(grant);
|
|
153
|
+
return {
|
|
154
|
+
approved: true,
|
|
155
|
+
approvalId: grant.approvalId ?? grant.id,
|
|
156
|
+
runId: grant.runId,
|
|
157
|
+
itemId: grant.itemId,
|
|
158
|
+
scope: grant.scope,
|
|
159
|
+
planRevision: grant.planRevision,
|
|
160
|
+
approvalClasses: grant.approvalClasses,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
84
163
|
const entry = approvalId
|
|
85
164
|
? pending.get(approvalId)
|
|
86
165
|
: [...pending.values()].find((item) =>
|
|
@@ -96,6 +175,31 @@ export function createApprovalManager(session, {
|
|
|
96
175
|
};
|
|
97
176
|
}
|
|
98
177
|
|
|
178
|
+
function reject(request = {}) {
|
|
179
|
+
const grant = normalizeApprovalGrant({
|
|
180
|
+
...request,
|
|
181
|
+
id: request.id ?? request.approvalId ?? randomUUID(),
|
|
182
|
+
workspaceId: request.workspaceId ?? request.workspace ?? session.workspace ?? null,
|
|
183
|
+
scope: normalizeGrantScope(request.scope),
|
|
184
|
+
status: 'rejected',
|
|
185
|
+
});
|
|
186
|
+
dispatchAgentEvent(session, createAgentEvent('approval.rejected', {
|
|
187
|
+
origin: 'runtime',
|
|
188
|
+
runId: grant.runId,
|
|
189
|
+
taskId: grant.taskId,
|
|
190
|
+
workspace: grant.workspaceId,
|
|
191
|
+
payload: grant,
|
|
192
|
+
}));
|
|
193
|
+
return {
|
|
194
|
+
approved: false,
|
|
195
|
+
rejected: true,
|
|
196
|
+
approvalId: grant.approvalId ?? grant.id,
|
|
197
|
+
runId: grant.runId,
|
|
198
|
+
itemId: grant.itemId,
|
|
199
|
+
scope: grant.scope,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
99
203
|
function list() {
|
|
100
204
|
return [...pending.entries()].map(([approvalId, item]) => ({
|
|
101
205
|
approvalId,
|
|
@@ -108,6 +212,31 @@ export function createApprovalManager(session, {
|
|
|
108
212
|
return {
|
|
109
213
|
requestApproval,
|
|
110
214
|
approve,
|
|
215
|
+
reject,
|
|
111
216
|
list,
|
|
112
217
|
};
|
|
218
|
+
|
|
219
|
+
function resolveMatchingPending(grant) {
|
|
220
|
+
for (const entry of pending.values()) {
|
|
221
|
+
if (grant.approvalId && entry.approvalId === grant.approvalId) entry.resolve();
|
|
222
|
+
else if (grant.taskId && entry.taskId === grant.taskId) entry.resolve();
|
|
223
|
+
else if (grant.itemId && entry.itemId === grant.itemId) entry.resolve();
|
|
224
|
+
else if (grant.scope === 'run' && grant.runId && entry.runId === grant.runId) entry.resolve();
|
|
225
|
+
else continue;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function normalizeGrantScope(scope) {
|
|
232
|
+
const normalized = String(scope ?? 'run').toLowerCase();
|
|
233
|
+
if (normalized === 'all') return 'run';
|
|
234
|
+
if (['run', 'task', 'group', 'tool'].includes(normalized)) return normalized;
|
|
235
|
+
return normalized === 'tool' ? 'tool' : 'run';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function normalizeClasses(value) {
|
|
239
|
+
if (value == null) return [];
|
|
240
|
+
const values = Array.isArray(value) ? value : [value];
|
|
241
|
+
return [...new Set(values.map((item) => String(item).trim()).filter(Boolean))];
|
|
113
242
|
}
|