@hueyexe/opencode-ensemble 0.14.1 → 0.15.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 +15 -7
- package/dist/index.js +2093 -1639
- package/package.json +8 -4
- package/src/dashboard-html.ts +11 -3
- package/src/{dashboard-js-part1.ts → dashboard-js-core.ts} +11 -3
- package/src/{dashboard-js-part3.ts → dashboard-js-events.ts} +25 -4
- package/src/{dashboard-js-part2.ts → dashboard-js-render.ts} +13 -8
- package/src/dashboard.ts +154 -78
- package/src/db.ts +88 -5
- package/src/hooks.ts +72 -5
- package/src/index.ts +31 -20
- package/src/messaging.ts +1 -1
- package/src/notify.ts +1 -1
- package/src/process.ts +55 -0
- package/src/recovery.ts +56 -31
- package/src/schema.ts +123 -3
- package/src/system-prompt.ts +1 -1
- package/src/tools/merge-helper.ts +63 -27
- package/src/tools/shared.ts +7 -10
- package/src/tools/team-cleanup.ts +38 -28
- package/src/tools/team-create.ts +17 -5
- package/src/tools/team-shutdown.ts +8 -7
- package/src/tools/team-spawn.ts +3 -1
- package/src/types.ts +51 -5
- package/src/util.ts +17 -0
- package/src/watchdog.ts +5 -4
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { Database } from "../db"
|
|
1
2
|
import { log } from "../log"
|
|
3
|
+
import { runCommand } from "../process"
|
|
2
4
|
|
|
3
5
|
/** Result of merging a single branch. */
|
|
4
6
|
export interface MergeResult {
|
|
@@ -18,17 +20,58 @@ export type PreserveBranchFn = (sourceBranch: string, targetBranch: string, cwd:
|
|
|
18
20
|
/** Injectable function for deleting a branch. */
|
|
19
21
|
export type DeleteBranchFn = (branch: string, cwd: string) => Promise<boolean>
|
|
20
22
|
|
|
23
|
+
/** Human-readable resource identity for a team. */
|
|
24
|
+
export interface TeamResourceParts {
|
|
25
|
+
projectName: string
|
|
26
|
+
teamName: string
|
|
27
|
+
teamId: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function shortTeamId(teamId: string): string {
|
|
31
|
+
return (teamId.split("_").at(-1) || teamId).slice(0, 6)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resourcePart(value: string): string {
|
|
35
|
+
const part = value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "")
|
|
36
|
+
return part || "unnamed"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Load the human-readable pieces used to build team resource names. */
|
|
40
|
+
export function getTeamResourceParts(db: Database, teamId: string): TeamResourceParts {
|
|
41
|
+
const row = db.query(
|
|
42
|
+
`SELECT t.id as team_id, t.name as team_name, p.name as project_name
|
|
43
|
+
FROM team t
|
|
44
|
+
JOIN project p ON t.project_id = p.id
|
|
45
|
+
WHERE t.id = ?`
|
|
46
|
+
).get(teamId) as { team_id: string; team_name: string; project_name: string } | null
|
|
47
|
+
if (!row) throw new Error(`Team not found: ${teamId}`)
|
|
48
|
+
return { projectName: row.project_name, teamName: row.team_name, teamId: row.team_id }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Build the readable namespace used for team-owned resources. */
|
|
52
|
+
export function teamResourceSlug(projectName: string, teamName: string, teamId: string): string {
|
|
53
|
+
return `${resourcePart(projectName)}-${resourcePart(teamName)}#${shortTeamId(teamId)}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Build the team-only resource segment used under project-scoped namespaces. */
|
|
57
|
+
export function teamResourceSegment(teamName: string, teamId: string): string {
|
|
58
|
+
return `${resourcePart(teamName)}#${shortTeamId(teamId)}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build an OpenCode worktree name for a team member. */
|
|
62
|
+
export function teamWorktreeName(projectName: string, teamName: string, teamId: string, memberName: string): string {
|
|
63
|
+
return `ensemble-${teamResourceSlug(projectName, teamName, teamId)}-${memberName}`
|
|
64
|
+
}
|
|
65
|
+
|
|
21
66
|
/**
|
|
22
67
|
* Copy a git branch to a new ref. Used to preserve worktree branches
|
|
23
68
|
* before session.abort() which may delete the worktree and its branch.
|
|
24
69
|
* Returns true if the branch was successfully copied.
|
|
25
70
|
*/
|
|
26
71
|
export async function preserveBranch(sourceBranch: string, targetBranch: string, cwd: string): Promise<boolean> {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const stderr = await new Response(proc.stderr).text()
|
|
31
|
-
log(`merge-helper:preserve:failed src=${sourceBranch} target=${targetBranch} err=${stderr.trim()}`)
|
|
72
|
+
const result = await runCommand(["git", "branch", targetBranch, sourceBranch], { cwd })
|
|
73
|
+
if (result.exitCode !== 0) {
|
|
74
|
+
log(`merge-helper:preserve:failed src=${sourceBranch} target=${targetBranch} err=${result.stderr.trim()}`)
|
|
32
75
|
return false
|
|
33
76
|
}
|
|
34
77
|
return true
|
|
@@ -38,9 +81,8 @@ export async function preserveBranch(sourceBranch: string, targetBranch: string,
|
|
|
38
81
|
* Delete a git branch. Returns true if successful.
|
|
39
82
|
*/
|
|
40
83
|
export async function deleteBranch(branch: string, cwd: string): Promise<boolean> {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
if (exit !== 0) {
|
|
84
|
+
const result = await runCommand(["git", "branch", "-D", branch], { cwd })
|
|
85
|
+
if (result.exitCode !== 0) {
|
|
44
86
|
log(`merge-helper:delete:failed branch=${branch}`)
|
|
45
87
|
return false
|
|
46
88
|
}
|
|
@@ -52,16 +94,13 @@ export async function deleteBranch(branch: string, cwd: string): Promise<boolean
|
|
|
52
94
|
* Used by mergeBranch (single) and mergeMultipleBranches (batch).
|
|
53
95
|
*/
|
|
54
96
|
export async function mergeBranchRaw(branch: string, cwd: string): Promise<MergeResult> {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const abort = Bun.spawn(["git", "merge", "--abort"], { cwd, stdout: "pipe", stderr: "pipe" })
|
|
63
|
-
await abort.exited
|
|
64
|
-
return { ok: false, error: stderr.trim() || `merge exited with code ${mergeExit}` }
|
|
97
|
+
const result = await runCommand(["git", "merge", "--squash", branch], { cwd })
|
|
98
|
+
|
|
99
|
+
if (result.exitCode !== 0) {
|
|
100
|
+
const stderr = result.stderr.trim()
|
|
101
|
+
log(`merge-helper:merge:conflict branch=${branch} err=${stderr}`)
|
|
102
|
+
await runCommand(["git", "merge", "--abort"], { cwd })
|
|
103
|
+
return { ok: false, error: stderr || `merge exited with code ${result.exitCode}` }
|
|
65
104
|
}
|
|
66
105
|
|
|
67
106
|
return { ok: true }
|
|
@@ -69,8 +108,7 @@ export async function mergeBranchRaw(branch: string, cwd: string): Promise<Merge
|
|
|
69
108
|
|
|
70
109
|
/** Unstage all changes so merge results appear as unstaged. */
|
|
71
110
|
export async function gitReset(cwd: string): Promise<void> {
|
|
72
|
-
|
|
73
|
-
await reset.exited
|
|
111
|
+
await runCommand(["git", "reset", "HEAD"], { cwd })
|
|
74
112
|
}
|
|
75
113
|
|
|
76
114
|
/**
|
|
@@ -91,11 +129,9 @@ export async function mergeBranch(branch: string, cwd: string): Promise<MergeRes
|
|
|
91
129
|
*/
|
|
92
130
|
export async function getOverlappingFiles(branch: string, cwd: string): Promise<string[]> {
|
|
93
131
|
const run = async (args: string[]) => {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (exit !== 0) throw new Error(`git ${args.join(" ")} failed with exit code ${exit}`)
|
|
98
|
-
return out.split("\n").filter(Boolean)
|
|
132
|
+
const result = await runCommand(["git", ...args], { cwd })
|
|
133
|
+
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed with exit code ${result.exitCode}`)
|
|
134
|
+
return result.stdout.split("\n").filter(Boolean)
|
|
99
135
|
}
|
|
100
136
|
const agentFiles = new Set(await run(["diff", "--name-only", "HEAD", branch]))
|
|
101
137
|
const localChanged = await run(["diff", "--name-only", "HEAD"])
|
|
@@ -107,6 +143,6 @@ export async function getOverlappingFiles(branch: string, cwd: string): Promise<
|
|
|
107
143
|
/**
|
|
108
144
|
* Build the preserved branch name for a team member.
|
|
109
145
|
*/
|
|
110
|
-
export function preservedBranchName(teamName: string, memberName: string): string {
|
|
111
|
-
return `ensemble/preserved/${teamName}/${memberName}`
|
|
146
|
+
export function preservedBranchName(projectName: string, teamName: string, teamId: string, memberName: string): string {
|
|
147
|
+
return `ensemble/preserved/${resourcePart(projectName)}/${teamResourceSegment(teamName, teamId)}/${resourcePart(memberName)}`
|
|
112
148
|
}
|
package/src/tools/shared.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { findTeamBySession } from "../types"
|
|
3
|
+
import { runCommand } from "../process"
|
|
3
4
|
|
|
4
5
|
/** Function type for dirty worktree check — injectable for testing. */
|
|
5
6
|
export type IsDirtyFn = (dir: string) => Promise<boolean>
|
|
@@ -10,11 +11,9 @@ export type CommitCountFn = (branch: string, cwd: string) => Promise<number>
|
|
|
10
11
|
/** Count commits a branch has ahead of HEAD. Approximate — may include base divergence. Returns -1 if check fails. */
|
|
11
12
|
export async function countBranchCommits(branch: string, cwd: string): Promise<number> {
|
|
12
13
|
try {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
if (exit !== 0) return -1
|
|
17
|
-
const n = Number.parseInt(out.trim(), 10)
|
|
14
|
+
const result = await runCommand(["git", "rev-list", "--count", `HEAD..${branch}`], { cwd })
|
|
15
|
+
if (result.exitCode !== 0) return -1
|
|
16
|
+
const n = Number.parseInt(result.stdout.trim(), 10)
|
|
18
17
|
return Number.isNaN(n) ? -1 : n
|
|
19
18
|
} catch { return -1 }
|
|
20
19
|
}
|
|
@@ -22,11 +21,9 @@ export async function countBranchCommits(branch: string, cwd: string): Promise<n
|
|
|
22
21
|
/** Check if a worktree directory has uncommitted changes via git status. */
|
|
23
22
|
export async function checkWorktreeDirty(dir: string): Promise<boolean> {
|
|
24
23
|
try {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (exitCode !== 0) return false // git failed — assume clean
|
|
29
|
-
return output.trim().length > 0
|
|
24
|
+
const result = await runCommand(["git", "-C", dir, "status", "--porcelain"])
|
|
25
|
+
if (result.exitCode !== 0) return false // git failed — assume clean
|
|
26
|
+
return result.stdout.trim().length > 0
|
|
30
27
|
} catch {
|
|
31
28
|
return false // can't check — assume clean
|
|
32
29
|
}
|
|
@@ -2,17 +2,20 @@ import type { ToolDeps } from "../types"
|
|
|
2
2
|
import { requireLead, requireCanPurgeArchivedTeams, checkWorktreeDirty } from "./shared"
|
|
3
3
|
import type { IsDirtyFn } from "./shared"
|
|
4
4
|
import { spawnFailures } from "./team-spawn"
|
|
5
|
-
import { mergeBranch, deleteBranch, preserveBranch, preservedBranchName, getOverlappingFiles } from "./merge-helper"
|
|
5
|
+
import { getTeamResourceParts, mergeBranch, deleteBranch, preserveBranch, preservedBranchName, getOverlappingFiles, teamResourceSegment } from "./merge-helper"
|
|
6
6
|
import type { MergeBranchFn, DeleteBranchFn, PreserveBranchFn, OverlapCheckFn } from "./merge-helper"
|
|
7
7
|
import { log } from "../log"
|
|
8
|
+
import { runCommand } from "../process"
|
|
8
9
|
|
|
9
10
|
type PurgeApprovalFn = (preview: string) => Promise<void>
|
|
10
|
-
type ListBranchesFn = (
|
|
11
|
+
type ListBranchesFn = (namespace: string, cwd: string) => Promise<string[]>
|
|
11
12
|
type BranchExistsFn = (branch: string, cwd: string) => Promise<boolean>
|
|
12
13
|
|
|
13
14
|
interface PurgeTarget {
|
|
14
15
|
id: string
|
|
15
16
|
name: string
|
|
17
|
+
project_id: string
|
|
18
|
+
project_name: string
|
|
16
19
|
time_updated: number
|
|
17
20
|
}
|
|
18
21
|
|
|
@@ -28,6 +31,7 @@ interface PurgeStats extends PurgeTarget {
|
|
|
28
31
|
interface PurgeMemberResource {
|
|
29
32
|
team_id: string
|
|
30
33
|
team_name: string
|
|
34
|
+
project_name: string
|
|
31
35
|
member_name: string
|
|
32
36
|
worktree_dir: string | null
|
|
33
37
|
workspace_id: string | null
|
|
@@ -36,12 +40,9 @@ interface PurgeMemberResource {
|
|
|
36
40
|
|
|
37
41
|
async function listPreservedBranches(teamName: string, cwd: string): Promise<string[]> {
|
|
38
42
|
try {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const exit = await proc.exited
|
|
43
|
-
if (exit !== 0) throw new Error(stderr.trim() || `git branch exited with code ${exit}`)
|
|
44
|
-
return out.split("\n").map(branch => branch.trim()).filter(Boolean)
|
|
43
|
+
const result = await runCommand(["git", "branch", "--list", `ensemble/preserved/${teamName}/*`, "--format", "%(refname:short)"], { cwd })
|
|
44
|
+
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || `git branch exited with code ${result.exitCode}`)
|
|
45
|
+
return result.stdout.split("\n").map(branch => branch.trim()).filter(Boolean)
|
|
45
46
|
} catch (err) {
|
|
46
47
|
const message = err instanceof Error ? err.message : String(err)
|
|
47
48
|
if (message.includes("not a git repository")) return []
|
|
@@ -51,15 +52,12 @@ async function listPreservedBranches(teamName: string, cwd: string): Promise<str
|
|
|
51
52
|
|
|
52
53
|
async function branchExists(branch: string, cwd: string): Promise<boolean> {
|
|
53
54
|
try {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (exit !== 0) {
|
|
59
|
-
if (stderr.includes("not a git repository")) return false
|
|
60
|
-
throw new Error(stderr.trim() || `git branch exited with code ${exit}`)
|
|
55
|
+
const result = await runCommand(["git", "branch", "--list", branch, "--format", "%(refname:short)"], { cwd })
|
|
56
|
+
if (result.exitCode !== 0) {
|
|
57
|
+
if (result.stderr.includes("not a git repository")) return false
|
|
58
|
+
throw new Error(result.stderr.trim() || `git branch exited with code ${result.exitCode}`)
|
|
61
59
|
}
|
|
62
|
-
return
|
|
60
|
+
return result.stdout.split("\n").map(item => item.trim()).includes(branch)
|
|
63
61
|
} catch (err) {
|
|
64
62
|
const message = err instanceof Error ? err.message : String(err)
|
|
65
63
|
if (message.includes("not a git repository")) return false
|
|
@@ -78,24 +76,25 @@ function resolvePurgeTargets(deps: ToolDeps, purge: string[]): PurgeTarget[] {
|
|
|
78
76
|
}
|
|
79
77
|
|
|
80
78
|
if (purge.includes("*")) {
|
|
81
|
-
return deps.db.query("SELECT id, name, time_updated FROM team WHERE status = 'archived' ORDER BY time_updated DESC, name ASC")
|
|
82
|
-
.all() as PurgeTarget[]
|
|
79
|
+
return deps.db.query("SELECT t.id, t.name, t.project_id, p.name as project_name, t.time_updated FROM team t JOIN project p ON t.project_id = p.id WHERE t.status = 'archived' AND t.project_id = ? ORDER BY t.time_updated DESC, t.name ASC")
|
|
80
|
+
.all(deps.directory) as PurgeTarget[]
|
|
83
81
|
}
|
|
84
82
|
|
|
85
83
|
const uniqueNames = [...new Set(purge)]
|
|
86
84
|
const rows = uniqueNames.map(name => ({
|
|
87
85
|
name,
|
|
88
|
-
|
|
86
|
+
teams: deps.db.query("SELECT t.id, t.name, t.project_id, p.name as project_name, t.status, t.time_updated FROM team t JOIN project p ON t.project_id = p.id WHERE t.name = ? AND t.project_id = ? ORDER BY t.time_updated DESC")
|
|
87
|
+
.all(name, deps.directory) as Array<{ id: string; name: string; project_id: string; project_name: string; status: string; time_updated: number }>,
|
|
89
88
|
}))
|
|
90
89
|
|
|
91
|
-
const missing = rows.filter(row => row.
|
|
90
|
+
const missing = rows.filter(row => row.teams.length === 0).map(row => row.name)
|
|
92
91
|
if (missing.length > 0) throw new Error(`Team not found: ${missing.join(", ")}`)
|
|
93
92
|
|
|
94
|
-
const active = rows.filter(row => row.team
|
|
93
|
+
const active = rows.filter(row => row.teams.some(team => team.status === "active")).map(row => row.name)
|
|
95
94
|
if (active.length > 0) throw new Error(`Cannot purge active team: ${active.join(", ")}`)
|
|
96
95
|
|
|
97
96
|
return rows
|
|
98
|
-
.map(row => row.
|
|
97
|
+
.map(row => row.teams[0]!)
|
|
99
98
|
.sort((a, b) => b.time_updated - a.time_updated || a.name.localeCompare(b.name))
|
|
100
99
|
}
|
|
101
100
|
|
|
@@ -121,6 +120,7 @@ function deleteArchivedTeams(deps: ToolDeps, targets: PurgeTarget[]): void {
|
|
|
121
120
|
function getPurgeMemberResources(deps: ToolDeps, targets: PurgeTarget[]): PurgeMemberResource[] {
|
|
122
121
|
return targets.flatMap(target => deps.db.query(
|
|
123
122
|
`SELECT t.name as team_name,
|
|
123
|
+
p.name as project_name,
|
|
124
124
|
tm.team_id,
|
|
125
125
|
tm.name as member_name,
|
|
126
126
|
tm.worktree_dir,
|
|
@@ -128,23 +128,32 @@ function getPurgeMemberResources(deps: ToolDeps, targets: PurgeTarget[]): PurgeM
|
|
|
128
128
|
tm.worktree_branch
|
|
129
129
|
FROM team_member tm
|
|
130
130
|
JOIN team t ON tm.team_id = t.id
|
|
131
|
+
JOIN project p ON t.project_id = p.id
|
|
131
132
|
WHERE tm.team_id = ?`
|
|
132
133
|
).all(target.id) as PurgeMemberResource[])
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
function preservedBranchPrefix(resource: PurgeMemberResource): string {
|
|
137
|
+
return `ensemble/preserved/${resource.project_name}/${teamResourceSegment(resource.team_name, resource.team_id)}/`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function legacyPreservedBranchPrefix(resource: PurgeMemberResource): string {
|
|
136
141
|
return `ensemble/preserved/${resource.team_name}/`
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
function staleEnsembleBranchNames(resource: PurgeMemberResource): string[] {
|
|
140
145
|
return [
|
|
146
|
+
`ensemble-${resource.team_id}-${resource.member_name}`,
|
|
141
147
|
`ensemble-${resource.team_name}-${resource.member_name}`,
|
|
142
148
|
`opencode/ensemble-${resource.team_name}-${resource.member_name}`,
|
|
143
149
|
]
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
function isPreservedBranch(resource: PurgeMemberResource): boolean {
|
|
147
|
-
return resource.worktree_branch !== null &&
|
|
153
|
+
return resource.worktree_branch !== null && (
|
|
154
|
+
resource.worktree_branch.startsWith(preservedBranchPrefix(resource)) ||
|
|
155
|
+
resource.worktree_branch.startsWith(legacyPreservedBranchPrefix(resource))
|
|
156
|
+
)
|
|
148
157
|
}
|
|
149
158
|
|
|
150
159
|
function isStaleEnsembleBranch(resource: PurgeMemberResource): boolean {
|
|
@@ -305,16 +314,16 @@ async function collectPreservedBranches(
|
|
|
305
314
|
listBranches: ListBranchesFn,
|
|
306
315
|
): Promise<Map<string, string[]>> {
|
|
307
316
|
const entries: Array<[string, string[]]> = await Promise.all(targets.map(async target => {
|
|
308
|
-
const
|
|
317
|
+
const prefixes = [`ensemble/preserved/${target.project_name}/${teamResourceSegment(target.name, target.id)}/`, `ensemble/preserved/${target.name}/`]
|
|
309
318
|
let listed: string[]
|
|
310
319
|
try {
|
|
311
|
-
listed = await listBranches(target.name, cwd)
|
|
320
|
+
listed = [...await listBranches(target.project_name, cwd), ...await listBranches(target.name, cwd)]
|
|
312
321
|
} catch (err) {
|
|
313
322
|
const message = err instanceof Error ? err.message : String(err)
|
|
314
323
|
if (message.includes("not a git repository")) listed = []
|
|
315
|
-
else throw new Error(`Failed to list preserved branches for ${target.
|
|
324
|
+
else throw new Error(`Failed to list preserved branches for ${target.id}: ${message}`)
|
|
316
325
|
}
|
|
317
|
-
const branches = listed.map(normalizeBranchName).filter(branch => branch.startsWith(prefix))
|
|
326
|
+
const branches = listed.map(normalizeBranchName).filter(branch => prefixes.some(prefix => branch.startsWith(prefix)))
|
|
318
327
|
return [target.id, [...new Set(branches)]]
|
|
319
328
|
}))
|
|
320
329
|
return new Map(entries)
|
|
@@ -478,7 +487,8 @@ export async function executeTeamCleanup(
|
|
|
478
487
|
for (const member of active) {
|
|
479
488
|
// Preserve branch before abort — session.abort() may destroy the worktree + branch
|
|
480
489
|
if (member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
|
|
481
|
-
const
|
|
490
|
+
const resource = getTeamResourceParts(deps.db, teamInfo.teamId)
|
|
491
|
+
const safeBranch = preservedBranchName(resource.projectName, resource.teamName, resource.teamId, member.name)
|
|
482
492
|
const ok = await preserveBranch(member.worktree_branch, safeBranch, deps.directory)
|
|
483
493
|
if (ok) {
|
|
484
494
|
deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
|
package/src/tools/team-create.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import { generateId, validateTeamName } from "../util"
|
|
2
|
+
import { generateId, generateProjectName, validateProjectName, validateTeamName } from "../util"
|
|
3
3
|
import { findTeamBySession } from "../types"
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -7,14 +7,19 @@ import { findTeamBySession } from "../types"
|
|
|
7
7
|
*/
|
|
8
8
|
export async function executeTeamCreate(
|
|
9
9
|
deps: ToolDeps,
|
|
10
|
-
args: { name: string },
|
|
10
|
+
args: { name: string; project_name?: string },
|
|
11
11
|
sessionId: string,
|
|
12
12
|
): Promise<string> {
|
|
13
13
|
const nameError = validateTeamName(args.name)
|
|
14
14
|
if (nameError) throw new Error(nameError)
|
|
15
|
+
if (args.project_name) {
|
|
16
|
+
const projectNameError = validateProjectName(args.project_name)
|
|
17
|
+
if (projectNameError) throw new Error(projectNameError)
|
|
18
|
+
}
|
|
15
19
|
|
|
16
20
|
// Check if team name already exists
|
|
17
|
-
const
|
|
21
|
+
const projectId = deps.directory
|
|
22
|
+
const existing = deps.db.query("SELECT id FROM team WHERE name = ? AND project_id = ? AND status = 'active'").get(args.name, projectId)
|
|
18
23
|
if (existing) throw new Error(`Team "${args.name}" already exists`)
|
|
19
24
|
|
|
20
25
|
// Check if session already leads a team
|
|
@@ -23,9 +28,16 @@ export async function executeTeamCreate(
|
|
|
23
28
|
|
|
24
29
|
const id = generateId("team")
|
|
25
30
|
const now = Date.now()
|
|
31
|
+
const projectName = args.project_name ?? generateProjectName()
|
|
26
32
|
deps.db.run(
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
`INSERT INTO project (id, name, path, status, time_created, time_updated)
|
|
34
|
+
VALUES (?, ?, ?, 'active', ?, ?)
|
|
35
|
+
ON CONFLICT(id) DO UPDATE SET time_updated = excluded.time_updated`,
|
|
36
|
+
[projectId, projectName, projectId, now, now]
|
|
37
|
+
)
|
|
38
|
+
deps.db.run(
|
|
39
|
+
"INSERT INTO team (id, name, project_id, lead_session_id, status, delegate, time_created, time_updated) VALUES (?, ?, ?, ?, 'active', 0, ?, ?)",
|
|
40
|
+
[id, args.name, projectId, sessionId, now, now]
|
|
29
41
|
)
|
|
30
42
|
|
|
31
43
|
return `Team "${args.name}" created. You are the lead. Use team_spawn to add teammates.`
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { requireLead, checkWorktreeDirty, countBranchCommits } from "./shared"
|
|
3
3
|
import type { IsDirtyFn, CommitCountFn } from "./shared"
|
|
4
|
-
import { preserveBranch, preservedBranchName } from "./merge-helper"
|
|
4
|
+
import { getTeamResourceParts, preserveBranch, preservedBranchName } from "./merge-helper"
|
|
5
5
|
import type { PreserveBranchFn } from "./merge-helper"
|
|
6
6
|
import { log } from "../log"
|
|
7
7
|
|
|
@@ -30,7 +30,7 @@ export async function executeTeamShutdown(
|
|
|
30
30
|
|
|
31
31
|
// Second call on an already-requested member → force abort
|
|
32
32
|
if (member.status === "shutdown_requested") {
|
|
33
|
-
await preserveAndAbort(deps, teamInfo.teamId,
|
|
33
|
+
await preserveAndAbort(deps, teamInfo.teamId, args.member, member.session_id, member.worktree_branch, preserve)
|
|
34
34
|
const status = await getBranchStatus(deps, teamInfo.teamId, args.member, member.worktree_dir, isDirty, commitCount)
|
|
35
35
|
return `Force shut down "${args.member}".${status}`
|
|
36
36
|
}
|
|
@@ -46,7 +46,7 @@ export async function executeTeamShutdown(
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
if (isIdle || force) {
|
|
49
|
-
await preserveAndAbort(deps, teamInfo.teamId,
|
|
49
|
+
await preserveAndAbort(deps, teamInfo.teamId, args.member, member.session_id, member.worktree_branch, preserve)
|
|
50
50
|
const status = await getBranchStatus(deps, teamInfo.teamId, args.member, member.worktree_dir, isDirty, commitCount)
|
|
51
51
|
return `Teammate "${args.member}" has been shut down.${status}`
|
|
52
52
|
}
|
|
@@ -55,7 +55,8 @@ export async function executeTeamShutdown(
|
|
|
55
55
|
// Branch must be preserved NOW — if the session crashes during shutdown_requested,
|
|
56
56
|
// the worktree and branch could be lost before force-abort ever runs.
|
|
57
57
|
if (member.worktree_branch) {
|
|
58
|
-
const
|
|
58
|
+
const resource = getTeamResourceParts(deps.db, teamInfo.teamId)
|
|
59
|
+
const safeBranch = preservedBranchName(resource.projectName, resource.teamName, resource.teamId, args.member)
|
|
59
60
|
const ok = await preserve(member.worktree_branch, safeBranch, deps.directory)
|
|
60
61
|
if (ok) {
|
|
61
62
|
deps.db.run(
|
|
@@ -88,13 +89,12 @@ export async function executeTeamShutdown(
|
|
|
88
89
|
|
|
89
90
|
/**
|
|
90
91
|
* Preserve the worktree branch, then abort the session and mark shutdown.
|
|
91
|
-
* The branch is copied to ensemble/preserved/{
|
|
92
|
+
* The branch is copied to ensemble/preserved/{team_id}/{name} BEFORE abort,
|
|
92
93
|
* so session.abort() cannot destroy the agent's committed work.
|
|
93
94
|
*/
|
|
94
95
|
async function preserveAndAbort(
|
|
95
96
|
deps: ToolDeps,
|
|
96
97
|
teamId: string,
|
|
97
|
-
teamName: string,
|
|
98
98
|
memberName: string,
|
|
99
99
|
sessionId: string,
|
|
100
100
|
worktreeBranch: string | null,
|
|
@@ -102,7 +102,8 @@ async function preserveAndAbort(
|
|
|
102
102
|
): Promise<void> {
|
|
103
103
|
// Preserve the branch BEFORE aborting — session.abort() may delete the worktree + branch
|
|
104
104
|
if (worktreeBranch && !worktreeBranch.startsWith("ensemble/preserved/")) {
|
|
105
|
-
const
|
|
105
|
+
const resource = getTeamResourceParts(deps.db, teamId)
|
|
106
|
+
const safeBranch = preservedBranchName(resource.projectName, resource.teamName, resource.teamId, memberName)
|
|
106
107
|
const ok = await preserve(worktreeBranch, safeBranch, deps.directory)
|
|
107
108
|
if (ok) {
|
|
108
109
|
deps.db.run(
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { requireLead } from "./shared"
|
|
|
4
4
|
import { sendMessage } from "../messaging"
|
|
5
5
|
import { log } from "../log"
|
|
6
6
|
import type { EnsembleConfig } from "../config"
|
|
7
|
+
import { getTeamResourceParts, teamWorktreeName } from "./merge-helper"
|
|
7
8
|
|
|
8
9
|
/** Tracks consecutive spawn failures per team for circuit breaker. */
|
|
9
10
|
export const spawnFailures = new Map<string, { count: number; lastError: string }>()
|
|
@@ -96,7 +97,8 @@ export async function executeTeamSpawn(
|
|
|
96
97
|
let worktreeBranch: string | null = null
|
|
97
98
|
|
|
98
99
|
if (useWorktree) {
|
|
99
|
-
const
|
|
100
|
+
const resource = getTeamResourceParts(deps.db, teamInfo.teamId)
|
|
101
|
+
const worktreeName = teamWorktreeName(resource.projectName, resource.teamName, resource.teamId, args.name)
|
|
100
102
|
try {
|
|
101
103
|
log(`spawn:worktree:start name=${args.name}`)
|
|
102
104
|
const result = await withTimeout(
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Database } from "
|
|
1
|
+
import type { Database } from "./db"
|
|
2
2
|
import type { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state"
|
|
3
3
|
import type { EnsembleConfig } from "./config"
|
|
4
4
|
|
|
@@ -76,20 +76,54 @@ export interface PluginClient {
|
|
|
76
76
|
/**
|
|
77
77
|
* Look up which team a session belongs to (as lead or member).
|
|
78
78
|
* Returns team info or undefined.
|
|
79
|
+
*
|
|
80
|
+
* Lookup order for member sessions:
|
|
81
|
+
* 1. Registry fast-path (in-memory cache)
|
|
82
|
+
* 2. SQLite fallback (canonical source)
|
|
83
|
+
*
|
|
84
|
+
* The SQLite fallback is critical when multiple Plugin instances run in the
|
|
85
|
+
* same process (e.g. opencode Desktop's local sidecar plus a connected
|
|
86
|
+
* `opencode serve` sharing the same DB). Each instance has its own
|
|
87
|
+
* MemberRegistry; without a SQLite fallback, a tool call dispatched to
|
|
88
|
+
* a Plugin instance whose registry never saw the spawn fails with
|
|
89
|
+
* "This session is not in a team."
|
|
90
|
+
*
|
|
91
|
+
* Only members in active teams with non-terminal status are resolved.
|
|
79
92
|
*/
|
|
80
93
|
export function findTeamBySession(
|
|
81
94
|
db: Database,
|
|
82
95
|
registry: MemberRegistry,
|
|
83
96
|
sessionId: string,
|
|
84
97
|
): { teamId: string; teamName: string; role: "lead" | "member"; memberName?: string } | undefined {
|
|
85
|
-
//
|
|
98
|
+
// Fast path: registry cache
|
|
86
99
|
const entry = registry.getBySession(sessionId)
|
|
87
100
|
if (entry) {
|
|
88
101
|
const team = db.query("SELECT name FROM team WHERE id = ? AND status = 'active'").get(entry.teamId) as { name: string } | null
|
|
89
102
|
if (team) return { teamId: entry.teamId, teamName: team.name, role: "member", memberName: entry.memberName }
|
|
90
103
|
}
|
|
91
104
|
|
|
92
|
-
//
|
|
105
|
+
// SQLite fallback: another Plugin instance may have registered this teammate
|
|
106
|
+
if (!entry) {
|
|
107
|
+
const memberRow = db.query(
|
|
108
|
+
`SELECT tm.team_id, tm.name as member_name, t.name as team_name
|
|
109
|
+
FROM team_member tm
|
|
110
|
+
JOIN team t ON tm.team_id = t.id
|
|
111
|
+
WHERE tm.session_id = ?
|
|
112
|
+
AND t.status = 'active'
|
|
113
|
+
AND tm.status NOT IN ('shutdown', 'error')`
|
|
114
|
+
).get(sessionId) as { team_id: string; member_name: string; team_name: string } | null
|
|
115
|
+
if (memberRow) {
|
|
116
|
+
// Populate the cache so subsequent lookups skip the SQL query.
|
|
117
|
+
// Safe without TTL: session IDs are server-generated UUIDs and are
|
|
118
|
+
// never reused across team_spawn invocations, so a session ID maps
|
|
119
|
+
// to at most one (teamId, memberName) for the life of the process.
|
|
120
|
+
// team_cleanup explicitly invalidates via registry.unregisterTeam.
|
|
121
|
+
registry.register(memberRow.team_id, memberRow.member_name, sessionId)
|
|
122
|
+
return { teamId: memberRow.team_id, teamName: memberRow.team_name, role: "member", memberName: memberRow.member_name }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check if session is a team lead (already SQLite-backed, naturally cross-instance)
|
|
93
127
|
const leadTeam = db.query("SELECT id, name FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionId) as { id: string; name: string } | null
|
|
94
128
|
if (leadTeam) return { teamId: leadTeam.id, teamName: leadTeam.name, role: "lead" }
|
|
95
129
|
|
|
@@ -99,7 +133,8 @@ export function findTeamBySession(
|
|
|
99
133
|
/**
|
|
100
134
|
* Get the session ID for a recipient by name.
|
|
101
135
|
* "lead" resolves to the team's lead_session_id.
|
|
102
|
-
* Otherwise looks up the member registry
|
|
136
|
+
* Otherwise looks up the member via the registry, then falls back to SQLite
|
|
137
|
+
* (covers the multi-Plugin-instance scenario — see findTeamBySession).
|
|
103
138
|
*/
|
|
104
139
|
export function resolveRecipientSession(
|
|
105
140
|
db: Database,
|
|
@@ -112,5 +147,16 @@ export function resolveRecipientSession(
|
|
|
112
147
|
return team?.lead_session_id
|
|
113
148
|
}
|
|
114
149
|
const entry = registry.getByName(teamId, recipientName)
|
|
115
|
-
return entry
|
|
150
|
+
if (entry) return entry.sessionId
|
|
151
|
+
|
|
152
|
+
// SQLite fallback: another Plugin instance may have registered this teammate
|
|
153
|
+
const memberRow = db.query(
|
|
154
|
+
`SELECT session_id FROM team_member
|
|
155
|
+
WHERE team_id = ? AND name = ? AND status NOT IN ('shutdown', 'error')`
|
|
156
|
+
).get(teamId, recipientName) as { session_id: string } | null
|
|
157
|
+
if (!memberRow) return undefined
|
|
158
|
+
|
|
159
|
+
// Populate the cache
|
|
160
|
+
registry.register(teamId, recipientName, memberRow.session_id)
|
|
161
|
+
return memberRow.session_id
|
|
116
162
|
}
|
package/src/util.ts
CHANGED
|
@@ -23,6 +23,23 @@ export function validateTeamName(name: string): string | undefined {
|
|
|
23
23
|
return undefined
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
const PROJECT_ADJECTIVES = ["amber", "brave", "calm", "copper", "crisp", "dark", "ember", "frost", "quiet", "silver", "steady", "swift"]
|
|
27
|
+
const PROJECT_NOUNS = ["atlas", "beacon", "comet", "harbor", "lab", "orbit", "river", "signal", "sparrow", "summit", "vector", "voyage"]
|
|
28
|
+
|
|
29
|
+
/** Generate a short human-readable project name for implicit project creation. */
|
|
30
|
+
export function generateProjectName(): string {
|
|
31
|
+
const adjective = PROJECT_ADJECTIVES[Math.floor(Math.random() * PROJECT_ADJECTIVES.length)] ?? "quiet"
|
|
32
|
+
const noun = PROJECT_NOUNS[Math.floor(Math.random() * PROJECT_NOUNS.length)] ?? "project"
|
|
33
|
+
return `${adjective}-${noun}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Validate a project display name. Returns an error if invalid. */
|
|
37
|
+
export function validateProjectName(name: string): string | undefined {
|
|
38
|
+
if (name.length < 1 || name.length > 64) return "Project name must be 1-64 characters"
|
|
39
|
+
if (!NAME_PATTERN.test(name)) return "Project name must be lowercase alphanumeric with hyphens only"
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
/**
|
|
27
44
|
* Detect if the plugin is loading inside a teammate's worktree instance.
|
|
28
45
|
* Worktree instances should skip recovery to avoid deadlocking the server.
|
package/src/watchdog.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Database } from "
|
|
1
|
+
import type { Database } from "./db"
|
|
2
2
|
import type { PluginClient } from "./types"
|
|
3
3
|
import type { MemberRegistry } from "./state"
|
|
4
4
|
import type { ProgressTracker } from "./progress"
|
|
@@ -185,18 +185,19 @@ export class Watchdog {
|
|
|
185
185
|
|
|
186
186
|
const cutoff = Date.now() - this.ttlMs
|
|
187
187
|
const stale = this.db.query(
|
|
188
|
-
`SELECT tm.team_id, tm.name, tm.session_id, tm.worktree_branch, t.name as team_name
|
|
188
|
+
`SELECT tm.team_id, tm.name, tm.session_id, tm.worktree_branch, t.name as team_name, p.name as project_name
|
|
189
189
|
FROM team_member tm
|
|
190
190
|
JOIN team t ON tm.team_id = t.id
|
|
191
|
+
JOIN project p ON t.project_id = p.id
|
|
191
192
|
WHERE t.status = 'active'
|
|
192
193
|
AND tm.status = 'busy'
|
|
193
194
|
AND tm.time_updated < ?`
|
|
194
|
-
).all(cutoff) as Array<{ team_id: string; name: string; session_id: string; worktree_branch: string | null; team_name: string }>
|
|
195
|
+
).all(cutoff) as Array<{ team_id: string; name: string; session_id: string; worktree_branch: string | null; team_name: string; project_name: string }>
|
|
195
196
|
|
|
196
197
|
for (const member of stale) {
|
|
197
198
|
// Preserve branch BEFORE abort — session.abort() may destroy the worktree + branch
|
|
198
199
|
if (this.cwd && member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
|
|
199
|
-
const safeBranch = preservedBranchName(member.team_name, member.name)
|
|
200
|
+
const safeBranch = preservedBranchName(member.project_name, member.team_name, member.team_id, member.name)
|
|
200
201
|
const ok = await preserveBranch(member.worktree_branch, safeBranch, this.cwd)
|
|
201
202
|
if (ok) {
|
|
202
203
|
this.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
|