@yeaft/webchat-agent 1.0.247 → 1.0.248

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.247",
3
+ "version": "1.0.248",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -7,6 +7,29 @@ export function eventMatchesActionGeneration(event, action) {
7
7
  return generation(event.actionGeneration) === generation(action.generation);
8
8
  }
9
9
 
10
+ export function currentActionInputEventIds(events, action) {
11
+ const inputEvents = new Map((Array.isArray(events) ? events : [])
12
+ .filter(event => event?.type === 'action.input_added' && event.actionId === action?.id)
13
+ .map(event => [String(event.id), event]));
14
+ const valid = new Set();
15
+ for (const event of inputEvents.values()) {
16
+ if (eventMatchesActionGeneration(event, action)) valid.add(String(event.id));
17
+ }
18
+ for (const event of Array.isArray(events) ? events : []) {
19
+ if (event?.type !== 'action.input_rebound' || event.actionId !== action?.id
20
+ || !eventMatchesActionGeneration(event, action)) continue;
21
+ const sourceEventIds = [
22
+ event.data?.sourceEventId,
23
+ ...(Array.isArray(event.data?.sourceEventIds) ? event.data.sourceEventIds : []),
24
+ ].filter(value => value != null);
25
+ for (const eventId of sourceEventIds) {
26
+ const key = String(eventId);
27
+ if (inputEvents.has(key)) valid.add(key);
28
+ }
29
+ }
30
+ return valid;
31
+ }
32
+
10
33
  export function runMatchesActionIdentity(run, action) {
11
34
  if (!run || !action) return false;
12
35
  const actionGeneration = generation(action.generation);
@@ -5,6 +5,7 @@ import { defaultRegistry } from '../vp/registry.js';
5
5
  import { scanVpLibrary } from '../vp/vp-store.js';
6
6
  import { WorkCenterService } from './service.js';
7
7
  import { WorkItemRunner } from './runner.js';
8
+ import { WorkItemCoordinator } from './coordinator.js';
8
9
  import { projectWorkCenterEvent, projectWorkItemDetail } from './projection.js';
9
10
  import { previewWorkCenterPlan } from './planner.js';
10
11
  import { readWorkCenterSettings, writeWorkCenterSettings } from './settings.js';
@@ -19,7 +20,7 @@ let shutdownPromise = null;
19
20
  let serviceFactory = null;
20
21
 
21
22
  const BROWSER_DETAIL_OPS = new Set([
22
- 'get', 'create', 'update', 'start', 'cancel', 'work_item_message', 'action_input', 'retry_action', 'guide', 'retry',
23
+ 'get', 'create', 'update', 'start', 'cancel', 'action_input', 'retry_action', 'guide', 'retry',
23
24
  ]);
24
25
  const BROWSER_ACTION_DEBUG_OPS = new Set(['get_action_messages', 'get_action_requests', 'get_action_request']);
25
26
  // `files` is an internal server-to-Agent field. The browser relay rejects any
@@ -28,7 +29,7 @@ const BROWSER_FILE_FIELDS = Object.freeze({
28
29
  create: [
29
30
  'title', 'goal', 'acceptanceCriteria', 'workItemType', 'workDir', 'reuseMemory', 'files', 'start',
30
31
  ],
31
- work_item_message: ['id', 'text', 'revision'],
32
+ work_item_message: ['id', 'text', 'revision', 'planRevision', 'ledgerRevision', 'coordinatorRevision'],
32
33
  action_input: ['id', 'text', 'actionId', 'revision', 'generation', 'files'],
33
34
  retry_action: ['id', 'actionId', 'revision', 'generation'],
34
35
  delete: ['id', 'revision'],
@@ -117,9 +118,22 @@ async function createDefaultService() {
117
118
  registry: defaultRegistry,
118
119
  store: null,
119
120
  });
121
+ const coordinator = new WorkItemCoordinator({
122
+ store: null,
123
+ runtimeProvider: async () => {
124
+ const runtime = await runner.runtimeProvider();
125
+ if (defaultRegistry.vpCount() === 0) {
126
+ for (const vp of scanVpLibrary({ dir: join(yeaftDir, 'virtual-persons') })) defaultRegistry.setVp(vp);
127
+ }
128
+ return runtime;
129
+ },
130
+ policyProvider: async () => readWorkCenterSettings(yeaftDir),
131
+ registry: defaultRegistry,
132
+ });
120
133
  const created = new WorkCenterService({
121
134
  yeaftDir,
122
135
  runner,
136
+ coordinator,
123
137
  runtimeInfoProvider: getSettingsRuntime,
124
138
  listAvailableVpIds: () => defaultRegistry.listVps().map(vp => vp.id),
125
139
  watcherOptions: {
@@ -130,6 +144,7 @@ async function createDefaultService() {
130
144
  },
131
145
  });
132
146
  runner.store = created.store;
147
+ coordinator.store = created.store;
133
148
  return created;
134
149
  }
135
150
 
@@ -44,9 +44,31 @@ export function validateCompletedResult(result, action, workItem) {
44
44
  result.error = 'Completed Action requires one ordered acceptance check with evidence for every acceptance criterion';
45
45
  return;
46
46
  }
47
- const mustVerify = action.type === 'test'
48
- || action.type === 'deliver'
49
- || (action.type === 'review' && result.reviewDecision === 'approved');
47
+ // Intermediate test Actions validate their own task-specific expected result.
48
+ // Requiring them to prove the entire WorkItem contract makes any DAG with
49
+ // later tests or reviews impossible to complete. Global proof belongs at the
50
+ // delivery boundary or an approved review with no unfinished downstream work.
51
+ const remainingStages = Array.isArray(workItem?.workflowSnapshot?.stages)
52
+ ? workItem.workflowSnapshot.stages.filter(stage => stage?.id !== action.stageId)
53
+ : [];
54
+ const downstream = new Set([action.stageId]);
55
+ let expanded = true;
56
+ while (expanded) {
57
+ expanded = false;
58
+ for (const stage of remainingStages) {
59
+ if (downstream.has(stage?.id)) continue;
60
+ if (Array.isArray(stage?.dependsOnStageIds)
61
+ && stage.dependsOnStageIds.some(stageId => downstream.has(stageId))) {
62
+ downstream.add(stage.id);
63
+ expanded = true;
64
+ }
65
+ }
66
+ }
67
+ const hasDownstreamStage = remainingStages.some(stage => downstream.has(stage?.id));
68
+ const hasDeliverStage = remainingStages.some(stage => stage?.type === 'deliver');
69
+ const mustVerify = action.type === 'deliver'
70
+ || (action.type === 'review' && result.reviewDecision === 'approved'
71
+ && !hasDownstreamStage && !hasDeliverStage);
50
72
  if (mustVerify && checks.some(check => check.status !== 'passed')) {
51
73
  result.outcome = 'failed';
52
74
  result.error = `${action.type} Action requires every acceptance check to pass`;
@@ -1,6 +1,9 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import {
2
3
  actionForStage,
3
4
  actionInstruction,
5
+ canonicalActionInstruction,
6
+ withoutActionInputContext,
4
7
  applyGeneratedPlan,
5
8
  getNextStep,
6
9
  initialActionFor,
@@ -76,6 +79,28 @@ function normalizeTerminalResult(result, action) {
76
79
  return normalized;
77
80
  }
78
81
 
82
+ function canonicalReplacementAction(workItem, action, context) {
83
+ const replacement = {
84
+ type: action.type,
85
+ stageId: action.stageId || action.type,
86
+ assignmentPolicy: action.assignmentPolicy,
87
+ modelPolicy: action.modelPolicy,
88
+ requiredRole: action.requiredRole,
89
+ dependsOnStageIds: action.dependsOnStageIds,
90
+ workspaceMode: action.workspaceMode,
91
+ changesRequestedStageId: action.changesRequestedStageId,
92
+ brief: action.brief,
93
+ context,
94
+ maxAttempts: action.maxAttempts || 2,
95
+ };
96
+ replacement.instruction = canonicalActionInstruction(
97
+ workItem,
98
+ { ...action, ...replacement },
99
+ context,
100
+ );
101
+ return replacement;
102
+ }
103
+
79
104
  function contextEntry(action, result, run) {
80
105
  return {
81
106
  type: action.type,
@@ -161,50 +186,22 @@ export class WorkflowController {
161
186
  const current = this.store.getWorkItem(id);
162
187
  const graphMode = current?.workflowSnapshot?.executionMode === 'graph';
163
188
  if (!expected.actionId || !Number.isInteger(expected.revision)
164
- || (graphMode && !Number.isInteger(expected.generation))) {
189
+ || (graphMode && (!Number.isInteger(expected.generation) || expected.generation < 1))) {
165
190
  throw new Error(`actionId, revision${graphMode ? ', and generation' : ''} are required for guidance`);
166
191
  }
167
192
  const detail = this.store.addActionGuidance(id, guidanceSummary, expected, (workItem, previous) => {
168
- const context = [...(previous.context || []), {
193
+ const context = [...withoutActionInputContext(previous.context), {
169
194
  type: 'guidance',
170
195
  role: 'user',
171
196
  summary: guidanceSummary,
172
197
  evidence: [],
173
198
  }];
174
- const step = {
175
- type: previous.type,
176
- stageId: previous.stageId || previous.type,
177
- assignmentPolicy: previous.assignmentPolicy,
178
- modelPolicy: previous.modelPolicy,
179
- requiredRole: previous.requiredRole,
180
- dependsOnStageIds: previous.dependsOnStageIds,
181
- workspaceMode: previous.workspaceMode,
182
- changesRequestedStageId: previous.changesRequestedStageId,
183
- brief: previous.brief,
184
- };
185
- return {
186
- ...step,
187
- context,
188
- instruction: actionInstruction(step, workItem, context, renderSessionContextSnapshot(workItem.sessionContext)),
189
- maxAttempts: previous.maxAttempts || 2,
190
- };
199
+ return canonicalReplacementAction(workItem, previous, context);
191
200
  }, input.attachments, input.addedAttachments);
192
201
  if (!detail) throw new Error(`WorkItem not found: ${id}`);
193
202
  return detail;
194
203
  }
195
204
 
196
- message(id, input = {}) {
197
- const text = typeof input.text === 'string' ? input.text.trim().slice(0, 8_000) : '';
198
- if (!text) throw new Error('WorkItem message is required');
199
- const revision = Number(input.revision);
200
- if (!Number.isInteger(revision)) throw new Error('revision is required for WorkItem messages');
201
- const detail = this.store.addWorkItemMessage(id, text, revision, (workItem, action) => (
202
- actionInstruction(action, workItem, action.context || [], renderSessionContextSnapshot(workItem.sessionContext))
203
- ));
204
- if (!detail) throw new Error(`WorkItem not found: ${id}`);
205
- return detail;
206
- }
207
-
208
205
  input(id, input = {}) {
209
206
  const text = typeof input.text === 'string' ? input.text.trim().slice(0, 8_000) : '';
210
207
  const addedAttachmentCount = Math.max(0, Number(input.addedAttachmentCount) || 0);
@@ -212,10 +209,14 @@ export class WorkflowController {
212
209
  const workItem = this.store.getWorkItem(id);
213
210
  if (!workItem) throw new Error(`WorkItem not found: ${id}`);
214
211
  const targetAction = this.store.getAction(input.actionId);
212
+ const expectedGeneration = Number(input.generation);
213
+ if (!Number.isInteger(expectedGeneration) || expectedGeneration < 1) {
214
+ throw new Error('actionId, revision, and generation are required for Action input');
215
+ }
215
216
  const graphMode = workItem.workflowSnapshot?.executionMode === 'graph';
216
- const targetMatches = graphMode
217
- ? targetAction?.workItemId === id && targetAction.generation === input.generation
218
- : workItem.currentActionId === input.actionId;
217
+ const targetMatches = targetAction?.workItemId === id
218
+ && targetAction.generation === expectedGeneration
219
+ && (graphMode || workItem.currentActionId === input.actionId);
219
220
  if (!targetMatches || workItem.revision !== input.revision) {
220
221
  throw new Error('Action changed before input was applied; refresh and try again');
221
222
  }
@@ -226,27 +227,8 @@ export class WorkflowController {
226
227
  const inputSummary = text || `The user added ${addedAttachmentCount} attachment(s) as additional context for this Action.`;
227
228
  return this.store.addActionInput(id, inputSummary, {
228
229
  actionId: input.actionId,
229
- generation: input.generation,
230
+ generation: expectedGeneration,
230
231
  revision: input.revision,
231
- }, (current, currentAction) => {
232
- const context = [...(currentAction.context || []), {
233
- type: 'input', role: 'user', summary: inputSummary, evidence: [],
234
- }];
235
- const step = {
236
- type: currentAction.type,
237
- stageId: currentAction.stageId || currentAction.type,
238
- assignmentPolicy: currentAction.assignmentPolicy,
239
- modelPolicy: currentAction.modelPolicy,
240
- requiredRole: currentAction.requiredRole,
241
- dependsOnStageIds: currentAction.dependsOnStageIds,
242
- workspaceMode: currentAction.workspaceMode,
243
- changesRequestedStageId: currentAction.changesRequestedStageId,
244
- brief: currentAction.brief,
245
- };
246
- return {
247
- context,
248
- instruction: actionInstruction(step, current, context, renderSessionContextSnapshot(current.sessionContext)),
249
- };
250
232
  }, input.attachments, input.addedAttachments);
251
233
  }
252
234
  if (!['waiting', 'failed'].includes(targetAction.status)) {
@@ -258,6 +240,7 @@ export class WorkflowController {
258
240
  expected: { actionId: input.actionId, generation: input.generation, revision: input.revision },
259
241
  attachments: input.attachments,
260
242
  inputEvent: {
243
+ inputId: randomUUID(),
261
244
  text: text || `The user added ${addedAttachmentCount} attachment(s) as additional context for this Action.`,
262
245
  attachments: input.addedAttachments,
263
246
  },
@@ -271,20 +254,7 @@ export class WorkflowController {
271
254
  if (previous?.status === 'waiting' && !answer && addedAttachmentCount === 0) {
272
255
  throw new Error('answer or attachments are required to resume a waiting Action');
273
256
  }
274
- const step = previous
275
- ? {
276
- type: previous.type,
277
- stageId: previous.stageId || previous.type,
278
- assignmentPolicy: previous.assignmentPolicy,
279
- modelPolicy: previous.modelPolicy,
280
- requiredRole: previous.requiredRole,
281
- dependsOnStageIds: previous.dependsOnStageIds,
282
- workspaceMode: previous.workspaceMode,
283
- changesRequestedStageId: previous.changesRequestedStageId,
284
- brief: previous.brief,
285
- }
286
- : initialActionFor(workItem);
287
- const context = Array.isArray(previous?.context) ? [...previous.context] : [];
257
+ const context = withoutActionInputContext(previous?.context);
288
258
  if (previousRun) {
289
259
  context.push({
290
260
  type: previous.type,
@@ -299,12 +269,19 @@ export class WorkflowController {
299
269
  : null),
300
270
  });
301
271
  }
302
- return {
303
- ...step,
304
- context,
305
- instruction: actionInstruction(step, workItem, context, renderSessionContextSnapshot(workItem.sessionContext)),
306
- maxAttempts: previous?.maxAttempts || 2,
307
- };
272
+ if (Number(workItem.executionSchemaVersion) === 2 && input.inputEvent?.inputId) {
273
+ context.push({
274
+ type: 'input',
275
+ role: 'user',
276
+ inputId: input.inputEvent.inputId,
277
+ summary: input.inputEvent.text || '',
278
+ attachments: Array.isArray(input.inputEvent.attachments) ? input.inputEvent.attachments : [],
279
+ evidence: [],
280
+ });
281
+ }
282
+ return previous
283
+ ? canonicalReplacementAction(workItem, previous, context)
284
+ : { ...initialActionFor(workItem), context };
308
285
  }, {
309
286
  expected: input.expected || null,
310
287
  attachments: input.attachments,
@@ -483,7 +460,7 @@ export class WorkflowController {
483
460
  : planProposal
484
461
  ? { ...effectiveWorkItem, workflowSnapshot: planProposal.workflowSnapshot }
485
462
  : effectiveWorkItem;
486
- const context = [...(action.context || []), contextEntry(action, result, activeRun)];
463
+ const context = [...withoutActionInputContext(action.context), contextEntry(action, result, activeRun)];
487
464
  if (plannedWorkItem.workflowSnapshot?.executionMode === 'graph') {
488
465
  if (staleReplanMutation) {
489
466
  return {
@@ -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
+ }