@yeaft/webchat-agent 1.0.411 → 1.0.413

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.411",
3
+ "version": "1.0.413",
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",
package/yeaft/engine.js CHANGED
@@ -22,7 +22,7 @@ import { promises as fsp } from 'fs';
22
22
  import { join, resolve as resolvePath } from 'path';
23
23
  import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
24
24
  import { getRuntimePlatformInfo } from './runtime-platform.js';
25
- import { LLMContextError, LLMAbortError, LLMAuthError, LLMRateLimitError, LLMServerError, LLMStreamIdleTimeoutError } from './llm/adapter.js';
25
+ import { LLMContextError, LLMAbortError, LLMAuthError, LLMPolicyError, LLMRateLimitError, LLMServerError, LLMStreamIdleTimeoutError } from './llm/adapter.js';
26
26
  import { runMemoryPreflow, buildRelevantScopes, memoryScopeLabel } from './sessions/pre-flow.js';
27
27
  import {
28
28
  readProjectDoc,
@@ -155,6 +155,8 @@ const RETRY_DEFAULTS = Object.freeze({
155
155
 
156
156
  const RETRY_CONTINUATION_PROMPT =
157
157
  'Continue from the exact point where the previous response stopped. Do not repeat text already produced.';
158
+ const POLICY_RECOVERY_PROMPT =
159
+ 'Continue this authorized code review, but describe security findings abstractly. Do not repeat credential-like or exploit payloads, secrets, tokens, or step-by-step misuse instructions. Preserve the technical conclusion, evidence location, severity, and remediation.';
158
160
 
159
161
  // Accept legacy namespaced commands and Claude Code-style bare skill commands.
160
162
  // Project-tier skills are shown as /<skill-name>; /yeaft-skills:<name> and
@@ -2791,6 +2793,7 @@ export class Engine {
2791
2793
  let retryPolicy = resolveRetryPolicy(this.#config);
2792
2794
  let consecutiveRetryableErrors = 0;
2793
2795
  let consecutiveForbiddenErrors = 0;
2796
+ let contentPolicyRecoveryAttempts = 0;
2794
2797
 
2795
2798
  while (true) {
2796
2799
  turnNumber++;
@@ -3442,17 +3445,42 @@ export class Engine {
3442
3445
  errorName: err?.name || null,
3443
3446
  statusCode: err?.statusCode ?? null,
3444
3447
  retryable: err instanceof LLMRateLimitError || err instanceof LLMServerError,
3448
+ reasonCode: err?.reasonCode || null,
3445
3449
  message: String(err?.message || '').slice(0, 200),
3446
3450
  },
3447
3451
  });
3448
3452
 
3449
3453
  const earlyIsRateLimit = err instanceof LLMRateLimitError;
3450
3454
  const earlyIsTransient = err instanceof LLMServerError;
3455
+ const earlyIsContentPolicy = err instanceof LLMPolicyError;
3451
3456
  // A completed tool_call has already crossed the streaming boundary to
3452
3457
  // the caller. Replaying that request would publish a duplicate call and
3453
3458
  // leave ambiguous execution ownership, so only pre-tool failures are
3454
3459
  // eligible for transparent retry or model fallback.
3455
3460
  const canReplayProviderRequest = toolCalls.length === 0;
3461
+ if (earlyIsContentPolicy && canReplayProviderRequest && contentPolicyRecoveryAttempts === 0) {
3462
+ contentPolicyRecoveryAttempts = 1;
3463
+ endAttemptTrace('llm_retry');
3464
+ if (responseText) prepareRetryContinuation();
3465
+ retryLifecycle.pendingContinuation = {
3466
+ role: 'user',
3467
+ content: POLICY_RECOVERY_PROMPT,
3468
+ userAuthored: false,
3469
+ };
3470
+ yield {
3471
+ type: 'llm_retry',
3472
+ attempt: 1,
3473
+ maxRetries: 1,
3474
+ delayMs: 0,
3475
+ reason: 'content_policy_recovery',
3476
+ recoveryMode: 'continue',
3477
+ errorName: err.name,
3478
+ statusCode: err.statusCode ?? 422,
3479
+ message: 'Provider content-safety rejection; retrying once with sensitive examples abstracted.',
3480
+ };
3481
+ yield { type: 'turn_end', turnNumber, stopReason: 'llm_retry', threadId };
3482
+ continue;
3483
+ }
3456
3484
  const earlyIsTemporaryForbidden = err instanceof LLMAuthError
3457
3485
  && err.statusCode === 403
3458
3486
  && err.temporary === true;
@@ -3640,9 +3668,25 @@ export class Engine {
3640
3668
  && consecutiveRetryableErrors >= retryPolicy.maxRetries;
3641
3669
  errorEvent.retryAttempts = consecutiveRetryableErrors;
3642
3670
  errorEvent.maxRetries = retryPolicy.maxRetries;
3671
+ } else if (err instanceof LLMPolicyError) {
3672
+ errorEvent.reason = 'content_policy_denied';
3673
+ errorEvent.retryExhausted = contentPolicyRecoveryAttempts >= 1;
3674
+ errorEvent.retryAttempts = contentPolicyRecoveryAttempts;
3675
+ errorEvent.maxRetries = 1;
3643
3676
  }
3644
3677
  yield errorEvent;
3645
- yield { type: 'turn_end', turnNumber, stopReason: 'error', threadId, terminal: true };
3678
+ yield {
3679
+ type: 'turn_end',
3680
+ turnNumber,
3681
+ stopReason: 'error',
3682
+ threadId,
3683
+ terminal: true,
3684
+ detail: {
3685
+ errorName: err?.name || 'Error',
3686
+ statusCode: err?.statusCode ?? null,
3687
+ reason: errorEvent.reason || err?.reasonCode || null,
3688
+ },
3689
+ };
3646
3690
  break;
3647
3691
  }
3648
3692
 
@@ -140,6 +140,29 @@ export function classifyAuthError(statusCode, responseBody = '', details = {}) {
140
140
  });
141
141
  }
142
142
 
143
+ /** Provider content-safety rejection (commonly 422) — eligible for one sanitized recovery. */
144
+ export class LLMPolicyError extends Error {
145
+ constructor(_providerMessage, statusCode = 422, details = {}) {
146
+ super('The LLM provider blocked this request under its content-safety policy. Continue and avoid repeating sensitive payloads or credential-like examples.');
147
+ this.name = 'LLMPolicyError';
148
+ this.statusCode = statusCode;
149
+ this.reasonCode = 'content_policy_denied';
150
+ this.provider = details.provider || null;
151
+ this.model = details.model || null;
152
+ }
153
+ }
154
+
155
+ const CONTENT_POLICY_RE = /(?:content (?:was )?flagged|content[-_ ]?safety|cybersecurity risk|safety policy|safety system|content[_ -]?filter|policy[_ -]?violation)/i;
156
+
157
+ export function classifyPolicyError(statusCode, responseBody = '', details = {}) {
158
+ const status = Number(statusCode) || 0;
159
+ const signals = providerErrorSignals(responseBody);
160
+ if (status !== 422 || (!CONTENT_POLICY_RE.test(signals.code) && !CONTENT_POLICY_RE.test(signals.message))) {
161
+ return null;
162
+ }
163
+ return new LLMPolicyError(signals.message, status, details);
164
+ }
165
+
143
166
  /** Context too long error (413 or API-specific) — need compaction. */
144
167
  export class LLMContextError extends Error {
145
168
  constructor(message) {
@@ -13,6 +13,7 @@ import {
13
13
  retryAfterFromResponse,
14
14
  LLMAuthError,
15
15
  classifyAuthError,
16
+ classifyPolicyError,
16
17
  LLMContextError,
17
18
  LLMServerError,
18
19
  LLMAbortError,
@@ -230,6 +231,8 @@ export class AnthropicAdapter extends LLMAdapter {
230
231
  const retryAfter = retryAfterFromResponse(response);
231
232
  return new LLMRateLimitError(`Anthropic overloaded (${authHint}): ${body}`, status, retryAfter);
232
233
  }
234
+ const policyError = classifyPolicyError(status, body);
235
+ if (policyError) return policyError;
233
236
  if (body.includes('prompt is too long') || body.includes('max_tokens')) {
234
237
  return new LLMContextError(`Anthropic context error (${authHint}): ${body}`);
235
238
  }
@@ -30,6 +30,7 @@ import {
30
30
  LLMRateLimitError,
31
31
  LLMAuthError,
32
32
  classifyAuthError,
33
+ classifyPolicyError,
33
34
  LLMContextError,
34
35
  LLMServerError,
35
36
  LLMAbortError,
@@ -219,6 +220,8 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
219
220
  const retryAfter = retryAfterFromResponse(response);
220
221
  return new LLMRateLimitError(`Overloaded: ${body}`, status, retryAfter);
221
222
  }
223
+ const policyError = classifyPolicyError(status, body);
224
+ if (policyError) return policyError;
222
225
  if (status === 413 || body.includes('context_length_exceeded') || body.includes('maximum context length')) {
223
226
  return new LLMContextError(`Context too long: ${body}`);
224
227
  }
@@ -74,7 +74,7 @@ Use this when work must continue beyond the current turn, needs role handoffs, r
74
74
  // a different dispatch policy into it.
75
75
  origin: {
76
76
  sessionId,
77
- messageId: ctx.inboundEnvelope?.msgId || null,
77
+ messageId: ctx.inboundEnvelope?.msg?.id || null,
78
78
  createdBy: ctx.currentVpId || 'assistant',
79
79
  },
80
80
  linkedSessionIds: [sessionId],
@@ -4464,9 +4464,13 @@ function handleEngineEvent(event, hctx) {
4464
4464
  const errMsg = event.error?.message || 'Unknown error';
4465
4465
  const retryAttempts = Number.isFinite(event.retryAttempts) ? event.retryAttempts : 0;
4466
4466
  const exhaustedIdle = event.reason === 'stream_idle_timeout' && event.retryExhausted;
4467
- const visibleErrMsg = exhaustedIdle && retryAttempts > 0
4468
- ? `${errMsg} after ${retryAttempts} fresh request retries`
4469
- : errMsg;
4467
+ const contentPolicyDenied = event.reason === 'content_policy_denied'
4468
+ || event.error?.reasonCode === 'content_policy_denied';
4469
+ const visibleErrMsg = contentPolicyDenied
4470
+ ? 'Provider blocked this request for content-safety reasons after one safe recovery attempt. Continue and ask the VP to avoid repeating sensitive payloads, credentials, tokens, or exploit samples.'
4471
+ : exhaustedIdle && retryAttempts > 0
4472
+ ? `${errMsg} after ${retryAttempts} fresh request retries`
4473
+ : errMsg;
4470
4474
  hctx.lastEngineErrorDetail = {
4471
4475
  message: visibleErrMsg,
4472
4476
  ...(event.reason ? { reason: event.reason } : {}),
@@ -27,7 +27,8 @@ const BROWSER_ACTION_DEBUG_OPS = new Set(['get_action_messages', 'get_action_req
27
27
  // client-supplied value and only emits files resolved from owned upload ids.
28
28
  const BROWSER_FILE_FIELDS = Object.freeze({
29
29
  create: [
30
- 'title', 'goal', 'acceptanceCriteria', 'workItemType', 'workDir', 'reuseMemory', 'files', 'start',
30
+ 'title', 'goal', 'acceptanceCriteria', 'workItemType', 'workDir', 'deliveryTarget',
31
+ 'reuseMemory', 'files', 'start',
31
32
  ],
32
33
  post_work_item_message: [
33
34
  'id', 'clientMessageId', 'text', 'target', 'revision', 'planRevision', 'ledgerRevision',
@@ -225,7 +226,7 @@ export async function handleWorkCenterRequest(msg) {
225
226
  const payload = Object.hasOwn(BROWSER_FILE_FIELDS, op)
226
227
  ? browserFilePayload(op, msg.payload)
227
228
  : (BROWSER_ACTION_DEBUG_OPS.has(op) ? browserFilePayload(op, msg.payload) : (msg.payload || {}));
228
- data = await workCenter.handle(op, payload);
229
+ data = await workCenter.handle(op, payload, { userOriginated: true });
229
230
  }
230
231
  if (BROWSER_DETAIL_OPS.has(op) && data?.accepted !== true) {
231
232
  data = workCenter.projectBrowserDetail(data);
@@ -13,6 +13,12 @@ export function normalizeContractPatch(value) {
13
13
  if (!criteria) throw new Error('contractPatch.acceptanceCriteria must be an array');
14
14
  patch.acceptanceCriteria = criteria;
15
15
  }
16
+ if (Object.hasOwn(value, 'deliveryTarget')) {
17
+ if (!['workspace_files', 'pull_request', 'merge'].includes(value.deliveryTarget)) {
18
+ throw new Error('contractPatch.deliveryTarget must be workspace_files, pull_request, or merge');
19
+ }
20
+ patch.deliveryTarget = value.deliveryTarget;
21
+ }
16
22
  return Object.keys(patch).length > 0 ? patch : null;
17
23
  }
18
24
 
@@ -11,7 +11,7 @@ import {
11
11
  } from './workflow.js';
12
12
  import { renderSessionContextSnapshot } from './session-context.js';
13
13
  import { normalizeSessionMessageQuote } from '../session-message-quote.js';
14
- import { normalizeEvidence } from './evidence.js';
14
+ import { normalizeEvidence, normalizeOutputs } from './evidence.js';
15
15
  import { isDynamicWorkItem } from './execution-mode.js';
16
16
  import { applyAdditivePlanProposal, applyReplanMutation } from './plan-mutation.js';
17
17
  import { normalizeContractPatch, validateCompletedResult } from './completion-contract.js';
@@ -25,6 +25,7 @@ function normalizeTerminalResult(result, action) {
25
25
  response: String(result.response || ''),
26
26
  summary: String(result.summary || ''),
27
27
  evidence: normalizeEvidence(result.evidence),
28
+ outputs: normalizeOutputs(result.outputs),
28
29
  waitingReason: result.waitingReason ? String(result.waitingReason) : null,
29
30
  error: result.error ? String(result.error) : null,
30
31
  failureKind: result.failureKind === 'system_blocked' ? 'system_blocked' : null,
@@ -9,7 +9,11 @@ import {
9
9
  } from '../llm/adapter.js';
10
10
  import { resolveWorkItemModel, selectWorkItemVp } from './assignment.js';
11
11
  import { normalizeContractPatch } from './completion-contract.js';
12
- import { prepareDynamicActionMutation } from './dynamic-coordination.js';
12
+ import { normalizeOutputs } from './evidence.js';
13
+ import {
14
+ normalizeDynamicActionClosures,
15
+ prepareDynamicActionMutation,
16
+ } from './dynamic-coordination.js';
13
17
  import { isDynamicWorkItem } from './execution-mode.js';
14
18
  import { applyCoordinatorReplan } from './plan-mutation.js';
15
19
  import { buildWorkItemAttachmentContext } from './attachments.js';
@@ -127,6 +131,7 @@ function coordinatorStageReferences(detail) {
127
131
  }
128
132
 
129
133
  function boundedAction(action, result, stageReferences, compact = false, dynamic = false) {
134
+ const preserveCanonicalResult = action?.status === 'completed';
130
135
  const brief = action?.brief && typeof action.brief === 'object' ? action.brief : null;
131
136
  return {
132
137
  ...(dynamic
@@ -153,18 +158,21 @@ function boundedAction(action, result, stageReferences, compact = false, dynamic
153
158
  expectedOutcome: truncateUtf8(brief.expectedOutcome, 256),
154
159
  },
155
160
  } : {}),
161
+ ...(action?.closeReason ? { closeReason: truncateUtf8(action.closeReason, 1_000) } : {}),
156
162
  result: result ? {
163
+ runId: truncateUtf8(result.id, 256),
157
164
  status: truncateUtf8(result.status, 64),
158
165
  summary: truncateUtf8(result.summary, compact ? 256 : 768),
159
- ...(!compact ? {
160
- evidence: boundedEvidence(result.evidence),
161
- acceptanceChecks: (Array.isArray(result.acceptanceChecks) ? result.acceptanceChecks : [])
166
+ evidence: boundedEvidence(result.evidence),
167
+ outputs: boundedEvidence(normalizeOutputs(result.outputs)),
168
+ acceptanceChecks: preserveCanonicalResult
169
+ ? (Array.isArray(result.acceptanceChecks) ? result.acceptanceChecks : [])
162
170
  .slice(0, 24).map(check => ({
163
171
  criterion: truncateUtf8(check?.criterion, 512),
164
172
  status: truncateUtf8(check?.status, 64),
165
173
  evidence: truncateUtf8(check?.evidence, 1_000),
166
- })),
167
- } : {}),
174
+ }))
175
+ : [],
168
176
  waitingReason: truncateUtf8(result.waitingReason, 384) || null,
169
177
  error: truncateUtf8(result.error, 384) || null,
170
178
  reviewDecision: truncateUtf8(result.reviewDecision, 64) || null,
@@ -219,6 +227,7 @@ Return exactly one JSON object and no surrounding prose:
219
227
  "question": null,
220
228
  "workItemType": null,
221
229
  "contractPatch": null,
230
+ "closeActions": [],
222
231
  "supersedeActionIds": [],
223
232
  "guidance": [],
224
233
  "actions": [],
@@ -229,10 +238,12 @@ Return exactly one JSON object and no surrounding prose:
229
238
  Rules:
230
239
  - answer: explain state only. Never use it for an automatic advance trigger.
231
240
  - create_actions: create 1..8 currently runnable Actions. Every Action needs type, objective, approach, expectedOutcome, capability, candidateVpIds, assignmentReason, sourceActionIds, workspaceMode, and optional maxAttempts/separateFromActionTypes. sourceActionIds are context/audit references, never scheduling dependencies. Do not include dependsOnActionIds, dependsOnStageIds, stages, or a graph.
241
+ - If no existing VP can execute a required capability, create one create_vp Action assigned to the existing VP best suited to author that specialist. After it completes, create the original Action with the new VP id. Never fail or retry the original Action merely because its capability label has no match.
242
+ - closeActions may accompany create_actions. Each entry is {"actionId":"failed or waiting durable Action id","reason":"why it is no longer required"}. Close only work made obsolete by replacement evidence or a clarified contract. Closed Actions remain audit history, are never acceptance evidence, and do not block completion.
232
243
  - guide_actions: target 1..8 unfinished non-running Actions by durable actionId.
233
- - request_human: only when external information or a user decision is genuinely required.
234
- - complete: only when every acceptance criterion has canonical completed Run evidence and there are no unfinished Actions. Include summary, ordered acceptanceResults with evidenceRunIds, evidenceRunIds, and residualRisks.
235
- - Preserve completed Action history. Never claim tests, review, merge, release, or external effects without canonical Run evidence.
244
+ - request_human: use when external information or a user decision is genuinely required. Before creating mutating or delivery Actions, ask whether the delivery boundary is files only, PR, or merge when the contract does not already say. After the user answers, persist it with contractPatch.deliveryTarget = workspace_files | pull_request | merge before creating more Actions.
245
+ - complete: only when every acceptance criterion has canonical completed Run evidence and there are no unfinished Actions after applying optional closeActions. Include summary, ordered acceptanceResults with evidenceRunIds, evidenceRunIds, and residualRisks. Reuse structured outputs already present on canonical Runs; do not create repetitive evidence-packaging Actions.
246
+ - Preserve completed and closed Action history. Never claim tests, review, merge, release, or external effects without canonical Run evidence.
236
247
  - Action templates are reusable capabilities, not a prescribed workflow. Create the smallest useful Action boundary, not tool-call-sized work.
237
248
  - Never return destructive cancellation. The user owns the explicit cancel control.`;
238
249
 
@@ -270,6 +281,13 @@ function cleanText(value, limit, name) {
270
281
  return text;
271
282
  }
272
283
 
284
+ function requiresDeliveryBoundaryDecision(detail, actions) {
285
+ const requested = Array.isArray(actions) ? actions : [];
286
+ return !detail?.deliveryTarget && requested.some(action => (
287
+ action?.type === 'create_vp' || action?.workspaceMode !== 'read'
288
+ ));
289
+ }
290
+
273
291
  function permanentCoordinatorDiagnostic(cause, phase, language) {
274
292
  const zh = coordinatorLanguage(language) === 'zh';
275
293
  if (cause instanceof LLMAuthError) {
@@ -378,7 +396,7 @@ function normalizeGuidance(value, detail) {
378
396
  }
379
397
  const dynamic = isDynamicWorkItem(detail);
380
398
  const active = (detail.actions || [])
381
- .filter(action => !['completed', 'superseded', 'cancelled'].includes(action.status));
399
+ .filter(action => !['completed', 'closed', 'superseded', 'cancelled'].includes(action.status));
382
400
  const activeByReference = new Map(active.map(action => [dynamic ? action.id : action.stageId, action]));
383
401
  const stageReferences = coordinatorStageReferences(detail);
384
402
  const seen = new Set();
@@ -456,13 +474,17 @@ export function normalizeCoordinatorResponse(value, detail, options = {}) {
456
474
  };
457
475
  }
458
476
  if (kind === 'request_human') {
477
+ if (options.automatic === true && source.contractPatch?.deliveryTarget) {
478
+ throw new Error('Automatic Work Center Coordinator delivery target changes are forbidden');
479
+ }
480
+ const contractPatch = dynamic ? normalizeContractPatch(source.contractPatch) : null;
459
481
  return {
460
482
  reply,
461
483
  decision: {
462
484
  kind,
463
485
  reason,
464
486
  question: cleanText(source.question, COORDINATOR_MAX_REPLY_CHARS, 'human question'),
465
- contractPatch: null,
487
+ contractPatch: dynamic && options.automatic !== true ? contractPatch : null,
466
488
  guidance: [],
467
489
  actions: [],
468
490
  },
@@ -474,6 +496,7 @@ export function normalizeCoordinatorResponse(value, detail, options = {}) {
474
496
  decision: {
475
497
  kind,
476
498
  reason,
499
+ closeActions: normalizeDynamicActionClosures(source.closeActions, detail.actions || []),
477
500
  completion: source.completion,
478
501
  contractPatch: null,
479
502
  guidance: [],
@@ -482,12 +505,19 @@ export function normalizeCoordinatorResponse(value, detail, options = {}) {
482
505
  };
483
506
  }
484
507
  if (dynamic && kind === 'create_actions') {
508
+ if (options.automatic === true && source.contractPatch?.deliveryTarget) {
509
+ throw new Error('Automatic Work Center Coordinator delivery target changes are forbidden');
510
+ }
485
511
  const contractPatch = normalizeContractPatch(source.contractPatch);
512
+ if (requiresDeliveryBoundaryDecision(detail, source.actions)) {
513
+ throw new Error('Work Center delivery target is unconfirmed; the delivery boundary requires request_human before creating mutating or delivery Actions');
514
+ }
486
515
  const decision = {
487
516
  kind,
488
517
  reason,
489
518
  workItemType: source.workItemType,
490
519
  contractPatch,
520
+ closeActions: source.closeActions,
491
521
  supersedeActionIds: source.supersedeActionIds,
492
522
  guidance: [],
493
523
  actions: source.actions,
@@ -581,6 +611,7 @@ function coordinatorSnapshot(detail) {
581
611
  status: truncateUtf8(detail.status, 64),
582
612
  title: truncateUtf8(detail.title, 1 * 1024),
583
613
  goal: truncateUtf8(detail.goal, 4 * 1024),
614
+ deliveryTarget: detail.deliveryTarget || null,
584
615
  acceptanceCriteria,
585
616
  workItemType: truncateUtf8(detail.workflowSnapshot?.workItemType, 256) || null,
586
617
  };
@@ -592,8 +623,8 @@ function coordinatorSnapshot(detail) {
592
623
  const currentActions = (Array.isArray(detail.actions) ? detail.actions : [])
593
624
  .filter(action => !['superseded', 'cancelled'].includes(action.status));
594
625
  const stageReferences = coordinatorStageReferences(detail);
595
- const unfinished = currentActions.filter(action => action.status !== 'completed');
596
- const completed = currentActions.filter(action => action.status === 'completed');
626
+ const unfinished = currentActions.filter(action => !['completed', 'closed'].includes(action.status));
627
+ const completed = currentActions.filter(action => ['completed', 'closed'].includes(action.status));
597
628
  const selected = [
598
629
  ...unfinished,
599
630
  ...completed.slice(-Math.max(0, COORDINATOR_MAX_ACTIONS - unfinished.length)),
@@ -627,7 +658,7 @@ function coordinatorSnapshot(detail) {
627
658
  return {
628
659
  workItem,
629
660
  actions,
630
- omittedCompletedActionCount: Math.max(0, completed.length - actions.filter(action => action.status === 'completed').length),
661
+ omittedCompletedActionCount: Math.max(0, completed.length - actions.filter(action => ['completed', 'closed'].includes(action.status)).length),
631
662
  conversation: coordinatorHistory(detail.messages),
632
663
  };
633
664
  }
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
 
3
- export const WORK_CENTER_SCHEMA_VERSION = 37;
3
+ export const WORK_CENTER_SCHEMA_VERSION = 39;
4
4
 
5
5
  const MIGRATIONS = [
6
6
  ['23-conversation-stream', migrateConversationStream],
@@ -18,6 +18,8 @@ const MIGRATIONS = [
18
18
  ['35-coordinator-provider-claims', migrateCoordinatorProviderClaims],
19
19
  ['36-dynamic-coordination', migrateDynamicCoordination],
20
20
  ['37-run-acceptance-checks', migrateRunAcceptanceChecks],
21
+ ['38-action-closure-and-outputs', migrateActionClosureAndOutputs],
22
+ ['39-action-creation-source', migrateActionCreationSource],
21
23
  ];
22
24
 
23
25
  const MIGRATION_ALIASES = new Map([
@@ -528,6 +530,48 @@ function migrateRunAcceptanceChecks(db) {
528
530
  `);
529
531
  }
530
532
 
533
+ function migrateActionClosureAndOutputs(db) {
534
+ if (!hasColumn(db, 'work_items', 'delivery_target')) {
535
+ db.exec('ALTER TABLE work_items ADD COLUMN delivery_target TEXT');
536
+ }
537
+ if (!hasColumn(db, 'actions', 'close_reason')) {
538
+ db.exec('ALTER TABLE actions ADD COLUMN close_reason TEXT');
539
+ }
540
+ if (!hasColumn(db, 'actions', 'closed_at')) {
541
+ db.exec('ALTER TABLE actions ADD COLUMN closed_at INTEGER');
542
+ }
543
+ if (!hasColumn(db, 'runs', 'outputs')) {
544
+ db.exec("ALTER TABLE runs ADD COLUMN outputs TEXT NOT NULL DEFAULT '[]'");
545
+ }
546
+ db.exec(`
547
+ DROP TRIGGER IF EXISTS trg_runs_terminal_identity_immutable;
548
+ CREATE TRIGGER IF NOT EXISTS trg_runs_terminal_identity_immutable
549
+ BEFORE UPDATE ON runs
550
+ WHEN OLD.terminal_status IS NOT NULL AND (
551
+ NEW.action_id IS NOT OLD.action_id OR NEW.work_item_id IS NOT OLD.work_item_id OR
552
+ NEW.owner_boot_id IS NOT OLD.owner_boot_id OR NEW.lease_epoch IS NOT OLD.lease_epoch OR
553
+ NEW.ordinal IS NOT OLD.ordinal OR NEW.started_at IS NOT OLD.started_at OR
554
+ NEW.status IS NOT OLD.status OR NEW.ended_at IS NOT OLD.ended_at OR
555
+ NEW.terminal_status IS NOT OLD.terminal_status OR NEW.terminal_at IS NOT OLD.terminal_at OR
556
+ NEW.response IS NOT OLD.response OR NEW.summary IS NOT OLD.summary OR
557
+ NEW.evidence IS NOT OLD.evidence OR NEW.outputs IS NOT OLD.outputs OR
558
+ NEW.acceptance_checks IS NOT OLD.acceptance_checks OR
559
+ NEW.waiting_reason IS NOT OLD.waiting_reason OR NEW.error IS NOT OLD.error OR
560
+ NEW.failure_kind IS NOT OLD.failure_kind OR NEW.failure_code IS NOT OLD.failure_code OR
561
+ NEW.review_decision IS NOT OLD.review_decision OR NEW.contract_patch IS NOT OLD.contract_patch OR
562
+ NEW.checkpoint IS NOT OLD.checkpoint)
563
+ BEGIN
564
+ SELECT RAISE(ABORT, 'terminal Run result is immutable');
565
+ END;
566
+ `);
567
+ }
568
+
569
+ function migrateActionCreationSource(db) {
570
+ if (!hasColumn(db, 'actions', 'creation_source')) {
571
+ db.exec("ALTER TABLE actions ADD COLUMN creation_source TEXT NOT NULL DEFAULT 'legacy'");
572
+ }
573
+ }
574
+
531
575
  function migrateReliabilityGuards(db) {
532
576
  for (const [column, definition] of [
533
577
  ['dispatch_capability', "TEXT NOT NULL DEFAULT 'unknown'"],
@@ -70,6 +70,25 @@ function normalizeSourceActionIds(value, actions) {
70
70
  return ids;
71
71
  }
72
72
 
73
+ export function normalizeDynamicActionClosures(value, actions) {
74
+ if (!Array.isArray(value)) return [];
75
+ const byId = new Map(actions.map(action => [action.id, action]));
76
+ const seen = new Set();
77
+ return value.map(raw => {
78
+ const actionId = requiredText(raw?.actionId, 'close actionId', 256);
79
+ if (seen.has(actionId)) throw new Error(`Work Center dynamic Action close target is duplicated: ${actionId}`);
80
+ seen.add(actionId);
81
+ const action = byId.get(actionId);
82
+ if (!action || !['waiting', 'failed'].includes(action.status)) {
83
+ throw new Error(`Work Center can close only a waiting or failed Action: ${actionId}`);
84
+ }
85
+ return {
86
+ actionId,
87
+ reason: requiredText(raw?.reason, 'close reason', 2_000),
88
+ };
89
+ });
90
+ }
91
+
73
92
  function normalizeSupersededActionIds(value, actions) {
74
93
  const ids = uniqueStrings(value);
75
94
  const byId = new Map(actions.map(action => [action.id, action]));
@@ -106,7 +125,12 @@ export function prepareDynamicActionMutation({
106
125
  const knownVpIds = Array.isArray(availableVpIds)
107
126
  ? new Set(availableVpIds.map(value => String(value || '').trim()).filter(Boolean))
108
127
  : null;
128
+ const closeActions = normalizeDynamicActionClosures(decision.closeActions, actions);
129
+ const closeActionIds = new Set(closeActions.map(entry => entry.actionId));
109
130
  const supersedeActionIds = normalizeSupersededActionIds(decision.supersedeActionIds, actions);
131
+ if (supersedeActionIds.some(actionId => closeActionIds.has(actionId))) {
132
+ throw new Error('Work Center cannot both close and supersede the same Action');
133
+ }
110
134
  const effectiveWorkItem = {
111
135
  ...workItem,
112
136
  ...(decision.contractPatch || {}),
@@ -133,6 +157,12 @@ export function prepareDynamicActionMutation({
133
157
  const unavailable = candidateVpIds.find(vpId => !knownVpIds.has(vpId));
134
158
  if (unavailable) throw new Error(`Work Center dynamic Action references unavailable VP "${unavailable}"`);
135
159
  }
160
+ if (type === 'create_vp' && !knownVpIds) {
161
+ throw new Error('Work Center create_vp Action requires the available VP inventory');
162
+ }
163
+ if (type === 'create_vp' && candidateVpIds.length !== 1) {
164
+ throw new Error('Work Center create_vp Action requires exactly one existing VP and an assignment reason');
165
+ }
136
166
  const assignmentReason = candidateVpIds.length > 0
137
167
  ? requiredText(raw.assignmentReason, 'assignmentReason', 1_000)
138
168
  : '';
@@ -140,6 +170,9 @@ export function prepareDynamicActionMutation({
140
170
  const workspaceMode = DYNAMIC_WORKSPACE_MODES.has(raw.workspaceMode)
141
171
  ? raw.workspaceMode
142
172
  : 'shared';
173
+ if (type === 'create_vp' && workspaceMode === 'read') {
174
+ throw new Error('Work Center create_vp Action cannot use read workspace mode because VP creation mutates Agent-global state');
175
+ }
143
176
  const sourceActionIds = normalizeSourceActionIds(raw.sourceActionIds, actions);
144
177
  if (workspaceMode === 'integrate' && sourceActionIds.length === 0) {
145
178
  throw new Error('Work Center integrate Action requires sourceActionIds');
@@ -198,6 +231,7 @@ export function prepareDynamicActionMutation({
198
231
  if (!workItemType) throw new Error('Work Center Coordinator must choose a specific WorkItem type');
199
232
  return {
200
233
  createdActions,
234
+ closeActions,
201
235
  supersedeActionIds,
202
236
  contractPatch: decision.contractPatch || null,
203
237
  workItemType,