302 lines
7.7 KiB
JavaScript
302 lines
7.7 KiB
JavaScript
// Pure utility functions for the team orchestrator
|
|
// Extracted from team-orchestrator.js for testability
|
|
|
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
|
import path from "node:path"
|
|
|
|
export const REGISTRY_VERSION = 1
|
|
export const LOCK_TIMEOUT_MS = 5000
|
|
export const LOCK_RETRY_MS = 50
|
|
export const LOCK_STALE_MS = 30 * 1000
|
|
|
|
export function now() {
|
|
return new Date().toISOString()
|
|
}
|
|
|
|
export function createTaskID() {
|
|
const random = Math.random().toString(36).slice(2, 8)
|
|
return `team_${Date.now().toString(36)}_${random}`
|
|
}
|
|
|
|
export function stateRoot(worktree) {
|
|
return path.join(worktree, ".opencode", "state")
|
|
}
|
|
|
|
export function artifactRoot(worktree) {
|
|
return path.join(stateRoot(worktree), "artifacts")
|
|
}
|
|
|
|
export function registryFile(worktree) {
|
|
return path.join(stateRoot(worktree), "tasks.json")
|
|
}
|
|
|
|
export function lockDirectory(worktree) {
|
|
return path.join(stateRoot(worktree), "tasks.lock")
|
|
}
|
|
|
|
export function temporaryRegistryFile(worktree) {
|
|
return path.join(stateRoot(worktree), `tasks.${process.pid}.tmp`)
|
|
}
|
|
|
|
export function projectRootFromContext(context) {
|
|
if (context.worktree && context.worktree !== "/") {
|
|
return context.worktree
|
|
}
|
|
return context.directory
|
|
}
|
|
|
|
export function isErrno(error, code) {
|
|
return Boolean(error && typeof error === "object" && "code" in error && error.code === code)
|
|
}
|
|
|
|
export function errorMessage(error) {
|
|
if (error instanceof Error) return error.message
|
|
return String(error)
|
|
}
|
|
|
|
export function isPidAlive(pid) {
|
|
try {
|
|
process.kill(pid, 0)
|
|
return true
|
|
} catch (error) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export async function sleep(ms) {
|
|
await new Promise(resolve => setTimeout(resolve, ms))
|
|
}
|
|
|
|
function lockMetaFile(worktree) {
|
|
return path.join(lockDirectory(worktree), "meta.json")
|
|
}
|
|
|
|
export async function acquireLock(worktree) {
|
|
await ensureState(worktree)
|
|
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
|
const lockPath = lockDirectory(worktree)
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
await mkdir(lockPath)
|
|
const meta = { owner: process.pid, createdAt: Date.now() }
|
|
await writeFile(lockMetaFile(worktree), JSON.stringify(meta), "utf8")
|
|
return
|
|
} catch (error) {
|
|
if (!isErrno(error, "EEXIST")) throw error
|
|
await recoverStaleLock(worktree)
|
|
await sleep(LOCK_RETRY_MS)
|
|
}
|
|
}
|
|
|
|
throw new Error("Timed out acquiring the team task registry lock")
|
|
}
|
|
|
|
export async function recoverStaleLock(worktree) {
|
|
const lockPath = lockDirectory(worktree)
|
|
const metaPath = lockMetaFile(worktree)
|
|
|
|
try {
|
|
const raw = await readFile(metaPath, "utf8")
|
|
const meta = JSON.parse(raw)
|
|
if (typeof meta.createdAt === "number" && Date.now() - meta.createdAt < LOCK_STALE_MS) {
|
|
if (typeof meta.owner === "number" && isPidAlive(meta.owner)) {
|
|
return
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (isErrno(error, "ENOENT")) {
|
|
// meta file missing — safe to reclaim
|
|
} else {
|
|
return // corrupt meta — don't reclaim
|
|
}
|
|
}
|
|
|
|
try {
|
|
await rm(lockPath, { recursive: true, force: true })
|
|
} catch (error) {
|
|
if (!isErrno(error, "ENOENT")) {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function withLockedRegistry(worktree, mutate) {
|
|
await acquireLock(worktree)
|
|
try {
|
|
const registry = await loadRegistry(worktree)
|
|
const result = await mutate(registry)
|
|
await saveRegistry(worktree, registry)
|
|
return result
|
|
} finally {
|
|
await rm(lockDirectory(worktree), { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
export async function ensureState(worktree) {
|
|
await mkdir(artifactRoot(worktree), { recursive: true })
|
|
}
|
|
|
|
export async function loadRegistry(worktree) {
|
|
await ensureState(worktree)
|
|
const file = registryFile(worktree)
|
|
try {
|
|
const raw = await readFile(file, "utf8")
|
|
const parsed = JSON.parse(raw)
|
|
if (!Array.isArray(parsed.tasks)) {
|
|
return { version: REGISTRY_VERSION, tasks: [] }
|
|
}
|
|
return parsed
|
|
} catch (error) {
|
|
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
return { version: REGISTRY_VERSION, tasks: [] }
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function saveRegistry(worktree, registry) {
|
|
await ensureState(worktree)
|
|
const file = registryFile(worktree)
|
|
const temporaryFile = temporaryRegistryFile(worktree)
|
|
await writeFile(
|
|
temporaryFile,
|
|
`${JSON.stringify({ version: REGISTRY_VERSION, tasks: registry.tasks }, null, 2)}\n`,
|
|
"utf8",
|
|
)
|
|
await rename(temporaryFile, file)
|
|
}
|
|
|
|
export function upsertTask(registry, task) {
|
|
const index = registry.tasks.findIndex(existing => existing.id === task.id)
|
|
if (index === -1) {
|
|
registry.tasks.push(task)
|
|
return
|
|
}
|
|
registry.tasks[index] = task
|
|
}
|
|
|
|
export function getTask(registry, taskID) {
|
|
return registry.tasks.find(task => task.id === taskID)
|
|
}
|
|
|
|
export async function addTask(worktree, task) {
|
|
await withLockedRegistry(worktree, registry => {
|
|
upsertTask(registry, task)
|
|
})
|
|
}
|
|
|
|
export async function readTask(worktree, taskID) {
|
|
const registry = await loadRegistry(worktree)
|
|
return getTask(registry, taskID)
|
|
}
|
|
|
|
export async function updateTask(worktree, taskID, mutate) {
|
|
return withLockedRegistry(worktree, registry => {
|
|
const task = getTask(registry, taskID)
|
|
if (!task) {
|
|
throw new Error(`Unknown task_id: ${taskID}`)
|
|
}
|
|
return mutate(task, registry)
|
|
})
|
|
}
|
|
|
|
export function buildWorkerPrompt(input) {
|
|
const sections = [
|
|
"You are a delegated worker in a multi-agent team orchestration prototype.",
|
|
"Stay focused on the assigned scope and produce a concise final result that a coordinator can synthesize.",
|
|
"",
|
|
`## Task ID\n${input.taskID}`,
|
|
"",
|
|
`## Title\n${input.title}`,
|
|
]
|
|
|
|
if (input.goal) {
|
|
sections.push("", `## Goal\n${input.goal}`)
|
|
}
|
|
|
|
if (input.files.length > 0) {
|
|
sections.push("", `## Suggested Files\n${input.files.map(file => `- ${file}`).join("\n")}`)
|
|
}
|
|
|
|
sections.push(
|
|
"",
|
|
"## Instructions",
|
|
input.prompt,
|
|
"",
|
|
"## Response Contract",
|
|
"Return a final response with these sections:",
|
|
"## Summary",
|
|
"## Files",
|
|
"- file paths you changed or inspected",
|
|
"## Risks",
|
|
"- remaining risks or unknowns",
|
|
"## Next Steps",
|
|
"- any follow-up the coordinator should consider",
|
|
)
|
|
|
|
return sections.join("\n")
|
|
}
|
|
|
|
export function extractTextFromParts(parts) {
|
|
return parts
|
|
.filter(part => part.type === "text" && typeof part.text === "string")
|
|
.map(part => part.text.trim())
|
|
.filter(Boolean)
|
|
.join("\n\n")
|
|
}
|
|
|
|
export const STATUS_ICONS = {
|
|
running: "▸",
|
|
ready: "◦",
|
|
done: "✓",
|
|
cancelled: "○",
|
|
error: "✗",
|
|
}
|
|
|
|
export function formatTaskLine(task, liveStatus) {
|
|
const status = liveStatus || task.status
|
|
const icon = STATUS_ICONS[status] || "•"
|
|
return `${icon} ${task.id} ${status} ${task.agent} ${task.title}`
|
|
}
|
|
|
|
export function artifactPathForDisplay(worktree, artifactPath) {
|
|
if (!artifactPath) return ""
|
|
return path.relative(worktree, artifactPath) || artifactPath
|
|
}
|
|
|
|
export async function recordTaskError(worktree, taskID, error) {
|
|
return updateTask(worktree, taskID, task => {
|
|
if (task.status === "cancelled") {
|
|
return { ...task }
|
|
}
|
|
task.status = "error"
|
|
task.updatedAt = now()
|
|
task.error = errorMessage(error)
|
|
return { ...task }
|
|
})
|
|
}
|
|
|
|
export async function recordTaskCompletion(worktree, taskID, latest, artifactPath) {
|
|
return updateTask(worktree, taskID, task => {
|
|
if (task.status === "cancelled") {
|
|
return {
|
|
cancelled: true,
|
|
task: { ...task },
|
|
}
|
|
}
|
|
|
|
task.status = "done"
|
|
task.updatedAt = now()
|
|
task.summary = latest.text || undefined
|
|
task.artifactPath = artifactPath
|
|
task.lastMessageID = latest.messageID
|
|
delete task.error
|
|
|
|
return {
|
|
cancelled: false,
|
|
task: { ...task },
|
|
}
|
|
})
|
|
}
|