@yeaft/webchat-agent 1.0.185 → 1.0.186
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/yeaft/work-center/assignment.js +8 -1
- package/yeaft/work-center/bridge.js +1 -0
- package/yeaft/work-center/controller.js +94 -6
- package/yeaft/work-center/plan-mutation.js +185 -0
- package/yeaft/work-center/runner.js +180 -4
- package/yeaft/work-center/service.js +3 -1
- package/yeaft/work-center/store.js +106 -4
- package/yeaft/work-center/workflow.js +88 -21
package/package.json
CHANGED
|
@@ -71,12 +71,19 @@ export function selectWorkItemVp({ policy: rawPolicy, stageType, vps, priorRuns
|
|
|
71
71
|
return { vp: fixed, reason: `fixed:${fixed.id}`, policy };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
const pool = policy.mode
|
|
74
|
+
const pool = ['pool', 'planned'].includes(policy.mode)
|
|
75
75
|
? policy.candidateVpIds.map(id => byId.get(id)).filter(Boolean)
|
|
76
76
|
: all;
|
|
77
77
|
if (pool.length === 0) throw policyError('No configured Work Center VP candidates are available');
|
|
78
78
|
const eligible = pool.filter(vp => !excluded.has(vp.id));
|
|
79
79
|
if (eligible.length === 0) throw policyError('No Work Center VP satisfies the stage separation policy');
|
|
80
|
+
if (policy.mode === 'planned') {
|
|
81
|
+
return {
|
|
82
|
+
vp: eligible[0],
|
|
83
|
+
reason: `planned:${eligible[0].id}:${policy.assignmentReason || stageType}`,
|
|
84
|
+
policy,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
80
87
|
|
|
81
88
|
const ranked = eligible
|
|
82
89
|
.map(vp => ({ vp, score: capabilityScore(vp, policy.capability || stageType) }))
|
|
@@ -116,6 +116,7 @@ async function createDefaultService() {
|
|
|
116
116
|
yeaftDir,
|
|
117
117
|
runner,
|
|
118
118
|
runtimeInfoProvider: getSettingsRuntime,
|
|
119
|
+
listAvailableVpIds: () => defaultRegistry.listVps().map(vp => vp.id),
|
|
119
120
|
watcherOptions: {
|
|
120
121
|
concurrencyProvider: () => readWorkCenterSettings(yeaftDir).maxConcurrentActions,
|
|
121
122
|
},
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from './workflow.js';
|
|
9
9
|
import { renderSessionContextSnapshot } from './session-context.js';
|
|
10
10
|
import { normalizeEvidence } from './evidence.js';
|
|
11
|
+
import { applyAdditivePlanProposal } from './plan-mutation.js';
|
|
11
12
|
|
|
12
13
|
function normalizeCriteria(value) {
|
|
13
14
|
if (!Array.isArray(value)) return null;
|
|
@@ -92,6 +93,10 @@ function normalizeTerminalResult(result, action) {
|
|
|
92
93
|
acceptanceChecks: Array.isArray(result.acceptanceChecks) ? result.acceptanceChecks : [],
|
|
93
94
|
checkpoint: result.checkpoint && typeof result.checkpoint === 'object'
|
|
94
95
|
? result.checkpoint : null,
|
|
96
|
+
planProposal: result.planProposal && typeof result.planProposal === 'object'
|
|
97
|
+
&& !Array.isArray(result.planProposal) ? result.planProposal : null,
|
|
98
|
+
replanRequest: result.replanRequest && typeof result.replanRequest === 'object'
|
|
99
|
+
&& !Array.isArray(result.replanRequest) ? result.replanRequest : null,
|
|
95
100
|
};
|
|
96
101
|
if (normalized.outcome === 'waiting' && !normalized.waitingReason) {
|
|
97
102
|
throw new Error('waiting outcome requires waitingReason');
|
|
@@ -101,6 +106,16 @@ function normalizeTerminalResult(result, action) {
|
|
|
101
106
|
normalized.error = 'Only triage may submit a WorkItem contractPatch';
|
|
102
107
|
normalized.contractPatch = null;
|
|
103
108
|
}
|
|
109
|
+
if (normalized.outcome !== 'completed') {
|
|
110
|
+
normalized.planProposal = null;
|
|
111
|
+
normalized.replanRequest = null;
|
|
112
|
+
}
|
|
113
|
+
if (normalized.planProposal && normalized.replanRequest) {
|
|
114
|
+
normalized.outcome = 'failed';
|
|
115
|
+
normalized.error = 'An Action cannot expand and replan the WorkItem in the same completion';
|
|
116
|
+
normalized.planProposal = null;
|
|
117
|
+
normalized.replanRequest = null;
|
|
118
|
+
}
|
|
104
119
|
if (action.type === 'review' && normalized.outcome === 'completed' && !normalized.reviewDecision) {
|
|
105
120
|
normalized.outcome = 'failed';
|
|
106
121
|
normalized.error = 'Completed review requires approved or changes_requested';
|
|
@@ -121,8 +136,11 @@ function contextEntry(action, result, run) {
|
|
|
121
136
|
}
|
|
122
137
|
|
|
123
138
|
export class WorkflowController {
|
|
124
|
-
constructor(store) {
|
|
139
|
+
constructor(store, options = {}) {
|
|
125
140
|
this.store = store;
|
|
141
|
+
this.listAvailableVpIds = typeof options.listAvailableVpIds === 'function'
|
|
142
|
+
? options.listAvailableVpIds
|
|
143
|
+
: null;
|
|
126
144
|
}
|
|
127
145
|
|
|
128
146
|
create(input) {
|
|
@@ -331,12 +349,45 @@ export class WorkflowController {
|
|
|
331
349
|
}
|
|
332
350
|
: current;
|
|
333
351
|
try {
|
|
334
|
-
|
|
352
|
+
const reservedStageIds = activeAction.stageId?.startsWith('replan-')
|
|
353
|
+
? this.store.getWorkItemDetail(activeWorkItem.id).actions.map(action => action.stageId)
|
|
354
|
+
: [];
|
|
355
|
+
validatedGeneratedWorkflow = applyGeneratedPlan(effective, result.plan, {
|
|
356
|
+
availableVpIds: this.listAvailableVpIds?.(),
|
|
357
|
+
reservedStageIds,
|
|
358
|
+
});
|
|
359
|
+
} catch (error) {
|
|
360
|
+
result.outcome = 'failed';
|
|
361
|
+
result.error = error?.message || String(error);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
let validatedPlanProposal = null;
|
|
365
|
+
if (result.outcome === 'completed' && result.planProposal) {
|
|
366
|
+
try {
|
|
367
|
+
validatedPlanProposal = applyAdditivePlanProposal({
|
|
368
|
+
workItem: activeWorkItem,
|
|
369
|
+
actions: this.store.getWorkItemDetail(activeWorkItem.id).actions,
|
|
370
|
+
proposal: result.planProposal,
|
|
371
|
+
availableVpIds: this.listAvailableVpIds?.(),
|
|
372
|
+
});
|
|
335
373
|
} catch (error) {
|
|
336
374
|
result.outcome = 'failed';
|
|
337
375
|
result.error = error?.message || String(error);
|
|
338
376
|
}
|
|
339
377
|
}
|
|
378
|
+
if (result.outcome === 'completed' && result.replanRequest) {
|
|
379
|
+
const basePlanRevision = Number(result.replanRequest.basePlanRevision);
|
|
380
|
+
const proposalId = typeof result.replanRequest.proposalId === 'string'
|
|
381
|
+
? result.replanRequest.proposalId.trim().slice(0, 128) : '';
|
|
382
|
+
const reason = typeof result.replanRequest.reason === 'string'
|
|
383
|
+
? result.replanRequest.reason.trim().slice(0, 4_000) : '';
|
|
384
|
+
if (!proposalId || !reason || basePlanRevision !== activeWorkItem.planRevision) {
|
|
385
|
+
result.outcome = 'failed';
|
|
386
|
+
result.error = 'Work Center replan request is missing fields or has a stale basePlanRevision';
|
|
387
|
+
} else {
|
|
388
|
+
result.replanRequest = { proposalId, reason, basePlanRevision };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
340
391
|
const detail = this.store.finalizeRun(
|
|
341
392
|
runId,
|
|
342
393
|
ownerBootId,
|
|
@@ -394,11 +445,40 @@ export class WorkflowController {
|
|
|
394
445
|
&& workItem.workflowSnapshot?.planningMode === 'ai'
|
|
395
446
|
? validatedGeneratedWorkflow
|
|
396
447
|
: null;
|
|
448
|
+
const planProposal = workItem.planRevision === activeWorkItem.planRevision
|
|
449
|
+
? validatedPlanProposal
|
|
450
|
+
: null;
|
|
451
|
+
if (validatedPlanProposal && !planProposal) {
|
|
452
|
+
throw new Error('Work Center plan revision changed before finalization');
|
|
453
|
+
}
|
|
397
454
|
const plannedWorkItem = generatedWorkflow
|
|
398
455
|
? { ...effectiveWorkItem, workflowSnapshot: generatedWorkflow }
|
|
399
|
-
:
|
|
456
|
+
: planProposal
|
|
457
|
+
? { ...effectiveWorkItem, workflowSnapshot: planProposal.workflowSnapshot }
|
|
458
|
+
: effectiveWorkItem;
|
|
400
459
|
const context = [...(action.context || []), contextEntry(action, result, activeRun)];
|
|
401
460
|
if (plannedWorkItem.workflowSnapshot?.executionMode === 'graph') {
|
|
461
|
+
if (result.replanRequest) {
|
|
462
|
+
const replanStage = {
|
|
463
|
+
...plannedWorkItem.workflowSnapshot.stages[0],
|
|
464
|
+
id: `replan-${workItem.planRevision + 1}`,
|
|
465
|
+
name: 'Replan',
|
|
466
|
+
type: 'triage',
|
|
467
|
+
objective: 'Replan the unfinished WorkItem graph without changing completed history.',
|
|
468
|
+
approach: `Inspect completed evidence and this replan reason: ${result.replanRequest.reason}`,
|
|
469
|
+
expectedOutcome: 'A validated replacement plan for all unfinished work.',
|
|
470
|
+
dependsOnStageIds: [],
|
|
471
|
+
};
|
|
472
|
+
return {
|
|
473
|
+
actionStatus: 'completed', workItemStatus: 'ready', graphAdvance: true,
|
|
474
|
+
replanBarrier: {
|
|
475
|
+
...result.replanRequest,
|
|
476
|
+
action: actionForStage(replanStage, plannedWorkItem, context),
|
|
477
|
+
},
|
|
478
|
+
eventType: 'workflow.replan_requested',
|
|
479
|
+
eventData: { reason: result.replanRequest.reason },
|
|
480
|
+
};
|
|
481
|
+
}
|
|
402
482
|
if (action.type === 'review' && result.reviewDecision === 'changes_requested') {
|
|
403
483
|
const targetStage = plannedWorkItem.workflowSnapshot.stages
|
|
404
484
|
.find(stage => stage.id === action.changesRequestedStageId);
|
|
@@ -413,15 +493,19 @@ export class WorkflowController {
|
|
|
413
493
|
}
|
|
414
494
|
const nextActions = generatedWorkflow
|
|
415
495
|
? generatedWorkflow.stages.slice(1).map(stage => actionForStage(stage, plannedWorkItem, context))
|
|
416
|
-
: [];
|
|
496
|
+
: (planProposal?.nextActions || []);
|
|
497
|
+
const workflowSnapshot = generatedWorkflow || planProposal?.workflowSnapshot || null;
|
|
417
498
|
return {
|
|
418
499
|
actionStatus: 'completed',
|
|
419
500
|
workItemStatus: nextActions.length > 0 ? 'ready' : 'running',
|
|
420
501
|
contractPatch,
|
|
421
|
-
workflowSnapshot
|
|
502
|
+
workflowSnapshot,
|
|
503
|
+
expectedPlanRevision: generatedWorkflow ? workItem.planRevision : planProposal?.basePlanRevision,
|
|
504
|
+
proposalId: generatedWorkflow ? `initial:${runId}` : planProposal?.proposalId,
|
|
505
|
+
dependencyPatches: planProposal?.dependencyPatches || [],
|
|
422
506
|
nextActions,
|
|
423
507
|
graphAdvance: true,
|
|
424
|
-
eventType: 'action.completed',
|
|
508
|
+
eventType: planProposal ? 'workflow.plan_expanded' : 'action.completed',
|
|
425
509
|
eventData: { nextActionCount: nextActions.length, reviewDecision: result.reviewDecision },
|
|
426
510
|
};
|
|
427
511
|
}
|
|
@@ -433,6 +517,8 @@ export class WorkflowController {
|
|
|
433
517
|
workItemStatus: 'done',
|
|
434
518
|
contractPatch,
|
|
435
519
|
workflowSnapshot: generatedWorkflow,
|
|
520
|
+
expectedPlanRevision: generatedWorkflow ? workItem.planRevision : undefined,
|
|
521
|
+
proposalId: generatedWorkflow ? `initial:${runId}` : undefined,
|
|
436
522
|
eventType: 'work_item.completed',
|
|
437
523
|
eventData: { summary: result.summary },
|
|
438
524
|
};
|
|
@@ -443,6 +529,8 @@ export class WorkflowController {
|
|
|
443
529
|
workItemStatus: 'ready',
|
|
444
530
|
contractPatch,
|
|
445
531
|
workflowSnapshot: generatedWorkflow,
|
|
532
|
+
expectedPlanRevision: generatedWorkflow ? workItem.planRevision : undefined,
|
|
533
|
+
proposalId: generatedWorkflow ? `initial:${runId}` : undefined,
|
|
446
534
|
nextAction: actionForStage(nextStep, plannedWorkItem, context),
|
|
447
535
|
eventType: 'action.completed',
|
|
448
536
|
eventData: {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import {
|
|
2
|
+
actionForStage,
|
|
3
|
+
applyGeneratedPlan,
|
|
4
|
+
canonicalActionId,
|
|
5
|
+
canonicalExplicitActionId,
|
|
6
|
+
canonicalExplicitActionIds,
|
|
7
|
+
} from './workflow.js';
|
|
8
|
+
|
|
9
|
+
function cleanProposalId(value) {
|
|
10
|
+
const id = typeof value === 'string' ? value.trim().slice(0, 128) : '';
|
|
11
|
+
if (!id) throw new Error('Work Center plan proposal requires proposalId');
|
|
12
|
+
return id;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function planActionFromStage(stage) {
|
|
16
|
+
return {
|
|
17
|
+
id: stage.id,
|
|
18
|
+
name: stage.name,
|
|
19
|
+
type: stage.type,
|
|
20
|
+
objective: stage.objective,
|
|
21
|
+
approach: stage.approach,
|
|
22
|
+
expectedOutcome: stage.expectedOutcome,
|
|
23
|
+
capability: stage.assignmentPolicy?.capability,
|
|
24
|
+
candidateVpIds: stage.assignmentPolicy?.mode === 'planned'
|
|
25
|
+
? stage.assignmentPolicy.candidateVpIds
|
|
26
|
+
: undefined,
|
|
27
|
+
assignmentReason: stage.assignmentPolicy?.assignmentReason,
|
|
28
|
+
separateFromActionTypes: stage.assignmentPolicy?.separateFromStageTypes,
|
|
29
|
+
dependsOnActionIds: stage.dependsOnStageIds || [],
|
|
30
|
+
workspaceMode: stage.workspaceMode,
|
|
31
|
+
changesRequestedActionId: stage.changesRequestedStageId,
|
|
32
|
+
maxAttempts: stage.maxAttempts,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stableTopologicalActions(actions) {
|
|
37
|
+
const byId = new Map(actions.map((action, index) => [action.id, { action, index }]));
|
|
38
|
+
const incoming = new Map(actions.map(action => [action.id, 0]));
|
|
39
|
+
const outgoing = new Map(actions.map(action => [action.id, []]));
|
|
40
|
+
for (const action of actions) {
|
|
41
|
+
const orderingDependencies = new Set([
|
|
42
|
+
...(action.dependsOnActionIds || []),
|
|
43
|
+
...(action.changesRequestedActionId ? [action.changesRequestedActionId] : []),
|
|
44
|
+
]);
|
|
45
|
+
for (const dependencyId of orderingDependencies) {
|
|
46
|
+
if (!byId.has(dependencyId) || dependencyId === action.id) {
|
|
47
|
+
throw new Error(`Work Center additive Action "${action.id}" has an invalid dependency "${dependencyId}"`);
|
|
48
|
+
}
|
|
49
|
+
incoming.set(action.id, incoming.get(action.id) + 1);
|
|
50
|
+
outgoing.get(dependencyId).push(action.id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const ready = actions.filter(action => incoming.get(action.id) === 0)
|
|
54
|
+
.sort((left, right) => byId.get(left.id).index - byId.get(right.id).index);
|
|
55
|
+
const ordered = [];
|
|
56
|
+
while (ready.length > 0) {
|
|
57
|
+
const action = ready.shift();
|
|
58
|
+
ordered.push(action);
|
|
59
|
+
for (const dependentId of outgoing.get(action.id)) {
|
|
60
|
+
const count = incoming.get(dependentId) - 1;
|
|
61
|
+
incoming.set(dependentId, count);
|
|
62
|
+
if (count === 0) {
|
|
63
|
+
ready.push(byId.get(dependentId).action);
|
|
64
|
+
ready.sort((left, right) => byId.get(left.id).index - byId.get(right.id).index);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (ordered.length !== actions.length) {
|
|
69
|
+
throw new Error('Work Center additive plan contains a dependency cycle');
|
|
70
|
+
}
|
|
71
|
+
return ordered;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateDependencyPatches(actions, patches, addedIds) {
|
|
75
|
+
const active = new Map(actions
|
|
76
|
+
.filter(action => !['superseded', 'cancelled'].includes(action.status))
|
|
77
|
+
.map(action => [action.stageId, action]));
|
|
78
|
+
const normalized = [];
|
|
79
|
+
const patchedActionIds = new Set();
|
|
80
|
+
for (const raw of Array.isArray(patches) ? patches : []) {
|
|
81
|
+
const actionId = typeof raw?.actionId === 'string' ? raw.actionId.trim() : '';
|
|
82
|
+
const action = actions.find(candidate => candidate.id === actionId);
|
|
83
|
+
if (!action || action.status !== 'ready' || action.attempt !== 0) {
|
|
84
|
+
throw new Error(`Work Center dependency patch target must be an unattempted ready Action: ${actionId || '(missing)'}`);
|
|
85
|
+
}
|
|
86
|
+
if (patchedActionIds.has(action.id)) {
|
|
87
|
+
throw new Error(`Work Center dependency patch target is duplicated: ${action.id}`);
|
|
88
|
+
}
|
|
89
|
+
patchedActionIds.add(action.id);
|
|
90
|
+
const addDependsOnActionIds = canonicalExplicitActionIds(
|
|
91
|
+
raw.addDependsOnActionIds,
|
|
92
|
+
'dependency patch',
|
|
93
|
+
);
|
|
94
|
+
if (addDependsOnActionIds.length === 0) throw new Error('Work Center dependency patch must add at least one dependency');
|
|
95
|
+
for (const dependencyId of addDependsOnActionIds) {
|
|
96
|
+
if (!active.has(dependencyId) && !addedIds.has(dependencyId)) {
|
|
97
|
+
throw new Error(`Work Center dependency patch references missing Action: ${dependencyId}`);
|
|
98
|
+
}
|
|
99
|
+
if (dependencyId === action.stageId) throw new Error('Work Center Action cannot depend on itself');
|
|
100
|
+
}
|
|
101
|
+
normalized.push({ action, addDependsOnActionIds });
|
|
102
|
+
}
|
|
103
|
+
return normalized;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function applyAdditivePlanProposal({ workItem, actions, proposal, availableVpIds = null }) {
|
|
107
|
+
if (workItem.workflowSnapshot?.executionMode !== 'graph') {
|
|
108
|
+
throw new Error('Work Center additive planning requires graph execution');
|
|
109
|
+
}
|
|
110
|
+
if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal)) {
|
|
111
|
+
throw new Error('Work Center plan proposal must be an object');
|
|
112
|
+
}
|
|
113
|
+
const proposalId = cleanProposalId(proposal.proposalId);
|
|
114
|
+
const basePlanRevision = Number(proposal.basePlanRevision);
|
|
115
|
+
if (!Number.isInteger(basePlanRevision) || basePlanRevision !== workItem.planRevision) {
|
|
116
|
+
throw new Error('Work Center plan proposal has a stale basePlanRevision');
|
|
117
|
+
}
|
|
118
|
+
if (!Array.isArray(proposal.actions) || proposal.actions.length < 1 || proposal.actions.length > 8) {
|
|
119
|
+
throw new Error('Work Center additive plan requires between 1 and 8 new Actions');
|
|
120
|
+
}
|
|
121
|
+
const activeStages = workItem.workflowSnapshot.stages || [];
|
|
122
|
+
const existingIds = new Set(activeStages.map(stage => stage.id));
|
|
123
|
+
const addedIds = new Set();
|
|
124
|
+
const canonicalActions = proposal.actions.map(raw => {
|
|
125
|
+
const id = canonicalActionId(raw?.id);
|
|
126
|
+
if (!id || existingIds.has(id) || addedIds.has(id)) {
|
|
127
|
+
throw new Error(`Work Center additive Action id is missing or already exists: ${id || '(missing)'}`);
|
|
128
|
+
}
|
|
129
|
+
addedIds.add(id);
|
|
130
|
+
const hasReturnTarget = Object.hasOwn(raw, 'changesRequestedActionId');
|
|
131
|
+
return {
|
|
132
|
+
...raw,
|
|
133
|
+
id,
|
|
134
|
+
dependsOnActionIds: canonicalExplicitActionIds(
|
|
135
|
+
raw.dependsOnActionIds,
|
|
136
|
+
`Action "${id}" dependencies`,
|
|
137
|
+
),
|
|
138
|
+
changesRequestedActionId: hasReturnTarget
|
|
139
|
+
? canonicalExplicitActionId(
|
|
140
|
+
raw.changesRequestedActionId,
|
|
141
|
+
`Action "${id}" review target`,
|
|
142
|
+
)
|
|
143
|
+
: undefined,
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
const dependencyPatches = validateDependencyPatches(actions, proposal.dependencyPatches, addedIds);
|
|
147
|
+
const synthetic = {
|
|
148
|
+
...workItem,
|
|
149
|
+
workflowSnapshot: {
|
|
150
|
+
...workItem.workflowSnapshot,
|
|
151
|
+
actionTemplates: [],
|
|
152
|
+
workItemType: workItem.workflowSnapshot.workItemType,
|
|
153
|
+
stages: [workItem.workflowSnapshot.stages[0]],
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
const patchByStageId = new Map(dependencyPatches.map(patch => [patch.action.stageId, patch]));
|
|
157
|
+
const mergedActions = [
|
|
158
|
+
...activeStages.slice(1).map(stage => {
|
|
159
|
+
const action = planActionFromStage(stage);
|
|
160
|
+
const patch = patchByStageId.get(stage.id);
|
|
161
|
+
if (!patch) return action;
|
|
162
|
+
return {
|
|
163
|
+
...action,
|
|
164
|
+
dependsOnActionIds: [...new Set([
|
|
165
|
+
...(action.dependsOnActionIds || []),
|
|
166
|
+
...patch.addDependsOnActionIds,
|
|
167
|
+
])],
|
|
168
|
+
};
|
|
169
|
+
}),
|
|
170
|
+
...canonicalActions,
|
|
171
|
+
];
|
|
172
|
+
const rawPlan = {
|
|
173
|
+
workItemType: workItem.workflowSnapshot.workItemType,
|
|
174
|
+
actions: stableTopologicalActions(mergedActions),
|
|
175
|
+
};
|
|
176
|
+
const workflowSnapshot = applyGeneratedPlan(synthetic, rawPlan, { availableVpIds });
|
|
177
|
+
const addedStages = workflowSnapshot.stages.filter(stage => addedIds.has(stage.id));
|
|
178
|
+
return {
|
|
179
|
+
proposalId,
|
|
180
|
+
basePlanRevision,
|
|
181
|
+
workflowSnapshot,
|
|
182
|
+
nextActions: addedStages.map(stage => actionForStage(stage, { ...workItem, workflowSnapshot }, [])),
|
|
183
|
+
dependencyPatches,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Engine } from '../engine.js';
|
|
2
2
|
import { ToolRegistry } from '../tools/registry.js';
|
|
3
|
+
import { defineTool } from '../tools/types.js';
|
|
3
4
|
import { allTools } from '../tools/index.js';
|
|
4
5
|
import { parsePatch } from '../tools/apply-patch.js';
|
|
5
6
|
import { defaultRegistry } from '../vp/registry.js';
|
|
@@ -30,6 +31,7 @@ import { loadMCPConfig } from '../config.js';
|
|
|
30
31
|
import { MCPManager } from '../mcp.js';
|
|
31
32
|
import { buildMcpFlattenedTools } from '../tools/mcp-tools.js';
|
|
32
33
|
import { recallWorkspaceSessionContext } from './workspace-context.js';
|
|
34
|
+
import { BUILT_IN_ACTION_TYPES } from './workflow.js';
|
|
33
35
|
|
|
34
36
|
const WORK_ITEM_TOOL_NAMES = Object.freeze([
|
|
35
37
|
'FileRead',
|
|
@@ -280,7 +282,126 @@ function wrapWorkItemTool(tool, canonicalDir, canonicalAttachmentFiles, isRunAct
|
|
|
280
282
|
};
|
|
281
283
|
}
|
|
282
284
|
|
|
283
|
-
export function
|
|
285
|
+
export function planningVpCatalog(vps) {
|
|
286
|
+
return vps.map(vp => ({
|
|
287
|
+
id: vp.id,
|
|
288
|
+
name: vp.name || vp.id,
|
|
289
|
+
nameZh: vp.nameZh || '',
|
|
290
|
+
role: vp.role || '',
|
|
291
|
+
roleZh: vp.roleZh || '',
|
|
292
|
+
area: vp.area || '',
|
|
293
|
+
traits: Array.isArray(vp.traits) ? vp.traits.slice(0, 20) : [],
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function createSubmitWorkItemPlanTool({ vps, collector, isRunActive }) {
|
|
298
|
+
const vpCatalog = planningVpCatalog(vps);
|
|
299
|
+
const vpIds = vpCatalog.map(vp => vp.id);
|
|
300
|
+
const actionTypes = BUILT_IN_ACTION_TYPES.filter(type => type !== 'triage');
|
|
301
|
+
const catalogDescription = `Action types: ${actionTypes.join(', ')}. Available VPs: ${vpCatalog.map(vp => `${vp.id} (${vp.role || vp.area || 'VP'}; ${vp.traits.join(', ') || 'no traits'})`).join('; ')}.`;
|
|
302
|
+
return defineTool({
|
|
303
|
+
name: 'SubmitWorkItemPlan',
|
|
304
|
+
description: `Submit the complete initial WorkItem contract and executable Action DAG. This tool records a Run-local proposal only; Work Center validates and persists it in the current Run finalization transaction. ${catalogDescription}`,
|
|
305
|
+
parameters: {
|
|
306
|
+
type: 'object',
|
|
307
|
+
additionalProperties: false,
|
|
308
|
+
required: ['summary', 'evidence', 'acceptanceChecks', 'workItemType', 'actions'],
|
|
309
|
+
properties: {
|
|
310
|
+
summary: { type: 'string', minLength: 1, maxLength: 2_000 },
|
|
311
|
+
evidence: { type: 'array', minItems: 1, maxItems: 20, items: { type: 'string', minLength: 1, maxLength: 1_000 } },
|
|
312
|
+
acceptanceChecks: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['criterion', 'status', 'evidence'], properties: { criterion: { type: 'string' }, status: { type: 'string', enum: ['passed', 'deferred', 'not_applicable'] }, evidence: { type: 'string', minLength: 1, maxLength: 1_000 } } } },
|
|
313
|
+
contractPatch: { type: 'object', additionalProperties: false, properties: { goal: { type: 'string', minLength: 1, maxLength: 8_000 }, acceptanceCriteria: { type: 'array', items: { type: 'string', minLength: 1, maxLength: 2_000 } } } },
|
|
314
|
+
workItemType: { type: 'string', minLength: 1, maxLength: 64 },
|
|
315
|
+
actions: { type: 'array', minItems: 1, maxItems: 8, items: { type: 'object', additionalProperties: false, required: ['id', 'name', 'type', 'objective', 'approach', 'expectedOutcome', 'candidateVpIds', 'assignmentReason', 'dependsOnActionIds', 'workspaceMode'], properties: {
|
|
316
|
+
id: { type: 'string', minLength: 1, maxLength: 64 }, name: { type: 'string', minLength: 1, maxLength: 120 },
|
|
317
|
+
type: { type: 'string', enum: actionTypes }, capability: { type: 'string', maxLength: 64 },
|
|
318
|
+
objective: { type: 'string', minLength: 1, maxLength: 2_000 }, approach: { type: 'string', minLength: 1, maxLength: 2_000 }, expectedOutcome: { type: 'string', minLength: 1, maxLength: 2_000 },
|
|
319
|
+
candidateVpIds: { type: 'array', minItems: 1, uniqueItems: true, items: { type: 'string', enum: vpIds } }, assignmentReason: { type: 'string', minLength: 1, maxLength: 1_000 },
|
|
320
|
+
dependsOnActionIds: { type: 'array', uniqueItems: true, items: { type: 'string' } }, workspaceMode: { type: 'string', enum: ['read', 'isolated-write', 'integrate', 'shared'] },
|
|
321
|
+
separateFromActionTypes: { type: 'array', uniqueItems: true, items: { type: 'string' } }, changesRequestedActionId: { type: 'string' }, maxAttempts: { type: 'integer', minimum: 1, maximum: 5 },
|
|
322
|
+
} } },
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
async execute(input, ctx = {}) {
|
|
326
|
+
if (!isRunActive()) throw new Error('Work Center Run is no longer active');
|
|
327
|
+
if (collector.value) throw new Error('WorkItem plan was already submitted for this Run');
|
|
328
|
+
collector.value = structuredClone(input);
|
|
329
|
+
ctx.requestEndTurn?.({ kind: 'work_item_plan_submitted' });
|
|
330
|
+
return JSON.stringify({ submitted: true, actionCount: input.actions.length });
|
|
331
|
+
},
|
|
332
|
+
isConcurrencySafe: () => false,
|
|
333
|
+
isReadOnly: () => false,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function terminalPlanningFields() {
|
|
338
|
+
return {
|
|
339
|
+
summary: { type: 'string', minLength: 1, maxLength: 2_000 },
|
|
340
|
+
evidence: { type: 'array', minItems: 1, maxItems: 20, items: { type: 'string', minLength: 1, maxLength: 1_000 } },
|
|
341
|
+
acceptanceChecks: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['criterion', 'status', 'evidence'], properties: { criterion: { type: 'string' }, status: { type: 'string', enum: ['passed', 'deferred', 'not_applicable'] }, evidence: { type: 'string', minLength: 1, maxLength: 1_000 } } } },
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function plannedActionSchema(vpIds, { requireCandidates = true } = {}) {
|
|
346
|
+
const required = ['id', 'name', 'type', 'objective', 'approach', 'expectedOutcome', 'dependsOnActionIds', 'workspaceMode'];
|
|
347
|
+
if (requireCandidates) required.push('candidateVpIds', 'assignmentReason');
|
|
348
|
+
return { type: 'object', additionalProperties: false, required, properties: {
|
|
349
|
+
id: { type: 'string', minLength: 1, maxLength: 64 }, name: { type: 'string', minLength: 1, maxLength: 120 },
|
|
350
|
+
type: { type: 'string', enum: BUILT_IN_ACTION_TYPES.filter(type => type !== 'triage') }, capability: { type: 'string', maxLength: 64 },
|
|
351
|
+
objective: { type: 'string', minLength: 1, maxLength: 2_000 }, approach: { type: 'string', minLength: 1, maxLength: 2_000 }, expectedOutcome: { type: 'string', minLength: 1, maxLength: 2_000 },
|
|
352
|
+
candidateVpIds: { type: 'array', minItems: 1, uniqueItems: true, items: { type: 'string', enum: vpIds } }, assignmentReason: { type: 'string', minLength: 1, maxLength: 1_000 },
|
|
353
|
+
dependsOnActionIds: { type: 'array', uniqueItems: true, items: { type: 'string' } }, workspaceMode: { type: 'string', enum: ['read', 'isolated-write', 'integrate', 'shared'] },
|
|
354
|
+
separateFromActionTypes: { type: 'array', uniqueItems: true, items: { type: 'string' } }, changesRequestedActionId: { type: 'string' }, maxAttempts: { type: 'integer', minimum: 1, maximum: 5 },
|
|
355
|
+
} };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function createProposeWorkItemActionsTool({ vps, workItem, actions, collector, isRunActive }) {
|
|
359
|
+
const vpCatalog = planningVpCatalog(vps);
|
|
360
|
+
const vpIds = vpCatalog.map(vp => vp.id);
|
|
361
|
+
const existing = actions.filter(action => !['superseded', 'cancelled'].includes(action.status));
|
|
362
|
+
return defineTool({
|
|
363
|
+
name: 'ProposeWorkItemActions',
|
|
364
|
+
description: `Propose an additive change to the current WorkItem DAG. It is applied only if this Action completes and its Run lease plus basePlanRevision remain valid. Existing Actions: ${existing.map(action => `${action.id}/${action.stageId} (${action.status}, attempt ${action.attempt})`).join('; ')}. Available VPs: ${vpCatalog.map(vp => `${vp.id} (${vp.role || vp.area || 'VP'})`).join('; ')}. Only add new Actions and optionally add dependencies to attempt=0 ready Actions.`,
|
|
365
|
+
parameters: { type: 'object', additionalProperties: false,
|
|
366
|
+
required: ['summary', 'evidence', 'acceptanceChecks', 'proposalId', 'basePlanRevision', 'actions'],
|
|
367
|
+
properties: {
|
|
368
|
+
...terminalPlanningFields(), proposalId: { type: 'string', minLength: 1, maxLength: 128 },
|
|
369
|
+
basePlanRevision: { type: 'integer', const: workItem.planRevision },
|
|
370
|
+
actions: { type: 'array', minItems: 1, maxItems: 8, items: plannedActionSchema(vpIds) },
|
|
371
|
+
dependencyPatches: { type: 'array', maxItems: 8, items: { type: 'object', additionalProperties: false, required: ['actionId', 'addDependsOnActionIds'], properties: { actionId: { type: 'string', enum: existing.filter(action => action.status === 'ready' && action.attempt === 0).map(action => action.id) }, addDependsOnActionIds: { type: 'array', minItems: 1, uniqueItems: true, items: { type: 'string' } } } } },
|
|
372
|
+
} },
|
|
373
|
+
async execute(input, ctx = {}) {
|
|
374
|
+
if (!isRunActive()) throw new Error('Work Center Run is no longer active');
|
|
375
|
+
if (collector.value) throw new Error('A WorkItem plan mutation was already submitted for this Run');
|
|
376
|
+
collector.value = { kind: 'expand', input: structuredClone(input) };
|
|
377
|
+
ctx.requestEndTurn?.({ kind: 'work_item_actions_proposed', proposalId: input.proposalId });
|
|
378
|
+
return JSON.stringify({ submitted: true, proposalId: input.proposalId, actionCount: input.actions.length });
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function createRequestWorkItemReplanTool({ workItem, collector, isRunActive }) {
|
|
384
|
+
return defineTool({
|
|
385
|
+
name: 'RequestWorkItemReplan',
|
|
386
|
+
description: 'Request an explicit replan barrier when additive Actions are insufficient because the contract or existing future topology must change. The current Action must still complete. Work Center will preserve completed history, fence sibling Runs, supersede only unfinished Actions, and insert a new triage/replan Action. Active integration finalization prevents the barrier.',
|
|
387
|
+
parameters: { type: 'object', additionalProperties: false,
|
|
388
|
+
required: ['summary', 'evidence', 'acceptanceChecks', 'proposalId', 'basePlanRevision', 'reason'],
|
|
389
|
+
properties: {
|
|
390
|
+
...terminalPlanningFields(), proposalId: { type: 'string', minLength: 1, maxLength: 128 },
|
|
391
|
+
basePlanRevision: { type: 'integer', const: workItem.planRevision },
|
|
392
|
+
reason: { type: 'string', minLength: 1, maxLength: 4_000 },
|
|
393
|
+
} },
|
|
394
|
+
async execute(input, ctx = {}) {
|
|
395
|
+
if (!isRunActive()) throw new Error('Work Center Run is no longer active');
|
|
396
|
+
if (collector.value) throw new Error('A WorkItem plan mutation was already submitted for this Run');
|
|
397
|
+
collector.value = { kind: 'replan', input: structuredClone(input) };
|
|
398
|
+
ctx.requestEndTurn?.({ kind: 'work_item_replan_requested', proposalId: input.proposalId });
|
|
399
|
+
return JSON.stringify({ submitted: true, proposalId: input.proposalId });
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function createWorkItemToolRegistry({ workDir, attachmentFiles = [], isRunActive, mcpTools = [], runTools = [] }) {
|
|
284
405
|
const canonicalDir = canonicalWorkDir(path.resolve(workDir));
|
|
285
406
|
const canonicalAttachmentFiles = attachmentFiles.map(file => ({
|
|
286
407
|
...file,
|
|
@@ -296,6 +417,9 @@ export function createWorkItemToolRegistry({ workDir, attachmentFiles = [], isRu
|
|
|
296
417
|
if (!tool?.name?.startsWith('mcp__')) continue;
|
|
297
418
|
registry.register(wrapWorkItemTool(tool, canonicalDir, canonicalAttachmentFiles, isRunActive));
|
|
298
419
|
}
|
|
420
|
+
for (const tool of runTools) {
|
|
421
|
+
registry.register(wrapWorkItemTool(tool, canonicalDir, canonicalAttachmentFiles, isRunActive));
|
|
422
|
+
}
|
|
299
423
|
return registry;
|
|
300
424
|
}
|
|
301
425
|
|
|
@@ -347,12 +471,17 @@ function completionContract(action, workItem) {
|
|
|
347
471
|
const planField = action.type === 'triage' && workItem?.workflowSnapshot?.planningMode === 'ai'
|
|
348
472
|
? ',\n "plan": { "workItemType": "specific-lowercase-slug", "actions": [{ "id": "stable-id", "name": "User-facing name", "type": "extensible-lowercase-slug (built-ins include research|design|diagnose|implement|migrate|test|review|document|operate|deliver|integrate|write|custom)", "capability": "specific executor capability", "objective": "task-specific concrete work this Action must do", "approach": "task-specific repository-aware method the executor must follow", "expectedOutcome": "task-specific verifiable result this Action must produce", "dependsOnActionIds": ["earlier Action id; [] means concurrent root"], "workspaceMode": "read|isolated-write|integrate|shared", "separateFromActionTypes": ["optional prior Action type"], "changesRequestedActionId": "for review: optional earlier editable Action id; omit to use nearest", "maxAttempts": 2 }] }'
|
|
349
473
|
: '';
|
|
474
|
+
const toolSubmission = action.type === 'triage' && workItem?.workflowSnapshot?.planningMode === 'ai'
|
|
475
|
+
? '\nSubmit the initial plan with SubmitWorkItemPlan. The legacy terminal JSON plan below exists only for compatibility; do not use it when the tool is available.'
|
|
476
|
+
: workItem?.workflowSnapshot?.executionMode === 'graph'
|
|
477
|
+
? '\nIf execution discovered strictly additive work, use ProposeWorkItemActions. If the contract or existing unfinished topology must change, use RequestWorkItemReplan. Both tools submit the completed Action and end the turn; do not emit terminal JSON after calling one.'
|
|
478
|
+
: '';
|
|
350
479
|
const acceptanceChecks = (workItem?.acceptanceCriteria || []).map(criterion => ({
|
|
351
480
|
criterion,
|
|
352
481
|
status: 'passed|deferred|not_applicable',
|
|
353
482
|
evidence: 'specific evidence reference',
|
|
354
483
|
}));
|
|
355
|
-
return
|
|
484
|
+
return `${toolSubmission}\n\nYou are executing one Work Center Action. Before the terminal JSON, write a concise user-facing response describing what you did and the result. Do not include raw tool output or secrets. End your response with exactly one JSON object, preferably in a json code fence:\n{
|
|
356
485
|
"outcome": "completed|waiting|retryable|failed",
|
|
357
486
|
"summary": "short result",
|
|
358
487
|
"evidence": ["test, PR, file, or other verifiable evidence"],
|
|
@@ -693,16 +822,34 @@ export class WorkItemRunner {
|
|
|
693
822
|
&& this.store.isActiveRun(run.id, ownerBootId, run.leaseEpoch);
|
|
694
823
|
const workspaceRuntime = await this.#workspaceRuntime(workspaceDir, workDir, isRunActive);
|
|
695
824
|
const mcpToolNames = workspaceRuntime.mcpTools.map(tool => tool.name);
|
|
825
|
+
const planCollector = { value: null };
|
|
826
|
+
const mutationCollector = { value: null };
|
|
827
|
+
const planToolEnabled = executionAction.type === 'triage'
|
|
828
|
+
&& workItem?.workflowSnapshot?.planningMode === 'ai';
|
|
829
|
+
const runTools = [];
|
|
830
|
+
if (planToolEnabled) runTools.push(createSubmitWorkItemPlanTool({
|
|
831
|
+
vps: this.registry.listVps(), collector: planCollector, isRunActive,
|
|
832
|
+
}));
|
|
833
|
+
if (!planToolEnabled && workItem?.workflowSnapshot?.executionMode === 'graph') {
|
|
834
|
+
runTools.push(createProposeWorkItemActionsTool({
|
|
835
|
+
vps: this.registry.listVps(), workItem,
|
|
836
|
+
actions: this.store.getWorkItemDetail(workItem.id).actions,
|
|
837
|
+
collector: mutationCollector, isRunActive,
|
|
838
|
+
}));
|
|
839
|
+
runTools.push(createRequestWorkItemReplanTool({ workItem, collector: mutationCollector, isRunActive }));
|
|
840
|
+
}
|
|
841
|
+
const runToolNames = runTools.map(tool => tool.name);
|
|
696
842
|
const toolPolicySnapshot = workItemToolPolicySnapshot(
|
|
697
843
|
workDir,
|
|
698
844
|
attachmentContext.files.map(file => file.ref),
|
|
699
|
-
mcpToolNames,
|
|
845
|
+
[...mcpToolNames, ...runToolNames],
|
|
700
846
|
);
|
|
701
847
|
const toolRegistry = createWorkItemToolRegistry({
|
|
702
848
|
workDir,
|
|
703
849
|
attachmentFiles: attachmentContext.files,
|
|
704
850
|
isRunActive,
|
|
705
851
|
mcpTools: workspaceRuntime.mcpTools,
|
|
852
|
+
runTools,
|
|
706
853
|
});
|
|
707
854
|
const config = {
|
|
708
855
|
...runtime.config,
|
|
@@ -844,7 +991,36 @@ export class WorkItemRunner {
|
|
|
844
991
|
}
|
|
845
992
|
const response = publicWorkItemResponse(text);
|
|
846
993
|
reportProgress(true);
|
|
847
|
-
const
|
|
994
|
+
const submittedPlan = planCollector.value;
|
|
995
|
+
const submittedExpansion = mutationCollector.value?.kind === 'expand'
|
|
996
|
+
? mutationCollector.value.input : null;
|
|
997
|
+
const submittedReplan = mutationCollector.value?.kind === 'replan'
|
|
998
|
+
? mutationCollector.value.input : null;
|
|
999
|
+
const parsedResult = submittedPlan ? {
|
|
1000
|
+
outcome: 'completed',
|
|
1001
|
+
summary: submittedPlan.summary,
|
|
1002
|
+
evidence: submittedPlan.evidence,
|
|
1003
|
+
contractPatch: submittedPlan.contractPatch || null,
|
|
1004
|
+
plan: { workItemType: submittedPlan.workItemType, actions: submittedPlan.actions },
|
|
1005
|
+
acceptanceChecks: submittedPlan.acceptanceChecks,
|
|
1006
|
+
} : submittedExpansion ? {
|
|
1007
|
+
outcome: 'completed', summary: submittedExpansion.summary,
|
|
1008
|
+
evidence: submittedExpansion.evidence, acceptanceChecks: submittedExpansion.acceptanceChecks,
|
|
1009
|
+
planProposal: {
|
|
1010
|
+
proposalId: submittedExpansion.proposalId,
|
|
1011
|
+
basePlanRevision: submittedExpansion.basePlanRevision,
|
|
1012
|
+
actions: submittedExpansion.actions,
|
|
1013
|
+
dependencyPatches: submittedExpansion.dependencyPatches || [],
|
|
1014
|
+
},
|
|
1015
|
+
} : submittedReplan ? {
|
|
1016
|
+
outcome: 'completed', summary: submittedReplan.summary,
|
|
1017
|
+
evidence: submittedReplan.evidence, acceptanceChecks: submittedReplan.acceptanceChecks,
|
|
1018
|
+
replanRequest: {
|
|
1019
|
+
proposalId: submittedReplan.proposalId,
|
|
1020
|
+
basePlanRevision: submittedReplan.basePlanRevision,
|
|
1021
|
+
reason: submittedReplan.reason,
|
|
1022
|
+
},
|
|
1023
|
+
} : parseStructuredResult(text, executionAction.type);
|
|
848
1024
|
if (parsedResult.outcome === 'completed' && action.workspace?.isolated) {
|
|
849
1025
|
const workspace = commitActionWorktree(action.workspace, action);
|
|
850
1026
|
this.store.setActionWorkspace(action.id, workspace);
|
|
@@ -64,7 +64,9 @@ export class WorkCenterService {
|
|
|
64
64
|
this.ownerBootId = options.ownerBootId || randomUUID();
|
|
65
65
|
this.attachmentRoot = options.attachmentRoot || join(yeaftDir, 'work-center', 'attachments');
|
|
66
66
|
this.store = options.store || new WorkItemStore(join(yeaftDir, 'work-center', 'work-center.db'));
|
|
67
|
-
this.controller = options.controller || new WorkflowController(this.store
|
|
67
|
+
this.controller = options.controller || new WorkflowController(this.store, {
|
|
68
|
+
listAvailableVpIds: options.listAvailableVpIds,
|
|
69
|
+
});
|
|
68
70
|
this.onEvent = typeof options.onEvent === 'function' ? options.onEvent : () => {};
|
|
69
71
|
this.watcher = new WorkItemWatcher({
|
|
70
72
|
store: this.store,
|
|
@@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
5
5
|
import { normalizeEvidence } from './evidence.js';
|
|
6
6
|
import { normalizeActionCheckpoint } from './action-checkpoint.js';
|
|
7
7
|
|
|
8
|
-
const SCHEMA_VERSION =
|
|
8
|
+
const SCHEMA_VERSION = 12;
|
|
9
9
|
const OPEN_ACTION_STATUSES = "'ready','running','waiting'";
|
|
10
10
|
const MAX_REUSABLE_CONTEXT_ITEMS = 12;
|
|
11
11
|
const MAX_RUN_RESPONSE_CHARS = 65_536;
|
|
@@ -34,6 +34,7 @@ function mapWorkItem(row) {
|
|
|
34
34
|
return {
|
|
35
35
|
id: row.id,
|
|
36
36
|
revision: row.revision,
|
|
37
|
+
planRevision: Math.max(0, Number(row.plan_revision) || 0),
|
|
37
38
|
title: row.title,
|
|
38
39
|
goal: row.goal,
|
|
39
40
|
acceptanceCriteria: parseJson(row.acceptance_criteria, []),
|
|
@@ -181,6 +182,7 @@ export class WorkItemStore {
|
|
|
181
182
|
CREATE TABLE IF NOT EXISTS work_items (
|
|
182
183
|
id TEXT PRIMARY KEY,
|
|
183
184
|
revision INTEGER NOT NULL DEFAULT 1,
|
|
185
|
+
plan_revision INTEGER NOT NULL DEFAULT 0,
|
|
184
186
|
title TEXT NOT NULL,
|
|
185
187
|
goal TEXT NOT NULL,
|
|
186
188
|
acceptance_criteria TEXT NOT NULL,
|
|
@@ -266,6 +268,19 @@ export class WorkItemStore {
|
|
|
266
268
|
data TEXT NOT NULL,
|
|
267
269
|
created_at INTEGER NOT NULL
|
|
268
270
|
);
|
|
271
|
+
CREATE TABLE IF NOT EXISTS plan_audits (
|
|
272
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
273
|
+
work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
|
|
274
|
+
proposal_id TEXT NOT NULL,
|
|
275
|
+
base_plan_revision INTEGER NOT NULL,
|
|
276
|
+
plan_revision INTEGER NOT NULL,
|
|
277
|
+
kind TEXT NOT NULL,
|
|
278
|
+
action_id TEXT NOT NULL,
|
|
279
|
+
run_id TEXT NOT NULL,
|
|
280
|
+
data TEXT NOT NULL,
|
|
281
|
+
created_at INTEGER NOT NULL,
|
|
282
|
+
UNIQUE(work_item_id, proposal_id)
|
|
283
|
+
);
|
|
269
284
|
CREATE INDEX IF NOT EXISTS idx_work_items_status_updated ON work_items(status, updated_at DESC);
|
|
270
285
|
CREATE INDEX IF NOT EXISTS idx_actions_ready ON actions(status, updated_at, sequence);
|
|
271
286
|
CREATE INDEX IF NOT EXISTS idx_runs_active ON runs(status, expires_at);
|
|
@@ -362,6 +377,9 @@ export class WorkItemStore {
|
|
|
362
377
|
if (!hasColumn(this.db, 'actions', 'workspace')) {
|
|
363
378
|
this.db.exec('ALTER TABLE actions ADD COLUMN workspace TEXT');
|
|
364
379
|
}
|
|
380
|
+
if (!hasColumn(this.db, 'work_items', 'plan_revision')) {
|
|
381
|
+
this.db.exec('ALTER TABLE work_items ADD COLUMN plan_revision INTEGER NOT NULL DEFAULT 0');
|
|
382
|
+
}
|
|
365
383
|
this.db.prepare(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
|
|
366
384
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(SCHEMA_VERSION));
|
|
367
385
|
}
|
|
@@ -1322,17 +1340,76 @@ export class WorkItemStore {
|
|
|
1322
1340
|
nextWorkItem = this.getWorkItem(workItem.id);
|
|
1323
1341
|
}
|
|
1324
1342
|
if (transition.workflowSnapshot) {
|
|
1325
|
-
|
|
1326
|
-
|
|
1343
|
+
const expectedPlanRevision = Number.isInteger(transition.expectedPlanRevision)
|
|
1344
|
+
? transition.expectedPlanRevision
|
|
1345
|
+
: workItem.planRevision;
|
|
1346
|
+
const changedPlan = this.db.prepare(`UPDATE work_items SET workflow_template = ?, workflow_snapshot = ?,
|
|
1347
|
+
plan_revision = plan_revision + 1, updated_at = ? WHERE id = ? AND plan_revision = ?`).run(
|
|
1327
1348
|
transition.workflowSnapshot.id,
|
|
1328
1349
|
stringify(transition.workflowSnapshot),
|
|
1329
1350
|
now,
|
|
1330
1351
|
workItem.id,
|
|
1352
|
+
expectedPlanRevision,
|
|
1331
1353
|
);
|
|
1354
|
+
if (Number(changedPlan.changes) !== 1) {
|
|
1355
|
+
throw new Error('Work Center plan revision changed before finalization');
|
|
1356
|
+
}
|
|
1332
1357
|
nextWorkItem = this.getWorkItem(workItem.id);
|
|
1333
1358
|
}
|
|
1334
1359
|
|
|
1360
|
+
for (const patch of Array.isArray(transition.dependencyPatches) ? transition.dependencyPatches : []) {
|
|
1361
|
+
const actionPatch = patch.action;
|
|
1362
|
+
const dependencies = [...new Set([
|
|
1363
|
+
...(actionPatch.dependsOnStageIds || []),
|
|
1364
|
+
...(patch.addDependsOnActionIds || []),
|
|
1365
|
+
])];
|
|
1366
|
+
const changed = this.db.prepare(`UPDATE actions SET depends_on_stage_ids = ?, updated_at = ?
|
|
1367
|
+
WHERE id = ? AND work_item_id = ? AND status = 'ready' AND attempt = 0 AND current_run_id IS NULL`).run(
|
|
1368
|
+
stringify(dependencies), now, actionPatch.id, workItem.id,
|
|
1369
|
+
);
|
|
1370
|
+
if (Number(changed.changes) !== 1) {
|
|
1371
|
+
throw new Error('Work Center dependency patch lost its unattempted Action fence');
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1335
1375
|
let nextAction = null;
|
|
1376
|
+
if (transition.replanBarrier) {
|
|
1377
|
+
const barrier = transition.replanBarrier;
|
|
1378
|
+
if (barrier.basePlanRevision !== workItem.planRevision) {
|
|
1379
|
+
throw new Error('Work Center replan request has a stale basePlanRevision');
|
|
1380
|
+
}
|
|
1381
|
+
const activeActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
|
|
1382
|
+
AND status NOT IN ('superseded', 'cancelled') ORDER BY sequence`).all(workItem.id).map(mapAction);
|
|
1383
|
+
this.#assertNoIntegrationReservation(activeActions, now);
|
|
1384
|
+
const unfinished = activeActions.filter(candidate => candidate.id !== action.id && candidate.status !== 'completed');
|
|
1385
|
+
for (const candidate of unfinished) {
|
|
1386
|
+
if (candidate.status === 'running' && candidate.currentRunId) {
|
|
1387
|
+
this.db.prepare(`UPDATE runs SET status = 'superseded', ended_at = ?, error = ?
|
|
1388
|
+
WHERE id = ? AND status = 'running'`).run(now, 'Superseded by Work Center replan barrier', candidate.currentRunId);
|
|
1389
|
+
}
|
|
1390
|
+
this.db.prepare(`UPDATE actions SET status = 'superseded', current_run_id = NULL,
|
|
1391
|
+
lease_epoch = lease_epoch + 1, updated_at = ? WHERE id = ? AND status != 'completed'`).run(now, candidate.id);
|
|
1392
|
+
}
|
|
1393
|
+
const replanWorkflow = {
|
|
1394
|
+
...nextWorkItem.workflowSnapshot,
|
|
1395
|
+
stages: [
|
|
1396
|
+
...(nextWorkItem.workflowSnapshot?.stages || []).filter(stage => activeActions
|
|
1397
|
+
.some(candidate => candidate.stageId === stage.id && candidate.status === 'completed')),
|
|
1398
|
+
{ ...(nextWorkItem.workflowSnapshot?.stages?.[0] || {}), id: barrier.action.stageId, type: 'triage', name: 'Replan' },
|
|
1399
|
+
],
|
|
1400
|
+
};
|
|
1401
|
+
const changedPlan = this.db.prepare(`UPDATE work_items SET workflow_snapshot = ?, plan_revision = plan_revision + 1,
|
|
1402
|
+
updated_at = ? WHERE id = ? AND plan_revision = ?`).run(
|
|
1403
|
+
stringify(replanWorkflow), now, workItem.id, barrier.basePlanRevision,
|
|
1404
|
+
);
|
|
1405
|
+
if (Number(changedPlan.changes) !== 1) throw new Error('Work Center replan barrier lost its plan revision fence');
|
|
1406
|
+
nextWorkItem = this.getWorkItem(workItem.id);
|
|
1407
|
+
nextAction = this.#insertAction(workItem.id, {
|
|
1408
|
+
...barrier.action,
|
|
1409
|
+
contractRevision: nextWorkItem.revision,
|
|
1410
|
+
status: 'ready',
|
|
1411
|
+
}, this.#nextSequence(workItem.id), now);
|
|
1412
|
+
}
|
|
1336
1413
|
if (transition.graphResetStageId) {
|
|
1337
1414
|
nextAction = this.#resetGraphFromStage(
|
|
1338
1415
|
workItem.id,
|
|
@@ -1382,8 +1459,33 @@ export class WorkItemStore {
|
|
|
1382
1459
|
if (Number(changedWorkItem.changes) !== 1) {
|
|
1383
1460
|
throw new Error('Work Center terminal transition lost the current WorkItem fence');
|
|
1384
1461
|
}
|
|
1462
|
+
if (transition.workflowSnapshot || transition.replanBarrier) {
|
|
1463
|
+
const proposalId = transition.proposalId || transition.replanBarrier?.proposalId;
|
|
1464
|
+
if (!proposalId) throw new Error('Work Center plan mutation requires proposalId');
|
|
1465
|
+
try {
|
|
1466
|
+
this.db.prepare(`INSERT INTO plan_audits
|
|
1467
|
+
(work_item_id, proposal_id, base_plan_revision, plan_revision, kind, action_id, run_id, data, created_at)
|
|
1468
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
1469
|
+
workItem.id, proposalId, workItem.planRevision, nextWorkItem.planRevision,
|
|
1470
|
+
transition.replanBarrier ? 'replan' : (workItem.planRevision === 0 ? 'initial' : 'expand'),
|
|
1471
|
+
action.id, runId, stringify(transition.eventData || {}), now,
|
|
1472
|
+
);
|
|
1473
|
+
} catch (error) {
|
|
1474
|
+
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
|
|
1475
|
+
throw new Error(`Work Center plan proposal was already applied: ${proposalId}`);
|
|
1476
|
+
}
|
|
1477
|
+
throw error;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1385
1480
|
this.onTransitionStep?.('before_event');
|
|
1386
|
-
this.appendEvent(workItem.id, transition.eventType,
|
|
1481
|
+
this.appendEvent(workItem.id, transition.eventType, {
|
|
1482
|
+
...(transition.eventData || {}),
|
|
1483
|
+
...((transition.workflowSnapshot || transition.replanBarrier) ? {
|
|
1484
|
+
proposalId: transition.proposalId || transition.replanBarrier?.proposalId || null,
|
|
1485
|
+
previousPlanRevision: workItem.planRevision,
|
|
1486
|
+
planRevision: nextWorkItem.planRevision,
|
|
1487
|
+
} : {}),
|
|
1488
|
+
}, {
|
|
1387
1489
|
actionId: action.id,
|
|
1388
1490
|
runId,
|
|
1389
1491
|
});
|
|
@@ -17,7 +17,7 @@ export const BUILT_IN_ACTION_TYPES = Object.freeze([
|
|
|
17
17
|
'custom',
|
|
18
18
|
]);
|
|
19
19
|
const STAGE_TYPES = new Set(BUILT_IN_ACTION_TYPES);
|
|
20
|
-
const ASSIGNMENT_MODES = new Set(['auto', 'pool', 'fixed']);
|
|
20
|
+
const ASSIGNMENT_MODES = new Set(['auto', 'pool', 'fixed', 'planned']);
|
|
21
21
|
const MODEL_MODES = new Set(['inherit', 'primary', 'fast', 'specific']);
|
|
22
22
|
const MODEL_EFFORTS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
|
|
23
23
|
const WORKSPACE_MODES = new Set(['shared', 'read', 'isolated-write', 'integrate']);
|
|
@@ -108,11 +108,29 @@ export const RUN_OUTCOMES = Object.freeze([
|
|
|
108
108
|
'failed',
|
|
109
109
|
]);
|
|
110
110
|
|
|
111
|
-
function
|
|
111
|
+
export function canonicalActionId(value, fallback = '') {
|
|
112
112
|
const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
113
113
|
return normalized || fallback;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
export function canonicalExplicitActionId(value, field) {
|
|
117
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
118
|
+
throw new Error(`Work Center ${field} contains an empty Action reference`);
|
|
119
|
+
}
|
|
120
|
+
const id = canonicalActionId(value);
|
|
121
|
+
if (!id) {
|
|
122
|
+
throw new Error(`Work Center ${field} contains an invalid Action reference: ${value}`);
|
|
123
|
+
}
|
|
124
|
+
return id;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function canonicalExplicitActionIds(value, field) {
|
|
128
|
+
if (!Array.isArray(value)) {
|
|
129
|
+
throw new Error(`Work Center ${field} must be an array of Action references`);
|
|
130
|
+
}
|
|
131
|
+
return [...new Set(value.map(item => canonicalExplicitActionId(item, field)))];
|
|
132
|
+
}
|
|
133
|
+
|
|
116
134
|
function uniqueStrings(value) {
|
|
117
135
|
if (!Array.isArray(value)) return [];
|
|
118
136
|
return [...new Set(value.map(item => String(item || '').trim()).filter(Boolean))];
|
|
@@ -126,12 +144,17 @@ export function normalizeAssignmentPolicy(value, stageType = 'custom') {
|
|
|
126
144
|
: null;
|
|
127
145
|
const candidateVpIds = uniqueStrings(source.candidateVpIds);
|
|
128
146
|
if (mode === 'fixed' && !fixedVpId) throw new Error('Fixed Work Center assignment requires fixedVpId');
|
|
129
|
-
if (
|
|
147
|
+
if (['pool', 'planned'].includes(mode) && candidateVpIds.length === 0) {
|
|
148
|
+
throw new Error('Work Center VP candidate list cannot be empty');
|
|
149
|
+
}
|
|
130
150
|
return {
|
|
131
151
|
mode,
|
|
132
|
-
capability:
|
|
152
|
+
capability: canonicalActionId(source.capability, stageType),
|
|
133
153
|
candidateVpIds,
|
|
134
154
|
fixedVpId,
|
|
155
|
+
assignmentReason: typeof source.assignmentReason === 'string'
|
|
156
|
+
? source.assignmentReason.trim().slice(0, 1_000)
|
|
157
|
+
: '',
|
|
135
158
|
separateFromStageTypes: uniqueStrings(source.separateFromStageTypes),
|
|
136
159
|
};
|
|
137
160
|
}
|
|
@@ -173,14 +196,14 @@ function normalizeGlobalInstructions(value) {
|
|
|
173
196
|
}
|
|
174
197
|
|
|
175
198
|
function generatedActionType(value) {
|
|
176
|
-
return
|
|
199
|
+
return canonicalActionId(value, 'custom').slice(0, 64);
|
|
177
200
|
}
|
|
178
201
|
|
|
179
202
|
export function normalizeWorkflowDefinition(value, index = 0) {
|
|
180
203
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
181
204
|
throw new Error('Work Center workflow must be an object');
|
|
182
205
|
}
|
|
183
|
-
const id =
|
|
206
|
+
const id = canonicalActionId(value.id, `workflow-${index + 1}`);
|
|
184
207
|
const name = String(value.name || id).trim() || id;
|
|
185
208
|
if (!Array.isArray(value.stages) || value.stages.length === 0) {
|
|
186
209
|
throw new Error(`Work Center workflow "${id}" requires at least one stage`);
|
|
@@ -193,7 +216,7 @@ export function normalizeWorkflowDefinition(value, index = 0) {
|
|
|
193
216
|
const type = planningMode === 'ai'
|
|
194
217
|
? generatedActionType(source.type)
|
|
195
218
|
: (STAGE_TYPES.has(source.type) ? source.type : 'custom');
|
|
196
|
-
const stageId =
|
|
219
|
+
const stageId = canonicalActionId(source.id, `${type}-${stageIndex + 1}`);
|
|
197
220
|
if (seen.has(stageId)) throw new Error(`Duplicate Work Center stage id: ${stageId}`);
|
|
198
221
|
seen.add(stageId);
|
|
199
222
|
const stage = {
|
|
@@ -211,7 +234,7 @@ export function normalizeWorkflowDefinition(value, index = 0) {
|
|
|
211
234
|
maxAttempts: Math.min(Math.max(Number(source.maxAttempts) || 2, 1), 5),
|
|
212
235
|
};
|
|
213
236
|
if (type === 'review') {
|
|
214
|
-
stage.changesRequestedStageId =
|
|
237
|
+
stage.changesRequestedStageId = canonicalActionId(source.changesRequestedStageId, 'implement');
|
|
215
238
|
}
|
|
216
239
|
return stage;
|
|
217
240
|
});
|
|
@@ -233,7 +256,7 @@ export function normalizeWorkflowDefinition(value, index = 0) {
|
|
|
233
256
|
planningMode,
|
|
234
257
|
executionMode,
|
|
235
258
|
workItemType: typeof value.workItemType === 'string' && value.workItemType.trim()
|
|
236
|
-
?
|
|
259
|
+
? canonicalActionId(value.workItemType, 'general')
|
|
237
260
|
: null,
|
|
238
261
|
globalInstructions: normalizeGlobalInstructions(value.globalInstructions),
|
|
239
262
|
modelPolicy: normalizeModelPolicy(value.modelPolicy),
|
|
@@ -324,7 +347,7 @@ export function resolvePlanningWorkflowSnapshot(settings, requestedWorkItemType
|
|
|
324
347
|
const requestedType = typeof requestedWorkItemType === 'string'
|
|
325
348
|
&& requestedWorkItemType.trim()
|
|
326
349
|
&& requestedWorkItemType.trim().toLowerCase() !== 'auto'
|
|
327
|
-
?
|
|
350
|
+
? canonicalActionId(requestedWorkItemType, '').slice(0, 64)
|
|
328
351
|
: '';
|
|
329
352
|
const actionTemplates = normalized.workflows.map(workflow => normalizeWorkflowDefinition({
|
|
330
353
|
...workflow,
|
|
@@ -394,8 +417,9 @@ export function resolveWorkflowSnapshot(settings, workflowId, stageOverrides = {
|
|
|
394
417
|
});
|
|
395
418
|
}
|
|
396
419
|
|
|
397
|
-
export function applyGeneratedPlan(workItem, rawPlan) {
|
|
420
|
+
export function applyGeneratedPlan(workItem, rawPlan, options = {}) {
|
|
398
421
|
const source = workflowFrom(workItem);
|
|
422
|
+
const forceGraph = options.forceGraph !== false;
|
|
399
423
|
if (source.planningMode !== 'ai') return source;
|
|
400
424
|
if (!rawPlan || typeof rawPlan !== 'object' || Array.isArray(rawPlan)) {
|
|
401
425
|
throw new Error('AI-planned triage requires a structured plan');
|
|
@@ -403,17 +427,23 @@ export function applyGeneratedPlan(workItem, rawPlan) {
|
|
|
403
427
|
if (typeof rawPlan.workItemType !== 'string' || !rawPlan.workItemType.trim()) {
|
|
404
428
|
throw new Error('AI-planned triage requires a specific workItemType');
|
|
405
429
|
}
|
|
406
|
-
const workItemType =
|
|
430
|
+
const workItemType = canonicalActionId(rawPlan.workItemType, '').slice(0, 64);
|
|
407
431
|
if (!workItemType) throw new Error('AI-planned triage requires a valid workItemType');
|
|
408
432
|
if (source.workItemType && workItemType !== source.workItemType) {
|
|
409
433
|
throw new Error(`AI-planned triage must keep the selected workItemType "${source.workItemType}"`);
|
|
410
434
|
}
|
|
435
|
+
const reservedStageIds = new Set((options.reservedStageIds || [])
|
|
436
|
+
.map(id => String(id || '').trim()).filter(Boolean));
|
|
411
437
|
const reusableTemplate = source.actionTemplates.find(template => template.workItemType === workItemType);
|
|
412
438
|
if (reusableTemplate) {
|
|
413
439
|
const templateActions = reusableTemplate.stages.filter(stage => stage.type !== 'triage');
|
|
414
440
|
if (templateActions.length === 0) {
|
|
415
441
|
throw new Error(`Reusable Action template "${workItemType}" has no executable Actions`);
|
|
416
442
|
}
|
|
443
|
+
const reusedStageId = templateActions.find(stage => reservedStageIds.has(stage.id))?.id;
|
|
444
|
+
if (reusedStageId) {
|
|
445
|
+
throw new Error(`AI-planned Action id reuses historical stage identity: ${reusedStageId}`);
|
|
446
|
+
}
|
|
417
447
|
return normalizeWorkflowDefinition({
|
|
418
448
|
...source,
|
|
419
449
|
workItemType,
|
|
@@ -424,14 +454,20 @@ export function applyGeneratedPlan(workItem, rawPlan) {
|
|
|
424
454
|
if (!Array.isArray(rawPlan.actions) || rawPlan.actions.length < 1 || rawPlan.actions.length > 8) {
|
|
425
455
|
throw new Error('AI-planned triage requires between 1 and 8 Actions when no reusable template exists');
|
|
426
456
|
}
|
|
457
|
+
const availableVpIds = Array.isArray(options.availableVpIds)
|
|
458
|
+
? new Set(options.availableVpIds.map(id => String(id || '').trim()).filter(Boolean))
|
|
459
|
+
: null;
|
|
427
460
|
const seen = new Set(['triage']);
|
|
428
461
|
let previousGeneratedId = null;
|
|
429
462
|
const generated = rawPlan.actions.map((rawAction, index) => {
|
|
430
463
|
const input = rawAction && typeof rawAction === 'object' && !Array.isArray(rawAction) ? rawAction : {};
|
|
431
464
|
const type = generatedActionType(input.type);
|
|
432
465
|
if (type === 'triage') throw new Error('AI-planned Actions cannot add another triage Action');
|
|
433
|
-
const id =
|
|
466
|
+
const id = canonicalActionId(input.id, `${type}-${index + 1}`);
|
|
434
467
|
if (seen.has(id)) throw new Error(`Duplicate AI-planned Action id: ${id}`);
|
|
468
|
+
if (reservedStageIds.has(id)) {
|
|
469
|
+
throw new Error(`AI-planned Action id reuses historical stage identity: ${id}`);
|
|
470
|
+
}
|
|
435
471
|
seen.add(id);
|
|
436
472
|
const objective = typeof input.objective === 'string' ? input.objective.trim().slice(0, 2_000) : '';
|
|
437
473
|
if (!objective) throw new Error(`AI-planned Action "${id}" requires a task-specific objective`);
|
|
@@ -451,6 +487,17 @@ export function applyGeneratedPlan(workItem, rawPlan) {
|
|
|
451
487
|
const actionInstruction = Object.hasOwn(source.actionInstructions, type)
|
|
452
488
|
? source.actionInstructions[type]
|
|
453
489
|
: source.actionInstructions.custom;
|
|
490
|
+
const candidateVpIds = uniqueStrings(input.candidateVpIds);
|
|
491
|
+
if (candidateVpIds.length > 0 && availableVpIds) {
|
|
492
|
+
const unavailable = candidateVpIds.find(vpId => !availableVpIds.has(vpId));
|
|
493
|
+
if (unavailable) throw new Error(`AI-planned Action "${id}" references unavailable VP "${unavailable}"`);
|
|
494
|
+
}
|
|
495
|
+
const assignmentReason = typeof input.assignmentReason === 'string'
|
|
496
|
+
? input.assignmentReason.trim().slice(0, 1_000)
|
|
497
|
+
: '';
|
|
498
|
+
if (candidateVpIds.length > 0 && !assignmentReason) {
|
|
499
|
+
throw new Error(`AI-planned Action "${id}" requires an assignmentReason`);
|
|
500
|
+
}
|
|
454
501
|
const stage = {
|
|
455
502
|
id,
|
|
456
503
|
name: String(input.name || id).trim().slice(0, 120) || id,
|
|
@@ -459,25 +506,37 @@ export function applyGeneratedPlan(workItem, rawPlan) {
|
|
|
459
506
|
approach,
|
|
460
507
|
expectedOutcome,
|
|
461
508
|
instruction: actionInstruction,
|
|
462
|
-
assignmentPolicy: {
|
|
509
|
+
assignmentPolicy: candidateVpIds.length > 0 ? {
|
|
510
|
+
mode: 'planned',
|
|
511
|
+
capability: canonicalActionId(input.capability, type),
|
|
512
|
+
candidateVpIds,
|
|
513
|
+
fixedVpId: null,
|
|
514
|
+
assignmentReason,
|
|
515
|
+
separateFromStageTypes: uniqueStrings(input.separateFromActionTypes),
|
|
516
|
+
} : {
|
|
463
517
|
mode: 'auto',
|
|
464
|
-
capability:
|
|
518
|
+
capability: canonicalActionId(input.capability, type),
|
|
465
519
|
candidateVpIds: [],
|
|
466
520
|
fixedVpId: null,
|
|
521
|
+
assignmentReason: '',
|
|
467
522
|
separateFromStageTypes: uniqueStrings(input.separateFromActionTypes),
|
|
468
523
|
},
|
|
469
524
|
modelPolicy: source.actionModelPolicies[type] || source.actionModelPolicies.custom,
|
|
470
525
|
dependsOnStageIds: Object.hasOwn(input, 'dependsOnActionIds')
|
|
471
|
-
?
|
|
526
|
+
? canonicalExplicitActionIds(
|
|
527
|
+
input.dependsOnActionIds,
|
|
528
|
+
`Action "${id}" dependencies`,
|
|
529
|
+
)
|
|
472
530
|
: (previousGeneratedId ? [previousGeneratedId] : []),
|
|
473
531
|
workspaceMode: WORKSPACE_MODES.has(input.workspaceMode) ? input.workspaceMode : 'shared',
|
|
474
532
|
maxAttempts: Math.min(Math.max(Number(input.maxAttempts) || 2, 1), 5),
|
|
475
533
|
};
|
|
476
534
|
previousGeneratedId = id;
|
|
477
|
-
if (type === 'review' && Object.
|
|
478
|
-
stage.changesRequestedStageId =
|
|
479
|
-
|
|
480
|
-
|
|
535
|
+
if (type === 'review' && Object.hasOwn(input, 'changesRequestedActionId')) {
|
|
536
|
+
stage.changesRequestedStageId = canonicalExplicitActionId(
|
|
537
|
+
input.changesRequestedActionId,
|
|
538
|
+
`Action "${id}" review target`,
|
|
539
|
+
);
|
|
481
540
|
}
|
|
482
541
|
return stage;
|
|
483
542
|
});
|
|
@@ -497,6 +556,14 @@ export function applyGeneratedPlan(workItem, rawPlan) {
|
|
|
497
556
|
if (!stage.changesRequestedStageId) {
|
|
498
557
|
throw new Error(`AI-planned review Action "${stage.id}" requires an earlier editable Action`);
|
|
499
558
|
}
|
|
559
|
+
const returnTarget = candidates.find(candidate => candidate.id === stage.changesRequestedStageId);
|
|
560
|
+
stage.assignmentPolicy = {
|
|
561
|
+
...stage.assignmentPolicy,
|
|
562
|
+
separateFromStageTypes: uniqueStrings([
|
|
563
|
+
...(stage.assignmentPolicy.separateFromStageTypes || []),
|
|
564
|
+
returnTarget.type,
|
|
565
|
+
]),
|
|
566
|
+
};
|
|
500
567
|
}
|
|
501
568
|
const hasIsolatedWrites = generated.some(stage => stage.workspaceMode === 'isolated-write');
|
|
502
569
|
const integrationStages = generated.filter(stage => stage.workspaceMode === 'integrate');
|
|
@@ -530,7 +597,7 @@ export function applyGeneratedPlan(workItem, rawPlan) {
|
|
|
530
597
|
}
|
|
531
598
|
return normalizeWorkflowDefinition({
|
|
532
599
|
...source,
|
|
533
|
-
executionMode: 'graph',
|
|
600
|
+
executionMode: forceGraph ? 'graph' : source.executionMode,
|
|
534
601
|
workItemType,
|
|
535
602
|
stages: [source.stages[0], ...generated],
|
|
536
603
|
});
|