@hueyexe/opencode-ensemble 0.16.1 → 0.17.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/dist/index.js +356 -140
- package/package.json +1 -1
- package/src/dashboard-js-render.ts +5 -2
- package/src/dashboard.ts +62 -5
- package/src/hooks.ts +86 -8
- package/src/index.ts +64 -17
- package/src/member-model.ts +30 -0
- package/src/notify.ts +44 -0
- package/src/progress.ts +26 -7
- package/src/recovery.ts +106 -3
- package/src/schema.ts +18 -0
- package/src/tasks.ts +19 -0
- package/src/tools/team-create.ts +15 -2
- package/src/tools/team-merge.ts +30 -3
- package/src/tools/team-message.ts +54 -6
- package/src/tools/team-shutdown.ts +6 -0
- package/src/tools/team-spawn.ts +16 -15
- package/src/tools/team-status.ts +38 -4
- package/src/types.ts +3 -0
- package/src/watchdog.ts +91 -17
package/dist/index.js
CHANGED
|
@@ -17608,7 +17608,12 @@ var MIGRATIONS = [
|
|
|
17608
17608
|
CREATE INDEX IF NOT EXISTS team_message_to_idx ON team_message(to_name);
|
|
17609
17609
|
CREATE INDEX IF NOT EXISTS team_message_undelivered_idx ON team_message(team_id, delivered) WHERE delivered = 0;
|
|
17610
17610
|
CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;
|
|
17611
|
-
PRAGMA foreign_keys=ON
|
|
17611
|
+
PRAGMA foreign_keys=ON;`,
|
|
17612
|
+
`ALTER TABLE team_member ADD COLUMN last_nudged_at INTEGER;`,
|
|
17613
|
+
`ALTER TABLE team_member ADD COLUMN retry_until INTEGER;
|
|
17614
|
+
ALTER TABLE team_member ADD COLUMN retry_attempt INTEGER;
|
|
17615
|
+
ALTER TABLE team_member ADD COLUMN retry_provider TEXT;
|
|
17616
|
+
ALTER TABLE team_member ADD COLUMN retry_message TEXT;`
|
|
17612
17617
|
];
|
|
17613
17618
|
function applyMigrations(db) {
|
|
17614
17619
|
const { user_version: current } = db.query("PRAGMA user_version").get();
|
|
@@ -17807,10 +17812,59 @@ function hasReportedCompletion(db, teamId, memberName) {
|
|
|
17807
17812
|
return row?.reported_to_lead === 1;
|
|
17808
17813
|
}
|
|
17809
17814
|
|
|
17815
|
+
// src/tasks.ts
|
|
17816
|
+
function releaseMemberTasks(db, teamId, memberName) {
|
|
17817
|
+
const result = db.run("UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE team_id = ? AND assignee = ? AND status = 'in_progress'", [Date.now(), teamId, memberName]);
|
|
17818
|
+
return result.changes;
|
|
17819
|
+
}
|
|
17820
|
+
|
|
17821
|
+
// src/member-model.ts
|
|
17822
|
+
function parseModelId(model) {
|
|
17823
|
+
const slash = model.indexOf("/");
|
|
17824
|
+
if (slash <= 0 || slash === model.length - 1)
|
|
17825
|
+
return;
|
|
17826
|
+
return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
|
|
17827
|
+
}
|
|
17828
|
+
function getMemberModel(db, teamId, memberName) {
|
|
17829
|
+
const row = db.query("SELECT model FROM team_member WHERE team_id = ? AND name = ?").get(teamId, memberName);
|
|
17830
|
+
if (!row?.model)
|
|
17831
|
+
return;
|
|
17832
|
+
return parseModelId(row.model);
|
|
17833
|
+
}
|
|
17834
|
+
|
|
17810
17835
|
// src/recovery.ts
|
|
17811
17836
|
init_merge_helper();
|
|
17812
17837
|
init_process();
|
|
17813
|
-
async function
|
|
17838
|
+
async function isSessionAlive(client3, sessionId, timeoutMs = 5000) {
|
|
17839
|
+
try {
|
|
17840
|
+
await Promise.race([
|
|
17841
|
+
client3.session.get({ sessionID: sessionId }),
|
|
17842
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("isSessionAlive timed out")), timeoutMs))
|
|
17843
|
+
]);
|
|
17844
|
+
return true;
|
|
17845
|
+
} catch (err) {
|
|
17846
|
+
if (err instanceof Error && err.message === "isSessionAlive timed out") {
|
|
17847
|
+
log(`recovery:team:liveness-check-timeout session=${sessionId}`);
|
|
17848
|
+
return true;
|
|
17849
|
+
}
|
|
17850
|
+
return false;
|
|
17851
|
+
}
|
|
17852
|
+
}
|
|
17853
|
+
async function recoverOrphanedTeams(db, client3, cwd, registry2) {
|
|
17854
|
+
const active = db.query(`SELECT id, lead_session_id FROM team
|
|
17855
|
+
WHERE status = 'active' AND (? IS NULL OR project_id = ? OR project_id = 'default')`).all(cwd ?? null, cwd ?? null);
|
|
17856
|
+
let archived = 0;
|
|
17857
|
+
for (const team of active) {
|
|
17858
|
+
if (await isSessionAlive(client3, team.lead_session_id))
|
|
17859
|
+
continue;
|
|
17860
|
+
db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), team.id]);
|
|
17861
|
+
registry2?.unregisterTeam(team.id);
|
|
17862
|
+
archived++;
|
|
17863
|
+
log(`recovery:team:orphaned team_id=${team.id} lead_session=${team.lead_session_id}`);
|
|
17864
|
+
}
|
|
17865
|
+
return { archived };
|
|
17866
|
+
}
|
|
17867
|
+
async function recoverStaleMembers(db, client3, cwd, abortTimeoutMs = 5000) {
|
|
17814
17868
|
const stale = db.query(`SELECT tm.session_id, tm.worktree_branch, tm.name, tm.team_id, t.name as team_name, p.name as project_name
|
|
17815
17869
|
FROM team_member tm
|
|
17816
17870
|
JOIN team t ON tm.team_id = t.id
|
|
@@ -17820,6 +17874,11 @@ async function recoverStaleMembers(db, client3, cwd) {
|
|
|
17820
17874
|
const result = db.run(`UPDATE team_member SET status = 'error', execution_status = 'idle', time_updated = ?
|
|
17821
17875
|
WHERE status = 'busy'
|
|
17822
17876
|
AND team_id IN (SELECT id FROM team WHERE status = 'active' AND (? IS NULL OR project_id = ? OR project_id = 'default'))`, [Date.now(), cwd ?? null, cwd ?? null]);
|
|
17877
|
+
for (const member of stale) {
|
|
17878
|
+
const released = releaseMemberTasks(db, member.team_id, member.name);
|
|
17879
|
+
if (released > 0)
|
|
17880
|
+
log(`recovery:tasks:released name=${member.name} count=${released}`);
|
|
17881
|
+
}
|
|
17823
17882
|
if (client3) {
|
|
17824
17883
|
for (const member of stale) {
|
|
17825
17884
|
if (cwd && member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
|
|
@@ -17831,8 +17890,15 @@ async function recoverStaleMembers(db, client3, cwd) {
|
|
|
17831
17890
|
}
|
|
17832
17891
|
}
|
|
17833
17892
|
try {
|
|
17834
|
-
await
|
|
17835
|
-
|
|
17893
|
+
await Promise.race([
|
|
17894
|
+
client3.session.abort({ sessionID: member.session_id }),
|
|
17895
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("session.abort timed out")), abortTimeoutMs))
|
|
17896
|
+
]);
|
|
17897
|
+
} catch (err) {
|
|
17898
|
+
if (err instanceof Error && err.message === "session.abort timed out") {
|
|
17899
|
+
log(`recovery:stale-members:abort-timeout session=${member.session_id}`);
|
|
17900
|
+
}
|
|
17901
|
+
}
|
|
17836
17902
|
}
|
|
17837
17903
|
}
|
|
17838
17904
|
return { interrupted: result.changes };
|
|
@@ -17881,9 +17947,11 @@ async function recoverUndeliveredMessages(db, client3, registry2) {
|
|
|
17881
17947
|
continue;
|
|
17882
17948
|
}
|
|
17883
17949
|
try {
|
|
17950
|
+
const recipientModel = getMemberModel(db, team.id, msg.to_name);
|
|
17884
17951
|
await client3.session.promptAsync({
|
|
17885
17952
|
sessionID: recipientSessionId,
|
|
17886
|
-
parts: [{ type: "text", text: `[Recovered team message from ${msg.from_name}]: ${msg.content}` }]
|
|
17953
|
+
parts: [{ type: "text", text: `[Recovered team message from ${msg.from_name}]: ${msg.content}` }],
|
|
17954
|
+
...recipientModel ? { model: recipientModel } : {}
|
|
17887
17955
|
});
|
|
17888
17956
|
markDelivered(db, msg.id);
|
|
17889
17957
|
redelivered++;
|
|
@@ -18096,6 +18164,77 @@ class PendingPurgeApprovals {
|
|
|
18096
18164
|
}
|
|
18097
18165
|
}
|
|
18098
18166
|
|
|
18167
|
+
// src/notify.ts
|
|
18168
|
+
var TOAST_CONFIG = {
|
|
18169
|
+
spawn: { variant: "success", duration: 3000 },
|
|
18170
|
+
message: { variant: "info", duration: 3000 },
|
|
18171
|
+
completed: { variant: "info", duration: 4000 },
|
|
18172
|
+
error: { variant: "error", duration: 5000 },
|
|
18173
|
+
shutdown: { variant: "info", duration: 3000 }
|
|
18174
|
+
};
|
|
18175
|
+
function formatMessage(type, data) {
|
|
18176
|
+
switch (type) {
|
|
18177
|
+
case "spawn":
|
|
18178
|
+
return `Teammate ${data.memberName} spawned (${data.agent})`;
|
|
18179
|
+
case "message":
|
|
18180
|
+
return `Message from ${data.from}`;
|
|
18181
|
+
case "completed":
|
|
18182
|
+
return `${data.memberName} finished work`;
|
|
18183
|
+
case "error":
|
|
18184
|
+
return `${data.memberName} encountered an error`;
|
|
18185
|
+
case "shutdown":
|
|
18186
|
+
return `${data.memberName} shut down`;
|
|
18187
|
+
}
|
|
18188
|
+
}
|
|
18189
|
+
async function notifyTeamEvent(client3, type, data) {
|
|
18190
|
+
const config2 = TOAST_CONFIG[type];
|
|
18191
|
+
try {
|
|
18192
|
+
await client3.tui.showToast({
|
|
18193
|
+
title: "Team",
|
|
18194
|
+
message: formatMessage(type, data),
|
|
18195
|
+
variant: config2.variant,
|
|
18196
|
+
duration: config2.duration
|
|
18197
|
+
});
|
|
18198
|
+
} catch {}
|
|
18199
|
+
}
|
|
18200
|
+
async function notifyWorkingProgress(client3, db, teamId) {
|
|
18201
|
+
const members = db.query("SELECT name, status FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamId);
|
|
18202
|
+
if (members.length === 0)
|
|
18203
|
+
return;
|
|
18204
|
+
const busy = members.filter((m) => m.status === "busy");
|
|
18205
|
+
try {
|
|
18206
|
+
if (busy.length === 0) {
|
|
18207
|
+
await client3.tui.showToast({
|
|
18208
|
+
title: "Team",
|
|
18209
|
+
message: "All teammates finished",
|
|
18210
|
+
variant: "success",
|
|
18211
|
+
duration: 4000
|
|
18212
|
+
});
|
|
18213
|
+
} else {
|
|
18214
|
+
const names = busy.map((m) => m.name).join(", ");
|
|
18215
|
+
await client3.tui.showToast({
|
|
18216
|
+
title: "Team",
|
|
18217
|
+
message: `Working: ${names} (${busy.length}/${members.length})`,
|
|
18218
|
+
variant: "info",
|
|
18219
|
+
duration: 5000
|
|
18220
|
+
});
|
|
18221
|
+
}
|
|
18222
|
+
} catch {}
|
|
18223
|
+
}
|
|
18224
|
+
function notifyLead(client3, db, teamId, content) {
|
|
18225
|
+
const id = sendMessage(db, { teamId, from: "system", to: "lead", content });
|
|
18226
|
+
const team = db.query("SELECT lead_session_id FROM team WHERE id = ?").get(teamId);
|
|
18227
|
+
if (!team?.lead_session_id)
|
|
18228
|
+
return id;
|
|
18229
|
+
client3.session.promptAsync({
|
|
18230
|
+
sessionID: team.lead_session_id,
|
|
18231
|
+
parts: [{ type: "text", text: "[System: New team message from system]" }]
|
|
18232
|
+
}).catch((err) => {
|
|
18233
|
+
log(`notifyLead:wake:failed team=${teamId} err=${err instanceof Error ? err.message : String(err)}`);
|
|
18234
|
+
});
|
|
18235
|
+
return id;
|
|
18236
|
+
}
|
|
18237
|
+
|
|
18099
18238
|
// src/types.ts
|
|
18100
18239
|
function findTeamBySession(db, registry2, sessionId) {
|
|
18101
18240
|
const entry = registry2.getBySession(sessionId);
|
|
@@ -18139,7 +18278,7 @@ function resolveRecipientSession(db, registry2, teamId, recipientName) {
|
|
|
18139
18278
|
|
|
18140
18279
|
// src/hooks.ts
|
|
18141
18280
|
var TEAM_TOOL_PREFIX = "team_";
|
|
18142
|
-
function handleSessionStatusEvent(db, registry2, sessionId, status) {
|
|
18281
|
+
function handleSessionStatusEvent(db, registry2, sessionId, status, retryPayload) {
|
|
18143
18282
|
const entry = registry2.getBySession(sessionId);
|
|
18144
18283
|
if (!entry)
|
|
18145
18284
|
return;
|
|
@@ -18154,6 +18293,9 @@ function handleSessionStatusEvent(db, registry2, sessionId, status) {
|
|
|
18154
18293
|
if (member.status === newStatus)
|
|
18155
18294
|
return;
|
|
18156
18295
|
db.run("UPDATE team_member SET status = ?, execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [newStatus, Date.now(), entry.teamId, entry.memberName]);
|
|
18296
|
+
if (newStatus === "shutdown") {
|
|
18297
|
+
releaseMemberTasks(db, entry.teamId, entry.memberName);
|
|
18298
|
+
}
|
|
18157
18299
|
if (member.status === "busy" && newStatus === "ready") {
|
|
18158
18300
|
const leadMsgCount = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND from_name = ? AND to_name = 'lead'").get(entry.teamId, entry.memberName).c;
|
|
18159
18301
|
if (leadMsgCount > 0) {
|
|
@@ -18166,10 +18308,26 @@ function handleSessionStatusEvent(db, registry2, sessionId, status) {
|
|
|
18166
18308
|
db.run("UPDATE team_member SET status = 'busy', execution_status = 'running', reported_to_lead = 0, time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), entry.teamId, entry.memberName]);
|
|
18167
18309
|
return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" };
|
|
18168
18310
|
}
|
|
18311
|
+
if (member.status === "busy" && member.execution_status === "starting") {
|
|
18312
|
+
db.run("UPDATE team_member SET execution_status = 'running', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), entry.teamId, entry.memberName]);
|
|
18313
|
+
return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" };
|
|
18314
|
+
}
|
|
18169
18315
|
if (member.status === "shutdown_requested") {
|
|
18170
18316
|
return { memberName: entry.memberName, teamId: entry.teamId, from: "shutdown_requested", to: "busy_while_shutdown" };
|
|
18171
18317
|
}
|
|
18172
18318
|
} else if (status === "retry") {
|
|
18319
|
+
if (retryPayload) {
|
|
18320
|
+
const next = typeof retryPayload.next === "number" && Number.isFinite(retryPayload.next) ? retryPayload.next : null;
|
|
18321
|
+
const attempt = typeof retryPayload.attempt === "number" && Number.isFinite(retryPayload.attempt) ? retryPayload.attempt : null;
|
|
18322
|
+
db.run("UPDATE team_member SET retry_until = ?, retry_attempt = ?, retry_provider = ?, retry_message = ? WHERE team_id = ? AND name = ?", [
|
|
18323
|
+
next,
|
|
18324
|
+
attempt,
|
|
18325
|
+
retryPayload.action?.provider ?? null,
|
|
18326
|
+
retryPayload.action?.message ?? retryPayload.message ?? null,
|
|
18327
|
+
entry.teamId,
|
|
18328
|
+
entry.memberName
|
|
18329
|
+
]);
|
|
18330
|
+
}
|
|
18173
18331
|
return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "retry" };
|
|
18174
18332
|
}
|
|
18175
18333
|
return;
|
|
@@ -18205,77 +18363,14 @@ function shouldNudgeIdleMember(db, teamId, memberName) {
|
|
|
18205
18363
|
const msg = db.query("SELECT id FROM team_message WHERE team_id = ? AND from_name = ? AND (to_name = 'lead' OR to_name IS NULL) LIMIT 1").get(teamId, memberName);
|
|
18206
18364
|
return !msg;
|
|
18207
18365
|
}
|
|
18208
|
-
function handleSessionErrorEvent(db, registry2, sessionId, error45) {
|
|
18366
|
+
function handleSessionErrorEvent(db, registry2, client3, sessionId, error45) {
|
|
18209
18367
|
if (!sessionId)
|
|
18210
18368
|
return;
|
|
18211
18369
|
const teamInfo = findTeamBySession(db, registry2, sessionId);
|
|
18212
18370
|
if (!teamInfo || teamInfo.role !== "member" || !teamInfo.memberName)
|
|
18213
18371
|
return;
|
|
18214
18372
|
const errMsg = error45?.data?.message ?? error45?.name ?? "unknown error";
|
|
18215
|
-
|
|
18216
|
-
teamId: teamInfo.teamId,
|
|
18217
|
-
from: "system",
|
|
18218
|
-
to: "lead",
|
|
18219
|
-
content: `Teammate "${teamInfo.memberName}" had a session error: ${errMsg}. Check their session for details. They may be stuck and need investigation or shutdown.`
|
|
18220
|
-
});
|
|
18221
|
-
}
|
|
18222
|
-
|
|
18223
|
-
// src/notify.ts
|
|
18224
|
-
var TOAST_CONFIG = {
|
|
18225
|
-
spawn: { variant: "success", duration: 3000 },
|
|
18226
|
-
message: { variant: "info", duration: 3000 },
|
|
18227
|
-
completed: { variant: "info", duration: 4000 },
|
|
18228
|
-
error: { variant: "error", duration: 5000 },
|
|
18229
|
-
shutdown: { variant: "info", duration: 3000 }
|
|
18230
|
-
};
|
|
18231
|
-
function formatMessage(type, data) {
|
|
18232
|
-
switch (type) {
|
|
18233
|
-
case "spawn":
|
|
18234
|
-
return `Teammate ${data.memberName} spawned (${data.agent})`;
|
|
18235
|
-
case "message":
|
|
18236
|
-
return `Message from ${data.from}`;
|
|
18237
|
-
case "completed":
|
|
18238
|
-
return `${data.memberName} finished work`;
|
|
18239
|
-
case "error":
|
|
18240
|
-
return `${data.memberName} encountered an error`;
|
|
18241
|
-
case "shutdown":
|
|
18242
|
-
return `${data.memberName} shut down`;
|
|
18243
|
-
}
|
|
18244
|
-
}
|
|
18245
|
-
async function notifyTeamEvent(client3, type, data) {
|
|
18246
|
-
const config2 = TOAST_CONFIG[type];
|
|
18247
|
-
try {
|
|
18248
|
-
await client3.tui.showToast({
|
|
18249
|
-
title: "Team",
|
|
18250
|
-
message: formatMessage(type, data),
|
|
18251
|
-
variant: config2.variant,
|
|
18252
|
-
duration: config2.duration
|
|
18253
|
-
});
|
|
18254
|
-
} catch {}
|
|
18255
|
-
}
|
|
18256
|
-
async function notifyWorkingProgress(client3, db, teamId) {
|
|
18257
|
-
const members = db.query("SELECT name, status FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamId);
|
|
18258
|
-
if (members.length === 0)
|
|
18259
|
-
return;
|
|
18260
|
-
const busy = members.filter((m) => m.status === "busy");
|
|
18261
|
-
try {
|
|
18262
|
-
if (busy.length === 0) {
|
|
18263
|
-
await client3.tui.showToast({
|
|
18264
|
-
title: "Team",
|
|
18265
|
-
message: "All teammates finished",
|
|
18266
|
-
variant: "success",
|
|
18267
|
-
duration: 4000
|
|
18268
|
-
});
|
|
18269
|
-
} else {
|
|
18270
|
-
const names = busy.map((m) => m.name).join(", ");
|
|
18271
|
-
await client3.tui.showToast({
|
|
18272
|
-
title: "Team",
|
|
18273
|
-
message: `Working: ${names} (${busy.length}/${members.length})`,
|
|
18274
|
-
variant: "info",
|
|
18275
|
-
duration: 5000
|
|
18276
|
-
});
|
|
18277
|
-
}
|
|
18278
|
-
} catch {}
|
|
18373
|
+
notifyLead(client3, db, teamInfo.teamId, `Teammate "${teamInfo.memberName}" had a session error: ${errMsg}. Check their session for details. They may be stuck and need investigation or shutdown.`);
|
|
18279
18374
|
}
|
|
18280
18375
|
|
|
18281
18376
|
// src/result-parser.ts
|
|
@@ -18544,6 +18639,7 @@ class ProgressTracker {
|
|
|
18544
18639
|
steps = new Map;
|
|
18545
18640
|
lastMessageAt = new Map;
|
|
18546
18641
|
lastTaskAt = new Map;
|
|
18642
|
+
busySince = new Map;
|
|
18547
18643
|
peerMessages = new Map;
|
|
18548
18644
|
reported = new Set;
|
|
18549
18645
|
chattyReported = new Set;
|
|
@@ -18598,14 +18694,18 @@ class ProgressTracker {
|
|
|
18598
18694
|
const recent = records.slice(-minSteps);
|
|
18599
18695
|
return recent.every((r) => r.outputTokens < threshold);
|
|
18600
18696
|
}
|
|
18601
|
-
|
|
18697
|
+
recordBusyStart(sessionId) {
|
|
18698
|
+
this.busySince.set(sessionId, Date.now());
|
|
18699
|
+
}
|
|
18700
|
+
lastActivityAt(sessionId) {
|
|
18602
18701
|
const records = this.steps.get(sessionId);
|
|
18603
|
-
|
|
18702
|
+
const lastStepAt = records && records.length > 0 ? records[records.length - 1].timestamp : 0;
|
|
18703
|
+
return Math.max(this.lastMessageAt.get(sessionId) ?? 0, this.lastTaskAt.get(sessionId) ?? 0, lastStepAt, this.busySince.get(sessionId) ?? 0);
|
|
18704
|
+
}
|
|
18705
|
+
isTimeStalled(sessionId, thresholdMs) {
|
|
18706
|
+
const baseline = this.lastActivityAt(sessionId);
|
|
18707
|
+
if (baseline === 0)
|
|
18604
18708
|
return false;
|
|
18605
|
-
const msgAt = this.lastMessageAt.get(sessionId) ?? 0;
|
|
18606
|
-
const taskAt = this.lastTaskAt.get(sessionId) ?? 0;
|
|
18607
|
-
const lastStep = records[records.length - 1]?.timestamp ?? 0;
|
|
18608
|
-
const baseline = Math.max(msgAt, taskAt, lastStep);
|
|
18609
18709
|
return Date.now() - baseline >= thresholdMs;
|
|
18610
18710
|
}
|
|
18611
18711
|
markReported(sessionId) {
|
|
@@ -18621,6 +18721,7 @@ class ProgressTracker {
|
|
|
18621
18721
|
this.steps.delete(sessionId);
|
|
18622
18722
|
this.lastMessageAt.delete(sessionId);
|
|
18623
18723
|
this.lastTaskAt.delete(sessionId);
|
|
18724
|
+
this.busySince.delete(sessionId);
|
|
18624
18725
|
this.peerMessages.delete(sessionId);
|
|
18625
18726
|
this.reported.delete(sessionId);
|
|
18626
18727
|
this.chattyReported.delete(sessionId);
|
|
@@ -19163,7 +19264,7 @@ function rAgents(t){
|
|
|
19163
19264
|
'<div class="flex items-center gap-2">'+
|
|
19164
19265
|
'<span class="w-[8px] h-[8px] rounded-full '+s.d+(m.status==='busy'?' pulse':'')+' shrink-0"></span>'+
|
|
19165
19266
|
'<span class="font-mono font-semibold text-[14px] truncate">'+E(m.name)+'</span>'+
|
|
19166
|
-
'<span class="text-[10px] px-1.5 py-[1px] rounded '+s.t+' bg-base-800/80 shrink-0">'+s.l+'</span>'+
|
|
19267
|
+
'<span class="text-[10px] px-1.5 py-[1px] rounded '+s.t+' bg-base-800/80 shrink-0">'+s.l+(m.lastNudgedAt?', nudged '+relT(m.lastNudgedAt):'')+'</span>'+
|
|
19167
19268
|
spark+
|
|
19168
19269
|
'<span class="text-[10px] text-txt-500 ml-auto shrink-0">'+E(m.agent)+'</span>'+
|
|
19169
19270
|
(m.model?chip(E(m.model),'muted'):'')+
|
|
@@ -19172,6 +19273,7 @@ function rAgents(t){
|
|
|
19172
19273
|
(mp?'<div class="mt-1 text-[12px] text-txt-400 truncate">Latest: '+E(mp)+'</div>':'')+
|
|
19173
19274
|
'<div class="mt-2 flex items-center gap-1.5 flex-wrap">'+
|
|
19174
19275
|
chip('status '+d,'muted')+chip('msg '+mi,'muted')+chip(E(m.executionStatus||m.status),m.status==='busy'?'blue':m.status==='error'?'red':'muted')+
|
|
19276
|
+
(m.isRetrying?chip('retrying'+(m.retryAttempt!=null?' (attempt '+m.retryAttempt+')':''),'amber'):'')+
|
|
19175
19277
|
(m.worktreeBranch?chip(E(m.worktreeBranch),'muted'):'')+
|
|
19176
19278
|
'</div></button>';
|
|
19177
19279
|
}).join('');
|
|
@@ -19195,6 +19297,8 @@ function openDrawer(name){
|
|
|
19195
19297
|
meta.push(chip(E(m.executionStatus||m.status),m.status==='busy'?'blue':m.status==='error'?'red':'muted'));
|
|
19196
19298
|
if(m.planApproval&&m.planApproval!=='none')meta.push(chip(E(m.planApproval),m.planApproval==='approved'?'green':m.planApproval==='rejected'?'red':'amber'));
|
|
19197
19299
|
meta.push(chip('spawned '+relT(m.timeCreated),'muted'));
|
|
19300
|
+
if(m.lastNudgedAt)meta.push(chip('nudged '+relT(m.lastNudgedAt),'amber'));
|
|
19301
|
+
if(m.isRetrying)meta.push(chip('retrying'+(m.retryAttempt!=null?' (attempt '+m.retryAttempt+')':'')+(m.retryMessage?': '+E(m.retryMessage):''),'amber'));
|
|
19198
19302
|
if(m.worktreeBranch)meta.push(chip(E(m.worktreeBranch),'muted'));
|
|
19199
19303
|
h+='<div class="flex flex-wrap gap-1.5 mb-4 pb-4 border-b border-base-800/50">'+meta.join('')+'</div>';
|
|
19200
19304
|
// Prompt
|
|
@@ -19262,7 +19366,7 @@ function rDrawerActivityUpdate(){
|
|
|
19262
19366
|
el.innerHTML='<div class="text-txt-500 text-[12px]">No activity recorded</div>';
|
|
19263
19367
|
return;
|
|
19264
19368
|
}
|
|
19265
|
-
var html=drawerActivity.map(function(a){
|
|
19369
|
+
var html=drawerActivity.slice().reverse().map(function(a){
|
|
19266
19370
|
var icon='\\u25CF',color='text-txt-500',label=a.type;
|
|
19267
19371
|
if(a.type==='tool_call'){icon='\\u25B8';color='text-blue-400';label=a.tool||'tool'}
|
|
19268
19372
|
if(a.type==='tool_result'){icon='\\u25B8';color=a.error?'text-red-400':'text-emerald-400';label=a.tool||'tool'}
|
|
@@ -19458,10 +19562,11 @@ function parseDependsOn(value) {
|
|
|
19458
19562
|
}
|
|
19459
19563
|
return [];
|
|
19460
19564
|
}
|
|
19565
|
+
var ENSEMBLE_STATE_VERSION = 1;
|
|
19461
19566
|
function buildState(db) {
|
|
19462
19567
|
const projects = db.query("SELECT id, name, path, status, time_created, time_updated FROM project ORDER BY time_updated DESC").all();
|
|
19463
|
-
const teams = db.query("SELECT id, name, project_id, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all();
|
|
19464
|
-
const memberStmt = db.query("SELECT name, agent, status, execution_status, session_id, worktree_branch, prompt, model, plan_approval, time_created, time_updated FROM team_member WHERE team_id = ?");
|
|
19568
|
+
const teams = db.query("SELECT id, name, project_id, lead_session_id, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all();
|
|
19569
|
+
const memberStmt = db.query("SELECT name, agent, status, execution_status, session_id, worktree_branch, prompt, model, plan_approval, time_created, time_updated, last_nudged_at, retry_until, retry_attempt, retry_provider, retry_message FROM team_member WHERE team_id = ?");
|
|
19465
19570
|
const taskStmt = db.query("SELECT id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task WHERE team_id = ?");
|
|
19466
19571
|
const msgStmt = db.query("SELECT id, from_name, to_name, content, delivered, read, time_created FROM team_message WHERE team_id = ? ORDER BY time_created DESC LIMIT 50");
|
|
19467
19572
|
const mappedTeams = teams.map((t) => {
|
|
@@ -19476,12 +19581,19 @@ function buildState(db) {
|
|
|
19476
19581
|
model: m.model,
|
|
19477
19582
|
planApproval: m.plan_approval,
|
|
19478
19583
|
timeCreated: m.time_created,
|
|
19479
|
-
timeUpdated: m.time_updated
|
|
19584
|
+
timeUpdated: m.time_updated,
|
|
19585
|
+
lastNudgedAt: m.last_nudged_at,
|
|
19586
|
+
isRetrying: m.retry_until !== null && m.retry_until > Date.now(),
|
|
19587
|
+
retryUntil: m.retry_until,
|
|
19588
|
+
retryAttempt: m.retry_attempt,
|
|
19589
|
+
retryProvider: m.retry_provider,
|
|
19590
|
+
retryMessage: m.retry_message
|
|
19480
19591
|
}));
|
|
19481
19592
|
return {
|
|
19482
19593
|
id: t.id,
|
|
19483
19594
|
name: t.name,
|
|
19484
19595
|
projectId: t.project_id,
|
|
19596
|
+
leadSessionId: t.lead_session_id,
|
|
19485
19597
|
status: t.status,
|
|
19486
19598
|
leadAgent: t.lead_agent,
|
|
19487
19599
|
timeCreated: t.time_created,
|
|
@@ -19514,6 +19626,7 @@ function buildState(db) {
|
|
|
19514
19626
|
teamsByProject.set(projectId, [...teamsByProject.get(projectId) ?? [], team]);
|
|
19515
19627
|
});
|
|
19516
19628
|
return {
|
|
19629
|
+
version: ENSEMBLE_STATE_VERSION,
|
|
19517
19630
|
projects: projects.flatMap((project) => {
|
|
19518
19631
|
const projectTeams = teamsByProject.get(project.id) ?? [];
|
|
19519
19632
|
if (projectTeams.length === 0)
|
|
@@ -19543,10 +19656,25 @@ function sendJson(res, data) {
|
|
|
19543
19656
|
});
|
|
19544
19657
|
res.end(JSON.stringify(data));
|
|
19545
19658
|
}
|
|
19659
|
+
function parseMessageTime(time3) {
|
|
19660
|
+
if (typeof time3 === "object" && time3 !== null) {
|
|
19661
|
+
const created = time3.created;
|
|
19662
|
+
if (typeof created === "number" && Number.isFinite(created))
|
|
19663
|
+
return created;
|
|
19664
|
+
}
|
|
19665
|
+
if (typeof time3 === "number" && Number.isFinite(time3))
|
|
19666
|
+
return time3;
|
|
19667
|
+
if (typeof time3 === "string") {
|
|
19668
|
+
const ms = new Date(time3).getTime();
|
|
19669
|
+
if (!Number.isNaN(ms))
|
|
19670
|
+
return ms;
|
|
19671
|
+
}
|
|
19672
|
+
return Date.now();
|
|
19673
|
+
}
|
|
19546
19674
|
function parseMessageParts(parts, msgInfo) {
|
|
19547
19675
|
const entries = [];
|
|
19548
19676
|
const info = msgInfo ?? {};
|
|
19549
|
-
const timestamp =
|
|
19677
|
+
const timestamp = parseMessageTime(info.time);
|
|
19550
19678
|
for (const part of parts) {
|
|
19551
19679
|
if (typeof part !== "object" || part === null)
|
|
19552
19680
|
continue;
|
|
@@ -19697,9 +19825,14 @@ async function executeTeamCreate(deps, args, sessionId) {
|
|
|
19697
19825
|
throw new Error(projectNameError);
|
|
19698
19826
|
}
|
|
19699
19827
|
const projectId = deps.directory;
|
|
19700
|
-
const existing = deps.db.query("SELECT id FROM team WHERE name = ? AND project_id = ? AND status = 'active'").get(args.name, projectId);
|
|
19701
|
-
if (existing)
|
|
19702
|
-
|
|
19828
|
+
const existing = deps.db.query("SELECT id, lead_session_id FROM team WHERE name = ? AND project_id = ? AND status = 'active'").get(args.name, projectId);
|
|
19829
|
+
if (existing) {
|
|
19830
|
+
if (await isSessionAlive(deps.client, existing.lead_session_id)) {
|
|
19831
|
+
throw new Error(`Team "${args.name}" already exists`);
|
|
19832
|
+
}
|
|
19833
|
+
deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), existing.id]);
|
|
19834
|
+
deps.registry.unregisterTeam(existing.id);
|
|
19835
|
+
}
|
|
19703
19836
|
const lead = findTeamBySession(deps.db, deps.registry, sessionId);
|
|
19704
19837
|
if (lead)
|
|
19705
19838
|
throw new Error(`This session already belongs to team "${lead.teamName}"`);
|
|
@@ -19800,12 +19933,6 @@ async function executeTeamClaim(deps, args, sessionId) {
|
|
|
19800
19933
|
// src/tools/team-spawn.ts
|
|
19801
19934
|
init_merge_helper();
|
|
19802
19935
|
var spawnFailures = new Map;
|
|
19803
|
-
function parseModelId(model) {
|
|
19804
|
-
const slash = model.indexOf("/");
|
|
19805
|
-
if (slash <= 0 || slash === model.length - 1)
|
|
19806
|
-
return;
|
|
19807
|
-
return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
|
|
19808
|
-
}
|
|
19809
19936
|
function resolveModel(explicitModel, agentType, teamMemberCount, config2) {
|
|
19810
19937
|
if (explicitModel)
|
|
19811
19938
|
return explicitModel;
|
|
@@ -19952,6 +20079,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
19952
20079
|
log(`spawn:model name=${args.name} model=${resolvedModel}`);
|
|
19953
20080
|
deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, workspace_id, plan_approval, time_created, time_updated)
|
|
19954
20081
|
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]);
|
|
20082
|
+
deps.progressTracker.recordBusyStart(childSessionId);
|
|
19955
20083
|
deps.registry.register(teamInfo.teamId, args.name, childSessionId);
|
|
19956
20084
|
let claimedTaskContent = null;
|
|
19957
20085
|
let claimWarning = null;
|
|
@@ -20031,7 +20159,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
20031
20159
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
|
|
20032
20160
|
deps.registry.unregister(childSessionId);
|
|
20033
20161
|
if (claimedTaskContent) {
|
|
20034
|
-
deps.db.run("UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE id = ? AND assignee = ?", [Date.now(), args.claim_task, args.name]);
|
|
20162
|
+
deps.db.run("UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE id = ? AND assignee = ? AND status = 'in_progress'", [Date.now(), args.claim_task, args.name]);
|
|
20035
20163
|
}
|
|
20036
20164
|
deps.client.session.abort({ sessionID: childSessionId }).catch(() => {});
|
|
20037
20165
|
if (workspaceId) {
|
|
@@ -20047,12 +20175,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
20047
20175
|
variant: "error",
|
|
20048
20176
|
duration: 8000
|
|
20049
20177
|
}).catch(() => {});
|
|
20050
|
-
|
|
20051
|
-
teamId: teamInfo.teamId,
|
|
20052
|
-
from: "system",
|
|
20053
|
-
to: "lead",
|
|
20054
|
-
content: `Teammate "${args.name}" failed to start and was removed${modelInfo}. Error: ${errMsg}. You may retry the spawn.`
|
|
20055
|
-
});
|
|
20178
|
+
notifyLead(deps.client, deps.db, teamInfo.teamId, `Teammate "${args.name}" failed to start and was removed${modelInfo}. Error: ${errMsg}. You may retry the spawn.`);
|
|
20056
20179
|
} catch {}
|
|
20057
20180
|
});
|
|
20058
20181
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
|
|
@@ -20066,7 +20189,32 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
20066
20189
|
// src/tools/team-message.ts
|
|
20067
20190
|
async function executeTeamMessage(deps, args, sessionId) {
|
|
20068
20191
|
const teamInfo = requireTeamMember(deps, sessionId);
|
|
20192
|
+
if (args.force && teamInfo.role !== "lead") {
|
|
20193
|
+
throw new Error("Only the lead can force-deliver a message to a completed teammate.");
|
|
20194
|
+
}
|
|
20069
20195
|
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
20196
|
+
if (args.model !== undefined) {
|
|
20197
|
+
if (teamInfo.role !== "lead")
|
|
20198
|
+
throw new Error("Only the lead can update a teammate's model.");
|
|
20199
|
+
const parsed = parseModelId(args.model);
|
|
20200
|
+
if (!parsed)
|
|
20201
|
+
throw new Error(`Invalid model "${args.model}" — expected "provider/model" format (e.g. "anthropic/claude-sonnet").`);
|
|
20202
|
+
const member = deps.db.query("SELECT status FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.to);
|
|
20203
|
+
if (!member)
|
|
20204
|
+
throw new Error(`Teammate "${args.to}" not found in team "${teamInfo.teamName}".`);
|
|
20205
|
+
if (member.status === "shutdown" || member.status === "error") {
|
|
20206
|
+
throw new Error(`Teammate "${args.to}" is shut down — spawn a new teammate instead of updating the model.`);
|
|
20207
|
+
}
|
|
20208
|
+
deps.db.run("UPDATE team_member SET model = ?, time_updated = ? WHERE team_id = ? AND name = ?", [args.model, Date.now(), teamInfo.teamId, args.to]);
|
|
20209
|
+
log(`team_message:model-update to=${args.to} model=${args.model}`);
|
|
20210
|
+
if (!args.text) {
|
|
20211
|
+
return `Updated ${args.to}'s model to ${args.model}. It applies on their next turn.`;
|
|
20212
|
+
}
|
|
20213
|
+
}
|
|
20214
|
+
if (args.text === undefined) {
|
|
20215
|
+
throw new Error("team_message requires either 'text' or 'model'.");
|
|
20216
|
+
}
|
|
20217
|
+
const text = args.text;
|
|
20070
20218
|
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
|
|
20071
20219
|
if (!recipientSessionId && args.to !== "lead") {
|
|
20072
20220
|
if (args.approve || args.reject) {
|
|
@@ -20076,14 +20224,14 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
20076
20224
|
teamId: teamInfo.teamId,
|
|
20077
20225
|
from: senderName,
|
|
20078
20226
|
to: args.to,
|
|
20079
|
-
content:
|
|
20227
|
+
content: text
|
|
20080
20228
|
});
|
|
20081
20229
|
log(`team_message:queued from=${senderName} to=${args.to} (recipient not yet spawned)`);
|
|
20082
20230
|
return `Message queued for ${args.to} — they haven't been spawned yet. It will be delivered when they join the team.`;
|
|
20083
20231
|
}
|
|
20084
20232
|
if (!recipientSessionId)
|
|
20085
20233
|
throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`);
|
|
20086
|
-
let messageText =
|
|
20234
|
+
let messageText = text;
|
|
20087
20235
|
if (args.approve || args.reject) {
|
|
20088
20236
|
if (args.approve && args.reject) {
|
|
20089
20237
|
throw new Error("Cannot both approve and reject a plan.");
|
|
@@ -20120,13 +20268,15 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
20120
20268
|
});
|
|
20121
20269
|
return `Message sent to ${args.to}.`;
|
|
20122
20270
|
}
|
|
20123
|
-
if (hasReportedCompletion(deps.db, teamInfo.teamId, args.to)) {
|
|
20124
|
-
return `Message stored for ${args.to} (teammate has completed their task — message will not wake them).`;
|
|
20271
|
+
if (hasReportedCompletion(deps.db, teamInfo.teamId, args.to) && !args.force) {
|
|
20272
|
+
return `Message stored for ${args.to} (teammate has completed their task — message will not wake them). Pass force:true to re-activate them.`;
|
|
20125
20273
|
}
|
|
20126
20274
|
const deliveryText = `[Team message from ${senderName}]: ${messageText}`;
|
|
20275
|
+
const recipientModel = getMemberModel(deps.db, teamInfo.teamId, args.to);
|
|
20127
20276
|
deps.client.session.promptAsync({
|
|
20128
20277
|
sessionID: recipientSessionId,
|
|
20129
|
-
parts: [{ type: "text", text: deliveryText }]
|
|
20278
|
+
parts: [{ type: "text", text: deliveryText }],
|
|
20279
|
+
...recipientModel ? { model: recipientModel } : {}
|
|
20130
20280
|
}).then(() => {
|
|
20131
20281
|
markDelivered(deps.db, msgId);
|
|
20132
20282
|
}).catch((err) => {
|
|
@@ -20241,6 +20391,9 @@ async function preserveAndAbort(deps, teamId, memberName, sessionId, worktreeBra
|
|
|
20241
20391
|
await deps.client.session.abort({ sessionID: sessionId });
|
|
20242
20392
|
} catch {}
|
|
20243
20393
|
deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamId, memberName]);
|
|
20394
|
+
const released = releaseMemberTasks(deps.db, teamId, memberName);
|
|
20395
|
+
if (released > 0)
|
|
20396
|
+
log(`shutdown:tasks:released name=${memberName} count=${released}`);
|
|
20244
20397
|
}
|
|
20245
20398
|
async function getBranchStatus(deps, teamId, memberName, worktreeDir, isDirty, commitCount) {
|
|
20246
20399
|
const row = deps.db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamId, memberName);
|
|
@@ -20710,9 +20863,9 @@ Review with: git diff`);
|
|
|
20710
20863
|
|
|
20711
20864
|
// src/tools/team-merge.ts
|
|
20712
20865
|
init_merge_helper();
|
|
20713
|
-
async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, delBranch = deleteBranch, overlapCheck = getOverlappingFiles) {
|
|
20866
|
+
async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, delBranch = deleteBranch, overlapCheck = getOverlappingFiles, commitCount = countBranchCommits, isDirty = checkWorktreeDirty) {
|
|
20714
20867
|
const teamInfo = requireLead(deps, sessionId);
|
|
20715
|
-
const member = deps.db.query("SELECT status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
|
|
20868
|
+
const member = deps.db.query("SELECT status, worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
|
|
20716
20869
|
if (!member)
|
|
20717
20870
|
throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
|
|
20718
20871
|
if (member.status !== "shutdown" && member.status !== "error") {
|
|
@@ -20723,6 +20876,21 @@ async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, del
|
|
|
20723
20876
|
}
|
|
20724
20877
|
const branch = member.worktree_branch;
|
|
20725
20878
|
log(`merge:start member=${args.member} branch=${branch}`);
|
|
20879
|
+
const commits = await commitCount(branch, deps.directory);
|
|
20880
|
+
if (commits === 0) {
|
|
20881
|
+
const dirty = member.worktree_dir ? await isDirty(member.worktree_dir).catch(() => false) : false;
|
|
20882
|
+
if (dirty) {
|
|
20883
|
+
return [
|
|
20884
|
+
`Nothing to merge for "${args.member}" — their branch (${branch}) has no commits.`,
|
|
20885
|
+
`Their worktree still has uncommitted changes that were never captured on the branch.`,
|
|
20886
|
+
`This work will be permanently lost if the worktree is removed (e.g. via team_cleanup).`,
|
|
20887
|
+
member.worktree_dir ? `Worktree: ${member.worktree_dir}` : ``,
|
|
20888
|
+
`Recover manually (copy files out of the worktree) before shutting down/cleaning up, or re-spawn and instruct them to commit their changes before reporting done.`
|
|
20889
|
+
].filter(Boolean).join(`
|
|
20890
|
+
`);
|
|
20891
|
+
}
|
|
20892
|
+
return `Nothing to merge for "${args.member}" — their branch (${branch}) has no commits and no uncommitted changes. They made no changes.`;
|
|
20893
|
+
}
|
|
20726
20894
|
try {
|
|
20727
20895
|
const overlap = await overlapCheck(branch, deps.directory);
|
|
20728
20896
|
if (overlap.length > 0) {
|
|
@@ -20875,7 +21043,16 @@ ${r.content}`;
|
|
|
20875
21043
|
|
|
20876
21044
|
// src/tools/team-status.ts
|
|
20877
21045
|
var lastCallTime = new Map;
|
|
21046
|
+
var lastKnownState = new Map;
|
|
20878
21047
|
var RATE_LIMIT_MS = 30000;
|
|
21048
|
+
function computeStateMarker(deps, teamId) {
|
|
21049
|
+
const row = deps.db.query(`SELECT MAX(x) as marker FROM (
|
|
21050
|
+
SELECT MAX(time_updated) as x FROM team_member WHERE team_id = ?
|
|
21051
|
+
UNION SELECT MAX(time_updated) FROM team_task WHERE team_id = ?
|
|
21052
|
+
UNION SELECT MAX(time_created) FROM team_message WHERE team_id = ?
|
|
21053
|
+
)`).get(teamId, teamId, teamId);
|
|
21054
|
+
return row?.marker ?? 0;
|
|
21055
|
+
}
|
|
20879
21056
|
function formatDuration(ms) {
|
|
20880
21057
|
if (ms < 60000)
|
|
20881
21058
|
return `${Math.floor(ms / 1000)}s`;
|
|
@@ -20887,11 +21064,13 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
20887
21064
|
const teamInfo = requireTeamMember(deps, sessionId);
|
|
20888
21065
|
const now = Date.now();
|
|
20889
21066
|
const last = lastCallTime.get(teamInfo.teamId);
|
|
20890
|
-
|
|
21067
|
+
const currentMarker = computeStateMarker(deps, teamInfo.teamId);
|
|
21068
|
+
if (last !== undefined && now - last < RATE_LIMIT_MS && lastKnownState.get(teamInfo.teamId) === currentMarker) {
|
|
20891
21069
|
return "No changes since last check.";
|
|
20892
21070
|
}
|
|
20893
21071
|
lastCallTime.set(teamInfo.teamId, now);
|
|
20894
|
-
|
|
21072
|
+
lastKnownState.set(teamInfo.teamId, currentMarker);
|
|
21073
|
+
const members = deps.db.query("SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval, time_updated, last_nudged_at, retry_until, retry_attempt FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
|
|
20895
21074
|
const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
|
|
20896
21075
|
const lines = [];
|
|
20897
21076
|
lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
|
|
@@ -20905,9 +21084,12 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
20905
21084
|
const duration3 = formatDuration(now - m.time_updated);
|
|
20906
21085
|
const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
|
|
20907
21086
|
const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : "";
|
|
21087
|
+
const nudged = m.last_nudged_at ? `, nudged ${formatDuration(now - m.last_nudged_at)} ago` : "";
|
|
21088
|
+
const isRetrying = m.retry_until !== null && m.retry_until > now;
|
|
21089
|
+
const retrying = isRetrying ? `, retrying (attempt ${m.retry_attempt}, ~${formatDuration(m.retry_until - now)})` : "";
|
|
20908
21090
|
const lastMsg = deps.db.query("SELECT MAX(time_created) as last_msg FROM team_message WHERE team_id = ? AND from_name = ?").get(teamInfo.teamId, m.name);
|
|
20909
21091
|
const msgInfo = lastMsg?.last_msg ? `last msg: ${formatDuration(now - lastMsg.last_msg)} ago` : "no messages yet";
|
|
20910
|
-
lines.push(` ${m.name} [${statusIcon} ${duration3}, ${msgInfo}${plan}] agent: ${m.agent}${branch}`);
|
|
21092
|
+
lines.push(` ${m.name} [${statusIcon} ${duration3}, ${msgInfo}${plan}${nudged}${retrying}] agent: ${m.agent}${branch}`);
|
|
20911
21093
|
const task = deps.db.query("SELECT content FROM team_task WHERE team_id = ? AND assignee = ? AND status = 'in_progress' LIMIT 1").get(teamInfo.teamId, m.name);
|
|
20912
21094
|
if (task) {
|
|
20913
21095
|
const truncated = task.content.length > 80 ? `${task.content.slice(0, 80)}...` : task.content;
|
|
@@ -21091,18 +21273,32 @@ class Watchdog {
|
|
|
21091
21273
|
const timeStalled = this.progressTracker.isTimeStalled(member.session_id, this.stallThresholdMs);
|
|
21092
21274
|
if (!tokenStalled && !timeStalled)
|
|
21093
21275
|
continue;
|
|
21094
|
-
this.
|
|
21276
|
+
if (hasReportedCompletion(this.db, member.team_id, member.name)) {
|
|
21277
|
+
log(`watchdog:stall:skip member=${member.name} team=${member.team_id} reason=already-reported-completion`);
|
|
21278
|
+
continue;
|
|
21279
|
+
}
|
|
21095
21280
|
const reason = tokenStalled ? "low output tokens" : "no communication";
|
|
21281
|
+
const recentNudgeRow = this.db.query("SELECT last_nudged_at FROM team_member WHERE team_id = ? AND name = ?").get(member.team_id, member.name);
|
|
21282
|
+
if (recentNudgeRow?.last_nudged_at) {
|
|
21283
|
+
const nudgedAt = recentNudgeRow.last_nudged_at;
|
|
21284
|
+
const activeSinceNudge = (this.progressTracker?.lastActivityAt(member.session_id) ?? 0) >= nudgedAt;
|
|
21285
|
+
if (!activeSinceNudge && Date.now() - nudgedAt < this.stallThresholdMs) {
|
|
21286
|
+
log(`watchdog:stall:skip member=${member.name} team=${member.team_id} reason=recently-nudged`);
|
|
21287
|
+
continue;
|
|
21288
|
+
}
|
|
21289
|
+
}
|
|
21290
|
+
this.db.run("UPDATE team_member SET last_nudged_at = ? WHERE team_id = ? AND name = ?", [Date.now(), member.team_id, member.name]);
|
|
21291
|
+
const stallModel = getMemberModel(this.db, member.team_id, member.name);
|
|
21096
21292
|
this.client.session.promptAsync({
|
|
21097
21293
|
sessionID: member.session_id,
|
|
21098
|
-
parts: [{ type: "text", text: "[System]: You appear stalled — no progress detected. Report your current status to the lead via team_message, or wrap up your work." }]
|
|
21099
|
-
|
|
21100
|
-
|
|
21101
|
-
|
|
21102
|
-
|
|
21103
|
-
|
|
21104
|
-
content: `Teammate "${member.name}" appears stalled (${reason}). Consider checking on them via team_message or shutting them down.`
|
|
21294
|
+
parts: [{ type: "text", text: "[System]: You appear stalled — no progress detected. Report your current status to the lead via team_message, or wrap up your work." }],
|
|
21295
|
+
...stallModel ? { model: stallModel } : {}
|
|
21296
|
+
}).then(() => {
|
|
21297
|
+
this.progressTracker.markReported(member.session_id);
|
|
21298
|
+
}).catch((err) => {
|
|
21299
|
+
log(`watchdog:stall:nudge-failed member=${member.name} team=${member.team_id} session=${member.session_id} err=${err instanceof Error ? err.message : String(err)}`);
|
|
21105
21300
|
});
|
|
21301
|
+
notifyLead(this.client, this.db, member.team_id, `Teammate "${member.name}" appears stalled (${reason}). Consider checking on them via team_message or shutting them down.`);
|
|
21106
21302
|
try {
|
|
21107
21303
|
await this.client.tui.showToast({
|
|
21108
21304
|
title: "Team",
|
|
@@ -21125,17 +21321,20 @@ class Watchdog {
|
|
|
21125
21321
|
continue;
|
|
21126
21322
|
if (!this.progressTracker.isChatty(member.session_id, this.peerMessageLimit, this.peerMessageWindowMs))
|
|
21127
21323
|
continue;
|
|
21324
|
+
if (hasReportedCompletion(this.db, member.team_id, member.name)) {
|
|
21325
|
+
log(`watchdog:chatty:skip member=${member.name} team=${member.team_id} reason=already-reported-completion`);
|
|
21326
|
+
continue;
|
|
21327
|
+
}
|
|
21128
21328
|
this.progressTracker.markChattyReported(member.session_id);
|
|
21329
|
+
const chattyModel = getMemberModel(this.db, member.team_id, member.name);
|
|
21129
21330
|
this.client.session.promptAsync({
|
|
21130
21331
|
sessionID: member.session_id,
|
|
21131
|
-
parts: [{ type: "text", text: "[System]: You've sent several messages to teammates. Focus on completing your task and send your results to the lead via team_message." }]
|
|
21132
|
-
|
|
21133
|
-
|
|
21134
|
-
|
|
21135
|
-
from: "system",
|
|
21136
|
-
to: "lead",
|
|
21137
|
-
content: `Agent "${member.name}" is sending many peer messages and may be over-coordinating. Consider checking on them.`
|
|
21332
|
+
parts: [{ type: "text", text: "[System]: You've sent several messages to teammates. Focus on completing your task and send your results to the lead via team_message." }],
|
|
21333
|
+
...chattyModel ? { model: chattyModel } : {}
|
|
21334
|
+
}).catch((err) => {
|
|
21335
|
+
log(`watchdog:chatty:nudge-failed member=${member.name} team=${member.team_id} session=${member.session_id} err=${err instanceof Error ? err.message : String(err)}`);
|
|
21138
21336
|
});
|
|
21337
|
+
notifyLead(this.client, this.db, member.team_id, `Agent "${member.name}" is sending many peer messages and may be over-coordinating. Consider checking on them.`);
|
|
21139
21338
|
log(`watchdog:chatty member=${member.name} limit=${this.peerMessageLimit}`);
|
|
21140
21339
|
}
|
|
21141
21340
|
}
|
|
@@ -21163,6 +21362,10 @@ class Watchdog {
|
|
|
21163
21362
|
}
|
|
21164
21363
|
}
|
|
21165
21364
|
this.db.run("UPDATE team_member SET status = 'error', execution_status = 'timed_out', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), member.team_id, member.name]);
|
|
21365
|
+
const released = releaseMemberTasks(this.db, member.team_id, member.name);
|
|
21366
|
+
if (released > 0)
|
|
21367
|
+
log(`watchdog:tasks:released name=${member.name} count=${released}`);
|
|
21368
|
+
notifyLead(this.client, this.db, member.team_id, `Teammate "${member.name}" timed out after exceeding the busy time limit and was aborted. Their in-progress work has been released. Review their session, then re-spawn or reassign the task if needed.`);
|
|
21166
21369
|
try {
|
|
21167
21370
|
await this.client.session.abort({ sessionID: member.session_id });
|
|
21168
21371
|
} catch {}
|
|
@@ -21213,9 +21416,15 @@ var plugin = async (input) => {
|
|
|
21213
21416
|
const rawClient = new OpencodeClient({ client: pluginTransport });
|
|
21214
21417
|
initLog(rawClient);
|
|
21215
21418
|
const client3 = wrapThrowingClient(rawClient);
|
|
21216
|
-
const deps = { db, registry: registry2, tracker, purgeApprovals, client: client3, directory: input.directory, config: config2 };
|
|
21419
|
+
const deps = { db, registry: registry2, tracker, purgeApprovals, client: client3, directory: input.directory, config: config2, progressTracker };
|
|
21217
21420
|
if (!isWorktreeInstance(input.directory)) {
|
|
21218
21421
|
log("init:recovery:start (main instance)");
|
|
21422
|
+
recoverOrphanedTeams(db, client3, input.directory, registry2).then((result) => {
|
|
21423
|
+
if (result.archived > 0)
|
|
21424
|
+
log(`init:recovery:orphaned-teams-archived=${result.archived}`);
|
|
21425
|
+
}).catch((err) => {
|
|
21426
|
+
log(`init:recover-orphaned-teams:failed err=${err instanceof Error ? err.message : String(err)}`);
|
|
21427
|
+
});
|
|
21219
21428
|
const recovery = await recoverStaleMembers(db, client3, input.directory);
|
|
21220
21429
|
if (recovery.interrupted > 0) {
|
|
21221
21430
|
log(`init:recovery:interrupted=${recovery.interrupted}`);
|
|
@@ -21266,7 +21475,8 @@ var plugin = async (input) => {
|
|
|
21266
21475
|
if (event.type === "session.status") {
|
|
21267
21476
|
const { sessionID, status } = event.properties;
|
|
21268
21477
|
const statusType = status.type;
|
|
21269
|
-
const
|
|
21478
|
+
const retryPayload = statusType === "retry" ? status : undefined;
|
|
21479
|
+
const transition = handleSessionStatusEvent(db, registry2, sessionID, statusType, retryPayload);
|
|
21270
21480
|
if (transition) {
|
|
21271
21481
|
if (transition.to === "shutdown") {
|
|
21272
21482
|
notifyTeamEvent(client3, "shutdown", { memberName: transition.memberName });
|
|
@@ -21282,12 +21492,7 @@ var plugin = async (input) => {
|
|
|
21282
21492
|
nudgedMembers.add(fastIdleKey);
|
|
21283
21493
|
const modelInfo = memberInfo.model ? ` (model: ${memberInfo.model})` : "";
|
|
21284
21494
|
log(`fast-idle: ${transition.memberName} went idle ${Math.round(spawnAge / 1000)}s after spawn with 0 messages${modelInfo}`);
|
|
21285
|
-
|
|
21286
|
-
teamId: transition.teamId,
|
|
21287
|
-
from: "system",
|
|
21288
|
-
to: "lead",
|
|
21289
|
-
content: `Warning: Teammate "${transition.memberName}" went idle immediately after spawning with no output${modelInfo}. This usually means the model failed to start (authentication error, invalid model, or provider issue). Check your API key and model configuration, then retry the spawn.`
|
|
21290
|
-
});
|
|
21495
|
+
notifyLead(client3, db, transition.teamId, `Warning: Teammate "${transition.memberName}" went idle immediately after spawning with no output${modelInfo}. This usually means the model failed to start (authentication error, invalid model, or provider issue). Check your API key and model configuration, then retry the spawn.`);
|
|
21291
21496
|
client3.tui.showToast({
|
|
21292
21497
|
title: "Team",
|
|
21293
21498
|
message: `${transition.memberName} failed to produce output${modelInfo}`,
|
|
@@ -21301,13 +21506,19 @@ var plugin = async (input) => {
|
|
|
21301
21506
|
if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName) && !hasReportedCompletion(db, transition.teamId, transition.memberName)) {
|
|
21302
21507
|
nudgedMembers.add(nudgeKey);
|
|
21303
21508
|
log(`nudge:idle-without-report name=${transition.memberName}`);
|
|
21509
|
+
const nudgeModel = getMemberModel(db, transition.teamId, transition.memberName);
|
|
21304
21510
|
client3.session.promptAsync({
|
|
21305
21511
|
sessionID,
|
|
21306
|
-
parts: [{ type: "text", text: "[System]: You completed your work but did not report results. Send your findings to the lead via team_message now." }]
|
|
21307
|
-
|
|
21512
|
+
parts: [{ type: "text", text: "[System]: You completed your work but did not report results. Send your findings to the lead via team_message now." }],
|
|
21513
|
+
...nudgeModel ? { model: nudgeModel } : {}
|
|
21514
|
+
}).catch((err) => {
|
|
21515
|
+
log(`nudge:idle-without-report:failed name=${transition.memberName} team=${transition.teamId} err=${err instanceof Error ? err.message : String(err)}`);
|
|
21516
|
+
});
|
|
21308
21517
|
}
|
|
21309
21518
|
} else if (transition.to === "error") {
|
|
21310
21519
|
notifyTeamEvent(client3, "error", { memberName: transition.memberName });
|
|
21520
|
+
} else if (transition.to === "busy") {
|
|
21521
|
+
progressTracker.recordBusyStart(sessionID);
|
|
21311
21522
|
} else if (transition.to === "retry") {
|
|
21312
21523
|
try {
|
|
21313
21524
|
await client3.tui.showToast({
|
|
@@ -21360,9 +21571,11 @@ var plugin = async (input) => {
|
|
|
21360
21571
|
const peerMsgs = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 AND time_created < ?").get(member.team_id, member.name, staleThreshold);
|
|
21361
21572
|
if (peerMsgs.c > 0) {
|
|
21362
21573
|
log(`wake-peer: ${member.name} has ${peerMsgs.c} pending peer messages`);
|
|
21574
|
+
const peerModel = getMemberModel(db, member.team_id, member.name);
|
|
21363
21575
|
client3.session.promptAsync({
|
|
21364
21576
|
sessionID,
|
|
21365
|
-
parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates]` }]
|
|
21577
|
+
parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates]` }],
|
|
21578
|
+
...peerModel ? { model: peerModel } : {}
|
|
21366
21579
|
}).catch((err) => {
|
|
21367
21580
|
log(`wake-peer:failed err=${err instanceof Error ? err.message : String(err)}`);
|
|
21368
21581
|
});
|
|
@@ -21378,7 +21591,7 @@ var plugin = async (input) => {
|
|
|
21378
21591
|
}
|
|
21379
21592
|
if (event.type === "session.error") {
|
|
21380
21593
|
const props = event.properties;
|
|
21381
|
-
handleSessionErrorEvent(db, registry2, props.sessionID, props.error);
|
|
21594
|
+
handleSessionErrorEvent(db, registry2, client3, props.sessionID, props.error);
|
|
21382
21595
|
}
|
|
21383
21596
|
if (event.type === "message.part.updated") {
|
|
21384
21597
|
const part = event.properties.part;
|
|
@@ -21473,19 +21686,21 @@ var plugin = async (input) => {
|
|
|
21473
21686
|
}
|
|
21474
21687
|
}),
|
|
21475
21688
|
team_message: tool({
|
|
21476
|
-
description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead.",
|
|
21689
|
+
description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead. " + "Lead only: pass 'model' (provider/model) to update a teammate's model in-place — it applies on their next turn. " + "When updating a model, 'text' is optional; omit it to change the model without sending a message.",
|
|
21477
21690
|
args: {
|
|
21478
21691
|
to: tool.schema.string().describe("Recipient name ('lead' or teammate name)"),
|
|
21479
|
-
text: tool.schema.string().describe("Message content (max 10KB)"),
|
|
21692
|
+
text: tool.schema.string().optional().describe("Message content (max 10KB). Optional only when 'model' is provided."),
|
|
21480
21693
|
approve: tool.schema.boolean().optional().describe("Approve a teammate's plan (only when recipient has plan_approval='pending')"),
|
|
21481
|
-
reject: tool.schema.string().optional().describe("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')")
|
|
21694
|
+
reject: tool.schema.string().optional().describe("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')"),
|
|
21695
|
+
force: tool.schema.boolean().optional().describe("Lead only. Re-activate a teammate who already reported task completion (normally their session won't be woken again). Use for legitimate follow-on work — e.g. the next round of a multi-round debate — not for courtesy replies."),
|
|
21696
|
+
model: tool.schema.string().optional().describe("Lead only: update the recipient teammate's model in-place, in 'provider/model' format (e.g. 'anthropic/claude-sonnet').")
|
|
21482
21697
|
},
|
|
21483
21698
|
async execute(args, ctx) {
|
|
21484
21699
|
const result = await executeTeamMessage(deps, args, ctx.sessionID);
|
|
21485
21700
|
progressTracker.recordMessage(ctx.sessionID);
|
|
21486
21701
|
if (args.to !== "lead")
|
|
21487
21702
|
progressTracker.recordPeerMessage(ctx.sessionID);
|
|
21488
|
-
ctx.metadata({ title: `Message → ${args.to}` });
|
|
21703
|
+
ctx.metadata({ title: args.model ? `Set ${args.to} model → ${args.model}` : `Message → ${args.to}` });
|
|
21489
21704
|
return result;
|
|
21490
21705
|
}
|
|
21491
21706
|
}),
|
|
@@ -21497,6 +21712,7 @@ var plugin = async (input) => {
|
|
|
21497
21712
|
async execute(args, ctx) {
|
|
21498
21713
|
const result = await executeTeamBroadcast(deps, args, ctx.sessionID);
|
|
21499
21714
|
progressTracker.recordMessage(ctx.sessionID);
|
|
21715
|
+
progressTracker.recordPeerMessage(ctx.sessionID);
|
|
21500
21716
|
ctx.metadata({ title: "Broadcast to team" });
|
|
21501
21717
|
return result;
|
|
21502
21718
|
}
|