@hueyexe/opencode-ensemble 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -15
- package/dist/index.js +349 -25
- package/package.json +1 -1
- package/src/config.ts +75 -0
- package/src/index.ts +34 -13
- package/src/progress.ts +85 -0
- package/src/result-parser.ts +40 -0
- package/src/system-prompt.ts +68 -2
- package/src/tools/team-cleanup.ts +83 -6
- package/src/tools/team-results.ts +7 -2
- package/src/tools/team-spawn.ts +26 -1
- package/src/tools/team-status.ts +25 -3
- package/src/watchdog.ts +67 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
/** Plugin configuration shape. All fields optional — defaults applied. */
|
|
5
|
+
export interface EnsembleConfig {
|
|
6
|
+
/** Auto-merge worktree branches on cleanup (default: true) */
|
|
7
|
+
mergeOnCleanup?: boolean
|
|
8
|
+
/** Stall detection threshold in ms (default: 180000 = 3 min, 0 to disable) */
|
|
9
|
+
stallThresholdMs?: number
|
|
10
|
+
/** Min steps before token-based stall check (default: 3) */
|
|
11
|
+
stallMinSteps?: number
|
|
12
|
+
/** Output token threshold for stall detection (default: 500) */
|
|
13
|
+
stallTokenThreshold?: number
|
|
14
|
+
/** Hard timeout for busy members in ms (default: 1800000 = 30 min, 0 to disable) */
|
|
15
|
+
timeoutMs?: number
|
|
16
|
+
/** Rate limit capacity (default: 10, 0 to disable) */
|
|
17
|
+
rateLimitCapacity?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Default configuration values. */
|
|
21
|
+
export const DEFAULT_CONFIG: Required<EnsembleConfig> = {
|
|
22
|
+
mergeOnCleanup: true,
|
|
23
|
+
stallThresholdMs: 180_000,
|
|
24
|
+
stallMinSteps: 3,
|
|
25
|
+
stallTokenThreshold: 500,
|
|
26
|
+
timeoutMs: 30 * 60 * 1000,
|
|
27
|
+
rateLimitCapacity: 10,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read a JSON config file, returning an empty object on missing/invalid. */
|
|
31
|
+
function readConfigFile(filePath: string): Partial<EnsembleConfig> {
|
|
32
|
+
try {
|
|
33
|
+
const text = readFileSync(filePath, "utf-8")
|
|
34
|
+
const raw = JSON.parse(text) as Record<string, unknown>
|
|
35
|
+
// Validate types — only accept numbers for numeric fields, booleans for boolean fields
|
|
36
|
+
const result: Partial<EnsembleConfig> = {}
|
|
37
|
+
if (typeof raw.mergeOnCleanup === "boolean") result.mergeOnCleanup = raw.mergeOnCleanup
|
|
38
|
+
if (typeof raw.stallThresholdMs === "number") result.stallThresholdMs = raw.stallThresholdMs
|
|
39
|
+
if (typeof raw.stallMinSteps === "number") result.stallMinSteps = raw.stallMinSteps
|
|
40
|
+
if (typeof raw.stallTokenThreshold === "number") result.stallTokenThreshold = raw.stallTokenThreshold
|
|
41
|
+
if (typeof raw.timeoutMs === "number") result.timeoutMs = raw.timeoutMs
|
|
42
|
+
if (typeof raw.rateLimitCapacity === "number") result.rateLimitCapacity = raw.rateLimitCapacity
|
|
43
|
+
return result
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return {}
|
|
46
|
+
console.warn(`[ensemble] Invalid config at ${filePath}, using defaults`)
|
|
47
|
+
return {}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Load plugin configuration. Merges global → project → env vars.
|
|
53
|
+
* Missing files are silently skipped. Invalid JSON logs a warning.
|
|
54
|
+
*/
|
|
55
|
+
export function loadConfig(projectDir: string): Required<EnsembleConfig> {
|
|
56
|
+
const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? ""
|
|
57
|
+
const globalPath = path.join(homeDir, ".config", "opencode", "ensemble.json")
|
|
58
|
+
const projectPath = path.join(projectDir, ".opencode", "ensemble.json")
|
|
59
|
+
|
|
60
|
+
const global = readConfigFile(globalPath)
|
|
61
|
+
const project = readConfigFile(projectPath)
|
|
62
|
+
const merged = { ...DEFAULT_CONFIG, ...global, ...project }
|
|
63
|
+
|
|
64
|
+
// Env vars override everything
|
|
65
|
+
const timeout = process.env.OPENCODE_ENSEMBLE_TIMEOUT
|
|
66
|
+
if (timeout !== undefined) merged.timeoutMs = timeout === "0" ? 0 : (parseInt(timeout, 10) || merged.timeoutMs)
|
|
67
|
+
|
|
68
|
+
const rateLimit = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT
|
|
69
|
+
if (rateLimit !== undefined) merged.rateLimitCapacity = rateLimit === "0" ? 0 : (parseInt(rateLimit, 10) || merged.rateLimitCapacity)
|
|
70
|
+
|
|
71
|
+
const stall = process.env.STALL_THRESHOLD_MS
|
|
72
|
+
if (stall !== undefined) merged.stallThresholdMs = stall === "0" ? 0 : (parseInt(stall, 10) || merged.stallThresholdMs)
|
|
73
|
+
|
|
74
|
+
return merged
|
|
75
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
|
|
|
13
13
|
import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
|
|
14
14
|
import { log, initLog } from "./log"
|
|
15
15
|
import { findTeamBySession } from "./types"
|
|
16
|
+
import { loadConfig } from "./config"
|
|
17
|
+
import { ProgressTracker } from "./progress"
|
|
16
18
|
import { executeTeamCreate } from "./tools/team-create"
|
|
17
19
|
import { executeTeamSpawn } from "./tools/team-spawn"
|
|
18
20
|
import { executeTeamMessage } from "./tools/team-message"
|
|
@@ -30,10 +32,8 @@ import type { ToolDeps, } from "./types"
|
|
|
30
32
|
import { TokenBucket } from "./rate-limit"
|
|
31
33
|
import { Watchdog } from "./watchdog"
|
|
32
34
|
|
|
33
|
-
const DEFAULT_RATE_LIMIT_CAPACITY = 10
|
|
34
35
|
const DEFAULT_RATE_LIMIT_REFILL = 2
|
|
35
36
|
const DEFAULT_RATE_LIMIT_INTERVAL_MS = 1000
|
|
36
|
-
const DEFAULT_WATCHDOG_TTL_MS = 30 * 60 * 1000 // 30 minutes
|
|
37
37
|
const DEFAULT_WATCHDOG_CHECK_MS = 60 * 1000 // 60 seconds
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -47,10 +47,14 @@ const plugin: Plugin = async (input) => {
|
|
|
47
47
|
mkdirSync(path.dirname(dbPath), { recursive: true })
|
|
48
48
|
const db = createDb(dbPath)
|
|
49
49
|
|
|
50
|
+
// Load plugin configuration (global → project → env vars)
|
|
51
|
+
const config = loadConfig(input.directory)
|
|
52
|
+
|
|
50
53
|
// Initialize in-memory state
|
|
51
54
|
const registry = new MemberRegistry()
|
|
52
55
|
const tracker = new DescendantTracker()
|
|
53
56
|
const nudgedMembers = new Set<string>()
|
|
57
|
+
const progressTracker = new ProgressTracker()
|
|
54
58
|
|
|
55
59
|
// Extract the working HeyAPI transport from the plugin-provided v1 client and pass it
|
|
56
60
|
// to the v2 OpencodeClient. The plugin framework provides a v1 client which stores its
|
|
@@ -89,24 +93,22 @@ const plugin: Plugin = async (input) => {
|
|
|
89
93
|
log(`init:skip-recovery (worktree instance: ${input.directory})`)
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
// Initialize rate limiter
|
|
93
|
-
// OPENCODE_ENSEMBLE_RATE_LIMIT=0 disables it entirely
|
|
94
|
-
const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT
|
|
95
|
-
const rateLimitCapacity = rateLimitEnv === "0" ? 0 : (parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY)
|
|
96
|
+
// Initialize rate limiter — config value already accounts for env var override
|
|
96
97
|
const rateLimiter = new TokenBucket({
|
|
97
|
-
capacity: rateLimitCapacity,
|
|
98
|
+
capacity: config.rateLimitCapacity,
|
|
98
99
|
refillRate: DEFAULT_RATE_LIMIT_REFILL,
|
|
99
100
|
refillIntervalMs: DEFAULT_RATE_LIMIT_INTERVAL_MS,
|
|
100
101
|
})
|
|
101
102
|
|
|
102
|
-
// Initialize watchdog for
|
|
103
|
-
// OPENCODE_ENSEMBLE_TIMEOUT=0 disables it entirely
|
|
104
|
-
const timeoutEnv = process.env.OPENCODE_ENSEMBLE_TIMEOUT
|
|
105
|
-
const watchdogTtl = timeoutEnv === "0" ? 0 : (parseInt(timeoutEnv ?? "", 10) || DEFAULT_WATCHDOG_TTL_MS)
|
|
103
|
+
// Initialize watchdog — config value already accounts for env var override
|
|
106
104
|
const watchdog = new Watchdog({
|
|
107
105
|
db, client, registry,
|
|
108
|
-
ttlMs:
|
|
106
|
+
ttlMs: config.timeoutMs,
|
|
109
107
|
checkIntervalMs: DEFAULT_WATCHDOG_CHECK_MS,
|
|
108
|
+
progressTracker,
|
|
109
|
+
stallThresholdMs: config.stallThresholdMs,
|
|
110
|
+
stallMinSteps: config.stallMinSteps,
|
|
111
|
+
stallTokenThreshold: config.stallTokenThreshold,
|
|
110
112
|
})
|
|
111
113
|
watchdog.start()
|
|
112
114
|
|
|
@@ -185,6 +187,16 @@ const plugin: Plugin = async (input) => {
|
|
|
185
187
|
handleSessionCreatedEvent(tracker, info.id, info.parentID)
|
|
186
188
|
}
|
|
187
189
|
}
|
|
190
|
+
|
|
191
|
+
// Track per-step output tokens for stall detection
|
|
192
|
+
if (event.type === "message.part.updated") {
|
|
193
|
+
const part = (event.properties as { part?: { type?: string; sessionID?: string; tokens?: { output?: number } } }).part
|
|
194
|
+
if (part?.type === "step-finish" && part.sessionID && part.tokens?.output !== undefined) {
|
|
195
|
+
if (registry.getBySession(part.sessionID)) {
|
|
196
|
+
progressTracker.recordStep(part.sessionID, part.tokens.output)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
188
200
|
},
|
|
189
201
|
|
|
190
202
|
// Sub-agent isolation + rate limiting hook
|
|
@@ -283,6 +295,8 @@ const plugin: Plugin = async (input) => {
|
|
|
283
295
|
},
|
|
284
296
|
async execute(args, ctx) {
|
|
285
297
|
const result = await executeTeamMessage(deps, args, ctx.sessionID)
|
|
298
|
+
// Track message activity for stall detection
|
|
299
|
+
progressTracker.recordMessage(ctx.sessionID)
|
|
286
300
|
ctx.metadata({ title: `Message → ${args.to}` })
|
|
287
301
|
return result
|
|
288
302
|
},
|
|
@@ -295,6 +309,8 @@ const plugin: Plugin = async (input) => {
|
|
|
295
309
|
},
|
|
296
310
|
async execute(args, ctx) {
|
|
297
311
|
const result = await executeTeamBroadcast(deps, args, ctx.sessionID)
|
|
312
|
+
// Track broadcast activity for stall detection
|
|
313
|
+
progressTracker.recordMessage(ctx.sessionID)
|
|
298
314
|
ctx.metadata({ title: "Broadcast to team" })
|
|
299
315
|
return result
|
|
300
316
|
},
|
|
@@ -334,6 +350,8 @@ const plugin: Plugin = async (input) => {
|
|
|
334
350
|
},
|
|
335
351
|
async execute(args, ctx) {
|
|
336
352
|
const result = await executeTeamTasksComplete(deps, args, ctx.sessionID)
|
|
353
|
+
// Track task completion for stall detection
|
|
354
|
+
progressTracker.recordTaskComplete(ctx.sessionID)
|
|
337
355
|
ctx.metadata({ title: `Completed task` })
|
|
338
356
|
return result
|
|
339
357
|
},
|
|
@@ -372,6 +390,9 @@ const plugin: Plugin = async (input) => {
|
|
|
372
390
|
},
|
|
373
391
|
async execute(args, ctx) {
|
|
374
392
|
const result = await executeTeamShutdown(deps, args, ctx.sessionID)
|
|
393
|
+
// Clean up progress tracking for the shut-down member
|
|
394
|
+
const member = deps.db.query("SELECT session_id FROM team_member WHERE name = ? AND status IN ('shutdown', 'shutdown_requested')").get(args.member) as { session_id: string } | null
|
|
395
|
+
if (member) progressTracker.remove(member.session_id)
|
|
375
396
|
const hasWarning = result.includes("uncommitted")
|
|
376
397
|
ctx.metadata({ title: hasWarning ? `${args.member} shut down — uncommitted changes` : `${args.member} shut down` })
|
|
377
398
|
return result
|
|
@@ -385,7 +406,7 @@ const plugin: Plugin = async (input) => {
|
|
|
385
406
|
acknowledge_uncommitted: tool.schema.boolean().default(false),
|
|
386
407
|
},
|
|
387
408
|
async execute(args, ctx) {
|
|
388
|
-
const result = await executeTeamCleanup(deps, args, ctx.sessionID)
|
|
409
|
+
const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, config.mergeOnCleanup)
|
|
389
410
|
const blocked = result.includes("uncommitted")
|
|
390
411
|
ctx.metadata({ title: blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up" })
|
|
391
412
|
return result
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** A single model step record. */
|
|
2
|
+
export interface StepRecord {
|
|
3
|
+
outputTokens: number
|
|
4
|
+
timestamp: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* In-memory tracker for teammate activity signals.
|
|
9
|
+
* Used by the watchdog for stall detection.
|
|
10
|
+
*/
|
|
11
|
+
export class ProgressTracker {
|
|
12
|
+
private steps = new Map<string, StepRecord[]>()
|
|
13
|
+
private lastMessageAt = new Map<string, number>()
|
|
14
|
+
private lastTaskAt = new Map<string, number>()
|
|
15
|
+
private reported = new Set<string>()
|
|
16
|
+
private readonly maxSteps: number
|
|
17
|
+
|
|
18
|
+
constructor(maxSteps = 10) {
|
|
19
|
+
this.maxSteps = maxSteps
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Record a model step completion with output token count. Keeps last `maxSteps` entries. */
|
|
23
|
+
recordStep(sessionId: string, outputTokens: number): void {
|
|
24
|
+
const records = this.steps.get(sessionId) ?? []
|
|
25
|
+
records.push({ outputTokens, timestamp: Date.now() })
|
|
26
|
+
if (records.length > this.maxSteps) records.shift()
|
|
27
|
+
this.steps.set(sessionId, records)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Record that this member sent a team_message. Clears stall report. */
|
|
31
|
+
recordMessage(sessionId: string): void {
|
|
32
|
+
this.lastMessageAt.set(sessionId, Date.now())
|
|
33
|
+
this.clearReport(sessionId)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Record that this member completed a task. Clears stall report. */
|
|
37
|
+
recordTaskComplete(sessionId: string): void {
|
|
38
|
+
this.lastTaskAt.set(sessionId, Date.now())
|
|
39
|
+
this.clearReport(sessionId)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Token-based stall: last `minSteps` steps all produced < `threshold` output tokens. */
|
|
43
|
+
isTokenStalled(sessionId: string, minSteps: number, threshold: number): boolean {
|
|
44
|
+
const records = this.steps.get(sessionId)
|
|
45
|
+
if (!records || records.length < minSteps) return false
|
|
46
|
+
const recent = records.slice(-minSteps)
|
|
47
|
+
return recent.every(r => r.outputTokens < threshold)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Time-based stall: no message or task completion within `thresholdMs` of now. */
|
|
51
|
+
isTimeStalled(sessionId: string, thresholdMs: number): boolean {
|
|
52
|
+
const records = this.steps.get(sessionId)
|
|
53
|
+
if (!records || records.length === 0) return false
|
|
54
|
+
|
|
55
|
+
const msgAt = this.lastMessageAt.get(sessionId) ?? 0
|
|
56
|
+
const taskAt = this.lastTaskAt.get(sessionId) ?? 0
|
|
57
|
+
const lastStep = records[records.length - 1]?.timestamp ?? 0
|
|
58
|
+
const baseline = Math.max(msgAt, taskAt, lastStep)
|
|
59
|
+
|
|
60
|
+
return Date.now() - baseline >= thresholdMs
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Mark this session as reported-stalled (avoid spam). */
|
|
64
|
+
markReported(sessionId: string): void {
|
|
65
|
+
this.reported.add(sessionId)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Check if already reported. */
|
|
69
|
+
isReported(sessionId: string): boolean {
|
|
70
|
+
return this.reported.has(sessionId)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Clear stall report (called on new activity). */
|
|
74
|
+
clearReport(sessionId: string): void {
|
|
75
|
+
this.reported.delete(sessionId)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Remove all tracking for a session (on shutdown). */
|
|
79
|
+
remove(sessionId: string): void {
|
|
80
|
+
this.steps.delete(sessionId)
|
|
81
|
+
this.lastMessageAt.delete(sessionId)
|
|
82
|
+
this.lastTaskAt.delete(sessionId)
|
|
83
|
+
this.reported.delete(sessionId)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Structured result parsed from `<task-result>` XML. */
|
|
2
|
+
export interface TaskResult {
|
|
3
|
+
status: string
|
|
4
|
+
summary: string
|
|
5
|
+
details: string
|
|
6
|
+
branch?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse `<task-result>` XML from message content.
|
|
11
|
+
* Returns null if not found or missing required fields.
|
|
12
|
+
*/
|
|
13
|
+
export function parseTaskResult(content: string): TaskResult | null {
|
|
14
|
+
const match = content.match(/<task-result>([\s\S]*?)<\/task-result>/)
|
|
15
|
+
if (!match) return null
|
|
16
|
+
|
|
17
|
+
const inner = match[1]!
|
|
18
|
+
const status = inner.match(/<status>([\s\S]*?)<\/status>/)?.[1]?.trim()
|
|
19
|
+
const summary = inner.match(/<summary>([\s\S]*?)<\/summary>/)?.[1]?.trim()
|
|
20
|
+
const details = inner.match(/<details>([\s\S]*?)<\/details>/)?.[1]?.trim()
|
|
21
|
+
const branch = inner.match(/<branch>([\s\S]*?)<\/branch>/)?.[1]?.trim()
|
|
22
|
+
|
|
23
|
+
if (!status || !summary || !details) return null
|
|
24
|
+
|
|
25
|
+
return { status, summary, details, branch: branch || undefined }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Format a TaskResult as clean readable text.
|
|
30
|
+
*/
|
|
31
|
+
export function formatTaskResult(from: string, result: TaskResult): string {
|
|
32
|
+
const lines = [
|
|
33
|
+
`[Result from ${from}]:`,
|
|
34
|
+
` Status: ${result.status}`,
|
|
35
|
+
` Summary: ${result.summary}`,
|
|
36
|
+
` Details: ${result.details}`,
|
|
37
|
+
]
|
|
38
|
+
if (result.branch) lines.push(` Branch: ${result.branch}`)
|
|
39
|
+
return lines.join("\n")
|
|
40
|
+
}
|
package/src/system-prompt.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite"
|
|
2
2
|
import { markDelivered } from "./messaging"
|
|
3
|
+
import { parseTaskResult, formatTaskResult } from "./result-parser"
|
|
4
|
+
|
|
5
|
+
/** Truncate a string to maxLen chars, appending "..." if truncated. */
|
|
6
|
+
function truncate(s: string, maxLen: number): string {
|
|
7
|
+
return s.length > maxLen ? `${s.slice(0, maxLen)}...` : s
|
|
8
|
+
}
|
|
3
9
|
|
|
4
10
|
/** Status display mapping from DB status to human-readable label. */
|
|
5
11
|
const STATUS_DISPLAY: Record<string, string> = {
|
|
@@ -51,11 +57,38 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
|
|
|
51
57
|
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
|
|
52
58
|
]
|
|
53
59
|
|
|
60
|
+
// Inline active and recently completed tasks
|
|
61
|
+
const activeTasks = db.query(
|
|
62
|
+
"SELECT content, assignee FROM team_task WHERE team_id = ? AND status = 'in_progress' ORDER BY time_updated DESC LIMIT 5"
|
|
63
|
+
).all(teamId) as Array<{ content: string; assignee: string | null }>
|
|
64
|
+
|
|
65
|
+
const recentCompleted = db.query(
|
|
66
|
+
"SELECT content, assignee FROM team_task WHERE team_id = ? AND status = 'completed' ORDER BY time_updated DESC LIMIT 3"
|
|
67
|
+
).all(teamId) as Array<{ content: string; assignee: string | null }>
|
|
68
|
+
|
|
69
|
+
if (activeTasks.length > 0) {
|
|
70
|
+
lines.push("Active tasks:")
|
|
71
|
+
for (const t of activeTasks) {
|
|
72
|
+
lines.push(` [in_progress] ${truncate(t.content, 120)}${t.assignee ? ` → ${t.assignee}` : ""}`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (recentCompleted.length > 0) {
|
|
76
|
+
lines.push("Recently completed:")
|
|
77
|
+
for (const t of recentCompleted) {
|
|
78
|
+
lines.push(` [completed] ${truncate(t.content, 120)}${t.assignee ? ` → ${t.assignee}` : ""}`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
54
82
|
if (pendingMessages.length > 0) {
|
|
55
83
|
lines.push("", "--- Team Messages ---")
|
|
56
84
|
const MAX_MSG = 500
|
|
57
85
|
for (const msg of pendingMessages) {
|
|
58
|
-
|
|
86
|
+
const parsed = parseTaskResult(msg.content)
|
|
87
|
+
if (parsed) {
|
|
88
|
+
// Truncate details for system prompt — full content available via team_results
|
|
89
|
+
const truncatedResult = { ...parsed, details: truncate(parsed.details, 500) }
|
|
90
|
+
lines.push(formatTaskResult(msg.from_name, truncatedResult))
|
|
91
|
+
} else if (msg.content.length > MAX_MSG) {
|
|
59
92
|
lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (use team_results to read full message)`)
|
|
60
93
|
} else {
|
|
61
94
|
lines.push(`[From ${msg.from_name}]: ${msg.content}`)
|
|
@@ -141,8 +174,41 @@ export function buildTeamCompactionContext(
|
|
|
141
174
|
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
|
|
142
175
|
]
|
|
143
176
|
|
|
144
|
-
if (role === "member") {
|
|
177
|
+
if (role === "member" && memberName) {
|
|
145
178
|
lines.push("IMPORTANT: You MUST send your results to the lead via team_message before stopping.")
|
|
179
|
+
|
|
180
|
+
// Include original task prompt
|
|
181
|
+
const member = db.query("SELECT prompt FROM team_member WHERE team_id = ? AND name = ?")
|
|
182
|
+
.get(teamId, memberName) as { prompt: string | null } | null
|
|
183
|
+
if (member?.prompt) {
|
|
184
|
+
lines.push(`Your original task: ${truncate(member.prompt, 300)}`)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Include recent messages involving this member
|
|
188
|
+
const recentMsgs = db.query(
|
|
189
|
+
"SELECT from_name, content FROM team_message WHERE team_id = ? AND (from_name = ? OR to_name = ?) ORDER BY time_created DESC LIMIT 3"
|
|
190
|
+
).all(teamId, memberName, memberName) as Array<{ from_name: string; content: string }>
|
|
191
|
+
if (recentMsgs.length > 0) {
|
|
192
|
+
lines.push("Recent context:")
|
|
193
|
+
for (const msg of recentMsgs) {
|
|
194
|
+
lines.push(` [${msg.from_name}]: ${truncate(msg.content, 200)}`)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} else if (role === "member") {
|
|
198
|
+
lines.push("IMPORTANT: You MUST send your results to the lead via team_message before stopping.")
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (role === "lead") {
|
|
202
|
+
// Include recently completed tasks
|
|
203
|
+
const completedTasks = db.query(
|
|
204
|
+
"SELECT content, assignee FROM team_task WHERE team_id = ? AND status = 'completed' ORDER BY time_updated DESC LIMIT 5"
|
|
205
|
+
).all(teamId) as Array<{ content: string; assignee: string | null }>
|
|
206
|
+
if (completedTasks.length > 0) {
|
|
207
|
+
lines.push("Recently completed:")
|
|
208
|
+
for (const t of completedTasks) {
|
|
209
|
+
lines.push(` [completed] ${truncate(t.content, 120)}${t.assignee ? ` (by ${t.assignee})` : ""}`)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
146
212
|
}
|
|
147
213
|
|
|
148
214
|
return lines.join("\n")
|
|
@@ -1,8 +1,64 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { requireLead, checkWorktreeDirty } from "./shared"
|
|
3
3
|
import type { IsDirtyFn } from "./shared"
|
|
4
|
+
import { spawnFailures } from "./team-spawn"
|
|
4
5
|
import { log } from "../log"
|
|
5
6
|
|
|
7
|
+
/** Result of merging all branches. */
|
|
8
|
+
export interface MergeAllResult {
|
|
9
|
+
merged: string[]
|
|
10
|
+
conflicted: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Injectable function that merges all branches into the working directory. */
|
|
14
|
+
export type MergeAllFn = (branches: string[], cwd: string) => Promise<MergeAllResult>
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default merge implementation: stash user work, squash-merge each branch,
|
|
18
|
+
* unstage merged changes, restore user work. One stash/pop cycle for all branches.
|
|
19
|
+
*/
|
|
20
|
+
export async function mergeAllBranches(branches: string[], cwd: string): Promise<MergeAllResult> {
|
|
21
|
+
const merged: string[] = []
|
|
22
|
+
const conflicted: string[] = []
|
|
23
|
+
|
|
24
|
+
// Stash existing work to preserve staged/unstaged state
|
|
25
|
+
const stash = Bun.spawn(["git", "stash", "--include-untracked"], { cwd, stdout: "pipe", stderr: "pipe" })
|
|
26
|
+
const stashOut = await new Response(stash.stdout).text()
|
|
27
|
+
const stashExit = await stash.exited
|
|
28
|
+
const didStash = stashExit === 0 && !stashOut.includes("No local changes")
|
|
29
|
+
|
|
30
|
+
for (const branch of branches) {
|
|
31
|
+
const merge = Bun.spawn(["git", "merge", "--squash", branch], { cwd, stdout: "pipe", stderr: "pipe" })
|
|
32
|
+
const stderrPromise = new Response(merge.stderr).text()
|
|
33
|
+
const exit = await merge.exited
|
|
34
|
+
if (exit !== 0) {
|
|
35
|
+
const stderr = await stderrPromise
|
|
36
|
+
log(`cleanup:merge:conflict branch=${branch} err=${stderr.trim()}`)
|
|
37
|
+
const abort = Bun.spawn(["git", "merge", "--abort"], { cwd, stdout: "pipe", stderr: "pipe" })
|
|
38
|
+
await abort.exited
|
|
39
|
+
conflicted.push(branch)
|
|
40
|
+
} else {
|
|
41
|
+
const del = Bun.spawn(["git", "branch", "-D", branch], { cwd, stdout: "pipe", stderr: "pipe" })
|
|
42
|
+
await del.exited
|
|
43
|
+
merged.push(branch)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Unstage merged changes so user reviews with `git diff`
|
|
48
|
+
if (merged.length > 0) {
|
|
49
|
+
const reset = Bun.spawn(["git", "reset", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" })
|
|
50
|
+
await reset.exited
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Restore user's original staged/unstaged work
|
|
54
|
+
if (didStash) {
|
|
55
|
+
const pop = Bun.spawn(["git", "stash", "pop", "--index"], { cwd, stdout: "pipe", stderr: "pipe" })
|
|
56
|
+
await pop.exited
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { merged, conflicted }
|
|
60
|
+
}
|
|
61
|
+
|
|
6
62
|
/**
|
|
7
63
|
* Execute the team_cleanup tool. Archives the team and cleans up resources.
|
|
8
64
|
* Checks for uncommitted changes BEFORE aborting sessions or removing worktrees.
|
|
@@ -14,6 +70,8 @@ export async function executeTeamCleanup(
|
|
|
14
70
|
args: { force: boolean; acknowledge_uncommitted?: boolean },
|
|
15
71
|
sessionId: string,
|
|
16
72
|
isDirty: IsDirtyFn = checkWorktreeDirty,
|
|
73
|
+
mergeAll: MergeAllFn = mergeAllBranches,
|
|
74
|
+
mergeOnCleanup = true,
|
|
17
75
|
): Promise<string> {
|
|
18
76
|
const teamInfo = requireLead(deps, sessionId)
|
|
19
77
|
|
|
@@ -57,8 +115,25 @@ export async function executeTeamCleanup(
|
|
|
57
115
|
}
|
|
58
116
|
}
|
|
59
117
|
|
|
60
|
-
//
|
|
61
|
-
const branches
|
|
118
|
+
// Collect branches and merge BEFORE removing worktrees (worktree.remove may delete the branch ref)
|
|
119
|
+
const branches = members
|
|
120
|
+
.map(m => m.worktree_branch)
|
|
121
|
+
.filter((b): b is string => b !== null)
|
|
122
|
+
|
|
123
|
+
let mergeResult = ""
|
|
124
|
+
if (branches.length > 0 && mergeOnCleanup) {
|
|
125
|
+
const { merged, conflicted } = await mergeAll(branches, deps.directory)
|
|
126
|
+
const parts: string[] = []
|
|
127
|
+
if (merged.length > 0) {
|
|
128
|
+
parts.push(`Merged ${merged.length} branch(es) into working directory (unstaged). Review changes with: git diff`)
|
|
129
|
+
}
|
|
130
|
+
if (conflicted.length > 0) {
|
|
131
|
+
parts.push(`Could not auto-merge: ${conflicted.join(", ")}. Merge manually with: git merge <branch>`)
|
|
132
|
+
}
|
|
133
|
+
mergeResult = parts.join("\n")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Remove workspaces and worktrees (after merging)
|
|
62
137
|
for (const member of members) {
|
|
63
138
|
if (member.workspace_id) {
|
|
64
139
|
try {
|
|
@@ -72,16 +147,18 @@ export async function executeTeamCleanup(
|
|
|
72
147
|
deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name])
|
|
73
148
|
} catch { /* best effort — worktree may already be gone */ }
|
|
74
149
|
}
|
|
75
|
-
if (member.worktree_branch) {
|
|
76
|
-
branches.push(member.worktree_branch)
|
|
77
|
-
}
|
|
78
150
|
}
|
|
79
151
|
|
|
80
152
|
// Archive team
|
|
81
153
|
deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), teamInfo.teamId])
|
|
82
154
|
|
|
83
|
-
// Clean up
|
|
155
|
+
// Clean up in-memory state
|
|
84
156
|
deps.registry.unregisterTeam(teamInfo.teamId)
|
|
157
|
+
spawnFailures.delete(teamInfo.teamId)
|
|
158
|
+
|
|
159
|
+
if (mergeResult) {
|
|
160
|
+
return `Team "${teamInfo.teamName}" cleaned up.\n${mergeResult}`
|
|
161
|
+
}
|
|
85
162
|
|
|
86
163
|
if (branches.length > 0) {
|
|
87
164
|
const mergeCommands = branches.map(b => ` git merge ${b}`).join("\n")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { requireTeamMember } from "./shared"
|
|
3
|
+
import { parseTaskResult, formatTaskResult } from "../result-parser"
|
|
3
4
|
|
|
4
5
|
/** Row shape for unread messages query. */
|
|
5
6
|
interface UnreadMessageRow {
|
|
@@ -39,6 +40,10 @@ export async function executeTeamResults(
|
|
|
39
40
|
const placeholders = ids.map(() => "?").join(", ")
|
|
40
41
|
deps.db.run(`UPDATE team_message SET read = 1 WHERE id IN (${placeholders})`, ids)
|
|
41
42
|
|
|
42
|
-
// Format output
|
|
43
|
-
return rows.map((r) =>
|
|
43
|
+
// Format output — parse structured task results when present
|
|
44
|
+
return rows.map((r) => {
|
|
45
|
+
const parsed = parseTaskResult(r.content)
|
|
46
|
+
if (parsed) return formatTaskResult(r.from_name, parsed)
|
|
47
|
+
return `[Message from ${r.from_name}]:\n${r.content}`
|
|
48
|
+
}).join("\n\n")
|
|
44
49
|
}
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -4,6 +4,9 @@ import { requireLead } from "./shared"
|
|
|
4
4
|
import { sendMessage } from "../messaging"
|
|
5
5
|
import { log } from "../log"
|
|
6
6
|
|
|
7
|
+
/** Tracks consecutive spawn failures per team for circuit breaker. */
|
|
8
|
+
export const spawnFailures = new Map<string, { count: number; lastError: string }>()
|
|
9
|
+
|
|
7
10
|
/** Timeout for worktree.create and session.create to prevent hanging on git lock contention. */
|
|
8
11
|
function getSpawnTimeout(): number {
|
|
9
12
|
return Number(process.env.SPAWN_TIMEOUT_MS) || 120_000
|
|
@@ -41,6 +44,12 @@ export async function executeTeamSpawn(
|
|
|
41
44
|
|
|
42
45
|
const teamInfo = requireLead(deps, sessionId)
|
|
43
46
|
|
|
47
|
+
// Circuit breaker — stop retrying after 3 consecutive failures
|
|
48
|
+
const failures = spawnFailures.get(teamInfo.teamId)
|
|
49
|
+
if (failures && failures.count >= 3) {
|
|
50
|
+
throw new Error(`Spawn circuit breaker tripped for team "${teamInfo.teamName}": 3 consecutive failures. Last error: ${failures.lastError}. Investigate before retrying — the circuit breaker resets on the next successful spawn.`)
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
// Check duplicate name
|
|
45
54
|
const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?")
|
|
46
55
|
.get(teamInfo.teamId, args.name)
|
|
@@ -146,6 +155,10 @@ export async function executeTeamSpawn(
|
|
|
146
155
|
log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`)
|
|
147
156
|
} catch (err) {
|
|
148
157
|
log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
|
|
158
|
+
// Track failure for circuit breaker
|
|
159
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
160
|
+
const prev = spawnFailures.get(teamInfo.teamId)
|
|
161
|
+
spawnFailures.set(teamInfo.teamId, { count: (prev?.count ?? 0) + 1, lastError: errMsg })
|
|
149
162
|
// Rollback workspace and worktree if session creation failed
|
|
150
163
|
if (workspaceId) {
|
|
151
164
|
try { await deps.client.workspace.remove({ id: workspaceId }) } catch { /* best effort */ }
|
|
@@ -184,6 +197,14 @@ export async function executeTeamSpawn(
|
|
|
184
197
|
`Your agent type is "${args.agent}".`,
|
|
185
198
|
]
|
|
186
199
|
|
|
200
|
+
// Show other teammates so this agent knows who to message
|
|
201
|
+
const otherMembers = deps.db.query(
|
|
202
|
+
"SELECT name FROM team_member WHERE team_id = ? AND name != ? AND status NOT IN ('shutdown', 'error')"
|
|
203
|
+
).all(teamInfo.teamId, args.name) as Array<{ name: string }>
|
|
204
|
+
if (otherMembers.length > 0) {
|
|
205
|
+
context.push(`Other teammates: ${otherMembers.map(m => m.name).join(", ")}`)
|
|
206
|
+
}
|
|
207
|
+
|
|
187
208
|
if (worktreeBranch && worktreeDir && !workspaceId) {
|
|
188
209
|
// Workspace binding failed — fallback to prompt-based CWD instruction
|
|
189
210
|
context.push(
|
|
@@ -263,7 +284,9 @@ export async function executeTeamSpawn(
|
|
|
263
284
|
context.push(
|
|
264
285
|
`${lastStep}. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.`,
|
|
265
286
|
"",
|
|
266
|
-
"If you are blocked
|
|
287
|
+
"If you are blocked:",
|
|
288
|
+
"- Send ONE message to the lead via team_message describing the specific blocker.",
|
|
289
|
+
"- Do NOT attempt workarounds or make assumptions. Wait for the lead's response.",
|
|
267
290
|
"",
|
|
268
291
|
"Your plain text output is NOT visible to the team. You MUST use team_message to communicate.",
|
|
269
292
|
)
|
|
@@ -319,6 +342,8 @@ export async function executeTeamSpawn(
|
|
|
319
342
|
|
|
320
343
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
|
|
321
344
|
const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
|
|
345
|
+
// Reset circuit breaker on success
|
|
346
|
+
spawnFailures.delete(teamInfo.teamId)
|
|
322
347
|
log(`spawn:done name=${args.name} sessionId=${childSessionId}`)
|
|
323
348
|
return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
|
|
324
349
|
}
|