@agentuity/opencode 1.0.19 → 1.0.21
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/dist/agents/expert-backend.d.ts +1 -1
- package/dist/agents/expert-backend.d.ts.map +1 -1
- package/dist/agents/expert-backend.js +0 -17
- package/dist/agents/expert-backend.js.map +1 -1
- package/dist/agents/expert.d.ts +1 -1
- package/dist/agents/expert.d.ts.map +1 -1
- package/dist/agents/expert.js +1 -1
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/index.js +0 -2
- package/dist/agents/index.js.map +1 -1
- package/dist/agents/lead.d.ts +1 -1
- package/dist/agents/lead.d.ts.map +1 -1
- package/dist/agents/lead.js +25 -145
- package/dist/agents/lead.js.map +1 -1
- package/dist/agents/scout.d.ts +1 -1
- package/dist/agents/scout.d.ts.map +1 -1
- package/dist/agents/scout.js +16 -0
- package/dist/agents/scout.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +1 -33
- package/dist/config/loader.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/plugin/hooks/cadence.d.ts +1 -2
- package/dist/plugin/hooks/cadence.d.ts.map +1 -1
- package/dist/plugin/hooks/cadence.js +7 -33
- package/dist/plugin/hooks/cadence.js.map +1 -1
- package/dist/plugin/hooks/compaction-utils.d.ts.map +1 -1
- package/dist/plugin/hooks/compaction-utils.js +6 -13
- package/dist/plugin/hooks/compaction-utils.js.map +1 -1
- package/dist/plugin/hooks/session-memory.d.ts +1 -2
- package/dist/plugin/hooks/session-memory.d.ts.map +1 -1
- package/dist/plugin/hooks/session-memory.js +6 -29
- package/dist/plugin/hooks/session-memory.js.map +1 -1
- package/dist/plugin/plugin.d.ts.map +1 -1
- package/dist/plugin/plugin.js +8 -222
- package/dist/plugin/plugin.js.map +1 -1
- package/dist/sqlite/types.d.ts +0 -6
- package/dist/sqlite/types.d.ts.map +1 -1
- package/dist/tmux/manager.d.ts +4 -4
- package/dist/tmux/manager.js +4 -4
- package/dist/tmux/types.d.ts +1 -1
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +1 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/types.d.ts +2 -20
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -9
- package/dist/types.js.map +1 -1
- package/package.json +3 -3
- package/src/agents/expert-backend.ts +0 -17
- package/src/agents/expert.ts +1 -1
- package/src/agents/index.ts +0 -2
- package/src/agents/lead.ts +25 -145
- package/src/agents/scout.ts +16 -0
- package/src/config/loader.ts +1 -45
- package/src/index.ts +0 -1
- package/src/plugin/hooks/cadence.ts +6 -39
- package/src/plugin/hooks/compaction-utils.ts +6 -12
- package/src/plugin/hooks/session-memory.ts +5 -35
- package/src/plugin/plugin.ts +7 -257
- package/src/sqlite/types.ts +0 -2
- package/src/tmux/manager.ts +4 -4
- package/src/tmux/types.ts +2 -2
- package/src/tools/index.ts +2 -9
- package/src/types.ts +0 -13
- package/dist/agents/monitor.d.ts +0 -4
- package/dist/agents/monitor.d.ts.map +0 -1
- package/dist/agents/monitor.js +0 -159
- package/dist/agents/monitor.js.map +0 -1
- package/dist/background/concurrency.d.ts +0 -36
- package/dist/background/concurrency.d.ts.map +0 -1
- package/dist/background/concurrency.js +0 -92
- package/dist/background/concurrency.js.map +0 -1
- package/dist/background/index.d.ts +0 -5
- package/dist/background/index.d.ts.map +0 -1
- package/dist/background/index.js +0 -4
- package/dist/background/index.js.map +0 -1
- package/dist/background/manager.d.ts +0 -123
- package/dist/background/manager.d.ts.map +0 -1
- package/dist/background/manager.js +0 -1075
- package/dist/background/manager.js.map +0 -1
- package/dist/background/types.d.ts +0 -90
- package/dist/background/types.d.ts.map +0 -1
- package/dist/background/types.js +0 -2
- package/dist/background/types.js.map +0 -1
- package/dist/tools/background.d.ts +0 -67
- package/dist/tools/background.d.ts.map +0 -1
- package/dist/tools/background.js +0 -95
- package/dist/tools/background.js.map +0 -1
- package/src/agents/monitor.ts +0 -161
- package/src/background/concurrency.ts +0 -116
- package/src/background/index.ts +0 -4
- package/src/background/manager.ts +0 -1215
- package/src/background/types.ts +0 -82
- package/src/tools/background.ts +0 -179
|
@@ -1,1075 +0,0 @@
|
|
|
1
|
-
import { agents } from '../agents';
|
|
2
|
-
import { ConcurrencyManager } from './concurrency';
|
|
3
|
-
const DEFAULT_BACKGROUND_CONFIG = {
|
|
4
|
-
enabled: true,
|
|
5
|
-
defaultConcurrency: 5,
|
|
6
|
-
staleTimeoutMs: 30 * 60 * 1000,
|
|
7
|
-
};
|
|
8
|
-
export class BackgroundManager {
|
|
9
|
-
ctx;
|
|
10
|
-
config;
|
|
11
|
-
concurrency;
|
|
12
|
-
callbacks;
|
|
13
|
-
dbReader;
|
|
14
|
-
serverUrl;
|
|
15
|
-
authHeaders;
|
|
16
|
-
tasks = new Map();
|
|
17
|
-
tasksByParent = new Map();
|
|
18
|
-
tasksBySession = new Map();
|
|
19
|
-
notifications = new Map();
|
|
20
|
-
toolCallIds = new Map();
|
|
21
|
-
/** Tracks tool call IDs that are currently in-flight (pending/running state) per task */
|
|
22
|
-
activeToolCallIds = new Map();
|
|
23
|
-
/** Maps parent session ID → monitor task ID for auto-launched monitors */
|
|
24
|
-
monitorsPerParent = new Map();
|
|
25
|
-
lastNotifyTimes = new Map();
|
|
26
|
-
shuttingDown = false;
|
|
27
|
-
refreshIntervalId;
|
|
28
|
-
constructor(ctx, config, callbacks, dbReader) {
|
|
29
|
-
this.ctx = ctx;
|
|
30
|
-
this.config = { ...DEFAULT_BACKGROUND_CONFIG, ...config };
|
|
31
|
-
this.concurrency = new ConcurrencyManager({
|
|
32
|
-
defaultLimit: this.config.defaultConcurrency,
|
|
33
|
-
limits: buildConcurrencyLimits(this.config),
|
|
34
|
-
});
|
|
35
|
-
this.callbacks = callbacks;
|
|
36
|
-
this.dbReader = dbReader;
|
|
37
|
-
this.serverUrl = this.resolveServerUrl();
|
|
38
|
-
this.authHeaders = this.resolveAuthHeaders();
|
|
39
|
-
// Periodic safety net: refresh task statuses every 30s in case events are missed
|
|
40
|
-
this.refreshIntervalId = setInterval(() => {
|
|
41
|
-
if (this.shuttingDown)
|
|
42
|
-
return;
|
|
43
|
-
const hasActive = Array.from(this.tasks.values()).some((t) => t.status === 'pending' || t.status === 'running');
|
|
44
|
-
if (hasActive) {
|
|
45
|
-
void this.refreshStatuses();
|
|
46
|
-
}
|
|
47
|
-
}, 30_000);
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Resolve the server URL from the plugin context.
|
|
51
|
-
* Mirrors the defensive pattern used in the tmux manager to handle
|
|
52
|
-
* sandbox environments where the client may not have a baseUrl configured.
|
|
53
|
-
*/
|
|
54
|
-
resolveServerUrl() {
|
|
55
|
-
const ctx = this.ctx;
|
|
56
|
-
const serverUrl = ctx.serverUrl ?? ctx.baseUrl ?? ctx.client?.baseUrl;
|
|
57
|
-
if (!serverUrl)
|
|
58
|
-
return undefined;
|
|
59
|
-
const urlStr = typeof serverUrl === 'string' ? serverUrl : serverUrl.toString();
|
|
60
|
-
// Strip trailing slash to prevent double-slash when SDK appends paths like /session
|
|
61
|
-
return urlStr.replace(/\/+$/, '');
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Resolve authentication headers from environment variables.
|
|
65
|
-
*
|
|
66
|
-
* Reads `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD` (set
|
|
67
|
-
* automatically by the OpenCode server in sandbox environments) and
|
|
68
|
-
* produces a Basic Auth header (`base64("username:password")`).
|
|
69
|
-
*
|
|
70
|
-
* In sandbox environments the SDK client's default auth may not carry over
|
|
71
|
-
* when a per-call `baseUrl` override is provided, so we need to explicitly
|
|
72
|
-
* attach these credentials for server-to-server requests.
|
|
73
|
-
*/
|
|
74
|
-
resolveAuthHeaders() {
|
|
75
|
-
const username = process.env.OPENCODE_SERVER_USERNAME;
|
|
76
|
-
const password = process.env.OPENCODE_SERVER_PASSWORD;
|
|
77
|
-
if (!username || !password)
|
|
78
|
-
return undefined;
|
|
79
|
-
const encoded = Buffer.from(username + ':' + password).toString('base64');
|
|
80
|
-
return { Authorization: `Basic ${encoded}` };
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Build the per-call client overrides (baseUrl + auth headers).
|
|
84
|
-
* Spread this into every SDK client call so both the server URL and
|
|
85
|
-
* authentication are correctly forwarded in sandbox environments.
|
|
86
|
-
*/
|
|
87
|
-
getClientOverrides() {
|
|
88
|
-
const overrides = {};
|
|
89
|
-
if (this.serverUrl)
|
|
90
|
-
overrides.baseUrl = this.serverUrl;
|
|
91
|
-
if (this.authHeaders)
|
|
92
|
-
overrides.headers = this.authHeaders;
|
|
93
|
-
return overrides;
|
|
94
|
-
}
|
|
95
|
-
async launch(input) {
|
|
96
|
-
const task = {
|
|
97
|
-
id: createTaskId(),
|
|
98
|
-
parentSessionId: input.parentSessionId,
|
|
99
|
-
parentMessageId: input.parentMessageId,
|
|
100
|
-
description: input.description,
|
|
101
|
-
prompt: input.prompt,
|
|
102
|
-
agent: input.agent,
|
|
103
|
-
status: 'pending',
|
|
104
|
-
queuedAt: new Date(),
|
|
105
|
-
concurrencyGroup: this.getConcurrencyGroup(input.agent),
|
|
106
|
-
notifiedStatuses: new Set(),
|
|
107
|
-
};
|
|
108
|
-
this.tasks.set(task.id, task);
|
|
109
|
-
this.indexTask(task);
|
|
110
|
-
if (!this.config.enabled) {
|
|
111
|
-
task.status = 'error';
|
|
112
|
-
task.error = 'Background tasks are disabled.';
|
|
113
|
-
task.completedAt = new Date();
|
|
114
|
-
this.markForNotification(task);
|
|
115
|
-
return task;
|
|
116
|
-
}
|
|
117
|
-
void this.startTask(task);
|
|
118
|
-
// Auto-launch a Monitor for this parent session if not already running.
|
|
119
|
-
// Monitor uses session_dashboard scoped to the parent session ID, so it only
|
|
120
|
-
// sees sibling tasks — not unrelated sessions across the server.
|
|
121
|
-
void this.ensureMonitorForParent(input.parentSessionId);
|
|
122
|
-
return task;
|
|
123
|
-
}
|
|
124
|
-
getTask(id) {
|
|
125
|
-
return this.tasks.get(id);
|
|
126
|
-
}
|
|
127
|
-
getTasksByParent(sessionId) {
|
|
128
|
-
const ids = this.tasksByParent.get(sessionId);
|
|
129
|
-
if (!ids)
|
|
130
|
-
return [];
|
|
131
|
-
return Array.from(ids)
|
|
132
|
-
.map((id) => this.tasks.get(id))
|
|
133
|
-
.filter((task) => Boolean(task));
|
|
134
|
-
}
|
|
135
|
-
findBySession(sessionId) {
|
|
136
|
-
const taskId = this.tasksBySession.get(sessionId);
|
|
137
|
-
if (!taskId)
|
|
138
|
-
return undefined;
|
|
139
|
-
return this.tasks.get(taskId);
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Inspect a background task by fetching its session messages.
|
|
143
|
-
* Useful for seeing what a child Lead or other agent is doing.
|
|
144
|
-
*/
|
|
145
|
-
async inspectTask(taskId) {
|
|
146
|
-
const task = this.tasks.get(taskId);
|
|
147
|
-
if (!task)
|
|
148
|
-
return undefined;
|
|
149
|
-
// Task exists but has not yet acquired a concurrency slot — it is queued
|
|
150
|
-
// and no session has been created yet. Return a lightweight inspection so
|
|
151
|
-
// callers can distinguish "queued/pending" from "not found".
|
|
152
|
-
if (!task.sessionId) {
|
|
153
|
-
return {
|
|
154
|
-
taskId: task.id,
|
|
155
|
-
sessionId: '',
|
|
156
|
-
status: task.status,
|
|
157
|
-
session: null,
|
|
158
|
-
messages: [],
|
|
159
|
-
lastActivity: task.queuedAt?.toISOString(),
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
try {
|
|
163
|
-
if (this.dbReader?.isAvailable()) {
|
|
164
|
-
const session = this.dbReader.getSession(task.sessionId);
|
|
165
|
-
const messageCount = this.dbReader.getMessageCount(task.sessionId);
|
|
166
|
-
const messageLimit = messageCount > 0 ? messageCount : 100;
|
|
167
|
-
const messageRows = this.dbReader.getMessages(task.sessionId, {
|
|
168
|
-
limit: messageLimit,
|
|
169
|
-
offset: 0,
|
|
170
|
-
});
|
|
171
|
-
const textParts = this.dbReader.getTextParts(task.sessionId, {
|
|
172
|
-
limit: Math.max(messageLimit * 5, 200),
|
|
173
|
-
});
|
|
174
|
-
const partsByMessage = groupTextPartsByMessage(textParts);
|
|
175
|
-
const messages = messageRows
|
|
176
|
-
.sort((a, b) => a.timeCreated - b.timeCreated)
|
|
177
|
-
.map((message) => ({
|
|
178
|
-
info: {
|
|
179
|
-
role: message.role,
|
|
180
|
-
agent: message.agent,
|
|
181
|
-
model: message.model,
|
|
182
|
-
cost: message.cost,
|
|
183
|
-
tokens: message.tokens,
|
|
184
|
-
error: message.error,
|
|
185
|
-
timeCreated: message.timeCreated,
|
|
186
|
-
timeUpdated: message.timeUpdated,
|
|
187
|
-
},
|
|
188
|
-
parts: buildTextParts(partsByMessage.get(message.id)),
|
|
189
|
-
}));
|
|
190
|
-
const activeTools = this.dbReader.getActiveToolCalls(task.sessionId).map((tool) => ({
|
|
191
|
-
tool: tool.tool,
|
|
192
|
-
status: tool.status,
|
|
193
|
-
callId: tool.callId,
|
|
194
|
-
}));
|
|
195
|
-
const todos = this.dbReader.getTodos(task.sessionId).map((todo) => ({
|
|
196
|
-
content: todo.content,
|
|
197
|
-
status: todo.status,
|
|
198
|
-
priority: todo.priority,
|
|
199
|
-
}));
|
|
200
|
-
const cost = this.dbReader.getSessionCost(task.sessionId);
|
|
201
|
-
const childSessionCount = this.dbReader.getChildSessions(task.sessionId).length;
|
|
202
|
-
return {
|
|
203
|
-
taskId: task.id,
|
|
204
|
-
sessionId: task.sessionId,
|
|
205
|
-
status: task.status,
|
|
206
|
-
session,
|
|
207
|
-
messages,
|
|
208
|
-
lastActivity: task.progress?.lastUpdate?.toISOString(),
|
|
209
|
-
messageCount,
|
|
210
|
-
activeTools,
|
|
211
|
-
todos,
|
|
212
|
-
costSummary: {
|
|
213
|
-
totalCost: cost.totalCost,
|
|
214
|
-
totalTokens: cost.totalTokens,
|
|
215
|
-
},
|
|
216
|
-
childSessionCount,
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
// Get session details
|
|
220
|
-
const sessionResponse = await this.ctx.client.session.get({
|
|
221
|
-
path: { id: task.sessionId },
|
|
222
|
-
throwOnError: false,
|
|
223
|
-
...this.getClientOverrides(),
|
|
224
|
-
});
|
|
225
|
-
// Get messages from the session
|
|
226
|
-
const messagesResponse = await this.ctx.client.session.messages({
|
|
227
|
-
path: { id: task.sessionId },
|
|
228
|
-
throwOnError: false,
|
|
229
|
-
...this.getClientOverrides(),
|
|
230
|
-
});
|
|
231
|
-
const session = unwrapResponse(sessionResponse);
|
|
232
|
-
const rawMessages = unwrapResponse(messagesResponse);
|
|
233
|
-
// Defensive array coercion (response may be non-array when throwOnError is false)
|
|
234
|
-
const messages = Array.isArray(rawMessages) ? rawMessages : [];
|
|
235
|
-
// Return structured inspection result
|
|
236
|
-
return {
|
|
237
|
-
taskId: task.id,
|
|
238
|
-
sessionId: task.sessionId,
|
|
239
|
-
status: task.status,
|
|
240
|
-
session,
|
|
241
|
-
messages,
|
|
242
|
-
lastActivity: task.progress?.lastUpdate?.toISOString(),
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
catch {
|
|
246
|
-
// Session might not exist anymore
|
|
247
|
-
return undefined;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
/**
|
|
251
|
-
* Refresh task statuses from the server.
|
|
252
|
-
* Useful for recovering state after issues or checking on stuck tasks.
|
|
253
|
-
*/
|
|
254
|
-
async refreshStatuses() {
|
|
255
|
-
const results = new Map();
|
|
256
|
-
// Get all our tracked session IDs
|
|
257
|
-
const sessionIds = Array.from(this.tasksBySession.keys());
|
|
258
|
-
if (sessionIds.length === 0)
|
|
259
|
-
return results;
|
|
260
|
-
try {
|
|
261
|
-
// Fetch children for each unique parent (more efficient than individual gets)
|
|
262
|
-
const parentIds = new Set();
|
|
263
|
-
for (const task of this.tasks.values()) {
|
|
264
|
-
if (task.parentSessionId) {
|
|
265
|
-
parentIds.add(task.parentSessionId);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
const completionPromises = [];
|
|
269
|
-
for (const parentId of parentIds) {
|
|
270
|
-
const childrenResponse = await this.ctx.client.session.children({
|
|
271
|
-
path: { id: parentId },
|
|
272
|
-
throwOnError: false,
|
|
273
|
-
...this.getClientOverrides(),
|
|
274
|
-
});
|
|
275
|
-
const rawChildren = unwrapResponse(childrenResponse);
|
|
276
|
-
const children = Array.isArray(rawChildren) ? rawChildren : [];
|
|
277
|
-
for (const child of children) {
|
|
278
|
-
const childSession = child;
|
|
279
|
-
if (!childSession.id)
|
|
280
|
-
continue;
|
|
281
|
-
const matchedTaskId = this.tasksBySession.get(childSession.id);
|
|
282
|
-
if (matchedTaskId) {
|
|
283
|
-
const task = this.tasks.get(matchedTaskId);
|
|
284
|
-
if (task) {
|
|
285
|
-
// Terminal tasks are final — never overwrite their status.
|
|
286
|
-
// The API can return undefined/unknown status for cleaned-up sessions
|
|
287
|
-
// which maps to 'pending' by default; without this guard that would
|
|
288
|
-
// illegally resurrect a completed/errored/cancelled task.
|
|
289
|
-
if (task.status === 'completed' ||
|
|
290
|
-
task.status === 'error' ||
|
|
291
|
-
task.status === 'cancelled') {
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
const newStatus = this.mapSessionStatusToTaskStatus(childSession);
|
|
295
|
-
if (newStatus !== task.status) {
|
|
296
|
-
// Use proper handlers to trigger side effects (concurrency, notifications, etc.)
|
|
297
|
-
if (newStatus === 'completed' && task.status === 'running') {
|
|
298
|
-
completionPromises.push(this.completeTask(task));
|
|
299
|
-
results.set(matchedTaskId, newStatus);
|
|
300
|
-
}
|
|
301
|
-
else if (newStatus === 'error') {
|
|
302
|
-
this.failTask(task, 'Session ended with error');
|
|
303
|
-
results.set(matchedTaskId, newStatus);
|
|
304
|
-
}
|
|
305
|
-
else {
|
|
306
|
-
// For other transitions (e.g., pending -> running), direct update is fine
|
|
307
|
-
task.status = newStatus;
|
|
308
|
-
results.set(matchedTaskId, newStatus);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
// Wait for all completion handlers to finish
|
|
316
|
-
await Promise.all(completionPromises);
|
|
317
|
-
}
|
|
318
|
-
catch (error) {
|
|
319
|
-
// Log but don't fail - this is a best-effort refresh
|
|
320
|
-
console.error('Failed to refresh task statuses:', error);
|
|
321
|
-
}
|
|
322
|
-
return results;
|
|
323
|
-
}
|
|
324
|
-
/**
|
|
325
|
-
* Recover background tasks from existing sessions.
|
|
326
|
-
* Call this on plugin startup to restore state after restart.
|
|
327
|
-
*
|
|
328
|
-
* This method queries all sessions and reconstructs task state from
|
|
329
|
-
* sessions that have JSON-encoded task metadata in their title.
|
|
330
|
-
*
|
|
331
|
-
* @returns The number of tasks recovered
|
|
332
|
-
*/
|
|
333
|
-
async recoverTasks() {
|
|
334
|
-
let recovered = 0;
|
|
335
|
-
try {
|
|
336
|
-
if (this.dbReader?.isAvailable()) {
|
|
337
|
-
const parentSessionId = process.env.AGENTUITY_OPENCODE_SESSION;
|
|
338
|
-
if (parentSessionId) {
|
|
339
|
-
const sessions = this.dbReader.getChildSessions(parentSessionId);
|
|
340
|
-
for (const sess of sessions) {
|
|
341
|
-
if (!sess.title?.startsWith('{'))
|
|
342
|
-
continue;
|
|
343
|
-
try {
|
|
344
|
-
const metadata = JSON.parse(sess.title);
|
|
345
|
-
if (!metadata.taskId || !metadata.taskId.startsWith('bg_'))
|
|
346
|
-
continue;
|
|
347
|
-
if (this.tasks.has(metadata.taskId))
|
|
348
|
-
continue;
|
|
349
|
-
const agentName = metadata.agent ?? 'unknown';
|
|
350
|
-
const task = {
|
|
351
|
-
id: metadata.taskId,
|
|
352
|
-
sessionId: sess.id,
|
|
353
|
-
parentSessionId: sess.parentId ?? '',
|
|
354
|
-
agent: agentName,
|
|
355
|
-
description: metadata.description ?? '',
|
|
356
|
-
prompt: '',
|
|
357
|
-
status: this.mapDbStatusToTaskStatus(sess.id),
|
|
358
|
-
queuedAt: metadata.createdAt ? new Date(metadata.createdAt) : new Date(),
|
|
359
|
-
startedAt: metadata.createdAt ? new Date(metadata.createdAt) : new Date(),
|
|
360
|
-
concurrencyGroup: this.getConcurrencyGroup(agentName),
|
|
361
|
-
progress: {
|
|
362
|
-
toolCalls: 0,
|
|
363
|
-
lastUpdate: new Date(),
|
|
364
|
-
activeToolCallsInFlight: 0,
|
|
365
|
-
},
|
|
366
|
-
};
|
|
367
|
-
// Mark recovered terminal tasks as already notified
|
|
368
|
-
if (task.status === 'completed' ||
|
|
369
|
-
task.status === 'error' ||
|
|
370
|
-
task.status === 'cancelled') {
|
|
371
|
-
task.notifiedStatuses = new Set([task.status]);
|
|
372
|
-
}
|
|
373
|
-
this.tasks.set(task.id, task);
|
|
374
|
-
this.tasksBySession.set(sess.id, task.id);
|
|
375
|
-
if (task.parentSessionId) {
|
|
376
|
-
const parentTasks = this.tasksByParent.get(task.parentSessionId) ?? new Set();
|
|
377
|
-
parentTasks.add(task.id);
|
|
378
|
-
this.tasksByParent.set(task.parentSessionId, parentTasks);
|
|
379
|
-
}
|
|
380
|
-
recovered++;
|
|
381
|
-
}
|
|
382
|
-
catch {
|
|
383
|
-
continue;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
return recovered;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
// Get all sessions
|
|
390
|
-
const sessionsResponse = await this.ctx.client.session.list({
|
|
391
|
-
throwOnError: false,
|
|
392
|
-
...this.getClientOverrides(),
|
|
393
|
-
});
|
|
394
|
-
const rawSessions = unwrapResponse(sessionsResponse);
|
|
395
|
-
const sessions = Array.isArray(rawSessions) ? rawSessions : [];
|
|
396
|
-
for (const session of sessions) {
|
|
397
|
-
const sess = session;
|
|
398
|
-
// Check if this is one of our background task sessions
|
|
399
|
-
// Our sessions have JSON-encoded task metadata in the title
|
|
400
|
-
if (!sess.title?.startsWith('{'))
|
|
401
|
-
continue;
|
|
402
|
-
try {
|
|
403
|
-
const metadata = JSON.parse(sess.title);
|
|
404
|
-
// Skip if not a valid task metadata (must have taskId starting with 'bg_')
|
|
405
|
-
if (!metadata.taskId || !metadata.taskId.startsWith('bg_'))
|
|
406
|
-
continue;
|
|
407
|
-
// Skip if we already have this task
|
|
408
|
-
if (this.tasks.has(metadata.taskId))
|
|
409
|
-
continue;
|
|
410
|
-
// Skip sessions without an ID
|
|
411
|
-
if (!sess.id)
|
|
412
|
-
continue;
|
|
413
|
-
// Reconstruct the task
|
|
414
|
-
const agentName = metadata.agent ?? 'unknown';
|
|
415
|
-
const task = {
|
|
416
|
-
id: metadata.taskId,
|
|
417
|
-
sessionId: sess.id,
|
|
418
|
-
parentSessionId: sess.parentID ?? '',
|
|
419
|
-
agent: agentName,
|
|
420
|
-
description: metadata.description ?? '',
|
|
421
|
-
prompt: '', // Original prompt not stored in metadata
|
|
422
|
-
status: this.mapSessionStatusToTaskStatus(sess),
|
|
423
|
-
queuedAt: metadata.createdAt ? new Date(metadata.createdAt) : new Date(),
|
|
424
|
-
startedAt: metadata.createdAt ? new Date(metadata.createdAt) : new Date(),
|
|
425
|
-
concurrencyGroup: this.getConcurrencyGroup(agentName),
|
|
426
|
-
progress: {
|
|
427
|
-
toolCalls: 0,
|
|
428
|
-
lastUpdate: new Date(),
|
|
429
|
-
activeToolCallsInFlight: 0,
|
|
430
|
-
},
|
|
431
|
-
};
|
|
432
|
-
// Mark recovered terminal tasks as already notified
|
|
433
|
-
if (task.status === 'completed' ||
|
|
434
|
-
task.status === 'error' ||
|
|
435
|
-
task.status === 'cancelled') {
|
|
436
|
-
task.notifiedStatuses = new Set([task.status]);
|
|
437
|
-
}
|
|
438
|
-
// Add to our tracking maps
|
|
439
|
-
this.tasks.set(task.id, task);
|
|
440
|
-
this.tasksBySession.set(sess.id, task.id);
|
|
441
|
-
if (task.parentSessionId) {
|
|
442
|
-
const parentTasks = this.tasksByParent.get(task.parentSessionId) ?? new Set();
|
|
443
|
-
parentTasks.add(task.id);
|
|
444
|
-
this.tasksByParent.set(task.parentSessionId, parentTasks);
|
|
445
|
-
}
|
|
446
|
-
recovered++;
|
|
447
|
-
}
|
|
448
|
-
catch {
|
|
449
|
-
// Not valid JSON or not our task, skip
|
|
450
|
-
continue;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
catch (error) {
|
|
455
|
-
console.error('Failed to recover tasks:', error);
|
|
456
|
-
}
|
|
457
|
-
return recovered;
|
|
458
|
-
}
|
|
459
|
-
mapSessionStatusToTaskStatus(session) {
|
|
460
|
-
// Map OpenCode session status to our task status
|
|
461
|
-
// Session status types: 'idle' | 'pending' | 'running' | 'compacting' | 'error'
|
|
462
|
-
const status = session?.status?.type;
|
|
463
|
-
switch (status) {
|
|
464
|
-
case 'idle':
|
|
465
|
-
return 'completed';
|
|
466
|
-
case 'pending':
|
|
467
|
-
return 'pending';
|
|
468
|
-
case 'running':
|
|
469
|
-
case 'compacting': // Session is compacting context — still actively running
|
|
470
|
-
return 'running';
|
|
471
|
-
case 'error':
|
|
472
|
-
return 'error';
|
|
473
|
-
default:
|
|
474
|
-
// Unknown session status - default to pending for best-effort recovery.
|
|
475
|
-
// Note: refreshStatuses() guards terminal tasks before calling this,
|
|
476
|
-
// so a 'pending' default can never downgrade a completed/errored task.
|
|
477
|
-
return 'pending';
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
cancel(taskId) {
|
|
481
|
-
const task = this.tasks.get(taskId);
|
|
482
|
-
if (!task || task.status === 'completed' || task.status === 'error') {
|
|
483
|
-
return false;
|
|
484
|
-
}
|
|
485
|
-
task.status = 'cancelled';
|
|
486
|
-
task.completedAt = new Date();
|
|
487
|
-
this.releaseConcurrency(task);
|
|
488
|
-
this.markForNotification(task);
|
|
489
|
-
if (task.sessionId) {
|
|
490
|
-
void this.abortSession(task.sessionId);
|
|
491
|
-
this.callbacks?.onSubagentSessionDeleted?.({ sessionId: task.sessionId });
|
|
492
|
-
}
|
|
493
|
-
return true;
|
|
494
|
-
}
|
|
495
|
-
handleEvent(event) {
|
|
496
|
-
if (!event || typeof event.type !== 'string')
|
|
497
|
-
return;
|
|
498
|
-
this.expireStaleTasks();
|
|
499
|
-
if (event.type === 'message.part.updated') {
|
|
500
|
-
const part = event.properties?.part;
|
|
501
|
-
if (!part)
|
|
502
|
-
return;
|
|
503
|
-
const sessionId = part.sessionID;
|
|
504
|
-
if (!sessionId)
|
|
505
|
-
return;
|
|
506
|
-
const task = this.findBySession(sessionId);
|
|
507
|
-
if (!task)
|
|
508
|
-
return;
|
|
509
|
-
this.updateProgress(task, part);
|
|
510
|
-
return;
|
|
511
|
-
}
|
|
512
|
-
if (event.type === 'session.idle') {
|
|
513
|
-
const sessionId = extractSessionId(event.properties);
|
|
514
|
-
const task = sessionId ? this.findBySession(sessionId) : undefined;
|
|
515
|
-
if (!task)
|
|
516
|
-
return;
|
|
517
|
-
void this.completeTask(task);
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
if (event.type === 'session.error') {
|
|
521
|
-
const sessionId = extractSessionId(event.properties);
|
|
522
|
-
const task = sessionId ? this.findBySession(sessionId) : undefined;
|
|
523
|
-
if (!task)
|
|
524
|
-
return;
|
|
525
|
-
const error = extractError(event.properties);
|
|
526
|
-
const errorMsg = error ?? 'Session error.';
|
|
527
|
-
this.failTask(task, errorMsg);
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
markForNotification(task) {
|
|
532
|
-
// Monitor tasks are infrastructure — never notify Lead about them.
|
|
533
|
-
// Monitor pushes its own consolidated report as its final output.
|
|
534
|
-
if (task.isMonitor)
|
|
535
|
-
return;
|
|
536
|
-
const sessionId = task.parentSessionId;
|
|
537
|
-
if (!sessionId)
|
|
538
|
-
return;
|
|
539
|
-
const queue = this.notifications.get(sessionId) ?? new Set();
|
|
540
|
-
queue.add(task.id);
|
|
541
|
-
this.notifications.set(sessionId, queue);
|
|
542
|
-
}
|
|
543
|
-
getPendingNotifications(sessionId) {
|
|
544
|
-
const queue = this.notifications.get(sessionId);
|
|
545
|
-
if (!queue)
|
|
546
|
-
return [];
|
|
547
|
-
return Array.from(queue)
|
|
548
|
-
.map((id) => this.tasks.get(id))
|
|
549
|
-
.filter((task) => Boolean(task));
|
|
550
|
-
}
|
|
551
|
-
clearNotifications(sessionId) {
|
|
552
|
-
this.notifications.delete(sessionId);
|
|
553
|
-
}
|
|
554
|
-
shutdown() {
|
|
555
|
-
this.shuttingDown = true;
|
|
556
|
-
if (this.refreshIntervalId) {
|
|
557
|
-
clearInterval(this.refreshIntervalId);
|
|
558
|
-
this.refreshIntervalId = undefined;
|
|
559
|
-
}
|
|
560
|
-
this.concurrency.clear();
|
|
561
|
-
this.notifications.clear();
|
|
562
|
-
try {
|
|
563
|
-
void this.callbacks?.onShutdown?.();
|
|
564
|
-
}
|
|
565
|
-
catch {
|
|
566
|
-
// Ignore shutdown callback errors
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
indexTask(task) {
|
|
570
|
-
const parentList = this.tasksByParent.get(task.parentSessionId) ?? new Set();
|
|
571
|
-
parentList.add(task.id);
|
|
572
|
-
this.tasksByParent.set(task.parentSessionId, parentList);
|
|
573
|
-
}
|
|
574
|
-
/**
|
|
575
|
-
* Ensure a Monitor agent is watching all background tasks for the given parent session.
|
|
576
|
-
*
|
|
577
|
-
* Called automatically whenever a new background task is launched. If a Monitor is
|
|
578
|
-
* already running for this parent, this is a no-op. The Monitor uses
|
|
579
|
-
* `agentuity_session_dashboard({ session_id: parentSessionId })` which is scoped
|
|
580
|
-
* to child sessions of that parent only — it does not see unrelated sessions.
|
|
581
|
-
*
|
|
582
|
-
* The Monitor pushes a consolidated status update to Lead when all tasks complete,
|
|
583
|
-
* so Lead doesn't need to self-poll.
|
|
584
|
-
*/
|
|
585
|
-
async ensureMonitorForParent(parentSessionId) {
|
|
586
|
-
if (this.shuttingDown)
|
|
587
|
-
return;
|
|
588
|
-
// Check if we already have a live monitor for this parent
|
|
589
|
-
const existingMonitorId = this.monitorsPerParent.get(parentSessionId);
|
|
590
|
-
if (existingMonitorId) {
|
|
591
|
-
const existing = this.tasks.get(existingMonitorId);
|
|
592
|
-
if (existing && (existing.status === 'pending' || existing.status === 'running')) {
|
|
593
|
-
return; // Monitor already active
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
// Find the Monitor agent display name
|
|
597
|
-
const monitorAgent = Object.values(agents).find((a) => a.role === 'monitor');
|
|
598
|
-
if (!monitorAgent)
|
|
599
|
-
return; // Monitor agent not registered
|
|
600
|
-
const monitorPrompt = `You are watching background tasks for parent session: ${parentSessionId}
|
|
601
|
-
|
|
602
|
-
Use \`agentuity_session_dashboard({ session_id: "${parentSessionId}" })\` to see all child task sessions and their current status.
|
|
603
|
-
|
|
604
|
-
Monitor all non-monitor background tasks until they complete. When all tasks are done (completed, error, or cancelled), send a consolidated summary back. Use \`agentuity_background_output\` to retrieve results for completed tasks.
|
|
605
|
-
|
|
606
|
-
Do not poll more than once every 30 seconds. Be patient — Scout tasks reading large codebases typically take 3–8 minutes.`;
|
|
607
|
-
try {
|
|
608
|
-
const monitorTask = {
|
|
609
|
-
id: createTaskId(),
|
|
610
|
-
parentSessionId,
|
|
611
|
-
description: 'Monitor background tasks',
|
|
612
|
-
prompt: monitorPrompt,
|
|
613
|
-
agent: monitorAgent.displayName,
|
|
614
|
-
status: 'pending',
|
|
615
|
-
queuedAt: new Date(),
|
|
616
|
-
// Monitor uses a dedicated concurrency lane so it can never be blocked
|
|
617
|
-
// by the tasks it's watching. If Monitor queued behind regular tasks it
|
|
618
|
-
// would never start, and Lead would receive no consolidated report.
|
|
619
|
-
concurrencyGroup: 'monitor',
|
|
620
|
-
notifiedStatuses: new Set(),
|
|
621
|
-
isMonitor: true,
|
|
622
|
-
};
|
|
623
|
-
this.tasks.set(monitorTask.id, monitorTask);
|
|
624
|
-
this.monitorsPerParent.set(parentSessionId, monitorTask.id);
|
|
625
|
-
// Index monitor task so it's tracked by parent (but flagged as monitor)
|
|
626
|
-
this.indexTask(monitorTask);
|
|
627
|
-
void this.startTask(monitorTask);
|
|
628
|
-
}
|
|
629
|
-
catch {
|
|
630
|
-
// Non-fatal: if monitor launch fails, the event-driven notifyParent
|
|
631
|
-
// still works as the primary completion signal
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
async startTask(task) {
|
|
635
|
-
if (this.shuttingDown)
|
|
636
|
-
return;
|
|
637
|
-
// Use task.concurrencyGroup if explicitly set (e.g. 'monitor' for the auto-launched
|
|
638
|
-
// Monitor agent), otherwise derive from the agent name. This lets Monitor run in its
|
|
639
|
-
// own concurrency lane so it can never be blocked by the tasks it's watching.
|
|
640
|
-
const concurrencyKey = task.concurrencyGroup ?? this.getConcurrencyKey(task.agent);
|
|
641
|
-
task.concurrencyKey = concurrencyKey;
|
|
642
|
-
try {
|
|
643
|
-
await this.concurrency.acquire(concurrencyKey);
|
|
644
|
-
}
|
|
645
|
-
catch (error) {
|
|
646
|
-
if (task.status !== 'cancelled') {
|
|
647
|
-
task.status = 'error';
|
|
648
|
-
task.error = extractErrorMessage(error, 'Failed to acquire slot.');
|
|
649
|
-
task.completedAt = new Date();
|
|
650
|
-
this.markForNotification(task);
|
|
651
|
-
}
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
if (task.status === 'cancelled') {
|
|
655
|
-
this.releaseConcurrency(task);
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
658
|
-
try {
|
|
659
|
-
// Store task metadata in session title for persistence/recovery
|
|
660
|
-
const taskMetadata = JSON.stringify({
|
|
661
|
-
taskId: task.id,
|
|
662
|
-
agent: task.agent,
|
|
663
|
-
description: task.description,
|
|
664
|
-
createdAt: task.queuedAt?.toISOString() ?? new Date().toISOString(),
|
|
665
|
-
});
|
|
666
|
-
const sessionResult = await this.ctx.client.session.create({
|
|
667
|
-
body: {
|
|
668
|
-
parentID: task.parentSessionId,
|
|
669
|
-
title: taskMetadata,
|
|
670
|
-
},
|
|
671
|
-
throwOnError: true,
|
|
672
|
-
...this.getClientOverrides(),
|
|
673
|
-
});
|
|
674
|
-
const session = unwrapResponse(sessionResult);
|
|
675
|
-
if (!session?.id) {
|
|
676
|
-
throw new Error('Failed to create session.');
|
|
677
|
-
}
|
|
678
|
-
task.sessionId = session.id;
|
|
679
|
-
task.status = 'running';
|
|
680
|
-
task.startedAt = new Date();
|
|
681
|
-
this.tasksBySession.set(session.id, task.id);
|
|
682
|
-
this.callbacks?.onSubagentSessionCreated?.({
|
|
683
|
-
sessionId: session.id,
|
|
684
|
-
parentId: task.parentSessionId,
|
|
685
|
-
title: task.description,
|
|
686
|
-
});
|
|
687
|
-
await this.ctx.client.session.prompt({
|
|
688
|
-
path: { id: session.id },
|
|
689
|
-
body: {
|
|
690
|
-
agent: task.agent,
|
|
691
|
-
parts: [{ type: 'text', text: task.prompt }],
|
|
692
|
-
},
|
|
693
|
-
throwOnError: true,
|
|
694
|
-
...this.getClientOverrides(),
|
|
695
|
-
});
|
|
696
|
-
}
|
|
697
|
-
catch (error) {
|
|
698
|
-
const errorMsg = extractErrorMessage(error, 'Failed to launch background task.');
|
|
699
|
-
// Log the actual error for debugging — critical in sandbox environments
|
|
700
|
-
// where the client may silently fail due to missing baseUrl
|
|
701
|
-
try {
|
|
702
|
-
void this.ctx.client.app.log({
|
|
703
|
-
body: {
|
|
704
|
-
service: 'agentuity-coder',
|
|
705
|
-
level: 'error',
|
|
706
|
-
message: `Background task ${task.id} failed to start: ${errorMsg}`,
|
|
707
|
-
},
|
|
708
|
-
...this.getClientOverrides(),
|
|
709
|
-
});
|
|
710
|
-
}
|
|
711
|
-
catch {
|
|
712
|
-
// If logging also fails, fall back to console
|
|
713
|
-
console.error(`[BackgroundManager] Task ${task.id} failed to start:`, errorMsg);
|
|
714
|
-
}
|
|
715
|
-
this.failTask(task, errorMsg);
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
updateProgress(task, part) {
|
|
719
|
-
const progress = task.progress ?? this.createProgress();
|
|
720
|
-
progress.lastUpdate = new Date();
|
|
721
|
-
if (part.type === 'tool') {
|
|
722
|
-
const callId = part.callID;
|
|
723
|
-
const toolName = part.tool;
|
|
724
|
-
const toolStatus = part.state?.status;
|
|
725
|
-
if (toolName) {
|
|
726
|
-
progress.lastTool = toolName;
|
|
727
|
-
}
|
|
728
|
-
if (callId) {
|
|
729
|
-
const seen = this.toolCallIds.get(task.id) ?? new Set();
|
|
730
|
-
const active = this.activeToolCallIds.get(task.id) ?? new Set();
|
|
731
|
-
if (!seen.has(callId)) {
|
|
732
|
-
// First time seeing this callId — it's a new tool call starting
|
|
733
|
-
seen.add(callId);
|
|
734
|
-
progress.toolCalls += 1;
|
|
735
|
-
this.toolCallIds.set(task.id, seen);
|
|
736
|
-
}
|
|
737
|
-
// Track in-flight status based on tool state
|
|
738
|
-
// Only remove for explicit terminal statuses; treat unknown/missing as in-flight
|
|
739
|
-
if (toolStatus === 'completed' ||
|
|
740
|
-
toolStatus === 'error' ||
|
|
741
|
-
toolStatus === 'cancelled') {
|
|
742
|
-
active.delete(callId);
|
|
743
|
-
}
|
|
744
|
-
else {
|
|
745
|
-
// pending, running, unknown, or missing status — treat as in-flight
|
|
746
|
-
active.add(callId);
|
|
747
|
-
}
|
|
748
|
-
this.activeToolCallIds.set(task.id, active);
|
|
749
|
-
progress.activeToolCallsInFlight = active.size;
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
if (part.type === 'text' && part.text) {
|
|
753
|
-
progress.lastMessage = part.text;
|
|
754
|
-
progress.lastMessageAt = new Date();
|
|
755
|
-
}
|
|
756
|
-
task.progress = progress;
|
|
757
|
-
}
|
|
758
|
-
createProgress() {
|
|
759
|
-
return {
|
|
760
|
-
toolCalls: 0,
|
|
761
|
-
lastUpdate: new Date(),
|
|
762
|
-
activeToolCallsInFlight: 0,
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
async completeTask(task) {
|
|
766
|
-
if (task.status !== 'running')
|
|
767
|
-
return;
|
|
768
|
-
task.status = 'completed';
|
|
769
|
-
task.completedAt = new Date();
|
|
770
|
-
this.releaseConcurrency(task);
|
|
771
|
-
if (task.sessionId) {
|
|
772
|
-
const result = await this.fetchLatestResult(task.sessionId);
|
|
773
|
-
if (result) {
|
|
774
|
-
task.result = result;
|
|
775
|
-
}
|
|
776
|
-
this.callbacks?.onSubagentSessionDeleted?.({ sessionId: task.sessionId });
|
|
777
|
-
}
|
|
778
|
-
this.markForNotification(task);
|
|
779
|
-
void this.notifyParent(task);
|
|
780
|
-
}
|
|
781
|
-
failTask(task, error) {
|
|
782
|
-
if (task.status === 'completed' || task.status === 'error')
|
|
783
|
-
return;
|
|
784
|
-
task.status = 'error';
|
|
785
|
-
task.error = error;
|
|
786
|
-
task.completedAt = new Date();
|
|
787
|
-
this.releaseConcurrency(task);
|
|
788
|
-
if (task.sessionId) {
|
|
789
|
-
this.callbacks?.onSubagentSessionDeleted?.({ sessionId: task.sessionId });
|
|
790
|
-
}
|
|
791
|
-
this.markForNotification(task);
|
|
792
|
-
void this.notifyParent(task);
|
|
793
|
-
}
|
|
794
|
-
releaseConcurrency(task) {
|
|
795
|
-
if (!task.concurrencyKey)
|
|
796
|
-
return;
|
|
797
|
-
this.concurrency.release(task.concurrencyKey);
|
|
798
|
-
delete task.concurrencyKey;
|
|
799
|
-
}
|
|
800
|
-
async notifyParent(task) {
|
|
801
|
-
if (!task.parentSessionId)
|
|
802
|
-
return;
|
|
803
|
-
if (this.shuttingDown)
|
|
804
|
-
return;
|
|
805
|
-
// Monitor tasks push their own report as their session output — no separate notification needed.
|
|
806
|
-
if (task.isMonitor)
|
|
807
|
-
return;
|
|
808
|
-
// Recovered tasks (from recoverTasks) have no notifiedStatuses.
|
|
809
|
-
// Assume they were already notified and skip to prevent duplicate notifications.
|
|
810
|
-
if (!task.notifiedStatuses) {
|
|
811
|
-
task.notifiedStatuses = new Set([task.status]);
|
|
812
|
-
return;
|
|
813
|
-
}
|
|
814
|
-
// Snapshot status at call-time to prevent race conditions where concurrent
|
|
815
|
-
// refreshStatuses() calls mutate task.status during our async awaits below.
|
|
816
|
-
// Without this, the wrong status could be recorded as notified after delivery.
|
|
817
|
-
const statusAtCallTime = task.status;
|
|
818
|
-
const notifiedStatuses = task.notifiedStatuses;
|
|
819
|
-
if (notifiedStatuses.has(statusAtCallTime)) {
|
|
820
|
-
return; // Already notified for this status, skip duplicate
|
|
821
|
-
}
|
|
822
|
-
// Belt-and-suspenders: rate limit notifications per task+status to 1 per 10s
|
|
823
|
-
const now = Date.now();
|
|
824
|
-
const lastNotifyKey = `${task.id}:${statusAtCallTime}`;
|
|
825
|
-
const lastTime = this.lastNotifyTimes.get(lastNotifyKey);
|
|
826
|
-
if (lastTime && now - lastTime < 10_000) {
|
|
827
|
-
return;
|
|
828
|
-
}
|
|
829
|
-
this.lastNotifyTimes.set(lastNotifyKey, now);
|
|
830
|
-
// Do NOT pre-mark as notified here — if all retries fail, the status
|
|
831
|
-
// must remain unmarked so future retry attempts (via refreshStatuses
|
|
832
|
-
// or Monitor) are not blocked. Mark only on confirmed delivery below.
|
|
833
|
-
const message = `[BACKGROUND TASK ${statusAtCallTime.toUpperCase()}]
|
|
834
|
-
|
|
835
|
-
Task: ${task.description}
|
|
836
|
-
Agent: ${task.agent}
|
|
837
|
-
Status: ${statusAtCallTime}
|
|
838
|
-
Task ID: ${task.id}
|
|
839
|
-
|
|
840
|
-
Use the agentuity_background_output tool with task_id "${task.id}" to view the result.`;
|
|
841
|
-
const maxRetries = 3;
|
|
842
|
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
843
|
-
try {
|
|
844
|
-
await this.ctx.client.session.prompt({
|
|
845
|
-
path: { id: task.parentSessionId },
|
|
846
|
-
body: {
|
|
847
|
-
parts: [{ type: 'text', text: message }],
|
|
848
|
-
},
|
|
849
|
-
throwOnError: true,
|
|
850
|
-
responseStyle: 'data',
|
|
851
|
-
...this.getClientOverrides(),
|
|
852
|
-
});
|
|
853
|
-
// Mark the snapshotted status as notified only AFTER confirmed delivery.
|
|
854
|
-
// Using the snapshot prevents recording the wrong status if task.status
|
|
855
|
-
// was mutated concurrently during the await above.
|
|
856
|
-
notifiedStatuses.add(statusAtCallTime);
|
|
857
|
-
task.notifiedStatuses = notifiedStatuses;
|
|
858
|
-
return; // Success
|
|
859
|
-
}
|
|
860
|
-
catch (error) {
|
|
861
|
-
const errorMsg = extractErrorMessage(error, 'notification failed');
|
|
862
|
-
if (attempt < maxRetries - 1) {
|
|
863
|
-
// Exponential backoff: 1s, 2s, 4s
|
|
864
|
-
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
|
|
865
|
-
if (this.shuttingDown)
|
|
866
|
-
return;
|
|
867
|
-
}
|
|
868
|
-
else {
|
|
869
|
-
console.error(`[BackgroundManager] Failed to notify parent for task ${task.id} after ${maxRetries} attempts:`, errorMsg);
|
|
870
|
-
// Safety net: ensure status is NOT marked as notified so future
|
|
871
|
-
// retry attempts (via refreshStatuses or Monitor) are not blocked
|
|
872
|
-
notifiedStatuses.delete(statusAtCallTime);
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
async abortSession(sessionId) {
|
|
878
|
-
try {
|
|
879
|
-
await this.ctx.client.session.abort({
|
|
880
|
-
path: { id: sessionId },
|
|
881
|
-
throwOnError: false,
|
|
882
|
-
...this.getClientOverrides(),
|
|
883
|
-
});
|
|
884
|
-
}
|
|
885
|
-
catch {
|
|
886
|
-
// Ignore abort errors
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
async fetchLatestResult(sessionId) {
|
|
890
|
-
try {
|
|
891
|
-
if (this.dbReader?.isAvailable()) {
|
|
892
|
-
const messages = this.dbReader.getMessages(sessionId, { limit: 100, offset: 0 });
|
|
893
|
-
const textParts = this.dbReader.getTextParts(sessionId, { limit: 300 });
|
|
894
|
-
const partsByMessage = groupTextPartsByMessage(textParts);
|
|
895
|
-
for (const message of messages) {
|
|
896
|
-
if (message.role !== 'assistant')
|
|
897
|
-
continue;
|
|
898
|
-
const text = joinTextParts(partsByMessage.get(message.id));
|
|
899
|
-
if (text)
|
|
900
|
-
return text;
|
|
901
|
-
}
|
|
902
|
-
return undefined;
|
|
903
|
-
}
|
|
904
|
-
const messagesResult = await this.ctx.client.session.messages({
|
|
905
|
-
path: { id: sessionId },
|
|
906
|
-
throwOnError: true,
|
|
907
|
-
...this.getClientOverrides(),
|
|
908
|
-
});
|
|
909
|
-
const messages = unwrapResponse(messagesResult) ?? [];
|
|
910
|
-
const entries = Array.isArray(messages) ? messages : [];
|
|
911
|
-
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
912
|
-
const entry = entries[i];
|
|
913
|
-
if (entry?.info?.role !== 'assistant')
|
|
914
|
-
continue;
|
|
915
|
-
const text = extractTextFromParts(entry.parts ?? []);
|
|
916
|
-
if (text)
|
|
917
|
-
return text;
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
catch {
|
|
921
|
-
return undefined;
|
|
922
|
-
}
|
|
923
|
-
return undefined;
|
|
924
|
-
}
|
|
925
|
-
mapDbStatusToTaskStatus(sessionId) {
|
|
926
|
-
if (!this.dbReader)
|
|
927
|
-
return 'pending';
|
|
928
|
-
const status = this.dbReader.getSessionStatus(sessionId).status;
|
|
929
|
-
switch (status) {
|
|
930
|
-
case 'idle':
|
|
931
|
-
case 'archived':
|
|
932
|
-
return 'completed';
|
|
933
|
-
case 'active':
|
|
934
|
-
case 'compacting':
|
|
935
|
-
return 'running';
|
|
936
|
-
case 'error':
|
|
937
|
-
return 'error';
|
|
938
|
-
default:
|
|
939
|
-
return 'pending';
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
getConcurrencyGroup(agentName) {
|
|
943
|
-
const model = getAgentModel(agentName);
|
|
944
|
-
if (!model)
|
|
945
|
-
return undefined;
|
|
946
|
-
const provider = model.split('/')[0];
|
|
947
|
-
if (model && this.config.modelConcurrency?.[model] !== undefined) {
|
|
948
|
-
return `model:${model}`;
|
|
949
|
-
}
|
|
950
|
-
if (provider && this.config.providerConcurrency?.[provider] !== undefined) {
|
|
951
|
-
return `provider:${provider}`;
|
|
952
|
-
}
|
|
953
|
-
return undefined;
|
|
954
|
-
}
|
|
955
|
-
getConcurrencyKey(agentName) {
|
|
956
|
-
const group = this.getConcurrencyGroup(agentName);
|
|
957
|
-
return group ?? 'default';
|
|
958
|
-
}
|
|
959
|
-
expireStaleTasks() {
|
|
960
|
-
const now = Date.now();
|
|
961
|
-
for (const task of this.tasks.values()) {
|
|
962
|
-
if (task.status !== 'pending' && task.status !== 'running')
|
|
963
|
-
continue;
|
|
964
|
-
// Use last activity time (last event received) rather than start time.
|
|
965
|
-
// A task actively doing tool calls every minute should never expire —
|
|
966
|
-
// only tasks that have gone silent for staleTimeoutMs should be killed.
|
|
967
|
-
const lastActivity = task.progress?.lastUpdate.getTime() ??
|
|
968
|
-
task.startedAt?.getTime() ??
|
|
969
|
-
task.queuedAt?.getTime();
|
|
970
|
-
if (!lastActivity)
|
|
971
|
-
continue;
|
|
972
|
-
if (now - lastActivity > this.config.staleTimeoutMs) {
|
|
973
|
-
this.failTask(task, 'Background task timed out (no activity).');
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
function buildConcurrencyLimits(config) {
|
|
979
|
-
const limits = {};
|
|
980
|
-
if (config.providerConcurrency) {
|
|
981
|
-
for (const [provider, limit] of Object.entries(config.providerConcurrency)) {
|
|
982
|
-
limits[`provider:${provider}`] = limit;
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
if (config.modelConcurrency) {
|
|
986
|
-
for (const [model, limit] of Object.entries(config.modelConcurrency)) {
|
|
987
|
-
limits[`model:${model}`] = limit;
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
return limits;
|
|
991
|
-
}
|
|
992
|
-
function getAgentModel(agentName) {
|
|
993
|
-
const agent = findAgentDefinition(agentName);
|
|
994
|
-
return agent?.defaultModel;
|
|
995
|
-
}
|
|
996
|
-
function findAgentDefinition(agentName) {
|
|
997
|
-
return Object.values(agents).find((agent) => agent.displayName === agentName || agent.id === agentName || agent.role === agentName);
|
|
998
|
-
}
|
|
999
|
-
function createTaskId() {
|
|
1000
|
-
return `bg_${Math.random().toString(36).slice(2, 8)}`;
|
|
1001
|
-
}
|
|
1002
|
-
function extractSessionId(properties) {
|
|
1003
|
-
return (properties?.sessionId ?? properties?.sessionID);
|
|
1004
|
-
}
|
|
1005
|
-
function extractError(properties) {
|
|
1006
|
-
const error = properties?.error;
|
|
1007
|
-
return error?.data?.message ?? (typeof error?.name === 'string' ? error.name : undefined);
|
|
1008
|
-
}
|
|
1009
|
-
function extractTextFromParts(parts) {
|
|
1010
|
-
const textParts = [];
|
|
1011
|
-
for (const part of parts) {
|
|
1012
|
-
if (typeof part !== 'object' || part === null)
|
|
1013
|
-
continue;
|
|
1014
|
-
const typed = part;
|
|
1015
|
-
if (typed.type === 'text' && typeof typed.text === 'string') {
|
|
1016
|
-
textParts.push(typed.text);
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
if (textParts.length === 0)
|
|
1020
|
-
return undefined;
|
|
1021
|
-
return textParts.join('\n');
|
|
1022
|
-
}
|
|
1023
|
-
function groupTextPartsByMessage(parts) {
|
|
1024
|
-
const grouped = new Map();
|
|
1025
|
-
for (const part of parts) {
|
|
1026
|
-
const list = grouped.get(part.messageId) ?? [];
|
|
1027
|
-
list.push(part);
|
|
1028
|
-
grouped.set(part.messageId, list);
|
|
1029
|
-
}
|
|
1030
|
-
for (const list of grouped.values()) {
|
|
1031
|
-
list.sort((a, b) => a.timeCreated - b.timeCreated);
|
|
1032
|
-
}
|
|
1033
|
-
return grouped;
|
|
1034
|
-
}
|
|
1035
|
-
function buildTextParts(parts) {
|
|
1036
|
-
if (!parts || parts.length === 0)
|
|
1037
|
-
return [];
|
|
1038
|
-
return parts.map((part) => ({ type: 'text', text: part.text }));
|
|
1039
|
-
}
|
|
1040
|
-
function joinTextParts(parts) {
|
|
1041
|
-
if (!parts || parts.length === 0)
|
|
1042
|
-
return undefined;
|
|
1043
|
-
return parts.map((part) => part.text).join('\n');
|
|
1044
|
-
}
|
|
1045
|
-
function unwrapResponse(result) {
|
|
1046
|
-
if (typeof result === 'object' && result !== null && 'data' in result) {
|
|
1047
|
-
return result.data;
|
|
1048
|
-
}
|
|
1049
|
-
return result;
|
|
1050
|
-
}
|
|
1051
|
-
/**
|
|
1052
|
-
* Extract an error message from an unknown thrown value.
|
|
1053
|
-
*
|
|
1054
|
-
* The OpenCode SDK client (with `throwOnError: true`) throws **plain objects**
|
|
1055
|
-
* (e.g. `{ message: "Not Found" }`) or raw strings rather than `Error` instances.
|
|
1056
|
-
* This helper normalises all shapes into a usable string.
|
|
1057
|
-
*/
|
|
1058
|
-
function extractErrorMessage(error, fallback) {
|
|
1059
|
-
if (error instanceof Error)
|
|
1060
|
-
return error.message;
|
|
1061
|
-
if (typeof error === 'string')
|
|
1062
|
-
return error || fallback;
|
|
1063
|
-
if (typeof error === 'object' && error !== null) {
|
|
1064
|
-
const obj = error;
|
|
1065
|
-
if (typeof obj.message === 'string')
|
|
1066
|
-
return obj.message || fallback;
|
|
1067
|
-
if (typeof obj.error === 'string')
|
|
1068
|
-
return obj.error || fallback;
|
|
1069
|
-
if (typeof obj.error === 'object' && obj.error !== null) {
|
|
1070
|
-
return extractErrorMessage(obj.error, fallback);
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
return fallback;
|
|
1074
|
-
}
|
|
1075
|
-
//# sourceMappingURL=manager.js.map
|