@yeaft/webchat-agent 1.0.122 → 1.0.124

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.
@@ -9,7 +9,11 @@ export const BUFFERABLE_TYPES = new Set([
9
9
  'turn_completed', 'conversation_closed',
10
10
  'session_id_update', 'compact_status', 'slash_commands_update',
11
11
  'background_task_started', 'background_task_output',
12
- 'subagent_started', 'subagent_message', 'subagent_completed'
12
+ 'subagent_started', 'subagent_message', 'subagent_completed',
13
+ // Work Center broadcasts are projections over Agent-local SQLite. Buffering
14
+ // prevents a terminal transition from disappearing during a short reconnect;
15
+ // clients still refresh with `list` after reconnect for authoritative state.
16
+ 'work_center_event'
13
17
  ]);
14
18
 
15
19
  function bufferMessage(msg, reason) {
@@ -31,6 +31,7 @@ import { discoverLlmModels } from '../llm-model-discovery.js';
31
31
  import { fetchModelsDev } from '../yeaft/llm/models-dev.js';
32
32
  import { handleYeaftSessionSend, handleYeaftSubAgentPrompt, handleYeaftTaskCancel, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, handleYeaftLoadHistory, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListSessions, handleYeaftCreateSession, handleYeaftRenameSession, handleYeaftUpdateSession, handleYeaftUpdateSessionConfig, handleYeaftArchiveSession, handleYeaftDeleteSession, handleYeaftSessionAddMember, handleYeaftSessionRemoveMember, handleYeaftSessionSetDefaultVp, handleYeaftScanWorkdirSessions, handleYeaftRestoreSession, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, handleYeaftMcpList, handleYeaftMcpAdd, handleYeaftMcpRemove, handleYeaftMcpReload, broadcastLanguageChange, broadcastYeaftSessionSnapshotEager, preloadYeaftSkillSlashCommands } from '../yeaft/web-bridge.js';
33
33
  import { startYeaftStatusRefresh, forceRefreshYeaftStatus } from '../yeaft/status-cache.js';
34
+ import { handleWorkCenterRequest } from '../yeaft/work-center/bridge.js';
34
35
 
35
36
  export async function applyLlmConfigUpdate(msg, dependencies = {}) {
36
37
  const updateConfig = dependencies.updateLlmConfig || updateLlmConfig;
@@ -638,6 +639,10 @@ export async function handleMessage(msg) {
638
639
  await handleYeaftFetchDebugHistory(msg);
639
640
  break;
640
641
 
642
+ case 'work_center_request':
643
+ await handleWorkCenterRequest(msg);
644
+ break;
645
+
641
646
  // Expert roles definition (for ExpertPanel detail view)
642
647
  case 'get_expert_roles': {
643
648
  const { getExpertRolesDefinition } = await import('../expert-roles.js');
@@ -73,7 +73,7 @@ export function buildWindowsWorkerCommand(nodePath) {
73
73
 
74
74
  // Shared cleanup logic for restart/upgrade
75
75
  function cleanupAndExit(exitCode) {
76
- setTimeout(() => {
76
+ setTimeout(async () => {
77
77
  for (const [, term] of ctx.terminals) {
78
78
  if (term.pty) { try { term.pty.kill(); } catch {} }
79
79
  if (term.timer) clearTimeout(term.timer);
@@ -84,6 +84,12 @@ function cleanupAndExit(exitCode) {
84
84
  if (state.inputStream) state.inputStream.done();
85
85
  }
86
86
  ctx.conversations.clear();
87
+ try {
88
+ const { shutdownWorkCenter } = await import('../yeaft/work-center/bridge.js');
89
+ await shutdownWorkCenter();
90
+ } catch (err) {
91
+ console.warn(`[Agent] Work Center shutdown failed: ${err?.message || err}`);
92
+ }
87
93
  stopAgentHeartbeat();
88
94
  if (ctx.ws) {
89
95
  ctx.ws.removeAllListeners('close');
package/index.js CHANGED
@@ -290,7 +290,7 @@ async function ensureYeaftSkills() {
290
290
  }
291
291
 
292
292
  // 优雅退出
293
- function cleanup() {
293
+ async function cleanup() {
294
294
  // 清理所有终端
295
295
  for (const [, term] of ctx.terminals) {
296
296
  if (term.pty) {
@@ -309,18 +309,22 @@ function cleanup() {
309
309
  }
310
310
  }
311
311
  ctx.conversations.clear();
312
+ try {
313
+ const { shutdownWorkCenter } = await import('./yeaft/work-center/bridge.js');
314
+ await shutdownWorkCenter();
315
+ } catch {}
312
316
  if (ctx.ws) ctx.ws.close();
313
317
  }
314
318
 
315
- process.on('SIGINT', () => {
319
+ process.on('SIGINT', async () => {
316
320
  console.log('Shutting down...');
317
- cleanup();
321
+ await cleanup();
318
322
  process.exit(0);
319
323
  });
320
324
 
321
- process.on('SIGTERM', () => {
325
+ process.on('SIGTERM', async () => {
322
326
  console.log('Shutting down...');
323
- cleanup();
327
+ await cleanup();
324
328
  process.exit(0);
325
329
  });
326
330
 
@@ -341,5 +345,12 @@ process.on('SIGTERM', () => {
341
345
  } catch (err) {
342
346
  console.warn(`[Agent] models.dev prime failed (will use config/defaults): ${err?.message || err}`);
343
347
  }
348
+ try {
349
+ const { bootWorkCenter } = await import('./yeaft/work-center/bridge.js');
350
+ await bootWorkCenter();
351
+ console.log('[Agent] Work Center watcher started');
352
+ } catch (err) {
353
+ console.warn(`[Agent] Work Center failed to start: ${err?.message || err}`);
354
+ }
344
355
  connect();
345
356
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.122",
3
+ "version": "1.0.124",
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
@@ -2278,7 +2278,7 @@ export class Engine {
2278
2278
  // OpenAI/Anthropic toolCallId pairing intact.
2279
2279
  let wireMessages = stripMetaForWire([...conversationMessages]);
2280
2280
 
2281
- if (this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
2281
+ if (scenario !== 'work-item' && this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
2282
2282
  try {
2283
2283
  const swept = await archiveToolResults({
2284
2284
  root: `${this.#yeaftDir}/memory`,
@@ -2315,7 +2315,7 @@ export class Engine {
2315
2315
  // The estimator (`estimateMessagesTokens`) is approxTokens
2316
2316
  // (char/4 with CJK weighting) — good enough for a guard rail; a
2317
2317
  // real tokenizer would be exact but adds a heavy dep.
2318
- if (this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
2318
+ if (scenario !== 'work-item' && this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
2319
2319
  const PREFLIGHT_RATIO = 0.85;
2320
2320
  const threshold = Math.floor(currentContextWindow * PREFLIGHT_RATIO);
2321
2321
  const estimate = estimateMessagesTokens(systemPrompt, wireMessages);
@@ -67,6 +67,7 @@ const RESTRICTED_TOOLS = new Set([
67
67
  'ListAgents',
68
68
  'RouteForward',
69
69
  'AskUser',
70
+ 'CreateWorkItem',
70
71
  ]);
71
72
 
72
73
  /** How long an idle sub-agent may wait for a follow-up before the watchdog reaps it. */
@@ -15,7 +15,19 @@ import { resolve, dirname } from 'path';
15
15
  * @param {string} patch
16
16
  * @returns {Array<{ file: string, hunks: Array }>}
17
17
  */
18
- function parsePatch(patch) {
18
+ function parsePatchTarget(line) {
19
+ const raw = line.slice(4).replace(/\r$/, '');
20
+ const tabIndex = raw.indexOf('\t');
21
+ let filePath = (tabIndex === -1 ? raw : raw.slice(0, tabIndex)).trim();
22
+ if (!filePath) throw new Error('Patch target path is empty');
23
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(filePath)) {
24
+ throw new Error('Patch target path contains control characters');
25
+ }
26
+ if (filePath.startsWith('b/')) filePath = filePath.slice(2);
27
+ return filePath === '/dev/null' ? null : filePath;
28
+ }
29
+
30
+ export function parsePatch(patch) {
19
31
  const files = [];
20
32
  const lines = patch.split('\n');
21
33
  let currentFile = null;
@@ -30,10 +42,8 @@ function parsePatch(patch) {
30
42
  continue;
31
43
  }
32
44
  if (line.startsWith('+++ ')) {
33
- let filePath = line.slice(4).trim();
34
- // Strip a/ or b/ prefix
35
- if (filePath.startsWith('b/')) filePath = filePath.slice(2);
36
- if (filePath === '/dev/null') continue;
45
+ const filePath = parsePatchTarget(line);
46
+ if (!filePath) continue;
37
47
 
38
48
  currentFile = { file: filePath, hunks: [] };
39
49
  files.push(currentFile);
@@ -0,0 +1,79 @@
1
+ import { defineTool } from './types.js';
2
+
3
+ function cleanCriteria(value) {
4
+ return Array.isArray(value)
5
+ ? value.map(item => String(item).trim()).filter(Boolean)
6
+ : [];
7
+ }
8
+
9
+ export default defineTool({
10
+ name: 'CreateWorkItem',
11
+ description: {
12
+ en: `Create a persistent Agent-level Work Center item from the current Session.
13
+
14
+ Use this when work must continue beyond the current turn, needs role handoffs, review, waiting, retry, or durable tracking. This creates the work contract; it does not execute it inline. The current Session is always stamped as the origin and cannot be overridden by model input.`,
15
+ zh: `从当前 Session 创建一个持久化的 Agent 级工作项。
16
+
17
+ 当工作需要跨 turn 继续、需要角色接力、评审、等待、重试或长期跟踪时使用。该工具只创建工作契约,不在当前 turn 内执行。来源 Session 由运行时强制写入,模型输入不能覆盖。`,
18
+ },
19
+ parameters: {
20
+ type: 'object',
21
+ properties: {
22
+ title: {
23
+ type: 'string',
24
+ description: { en: 'Short work item title', zh: '简短的工作项标题' },
25
+ },
26
+ goal: {
27
+ type: 'string',
28
+ description: { en: 'Stable outcome the work item must achieve', zh: '工作项必须达到的稳定目标' },
29
+ },
30
+ acceptanceCriteria: {
31
+ type: 'array',
32
+ items: { type: 'string' },
33
+ description: { en: 'Verifiable completion criteria', zh: '可验证的完成条件' },
34
+ },
35
+ workDir: {
36
+ type: 'string',
37
+ description: { en: 'Optional project directory for execution', zh: '执行时使用的可选项目目录' },
38
+ },
39
+ start: {
40
+ type: 'boolean',
41
+ description: { en: 'Start triage immediately (default true)', zh: '是否立即开始 triage(默认 true)' },
42
+ },
43
+ },
44
+ required: ['title', 'goal'],
45
+ },
46
+ isConcurrencySafe: () => false,
47
+ isReadOnly: () => false,
48
+ async execute(input, ctx = {}) {
49
+ const sessionId = typeof ctx.sessionId === 'string' ? ctx.sessionId.trim() : '';
50
+ if (!sessionId) throw new Error('CreateWorkItem requires an active Session');
51
+ const title = typeof input?.title === 'string' ? input.title.trim() : '';
52
+ const goal = typeof input?.goal === 'string' ? input.goal.trim() : '';
53
+ if (!title || !goal) throw new Error('title and goal are required');
54
+
55
+ // Dynamic import avoids tools/index -> create-work-item -> bridge -> runner
56
+ // -> tools/index becoming a static initialization cycle.
57
+ const { createWorkItemFromProducer } = await import('../work-center/bridge.js');
58
+ const detail = await createWorkItemFromProducer({
59
+ title,
60
+ goal,
61
+ acceptanceCriteria: cleanCriteria(input.acceptanceCriteria),
62
+ workDir: typeof input.workDir === 'string' ? input.workDir.trim() : (ctx.cwd || ''),
63
+ workflowTemplate: 'software-change',
64
+ origin: {
65
+ sessionId,
66
+ messageId: ctx.inboundEnvelope?.msgId || null,
67
+ createdBy: ctx.currentVpId || 'assistant',
68
+ },
69
+ linkedSessionIds: [sessionId],
70
+ start: input.start !== false,
71
+ });
72
+ return JSON.stringify({
73
+ workItemId: detail.id,
74
+ status: detail.status,
75
+ title: detail.title,
76
+ message: `Created Work Center item ${detail.id}`,
77
+ });
78
+ },
79
+ });
@@ -55,6 +55,7 @@ import routeForward from './route-forward.js';
55
55
  // --- P1 Progress tracking ---
56
56
  import todoWrite from './todo-write.js';
57
57
  import startPlan from './start-plan.js';
58
+ import createWorkItem from './create-work-item.js';
58
59
 
59
60
  // H2.f.4: user-facing thread tools (spawnThread/switchThread/listThreads/...)
60
61
  // were deleted. PR #797 reintroduces runtime-owned VP thread routing below the
@@ -119,6 +120,7 @@ export const allTools = [
119
120
  // P1 Progress tracking
120
121
  todoWrite,
121
122
  startPlan,
123
+ createWorkItem,
122
124
 
123
125
  // P2 Auxiliary
124
126
  jsRepl,
@@ -3943,7 +3943,7 @@ export function buildVpQueryOpts({ vpId, sessionCoordinator, sessionId, envelope
3943
3943
  * @param {{ workDir?: string, sessionId?: string|null, sessionMeta?: object, perfTraceId?: string|null, messageType?: string }} [opts]
3944
3944
  * @returns {Promise<import('./session.js').Session>}
3945
3945
  */
3946
- async function ensureSessionLoaded(opts = {}) {
3946
+ export async function ensureSessionLoaded(opts = {}) {
3947
3947
  if (session) return session;
3948
3948
  if (sessionLoadPromise) return sessionLoadPromise;
3949
3949
 
@@ -0,0 +1,142 @@
1
+ import ctx from '../../context.js';
2
+ import { sendToServer } from '../../connection/buffer.js';
3
+ import { ensureSessionLoaded } from '../web-bridge.js';
4
+ import { defaultRegistry } from '../vp/registry.js';
5
+ import { scanVpLibrary } from '../vp/vp-store.js';
6
+ import { WorkCenterService } from './service.js';
7
+ import { WorkItemRunner } from './runner.js';
8
+ import { projectWorkCenterEvent } from './projection.js';
9
+ import { join } from 'node:path';
10
+
11
+ let service = null;
12
+ let initPromise = null;
13
+ let shuttingDown = false;
14
+ let shutdownPromise = null;
15
+ let serviceFactory = null;
16
+
17
+ function send(msg) {
18
+ sendToServer(msg);
19
+ }
20
+
21
+ async function getRuntime() {
22
+ return ensureSessionLoaded();
23
+ }
24
+
25
+ async function createDefaultService() {
26
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
27
+ if (!yeaftDir) throw new Error('Work Center requires a configured Yeaft directory');
28
+ const runner = new WorkItemRunner({
29
+ runtimeProvider: async () => {
30
+ const runtime = await getRuntime();
31
+ if (defaultRegistry.vpCount() === 0) {
32
+ for (const vp of scanVpLibrary({ dir: join(runtime.yeaftDir, 'virtual-persons') })) defaultRegistry.setVp(vp);
33
+ }
34
+ return {
35
+ ...runtime,
36
+ defaultWorkDir: ctx.CONFIG?.workDir || process.cwd(),
37
+ };
38
+ },
39
+ registry: defaultRegistry,
40
+ store: null,
41
+ });
42
+ const created = new WorkCenterService({
43
+ yeaftDir,
44
+ runner,
45
+ onEvent(event) {
46
+ send({ type: 'work_center_event', event: projectWorkCenterEvent(event) });
47
+ },
48
+ });
49
+ runner.store = created.store;
50
+ return created;
51
+ }
52
+
53
+ async function ensureWorkCenter() {
54
+ if (service) return service;
55
+ if (shuttingDown) throw new Error('Work Center is shutting down');
56
+ if (initPromise) return initPromise;
57
+ initPromise = (async () => {
58
+ const created = serviceFactory ? await serviceFactory() : await createDefaultService();
59
+ if (shuttingDown) {
60
+ await created.shutdown();
61
+ throw new Error('Work Center shut down during initialization');
62
+ }
63
+ created.start();
64
+ service = created;
65
+ return created;
66
+ })();
67
+ try {
68
+ return await initPromise;
69
+ } finally {
70
+ initPromise = null;
71
+ }
72
+ }
73
+
74
+ export async function bootWorkCenter() {
75
+ return ensureWorkCenter();
76
+ }
77
+
78
+ export async function createWorkItemFromProducer(payload) {
79
+ const workCenter = await ensureWorkCenter();
80
+ return workCenter.handle('create', payload);
81
+ }
82
+
83
+ export async function handleWorkCenterRequest(msg) {
84
+ const requestId = typeof msg.requestId === 'string' ? msg.requestId : null;
85
+ const op = typeof msg.op === 'string' ? msg.op : '';
86
+ try {
87
+ const workCenter = await ensureWorkCenter();
88
+ const data = await workCenter.handle(op, msg.payload || {});
89
+ send({
90
+ type: 'work_center_response',
91
+ requestId,
92
+ op,
93
+ ok: true,
94
+ data,
95
+ _requestUserId: msg._requestUserId || null,
96
+ });
97
+ } catch (err) {
98
+ send({
99
+ type: 'work_center_response',
100
+ requestId,
101
+ op,
102
+ ok: false,
103
+ error: err?.message || String(err),
104
+ _requestUserId: msg._requestUserId || null,
105
+ });
106
+ }
107
+ }
108
+
109
+ export async function shutdownWorkCenter() {
110
+ if (shutdownPromise) return shutdownPromise;
111
+ shuttingDown = true;
112
+ const pendingInit = initPromise;
113
+ shutdownPromise = (async () => {
114
+ let current = service;
115
+ if (!current && pendingInit) {
116
+ try { current = await pendingInit; } catch {}
117
+ }
118
+ service = null;
119
+ if (current) await current.shutdown();
120
+ })();
121
+ try {
122
+ await shutdownPromise;
123
+ } finally {
124
+ initPromise = null;
125
+ shutdownPromise = null;
126
+ }
127
+ }
128
+
129
+ export function __testSetWorkCenterService(next) {
130
+ service = next || null;
131
+ initPromise = null;
132
+ shuttingDown = false;
133
+ shutdownPromise = null;
134
+ }
135
+
136
+ export function __testSetWorkCenterFactory(factory) {
137
+ serviceFactory = factory || null;
138
+ service = null;
139
+ initPromise = null;
140
+ shuttingDown = false;
141
+ shutdownPromise = null;
142
+ }
@@ -0,0 +1,213 @@
1
+ import { actionInstruction, getNextStep, initialActionFor, RUN_OUTCOMES } from './workflow.js';
2
+ import { normalizeEvidence } from './evidence.js';
3
+
4
+ function normalizeCriteria(value) {
5
+ if (!Array.isArray(value)) return null;
6
+ return value.map(item => String(item).trim()).filter(Boolean);
7
+ }
8
+
9
+ function normalizeContractPatch(value) {
10
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
11
+ const patch = {};
12
+ if (typeof value.goal === 'string' && value.goal.trim()) patch.goal = value.goal.trim();
13
+ if (Object.prototype.hasOwnProperty.call(value, 'acceptanceCriteria')) {
14
+ const criteria = normalizeCriteria(value.acceptanceCriteria);
15
+ if (!criteria) throw new Error('contractPatch.acceptanceCriteria must be an array');
16
+ patch.acceptanceCriteria = criteria;
17
+ }
18
+ return Object.keys(patch).length > 0 ? patch : null;
19
+ }
20
+
21
+ function normalizeTerminalResult(result, action) {
22
+ if (!result || !RUN_OUTCOMES.includes(result.outcome)) {
23
+ throw new Error(`Invalid Work Center outcome: ${result?.outcome || '(missing)'}`);
24
+ }
25
+ const normalized = {
26
+ outcome: result.outcome,
27
+ summary: String(result.summary || ''),
28
+ evidence: normalizeEvidence(result.evidence),
29
+ waitingReason: result.waitingReason ? String(result.waitingReason) : null,
30
+ error: result.error ? String(result.error) : null,
31
+ reviewDecision: ['approved', 'changes_requested'].includes(result.reviewDecision)
32
+ ? result.reviewDecision
33
+ : null,
34
+ contractPatch: normalizeContractPatch(result.contractPatch),
35
+ };
36
+ if (normalized.outcome === 'waiting' && !normalized.waitingReason) {
37
+ throw new Error('waiting outcome requires waitingReason');
38
+ }
39
+ if (action.type !== 'triage' && normalized.contractPatch) {
40
+ normalized.outcome = 'failed';
41
+ normalized.error = 'Only triage may submit a WorkItem contractPatch';
42
+ normalized.contractPatch = null;
43
+ }
44
+ if (action.type === 'review' && normalized.outcome === 'completed' && !normalized.reviewDecision) {
45
+ normalized.outcome = 'failed';
46
+ normalized.error = 'Completed review requires approved or changes_requested';
47
+ }
48
+ return normalized;
49
+ }
50
+
51
+ function contextEntry(action, result) {
52
+ return {
53
+ type: action.type,
54
+ role: action.requiredRole,
55
+ summary: result.summary || '',
56
+ evidence: result.evidence || [],
57
+ reviewDecision: result.reviewDecision || null,
58
+ };
59
+ }
60
+
61
+ export class WorkflowController {
62
+ constructor(store) {
63
+ this.store = store;
64
+ }
65
+
66
+ create(input) {
67
+ const draft = {
68
+ ...input,
69
+ workflowTemplate: input.workflowTemplate || 'software-change',
70
+ acceptanceCriteria: Array.isArray(input.acceptanceCriteria) ? input.acceptanceCriteria : [],
71
+ };
72
+ const firstAction = input.start !== false ? initialActionFor(draft) : null;
73
+ return this.store.createWorkItem(draft, firstAction);
74
+ }
75
+
76
+ start(id) {
77
+ const detail = this.store.startWorkItemAtomic(id, initialActionFor);
78
+ if (!detail) throw new Error(`WorkItem not found: ${id}`);
79
+ return detail;
80
+ }
81
+
82
+ update(id, patch) {
83
+ const updated = this.store.updateWorkItemAtomic(id, patch, initialActionFor);
84
+ if (!updated) throw new Error(`WorkItem not found: ${id}`);
85
+ return this.store.getWorkItemDetail(id);
86
+ }
87
+
88
+ cancel(id) {
89
+ const workItem = this.store.cancelWorkItemAtomic(id);
90
+ if (!workItem) throw new Error(`WorkItem not found: ${id}`);
91
+ return this.store.getWorkItemDetail(id);
92
+ }
93
+
94
+ retry(id, input = {}) {
95
+ const answer = typeof input.answer === 'string' ? input.answer.trim().slice(0, 8_000) : '';
96
+ const detail = this.store.retryWorkItemAtomic(id, (workItem, previous, previousRun) => {
97
+ if (workItem.status === 'waiting' && !answer) {
98
+ throw new Error('answer is required to resume a waiting WorkItem');
99
+ }
100
+ const step = previous
101
+ ? { type: previous.type, requiredRole: previous.requiredRole }
102
+ : initialActionFor(workItem);
103
+ const context = Array.isArray(previous?.context) ? [...previous.context] : [];
104
+ if (previousRun) {
105
+ context.push({
106
+ type: previous.type,
107
+ role: previous.requiredRole,
108
+ summary: previousRun.summary || '',
109
+ evidence: normalizeEvidence(previousRun.evidence),
110
+ waitingReason: previousRun.waitingReason || null,
111
+ answer: answer || null,
112
+ });
113
+ }
114
+ return {
115
+ ...step,
116
+ context,
117
+ instruction: actionInstruction(step, workItem, context),
118
+ maxAttempts: previous?.maxAttempts || 2,
119
+ };
120
+ });
121
+ if (!detail) throw new Error(`WorkItem not found: ${id}`);
122
+ return detail;
123
+ }
124
+
125
+ submit(runId, ownerBootId, leaseEpoch, rawResult) {
126
+ const activeRun = this.store.getRun(runId);
127
+ const activeAction = activeRun ? this.store.getAction(activeRun.actionId) : null;
128
+ if (!activeRun || !activeAction) throw new Error('Run is stale, cancelled, or already finished');
129
+ const result = normalizeTerminalResult(rawResult, activeAction);
130
+ const detail = this.store.finalizeRun(
131
+ runId,
132
+ ownerBootId,
133
+ leaseEpoch,
134
+ result,
135
+ ({ action, workItem }) => {
136
+ if (result.outcome === 'waiting') {
137
+ return {
138
+ actionStatus: 'completed',
139
+ workItemStatus: 'waiting',
140
+ keepCurrentAction: true,
141
+ eventType: 'action.waiting',
142
+ eventData: { reason: result.waitingReason },
143
+ };
144
+ }
145
+
146
+ if (result.outcome === 'retryable') {
147
+ const retryable = action.attempt < action.maxAttempts;
148
+ return {
149
+ actionStatus: retryable ? 'ready' : 'failed',
150
+ workItemStatus: retryable ? 'ready' : 'needs_attention',
151
+ keepCurrentAction: true,
152
+ eventType: retryable ? 'action.retry_scheduled' : 'action.retry_exhausted',
153
+ eventData: {
154
+ attempt: action.attempt,
155
+ maxAttempts: action.maxAttempts,
156
+ error: result.error,
157
+ },
158
+ };
159
+ }
160
+
161
+ if (result.outcome === 'failed') {
162
+ return {
163
+ actionStatus: 'failed',
164
+ workItemStatus: 'needs_attention',
165
+ keepCurrentAction: true,
166
+ eventType: 'action.failed',
167
+ eventData: { error: result.error },
168
+ };
169
+ }
170
+
171
+ const contractPatch = action.type === 'triage' ? result.contractPatch : null;
172
+ const effectiveWorkItem = contractPatch
173
+ ? {
174
+ ...workItem,
175
+ goal: contractPatch.goal ?? workItem.goal,
176
+ acceptanceCriteria: contractPatch.acceptanceCriteria ?? workItem.acceptanceCriteria,
177
+ revision: workItem.revision + 1,
178
+ }
179
+ : workItem;
180
+ const nextStep = getNextStep(workItem.workflowTemplate, action.type, result);
181
+ if (!nextStep) {
182
+ return {
183
+ actionStatus: 'completed',
184
+ workItemStatus: 'done',
185
+ contractPatch,
186
+ eventType: 'work_item.completed',
187
+ eventData: { summary: result.summary },
188
+ };
189
+ }
190
+
191
+ const context = [...(action.context || []), contextEntry(action, result)];
192
+ return {
193
+ actionStatus: 'completed',
194
+ workItemStatus: 'ready',
195
+ contractPatch,
196
+ nextAction: {
197
+ ...nextStep,
198
+ context,
199
+ instruction: actionInstruction(nextStep, effectiveWorkItem, context),
200
+ maxAttempts: 2,
201
+ },
202
+ eventType: 'action.completed',
203
+ eventData: {
204
+ nextActionType: nextStep.type,
205
+ reviewDecision: result.reviewDecision,
206
+ },
207
+ };
208
+ },
209
+ );
210
+ if (!detail) throw new Error('Run is stale, cancelled, expired, or already finished');
211
+ return detail;
212
+ }
213
+ }