@yeaft/webchat-agent 1.0.195 → 1.0.196

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.195",
3
+ "version": "1.0.196",
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
@@ -1743,7 +1743,7 @@ export class Engine {
1743
1743
  * string-prompt shape (no regression for existing callers).
1744
1744
  * @yields {EngineEvent}
1745
1745
  */
1746
- async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, collabToolPolicy = null } = {}) {
1746
+ async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, closePendingUserInput = null, collabToolPolicy = null } = {}) {
1747
1747
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1748
1748
  yield {
1749
1749
  type: 'error',
@@ -1816,7 +1816,7 @@ export class Engine {
1816
1816
 
1817
1817
  try {
1818
1818
  this.#currentThreadId = threadId || MAIN_THREAD_ID;
1819
- yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName });
1819
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, closePendingUserInput, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName });
1820
1820
  } finally {
1821
1821
  if (signal) {
1822
1822
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
@@ -1850,7 +1850,7 @@ export class Engine {
1850
1850
  * in a try/finally without indenting the whole loop.
1851
1851
  * @private
1852
1852
  */
1853
- async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, collabToolPolicy = null, explicitSkillName = null }) {
1853
+ async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, closePendingUserInput = null, collabToolPolicy = null, explicitSkillName = null }) {
1854
1854
 
1855
1855
  const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
1856
1856
  ? collabToolPolicy
@@ -3035,8 +3035,29 @@ export class Engine {
3035
3035
  // into the regular end_turn path.
3036
3036
  }
3037
3037
 
3038
- // If no tool calls, we're done
3038
+ // If no tool calls, we're done. Callers with a durable append queue may
3039
+ // atomically close it here. A failed close means input won the race with
3040
+ // terminal completion, so drain it and keep this same Engine query alive.
3039
3041
  if (stopReason !== 'tool_use' || toolCalls.length === 0) {
3042
+ if (typeof closePendingUserInput === 'function' && !closePendingUserInput()) {
3043
+ const appendedBeforeClose = this.#drainPendingUserMessages(drainPendingUserMessages);
3044
+ if (appendedBeforeClose.length === 0) {
3045
+ throw new Error('Could not close pending user input for terminal completion');
3046
+ }
3047
+ for (const item of appendedBeforeClose) {
3048
+ conversationMessages.push({ role: 'user', content: item.content });
3049
+ yield {
3050
+ type: 'user_append',
3051
+ turnId: queryTurnId,
3052
+ loopNumber: turnNumber,
3053
+ threadId,
3054
+ preview: String(item.preview || '').slice(0, 200),
3055
+ internal: Boolean(item.internal),
3056
+ };
3057
+ }
3058
+ yield { type: 'turn_end', turnNumber, stopReason: 'user_append_continue', threadId };
3059
+ continue;
3060
+ }
3040
3061
  if (pendingSubAgentNotifs.length > 0) {
3041
3062
  acknowledgePendingNotifications(notifScope, pendingSubAgentNotifs.map(n => n.id));
3042
3063
  }
@@ -249,18 +249,35 @@ export class WorkflowController {
249
249
  const workItem = this.store.getWorkItem(id);
250
250
  if (!workItem) throw new Error(`WorkItem not found: ${id}`);
251
251
  if (['ready', 'running'].includes(workItem.status)) {
252
- if (workItem.currentActionId !== input.actionId || workItem.revision !== input.revision) {
253
- throw new Error('Action changed before input was applied; refresh and try again');
252
+ const activeAction = this.store.getAction(input.actionId);
253
+ if (activeAction?.status === 'running' && addedAttachmentCount > 0) {
254
+ throw new Error('Files cannot be added while an Action is running; send text now or wait for the next Action boundary');
254
255
  }
255
- return this.guide(id, {
256
- guidance: text,
256
+ const inputSummary = text || `The user added ${addedAttachmentCount} attachment(s) as additional context for this Action.`;
257
+ return this.store.addActionInput(id, inputSummary, {
257
258
  actionId: input.actionId,
258
259
  generation: input.generation,
259
260
  revision: input.revision,
260
- addedAttachmentCount,
261
- addedAttachments: input.addedAttachments,
262
- attachments: input.attachments,
263
- });
261
+ }, (current, action) => {
262
+ const context = [...(action.context || []), {
263
+ type: 'input', role: 'user', summary: inputSummary, evidence: [],
264
+ }];
265
+ const step = {
266
+ type: action.type,
267
+ stageId: action.stageId || action.type,
268
+ assignmentPolicy: action.assignmentPolicy,
269
+ modelPolicy: action.modelPolicy,
270
+ requiredRole: action.requiredRole,
271
+ dependsOnStageIds: action.dependsOnStageIds,
272
+ workspaceMode: action.workspaceMode,
273
+ changesRequestedStageId: action.changesRequestedStageId,
274
+ brief: action.brief,
275
+ };
276
+ return {
277
+ context,
278
+ instruction: actionInstruction(step, current, context, renderSessionContextSnapshot(current.sessionContext)),
279
+ };
280
+ }, input.attachments, input.addedAttachments);
264
281
  }
265
282
  if (!['waiting', 'needs_attention'].includes(workItem.status)) {
266
283
  throw new Error(`WorkItem in ${workItem.status} cannot accept Action input`);
@@ -341,6 +358,13 @@ export class WorkflowController {
341
358
  const activeAction = activeRun ? this.store.getAction(activeRun.actionId) : null;
342
359
  if (!activeRun || !activeAction) throw new Error('Run is stale, cancelled, or already finished');
343
360
  const activeWorkItem = this.store.getWorkItem(activeRun.workItemId);
361
+ if (activeRun.acceptingInput !== false
362
+ && !this.store.closeRunInput(runId, ownerBootId, leaseEpoch)) {
363
+ if (!this.store.isActiveRun(runId, ownerBootId, leaseEpoch)) {
364
+ throw new Error('Run is stale, cancelled, expired, or already finished');
365
+ }
366
+ throw new Error('Run has unconsumed Action input and cannot finish yet');
367
+ }
344
368
  const result = normalizeTerminalResult(rawResult, activeAction);
345
369
  validateCompletedResult(result, activeAction, activeWorkItem);
346
370
  let validatedGeneratedWorkflow = null;
@@ -113,12 +113,30 @@ function runResponseMessage(run) {
113
113
  });
114
114
  }
115
115
 
116
+ function loopOutputMessages(action, events) {
117
+ return (Array.isArray(events) ? events : [])
118
+ .filter(event => event?.actionId === action?.id && event.type === 'run.loop_output')
119
+ .map(event => normalizeProjectedMessage({
120
+ id: `event:${event.id}`,
121
+ role: 'assistant',
122
+ kind: 'response',
123
+ status: 'completed',
124
+ text: event.data?.response || '',
125
+ createdAt: event.createdAt,
126
+ }))
127
+ .filter(Boolean);
128
+ }
129
+
116
130
  function actionMessages(action, runs, events) {
117
131
  const matchingRuns = Array.isArray(runs)
118
132
  ? runs.filter(run => run?.actionId === action?.id)
119
133
  : [];
120
- return [...actionInputMessages(action, events), ...matchingRuns
134
+ const runsWithLoopOutput = new Set((Array.isArray(events) ? events : [])
135
+ .filter(event => event?.actionId === action?.id && event.type === 'run.loop_output' && event.runId)
136
+ .map(event => event.runId));
137
+ return [...actionInputMessages(action, events), ...loopOutputMessages(action, events), ...matchingRuns
121
138
  .sort((left, right) => count(left.startedAt) - count(right.startedAt))
139
+ .filter(run => !runsWithLoopOutput.has(run.id))
122
140
  .map(run => runResponseMessage(run))
123
141
  .filter(Boolean)]
124
142
  .sort((left, right) => left.createdAt - right.createdAt
@@ -294,6 +312,18 @@ function projectAction(action, runs, events, includeBody = true) {
294
312
  key,
295
313
  truncateUtf8(value, includeBody ? MAX_CURRENT_BRIEF_BYTES : MAX_HISTORICAL_BRIEF_CHARS),
296
314
  ]));
315
+ const matchingRuns = Array.isArray(runs) ? runs.filter(run => run?.actionId === action.id) : [];
316
+ const latestRun = [...matchingRuns].sort((left, right) => (
317
+ count(right.startedAt) - count(left.startedAt) || count(right.progressRevision) - count(left.progressRevision)
318
+ ))[0];
319
+ const assignedVp = latestRun?.vpSnapshot ? {
320
+ id: latestRun.vpSnapshot.id || null,
321
+ name: latestRun.vpSnapshot.name || latestRun.vpSnapshot.id || null,
322
+ } : null;
323
+ const contentSummary = truncateUtf8(
324
+ execution.response || latestRun?.summary || brief?.objective || brief?.expectedOutcome || '',
325
+ MAX_HISTORICAL_BRIEF_CHARS,
326
+ );
297
327
  return {
298
328
  id: action.id,
299
329
  sequence: action.sequence,
@@ -307,6 +337,8 @@ function projectAction(action, runs, events, includeBody = true) {
307
337
  requiredRole: action.requiredRole || '',
308
338
  brief: projectedBrief,
309
339
  status: action.status,
340
+ assignedVp,
341
+ contentSummary,
310
342
  executionStats: executionStats(execution),
311
343
  loopCount: execution.loopCount,
312
344
  toolCount: execution.toolCount,
@@ -366,6 +398,8 @@ function projectActionStats(detail) {
366
398
  const stats = {
367
399
  id: projected.id,
368
400
  status: projected.status,
401
+ assignedVp: projected.assignedVp,
402
+ contentSummary: projected.contentSummary,
369
403
  executionStats: projected.executionStats,
370
404
  loopCount: projected.loopCount,
371
405
  toolCount: projected.toolCount,
@@ -783,7 +783,7 @@ export class WorkItemRunner {
783
783
  }
784
784
  }
785
785
 
786
- async run({ workItem, action, run, signal, ownerBootId, onProgress, registerProgressReader }) {
786
+ async run({ workItem, action, run, signal, ownerBootId, onProgress, registerProgressReader, registerInputWake }) {
787
787
  const runtime = await this.runtimeProvider();
788
788
  const currentSettings = workItem?.workflowSnapshot?.planningMode === 'ai' && this.policyProvider
789
789
  ? await this.policyProvider()
@@ -840,6 +840,7 @@ export class WorkItemRunner {
840
840
  reuseMemory: workItem.reuseMemory,
841
841
  });
842
842
  const attachmentContext = buildWorkItemAttachmentContext(workItem, { root: this.attachmentRoot });
843
+ const attachmentFileById = new Map(attachmentContext.files.map(file => [file.id, file]));
843
844
  const fixedPromptSuffix = `${resumeBlock}${attachmentContext.promptBlock}${completionContract(executionAction, workItem)}`;
844
845
  const reservedPromptBytes = Buffer.byteLength(fixedPromptSuffix, 'utf8');
845
846
  const mainline = v2Execution
@@ -992,6 +993,29 @@ export class WorkItemRunner {
992
993
  taskManager: null,
993
994
  vpId: vp.id,
994
995
  });
996
+ if (typeof registerInputWake === 'function') {
997
+ registerInputWake(() => engine.wakeForPendingUserMessage?.());
998
+ }
999
+ const drainPendingUserMessages = () => {
1000
+ const pending = this.store.listPendingActionInputs?.(
1001
+ action.id, run.id, ownerBootId, run.leaseEpoch,
1002
+ ) || [];
1003
+ const accepted = [];
1004
+ for (const item of pending) {
1005
+ const attachmentLines = item.attachments.map(attachment => {
1006
+ const file = attachmentFileById.get(attachment.id);
1007
+ return file ? `- ${attachment.name}: ${file.ref}` : `- ${attachment.name}`;
1008
+ });
1009
+ const content = [item.text, attachmentLines.length > 0
1010
+ ? `Additional WorkItem attachments:\n${attachmentLines.join('\n')}` : '']
1011
+ .filter(Boolean).join('\n\n');
1012
+ if (!content || !this.store.acknowledgeActionInput?.(
1013
+ item.id, action.id, run.id, ownerBootId, run.leaseEpoch,
1014
+ )) continue;
1015
+ accepted.push({ content, preview: item.text || '[attachments]' });
1016
+ }
1017
+ return accepted;
1018
+ };
995
1019
  try {
996
1020
  const prompt = v2Execution
997
1021
  ? `${renderMainlineContextSnapshot(mainline.contextSnapshot)}${fixedPromptSuffix}`
@@ -1015,9 +1039,19 @@ export class WorkItemRunner {
1015
1039
  workCenterInstructions: workItem?.workflowSnapshot?.globalInstructions || '',
1016
1040
  workDir,
1017
1041
  userAlreadyPersisted: true,
1042
+ drainPendingUserMessages,
1043
+ closePendingUserInput: () => this.store.closeRunInput(
1044
+ run.id, ownerBootId, run.leaseEpoch,
1045
+ ),
1018
1046
  collabToolPolicy: 'single-vp',
1019
1047
  })) {
1020
- if (event?.type === 'loop') loopCount += 1;
1048
+ if (event?.type === 'loop') {
1049
+ loopCount += 1;
1050
+ this.store.appendRunLoop?.(run.id, ownerBootId, run.leaseEpoch, {
1051
+ ...event,
1052
+ response: publicWorkItemResponse(event.response),
1053
+ });
1054
+ }
1021
1055
  else if (event?.type === 'tool_start') toolInputs.set(event.id, event.input);
1022
1056
  else if (event?.type === 'tool_end') {
1023
1057
  toolCount += 1;
@@ -93,7 +93,7 @@ export class WorkCenterService {
93
93
  case 'get_action_messages': {
94
94
  const detail = this.#requiredItem(payload.id);
95
95
  const action = this.#requiredAction(detail, payload.actionId);
96
- return projectActionMessagePage(action, detail.runs, detail.events, {
96
+ return projectActionMessagePage(action, detail.runs, this.store.listActionEvents(action.id), {
97
97
  cursor: payload.cursor,
98
98
  limit: payload.limit,
99
99
  });
@@ -237,6 +237,7 @@ export class WorkCenterService {
237
237
  throw error;
238
238
  }
239
239
  this.watcher.abortInvalidWorkItemRuns(id);
240
+ this.watcher.notifyActionInput(id, payload.actionId);
240
241
  this.#emit({ type: 'action.input_added', workItem: detail });
241
242
  return detail;
242
243
  }
@@ -5,7 +5,7 @@ import { createHash, randomUUID } from 'node:crypto';
5
5
  import { normalizeEvidence } from './evidence.js';
6
6
  import { normalizeActionCheckpoint } from './action-checkpoint.js';
7
7
 
8
- const SCHEMA_VERSION = 14;
8
+ const SCHEMA_VERSION = 15;
9
9
  const OPEN_ACTION_STATUSES = "'ready','running','waiting'";
10
10
  const MAX_REUSABLE_CONTEXT_ITEMS = 12;
11
11
  const MAX_RUN_RESPONSE_CHARS = 65_536;
@@ -195,6 +195,7 @@ function mapRun(row) {
195
195
  totalTokens: Math.max(0, Number(row.total_tokens) || 0),
196
196
  progressRevision: Math.max(0, Number(row.progress_revision) || 0),
197
197
  checkpoint: normalizeActionCheckpoint(parseJson(row.checkpoint, null)),
198
+ acceptingInput: row.accepting_input !== 0,
198
199
  };
199
200
  }
200
201
 
@@ -350,7 +351,8 @@ export class WorkItemStore {
350
351
  cache_write_tokens INTEGER NOT NULL DEFAULT 0,
351
352
  total_tokens INTEGER NOT NULL DEFAULT 0,
352
353
  progress_revision INTEGER NOT NULL DEFAULT 0,
353
- checkpoint TEXT
354
+ checkpoint TEXT,
355
+ accepting_input INTEGER NOT NULL DEFAULT 1
354
356
  );
355
357
  CREATE TABLE IF NOT EXISTS events (
356
358
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -361,6 +363,15 @@ export class WorkItemStore {
361
363
  data TEXT NOT NULL,
362
364
  created_at INTEGER NOT NULL
363
365
  );
366
+ CREATE TABLE IF NOT EXISTS pending_action_inputs (
367
+ event_id INTEGER PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE,
368
+ work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
369
+ action_id TEXT NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
370
+ run_id TEXT,
371
+ text TEXT NOT NULL,
372
+ attachments TEXT NOT NULL DEFAULT '[]',
373
+ consumed_at INTEGER
374
+ );
364
375
  CREATE TABLE IF NOT EXISTS plan_audits (
365
376
  id INTEGER PRIMARY KEY AUTOINCREMENT,
366
377
  work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
@@ -391,6 +402,8 @@ export class WorkItemStore {
391
402
  CREATE INDEX IF NOT EXISTS idx_actions_ready ON actions(status, updated_at, sequence);
392
403
  CREATE INDEX IF NOT EXISTS idx_runs_active ON runs(status, expires_at);
393
404
  CREATE INDEX IF NOT EXISTS idx_events_work_item ON events(work_item_id, id);
405
+ CREATE INDEX IF NOT EXISTS idx_pending_action_inputs_action
406
+ ON pending_action_inputs(action_id, consumed_at, event_id);
394
407
  `);
395
408
 
396
409
  // The feature shipped first as an unmerged PR, but keep the store tolerant
@@ -459,6 +472,9 @@ export class WorkItemStore {
459
472
  if (!hasColumn(this.db, 'runs', 'checkpoint')) {
460
473
  this.db.exec('ALTER TABLE runs ADD COLUMN checkpoint TEXT');
461
474
  }
475
+ if (!hasColumn(this.db, 'runs', 'accepting_input')) {
476
+ this.db.exec('ALTER TABLE runs ADD COLUMN accepting_input INTEGER NOT NULL DEFAULT 1');
477
+ }
462
478
  if (!hasColumn(this.db, 'work_items', 'workflow_snapshot')) {
463
479
  this.db.exec('ALTER TABLE work_items ADD COLUMN workflow_snapshot TEXT');
464
480
  }
@@ -535,6 +551,110 @@ export class WorkItemStore {
535
551
  return Number(result.lastInsertRowid);
536
552
  }
537
553
 
554
+ addActionInput(id, input, expected, updateReadyAction, attachments = null, addedAttachments = []) {
555
+ return withTransaction(this.db, () => {
556
+ const workItem = this.getWorkItem(id);
557
+ if (!workItem) return null;
558
+ if (!['ready', 'running'].includes(workItem.status)) {
559
+ throw new Error(`WorkItem in ${workItem.status} cannot accept Action input`);
560
+ }
561
+ const action = this.getAction(expected.actionId);
562
+ const graphMode = workItem.workflowSnapshot?.executionMode === 'graph';
563
+ const actionMatches = graphMode
564
+ ? action?.workItemId === id && ['ready', 'running'].includes(action.status)
565
+ : action?.id === workItem.currentActionId && ['ready', 'running'].includes(action?.status);
566
+ const activeRun = action?.currentRunId ? this.getRun(action.currentRunId) : null;
567
+ if (!actionMatches || activeRun?.acceptingInput === false || workItem.revision !== expected.revision) {
568
+ throw new Error('Action changed before input was applied; refresh and try again');
569
+ }
570
+ const now = this.now();
571
+ const revision = workItem.revision + 1;
572
+ const updated = updateReadyAction(workItem, action);
573
+ const changedAction = this.db.prepare(`UPDATE actions SET context = ?, instruction = ?, updated_at = ?
574
+ WHERE id = ? AND status = ? AND current_run_id IS ?`).run(
575
+ stringify(updated.context || []),
576
+ updated.instruction || action.instruction,
577
+ now,
578
+ action.id,
579
+ action.status,
580
+ action.currentRunId,
581
+ );
582
+ if (Number(changedAction.changes) !== 1) {
583
+ throw new Error('Action changed before input was applied; refresh and try again');
584
+ }
585
+ this.db.prepare(`UPDATE work_items SET attachments = ?, revision = ?, updated_at = ?
586
+ WHERE id = ?`).run(
587
+ stringify(Array.isArray(attachments) ? attachments : workItem.attachments),
588
+ revision,
589
+ now,
590
+ id,
591
+ );
592
+ const projectedAttachments = (Array.isArray(addedAttachments) ? addedAttachments : []).map(attachment => ({
593
+ id: attachment.id,
594
+ name: attachment.name,
595
+ mimeType: attachment.mimeType,
596
+ size: Math.max(0, Number(attachment.size) || 0),
597
+ isImage: attachment.isImage === true,
598
+ }));
599
+ const eventId = this.appendEvent(id, 'action.input_added', {
600
+ text: input,
601
+ attachments: projectedAttachments,
602
+ }, { actionId: action.id, runId: action.currentRunId });
603
+ if (action.status === 'running') {
604
+ this.db.prepare(`INSERT INTO pending_action_inputs
605
+ (event_id, work_item_id, action_id, run_id, text, attachments, consumed_at)
606
+ VALUES (?, ?, ?, ?, ?, ?, NULL)`).run(
607
+ eventId,
608
+ id,
609
+ action.id,
610
+ action.currentRunId,
611
+ input,
612
+ stringify(projectedAttachments),
613
+ );
614
+ }
615
+ return this.getWorkItemDetail(id);
616
+ });
617
+ }
618
+
619
+ listPendingActionInputs(actionId, runId, ownerBootId, leaseEpoch) {
620
+ const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
621
+ if (!active || active.action_id !== actionId) return [];
622
+ return this.db.prepare(`SELECT * FROM pending_action_inputs
623
+ WHERE action_id = ? AND consumed_at IS NULL ORDER BY event_id`).all(actionId).map(row => ({
624
+ id: String(row.event_id),
625
+ text: row.text || '',
626
+ attachments: parseJson(row.attachments, []),
627
+ }));
628
+ }
629
+
630
+ acknowledgeActionInput(eventId, actionId, runId, ownerBootId, leaseEpoch) {
631
+ return withTransaction(this.db, () => {
632
+ const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
633
+ if (!active || active.action_id !== actionId) return false;
634
+ const result = this.db.prepare(`UPDATE pending_action_inputs SET consumed_at = ?
635
+ WHERE event_id = ? AND action_id = ? AND consumed_at IS NULL`).run(
636
+ this.now(), Number(eventId), actionId,
637
+ );
638
+ return Number(result.changes) === 1;
639
+ });
640
+ }
641
+
642
+ appendRunLoop(runId, ownerBootId, leaseEpoch, loop = {}) {
643
+ return withTransaction(this.db, () => {
644
+ const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
645
+ if (!active) return null;
646
+ const response = normalizeRunResponse(loop.response).trim();
647
+ if (response) {
648
+ this.appendEvent(active.work_item_id, 'run.loop_output', {
649
+ loopNumber: Math.max(0, Number(loop.loopNumber) || 0),
650
+ response,
651
+ stopReason: loop.stopReason || null,
652
+ }, { actionId: active.action_id, runId });
653
+ }
654
+ return this.getWorkItemDetail(active.work_item_id);
655
+ });
656
+ }
657
+
538
658
  createPlanConflict(workItemId, input = {}) {
539
659
  const id = input.id || randomUUID();
540
660
  const now = this.now();
@@ -1001,6 +1121,10 @@ export class WorkItemStore {
1001
1121
  return graphExecutionState(detail, detail.actions);
1002
1122
  }
1003
1123
 
1124
+ listActionEvents(actionId) {
1125
+ return this.db.prepare(`SELECT * FROM events WHERE action_id = ? ORDER BY id`).all(actionId).map(mapEvent);
1126
+ }
1127
+
1004
1128
  getReusableContext(workDir, excludeWorkItemId = null) {
1005
1129
  const workspaceKey = canonicalWorkspaceKey(workDir);
1006
1130
  if (!workspaceKey) return [];
@@ -1544,12 +1668,29 @@ export class WorkItemStore {
1544
1668
  };
1545
1669
  }
1546
1670
 
1671
+ closeRunInput(runId, ownerBootId, leaseEpoch) {
1672
+ return withTransaction(this.db, () => {
1673
+ const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
1674
+ if (!active) return false;
1675
+ const pendingInput = this.db.prepare(`SELECT event_id FROM pending_action_inputs
1676
+ WHERE action_id = ? AND consumed_at IS NULL LIMIT 1`).get(active.action_id);
1677
+ if (pendingInput) return false;
1678
+ const changed = this.db.prepare(`UPDATE runs SET accepting_input = 0
1679
+ WHERE id = ? AND owner_boot_id = ? AND lease_epoch = ? AND status = 'running'
1680
+ AND accepting_input = 1`).run(runId, ownerBootId, leaseEpoch);
1681
+ return Number(changed.changes) === 1;
1682
+ });
1683
+ }
1684
+
1547
1685
  finalizeRun(runId, ownerBootId, leaseEpoch, result, makeTransition) {
1548
1686
  return withTransaction(this.db, () => {
1549
1687
  const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
1550
- if (!active) return null;
1688
+ if (!active || Number(active.accepting_input) !== 0) return null;
1551
1689
  const action = this.getAction(active.action_id);
1552
1690
  const workItem = this.getWorkItem(active.work_item_id);
1691
+ const pendingInput = this.db.prepare(`SELECT event_id FROM pending_action_inputs
1692
+ WHERE action_id = ? AND consumed_at IS NULL LIMIT 1`).get(action.id);
1693
+ if (pendingInput) throw new Error('Run has unconsumed Action input and cannot finish yet');
1553
1694
  const priorRuns = this.db.prepare(`SELECT * FROM runs
1554
1695
  WHERE work_item_id = ? AND id != ? AND status != 'running'
1555
1696
  ORDER BY started_at ASC`).all(workItem.id, runId).map(mapRun);
@@ -1727,14 +1868,15 @@ export class WorkItemStore {
1727
1868
  currentActionId = blocked?.id || runnable?.id || null;
1728
1869
  changedWorkItem = this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
1729
1870
  current_run_id = NULL, ledger_revision = ledger_revision + ?, updated_at = ?
1730
- WHERE id = ? AND status IN ('ready', 'running', 'waiting', 'needs_attention')`).run(
1731
- workItemStatus, currentActionId, ledgerIncrement, now, workItem.id,
1871
+ WHERE id = ? AND status IN ('ready', 'running', 'waiting', 'needs_attention')
1872
+ AND revision = ?`).run(
1873
+ workItemStatus, currentActionId, ledgerIncrement, now, workItem.id, nextWorkItem.revision,
1732
1874
  );
1733
1875
  } else {
1734
1876
  changedWorkItem = this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
1735
1877
  current_run_id = NULL, ledger_revision = ledger_revision + ?, updated_at = ? WHERE id = ? AND current_run_id = ?
1736
- AND current_action_id = ? AND status = 'running'`).run(
1737
- workItemStatus, currentActionId, ledgerIncrement, now, workItem.id, runId, action.id,
1878
+ AND current_action_id = ? AND status = 'running' AND revision = ?`).run(
1879
+ workItemStatus, currentActionId, ledgerIncrement, now, workItem.id, runId, action.id, nextWorkItem.revision,
1738
1880
  );
1739
1881
  }
1740
1882
  if (Number(changedWorkItem.changes) !== 1) {
@@ -88,6 +88,13 @@ export class WorkItemWatcher {
88
88
  }
89
89
  }
90
90
 
91
+ notifyActionInput(workItemId, actionId) {
92
+ for (const entry of this.activeRuns.values()) {
93
+ if (entry.workItemId !== workItemId || entry.actionId !== actionId) continue;
94
+ try { entry.wakeForPendingUserMessage?.(); } catch {}
95
+ }
96
+ }
97
+
91
98
  #recoverExpiredRuns() {
92
99
  const recovered = this.store.recoverInterruptedRuns?.(this.ownerBootId) || 0;
93
100
  if (recovered > 0) {
@@ -172,11 +179,15 @@ export class WorkItemWatcher {
172
179
  readFinalProgress: null,
173
180
  interrupted: false,
174
181
  workItemId: claim.workItem.id,
182
+ actionId: claim.action.id,
175
183
  runId: claim.run.id,
176
184
  leaseEpoch: claim.run.leaseEpoch,
185
+ wakeForPendingUserMessage: null,
177
186
  };
178
187
  entry.promise = this.#execute(claim, abortController.signal, readProgress => {
179
188
  entry.readFinalProgress = readProgress;
189
+ }, wake => {
190
+ entry.wakeForPendingUserMessage = wake;
180
191
  }).finally(() => {
181
192
  clearInterval(renewal);
182
193
  this.activeRuns.delete(key);
@@ -186,7 +197,7 @@ export class WorkItemWatcher {
186
197
  this.onEvent({ type: 'run.started', workItem: this.store.getWorkItemDetail(claim.workItem.id) });
187
198
  }
188
199
 
189
- async #execute(claim, signal, registerProgressReader) {
200
+ async #execute(claim, signal, registerProgressReader, registerInputWake) {
190
201
  try {
191
202
  let result;
192
203
  try {
@@ -195,6 +206,7 @@ export class WorkItemWatcher {
195
206
  signal,
196
207
  ownerBootId: this.ownerBootId,
197
208
  registerProgressReader,
209
+ registerInputWake,
198
210
  onProgress: progress => {
199
211
  const detail = this.store.updateRunProgress(
200
212
  claim.run.id,