@yeaft/webchat-agent 1.0.165 → 1.0.167
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/connection/message-router.js +9 -1
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +126 -0
- package/yeaft/tools/create-work-item.js +3 -1
- package/yeaft/web-bridge.js +77 -0
- package/yeaft/work-center/assignment.js +12 -4
- package/yeaft/work-center/bridge.js +11 -2
- package/yeaft/work-center/controller.js +46 -5
- package/yeaft/work-center/projection.js +4 -0
- package/yeaft/work-center/runner.js +173 -9
- package/yeaft/work-center/service.js +5 -0
- package/yeaft/work-center/session-context.js +75 -0
- package/yeaft/work-center/store.js +381 -67
- package/yeaft/work-center/watcher.js +164 -84
- package/yeaft/work-center/workflow.js +77 -5
- package/yeaft/work-center/workspace.js +183 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
2
2
|
const DEFAULT_LEASE_MS = 60_000;
|
|
3
|
+
const DEFAULT_MAX_CONCURRENT_ACTIONS = 3;
|
|
3
4
|
|
|
4
5
|
export class WorkItemWatcher {
|
|
5
6
|
constructor(options) {
|
|
@@ -12,8 +13,13 @@ export class WorkItemWatcher {
|
|
|
12
13
|
? Number(options.pollIntervalMs)
|
|
13
14
|
: DEFAULT_POLL_INTERVAL_MS;
|
|
14
15
|
this.leaseMs = Number(options.leaseMs) > 0 ? Number(options.leaseMs) : DEFAULT_LEASE_MS;
|
|
16
|
+
this.concurrencyProvider = typeof options.concurrencyProvider === 'function'
|
|
17
|
+
? options.concurrencyProvider
|
|
18
|
+
: () => DEFAULT_MAX_CONCURRENT_ACTIONS;
|
|
15
19
|
this.timer = null;
|
|
16
20
|
this.ticking = false;
|
|
21
|
+
this.tickPromise = null;
|
|
22
|
+
this.lifecycle = 'running';
|
|
17
23
|
this.activeRuns = new Map();
|
|
18
24
|
}
|
|
19
25
|
|
|
@@ -27,18 +33,30 @@ export class WorkItemWatcher {
|
|
|
27
33
|
|
|
28
34
|
start() {
|
|
29
35
|
if (this.timer) return;
|
|
36
|
+
this.lifecycle = 'running';
|
|
30
37
|
this.timer = setInterval(() => { this.tick().catch(() => {}); }, this.pollIntervalMs);
|
|
31
38
|
this.timer.unref?.();
|
|
32
39
|
this.tick().catch(() => {});
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
async stop() {
|
|
43
|
+
this.lifecycle = 'stopping';
|
|
36
44
|
if (this.timer) clearInterval(this.timer);
|
|
37
45
|
this.timer = null;
|
|
38
|
-
const
|
|
46
|
+
const failures = [];
|
|
47
|
+
const stoppingRuns = new Map(this.activeRuns);
|
|
48
|
+
for (const entry of stoppingRuns.values()) {
|
|
49
|
+
entry.abortController.abort('watcher_stopped');
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
await this.tickPromise;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
failures.push(error);
|
|
55
|
+
}
|
|
56
|
+
for (const [runId, entry] of this.activeRuns) stoppingRuns.set(runId, entry);
|
|
57
|
+
const active = Array.from(stoppingRuns.values());
|
|
39
58
|
for (const entry of active) entry.abortController.abort('watcher_stopped');
|
|
40
59
|
await Promise.allSettled(active.map(entry => entry.promise));
|
|
41
|
-
const failures = [];
|
|
42
60
|
for (const entry of active) {
|
|
43
61
|
try {
|
|
44
62
|
const finalProgress = entry.readFinalProgress?.() || null;
|
|
@@ -54,6 +72,7 @@ export class WorkItemWatcher {
|
|
|
54
72
|
failures.push(error);
|
|
55
73
|
}
|
|
56
74
|
}
|
|
75
|
+
this.lifecycle = 'idle';
|
|
57
76
|
if (failures.length > 0) {
|
|
58
77
|
throw new AggregateError(failures, 'Could not persist one or more Work Center interruptions');
|
|
59
78
|
}
|
|
@@ -69,96 +88,157 @@ export class WorkItemWatcher {
|
|
|
69
88
|
}
|
|
70
89
|
}
|
|
71
90
|
|
|
91
|
+
#recoverExpiredRuns() {
|
|
92
|
+
const recovered = this.store.recoverInterruptedRuns?.(this.ownerBootId) || 0;
|
|
93
|
+
if (recovered > 0) {
|
|
94
|
+
for (const entry of this.activeRuns.values()) {
|
|
95
|
+
if (!this.store.isActiveRun(entry.runId, this.ownerBootId, entry.leaseEpoch)) {
|
|
96
|
+
entry.abortController.abort('work_item_lease_expired');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return recovered;
|
|
101
|
+
}
|
|
102
|
+
|
|
72
103
|
async tick() {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// project directory. Parallel execution can be added only with exclusive
|
|
76
|
-
// workspace leases.
|
|
77
|
-
if (this.ticking || this.activeRuns.size > 0 || !this.runner) return;
|
|
104
|
+
if (this.lifecycle !== 'running' || !this.runner) return;
|
|
105
|
+
if (this.tickPromise) return this.tickPromise;
|
|
78
106
|
this.ticking = true;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
107
|
+
this.tickPromise = (async () => {
|
|
108
|
+
try {
|
|
109
|
+
this.#recoverExpiredRuns();
|
|
110
|
+
const limit = Math.min(Math.max(Number(await this.concurrencyProvider()) || 3, 1), 12);
|
|
111
|
+
while (this.lifecycle === 'running' && this.activeRuns.size < limit) {
|
|
112
|
+
let claim = this.store.claimReadyAction(this.ownerBootId, this.leaseMs);
|
|
113
|
+
if (!claim) break;
|
|
114
|
+
try {
|
|
115
|
+
claim = await this.runner.prepare?.({ ...claim, ownerBootId: this.ownerBootId }) || claim;
|
|
116
|
+
if (this.lifecycle !== 'running') {
|
|
117
|
+
try { this.runner.cleanup?.(claim.action); } catch {}
|
|
118
|
+
this.store.interruptRun(
|
|
119
|
+
claim.run.id,
|
|
120
|
+
this.ownerBootId,
|
|
121
|
+
claim.run.leaseEpoch,
|
|
122
|
+
'Work Center watcher stopped during Action preparation',
|
|
123
|
+
null,
|
|
124
|
+
);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
try { this.runner.cleanup?.(claim.action); } catch {}
|
|
129
|
+
if (this.lifecycle !== 'running') {
|
|
130
|
+
this.store.interruptRun(
|
|
131
|
+
claim.run.id,
|
|
132
|
+
this.ownerBootId,
|
|
133
|
+
claim.run.leaseEpoch,
|
|
134
|
+
'Work Center watcher stopped during Action preparation',
|
|
135
|
+
null,
|
|
136
|
+
);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
this.controller.submit(claim.run.id, this.ownerBootId, claim.run.leaseEpoch, {
|
|
140
|
+
outcome: error?.workItemPrepareRetryable ? 'retryable' : 'failed',
|
|
141
|
+
response: '', summary: '', evidence: [],
|
|
142
|
+
error: error?.message || String(error),
|
|
143
|
+
});
|
|
144
|
+
this.onEvent({ type: 'run.finished', workItem: this.store.getWorkItemDetail(claim.workItem.id) });
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
this.#startClaim(claim);
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
this.ticking = false;
|
|
151
|
+
this.tickPromise = null;
|
|
152
|
+
}
|
|
153
|
+
})();
|
|
154
|
+
return this.tickPromise;
|
|
155
|
+
}
|
|
90
156
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
157
|
+
#startClaim(claim) {
|
|
158
|
+
const abortController = new AbortController();
|
|
159
|
+
const key = claim.run.id;
|
|
160
|
+
const renewEvery = Math.max(1_000, Math.floor(this.leaseMs / 3));
|
|
161
|
+
const renewal = setInterval(() => {
|
|
162
|
+
const ok = this.store.renewLease(key, this.ownerBootId, claim.run.leaseEpoch, this.leaseMs);
|
|
163
|
+
if (!ok) {
|
|
164
|
+
this.#recoverExpiredRuns();
|
|
165
|
+
abortController.abort('work_item_lease_lost');
|
|
166
|
+
}
|
|
167
|
+
}, renewEvery);
|
|
168
|
+
renewal.unref?.();
|
|
169
|
+
const entry = {
|
|
170
|
+
promise: null,
|
|
171
|
+
abortController,
|
|
172
|
+
readFinalProgress: null,
|
|
173
|
+
interrupted: false,
|
|
174
|
+
workItemId: claim.workItem.id,
|
|
175
|
+
runId: claim.run.id,
|
|
176
|
+
leaseEpoch: claim.run.leaseEpoch,
|
|
177
|
+
};
|
|
178
|
+
entry.promise = this.#execute(claim, abortController.signal, readProgress => {
|
|
179
|
+
entry.readFinalProgress = readProgress;
|
|
180
|
+
}).finally(() => {
|
|
181
|
+
clearInterval(renewal);
|
|
182
|
+
this.activeRuns.delete(key);
|
|
183
|
+
if (this.lifecycle === 'running') queueMicrotask(() => { this.tick().catch(() => {}); });
|
|
184
|
+
});
|
|
185
|
+
this.activeRuns.set(key, entry);
|
|
186
|
+
this.onEvent({ type: 'run.started', workItem: this.store.getWorkItemDetail(claim.workItem.id) });
|
|
111
187
|
}
|
|
112
188
|
|
|
113
189
|
async #execute(claim, signal, registerProgressReader) {
|
|
114
|
-
let result;
|
|
115
190
|
try {
|
|
116
|
-
result
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
this.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
191
|
+
let result;
|
|
192
|
+
try {
|
|
193
|
+
result = await this.runner.run({
|
|
194
|
+
...claim,
|
|
195
|
+
signal,
|
|
196
|
+
ownerBootId: this.ownerBootId,
|
|
197
|
+
registerProgressReader,
|
|
198
|
+
onProgress: progress => {
|
|
199
|
+
const detail = this.store.updateRunProgress(
|
|
200
|
+
claim.run.id,
|
|
201
|
+
this.ownerBootId,
|
|
202
|
+
claim.run.leaseEpoch,
|
|
203
|
+
progress,
|
|
204
|
+
);
|
|
205
|
+
if (detail) this.onEvent({ type: 'run.progress', workItem: detail });
|
|
206
|
+
return !!detail;
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if (signal.aborted) return;
|
|
211
|
+
result = {
|
|
212
|
+
outcome: err?.retryable === false ? 'failed' : 'retryable',
|
|
213
|
+
response: err?.workItemExecutionStats?.response || '',
|
|
214
|
+
summary: '',
|
|
215
|
+
evidence: [],
|
|
216
|
+
error: err?.message || String(err),
|
|
217
|
+
loopCount: err?.workItemExecutionStats?.loopCount || 0,
|
|
218
|
+
toolCount: err?.workItemExecutionStats?.toolCount || 0,
|
|
219
|
+
llmRequestCount: err?.workItemExecutionStats?.llmRequestCount || 0,
|
|
220
|
+
inputTokens: err?.workItemExecutionStats?.inputTokens || 0,
|
|
221
|
+
outputTokens: err?.workItemExecutionStats?.outputTokens || 0,
|
|
222
|
+
cacheReadTokens: err?.workItemExecutionStats?.cacheReadTokens || 0,
|
|
223
|
+
cacheWriteTokens: err?.workItemExecutionStats?.cacheWriteTokens || 0,
|
|
224
|
+
totalTokens: err?.workItemExecutionStats?.totalTokens || 0,
|
|
225
|
+
checkpoint: err?.workItemExecutionStats?.checkpoint || null,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
133
228
|
if (signal.aborted) return;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
totalTokens: err?.workItemExecutionStats?.totalTokens || 0,
|
|
148
|
-
checkpoint: err?.workItemExecutionStats?.checkpoint || null,
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
if (signal.aborted) return;
|
|
152
|
-
try {
|
|
153
|
-
const workItem = this.controller.submit(
|
|
154
|
-
claim.run.id,
|
|
155
|
-
this.ownerBootId,
|
|
156
|
-
claim.run.leaseEpoch,
|
|
157
|
-
result,
|
|
158
|
-
);
|
|
159
|
-
this.onEvent({ type: 'run.finished', workItem });
|
|
160
|
-
} catch (err) {
|
|
161
|
-
if (!/stale|cancelled|already finished/i.test(err?.message || '')) throw err;
|
|
229
|
+
try {
|
|
230
|
+
const workItem = this.controller.submit(
|
|
231
|
+
claim.run.id,
|
|
232
|
+
this.ownerBootId,
|
|
233
|
+
claim.run.leaseEpoch,
|
|
234
|
+
result,
|
|
235
|
+
);
|
|
236
|
+
this.onEvent({ type: 'run.finished', workItem });
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (!/stale|cancelled|already finished/i.test(err?.message || '')) throw err;
|
|
239
|
+
}
|
|
240
|
+
} finally {
|
|
241
|
+
try { this.runner.cleanup?.(claim.action); } catch {}
|
|
162
242
|
}
|
|
163
243
|
}
|
|
164
244
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { renderSessionContextSnapshot } from './session-context.js';
|
|
2
|
+
|
|
1
3
|
export const BUILT_IN_ACTION_TYPES = Object.freeze([
|
|
2
4
|
'triage',
|
|
3
5
|
'research',
|
|
@@ -7,6 +9,7 @@ export const BUILT_IN_ACTION_TYPES = Object.freeze([
|
|
|
7
9
|
'migrate',
|
|
8
10
|
'test',
|
|
9
11
|
'review',
|
|
12
|
+
'integrate',
|
|
10
13
|
'document',
|
|
11
14
|
'operate',
|
|
12
15
|
'deliver',
|
|
@@ -17,6 +20,8 @@ const STAGE_TYPES = new Set(BUILT_IN_ACTION_TYPES);
|
|
|
17
20
|
const ASSIGNMENT_MODES = new Set(['auto', 'pool', 'fixed']);
|
|
18
21
|
const MODEL_MODES = new Set(['inherit', 'primary', 'fast', 'specific']);
|
|
19
22
|
const MODEL_EFFORTS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
|
|
23
|
+
const WORKSPACE_MODES = new Set(['shared', 'read', 'isolated-write', 'integrate']);
|
|
24
|
+
const HIGH_EFFORT_ACTION_TYPES = new Set(['triage', 'research', 'design', 'diagnose', 'review']);
|
|
20
25
|
|
|
21
26
|
const DEFAULT_STAGE_INSTRUCTIONS = Object.freeze({
|
|
22
27
|
triage: 'Turn the request into an executable contract. Inspect relevant repository facts before deciding the flow. Classify the WorkItem, identify constraints, risks, dependencies, and missing acceptance criteria, then plan only the Actions needed for this task. Do not implement. If the goal or acceptance criteria must change, submit a contractPatch and explain why.',
|
|
@@ -27,6 +32,7 @@ const DEFAULT_STAGE_INSTRUCTIONS = Object.freeze({
|
|
|
27
32
|
migrate: 'Execute the required migration without losing existing semantics or data. Define compatibility and rollback boundaries, make the operation repeatable or safely fenced, validate representative legacy data, and provide evidence for both upgraded and already-current states.',
|
|
28
33
|
test: 'Verify the current result against every applicable acceptance criterion. Run focused tests first, then the broader checks justified by the risk. Cover failure, compatibility, and boundary cases, report exact reproducible evidence, and do not hide skipped or inconclusive checks.',
|
|
29
34
|
review: 'Review the implementation and evidence independently against the WorkItem contract and repository rules. Prioritize correctness, security, data loss, compatibility, and missing tests over style preferences. Return approved or changes_requested with concrete, actionable findings and evidence.',
|
|
35
|
+
integrate: 'Integrate only completed isolated Action branches. Stop on any merge conflict instead of guessing, inspect the combined tree, run focused consistency checks, and leave a clean integrated result for downstream verification.',
|
|
30
36
|
document: 'Update the user or maintainer documentation required by the Action objective. Keep terminology and examples consistent with actual behavior, cover compatibility or operational consequences, and verify links, commands, and bilingual requirements where applicable.',
|
|
31
37
|
operate: 'Perform the operational change with explicit preconditions, safety fences, observability, and rollback handling. Verify the live or simulated postcondition from authoritative state, avoid destructive shortcuts, and record the exact evidence needed for handoff.',
|
|
32
38
|
deliver: 'Deliver only an approved result using the repository release policy. Recheck the reviewed commit and remote state, run required final verification on the immutable delivery tree, publish only the requested artifacts, and report commit, tag, deployment, and residual-risk evidence.',
|
|
@@ -43,6 +49,7 @@ const DEFAULT_ACTION_BRIEFS = Object.freeze({
|
|
|
43
49
|
migrate: ['Move existing data or behavior to the required shape without losing semantics.', 'Fence compatibility and rollback boundaries, then validate legacy and already-current states.', 'A repeatable or safely fenced migration with representative evidence.'],
|
|
44
50
|
test: ['Verify the current result against the Work Item acceptance criteria.', 'Run focused checks first, then broader risk-appropriate tests including failure and compatibility cases.', 'Reproducible pass/fail evidence for every applicable acceptance criterion.'],
|
|
45
51
|
review: ['Review the current result independently against the Work Item contract.', 'Prioritize correctness, security, data loss, compatibility, and missing tests over style preferences.', 'An approval or concrete change request supported by evidence.'],
|
|
52
|
+
integrate: ['Combine the completed isolated Action branches into one verified result.', 'Merge only declared dependency commits, stop on conflicts, and inspect the combined tree.', 'A clean integrated result ready for test and review.'],
|
|
46
53
|
document: ['Update the documentation required by this Action.', 'Align terminology and examples with actual behavior and verify links, commands, and language requirements.', 'Accurate, usable documentation that matches the delivered behavior.'],
|
|
47
54
|
operate: ['Perform the requested operational change safely.', 'Check preconditions, apply safety fences, preserve rollback options, and verify authoritative state.', 'A verified operational postcondition with handoff and rollback evidence.'],
|
|
48
55
|
deliver: ['Deliver the approved Work Item result using repository release policy.', 'Recheck immutable reviewed state, run final gates, and publish only the requested artifacts.', 'A traceable delivery with commit, artifact, deployment, and residual-risk evidence.'],
|
|
@@ -138,6 +145,21 @@ export function normalizeModelPolicy(value) {
|
|
|
138
145
|
return { mode, model, effort };
|
|
139
146
|
}
|
|
140
147
|
|
|
148
|
+
export function defaultActionModelPolicy(type, fallback = null) {
|
|
149
|
+
const base = normalizeModelPolicy(fallback);
|
|
150
|
+
return { ...base, effort: HIGH_EFFORT_ACTION_TYPES.has(type) ? 'high' : 'medium' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function normalizeActionModelPolicies(value, fallback = null) {
|
|
154
|
+
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
155
|
+
return Object.fromEntries([...STAGE_TYPES].map(type => [
|
|
156
|
+
type,
|
|
157
|
+
normalizeModelPolicy(Object.hasOwn(source, type)
|
|
158
|
+
? { ...defaultActionModelPolicy(type, fallback), ...source[type] }
|
|
159
|
+
: defaultActionModelPolicy(type, fallback)),
|
|
160
|
+
]));
|
|
161
|
+
}
|
|
162
|
+
|
|
141
163
|
export function defaultWorkCenterStageInstruction(type) {
|
|
142
164
|
return DEFAULT_STAGE_INSTRUCTIONS[STAGE_TYPES.has(type) ? type : 'custom'];
|
|
143
165
|
}
|
|
@@ -164,6 +186,7 @@ export function normalizeWorkflowDefinition(value, index = 0) {
|
|
|
164
186
|
throw new Error(`Work Center workflow "${id}" requires at least one stage`);
|
|
165
187
|
}
|
|
166
188
|
const planningMode = value.planningMode === 'ai' ? 'ai' : 'static';
|
|
189
|
+
const executionMode = value.executionMode === 'graph' ? 'graph' : 'linear';
|
|
167
190
|
const seen = new Set();
|
|
168
191
|
const stages = value.stages.map((rawStage, stageIndex) => {
|
|
169
192
|
const source = rawStage && typeof rawStage === 'object' ? rawStage : {};
|
|
@@ -183,6 +206,8 @@ export function normalizeWorkflowDefinition(value, index = 0) {
|
|
|
183
206
|
: defaultWorkCenterStageInstruction(type),
|
|
184
207
|
assignmentPolicy: normalizeAssignmentPolicy(source.assignmentPolicy, type),
|
|
185
208
|
modelPolicy: normalizeModelPolicy(source.modelPolicy),
|
|
209
|
+
dependsOnStageIds: uniqueStrings(source.dependsOnStageIds),
|
|
210
|
+
workspaceMode: WORKSPACE_MODES.has(source.workspaceMode) ? source.workspaceMode : 'shared',
|
|
186
211
|
maxAttempts: Math.min(Math.max(Number(source.maxAttempts) || 2, 1), 5),
|
|
187
212
|
};
|
|
188
213
|
if (type === 'review') {
|
|
@@ -206,11 +231,13 @@ export function normalizeWorkflowDefinition(value, index = 0) {
|
|
|
206
231
|
id,
|
|
207
232
|
name,
|
|
208
233
|
planningMode,
|
|
234
|
+
executionMode,
|
|
209
235
|
workItemType: typeof value.workItemType === 'string' && value.workItemType.trim()
|
|
210
236
|
? cleanId(value.workItemType, 'general')
|
|
211
237
|
: null,
|
|
212
238
|
globalInstructions: normalizeGlobalInstructions(value.globalInstructions),
|
|
213
239
|
modelPolicy: normalizeModelPolicy(value.modelPolicy),
|
|
240
|
+
actionModelPolicies: normalizeActionModelPolicies(value.actionModelPolicies, value.modelPolicy),
|
|
214
241
|
actionInstructions: normalizeActionInstructions(value.actionInstructions),
|
|
215
242
|
actionTemplates: Array.isArray(value.actionTemplates)
|
|
216
243
|
? value.actionTemplates.map(template => normalizeWorkflowDefinition(template))
|
|
@@ -235,9 +262,11 @@ export function defaultWorkCenterSettings() {
|
|
|
235
262
|
revision: 1,
|
|
236
263
|
defaultWorkflowId: 'software-change',
|
|
237
264
|
startImmediately: true,
|
|
265
|
+
maxConcurrentActions: 3,
|
|
238
266
|
defaultWorkDir: '',
|
|
239
267
|
globalInstructions: '',
|
|
240
268
|
modelPolicy: { mode: 'inherit', model: null, effort: null },
|
|
269
|
+
actionModelPolicies: normalizeActionModelPolicies(),
|
|
241
270
|
actionInstructions: normalizeActionInstructions(),
|
|
242
271
|
workflows: [normalizeWorkflowDefinition({
|
|
243
272
|
id: 'software-change',
|
|
@@ -271,9 +300,11 @@ export function normalizeWorkCenterSettings(value) {
|
|
|
271
300
|
revision,
|
|
272
301
|
defaultWorkflowId,
|
|
273
302
|
startImmediately: source.startImmediately !== false,
|
|
303
|
+
maxConcurrentActions: Math.min(Math.max(Number(source.maxConcurrentActions) || 3, 1), 12),
|
|
274
304
|
defaultWorkDir: typeof source.defaultWorkDir === 'string' ? source.defaultWorkDir.trim() : '',
|
|
275
305
|
globalInstructions: normalizeGlobalInstructions(source.globalInstructions),
|
|
276
306
|
modelPolicy: normalizeModelPolicy(source.modelPolicy || migratedModelPolicy),
|
|
307
|
+
actionModelPolicies: normalizeActionModelPolicies(source.actionModelPolicies, source.modelPolicy || migratedModelPolicy),
|
|
277
308
|
actionInstructions: normalizeActionInstructions(source.actionInstructions || migratedInstructions),
|
|
278
309
|
workflows,
|
|
279
310
|
};
|
|
@@ -310,7 +341,7 @@ export function resolvePlanningWorkflowSnapshot(settings, requestedWorkItemType
|
|
|
310
341
|
const typeInstruction = requestedType
|
|
311
342
|
? `The user explicitly selected workItemType "${requestedType}". Keep that exact type.`
|
|
312
343
|
: 'Infer one specific workItemType from the contract.';
|
|
313
|
-
const triageInstruction = `${normalized.actionInstructions.triage}\n\n${typeInstruction}\nReusable Action templates:\n${catalog || '(none)'}\nIf the final type matches a reusable template, return its workItemType and omit actions so Work Center can apply that frozen template. Otherwise generate the smallest reliable
|
|
344
|
+
const triageInstruction = `${normalized.actionInstructions.triage}\n\n${typeInstruction}\nReusable Action templates:\n${catalog || '(none)'}\nIf the final type matches a reusable template, return its workItemType and omit actions so Work Center can apply that frozen template. Otherwise generate the smallest reliable graph of 1 to 8 Actions. Split independent work into separate Actions and declare dependsOnActionIds. Use workspaceMode read for analysis, isolated-write for independent Git changes, integrate for an integrate Action that combines isolated-write dependencies, and shared for serial side effects. Non-Git or dirty workspaces are serialized automatically. Every generated Action must state objective, approach, expectedOutcome, capability, dependencies, and workspaceMode. Add only Actions required by this task. Do not copy a generic workflow.`;
|
|
314
345
|
return normalizeWorkflowDefinition({
|
|
315
346
|
id: 'ai-planned',
|
|
316
347
|
name: 'AI planned',
|
|
@@ -318,6 +349,7 @@ export function resolvePlanningWorkflowSnapshot(settings, requestedWorkItemType
|
|
|
318
349
|
workItemType: requestedType || null,
|
|
319
350
|
globalInstructions: normalized.globalInstructions,
|
|
320
351
|
modelPolicy: normalized.modelPolicy,
|
|
352
|
+
actionModelPolicies: normalized.actionModelPolicies,
|
|
321
353
|
actionInstructions: normalized.actionInstructions,
|
|
322
354
|
actionTemplates,
|
|
323
355
|
stages: [{
|
|
@@ -330,7 +362,7 @@ export function resolvePlanningWorkflowSnapshot(settings, requestedWorkItemType
|
|
|
330
362
|
mode: 'auto', capability: 'triage', candidateVpIds: [], fixedVpId: null,
|
|
331
363
|
separateFromStageTypes: [],
|
|
332
364
|
},
|
|
333
|
-
modelPolicy: normalized.
|
|
365
|
+
modelPolicy: normalized.actionModelPolicies.triage,
|
|
334
366
|
maxAttempts: 2,
|
|
335
367
|
}],
|
|
336
368
|
});
|
|
@@ -393,6 +425,7 @@ export function applyGeneratedPlan(workItem, rawPlan) {
|
|
|
393
425
|
throw new Error('AI-planned triage requires between 1 and 8 Actions when no reusable template exists');
|
|
394
426
|
}
|
|
395
427
|
const seen = new Set(['triage']);
|
|
428
|
+
let previousGeneratedId = null;
|
|
396
429
|
const generated = rawPlan.actions.map((rawAction, index) => {
|
|
397
430
|
const input = rawAction && typeof rawAction === 'object' && !Array.isArray(rawAction) ? rawAction : {};
|
|
398
431
|
const type = generatedActionType(input.type);
|
|
@@ -427,9 +460,14 @@ export function applyGeneratedPlan(workItem, rawPlan) {
|
|
|
427
460
|
fixedVpId: null,
|
|
428
461
|
separateFromStageTypes: uniqueStrings(input.separateFromActionTypes),
|
|
429
462
|
},
|
|
430
|
-
modelPolicy: source.
|
|
463
|
+
modelPolicy: source.actionModelPolicies[type] || source.actionModelPolicies.custom,
|
|
464
|
+
dependsOnStageIds: Object.hasOwn(input, 'dependsOnActionIds')
|
|
465
|
+
? uniqueStrings(input.dependsOnActionIds)
|
|
466
|
+
: (previousGeneratedId ? [previousGeneratedId] : []),
|
|
467
|
+
workspaceMode: WORKSPACE_MODES.has(input.workspaceMode) ? input.workspaceMode : 'shared',
|
|
431
468
|
maxAttempts: Math.min(Math.max(Number(input.maxAttempts) || 2, 1), 5),
|
|
432
469
|
};
|
|
470
|
+
previousGeneratedId = id;
|
|
433
471
|
if (type === 'review' && Object.prototype.hasOwnProperty.call(input, 'changesRequestedActionId')) {
|
|
434
472
|
stage.changesRequestedStageId = typeof input.changesRequestedActionId === 'string'
|
|
435
473
|
? cleanId(input.changesRequestedActionId, '')
|
|
@@ -454,8 +492,39 @@ export function applyGeneratedPlan(workItem, rawPlan) {
|
|
|
454
492
|
throw new Error(`AI-planned review Action "${stage.id}" requires an earlier editable Action`);
|
|
455
493
|
}
|
|
456
494
|
}
|
|
495
|
+
const hasIsolatedWrites = generated.some(stage => stage.workspaceMode === 'isolated-write');
|
|
496
|
+
const integrationStages = generated.filter(stage => stage.workspaceMode === 'integrate');
|
|
497
|
+
if (hasIsolatedWrites && integrationStages.length !== 1) {
|
|
498
|
+
throw new Error('AI-planned isolated-write Actions require exactly one integration Action');
|
|
499
|
+
}
|
|
500
|
+
for (const [index, stage] of generated.entries()) {
|
|
501
|
+
for (const dependencyId of stage.dependsOnStageIds) {
|
|
502
|
+
if (!generated.slice(0, index).some(candidate => candidate.id === dependencyId)) {
|
|
503
|
+
throw new Error(`AI-planned Action "${stage.id}" has a missing, self, or future dependency "${dependencyId}"`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const isolatedDependencies = stage.dependsOnStageIds
|
|
507
|
+
.map(id => generated.find(candidate => candidate.id === id))
|
|
508
|
+
.filter(candidate => candidate?.workspaceMode === 'isolated-write');
|
|
509
|
+
if (stage.workspaceMode === 'integrate' && (stage.type !== 'integrate' || isolatedDependencies.length === 0)) {
|
|
510
|
+
throw new Error(`AI-planned integration Action "${stage.id}" must depend on isolated-write Actions`);
|
|
511
|
+
}
|
|
512
|
+
if (stage.workspaceMode === 'integrate') {
|
|
513
|
+
const required = generated.filter(candidate => candidate.workspaceMode === 'isolated-write').map(candidate => candidate.id);
|
|
514
|
+
if (required.some(id => !stage.dependsOnStageIds.includes(id))) {
|
|
515
|
+
throw new Error(`AI-planned integration Action "${stage.id}" must depend on every isolated-write Action`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (stage.type === 'integrate' && stage.workspaceMode !== 'integrate') {
|
|
519
|
+
throw new Error(`AI-planned integrate Action "${stage.id}" requires integration workspace mode`);
|
|
520
|
+
}
|
|
521
|
+
if (stage.workspaceMode !== 'integrate' && isolatedDependencies.length > 0) {
|
|
522
|
+
throw new Error(`AI-planned Action "${stage.id}" must consume isolated writes through integration`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
457
525
|
return normalizeWorkflowDefinition({
|
|
458
526
|
...source,
|
|
527
|
+
executionMode: 'graph',
|
|
459
528
|
workItemType,
|
|
460
529
|
stages: [source.stages[0], ...generated],
|
|
461
530
|
});
|
|
@@ -539,9 +608,9 @@ function renderContext(context = []) {
|
|
|
539
608
|
return `\n\nReusable Work Center context and prior Action results:\n${blocks.join('\n\n')}`;
|
|
540
609
|
}
|
|
541
610
|
|
|
542
|
-
export function actionInstruction(stage, workItem, context = []) {
|
|
611
|
+
export function actionInstruction(stage, workItem, context = [], sessionContextBlock = renderSessionContextSnapshot(workItem?.sessionContext)) {
|
|
543
612
|
const criteria = (workItem.acceptanceCriteria || []).map(item => `- ${item}`).join('\n') || '- No explicit criteria';
|
|
544
|
-
const common = `WorkItem: ${workItem.title}\nGoal: ${workItem.goal}\nAcceptance criteria:\n${criteria}${renderContext(context)}`;
|
|
613
|
+
const common = `WorkItem: ${workItem.title}\nGoal: ${workItem.goal}\nAcceptance criteria:\n${criteria}${sessionContextBlock}${renderContext(context)}`;
|
|
545
614
|
const policy = stage.instruction || defaultWorkCenterStageInstruction(stage.type);
|
|
546
615
|
const brief = normalizeActionBrief(stage.brief || stage, stage.type);
|
|
547
616
|
const contract = `Action type: ${stage.type}\nWhat to do:\n${brief.objective}\n\nHow to do it:\n${brief.approach}\n\nExpected result:\n${brief.expectedOutcome}`;
|
|
@@ -554,6 +623,9 @@ export function actionForStage(stage, workItem, context = []) {
|
|
|
554
623
|
stageId: stage.id,
|
|
555
624
|
assignmentPolicy: stage.assignmentPolicy,
|
|
556
625
|
modelPolicy: stage.modelPolicy,
|
|
626
|
+
dependsOnStageIds: stage.dependsOnStageIds || [],
|
|
627
|
+
workspaceMode: stage.workspaceMode || 'shared',
|
|
628
|
+
changesRequestedStageId: stage.changesRequestedStageId || null,
|
|
557
629
|
// Storage compatibility for databases created before assignment policies.
|
|
558
630
|
requiredRole: stage.assignmentPolicy.mode === 'fixed' ? stage.assignmentPolicy.fixedVpId : '',
|
|
559
631
|
brief: normalizeActionBrief(stage, stage.type),
|