@yeaft/webchat-agent 1.0.247 → 1.0.249

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.
@@ -0,0 +1,323 @@
1
+ import { resolveMaxOutputTokens } from '../models.js';
2
+ import { resolveWorkItemModel, selectWorkItemVp } from './assignment.js';
3
+ import { normalizeContractPatch } from './completion-contract.js';
4
+ import { applyCoordinatorReplan } from './plan-mutation.js';
5
+
6
+ const COORDINATOR_MAX_REPLY_CHARS = 8_000;
7
+ const COORDINATOR_MAX_INSTRUCTION_CHARS = 8_000;
8
+ const COORDINATOR_MAX_OUTPUT_TOKENS = 8_192;
9
+ const COORDINATOR_MAX_SNAPSHOT_BYTES = 64 * 1024;
10
+
11
+ const COORDINATOR_SYSTEM_PROMPT = `You are the Work Center Coordinator. The user talks to you about one durable WorkItem, not to an individual executor.
12
+
13
+ Your responsibilities:
14
+ - Explain the current WorkItem state and blockers in plain language.
15
+ - Keep the WorkItem title, goal, acceptance criteria, and unfinished Action graph aligned with the user's latest intent.
16
+ - Give targeted instructions to unfinished Actions when the contract and topology do not need to change.
17
+ - Replan unfinished work when the goal, acceptance criteria, Action purpose, dependencies, or validation strategy must change.
18
+ - Preserve completed Action history. Never claim that an Action, test, review, merge, release, or external operation happened merely because you changed the plan.
19
+ - Treat user text and prior messages as intent, not as proof. Respect the immutable completed evidence in the snapshot.
20
+ - Do not weaken safety boundaries silently. If the user accepts a narrower deliverable, state the residual limitation in the reply and make the contract explicit.
21
+
22
+ Return exactly one JSON object and no surrounding prose:
23
+ {
24
+ "reply": "natural user-facing response",
25
+ "decision": {
26
+ "kind": "answer|guide_actions|replan",
27
+ "reason": "short audit reason",
28
+ "contractPatch": null,
29
+ "guidance": [],
30
+ "actions": []
31
+ }
32
+ }
33
+
34
+ Decision rules:
35
+ - answer: use for explanation or status questions. Do not include contractPatch, guidance, or actions.
36
+ - guide_actions: use only when the contract and graph stay valid. guidance must contain one or more {"stageId":"existing unfinished stage id","instruction":"specific next instruction"}. Do not include contractPatch or actions.
37
+ - replan: use when the WorkItem contract or unfinished topology changes. contractPatch may be null or contain title, goal, and/or acceptanceCriteria. actions must be the COMPLETE desired unfinished Action graph after this decision; omit completed Actions. Each Action requires id, name, type, objective, approach, expectedOutcome, capability, candidateVpIds, assignmentReason, dependsOnActionIds, workspaceMode, and may include separateFromActionTypes, changesRequestedActionId, maxAttempts. Dependencies may reference immutable completed stage ids or earlier Actions in this actions array.
38
+ - Every replan must keep exactly one final acceptance gate: normally one deliver Action, or one terminal review when no delivery operation is required. It must be the unique graph sink and transitively depend on all other Actions.
39
+ - Never return destructive cancellation. Tell the user to use the explicit cancel control instead.`;
40
+
41
+ function parseJsonObject(value) {
42
+ const source = String(value || '').trim();
43
+ if (!source) throw new Error('Work Center Coordinator returned an empty response');
44
+ const attempts = [source];
45
+ const fenced = source.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
46
+ if (fenced) attempts.push(fenced.trim());
47
+ const first = source.indexOf('{');
48
+ const last = source.lastIndexOf('}');
49
+ if (first >= 0 && last > first) attempts.push(source.slice(first, last + 1));
50
+ for (const attempt of attempts) {
51
+ try {
52
+ const parsed = JSON.parse(attempt);
53
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
54
+ } catch {}
55
+ }
56
+ throw new Error('Work Center Coordinator did not return valid JSON');
57
+ }
58
+
59
+ function cleanText(value, limit, name) {
60
+ const text = typeof value === 'string' ? value.trim().slice(0, limit) : '';
61
+ if (!text) throw new Error(`Work Center Coordinator ${name} is required`);
62
+ return text;
63
+ }
64
+
65
+ function normalizeGuidance(value, detail) {
66
+ if (!Array.isArray(value) || value.length < 1 || value.length > 8) {
67
+ throw new Error('Work Center Coordinator guidance requires between 1 and 8 targets');
68
+ }
69
+ const activeByStage = new Map((detail.actions || [])
70
+ .filter(action => !['completed', 'superseded', 'cancelled'].includes(action.status))
71
+ .map(action => [action.stageId, action]));
72
+ const seen = new Set();
73
+ return value.map(entry => {
74
+ const stageId = typeof entry?.stageId === 'string' ? entry.stageId.trim() : '';
75
+ if (!stageId || seen.has(stageId) || !activeByStage.has(stageId)) {
76
+ throw new Error(`Work Center Coordinator guidance references an invalid unfinished Action: ${stageId || '(missing)'}`);
77
+ }
78
+ seen.add(stageId);
79
+ return {
80
+ stageId,
81
+ instruction: cleanText(entry?.instruction, COORDINATOR_MAX_INSTRUCTION_CHARS, 'guidance instruction'),
82
+ };
83
+ });
84
+ }
85
+
86
+ export function normalizeCoordinatorResponse(value, detail) {
87
+ const parsed = typeof value === 'string' ? parseJsonObject(value) : value;
88
+ const reply = cleanText(parsed?.reply, COORDINATOR_MAX_REPLY_CHARS, 'reply');
89
+ const source = parsed?.decision && typeof parsed.decision === 'object' && !Array.isArray(parsed.decision)
90
+ ? parsed.decision
91
+ : {};
92
+ const kind = ['answer', 'guide_actions', 'replan'].includes(source.kind) ? source.kind : '';
93
+ if (!kind) throw new Error('Work Center Coordinator decision kind is invalid');
94
+ const reason = cleanText(source.reason, 2_000, 'decision reason');
95
+ if (kind === 'answer') {
96
+ return { reply, decision: { kind, reason, contractPatch: null, guidance: [], actions: [] } };
97
+ }
98
+ if (kind === 'guide_actions') {
99
+ return {
100
+ reply,
101
+ decision: {
102
+ kind,
103
+ reason,
104
+ contractPatch: null,
105
+ guidance: normalizeGuidance(source.guidance, detail),
106
+ actions: [],
107
+ },
108
+ };
109
+ }
110
+ const contractPatch = normalizeContractPatch(source.contractPatch);
111
+ if (!Array.isArray(source.actions) || source.actions.length < 1 || source.actions.length > 8) {
112
+ throw new Error('Work Center Coordinator replan requires the complete unfinished Action graph');
113
+ }
114
+ return {
115
+ reply,
116
+ decision: {
117
+ kind,
118
+ reason,
119
+ contractPatch,
120
+ guidance: [],
121
+ actions: structuredClone(source.actions),
122
+ },
123
+ };
124
+ }
125
+
126
+ function coordinatorHistory(messages) {
127
+ return (Array.isArray(messages) ? messages : [])
128
+ .filter(message => message?.role !== 'assistant' || message.status !== 'thinking')
129
+ .filter(message => typeof message?.text === 'string' && message.text.trim())
130
+ .slice(-20)
131
+ .map(message => ({
132
+ role: message.role === 'assistant' ? 'assistant' : 'user',
133
+ text: message.role === 'legacy_instruction'
134
+ ? `[Legacy global instruction already delivered to executors] ${message.text.slice(0, COORDINATOR_MAX_REPLY_CHARS)}`
135
+ : message.text.slice(0, COORDINATOR_MAX_REPLY_CHARS),
136
+ }));
137
+ }
138
+
139
+ function coordinatorSnapshotText(detail) {
140
+ const snapshot = JSON.stringify(coordinatorSnapshot(detail));
141
+ if (Buffer.byteLength(snapshot, 'utf8') > COORDINATOR_MAX_SNAPSHOT_BYTES) {
142
+ throw new Error('WorkItem is too large for a safe Coordinator turn; compact the Action history first');
143
+ }
144
+ return snapshot;
145
+ }
146
+
147
+ function finalizedCriteria(detail, contractPatch) {
148
+ const criteria = contractPatch?.acceptanceCriteria ?? detail.acceptanceCriteria ?? [];
149
+ if (!Array.isArray(criteria) || criteria.length < 1 || criteria.length > 24) {
150
+ throw new Error('Work Center Coordinator replan requires between 1 and 24 acceptance criteria');
151
+ }
152
+ return criteria;
153
+ }
154
+
155
+ function coordinatorSnapshot(detail) {
156
+ const runs = Array.isArray(detail.runs) ? detail.runs : [];
157
+ const canonicalRunByAction = new Map();
158
+ for (const action of detail.actions || []) {
159
+ const candidates = runs
160
+ .filter(run => run.actionId === action.id && run.status !== 'running')
161
+ .sort((left, right) => Number(right.endedAt || right.startedAt) - Number(left.endedAt || left.startedAt));
162
+ const canonical = action.resultRunId
163
+ ? candidates.find(run => run.id === action.resultRunId)
164
+ : candidates[0];
165
+ if (canonical) canonicalRunByAction.set(action.id, canonical);
166
+ }
167
+ return {
168
+ workItem: {
169
+ id: detail.id,
170
+ revision: detail.revision,
171
+ planRevision: detail.planRevision,
172
+ ledgerRevision: detail.ledgerRevision,
173
+ status: detail.status,
174
+ title: detail.title,
175
+ goal: detail.goal,
176
+ acceptanceCriteria: detail.acceptanceCriteria || [],
177
+ workItemType: detail.workflowSnapshot?.workItemType || null,
178
+ },
179
+ actions: (detail.actions || [])
180
+ .filter(action => !['superseded', 'cancelled'].includes(action.status))
181
+ .map(action => {
182
+ const result = canonicalRunByAction.get(action.id);
183
+ return {
184
+ id: action.id,
185
+ stageId: action.stageId,
186
+ type: action.type,
187
+ status: action.status,
188
+ generation: action.generation,
189
+ dependencies: action.dependsOnStageIds || [],
190
+ workspaceMode: action.workspaceMode,
191
+ brief: action.brief || null,
192
+ result: result ? {
193
+ status: result.status,
194
+ summary: result.summary || '',
195
+ evidence: (result.evidence || []).slice(0, 20),
196
+ waitingReason: result.waitingReason || null,
197
+ error: result.error || null,
198
+ reviewDecision: result.reviewDecision || null,
199
+ } : null,
200
+ };
201
+ }),
202
+ conversation: coordinatorHistory(detail.messages),
203
+ };
204
+ }
205
+
206
+ export class WorkItemCoordinator {
207
+ constructor(options = {}) {
208
+ this.store = options.store;
209
+ this.runtimeProvider = options.runtimeProvider;
210
+ this.policyProvider = typeof options.policyProvider === 'function' ? options.policyProvider : async () => ({});
211
+ this.registry = options.registry;
212
+ this.activeTurns = new Map();
213
+ this.activeTasks = new Map();
214
+ this.shuttingDown = false;
215
+ }
216
+
217
+ message(id, input = {}, options = {}) {
218
+ if (this.shuttingDown) throw new Error('Work Center Coordinator is shutting down');
219
+ const text = cleanText(input.text, COORDINATOR_MAX_REPLY_CHARS, 'message');
220
+ const started = this.store.beginCoordinatorTurn(id, text, {
221
+ revision: Number(input.revision),
222
+ planRevision: Number(input.planRevision),
223
+ ledgerRevision: Number(input.ledgerRevision),
224
+ coordinatorRevision: Number(input.coordinatorRevision),
225
+ });
226
+ if (!started) throw new Error(`WorkItem not found: ${id}`);
227
+ options.onUpdate?.('coordinator.turn_started', started.detail);
228
+
229
+ const abortController = new AbortController();
230
+ this.activeTurns.set(started.turnId, abortController);
231
+ const task = new Promise(resolve => setTimeout(resolve, 0)).then(async () => {
232
+ try {
233
+ const runtime = await this.runtimeProvider();
234
+ if (this.shuttingDown) throw new Error('Work Center Coordinator is shutting down');
235
+ const settings = await this.policyProvider();
236
+ const vps = this.registry?.listVps?.() || [];
237
+ const assignment = selectWorkItemVp({
238
+ policy: { mode: 'pool', candidateVpIds: vps.map(vp => vp.id), capability: 'triage' },
239
+ stageType: 'triage',
240
+ vps,
241
+ priorRuns: started.detail.runs || [],
242
+ });
243
+ const coordinatorPolicy = settings?.coordinatorModelPolicy || {
244
+ ...(settings?.modelPolicy || {}),
245
+ effort: settings?.actionModelPolicies?.triage?.effort || settings?.modelPolicy?.effort || 'high',
246
+ };
247
+ const resolved = resolveWorkItemModel(runtime.config, assignment.vp, coordinatorPolicy);
248
+ const result = await Promise.race([
249
+ runtime.adapter.call({
250
+ model: resolved.model,
251
+ system: COORDINATOR_SYSTEM_PROMPT,
252
+ messages: [{
253
+ role: 'user',
254
+ content: `Current WorkItem snapshot:\n${coordinatorSnapshotText(started.detail)}\n\nLatest user message:\n${text}`,
255
+ }],
256
+ maxTokens: Math.min(
257
+ resolveMaxOutputTokens(resolved.model, runtime.config),
258
+ COORDINATOR_MAX_OUTPUT_TOKENS,
259
+ ),
260
+ effort: resolved.effort,
261
+ effortSource: resolved.source,
262
+ effortContext: { scenario: 'work-center-coordinator' },
263
+ signal: abortController.signal,
264
+ }),
265
+ new Promise((_, reject) => {
266
+ abortController.signal.addEventListener('abort', () => {
267
+ reject(new Error('Work Center Coordinator was interrupted'));
268
+ }, { once: true });
269
+ }),
270
+ ]);
271
+ const normalized = normalizeCoordinatorResponse(result?.text, started.detail);
272
+ if (normalized.decision.kind === 'replan') {
273
+ finalizedCriteria(started.detail, normalized.decision.contractPatch);
274
+ }
275
+ const mutation = normalized.decision.kind === 'replan'
276
+ ? applyCoordinatorReplan({
277
+ workItem: {
278
+ ...started.detail,
279
+ ...(normalized.decision.contractPatch || {}),
280
+ },
281
+ actions: started.detail.actions || [],
282
+ proposal: {
283
+ proposalId: `coordinator:${started.turnId}`,
284
+ basePlanRevision: started.detail.planRevision,
285
+ reason: normalized.decision.reason,
286
+ actions: normalized.decision.actions,
287
+ },
288
+ availableVpIds: vps.map(vp => vp.id),
289
+ })
290
+ : null;
291
+ const detail = this.store.completeCoordinatorTurn(started.turnId, {
292
+ reply: normalized.reply,
293
+ decision: normalized.decision,
294
+ mutation,
295
+ }, started.fence);
296
+ if (!detail) throw new Error('Work Center Coordinator turn is stale or already completed');
297
+ options.onUpdate?.('coordinator.turn_completed', detail);
298
+ return detail;
299
+ } catch (error) {
300
+ const detail = this.store.failCoordinatorTurn(started.turnId, error, started.fence);
301
+ if (detail) {
302
+ options.onUpdate?.('coordinator.turn_failed', detail);
303
+ return detail;
304
+ }
305
+ throw error;
306
+ } finally {
307
+ this.activeTurns.delete(started.turnId);
308
+ this.activeTasks.delete(started.turnId);
309
+ }
310
+ });
311
+ this.activeTasks.set(started.turnId, task);
312
+ return { detail: started.detail, task };
313
+ }
314
+
315
+ async shutdown() {
316
+ this.shuttingDown = true;
317
+ for (const controller of this.activeTurns.values()) controller.abort('work_center_coordinator_shutdown');
318
+ const tasks = [...this.activeTasks.values()];
319
+ if (tasks.length > 0) await Promise.allSettled(tasks);
320
+ this.activeTurns.clear();
321
+ this.activeTasks.clear();
322
+ }
323
+ }
@@ -1,5 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { eventMatchesActionGeneration, runMatchesActionIdentity } from './action-identity.js';
2
+ import {
3
+ currentActionInputEventIds,
4
+ eventMatchesActionGeneration,
5
+ runMatchesActionIdentity,
6
+ } from './action-identity.js';
3
7
  import { normalizeSessionContextSnapshot } from './session-context.js';
4
8
 
5
9
  export const MAINLINE_CONTEXT_HARD_LIMIT_BYTES = 64 * 1024;
@@ -64,32 +68,93 @@ function clamp(value, minimum, maximum) {
64
68
  return Math.min(maximum, Math.max(minimum, value));
65
69
  }
66
70
 
67
- function workItemMessageView(messages) {
68
- return (Array.isArray(messages) ? messages : [])
69
- .filter(message => typeof message?.text === 'string' && message.text)
71
+ function inputEventView(event) {
72
+ return {
73
+ eventId: event.id,
74
+ inputId: event.data?.inputId || null,
75
+ actionId: event.actionId || null,
76
+ text: event.data?.text || '',
77
+ attachments: Array.isArray(event.data?.attachments) ? event.data.attachments : [],
78
+ };
79
+ }
80
+
81
+ function canonicalActionUserContext(events, action) {
82
+ const actionEvents = (Array.isArray(events) ? events : [])
83
+ .filter(event => event?.actionId === action?.id
84
+ && ['action.guidance_added', 'action.input_added'].includes(event.type))
70
85
  .slice()
71
- .sort((left, right) => count(left.createdAt) - count(right.createdAt)
72
- || String(left.id).localeCompare(String(right.id)))
73
- .map(message => ({
74
- messageId: message.id,
75
- text: message.text,
76
- createdAt: count(message.createdAt),
77
- }));
86
+ .sort((left, right) => count(left.id) - count(right.id));
87
+ const inputEvents = actionEvents.filter(event => event.type === 'action.input_added');
88
+ const validInputEventIds = currentActionInputEventIds(events, action);
89
+ const eventByInputId = new Map(inputEvents
90
+ .filter(event => event.data?.inputId)
91
+ .map(event => [event.data.inputId, event]));
92
+ const currentInputEvents = inputEvents.filter(event => validInputEventIds.has(String(event.id)));
93
+ const usedEventIds = new Set();
94
+ const contextEntries = (Array.isArray(action?.context) ? action.context : [])
95
+ .filter(entry => ['input', 'guidance', 'coordinator-guidance'].includes(entry?.type)
96
+ && typeof entry.summary === 'string');
97
+ const values = contextEntries.flatMap((entry, index) => {
98
+ if (entry.type !== 'input') {
99
+ const event = actionEvents.find(candidate => !usedEventIds.has(candidate.id)
100
+ && candidate.type === 'action.guidance_added'
101
+ && (candidate.data?.guidance || '') === entry.summary) || null;
102
+ if (event) usedEventIds.add(event.id);
103
+ return [{
104
+ eventId: event?.id ?? null,
105
+ inputId: null,
106
+ actionId: action.id,
107
+ text: entry.summary,
108
+ attachments: Array.isArray(entry.attachments)
109
+ ? entry.attachments
110
+ : Array.isArray(event?.data?.attachments) ? event.data.attachments : [],
111
+ }];
112
+ }
113
+ let event = entry.inputId ? eventByInputId.get(entry.inputId) : null;
114
+ if (!event && typeof entry.inputId === 'string' && entry.inputId.startsWith('legacy-event:')) {
115
+ const legacyEventId = Number(entry.inputId.slice('legacy-event:'.length));
116
+ event = inputEvents.find(candidate => candidate.id === legacyEventId) || null;
117
+ }
118
+ if (!event && !entry.inputId) {
119
+ event = currentInputEvents.find(candidate => !usedEventIds.has(candidate.id)
120
+ && (candidate.data?.text || '') === entry.summary) || null;
121
+ }
122
+ if (!entry.inputId && !event) return [];
123
+ if (event) usedEventIds.add(event.id);
124
+ return [{
125
+ eventId: event?.id ?? null,
126
+ inputId: entry.inputId || event?.data?.inputId || `legacy-context:${index}`,
127
+ actionId: action.id,
128
+ text: entry.summary,
129
+ attachments: Array.isArray(entry.attachments)
130
+ ? entry.attachments
131
+ : Array.isArray(event?.data?.attachments) ? event.data.attachments : [],
132
+ }];
133
+ });
134
+ return { values, usedEventIds, validInputEventIds };
78
135
  }
79
136
 
80
137
  function guidanceView(events, action) {
81
- return (Array.isArray(events) ? events : [])
138
+ const allEvents = Array.isArray(events) ? events : [];
139
+ const canonicalEntries = canonicalActionUserContext(allEvents, action);
140
+ const currentEvents = allEvents
82
141
  .filter(event => event?.actionId === action?.id
83
- && eventMatchesActionGeneration(event, action)
84
- && ['action.guidance_added', 'action.input_added'].includes(event.type))
142
+ && ((event.type === 'action.input_added'
143
+ && canonicalEntries.validInputEventIds.has(String(event.id)))
144
+ || (event.type === 'action.guidance_added' && eventMatchesActionGeneration(event, action)))
145
+ && !canonicalEntries.usedEventIds.has(event.id))
85
146
  .slice()
86
147
  .sort((left, right) => count(left.id) - count(right.id))
87
- .map(event => ({
88
- eventId: event.id,
89
- actionId: event.actionId || null,
90
- text: event.data?.guidance || event.data?.text || '',
91
- attachments: Array.isArray(event.data?.attachments) ? event.data.attachments : [],
92
- }));
148
+ .map(event => event.type === 'action.input_added'
149
+ ? inputEventView(event)
150
+ : {
151
+ eventId: event.id,
152
+ inputId: null,
153
+ actionId: event.actionId || null,
154
+ text: event.data?.guidance || '',
155
+ attachments: Array.isArray(event.data?.attachments) ? event.data.attachments : [],
156
+ });
157
+ return [...canonicalEntries.values, ...currentEvents];
93
158
  }
94
159
 
95
160
  /**
@@ -259,21 +324,7 @@ export function buildMainlineContextSnapshot(detail, action, budgetInput = {}) {
259
324
  return false;
260
325
  };
261
326
  const sessionContext = normalizeSessionContextSnapshot(detail.sessionContext);
262
- const workItemMessages = workItemMessageView(detail.messages);
263
327
  const guidance = guidanceView(detail.events, action);
264
- const newestFirstMessages = workItemMessages.slice().reverse();
265
- for (const [index, message] of newestFirstMessages.entries()) {
266
- const next = {
267
- ...snapshot.userContext,
268
- workItemMessages: [message, ...snapshot.userContext.workItemMessages],
269
- includedCount: snapshot.userContext.includedCount + 1,
270
- omittedCount: 0,
271
- };
272
- const included = trySet('userContext', next);
273
- if (index === 0 && !included) {
274
- throw mainlineContextBlocked('Latest WorkItem message exceeds the Mainline prompt budget');
275
- }
276
- }
277
328
  const otherUserEntries = [
278
329
  ...guidance.map(value => ({ kind: 'guidance', value })),
279
330
  ...sessionContext.map(value => ({ kind: 'sessionContext', value })),
@@ -287,8 +338,7 @@ export function buildMainlineContextSnapshot(detail, action, budgetInput = {}) {
287
338
  };
288
339
  trySet('userContext', next);
289
340
  }
290
- snapshot.userContext.omittedCount = workItemMessages.length + otherUserEntries.length
291
- - snapshot.userContext.includedCount;
341
+ snapshot.userContext.omittedCount = otherUserEntries.length - snapshot.userContext.includedCount;
292
342
 
293
343
  const siblingEntries = Object.entries(projection.canonicalActionResults)
294
344
  .filter(([actionId]) => actionId !== action.id && !dependencies.some(item => item.actionId === actionId))
@@ -4,6 +4,7 @@ import {
4
4
  canonicalActionId,
5
5
  canonicalExplicitActionId,
6
6
  canonicalExplicitActionIds,
7
+ validateGeneratedCompletionGate,
7
8
  } from './workflow.js';
8
9
 
9
10
  function cleanProposalId(value) {
@@ -174,9 +175,15 @@ export function applyAdditivePlanProposal({ workItem, actions, proposal, availab
174
175
  }),
175
176
  ...canonicalActions,
176
177
  ];
178
+ const orderedActions = stableTopologicalActions(mergedActions);
179
+ validateGeneratedCompletionGate(orderedActions.map(action => ({
180
+ id: action.id,
181
+ type: action.type,
182
+ dependsOnStageIds: action.dependsOnActionIds || [],
183
+ })));
177
184
  const rawPlan = {
178
185
  workItemType: workItem.workflowSnapshot.workItemType,
179
- actions: stableTopologicalActions(mergedActions),
186
+ actions: orderedActions,
180
187
  };
181
188
  const workflowSnapshot = applyGeneratedPlan(synthetic, rawPlan, { availableVpIds });
182
189
  const addedStages = workflowSnapshot.stages.filter(stage => addedIds.has(stage.id));
@@ -189,6 +196,96 @@ export function applyAdditivePlanProposal({ workItem, actions, proposal, availab
189
196
  };
190
197
  }
191
198
 
199
+ export function applyCoordinatorReplan({ workItem, actions, proposal, availableVpIds = null }) {
200
+ if (workItem.workflowSnapshot?.executionMode !== 'graph'
201
+ || workItem.workflowSnapshot?.planningMode !== 'ai') {
202
+ throw new Error('Work Center Coordinator replan requires an AI-planned Action graph');
203
+ }
204
+ if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal)) {
205
+ throw new Error('Work Center Coordinator replan must be an object');
206
+ }
207
+ const proposalId = cleanProposalId(proposal.proposalId);
208
+ const basePlanRevision = Number(proposal.basePlanRevision);
209
+ if (!Number.isInteger(basePlanRevision) || basePlanRevision !== workItem.planRevision) {
210
+ throw new Error('Work Center Coordinator replan has a stale basePlanRevision');
211
+ }
212
+ if (!Array.isArray(proposal.actions) || proposal.actions.length < 1 || proposal.actions.length > 8) {
213
+ throw new Error('Work Center Coordinator replan requires between 1 and 8 unfinished Actions');
214
+ }
215
+
216
+ const active = actions.filter(candidate => !['superseded', 'cancelled'].includes(candidate.status));
217
+ const completed = active.filter(candidate => candidate.status === 'completed');
218
+ const completedStageIds = new Set(completed.map(candidate => candidate.stageId));
219
+ const unfinished = active.filter(candidate => candidate.status !== 'completed');
220
+ const unfinishedByStage = new Map(unfinished.map(candidate => [candidate.stageId, candidate]));
221
+ const historicalStageIds = new Set(actions.map(candidate => candidate.stageId));
222
+ const currentStages = new Map((workItem.workflowSnapshot.stages || []).map(stage => [stage.id, stage]));
223
+ const futureStageIds = new Set();
224
+
225
+ const normalizedFuture = proposal.actions.map(raw => {
226
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
227
+ throw new Error('Work Center Coordinator replan requires full Action specifications');
228
+ }
229
+ const id = canonicalActionId(raw.id);
230
+ if (!id || futureStageIds.has(id) || completedStageIds.has(id)) {
231
+ throw new Error(`Work Center Coordinator Action id is missing, duplicated, or completed: ${id || '(missing)'}`);
232
+ }
233
+ if (historicalStageIds.has(id) && !unfinishedByStage.has(id)) {
234
+ throw new Error(`Work Center Coordinator Action reuses historical stage identity: ${id}`);
235
+ }
236
+ futureStageIds.add(id);
237
+ const dependsOnActionIds = canonicalExplicitActionIds(
238
+ raw.dependsOnActionIds,
239
+ `Coordinator Action "${id}" dependencies`,
240
+ );
241
+ for (const dependencyId of dependsOnActionIds) {
242
+ if (!completedStageIds.has(dependencyId) && !futureStageIds.has(dependencyId)) {
243
+ throw new Error(`Work Center Coordinator Action "${id}" references a missing or future dependency "${dependencyId}"`);
244
+ }
245
+ }
246
+ return {
247
+ ...raw,
248
+ id,
249
+ dependsOnActionIds,
250
+ changesRequestedActionId: Object.hasOwn(raw, 'changesRequestedActionId')
251
+ ? canonicalExplicitActionId(raw.changesRequestedActionId, `Coordinator Action "${id}" review target`)
252
+ : undefined,
253
+ };
254
+ });
255
+
256
+ const completedInputs = completed.filter(candidate => candidate.type !== 'triage').map(candidate => {
257
+ const stage = currentStages.get(candidate.stageId);
258
+ if (!stage) throw new Error(`Work Center completed Action is missing from the frozen workflow: ${candidate.stageId}`);
259
+ return planActionFromStage(stage);
260
+ });
261
+ const synthetic = {
262
+ ...workItem,
263
+ workflowSnapshot: {
264
+ ...workItem.workflowSnapshot,
265
+ actionTemplates: [],
266
+ stages: [workItem.workflowSnapshot.stages[0]],
267
+ },
268
+ };
269
+ const workflowSnapshot = applyGeneratedPlan(synthetic, {
270
+ workItemType: workItem.workflowSnapshot.workItemType,
271
+ actions: [...completedInputs, ...normalizedFuture],
272
+ }, { availableVpIds });
273
+ const stageById = new Map(workflowSnapshot.stages.map(stage => [stage.id, stage]));
274
+
275
+ return {
276
+ proposalId,
277
+ reason: typeof proposal.reason === 'string' ? proposal.reason.trim().slice(0, 4_000) : '',
278
+ basePlanRevision,
279
+ workflowSnapshot,
280
+ unfinished,
281
+ nextActions: normalizedFuture.map(input => {
282
+ const prior = unfinishedByStage.get(input.id) || null;
283
+ const nextAction = actionForStage(stageById.get(input.id), { ...workItem, workflowSnapshot }, []);
284
+ return { prior, nextAction };
285
+ }),
286
+ };
287
+ }
288
+
192
289
  export function applyReplanMutation({ workItem, action, actions, proposal, availableVpIds = null }) {
193
290
  if (workItem.workflowSnapshot?.executionMode !== 'graph'
194
291
  || action?.type !== 'triage'
@@ -283,7 +380,7 @@ export function applyReplanMutation({ workItem, action, actions, proposal, avail
283
380
  }, { availableVpIds });
284
381
  const stageById = new Map(workflowSnapshot.stages.map(stage => [stage.id, stage]));
285
382
  const context = (Array.isArray(action.context) ? action.context : [])
286
- .filter(entry => entry?.type !== 'replan-barrier');
383
+ .filter(entry => entry?.type !== 'replan-barrier' && entry?.type !== 'input');
287
384
  return {
288
385
  proposalId,
289
386
  basePlanRevision,