@lelouchhe/webagent 0.9.0 → 0.10.0
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/README.md +39 -14
- package/config.toml +7 -27
- package/dist/index.html +4 -4
- package/dist/js/app.INIQQEGD.js +5 -0
- package/dist/js/{chunk.S5LRNRJI.js → chunk.7WADDFJZ.js} +27 -27
- package/dist/js/viewer.RHZMFYWJ.js +1 -0
- package/dist/login.html +1 -1
- package/dist/share-viewer.html +5 -5
- package/dist/{styles.00nlhhf3.css → styles.01aj0l37.css} +19 -2
- package/dist/sw.js +6 -6
- package/lib/attachment-dispatch.js +60 -31
- package/lib/attachment-interceptor.js +7 -7
- package/lib/attachment-labels.js +1 -1
- package/lib/attachments.js +25 -0
- package/lib/auth-middleware.js +2 -2
- package/lib/auth.js +2 -2
- package/lib/bridge.js +109 -83
- package/lib/client-registry.js +12 -12
- package/lib/config.js +2 -31
- package/lib/event-handler.js +143 -90
- package/lib/files/routes.js +1 -1
- package/lib/mcp/capability.js +74 -0
- package/lib/mcp/server.js +148 -0
- package/lib/mcp/task-history.js +245 -0
- package/lib/mcp/task-host.js +253 -0
- package/lib/mcp/tools.js +168 -0
- package/lib/mode-bucket.js +1 -1
- package/lib/push-service.js +33 -35
- package/lib/routes.js +947 -489
- package/lib/server.js +64 -16
- package/lib/share/routes.js +88 -88
- package/lib/shared/task-reference.js +20 -0
- package/lib/sse-manager.js +8 -8
- package/lib/store.js +941 -314
- package/lib/task-collaboration.js +15 -0
- package/lib/task-manager.js +1409 -0
- package/lib/task-path.js +131 -0
- package/lib/{session-state.js → task-state.js} +64 -41
- package/lib/task-tree-lock.js +74 -0
- package/lib/{sessions-anchor.js → tasks-anchor.js} +8 -7
- package/lib/tokens.js +1 -1
- package/lib/types.js +2 -2
- package/package.json +7 -1
- package/dist/js/app.QC7IRDTP.js +0 -5
- package/dist/js/viewer.GP5VXAUY.js +0 -1
- package/lib/session-manager.js +0 -638
- package/lib/title-service.js +0 -95
|
@@ -0,0 +1,1409 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { stat } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { MessageNotFoundError, ROOT_TASK_ID, } from "./store.js";
|
|
7
|
+
import { buildMcpServerEntry } from "./mcp/server.js";
|
|
8
|
+
import { TaskStateManager } from "./task-state.js";
|
|
9
|
+
import { buildLabelMap } from "./attachment-labels.js";
|
|
10
|
+
import { abbreviateHomePath, expandHomePath } from "./home-path.js";
|
|
11
|
+
import { log } from "./log.js";
|
|
12
|
+
import { TaskTreeLock } from "./task-tree-lock.js";
|
|
13
|
+
const slog = log.scope("task");
|
|
14
|
+
const IS_WIN = process.platform === "win32";
|
|
15
|
+
export function interruptBashProc(proc, force = false) {
|
|
16
|
+
if (!proc)
|
|
17
|
+
return;
|
|
18
|
+
if (IS_WIN && typeof proc.pid === "number") {
|
|
19
|
+
// Windows: kill entire process tree since there are no process groups
|
|
20
|
+
spawn("taskkill", ["/T", "/F", "/PID", String(proc.pid)]).unref();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (typeof proc.pid === "number") {
|
|
24
|
+
try {
|
|
25
|
+
process.kill(-proc.pid, force ? "SIGKILL" : "SIGINT");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Fall through to direct child kill when the process is not a group leader.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
proc.kill(force ? "SIGKILL" : "SIGINT");
|
|
33
|
+
}
|
|
34
|
+
function configOptionId(configOptions, logicalId) {
|
|
35
|
+
const selectable = configOptions.filter((option) => "options" in option);
|
|
36
|
+
if (logicalId === "thinking") {
|
|
37
|
+
return (selectable.find((option) => option.id === "reasoning_effort")?.id ??
|
|
38
|
+
selectable.find((option) => option.id === "thought_level" || option.category === "thought_level")?.id);
|
|
39
|
+
}
|
|
40
|
+
return selectable.find((option) => option.id === logicalId)?.id;
|
|
41
|
+
}
|
|
42
|
+
/** Minimum age (seconds) before an empty task is eligible for cleanup. */
|
|
43
|
+
const EMPTY_TASK_MIN_AGE_S = 60;
|
|
44
|
+
const HANDOFF_REMINDER_TEXT = [
|
|
45
|
+
"## Task Handoff Required",
|
|
46
|
+
"",
|
|
47
|
+
"This Task turn ended without a lifecycle handoff.",
|
|
48
|
+
"",
|
|
49
|
+
"Choose one:",
|
|
50
|
+
"- Continue the work if it is not finished.",
|
|
51
|
+
"- If the assignment is complete, call `task_update(done, ...)` with the completion report.",
|
|
52
|
+
"- If the Task cannot continue without input or a decision, call `task_update(blocked, ...)` and explain what is missing.",
|
|
53
|
+
"",
|
|
54
|
+
"Do not end this Task turn with a prose answer only.",
|
|
55
|
+
].join("\n");
|
|
56
|
+
export class InvalidTaskDirectoryError extends Error {
|
|
57
|
+
constructor(cwd) {
|
|
58
|
+
super(`Directory does not exist: ${cwd}`);
|
|
59
|
+
this.name = "InvalidTaskDirectoryError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export class TaskNotFoundError extends Error {
|
|
63
|
+
constructor(taskId) {
|
|
64
|
+
super(`Task not found: ${taskId}`);
|
|
65
|
+
this.name = "TaskNotFoundError";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export class TaskBusyError extends Error {
|
|
69
|
+
/** `taskPath` is the task's tree path when the store could resolve one. */
|
|
70
|
+
constructor(taskId, taskPath) {
|
|
71
|
+
super(`Cancel active work in ${taskPath ?? taskId} before modifying that task`);
|
|
72
|
+
this.name = "TaskBusyError";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export class TaskTreeBusyError extends Error {
|
|
76
|
+
constructor(taskId) {
|
|
77
|
+
super(`Task tree is busy while modifying ${taskId}`);
|
|
78
|
+
this.name = "TaskTreeBusyError";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export class AgentNotReadyError extends Error {
|
|
82
|
+
constructor(taskId) {
|
|
83
|
+
super(`Agent is not ready for task ${taskId}`);
|
|
84
|
+
this.name = "AgentNotReadyError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Centralizes all task-related state that was previously scattered
|
|
89
|
+
* across module-level variables in server.ts.
|
|
90
|
+
*/
|
|
91
|
+
export class TaskManager {
|
|
92
|
+
liveTasks = new Set();
|
|
93
|
+
/** Tasks with a minted MCP capability awaiting ACP task/new. */
|
|
94
|
+
creatingTasks = new Set();
|
|
95
|
+
restoringTasks = new Set();
|
|
96
|
+
assistantBuffers = new Map();
|
|
97
|
+
thinkingBuffers = new Map();
|
|
98
|
+
activePrompts = new Set();
|
|
99
|
+
/** Delivery rows currently being claimed/resolved for one target task. */
|
|
100
|
+
drainingCollaborationTasks = new Set();
|
|
101
|
+
/** Tasks undergoing compact summary generation or ACP rotation. */
|
|
102
|
+
compactingTasks = new Set();
|
|
103
|
+
/** Root reset barrier covering the asynchronous tree replacement. */
|
|
104
|
+
resettingTasks = new Set();
|
|
105
|
+
/** Barrier covering the asynchronous ACP replacement itself. */
|
|
106
|
+
rotatingTasks = new Set();
|
|
107
|
+
pendingPromptSubmissions = new Map();
|
|
108
|
+
cancelledPromptSubmissions = new Set();
|
|
109
|
+
runningBashProcs = new Map();
|
|
110
|
+
interruptedBashProcs = new WeakSet();
|
|
111
|
+
/** Pending permission requests keyed by requestId. */
|
|
112
|
+
pendingPermissions = new Map();
|
|
113
|
+
/** Per-task runtime state (busy/streaming/permissions snapshots + patches). */
|
|
114
|
+
state = new TaskStateManager();
|
|
115
|
+
/** Deduplicates concurrent resume calls for the same task. */
|
|
116
|
+
pendingResumes = new Map();
|
|
117
|
+
/** Hierarchical lock for task-tree structure mutations. */
|
|
118
|
+
treeLock = new TaskTreeLock();
|
|
119
|
+
/** Completion barrier for a Root reset requested before a create arrives. */
|
|
120
|
+
rootResetPromise = null;
|
|
121
|
+
nextPromptNumber = 0;
|
|
122
|
+
nextPromptSubmissionNumber = 0;
|
|
123
|
+
/** Deduplicates concurrent attempts to materialize one inbox message. */
|
|
124
|
+
pendingMessageConsumes = new Map();
|
|
125
|
+
/**
|
|
126
|
+
* Per-task attachment label map (CLAUDE.md "Attachment label
|
|
127
|
+
* egress rewrite"). Built lazily from the `attachments` table on
|
|
128
|
+
* first read; invalidated on attachment INSERT and task
|
|
129
|
+
* DELETE. Lookup is cheap (Map.get); rebuild is one SQLite query.
|
|
130
|
+
*/
|
|
131
|
+
attachmentLabelCache = new Map();
|
|
132
|
+
agentCommandSnapshots = new Map();
|
|
133
|
+
agentCommandEpoch = randomUUID();
|
|
134
|
+
cachedConfigOptions = [];
|
|
135
|
+
agentInfo = null;
|
|
136
|
+
store;
|
|
137
|
+
defaultCwd;
|
|
138
|
+
dataDir;
|
|
139
|
+
capabilities;
|
|
140
|
+
mcpBaseUrl;
|
|
141
|
+
constructor(store, defaultCwd, dataDir,
|
|
142
|
+
/**
|
|
143
|
+
* Optional MCP capability store. When provided together with
|
|
144
|
+
* `mcpBaseUrl`, every created/restored task is given a uniquely
|
|
145
|
+
* named MCP server entry whose Authorization header carries a freshly
|
|
146
|
+
* minted capability — the MCP control plane for ACP tasks. Lifecycle
|
|
147
|
+
* here is the single source of truth: mint happens next to
|
|
148
|
+
* `liveTasks.add`, revoke next to `liveTasks.delete`.
|
|
149
|
+
*/
|
|
150
|
+
capabilities, mcpBaseUrl) {
|
|
151
|
+
this.store = store;
|
|
152
|
+
this.defaultCwd = defaultCwd;
|
|
153
|
+
this.dataDir = dataDir;
|
|
154
|
+
this.capabilities = capabilities;
|
|
155
|
+
this.mcpBaseUrl = mcpBaseUrl;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* MCP may connect while ACP task/new or task/load is still pending.
|
|
159
|
+
* Treat those capability-backed tasks as active for that narrow window;
|
|
160
|
+
* they are promoted to live only after persistence/ACP succeeds.
|
|
161
|
+
*/
|
|
162
|
+
isMcpSessionActive(taskId) {
|
|
163
|
+
return (this.liveTasks.has(taskId) ||
|
|
164
|
+
this.creatingTasks.has(taskId) ||
|
|
165
|
+
this.restoringTasks.has(taskId));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Mint a capability for one task and build its MCP server
|
|
169
|
+
* `mcpServers` entry. Returns undefined (no MCP) when the server was not
|
|
170
|
+
* configured with a capability store + base URL, keeping tasks
|
|
171
|
+
* without MCP on the previous empty-mcpServers path.
|
|
172
|
+
*/
|
|
173
|
+
buildMcpServerForExecution(taskId, preserveExisting) {
|
|
174
|
+
if (!this.capabilities || !this.mcpBaseUrl)
|
|
175
|
+
return undefined;
|
|
176
|
+
const token = preserveExisting
|
|
177
|
+
? this.capabilities.mintAdditional(taskId)
|
|
178
|
+
: this.capabilities.mint(taskId);
|
|
179
|
+
return {
|
|
180
|
+
token,
|
|
181
|
+
servers: [buildMcpServerEntry(token, this.mcpBaseUrl)],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
buildMcpServers(taskId) {
|
|
185
|
+
return this.buildMcpServerForExecution(taskId, false)?.servers;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* newSession options with an optional mcpServers entry only when Task mode
|
|
189
|
+
* is configured — otherwise the previous call shape stays untouched.
|
|
190
|
+
*/
|
|
191
|
+
buildNewTaskOptions(silent, mcpServers) {
|
|
192
|
+
const options = {
|
|
193
|
+
silent,
|
|
194
|
+
};
|
|
195
|
+
if (mcpServers !== undefined)
|
|
196
|
+
options.mcpServers = mcpServers;
|
|
197
|
+
return options;
|
|
198
|
+
}
|
|
199
|
+
hydrate() {
|
|
200
|
+
// No per-task title tracking needed after automatic title-service
|
|
201
|
+
// removal; titles persist in the store and are broadcast via
|
|
202
|
+
// task_title_updated on manual rename.
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Drop live state for tasks that the store cleaned as empty.
|
|
206
|
+
*/
|
|
207
|
+
cleanupEmptyTasks(bridge) {
|
|
208
|
+
const cleaned = this.store.deleteEmptyTasks(EMPTY_TASK_MIN_AGE_S);
|
|
209
|
+
for (const entry of cleaned) {
|
|
210
|
+
if (entry.agentSessionId) {
|
|
211
|
+
void bridge?.retireExecution?.(entry.agentSessionId);
|
|
212
|
+
}
|
|
213
|
+
this.liveTasks.delete(entry.id);
|
|
214
|
+
this.capabilities?.revokeByTask(entry.id);
|
|
215
|
+
this.agentCommandSnapshots.delete(entry.id);
|
|
216
|
+
}
|
|
217
|
+
if (cleaned.length > 0)
|
|
218
|
+
slog.info("cleaned empty task(s)", { count: cleaned.length });
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Empty-task GC scans all branches, so it gets a short Root-exclusive lock.
|
|
222
|
+
* It is best-effort: an active branch mutation wins and a later create will
|
|
223
|
+
* get another opportunity to run the cleanup.
|
|
224
|
+
*/
|
|
225
|
+
tryCleanupEmptyTasks(bridge) {
|
|
226
|
+
const release = this.treeLock.tryAcquire({ exclusive: [ROOT_TASK_ID] });
|
|
227
|
+
if (!release)
|
|
228
|
+
return;
|
|
229
|
+
try {
|
|
230
|
+
this.cleanupEmptyTasks(bridge);
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
release();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Create the ACP task while exposing its freshly minted capability as an
|
|
238
|
+
* active initializing task. The caller promotes it to live only after
|
|
239
|
+
* local persistence succeeds.
|
|
240
|
+
*/
|
|
241
|
+
async createAgentSession(bridge, taskId, cwd, options, capabilityToken) {
|
|
242
|
+
this.creatingTasks.add(taskId);
|
|
243
|
+
try {
|
|
244
|
+
return await bridge.newSession(cwd, options);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
this.creatingTasks.delete(taskId);
|
|
248
|
+
if (capabilityToken)
|
|
249
|
+
this.capabilities?.revoke(capabilityToken);
|
|
250
|
+
else
|
|
251
|
+
this.capabilities?.revokeByTask(taskId);
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** Default new WebAgent tasks to the reserved Root when it exists. */
|
|
256
|
+
resolveParentId(parentId) {
|
|
257
|
+
return (parentId ??
|
|
258
|
+
(this.store.getTaskIncludingDeleted(ROOT_TASK_ID) ? ROOT_TASK_ID : null));
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Lock the lineage needed to mutate a task subtree. The lock request is
|
|
262
|
+
* recomputed after acquisition so a concurrent reparent/delete cannot leave
|
|
263
|
+
* us holding a stale path.
|
|
264
|
+
*/
|
|
265
|
+
getActiveTaskWorkKind(taskId) {
|
|
266
|
+
if (this.creatingTasks.has(taskId))
|
|
267
|
+
return "agent";
|
|
268
|
+
if (this.pendingPromptSubmissions.has(taskId))
|
|
269
|
+
return "agent";
|
|
270
|
+
if (this.drainingCollaborationTasks.has(taskId))
|
|
271
|
+
return "agent";
|
|
272
|
+
if (this.activePrompts.has(taskId))
|
|
273
|
+
return "agent";
|
|
274
|
+
if (this.compactingTasks.has(taskId))
|
|
275
|
+
return "agent";
|
|
276
|
+
if (this.rotatingTasks.has(taskId))
|
|
277
|
+
return "agent";
|
|
278
|
+
if (this.runningBashProcs.has(taskId))
|
|
279
|
+
return "bash";
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
async waitForRootResetIfNeeded(taskId) {
|
|
283
|
+
while (this.resettingTasks.has(taskId)) {
|
|
284
|
+
const reset = this.rootResetPromise;
|
|
285
|
+
if (!reset)
|
|
286
|
+
throw new TaskTreeBusyError(taskId);
|
|
287
|
+
await reset;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async acquireTaskTreeMutationLock(taskId) {
|
|
291
|
+
for (;;) {
|
|
292
|
+
const lineage = this.store.getTaskLineage(taskId);
|
|
293
|
+
if (!lineage)
|
|
294
|
+
throw new TaskNotFoundError(taskId);
|
|
295
|
+
const release = await this.treeLock.acquire({
|
|
296
|
+
shared: lineage.slice(0, -1),
|
|
297
|
+
exclusive: [lineage[lineage.length - 1]],
|
|
298
|
+
});
|
|
299
|
+
const current = this.store.getTaskLineage(taskId);
|
|
300
|
+
if (current?.length === lineage.length &&
|
|
301
|
+
current.every((id, index) => id === lineage[index])) {
|
|
302
|
+
return release;
|
|
303
|
+
}
|
|
304
|
+
release();
|
|
305
|
+
if (!current)
|
|
306
|
+
throw new TaskNotFoundError(taskId);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** Create a new task in both bridge and store, inheriting the source task's config. */
|
|
310
|
+
async createTask(bridge, cwd, inheritFromTaskId, source = "auto", opts) {
|
|
311
|
+
const parentId = this.resolveParentId(opts?.parentId);
|
|
312
|
+
if (parentId) {
|
|
313
|
+
await this.waitForRootResetIfNeeded(parentId);
|
|
314
|
+
const parent = this.store.getTaskIncludingDeleted(parentId);
|
|
315
|
+
if (parent?.deleted_at !== null)
|
|
316
|
+
throw new TaskNotFoundError(parentId);
|
|
317
|
+
}
|
|
318
|
+
const result = await this.createTaskImpl(bridge, cwd, inheritFromTaskId, source, { ...opts, parentId });
|
|
319
|
+
// Empty-task GC touches arbitrary branches, so run it as a short,
|
|
320
|
+
// best-effort Root-exclusive operation after the task mutation is done.
|
|
321
|
+
// If another branch is active, skip this pass and let a later create retry.
|
|
322
|
+
this.tryCleanupEmptyTasks(bridge);
|
|
323
|
+
return result;
|
|
324
|
+
}
|
|
325
|
+
async persistCreatedTask(taskId, taskCwd, source, agentSessionId, parentId, metadata) {
|
|
326
|
+
for (;;) {
|
|
327
|
+
// Never await the Root reset while holding a lineage lock. A reset can
|
|
328
|
+
// be queued behind this request and waiting here would deadlock both.
|
|
329
|
+
if (parentId)
|
|
330
|
+
await this.waitForRootResetIfNeeded(parentId);
|
|
331
|
+
const release = parentId
|
|
332
|
+
? await this.acquireTaskTreeMutationLock(parentId)
|
|
333
|
+
: null;
|
|
334
|
+
try {
|
|
335
|
+
// The reset may have been requested after the wait but before lock
|
|
336
|
+
// acquisition. Drop the lock and wait outside it before retrying.
|
|
337
|
+
if (parentId && this.resettingTasks.has(parentId))
|
|
338
|
+
continue;
|
|
339
|
+
if (parentId) {
|
|
340
|
+
const parent = this.store.getTaskIncludingDeleted(parentId);
|
|
341
|
+
if (parent?.deleted_at !== null)
|
|
342
|
+
throw new TaskNotFoundError(parentId);
|
|
343
|
+
}
|
|
344
|
+
this.store.createTask(taskId, taskCwd, source, agentSessionId, parentId, metadata);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
release?.();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async inheritTaskConfig(bridge, taskId, configOptions, sourceTask) {
|
|
353
|
+
const inherited = [
|
|
354
|
+
{
|
|
355
|
+
configId: configOptionId(configOptions, "model"),
|
|
356
|
+
value: sourceTask.model,
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
configId: configOptionId(configOptions, "thinking"),
|
|
360
|
+
value: sourceTask.reasoning_effort,
|
|
361
|
+
},
|
|
362
|
+
];
|
|
363
|
+
let updatedConfigOptions = configOptions;
|
|
364
|
+
for (const { configId, value } of inherited) {
|
|
365
|
+
if (!configId || !value)
|
|
366
|
+
continue;
|
|
367
|
+
try {
|
|
368
|
+
const next = await bridge.setConfigOption(taskId, configId, value);
|
|
369
|
+
if (next.length > 0) {
|
|
370
|
+
updatedConfigOptions = next;
|
|
371
|
+
this.recordConfigOptions(taskId, next);
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
this.store.updateTaskConfig(taskId, configId, value);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
// Option may no longer be available; ignore
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return updatedConfigOptions;
|
|
382
|
+
}
|
|
383
|
+
// eslint-disable-next-line complexity -- task creation owns ACP setup, persistence, inheritance, and cleanup.
|
|
384
|
+
async createTaskImpl(bridge, cwd, inheritFromTaskId, source = "auto", opts) {
|
|
385
|
+
const taskCwd = expandHomePath(cwd ?? this.defaultCwd);
|
|
386
|
+
try {
|
|
387
|
+
const info = await stat(taskCwd);
|
|
388
|
+
if (!info.isDirectory())
|
|
389
|
+
throw new Error("not a directory");
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
throw new InvalidTaskDirectoryError(taskCwd);
|
|
393
|
+
}
|
|
394
|
+
const sourceTask = inheritFromTaskId
|
|
395
|
+
? this.store.getTask(inheritFromTaskId)
|
|
396
|
+
: null;
|
|
397
|
+
const taskId = randomUUID();
|
|
398
|
+
// Mint the task capability and build its mcpServers entry BEFORE the
|
|
399
|
+
// ACP task exists (the definition is carried in task/new itself),
|
|
400
|
+
// so a capability is only ever issued for a task we are about to
|
|
401
|
+
// create. The failed-persistence path below revokes it again.
|
|
402
|
+
const mcpServers = this.buildMcpServers(taskId);
|
|
403
|
+
const { sessionId: agentSessionId, configOptions: createdConfigOptions } = await this.createAgentSession(bridge, taskId, taskCwd, this.buildNewTaskOptions(opts?.silent, mcpServers));
|
|
404
|
+
let configOptions = createdConfigOptions;
|
|
405
|
+
const parentId = opts?.parentId ?? null;
|
|
406
|
+
try {
|
|
407
|
+
await this.persistCreatedTask(taskId, taskCwd, source, agentSessionId, parentId, {
|
|
408
|
+
title: opts?.title,
|
|
409
|
+
brief: opts?.brief,
|
|
410
|
+
workflowStatus: opts?.workflowStatus,
|
|
411
|
+
initialMessage: opts?.initialMessage,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
slog.warn("ACP task created but local persistence failed", {
|
|
416
|
+
agentSessionId,
|
|
417
|
+
error: err,
|
|
418
|
+
});
|
|
419
|
+
bridge.discardUnboundSession?.(agentSessionId);
|
|
420
|
+
this.creatingTasks.delete(taskId);
|
|
421
|
+
this.capabilities?.revokeByTask(taskId);
|
|
422
|
+
throw err;
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
this.liveTasks.add(taskId);
|
|
426
|
+
this.recordConfigOptions(taskId, createdConfigOptions);
|
|
427
|
+
bridge.sessionMapped?.(agentSessionId);
|
|
428
|
+
// Inherit config options from source task
|
|
429
|
+
if (sourceTask) {
|
|
430
|
+
configOptions = await this.inheritTaskConfig(bridge, taskId, configOptions, sourceTask);
|
|
431
|
+
}
|
|
432
|
+
if (opts?.model || opts?.thinking) {
|
|
433
|
+
try {
|
|
434
|
+
configOptions = await this.applyTaskConfigOverrides(bridge, taskId, configOptions, opts.model, opts.thinking);
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
// The task is live but still carries the creation barrier. Drop it
|
|
438
|
+
// before deleteTask checks busy state so failed overrides cannot
|
|
439
|
+
// strand the persisted task and ACP execution.
|
|
440
|
+
this.creatingTasks.delete(taskId);
|
|
441
|
+
await this.deleteTask(bridge, taskId).catch((cleanupError) => {
|
|
442
|
+
slog.warn("failed to clean up task after config override failure", {
|
|
443
|
+
taskId,
|
|
444
|
+
error: cleanupError,
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const task = this.store.getTask(taskId);
|
|
451
|
+
return {
|
|
452
|
+
taskId: taskId,
|
|
453
|
+
configOptions: task ? this.applyStoredConfig(configOptions, task) : [],
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
finally {
|
|
457
|
+
this.creatingTasks.delete(taskId);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
async applyTaskConfigOverrides(bridge, taskId, configOptions, model, thinking) {
|
|
461
|
+
const requested = [
|
|
462
|
+
{ logicalId: "model", value: model },
|
|
463
|
+
{ logicalId: "thinking", value: thinking },
|
|
464
|
+
];
|
|
465
|
+
let updated = configOptions;
|
|
466
|
+
for (const { logicalId, value } of requested) {
|
|
467
|
+
if (!value)
|
|
468
|
+
continue;
|
|
469
|
+
const configId = configOptionId(updated, logicalId);
|
|
470
|
+
if (!configId) {
|
|
471
|
+
throw new Error(`unsupported_config:${logicalId}`);
|
|
472
|
+
}
|
|
473
|
+
const option = updated.find((entry) => entry.id === configId);
|
|
474
|
+
if (!option || !("options" in option)) {
|
|
475
|
+
throw new Error(`unsupported_config:${logicalId}`);
|
|
476
|
+
}
|
|
477
|
+
if (!option.options.some((entry) => entry.value === value)) {
|
|
478
|
+
throw new Error(`invalid_config_value:${logicalId}`);
|
|
479
|
+
}
|
|
480
|
+
const next = await bridge.setConfigOption(taskId, configId, value);
|
|
481
|
+
if (next.length > 0) {
|
|
482
|
+
updated = next;
|
|
483
|
+
this.recordConfigOptions(taskId, next);
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
this.store.updateTaskConfig(taskId, configId, value);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return updated;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Rotate only the ACP execution for a stable WebAgent task.
|
|
493
|
+
* The old execution remains persisted as historical binding provenance.
|
|
494
|
+
*/
|
|
495
|
+
async clearTask(bridge, taskId, cwd, opts = {}) {
|
|
496
|
+
if (this.rotatingTasks.has(taskId)) {
|
|
497
|
+
throw new Error(`Task is already being rotated: ${taskId}`);
|
|
498
|
+
}
|
|
499
|
+
this.rotatingTasks.add(taskId);
|
|
500
|
+
// Other clients watching this task see busy for the whole rotation
|
|
501
|
+
// window instead of racing into a 409.
|
|
502
|
+
this.syncBusy(taskId);
|
|
503
|
+
try {
|
|
504
|
+
const result = await this.clearTaskImpl(bridge, taskId, cwd, opts.preserveRuntimeState);
|
|
505
|
+
if (!opts.preservePendingCompactSummary) {
|
|
506
|
+
this.store.clearPendingCompactSummary(taskId);
|
|
507
|
+
}
|
|
508
|
+
return result;
|
|
509
|
+
}
|
|
510
|
+
finally {
|
|
511
|
+
this.rotatingTasks.delete(taskId);
|
|
512
|
+
// Skip the idle sync when the task was hard-deleted mid-rotation
|
|
513
|
+
// (parent cascade): syncBusy would lazily re-create a stale runtime
|
|
514
|
+
// state row that releaseTaskRuntime had already dropped.
|
|
515
|
+
if (this.store.getTaskIncludingDeleted(taskId)) {
|
|
516
|
+
this.syncBusy(taskId);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
async clearTaskImpl(bridge, taskId, cwd, preserveRuntimeState = false) {
|
|
521
|
+
const task = this.store.getTask(taskId);
|
|
522
|
+
if (!task)
|
|
523
|
+
throw new Error(`Task not found: ${taskId}`);
|
|
524
|
+
const taskCwd = expandHomePath(cwd ?? task.cwd);
|
|
525
|
+
try {
|
|
526
|
+
const info = await stat(taskCwd);
|
|
527
|
+
if (!info.isDirectory())
|
|
528
|
+
throw new Error("not a directory");
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
throw new InvalidTaskDirectoryError(taskCwd);
|
|
532
|
+
}
|
|
533
|
+
const execution = this.buildMcpServerForExecution(taskId, true);
|
|
534
|
+
const created = await this.createAgentSession(bridge, taskId, taskCwd, this.buildNewTaskOptions(undefined, execution?.servers), execution?.token);
|
|
535
|
+
const retiredAgentSessionId = this.store.getAgentSessionId(taskId);
|
|
536
|
+
try {
|
|
537
|
+
this.store.rotateAgentSession(taskId, created.sessionId, taskCwd);
|
|
538
|
+
}
|
|
539
|
+
catch (error) {
|
|
540
|
+
bridge.discardUnboundSession?.(created.sessionId);
|
|
541
|
+
this.creatingTasks.delete(taskId);
|
|
542
|
+
if (execution?.token)
|
|
543
|
+
this.capabilities?.revoke(execution.token);
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
this.liveTasks.add(taskId);
|
|
547
|
+
this.creatingTasks.delete(taskId);
|
|
548
|
+
if (execution?.token) {
|
|
549
|
+
this.capabilities?.revokeOtherTokens(taskId, execution.token);
|
|
550
|
+
}
|
|
551
|
+
bridge.sessionMapped?.(created.sessionId);
|
|
552
|
+
// The old ACP execution is explicitly retired now that the new binding
|
|
553
|
+
// is authoritative; best-effort, never rolls back the rotation. Only
|
|
554
|
+
// retire when the binding actually moved: an agent that returns the
|
|
555
|
+
// current execution id makes rotate a no-op, and retiring it would kill
|
|
556
|
+
// the still-live execution.
|
|
557
|
+
if (retiredAgentSessionId && retiredAgentSessionId !== created.sessionId) {
|
|
558
|
+
void bridge.retireExecution?.(retiredAgentSessionId);
|
|
559
|
+
}
|
|
560
|
+
this.resetTaskRuntime(taskId, preserveRuntimeState);
|
|
561
|
+
this.store.failOutstandingDeliveriesForTaskClear(taskId);
|
|
562
|
+
this.store.updateTaskWorkflowStatus(taskId, "idle");
|
|
563
|
+
const configOptions = await this.restoreTaskConfig(bridge, taskId, created.configOptions, task);
|
|
564
|
+
return {
|
|
565
|
+
taskId,
|
|
566
|
+
configOptions: this.applyStoredConfig(configOptions, task),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Reset runtime-only state after replacing a Task's ACP execution.
|
|
571
|
+
* The runtime state ledger is reset to defaults but keeps its seq counter
|
|
572
|
+
* (never hard-deleted): the WebAgent task survives rotation, and clients
|
|
573
|
+
* validate incremental state_patch events against a monotonic server seq.
|
|
574
|
+
*/
|
|
575
|
+
resetTaskRuntime(taskId, preserveRuntimeState = false) {
|
|
576
|
+
this.assistantBuffers.delete(taskId);
|
|
577
|
+
this.thinkingBuffers.delete(taskId);
|
|
578
|
+
this.activePrompts.delete(taskId);
|
|
579
|
+
const pendingSubmission = this.pendingPromptSubmissions.get(taskId);
|
|
580
|
+
this.pendingPromptSubmissions.delete(taskId);
|
|
581
|
+
if (pendingSubmission !== undefined) {
|
|
582
|
+
this.cancelledPromptSubmissions.delete(pendingSubmission);
|
|
583
|
+
}
|
|
584
|
+
this.runningBashProcs.delete(taskId);
|
|
585
|
+
this.agentCommandSnapshots.delete(taskId);
|
|
586
|
+
for (const [requestId, permission] of this.pendingPermissions) {
|
|
587
|
+
if (permission.taskId === taskId) {
|
|
588
|
+
this.pendingPermissions.delete(requestId);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (!preserveRuntimeState)
|
|
592
|
+
this.state.reset(taskId);
|
|
593
|
+
}
|
|
594
|
+
/** Bind an ACP execution to the reserved Root record after bridge startup. */
|
|
595
|
+
async ensureRootTask(bridge) {
|
|
596
|
+
const root = this.store.getTaskIncludingDeleted(ROOT_TASK_ID);
|
|
597
|
+
if (!root || this.store.getAgentSessionId(ROOT_TASK_ID))
|
|
598
|
+
return;
|
|
599
|
+
const execution = this.buildMcpServerForExecution(ROOT_TASK_ID, false);
|
|
600
|
+
const created = await this.createAgentSession(bridge, ROOT_TASK_ID, root.cwd, this.buildNewTaskOptions(undefined, execution?.servers), execution?.token);
|
|
601
|
+
try {
|
|
602
|
+
this.store.bindAgentSession(ROOT_TASK_ID, created.sessionId);
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
bridge.discardUnboundSession?.(created.sessionId);
|
|
606
|
+
this.creatingTasks.delete(ROOT_TASK_ID);
|
|
607
|
+
if (execution?.token)
|
|
608
|
+
this.capabilities?.revoke(execution.token);
|
|
609
|
+
throw error;
|
|
610
|
+
}
|
|
611
|
+
this.liveTasks.add(ROOT_TASK_ID);
|
|
612
|
+
this.creatingTasks.delete(ROOT_TASK_ID);
|
|
613
|
+
this.recordConfigOptions(ROOT_TASK_ID, created.configOptions);
|
|
614
|
+
bridge.sessionMapped?.(created.sessionId);
|
|
615
|
+
}
|
|
616
|
+
/** Reapply the stable Task config to a newly created ACP execution. */
|
|
617
|
+
async restoreTaskConfig(bridge, taskId, configOptions, task) {
|
|
618
|
+
const values = [
|
|
619
|
+
{ id: configOptionId(configOptions, "mode"), value: task.mode },
|
|
620
|
+
{
|
|
621
|
+
id: configOptionId(configOptions, "thinking"),
|
|
622
|
+
value: task.reasoning_effort,
|
|
623
|
+
},
|
|
624
|
+
{ id: configOptionId(configOptions, "model"), value: task.model },
|
|
625
|
+
];
|
|
626
|
+
let updated = configOptions;
|
|
627
|
+
for (const { id, value } of values) {
|
|
628
|
+
if (!id || !value)
|
|
629
|
+
continue;
|
|
630
|
+
try {
|
|
631
|
+
const next = await bridge.setConfigOption(taskId, id, value);
|
|
632
|
+
if (next.length > 0) {
|
|
633
|
+
updated = next;
|
|
634
|
+
this.cachedConfigOptions = next;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
// An option may no longer be supported by the current agent.
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return updated;
|
|
642
|
+
}
|
|
643
|
+
/** Cache the ACP config schema and persist this task's current values. */
|
|
644
|
+
recordConfigOptions(taskId, configOptions) {
|
|
645
|
+
if (configOptions.length === 0)
|
|
646
|
+
return;
|
|
647
|
+
this.cachedConfigOptions = configOptions;
|
|
648
|
+
for (const option of configOptions) {
|
|
649
|
+
if (typeof option.currentValue === "string") {
|
|
650
|
+
this.store.updateTaskConfig(taskId, option.id, option.currentValue);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Materialize a pending inbox message as a real ACP-backed task.
|
|
656
|
+
* Returns null when the message is neither pending nor previously consumed.
|
|
657
|
+
*/
|
|
658
|
+
consumeMessage(bridge, messageId, inheritFromTaskId) {
|
|
659
|
+
const pending = this.pendingMessageConsumes.get(messageId);
|
|
660
|
+
if (pending)
|
|
661
|
+
return pending;
|
|
662
|
+
const existing = this.store.findConsumedMessageTask(messageId);
|
|
663
|
+
if (existing) {
|
|
664
|
+
return Promise.resolve({
|
|
665
|
+
taskId: existing,
|
|
666
|
+
alreadyConsumed: true,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
const operation = this.consumePendingMessage(bridge, messageId, inheritFromTaskId).finally(() => {
|
|
670
|
+
if (this.pendingMessageConsumes.get(messageId) === operation) {
|
|
671
|
+
this.pendingMessageConsumes.delete(messageId);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
this.pendingMessageConsumes.set(messageId, operation);
|
|
675
|
+
return operation;
|
|
676
|
+
}
|
|
677
|
+
async cleanupCreatedTask(bridge, taskId, messageId) {
|
|
678
|
+
try {
|
|
679
|
+
await this.deleteTask(bridge, taskId);
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
// Cleanup is best-effort. A concurrent reset may already have removed
|
|
683
|
+
// the task; never replace the message-consume result with that error.
|
|
684
|
+
slog.warn("failed to clean up temporary message task", {
|
|
685
|
+
messageId,
|
|
686
|
+
taskId,
|
|
687
|
+
error,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
async consumePendingMessage(bridge, messageId, inheritFromTaskId) {
|
|
692
|
+
const message = this.store.getMessage(messageId);
|
|
693
|
+
if (!message)
|
|
694
|
+
return null;
|
|
695
|
+
const { taskId } = await this.createTask(bridge, message.cwd ?? undefined, inheritFromTaskId, "message", { silent: true });
|
|
696
|
+
try {
|
|
697
|
+
const result = this.store.consumeMessageTx(messageId, taskId);
|
|
698
|
+
if (result.alreadyConsumed) {
|
|
699
|
+
await this.cleanupCreatedTask(bridge, taskId, messageId);
|
|
700
|
+
}
|
|
701
|
+
return result;
|
|
702
|
+
}
|
|
703
|
+
catch (err) {
|
|
704
|
+
await this.cleanupCreatedTask(bridge, taskId, messageId);
|
|
705
|
+
if (err instanceof MessageNotFoundError) {
|
|
706
|
+
const consumedTaskId = this.store.findConsumedMessageTask(messageId);
|
|
707
|
+
return consumedTaskId
|
|
708
|
+
? { taskId: consumedTaskId, alreadyConsumed: true }
|
|
709
|
+
: null;
|
|
710
|
+
}
|
|
711
|
+
slog.warn("message consume left an unreachable ACP task", {
|
|
712
|
+
messageId,
|
|
713
|
+
taskId,
|
|
714
|
+
error: err,
|
|
715
|
+
});
|
|
716
|
+
throw err;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/** Resume a task — returns event to send to the requesting client. */
|
|
720
|
+
async resumeTask(bridge, taskId) {
|
|
721
|
+
const task = this.store.getTask(taskId);
|
|
722
|
+
if (!task)
|
|
723
|
+
throw new Error("Task not found");
|
|
724
|
+
if (this.liveTasks.has(taskId)) {
|
|
725
|
+
// Task already live — build configOptions with stored overrides
|
|
726
|
+
const configOptions = this.buildConfigOptions(task);
|
|
727
|
+
return {
|
|
728
|
+
type: "task_created",
|
|
729
|
+
taskId,
|
|
730
|
+
cwd: task.cwd,
|
|
731
|
+
cwdDisplay: abbreviateHomePath(task.cwd),
|
|
732
|
+
title: task.title,
|
|
733
|
+
configOptions,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
// Restore via ACP
|
|
737
|
+
this.restoringTasks.add(taskId);
|
|
738
|
+
try {
|
|
739
|
+
// Mint a fresh capability for the restored task: any token from
|
|
740
|
+
// before a restart is gone with the old process, and the task is
|
|
741
|
+
// still live, so it gets an MCP server entry like a new task does.
|
|
742
|
+
const mcpServers = this.buildMcpServers(taskId);
|
|
743
|
+
const loaded = await bridge.loadSession(taskId, task.cwd, mcpServers);
|
|
744
|
+
this.liveTasks.add(taskId);
|
|
745
|
+
// Piggyback a cache-warming setConfigOption on the user's own resume
|
|
746
|
+
// when the global cache is empty (typical after bridge.restart). Uses
|
|
747
|
+
// the task's own stored value — idempotent, no side effect. Failure
|
|
748
|
+
// is swallowed: the resume still succeeds and the frontend falls back
|
|
749
|
+
// to snapshot-based mode/model display (see public/js/state.ts).
|
|
750
|
+
await this.tryWarmCache(bridge, taskId, task, loaded.configOptions);
|
|
751
|
+
const configOptions = this.applyStoredConfig(loaded.configOptions.length > 0
|
|
752
|
+
? loaded.configOptions
|
|
753
|
+
: this.cachedConfigOptions, task);
|
|
754
|
+
slog.info("restored", { taskId: taskId.slice(0, 8) + "…" });
|
|
755
|
+
return {
|
|
756
|
+
type: "task_created",
|
|
757
|
+
taskId,
|
|
758
|
+
cwd: task.cwd,
|
|
759
|
+
cwdDisplay: abbreviateHomePath(task.cwd),
|
|
760
|
+
title: task.title,
|
|
761
|
+
configOptions,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
catch (err) {
|
|
765
|
+
this.capabilities?.revokeByTask(taskId);
|
|
766
|
+
slog.error("restore failed", { error: err });
|
|
767
|
+
throw err;
|
|
768
|
+
}
|
|
769
|
+
finally {
|
|
770
|
+
this.restoringTasks.delete(taskId);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* When cachedConfigOptions is empty, use the task's own stored config
|
|
775
|
+
* value to trigger a setConfigOption. The agent's response carries the
|
|
776
|
+
* full ConfigOption[] schema (options lists + in-memory currentValues),
|
|
777
|
+
* which we cache. Writes **only** the global cache, never the task's
|
|
778
|
+
* DB row — setConfigOption's currentValue for unrelated keys is the
|
|
779
|
+
* agent's in-memory default, not the user's preference.
|
|
780
|
+
*
|
|
781
|
+
* Key priority mode > thinking aliases > model:
|
|
782
|
+
* - mode is a small stable enum, rewriting current value is idempotent.
|
|
783
|
+
* - ACP agents use both reasoning_effort and thought_level for thinking.
|
|
784
|
+
* - model has the highest schema-drift risk (agent upgrades drop values).
|
|
785
|
+
*/
|
|
786
|
+
async tryWarmCache(bridge, taskId, task, configOptions) {
|
|
787
|
+
if (this.cachedConfigOptions.length > 0)
|
|
788
|
+
return;
|
|
789
|
+
const candidates = [];
|
|
790
|
+
const modeId = configOptionId(configOptions, "mode");
|
|
791
|
+
const thinkingId = configOptionId(configOptions, "thinking");
|
|
792
|
+
const modelId = configOptionId(configOptions, "model");
|
|
793
|
+
if (modeId && task.mode)
|
|
794
|
+
candidates.push({ id: modeId, value: task.mode });
|
|
795
|
+
if (thinkingId && task.reasoning_effort) {
|
|
796
|
+
candidates.push({ id: thinkingId, value: task.reasoning_effort });
|
|
797
|
+
}
|
|
798
|
+
if (modelId && task.model)
|
|
799
|
+
candidates.push({ id: modelId, value: task.model });
|
|
800
|
+
if (candidates.length === 0)
|
|
801
|
+
return;
|
|
802
|
+
let lastError = null;
|
|
803
|
+
for (const candidate of candidates) {
|
|
804
|
+
try {
|
|
805
|
+
const opts = await bridge.setConfigOption(taskId, candidate.id, candidate.value);
|
|
806
|
+
if (opts.length > 0) {
|
|
807
|
+
this.cachedConfigOptions = opts;
|
|
808
|
+
slog.info("warmed cache on resume", { options: opts.length });
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
catch (err) {
|
|
813
|
+
lastError = err;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (lastError) {
|
|
817
|
+
slog.warn("cache warming failed", {
|
|
818
|
+
taskId: taskId.slice(0, 8) + "…",
|
|
819
|
+
error: lastError,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Ensure a task is resumed (live in ACP). Deduplicates concurrent calls.
|
|
825
|
+
* Unlike resumeTask(), this is fire-and-forget safe — callers that only
|
|
826
|
+
* need the task alive (but not the event payload) can await this.
|
|
827
|
+
*/
|
|
828
|
+
async ensureResumed(bridge, taskId) {
|
|
829
|
+
if (this.liveTasks.has(taskId))
|
|
830
|
+
return;
|
|
831
|
+
const existing = this.pendingResumes.get(taskId);
|
|
832
|
+
if (existing)
|
|
833
|
+
return existing;
|
|
834
|
+
const p = this.resumeTask(bridge, taskId)
|
|
835
|
+
.then(() => { })
|
|
836
|
+
.finally(() => this.pendingResumes.delete(taskId));
|
|
837
|
+
this.pendingResumes.set(taskId, p);
|
|
838
|
+
return p;
|
|
839
|
+
}
|
|
840
|
+
/** Build configOptions from cache, overriding currentValue with stored task values. */
|
|
841
|
+
buildConfigOptions(task) {
|
|
842
|
+
return this.applyStoredConfig(this.cachedConfigOptions, task);
|
|
843
|
+
}
|
|
844
|
+
/** Override currentValue in configOptions with stored task values. */
|
|
845
|
+
applyStoredConfig(configOptions, task) {
|
|
846
|
+
if (!configOptions.length)
|
|
847
|
+
return configOptions;
|
|
848
|
+
const stored = {
|
|
849
|
+
model: task.model,
|
|
850
|
+
mode: task.mode,
|
|
851
|
+
reasoning_effort: task.reasoning_effort,
|
|
852
|
+
thought_level: task.reasoning_effort,
|
|
853
|
+
};
|
|
854
|
+
return configOptions.map((opt) => {
|
|
855
|
+
const override = opt.category === "thought_level"
|
|
856
|
+
? task.reasoning_effort
|
|
857
|
+
: stored[opt.id];
|
|
858
|
+
if (override && "options" in opt)
|
|
859
|
+
return { ...opt, currentValue: override };
|
|
860
|
+
return opt;
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Lazy-build and return the attachment label map for a task.
|
|
865
|
+
* Used by both egress chokepoints (SSE broadcast + replay helper)
|
|
866
|
+
* to translate uuid paths into `<name> [#<id4>]` labels.
|
|
867
|
+
*
|
|
868
|
+
* Cached; call `invalidateLabelCache(sid)` after any attachments
|
|
869
|
+
* INSERT/DELETE to force rebuild. Restart-safe: empty on cold
|
|
870
|
+
* start, rebuilt on first egress per task.
|
|
871
|
+
*/
|
|
872
|
+
getLabelMap(taskId) {
|
|
873
|
+
const hit = this.attachmentLabelCache.get(taskId);
|
|
874
|
+
if (hit)
|
|
875
|
+
return hit;
|
|
876
|
+
const map = buildLabelMap(this.store.listAttachmentLabels(taskId));
|
|
877
|
+
this.attachmentLabelCache.set(taskId, map);
|
|
878
|
+
return map;
|
|
879
|
+
}
|
|
880
|
+
/** Invalidate label cache for a task (call after attachment write). */
|
|
881
|
+
invalidateLabelCache(taskId) {
|
|
882
|
+
this.attachmentLabelCache.delete(taskId);
|
|
883
|
+
}
|
|
884
|
+
updateAgentCommands(taskId, commands) {
|
|
885
|
+
const current = this.agentCommandSnapshots.get(taskId);
|
|
886
|
+
const snapshot = {
|
|
887
|
+
epoch: this.agentCommandEpoch,
|
|
888
|
+
revision: (current?.revision ?? 0) + 1,
|
|
889
|
+
commands: commands.map((command) => ({
|
|
890
|
+
...command,
|
|
891
|
+
...(command.input ? { input: { ...command.input } } : {}),
|
|
892
|
+
})),
|
|
893
|
+
};
|
|
894
|
+
this.agentCommandSnapshots.set(taskId, snapshot);
|
|
895
|
+
return snapshot;
|
|
896
|
+
}
|
|
897
|
+
getAgentCommands(taskId) {
|
|
898
|
+
return (this.agentCommandSnapshots.get(taskId) ?? {
|
|
899
|
+
epoch: this.agentCommandEpoch,
|
|
900
|
+
revision: 0,
|
|
901
|
+
commands: [],
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
clearAgentCommands() {
|
|
905
|
+
const cleared = [];
|
|
906
|
+
for (const [taskId, current] of this.agentCommandSnapshots) {
|
|
907
|
+
const snapshot = {
|
|
908
|
+
epoch: this.agentCommandEpoch,
|
|
909
|
+
revision: current.revision + 1,
|
|
910
|
+
commands: [],
|
|
911
|
+
};
|
|
912
|
+
this.agentCommandSnapshots.set(taskId, snapshot);
|
|
913
|
+
cleared.push({
|
|
914
|
+
taskId,
|
|
915
|
+
...snapshot,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
return cleared;
|
|
919
|
+
}
|
|
920
|
+
/** Reset Root's execution and delete its entire descendant tree. Root's
|
|
921
|
+
* stable row remains as the task-tree anchor; its own history and
|
|
922
|
+
* attachments are cleared. */
|
|
923
|
+
async resetRootTask(bridge) {
|
|
924
|
+
if (this.resettingTasks.has(ROOT_TASK_ID)) {
|
|
925
|
+
throw new Error("Root task is already being reset");
|
|
926
|
+
}
|
|
927
|
+
if (this.store.hasActiveShare(ROOT_TASK_ID)) {
|
|
928
|
+
throw new Error("Root task has an active share");
|
|
929
|
+
}
|
|
930
|
+
// Mark the currently known tree before waiting for the lock. New tasks
|
|
931
|
+
// cannot be created while the exclusive Root request is queued, because
|
|
932
|
+
// every create request takes a shared lock on its ancestors.
|
|
933
|
+
const protectedIds = [
|
|
934
|
+
ROOT_TASK_ID,
|
|
935
|
+
...this.store.getDescendantTaskIds(ROOT_TASK_ID),
|
|
936
|
+
];
|
|
937
|
+
const busyBeforeReset = protectedIds.find((id) => this.getActiveTaskWorkKind(id) !== null);
|
|
938
|
+
if (busyBeforeReset) {
|
|
939
|
+
throw new TaskBusyError(busyBeforeReset, this.store.getTaskPath(busyBeforeReset));
|
|
940
|
+
}
|
|
941
|
+
const protectedSet = new Set(protectedIds);
|
|
942
|
+
let resolveReset;
|
|
943
|
+
const resetCompletion = new Promise((resolve) => {
|
|
944
|
+
resolveReset = resolve;
|
|
945
|
+
});
|
|
946
|
+
this.rootResetPromise = resetCompletion;
|
|
947
|
+
for (const id of protectedIds)
|
|
948
|
+
this.resettingTasks.add(id);
|
|
949
|
+
let release = null;
|
|
950
|
+
try {
|
|
951
|
+
release = await this.acquireTaskTreeMutationLock(ROOT_TASK_ID);
|
|
952
|
+
// A branch mutation that was already holding its lock may have
|
|
953
|
+
// persisted a new descendant while Root was waiting. Re-snapshot after
|
|
954
|
+
// acquiring X(Root), reject any work that began on such a task, and
|
|
955
|
+
// only then extend the reset barrier before the first await.
|
|
956
|
+
const currentIds = [
|
|
957
|
+
ROOT_TASK_ID,
|
|
958
|
+
...this.store.getDescendantTaskIds(ROOT_TASK_ID),
|
|
959
|
+
];
|
|
960
|
+
const busyTask = currentIds.find((id) => this.getActiveTaskWorkKind(id) !== null);
|
|
961
|
+
if (busyTask) {
|
|
962
|
+
throw new TaskBusyError(busyTask, this.store.getTaskPath(busyTask));
|
|
963
|
+
}
|
|
964
|
+
const newIds = currentIds.filter((id) => !protectedSet.has(id));
|
|
965
|
+
for (const id of newIds) {
|
|
966
|
+
protectedSet.add(id);
|
|
967
|
+
protectedIds.push(id);
|
|
968
|
+
this.resettingTasks.add(id);
|
|
969
|
+
}
|
|
970
|
+
await this.clearTask(bridge, ROOT_TASK_ID);
|
|
971
|
+
const result = this.store.resetRootTask();
|
|
972
|
+
for (const entry of result.affected) {
|
|
973
|
+
if (entry.agentSessionId)
|
|
974
|
+
void bridge.retireExecution?.(entry.agentSessionId);
|
|
975
|
+
this.releaseTaskRuntime(entry.id, entry.mode);
|
|
976
|
+
}
|
|
977
|
+
this.attachmentLabelCache.delete(ROOT_TASK_ID);
|
|
978
|
+
await rm(join(this.dataDir, "tasks", ROOT_TASK_ID), {
|
|
979
|
+
recursive: true,
|
|
980
|
+
force: true,
|
|
981
|
+
}).catch(() => { });
|
|
982
|
+
return result;
|
|
983
|
+
}
|
|
984
|
+
finally {
|
|
985
|
+
for (const id of protectedIds)
|
|
986
|
+
this.resettingTasks.delete(id);
|
|
987
|
+
release?.();
|
|
988
|
+
if (this.rootResetPromise === resetCompletion) {
|
|
989
|
+
this.rootResetPromise = null;
|
|
990
|
+
resolveReset();
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
/** Delete a task from store and clean up all state (including images).
|
|
995
|
+
* Descendant tasks are deleted too (direct cascade, no confirmation yet
|
|
996
|
+
* — a tree UI will add one). Every affected task's ACP execution is
|
|
997
|
+
* retired explicitly and hard-deleted image directories are removed.
|
|
998
|
+
* Returns the store's affected list so callers can broadcast
|
|
999
|
+
* `task_deleted` for each removed task. */
|
|
1000
|
+
async deleteTask(bridge, taskId) {
|
|
1001
|
+
const release = await this.acquireTaskTreeMutationLock(taskId);
|
|
1002
|
+
const task = this.store.getTask(taskId);
|
|
1003
|
+
if (!task) {
|
|
1004
|
+
release();
|
|
1005
|
+
throw new TaskNotFoundError(taskId);
|
|
1006
|
+
}
|
|
1007
|
+
const affectedIds = [taskId, ...this.store.getDescendantTaskIds(taskId)];
|
|
1008
|
+
const busyTask = affectedIds.find((id) => this.getBusyKind(id) !== null);
|
|
1009
|
+
if (busyTask) {
|
|
1010
|
+
release();
|
|
1011
|
+
throw new TaskBusyError(busyTask, this.store.getTaskPath(busyTask));
|
|
1012
|
+
}
|
|
1013
|
+
try {
|
|
1014
|
+
const result = this.store.deleteTask(taskId);
|
|
1015
|
+
for (const entry of result.affected) {
|
|
1016
|
+
if (entry.agentSessionId)
|
|
1017
|
+
void bridge?.retireExecution?.(entry.agentSessionId);
|
|
1018
|
+
this.releaseTaskRuntime(entry.id, entry.mode);
|
|
1019
|
+
}
|
|
1020
|
+
return result;
|
|
1021
|
+
}
|
|
1022
|
+
finally {
|
|
1023
|
+
release();
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
/** Drop in-memory state owned by a task that no longer exists. */
|
|
1027
|
+
releaseTaskRuntime(id, mode) {
|
|
1028
|
+
this.capabilities?.revokeByTask(id);
|
|
1029
|
+
this.liveTasks.delete(id);
|
|
1030
|
+
this.assistantBuffers.delete(id);
|
|
1031
|
+
this.thinkingBuffers.delete(id);
|
|
1032
|
+
this.activePrompts.delete(id);
|
|
1033
|
+
this.compactingTasks.delete(id);
|
|
1034
|
+
this.resettingTasks.delete(id);
|
|
1035
|
+
this.rotatingTasks.delete(id);
|
|
1036
|
+
const pendingSubmission = this.pendingPromptSubmissions.get(id);
|
|
1037
|
+
this.pendingPromptSubmissions.delete(id);
|
|
1038
|
+
if (pendingSubmission !== undefined) {
|
|
1039
|
+
this.cancelledPromptSubmissions.delete(pendingSubmission);
|
|
1040
|
+
}
|
|
1041
|
+
this.runningBashProcs.delete(id);
|
|
1042
|
+
this.attachmentLabelCache.delete(id);
|
|
1043
|
+
this.agentCommandSnapshots.delete(id);
|
|
1044
|
+
// Clean pending permissions for this task
|
|
1045
|
+
for (const [reqId, perm] of this.pendingPermissions) {
|
|
1046
|
+
if (perm.taskId === id)
|
|
1047
|
+
this.pendingPermissions.delete(reqId);
|
|
1048
|
+
}
|
|
1049
|
+
this.state.delete(id);
|
|
1050
|
+
if (mode === "hard") {
|
|
1051
|
+
// Tombstoned tasks keep their attachments alive for the share viewer
|
|
1052
|
+
// (shared files still resolve via /s/:token/attachments/...). The reap
|
|
1053
|
+
// path in share/routes.ts removes them once the last share is gone.
|
|
1054
|
+
rm(join(this.dataDir, "tasks", id), {
|
|
1055
|
+
recursive: true,
|
|
1056
|
+
force: true,
|
|
1057
|
+
}).catch(() => { });
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
/** Flush assistant/thinking buffers to store. */
|
|
1061
|
+
flushBuffers(taskId) {
|
|
1062
|
+
this.flushAssistantBuffer(taskId);
|
|
1063
|
+
this.flushThinkingBuffer(taskId);
|
|
1064
|
+
}
|
|
1065
|
+
/** Flush only the assistant message buffer to store. */
|
|
1066
|
+
flushAssistantBuffer(taskId) {
|
|
1067
|
+
const assistant = this.assistantBuffers.get(taskId);
|
|
1068
|
+
this.assistantBuffers.delete(taskId);
|
|
1069
|
+
if (assistant && this.store.getTask(taskId)) {
|
|
1070
|
+
this.store.saveEvent(taskId, "assistant_message", { text: assistant }, { from_ref: "agent" });
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
/** Flush only the thinking buffer to store. */
|
|
1074
|
+
flushThinkingBuffer(taskId) {
|
|
1075
|
+
const thinking = this.thinkingBuffers.get(taskId);
|
|
1076
|
+
this.thinkingBuffers.delete(taskId);
|
|
1077
|
+
if (thinking && this.store.getTask(taskId)) {
|
|
1078
|
+
this.store.saveEvent(taskId, "thinking", { text: thinking }, { from_ref: "agent" });
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
/** Append to assistant message buffer. */
|
|
1082
|
+
appendAssistant(taskId, text) {
|
|
1083
|
+
const buf = (this.assistantBuffers.get(taskId) ?? "") + text;
|
|
1084
|
+
this.assistantBuffers.set(taskId, buf);
|
|
1085
|
+
}
|
|
1086
|
+
/** Append to thinking buffer. */
|
|
1087
|
+
appendThinking(taskId, text) {
|
|
1088
|
+
const buf = (this.thinkingBuffers.get(taskId) ?? "") + text;
|
|
1089
|
+
this.thinkingBuffers.set(taskId, buf);
|
|
1090
|
+
}
|
|
1091
|
+
/** Get CWD for a task (falls back to default). */
|
|
1092
|
+
getTaskCwd(taskId) {
|
|
1093
|
+
return this.store.getTask(taskId)?.cwd ?? this.defaultCwd;
|
|
1094
|
+
}
|
|
1095
|
+
getBusyKind(taskId) {
|
|
1096
|
+
if (this.creatingTasks.has(taskId))
|
|
1097
|
+
return "agent";
|
|
1098
|
+
if (this.pendingPromptSubmissions.has(taskId))
|
|
1099
|
+
return "agent";
|
|
1100
|
+
if (this.activePrompts.has(taskId))
|
|
1101
|
+
return "agent";
|
|
1102
|
+
if (this.compactingTasks.has(taskId))
|
|
1103
|
+
return "agent";
|
|
1104
|
+
if (this.resettingTasks.has(taskId))
|
|
1105
|
+
return "agent";
|
|
1106
|
+
return this.getActiveTaskWorkKind(taskId);
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* True when `promptId` still names the task's live turn. A turn can
|
|
1110
|
+
* outlive its own supertask — cancelling one and immediately starting
|
|
1111
|
+
* another interleaves them — and its terminal work must not clear state
|
|
1112
|
+
* that now belongs to the replacement. An absent id is treated as current
|
|
1113
|
+
* so callers predating turn identity keep their old behaviour.
|
|
1114
|
+
*/
|
|
1115
|
+
isCurrentPrompt(taskId, promptId) {
|
|
1116
|
+
if (!promptId)
|
|
1117
|
+
return true;
|
|
1118
|
+
const current = this.state.getState(taskId).runtime.busy?.promptId;
|
|
1119
|
+
return current == null || current === promptId;
|
|
1120
|
+
}
|
|
1121
|
+
reservePromptSubmission(taskId) {
|
|
1122
|
+
if (this.getBusyKind(taskId) !== null)
|
|
1123
|
+
return null;
|
|
1124
|
+
const submissionId = ++this.nextPromptSubmissionNumber;
|
|
1125
|
+
this.pendingPromptSubmissions.set(taskId, submissionId);
|
|
1126
|
+
return submissionId;
|
|
1127
|
+
}
|
|
1128
|
+
cancelPendingPromptSubmission(taskId) {
|
|
1129
|
+
const submissionId = this.pendingPromptSubmissions.get(taskId);
|
|
1130
|
+
if (submissionId === undefined)
|
|
1131
|
+
return false;
|
|
1132
|
+
this.cancelledPromptSubmissions.add(submissionId);
|
|
1133
|
+
return true;
|
|
1134
|
+
}
|
|
1135
|
+
/** Cancel the current execution without deleting or retiring the Task. */
|
|
1136
|
+
// eslint-disable-next-line complexity -- preserves the cancel race/state machine in one operation.
|
|
1137
|
+
async cancelTaskExecution(taskId, bridge, cancelTimeoutMs) {
|
|
1138
|
+
const hadAgentPrompt = this.activePrompts.has(taskId);
|
|
1139
|
+
const hadPendingPrompt = this.cancelPendingPromptSubmission(taskId);
|
|
1140
|
+
const hadBash = this.runningBashProcs.has(taskId);
|
|
1141
|
+
if (!hadAgentPrompt && !hadPendingPrompt && !hadBash) {
|
|
1142
|
+
return { status: "idle", workPending: false };
|
|
1143
|
+
}
|
|
1144
|
+
const cancelledPromptId = hadAgentPrompt
|
|
1145
|
+
? (this.state.getState(taskId).runtime.busy?.promptId ?? null)
|
|
1146
|
+
: null;
|
|
1147
|
+
const proc = this.runningBashProcs.get(taskId);
|
|
1148
|
+
if (proc) {
|
|
1149
|
+
const force = this.interruptedBashProcs.has(proc);
|
|
1150
|
+
interruptBashProc(proc, force);
|
|
1151
|
+
this.interruptedBashProcs.add(proc);
|
|
1152
|
+
}
|
|
1153
|
+
if (hadAgentPrompt && !bridge)
|
|
1154
|
+
throw new AgentNotReadyError(taskId);
|
|
1155
|
+
if (hadAgentPrompt && bridge) {
|
|
1156
|
+
await bridge.cancel(taskId);
|
|
1157
|
+
const busy = this.state.getState(taskId).runtime.busy;
|
|
1158
|
+
const stillCancellingSamePrompt = this.activePrompts.has(taskId) &&
|
|
1159
|
+
busy?.kind === "agent" &&
|
|
1160
|
+
busy.promptId === cancelledPromptId;
|
|
1161
|
+
if (stillCancellingSamePrompt) {
|
|
1162
|
+
this.state.markCancelRequested(taskId);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
const busyAfterCancel = this.state.getState(taskId).runtime.busy;
|
|
1166
|
+
const cancelPending = hadAgentPrompt &&
|
|
1167
|
+
this.activePrompts.has(taskId) &&
|
|
1168
|
+
busyAfterCancel?.kind === "agent" &&
|
|
1169
|
+
busyAfterCancel.promptId === cancelledPromptId;
|
|
1170
|
+
if (cancelPending && cancelTimeoutMs > 0) {
|
|
1171
|
+
this.state.armCancelSafety(taskId, cancelTimeoutMs);
|
|
1172
|
+
}
|
|
1173
|
+
this.syncBusy(taskId);
|
|
1174
|
+
const workPending = cancelPending || hadBash;
|
|
1175
|
+
const replacementPromptActive = hadAgentPrompt &&
|
|
1176
|
+
((this.activePrompts.has(taskId) &&
|
|
1177
|
+
busyAfterCancel?.kind === "agent" &&
|
|
1178
|
+
busyAfterCancel.promptId !== cancelledPromptId) ||
|
|
1179
|
+
this.pendingPromptSubmissions.has(taskId));
|
|
1180
|
+
return {
|
|
1181
|
+
status: workPending
|
|
1182
|
+
? "cancelling"
|
|
1183
|
+
: replacementPromptActive
|
|
1184
|
+
? "superseded"
|
|
1185
|
+
: "cancelled",
|
|
1186
|
+
workPending,
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
isPromptSubmissionCancelled(submissionId) {
|
|
1190
|
+
return this.cancelledPromptSubmissions.has(submissionId);
|
|
1191
|
+
}
|
|
1192
|
+
releasePromptSubmission(taskId, submissionId, sync = true) {
|
|
1193
|
+
if (this.pendingPromptSubmissions.get(taskId) === submissionId) {
|
|
1194
|
+
this.pendingPromptSubmissions.delete(taskId);
|
|
1195
|
+
}
|
|
1196
|
+
this.cancelledPromptSubmissions.delete(submissionId);
|
|
1197
|
+
if (sync)
|
|
1198
|
+
this.syncBusy(taskId);
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Recompute busy from active prompts/bash procs and patch the state manager.
|
|
1202
|
+
* Call this immediately after mutating activePrompts / runningBashProcs so
|
|
1203
|
+
* the frontend snapshot stays in sync via `state_patch` broadcast.
|
|
1204
|
+
*
|
|
1205
|
+
* `promptId` attaches to an agent busy transition (ignored otherwise). If
|
|
1206
|
+
* omitted when staying agent-busy, the existing promptId is preserved.
|
|
1207
|
+
*/
|
|
1208
|
+
syncBusy(taskId, promptId) {
|
|
1209
|
+
const kind = this.getBusyKind(taskId);
|
|
1210
|
+
const current = this.state.getState(taskId).runtime.busy;
|
|
1211
|
+
if (kind === null) {
|
|
1212
|
+
if (current !== null)
|
|
1213
|
+
this.state.patch(taskId, { runtime: { busy: null } });
|
|
1214
|
+
// Also clear any pending cancel safety net now that we are idle.
|
|
1215
|
+
this.state.clearCancelSafety(taskId);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
const nextPromptId = kind === "agent"
|
|
1219
|
+
? (promptId ??
|
|
1220
|
+
(current?.kind === "agent"
|
|
1221
|
+
? current.promptId
|
|
1222
|
+
: `prompt-${++this.nextPromptNumber}`))
|
|
1223
|
+
: null;
|
|
1224
|
+
const sameWork = current?.kind === kind && current.promptId === nextPromptId;
|
|
1225
|
+
if (sameWork)
|
|
1226
|
+
return;
|
|
1227
|
+
this.state.patch(taskId, {
|
|
1228
|
+
runtime: {
|
|
1229
|
+
busy: {
|
|
1230
|
+
kind: kind,
|
|
1231
|
+
since: current?.kind === kind ? current.since : new Date().toISOString(),
|
|
1232
|
+
promptId: nextPromptId,
|
|
1233
|
+
cancelStatus: null,
|
|
1234
|
+
},
|
|
1235
|
+
},
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Give a collaboration task one recovery turn when its previous prompt
|
|
1240
|
+
* ended while the workflow was still running. The caller has already
|
|
1241
|
+
* transitioned the workflow back to idle; keeping this prompt out of the
|
|
1242
|
+
* running status prevents a missing handoff from triggering a reminder loop.
|
|
1243
|
+
*/
|
|
1244
|
+
async promptHandoffReminder(bridge, taskId) {
|
|
1245
|
+
if (this.getBusyKind(taskId) !== null)
|
|
1246
|
+
return false;
|
|
1247
|
+
const task = this.store.getTask(taskId);
|
|
1248
|
+
if (task?.workflow_status !== "idle")
|
|
1249
|
+
return false;
|
|
1250
|
+
this.store.saveEvent(taskId, "system_message", {
|
|
1251
|
+
kind: "handoff_reminder",
|
|
1252
|
+
title: "Task handoff required",
|
|
1253
|
+
body: HANDOFF_REMINDER_TEXT,
|
|
1254
|
+
}, { from_ref: "system" });
|
|
1255
|
+
this.activePrompts.add(taskId);
|
|
1256
|
+
this.syncBusy(taskId);
|
|
1257
|
+
const promptId = this.state.getState(taskId).runtime.busy?.promptId ?? undefined;
|
|
1258
|
+
try {
|
|
1259
|
+
await bridge.prompt(taskId, HANDOFF_REMINDER_TEXT, undefined, promptId);
|
|
1260
|
+
return true;
|
|
1261
|
+
}
|
|
1262
|
+
catch (error) {
|
|
1263
|
+
slog.warn("handoff reminder failed", {
|
|
1264
|
+
taskId: taskId.slice(0, 8),
|
|
1265
|
+
error,
|
|
1266
|
+
});
|
|
1267
|
+
if (this.isCurrentPrompt(taskId, promptId)) {
|
|
1268
|
+
this.activePrompts.delete(taskId);
|
|
1269
|
+
this.syncBusy(taskId);
|
|
1270
|
+
}
|
|
1271
|
+
return false;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Claim the complete queued Delivery snapshot for an idle target and submit
|
|
1276
|
+
* it as one ACP prompt. The caller never trusts a client for this decision:
|
|
1277
|
+
* Store rows, runtime busy state, and the bridge binding are authoritative.
|
|
1278
|
+
*/
|
|
1279
|
+
async drainCollaborationDeliveries(bridge, taskId) {
|
|
1280
|
+
if (this.getBusyKind(taskId) !== null)
|
|
1281
|
+
return false;
|
|
1282
|
+
// Enter a busy turn (which mints a new promptId) only when there is
|
|
1283
|
+
// something to deliver; otherwise the churn advances client turn
|
|
1284
|
+
// identity for nothing and can strand a finishing turn's terminator.
|
|
1285
|
+
if (this.store.countQueuedDeliveries(taskId) === 0)
|
|
1286
|
+
return false;
|
|
1287
|
+
this.drainingCollaborationTasks.add(taskId);
|
|
1288
|
+
this.syncBusy(taskId);
|
|
1289
|
+
try {
|
|
1290
|
+
await this.ensureResumed(bridge, taskId);
|
|
1291
|
+
const deliveries = this.store.claimQueuedDeliveries(taskId);
|
|
1292
|
+
if (deliveries.length === 0)
|
|
1293
|
+
return false;
|
|
1294
|
+
const entries = deliveries.map((delivery) => {
|
|
1295
|
+
const message = this.store.getCollaborationMessage(delivery.message_id);
|
|
1296
|
+
if (!message) {
|
|
1297
|
+
throw new Error(`Collaboration message missing: ${delivery.message_id}`);
|
|
1298
|
+
}
|
|
1299
|
+
const source = this.store.getTaskIncludingDeleted(message.source_task_id);
|
|
1300
|
+
// The full source id is the only reliable reply handle available to
|
|
1301
|
+
// the receiving agent today; label it explicitly. The quoted name is
|
|
1302
|
+
// deliberately not labeled "title" — titles change and can collide,
|
|
1303
|
+
// so the id stays the only thing presented as a precise handle. The
|
|
1304
|
+
// body is fenced verbatim between scissor lines so the agent never
|
|
1305
|
+
// reads metadata as instruction and the forwarding layer never
|
|
1306
|
+
// rewrites the content.
|
|
1307
|
+
const sourceName = source?.title ?? message.source_task_id.slice(0, 8);
|
|
1308
|
+
return `From "${sourceName}" (task id ${message.source_task_id}):\n---8<---\n${message.body}\n---8<---`;
|
|
1309
|
+
});
|
|
1310
|
+
this.store.updateTaskWorkflowStatus(taskId, "running");
|
|
1311
|
+
this.drainingCollaborationTasks.delete(taskId);
|
|
1312
|
+
this.activePrompts.add(taskId);
|
|
1313
|
+
this.syncBusy(taskId);
|
|
1314
|
+
const promptId = this.state.getState(taskId).runtime.busy?.promptId ?? undefined;
|
|
1315
|
+
void bridge
|
|
1316
|
+
.prompt(taskId, entries.join("\n\n"), undefined, promptId)
|
|
1317
|
+
.then(() => {
|
|
1318
|
+
this.store.markCollaborationDeliveriesDelivered(deliveries.map((delivery) => delivery.id));
|
|
1319
|
+
}, (error) => {
|
|
1320
|
+
slog.error("collaboration delivery failed", {
|
|
1321
|
+
taskId: taskId.slice(0, 8),
|
|
1322
|
+
error,
|
|
1323
|
+
});
|
|
1324
|
+
this.store.failCollaborationDeliveries(deliveries.map((delivery) => delivery.id), "prompt_failed");
|
|
1325
|
+
if (!this.isCurrentPrompt(taskId, promptId))
|
|
1326
|
+
return;
|
|
1327
|
+
this.activePrompts.delete(taskId);
|
|
1328
|
+
// Same attribution as an ACP error event: a rejected delivery
|
|
1329
|
+
// prompt must not leave the target marked running.
|
|
1330
|
+
this.store.updateTaskWorkflowStatus(taskId, "idle");
|
|
1331
|
+
this.syncBusy(taskId);
|
|
1332
|
+
});
|
|
1333
|
+
return true;
|
|
1334
|
+
}
|
|
1335
|
+
catch (error) {
|
|
1336
|
+
slog.error("collaboration delivery drain failed", {
|
|
1337
|
+
taskId: taskId.slice(0, 8),
|
|
1338
|
+
error,
|
|
1339
|
+
});
|
|
1340
|
+
return false;
|
|
1341
|
+
}
|
|
1342
|
+
finally {
|
|
1343
|
+
if (this.drainingCollaborationTasks.delete(taskId))
|
|
1344
|
+
this.syncBusy(taskId);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* If the task's last turn was interrupted (user_message without prompt_done),
|
|
1349
|
+
* auto-retry by prompting the agent to continue. Returns true if retrying.
|
|
1350
|
+
*/
|
|
1351
|
+
autoRetryIfNeeded(bridge, taskId) {
|
|
1352
|
+
if (this.activePrompts.has(taskId))
|
|
1353
|
+
return false;
|
|
1354
|
+
if (!this.store.hasInterruptedTurn(taskId))
|
|
1355
|
+
return false;
|
|
1356
|
+
slog.info("auto-retrying interrupted turn", {
|
|
1357
|
+
taskId: taskId.slice(0, 8) + "…",
|
|
1358
|
+
});
|
|
1359
|
+
this.activePrompts.add(taskId);
|
|
1360
|
+
this.syncBusy(taskId);
|
|
1361
|
+
const promptId = this.state.getState(taskId).runtime.busy?.promptId ?? undefined;
|
|
1362
|
+
bridge
|
|
1363
|
+
.prompt(taskId, "Continue your previous response — it was interrupted mid-way.", undefined, promptId)
|
|
1364
|
+
.catch((err) => {
|
|
1365
|
+
slog.error("auto-retry failed", {
|
|
1366
|
+
taskId: taskId.slice(0, 8) + "…",
|
|
1367
|
+
error: err,
|
|
1368
|
+
});
|
|
1369
|
+
if (!this.isCurrentPrompt(taskId, promptId))
|
|
1370
|
+
return;
|
|
1371
|
+
this.activePrompts.delete(taskId);
|
|
1372
|
+
this.syncBusy(taskId);
|
|
1373
|
+
});
|
|
1374
|
+
return true;
|
|
1375
|
+
}
|
|
1376
|
+
/** Get pending permission requests for a task (or all tasks if no id). */
|
|
1377
|
+
getPendingPermissions(taskId) {
|
|
1378
|
+
const perms = [...this.pendingPermissions.values()];
|
|
1379
|
+
return taskId ? perms.filter((p) => p.taskId === taskId) : perms;
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Re-derive runtime.pendingPermissions from the Map and push via state_patch.
|
|
1383
|
+
* Call this after every mutation of `pendingPermissions` so the frontend
|
|
1384
|
+
* snapshot stays authoritative.
|
|
1385
|
+
*/
|
|
1386
|
+
syncPendingPermissions(taskId) {
|
|
1387
|
+
const forTask = [...this.pendingPermissions.values()]
|
|
1388
|
+
.filter((p) => p.taskId === taskId)
|
|
1389
|
+
.map((p) => ({
|
|
1390
|
+
requestId: p.requestId,
|
|
1391
|
+
toolName: "",
|
|
1392
|
+
title: p.title,
|
|
1393
|
+
options: p.options.map((o) => ({
|
|
1394
|
+
optionId: o.optionId,
|
|
1395
|
+
label: o.label,
|
|
1396
|
+
})),
|
|
1397
|
+
}));
|
|
1398
|
+
this.state.patch(taskId, {
|
|
1399
|
+
runtime: { pendingPermissions: forTask },
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
/** Kill all running bash processes (for shutdown). */
|
|
1403
|
+
killAllBashProcs() {
|
|
1404
|
+
const forceSignal = process.platform === "win32" ? undefined : "SIGKILL";
|
|
1405
|
+
for (const [, proc] of this.runningBashProcs)
|
|
1406
|
+
proc.kill(forceSignal);
|
|
1407
|
+
this.runningBashProcs.clear();
|
|
1408
|
+
}
|
|
1409
|
+
}
|