@yeaft/webchat-agent 1.0.194 → 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/local-runtime/server/handlers/agent-output.js +56 -38
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +6 -11
- 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 +25 -4
- package/yeaft/work-center/controller.js +32 -8
- package/yeaft/work-center/projection.js +35 -1
- package/yeaft/work-center/runner.js +36 -2
- package/yeaft/work-center/service.js +2 -1
- package/yeaft/work-center/store.js +149 -7
- package/yeaft/work-center/watcher.js +13 -1
|
Binary file
|
package/package.json
CHANGED
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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')
|
|
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,
|
|
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 =
|
|
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')
|
|
1731
|
-
|
|
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'
|
|
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,
|