@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/src/recovery.ts
CHANGED
|
@@ -2,18 +2,104 @@ import type { Database } from "./db"
|
|
|
2
2
|
import type { PluginClient } from "./types"
|
|
3
3
|
import type { MemberRegistry } from "./state"
|
|
4
4
|
import { getUndeliveredMessages, markDelivered, hasReportedCompletion } from "./messaging"
|
|
5
|
+
import { releaseMemberTasks } from "./tasks"
|
|
6
|
+
import { getMemberModel } from "./member-model"
|
|
5
7
|
import { preserveBranch, preservedBranchName, teamResourceSegment } from "./tools/merge-helper"
|
|
6
8
|
import { log } from "./log"
|
|
7
9
|
import { runCommand } from "./process"
|
|
8
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Whether a session still exists in OpenCode. `client.session.get` throws for a
|
|
13
|
+
* deleted/unknown session rather than returning empty data -- see dashboard.ts's
|
|
14
|
+
* existing best-effort usage of the same call.
|
|
15
|
+
*
|
|
16
|
+
* Bounded by a timeout: a `session.get` call made from within this plugin's own
|
|
17
|
+
* init sequence, before the server has finished bootstrapping, can hang the
|
|
18
|
+
* request indefinitely rather than resolve or reject -- the same class of
|
|
19
|
+
* self-referential-HTTP-call deadlock team-spawn.ts's own `withTimeout` exists
|
|
20
|
+
* to guard against for `worktree.create`/`session.create` ("makes HTTP calls
|
|
21
|
+
* back to the server, which deadlocks because the server is still handling
|
|
22
|
+
* session.create"). On timeout, treat liveness as UNKNOWN (return true, i.e.
|
|
23
|
+
* "assume alive, don't archive") rather than assuming dead -- a false negative
|
|
24
|
+
* here just means the orphan isn't reconciled this pass; a false positive means
|
|
25
|
+
* archiving a team that might still be genuinely active.
|
|
26
|
+
*/
|
|
27
|
+
export async function isSessionAlive(client: PluginClient, sessionId: string, timeoutMs = 5000): Promise<boolean> {
|
|
28
|
+
try {
|
|
29
|
+
await Promise.race([
|
|
30
|
+
client.session.get({ sessionID: sessionId }),
|
|
31
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("isSessionAlive timed out")), timeoutMs)),
|
|
32
|
+
])
|
|
33
|
+
return true
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err instanceof Error && err.message === "isSessionAlive timed out") {
|
|
36
|
+
log(`recovery:team:liveness-check-timeout session=${sessionId}`)
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reconcile 'active' teams whose lead session no longer exists in OpenCode --
|
|
45
|
+
* e.g. the user deleted the session via OpenCode's own session UI/API, not via
|
|
46
|
+
* team_cleanup. Ensemble's own bookkeeping has no way to learn about that on its
|
|
47
|
+
* own: nothing tells it the session is gone, so the team row stays 'active'
|
|
48
|
+
* forever, blocking team_create ("already exists") and team_cleanup's purge path
|
|
49
|
+
* ("cannot purge active team" -- purge only operates on archived teams) for that
|
|
50
|
+
* name indefinitely. A team with no live lead session is unrecoverable through
|
|
51
|
+
* any legitimate path (there is no "reassign lead" tool), so archiving it directly
|
|
52
|
+
* is safe regardless of member state -- the existing recoverOrphanedWorktrees/
|
|
53
|
+
* recoverOrphanedBranches passes then naturally sweep up its resources on the same
|
|
54
|
+
* recovery cycle, since those already scope to archived teams with no active
|
|
55
|
+
* members.
|
|
56
|
+
*
|
|
57
|
+
* Scoped by cwd the same way recoverStaleMembers is, to avoid reconciling other
|
|
58
|
+
* projects' teams when multiple projects share one DB.
|
|
59
|
+
*/
|
|
60
|
+
export async function recoverOrphanedTeams(
|
|
61
|
+
db: Database,
|
|
62
|
+
client: PluginClient,
|
|
63
|
+
cwd?: string,
|
|
64
|
+
registry?: MemberRegistry,
|
|
65
|
+
): Promise<{ archived: number }> {
|
|
66
|
+
const active = db.query(
|
|
67
|
+
`SELECT id, lead_session_id FROM team
|
|
68
|
+
WHERE status = 'active' AND (? IS NULL OR project_id = ? OR project_id = 'default')`
|
|
69
|
+
).all(cwd ?? null, cwd ?? null) as Array<{ id: string; lead_session_id: string }>
|
|
70
|
+
|
|
71
|
+
let archived = 0
|
|
72
|
+
for (const team of active) {
|
|
73
|
+
if (await isSessionAlive(client, team.lead_session_id)) continue
|
|
74
|
+
|
|
75
|
+
db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), team.id])
|
|
76
|
+
registry?.unregisterTeam(team.id)
|
|
77
|
+
archived++
|
|
78
|
+
log(`recovery:team:orphaned team_id=${team.id} lead_session=${team.lead_session_id}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { archived }
|
|
82
|
+
}
|
|
83
|
+
|
|
9
84
|
/**
|
|
10
85
|
* Scan for team members stuck in 'busy' status (stale from a crash)
|
|
11
86
|
* and mark them as 'error' with execution_status 'idle'.
|
|
12
87
|
* Preserves worktree branches before aborting orphaned sessions.
|
|
13
88
|
* Only processes members in active teams.
|
|
14
89
|
* Returns the count of interrupted members.
|
|
90
|
+
*
|
|
91
|
+
* `abortTimeoutMs` bounds each `client.session.abort()` call -- the same
|
|
92
|
+
* self-referential-HTTP-call-back-to-this-server shape as
|
|
93
|
+
* `isSessionAlive()`'s `client.session.get()` (see that function's doc
|
|
94
|
+
* comment for the full deadlock mechanism, confirmed live 2026-08-17). This
|
|
95
|
+
* function is awaited synchronously in plugin init (unlike
|
|
96
|
+
* `recoverOrphanedTeams`, which is fire-and-forget) because
|
|
97
|
+
* `rehydrateRegistry` depends on it completing first, so a bounded timeout on
|
|
98
|
+
* the network call -- not restructuring the await -- is the fix here: the
|
|
99
|
+
* DB-only work (marking members 'error') always completes and is reflected in
|
|
100
|
+
* the returned count regardless of whether any individual abort call settles.
|
|
15
101
|
*/
|
|
16
|
-
export async function recoverStaleMembers(db: Database, client?: PluginClient, cwd?: string): Promise<{ interrupted: number }> {
|
|
102
|
+
export async function recoverStaleMembers(db: Database, client?: PluginClient, cwd?: string, abortTimeoutMs = 5000): Promise<{ interrupted: number }> {
|
|
17
103
|
// Find stale members with branch info so we can preserve before aborting
|
|
18
104
|
const stale = db.query(
|
|
19
105
|
`SELECT tm.session_id, tm.worktree_branch, tm.name, tm.team_id, t.name as team_name, p.name as project_name
|
|
@@ -31,6 +117,13 @@ export async function recoverStaleMembers(db: Database, client?: PluginClient, c
|
|
|
31
117
|
[Date.now(), cwd ?? null, cwd ?? null]
|
|
32
118
|
)
|
|
33
119
|
|
|
120
|
+
// Release each stale member's in_progress tasks back to the pool so their
|
|
121
|
+
// unfinished work is reclaimable after a crash (issue #27).
|
|
122
|
+
for (const member of stale) {
|
|
123
|
+
const released = releaseMemberTasks(db, member.team_id, member.name)
|
|
124
|
+
if (released > 0) log(`recovery:tasks:released name=${member.name} count=${released}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
34
127
|
// Preserve branches then abort orphaned sessions
|
|
35
128
|
if (client) {
|
|
36
129
|
for (const member of stale) {
|
|
@@ -45,8 +138,16 @@ export async function recoverStaleMembers(db: Database, client?: PluginClient, c
|
|
|
45
138
|
}
|
|
46
139
|
}
|
|
47
140
|
try {
|
|
48
|
-
await
|
|
49
|
-
|
|
141
|
+
await Promise.race([
|
|
142
|
+
client.session.abort({ sessionID: member.session_id }),
|
|
143
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("session.abort timed out")), abortTimeoutMs)),
|
|
144
|
+
])
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (err instanceof Error && err.message === "session.abort timed out") {
|
|
147
|
+
log(`recovery:stale-members:abort-timeout session=${member.session_id}`)
|
|
148
|
+
}
|
|
149
|
+
/* best effort */
|
|
150
|
+
}
|
|
50
151
|
}
|
|
51
152
|
}
|
|
52
153
|
|
|
@@ -133,9 +234,11 @@ export async function recoverUndeliveredMessages(
|
|
|
133
234
|
}
|
|
134
235
|
|
|
135
236
|
try {
|
|
237
|
+
const recipientModel = getMemberModel(db, team.id, msg.to_name)
|
|
136
238
|
await client.session.promptAsync({
|
|
137
239
|
sessionID: recipientSessionId,
|
|
138
240
|
parts: [{ type: "text", text: `[Recovered team message from ${msg.from_name}]: ${msg.content}` }],
|
|
241
|
+
...(recipientModel ? { model: recipientModel } : {}),
|
|
139
242
|
})
|
|
140
243
|
markDelivered(db, msg.id)
|
|
141
244
|
redelivered++
|
package/src/schema.ts
CHANGED
|
@@ -186,6 +186,24 @@ export const MIGRATIONS: string[] = [
|
|
|
186
186
|
CREATE INDEX IF NOT EXISTS team_message_undelivered_idx ON team_message(team_id, delivered) WHERE delivered = 0;
|
|
187
187
|
CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;
|
|
188
188
|
PRAGMA foreign_keys=ON;`,
|
|
189
|
+
// Migration 9: Add last_nudged_at to team_member — additive-only display-staleness
|
|
190
|
+
// signal for the watchdog's soft stall-nudge path. Deliberately NOT a new status
|
|
191
|
+
// enum value (see checkStalled() in watchdog.ts for the rationale): a 6th CHECK
|
|
192
|
+
// constraint literal would require a table-rebuild migration and touch every
|
|
193
|
+
// consumer that switches on the 5 known status strings. This column is additive —
|
|
194
|
+
// existing consumers that don't know about it simply don't render it.
|
|
195
|
+
`ALTER TABLE team_member ADD COLUMN last_nudged_at INTEGER;`,
|
|
196
|
+
// Migration 10: Add retry_* columns to team_member — additive-only provider-retry
|
|
197
|
+
// display signal (see hooks.ts's "retry" branch). Same non-negotiable as Migration
|
|
198
|
+
// 9: no 6th status/execution_status literal, no table rebuild. retry_until is a
|
|
199
|
+
// TTL, not a stored enum — "currently retrying" is derived at read time
|
|
200
|
+
// (retry_until > Date.now()), so there is no explicit clear-write anywhere in this
|
|
201
|
+
// fix; it simply becomes stale and gets superseded by real activity or by time
|
|
202
|
+
// elapsing.
|
|
203
|
+
`ALTER TABLE team_member ADD COLUMN retry_until INTEGER;
|
|
204
|
+
ALTER TABLE team_member ADD COLUMN retry_attempt INTEGER;
|
|
205
|
+
ALTER TABLE team_member ADD COLUMN retry_provider TEXT;
|
|
206
|
+
ALTER TABLE team_member ADD COLUMN retry_message TEXT;`,
|
|
189
207
|
]
|
|
190
208
|
|
|
191
209
|
/**
|
package/src/tasks.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Database } from "./db"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Release all in-progress tasks assigned to a departing member back to the
|
|
5
|
+
* shared pool. Sets status to 'pending' and clears the assignee so another
|
|
6
|
+
* teammate can claim the work. Completed, cancelled, and blocked tasks are
|
|
7
|
+
* left untouched. Returns the number of tasks released.
|
|
8
|
+
*
|
|
9
|
+
* Call this whenever a member reaches a terminal state (shutdown, timeout,
|
|
10
|
+
* crash recovery, or spawn rollback) so their unfinished work is not stranded
|
|
11
|
+
* as permanently in_progress with a dead assignee (issue #27).
|
|
12
|
+
*/
|
|
13
|
+
export function releaseMemberTasks(db: Database, teamId: string, memberName: string): number {
|
|
14
|
+
const result = db.run(
|
|
15
|
+
"UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE team_id = ? AND assignee = ? AND status = 'in_progress'",
|
|
16
|
+
[Date.now(), teamId, memberName],
|
|
17
|
+
)
|
|
18
|
+
return result.changes
|
|
19
|
+
}
|
package/src/tools/team-create.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { generateId, generateProjectName, validateProjectName, validateTeamName } from "../util"
|
|
3
3
|
import { findTeamBySession } from "../types"
|
|
4
|
+
import { isSessionAlive } from "../recovery"
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Execute the team_create tool. Creates a new team with the caller as lead.
|
|
@@ -19,8 +20,20 @@ export async function executeTeamCreate(
|
|
|
19
20
|
|
|
20
21
|
// Check if team name already exists
|
|
21
22
|
const projectId = deps.directory
|
|
22
|
-
const existing = deps.db.query("SELECT id FROM team WHERE name = ? AND project_id = ? AND status = 'active'")
|
|
23
|
-
|
|
23
|
+
const existing = deps.db.query("SELECT id, lead_session_id FROM team WHERE name = ? AND project_id = ? AND status = 'active'")
|
|
24
|
+
.get(args.name, projectId) as { id: string; lead_session_id: string } | undefined
|
|
25
|
+
if (existing) {
|
|
26
|
+
// The existing team's lead session may have been deleted externally (via
|
|
27
|
+
// OpenCode's own session UI/API, not team_cleanup) rather than genuinely
|
|
28
|
+
// still being in use. Reconcile on demand rather than block this call on a
|
|
29
|
+
// team nobody can ever be lead of again -- don't wait for the next plugin
|
|
30
|
+
// restart's periodic recoverOrphanedTeams pass to notice.
|
|
31
|
+
if (await isSessionAlive(deps.client, existing.lead_session_id)) {
|
|
32
|
+
throw new Error(`Team "${args.name}" already exists`)
|
|
33
|
+
}
|
|
34
|
+
deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), existing.id])
|
|
35
|
+
deps.registry.unregisterTeam(existing.id)
|
|
36
|
+
}
|
|
24
37
|
|
|
25
38
|
// Check if session already leads a team
|
|
26
39
|
const lead = findTeamBySession(deps.db, deps.registry, sessionId)
|
package/src/tools/team-merge.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import { requireLead } from "./shared"
|
|
2
|
+
import { requireLead, checkWorktreeDirty, countBranchCommits } from "./shared"
|
|
3
|
+
import type { IsDirtyFn, CommitCountFn } from "./shared"
|
|
3
4
|
import { mergeBranch, deleteBranch, getOverlappingFiles } from "./merge-helper"
|
|
4
5
|
import type { MergeBranchFn, DeleteBranchFn, OverlapCheckFn } from "./merge-helper"
|
|
5
6
|
import { log } from "../log"
|
|
@@ -15,11 +16,13 @@ export async function executeTeamMerge(
|
|
|
15
16
|
merge: MergeBranchFn = mergeBranch,
|
|
16
17
|
delBranch: DeleteBranchFn = deleteBranch,
|
|
17
18
|
overlapCheck: OverlapCheckFn = getOverlappingFiles,
|
|
19
|
+
commitCount: CommitCountFn = countBranchCommits,
|
|
20
|
+
isDirty: IsDirtyFn = checkWorktreeDirty,
|
|
18
21
|
): Promise<string> {
|
|
19
22
|
const teamInfo = requireLead(deps, sessionId)
|
|
20
23
|
|
|
21
|
-
const member = deps.db.query("SELECT status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
|
|
22
|
-
.get(teamInfo.teamId, args.member) as { status: string; worktree_branch: string | null } | null
|
|
24
|
+
const member = deps.db.query("SELECT status, worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?")
|
|
25
|
+
.get(teamInfo.teamId, args.member) as { status: string; worktree_branch: string | null; worktree_dir: string | null } | null
|
|
23
26
|
if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
|
|
24
27
|
|
|
25
28
|
if (member.status !== "shutdown" && member.status !== "error") {
|
|
@@ -33,6 +36,30 @@ export async function executeTeamMerge(
|
|
|
33
36
|
const branch = member.worktree_branch
|
|
34
37
|
log(`merge:start member=${args.member} branch=${branch}`)
|
|
35
38
|
|
|
39
|
+
// A branch with zero new commits produces a trivially "successful" no-op squash merge --
|
|
40
|
+
// there is genuinely nothing to apply. Reporting that as "Merged ... changes" is
|
|
41
|
+
// misleading: if the teammate's worktree still has uncommitted work, that work was never
|
|
42
|
+
// captured on the branch at all and is about to be permanently lost the moment the
|
|
43
|
+
// worktree is removed (e.g. by team_cleanup). Say so plainly instead of a false success,
|
|
44
|
+
// and don't tear down the branch/worktree while it may be the only copy of the work.
|
|
45
|
+
// A commit-count check failure returns -1 (unknown) -- never treat that as "nothing to
|
|
46
|
+
// merge"; fall through to the real merge attempt so a check failure can't silently skip
|
|
47
|
+
// real work.
|
|
48
|
+
const commits = await commitCount(branch, deps.directory)
|
|
49
|
+
if (commits === 0) {
|
|
50
|
+
const dirty = member.worktree_dir ? await isDirty(member.worktree_dir).catch(() => false) : false
|
|
51
|
+
if (dirty) {
|
|
52
|
+
return [
|
|
53
|
+
`Nothing to merge for "${args.member}" — their branch (${branch}) has no commits.`,
|
|
54
|
+
`Their worktree still has uncommitted changes that were never captured on the branch.`,
|
|
55
|
+
`This work will be permanently lost if the worktree is removed (e.g. via team_cleanup).`,
|
|
56
|
+
member.worktree_dir ? `Worktree: ${member.worktree_dir}` : ``,
|
|
57
|
+
`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.`,
|
|
58
|
+
].filter(Boolean).join("\n")
|
|
59
|
+
}
|
|
60
|
+
return `Nothing to merge for "${args.member}" — their branch (${branch}) has no commits and no uncommitted changes. They made no changes.`
|
|
61
|
+
}
|
|
62
|
+
|
|
36
63
|
// Block merge if lead has local changes to files the agent also modified
|
|
37
64
|
try {
|
|
38
65
|
const overlap = await overlapCheck(branch, deps.directory)
|
|
@@ -2,21 +2,62 @@ import type { ToolDeps } from "../types"
|
|
|
2
2
|
import { resolveRecipientSession } from "../types"
|
|
3
3
|
import { requireTeamMember } from "./shared"
|
|
4
4
|
import { sendMessage, markDelivered, hasReportedCompletion } from "../messaging"
|
|
5
|
+
import { parseModelId, getMemberModel } from "../member-model"
|
|
5
6
|
import { log } from "../log"
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Execute the team_message tool. Sends a direct message to a teammate or lead.
|
|
9
10
|
* Optionally approves or rejects a teammate's plan (lead only).
|
|
11
|
+
* Optionally forces delivery to a teammate who already reported completion (lead only) —
|
|
12
|
+
* see the `force` guard below.
|
|
13
|
+
* Optionally updates a teammate's model in-place (lead only) via the `model`
|
|
14
|
+
* param — see #26. When `model` is set, `text` is optional: if omitted, the
|
|
15
|
+
* model is updated without delivering a message.
|
|
10
16
|
*/
|
|
11
17
|
export async function executeTeamMessage(
|
|
12
18
|
deps: ToolDeps,
|
|
13
|
-
args: { to: string; text
|
|
19
|
+
args: { to: string; text?: string; approve?: boolean; reject?: string; force?: boolean; model?: string },
|
|
14
20
|
sessionId: string,
|
|
15
21
|
): Promise<string> {
|
|
16
22
|
const teamInfo = requireTeamMember(deps, sessionId)
|
|
17
23
|
|
|
24
|
+
if (args.force && teamInfo.role !== "lead") {
|
|
25
|
+
throw new Error("Only the lead can force-deliver a message to a completed teammate.")
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
19
29
|
|
|
30
|
+
// In-place model update (lead only). Handled before recipient session
|
|
31
|
+
// resolution so it works even when the member is idle/not in the registry,
|
|
32
|
+
// and rejects terminal members explicitly. See #26.
|
|
33
|
+
if (args.model !== undefined) {
|
|
34
|
+
if (teamInfo.role !== "lead") throw new Error("Only the lead can update a teammate's model.")
|
|
35
|
+
const parsed = parseModelId(args.model)
|
|
36
|
+
if (!parsed) throw new Error(`Invalid model "${args.model}" — expected "provider/model" format (e.g. "anthropic/claude-sonnet").`)
|
|
37
|
+
const member = deps.db.query("SELECT status FROM team_member WHERE team_id = ? AND name = ?")
|
|
38
|
+
.get(teamInfo.teamId, args.to) as { status: string } | null
|
|
39
|
+
if (!member) throw new Error(`Teammate "${args.to}" not found in team "${teamInfo.teamName}".`)
|
|
40
|
+
if (member.status === "shutdown" || member.status === "error") {
|
|
41
|
+
throw new Error(`Teammate "${args.to}" is shut down — spawn a new teammate instead of updating the model.`)
|
|
42
|
+
}
|
|
43
|
+
deps.db.run(
|
|
44
|
+
"UPDATE team_member SET model = ?, time_updated = ? WHERE team_id = ? AND name = ?",
|
|
45
|
+
[args.model, Date.now(), teamInfo.teamId, args.to],
|
|
46
|
+
)
|
|
47
|
+
log(`team_message:model-update to=${args.to} model=${args.model}`)
|
|
48
|
+
|
|
49
|
+
// If there's no accompanying message, we're done — the new model applies
|
|
50
|
+
// on the teammate's next natural delivery.
|
|
51
|
+
if (!args.text) {
|
|
52
|
+
return `Updated ${args.to}'s model to ${args.model}. It applies on their next turn.`
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (args.text === undefined) {
|
|
57
|
+
throw new Error("team_message requires either 'text' or 'model'.")
|
|
58
|
+
}
|
|
59
|
+
const text = args.text
|
|
60
|
+
|
|
20
61
|
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to)
|
|
21
62
|
|
|
22
63
|
// If recipient not found, store the message for later delivery (they may not be spawned yet)
|
|
@@ -29,7 +70,7 @@ export async function executeTeamMessage(
|
|
|
29
70
|
teamId: teamInfo.teamId,
|
|
30
71
|
from: senderName,
|
|
31
72
|
to: args.to,
|
|
32
|
-
content:
|
|
73
|
+
content: text,
|
|
33
74
|
})
|
|
34
75
|
log(`team_message:queued from=${senderName} to=${args.to} (recipient not yet spawned)`)
|
|
35
76
|
return `Message queued for ${args.to} — they haven't been spawned yet. It will be delivered when they join the team.`
|
|
@@ -37,7 +78,7 @@ export async function executeTeamMessage(
|
|
|
37
78
|
if (!recipientSessionId) throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`)
|
|
38
79
|
|
|
39
80
|
// Handle plan approval/rejection
|
|
40
|
-
let messageText =
|
|
81
|
+
let messageText = text
|
|
41
82
|
if (args.approve || args.reject) {
|
|
42
83
|
if (args.approve && args.reject) {
|
|
43
84
|
throw new Error("Cannot both approve and reject a plan.")
|
|
@@ -90,16 +131,23 @@ export async function executeTeamMessage(
|
|
|
90
131
|
return `Message sent to ${args.to}.`
|
|
91
132
|
}
|
|
92
133
|
|
|
93
|
-
// Guard: skip promptAsync delivery to teammates who have already reported completion (issue #3)
|
|
94
|
-
|
|
95
|
-
|
|
134
|
+
// Guard: skip promptAsync delivery to teammates who have already reported completion (issue #3),
|
|
135
|
+
// unless the lead explicitly forces it. Forcing wakes the session; the natural busy transition
|
|
136
|
+
// (handleSessionStatusEvent) resets the completion flag when the teammate next goes idle, the
|
|
137
|
+
// same reset that already happens for a normal re-activation — this just gives the lead a way
|
|
138
|
+
// to trigger it deliberately instead of it being unreachable once the guard is set.
|
|
139
|
+
if (hasReportedCompletion(deps.db, teamInfo.teamId, args.to) && !args.force) {
|
|
140
|
+
return `Message stored for ${args.to} (teammate has completed their task — message will not wake them). Pass force:true to re-activate them.`
|
|
96
141
|
}
|
|
97
142
|
|
|
98
143
|
// For member-to-member messages, fire-and-forget delivery is safe.
|
|
144
|
+
// Deliver on the recipient's configured model, if any (#26).
|
|
99
145
|
const deliveryText = `[Team message from ${senderName}]: ${messageText}`
|
|
146
|
+
const recipientModel = getMemberModel(deps.db, teamInfo.teamId, args.to)
|
|
100
147
|
deps.client.session.promptAsync({
|
|
101
148
|
sessionID: recipientSessionId,
|
|
102
149
|
parts: [{ type: "text", text: deliveryText }],
|
|
150
|
+
...(recipientModel ? { model: recipientModel } : {}),
|
|
103
151
|
}).then(() => {
|
|
104
152
|
markDelivered(deps.db, msgId)
|
|
105
153
|
}).catch((err) => {
|
|
@@ -3,6 +3,7 @@ import { requireLead, checkWorktreeDirty, countBranchCommits } from "./shared"
|
|
|
3
3
|
import type { IsDirtyFn, CommitCountFn } from "./shared"
|
|
4
4
|
import { getTeamResourceParts, preserveBranch, preservedBranchName } from "./merge-helper"
|
|
5
5
|
import type { PreserveBranchFn } from "./merge-helper"
|
|
6
|
+
import { releaseMemberTasks } from "../tasks"
|
|
6
7
|
import { log } from "../log"
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -127,6 +128,11 @@ async function preserveAndAbort(
|
|
|
127
128
|
"UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
|
|
128
129
|
[Date.now(), teamId, memberName],
|
|
129
130
|
)
|
|
131
|
+
|
|
132
|
+
// Release any tasks this member was working — they are gone now, so their
|
|
133
|
+
// in_progress work must return to the pool for another teammate (issue #27).
|
|
134
|
+
const released = releaseMemberTasks(deps.db, teamId, memberName)
|
|
135
|
+
if (released > 0) log(`shutdown:tasks:released name=${memberName} count=${released}`)
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
/** Build a status line describing the teammate's work: commit count, dirty state, next step. */
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -2,7 +2,9 @@ import type { ToolDeps, PermissionRule } from "../types"
|
|
|
2
2
|
import { validateMemberName } from "../util"
|
|
3
3
|
import { requireLead } from "./shared"
|
|
4
4
|
import { claimTask } from "./team-claim"
|
|
5
|
-
import {
|
|
5
|
+
import { notifyLead } from "../notify"
|
|
6
|
+
import { releaseMemberTasks } from "../tasks"
|
|
7
|
+
import { parseModelId } from "../member-model"
|
|
6
8
|
import { log } from "../log"
|
|
7
9
|
import type { EnsembleConfig } from "../config"
|
|
8
10
|
import { getTeamResourceParts, teamWorktreeName } from "./merge-helper"
|
|
@@ -10,13 +12,6 @@ import { getTeamResourceParts, teamWorktreeName } from "./merge-helper"
|
|
|
10
12
|
/** Tracks consecutive spawn failures per team for circuit breaker. */
|
|
11
13
|
export const spawnFailures = new Map<string, { count: number; lastError: string }>()
|
|
12
14
|
|
|
13
|
-
/** Parse "provider/model" string into { providerID, modelID } for the SDK. */
|
|
14
|
-
function parseModelId(model: string): { providerID: string; modelID: string } | undefined {
|
|
15
|
-
const slash = model.indexOf("/")
|
|
16
|
-
if (slash <= 0 || slash === model.length - 1) return undefined
|
|
17
|
-
return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) }
|
|
18
|
-
}
|
|
19
|
-
|
|
20
15
|
/**
|
|
21
16
|
* Resolve which model to use for a spawned agent.
|
|
22
17
|
* Priority: explicit arg > modelsByAgent > rotation/random > defaultModel > undefined.
|
|
@@ -231,6 +226,12 @@ export async function executeTeamSpawn(
|
|
|
231
226
|
[teamInfo.teamId, args.name, childSessionId, agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]
|
|
232
227
|
)
|
|
233
228
|
|
|
229
|
+
// Row is inserted as 'busy' directly -- there is no ready->busy status-event
|
|
230
|
+
// transition for a fresh spawn, so the watchdog's usual hook point never fires.
|
|
231
|
+
// Record the baseline here instead, or a member stalled on its first action
|
|
232
|
+
// is invisible to checkStalled() until its first step-finish event lands.
|
|
233
|
+
deps.progressTracker.recordBusyStart(childSessionId)
|
|
234
|
+
|
|
234
235
|
// Register in memory
|
|
235
236
|
deps.registry.register(teamInfo.teamId, args.name, childSessionId)
|
|
236
237
|
|
|
@@ -396,7 +397,7 @@ export async function executeTeamSpawn(
|
|
|
396
397
|
// Release any task auto-claimed for this teammate so it returns to the pool.
|
|
397
398
|
if (claimedTaskContent) {
|
|
398
399
|
deps.db.run(
|
|
399
|
-
"UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE id = ? AND assignee = ?",
|
|
400
|
+
"UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE id = ? AND assignee = ? AND status = 'in_progress'",
|
|
400
401
|
[Date.now(), args.claim_task, args.name]
|
|
401
402
|
)
|
|
402
403
|
}
|
|
@@ -414,12 +415,12 @@ export async function executeTeamSpawn(
|
|
|
414
415
|
variant: "error",
|
|
415
416
|
duration: 8000,
|
|
416
417
|
}).catch(() => { /* TUI may not be available */ })
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
418
|
+
notifyLead(
|
|
419
|
+
deps.client,
|
|
420
|
+
deps.db,
|
|
421
|
+
teamInfo.teamId,
|
|
422
|
+
`Teammate "${args.name}" failed to start and was removed${modelInfo}. Error: ${errMsg}. You may retry the spawn.`,
|
|
423
|
+
)
|
|
423
424
|
} catch { /* rollback failed — watchdog will clean up stale member */ }
|
|
424
425
|
})
|
|
425
426
|
|
package/src/tools/team-status.ts
CHANGED
|
@@ -4,8 +4,28 @@ import { requireTeamMember } from "./shared"
|
|
|
4
4
|
/** Tracks the last time team_status was called per team ID (epoch ms). */
|
|
5
5
|
export const lastCallTime = new Map<string, number>()
|
|
6
6
|
|
|
7
|
+
/** Tracks the state marker (see computeStateMarker) observed at the last real read per team. */
|
|
8
|
+
export const lastKnownState = new Map<string, number>()
|
|
9
|
+
|
|
7
10
|
const RATE_LIMIT_MS = 30_000
|
|
8
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Cheap per-team "has anything changed" fingerprint — the max time_updated/time_created
|
|
14
|
+
* across the three tables team_status reports on. One MAX() query, no hashing: the
|
|
15
|
+
* throttle only needs to detect "something changed," not what, so this is deliberately
|
|
16
|
+
* the simplest sufficient version (see Fix 3 in the spec).
|
|
17
|
+
*/
|
|
18
|
+
function computeStateMarker(deps: ToolDeps, teamId: string): number {
|
|
19
|
+
const row = deps.db.query(
|
|
20
|
+
`SELECT MAX(x) as marker FROM (
|
|
21
|
+
SELECT MAX(time_updated) as x FROM team_member WHERE team_id = ?
|
|
22
|
+
UNION SELECT MAX(time_updated) FROM team_task WHERE team_id = ?
|
|
23
|
+
UNION SELECT MAX(time_created) FROM team_message WHERE team_id = ?
|
|
24
|
+
)`
|
|
25
|
+
).get(teamId, teamId, teamId) as { marker: number | null } | null
|
|
26
|
+
return row?.marker ?? 0
|
|
27
|
+
}
|
|
28
|
+
|
|
9
29
|
/** Format a duration in ms to a human-readable string. */
|
|
10
30
|
export function formatDuration(ms: number): string {
|
|
11
31
|
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`
|
|
@@ -24,15 +44,20 @@ export async function executeTeamStatus(
|
|
|
24
44
|
|
|
25
45
|
const now = Date.now()
|
|
26
46
|
const last = lastCallTime.get(teamInfo.teamId)
|
|
27
|
-
|
|
47
|
+
const currentMarker = computeStateMarker(deps, teamInfo.teamId)
|
|
48
|
+
// The 30s throttle's purpose is preventing hammering, not lying about state — only
|
|
49
|
+
// short-circuit when nothing has actually changed since the last real read.
|
|
50
|
+
if (last !== undefined && (now - last) < RATE_LIMIT_MS && lastKnownState.get(teamInfo.teamId) === currentMarker) {
|
|
28
51
|
return "No changes since last check."
|
|
29
52
|
}
|
|
30
53
|
lastCallTime.set(teamInfo.teamId, now)
|
|
54
|
+
lastKnownState.set(teamInfo.teamId, currentMarker)
|
|
31
55
|
|
|
32
56
|
const members = deps.db.query(
|
|
33
|
-
"SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval, time_updated FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
|
|
57
|
+
"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"
|
|
34
58
|
).all(teamInfo.teamId) as Array<{
|
|
35
|
-
name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; worktree_dir: string | null; plan_approval: string; time_updated: number
|
|
59
|
+
name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; worktree_dir: string | null; plan_approval: string; time_updated: number; last_nudged_at: number | null
|
|
60
|
+
retry_until: number | null; retry_attempt: number | null
|
|
36
61
|
}>
|
|
37
62
|
|
|
38
63
|
const tasks = deps.db.query(
|
|
@@ -52,13 +77,22 @@ export async function executeTeamStatus(
|
|
|
52
77
|
const duration = formatDuration(now - m.time_updated)
|
|
53
78
|
const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : ""
|
|
54
79
|
const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : ""
|
|
80
|
+
// Additive display-staleness annotation (Fix 1) — surfaces a soft stall-nudge
|
|
81
|
+
// without inventing a new status value. Annotates the existing status, doesn't
|
|
82
|
+
// replace it.
|
|
83
|
+
const nudged = m.last_nudged_at ? `, nudged ${formatDuration(now - m.last_nudged_at)} ago` : ""
|
|
84
|
+
// Additive provider-retry annotation (Fix 4) — derived at read time from the
|
|
85
|
+
// retry_until TTL (Fix 3), same annotation pattern as `nudged` above. Generic
|
|
86
|
+
// "retrying" label, no rate-limit-specific wording (Fix 2).
|
|
87
|
+
const isRetrying = m.retry_until !== null && m.retry_until > now
|
|
88
|
+
const retrying = isRetrying ? `, retrying (attempt ${m.retry_attempt}, ~${formatDuration(m.retry_until! - now)})` : ""
|
|
55
89
|
|
|
56
90
|
// Last message time
|
|
57
91
|
const lastMsg = deps.db.query("SELECT MAX(time_created) as last_msg FROM team_message WHERE team_id = ? AND from_name = ?")
|
|
58
92
|
.get(teamInfo.teamId, m.name) as { last_msg: number | null } | null
|
|
59
93
|
const msgInfo = lastMsg?.last_msg ? `last msg: ${formatDuration(now - lastMsg.last_msg)} ago` : "no messages yet"
|
|
60
94
|
|
|
61
|
-
lines.push(` ${m.name} [${statusIcon} ${duration}, ${msgInfo}${plan}] agent: ${m.agent}${branch}`)
|
|
95
|
+
lines.push(` ${m.name} [${statusIcon} ${duration}, ${msgInfo}${plan}${nudged}${retrying}] agent: ${m.agent}${branch}`)
|
|
62
96
|
|
|
63
97
|
// Current task
|
|
64
98
|
const task = deps.db.query("SELECT content FROM team_task WHERE team_id = ? AND assignee = ? AND status = 'in_progress' LIMIT 1")
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Database } from "./db"
|
|
2
2
|
import type { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state"
|
|
3
3
|
import type { EnsembleConfig } from "./config"
|
|
4
|
+
import type { ProgressTracker } from "./progress"
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Shared dependencies injected into every tool's execute function.
|
|
@@ -17,6 +18,8 @@ export interface ToolDeps {
|
|
|
17
18
|
directory: string
|
|
18
19
|
/** Plugin configuration. */
|
|
19
20
|
config: Required<EnsembleConfig>
|
|
21
|
+
/** Stall/activity tracker — shared with the Watchdog. */
|
|
22
|
+
progressTracker: ProgressTracker
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
/** A single permission rule for session-level enforcement. */
|