@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/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +22 -5
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/engine.js +46 -2
- package/yeaft/llm/adapter.js +23 -0
- package/yeaft/llm/anthropic.js +3 -0
- package/yeaft/llm/openai-responses.js +3 -0
- package/yeaft/tools/create-work-item.js +1 -1
- package/yeaft/web-bridge.js +7 -3
- package/yeaft/work-center/bridge.js +3 -2
- package/yeaft/work-center/completion-contract.js +6 -0
- package/yeaft/work-center/controller.js +2 -1
- package/yeaft/work-center/coordinator.js +45 -14
- package/yeaft/work-center/durable-model.js +45 -1
- package/yeaft/work-center/dynamic-coordination.js +34 -0
- package/yeaft/work-center/evidence.js +235 -0
- package/yeaft/work-center/mainline-projection.js +4 -1
- package/yeaft/work-center/projection.js +45 -4
- package/yeaft/work-center/runner.js +82 -7
- package/yeaft/work-center/service.js +6 -0
- package/yeaft/work-center/store.js +162 -19
- package/yeaft/work-center/workflow.js +6 -0
|
Binary file
|
package/package.json
CHANGED
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 {
|
|
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
|
|
package/yeaft/llm/adapter.js
CHANGED
|
@@ -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) {
|
package/yeaft/llm/anthropic.js
CHANGED
|
@@ -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?.
|
|
77
|
+
messageId: ctx.inboundEnvelope?.msg?.id || null,
|
|
78
78
|
createdBy: ctx.currentVpId || 'assistant',
|
|
79
79
|
},
|
|
80
80
|
linkedSessionIds: [sessionId],
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -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
|
|
4468
|
-
|
|
4469
|
-
|
|
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', '
|
|
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 {
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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:
|
|
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
|
|
596
|
-
const completed = currentActions.filter(action => action.status
|
|
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
|
|
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 =
|
|
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,
|