@yeaft/webchat-agent 1.0.166 → 1.0.168

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