AgentWorktreeManager.kt
src/jvmMain/kotlin/afield/tools/automation/AgentWorktreeManager.kt · 299 lines
package afield.tools.automation
import afield.data.table.div
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.ref
import afield.data.table.rem
import afield.data.table.times
import afield.document.workspace.metadata
import afield.runtime.execution.plus
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.ui.notation.with
import afield.audio.synth.*
import afield.data.table.get
import afield.document.workspace.AgentWorktreeActionResponse
import afield.document.workspace.AgentWorktreeProposal
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.lifecycle.map
import afield.tools.docs.format
import afield.ui.output.templates.filter
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import java.io.File
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlin.io.path.absolutePathString
import kotlin.io.path.name
Creates, lists, merges and deletes git worktrees that agents work in, one branch per proposal.
AgentWorktreeManager.createProposal adds a worktree on a new agent/<task>/<id> branch from the
current HEAD and records it as JSON in a .metadata folder under the worktree root. That root
is afield.agentWorktreeRoot, AFIELD_AGENT_WORKTREE_ROOT, or <checkout>-agent-worktrees
beside the checkout. mergeProposal refuses to merge into a checkout with uncommitted changes.
object AgentWorktreeManager
Git worktree proposals for agents: create, list, update, merge, finalize and delete.
object AgentWorktreeManager {
private val json = Json {
ignoreUnknownKeys = true
prettyPrint = true
}
private val idFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").withZone(ZoneOffset.UTC)
fun listProposals(projectRoot: File = File(".").canonicalFile): List<AgentWorktreeProposal>
fun listProposals(projectRoot: File = File(".").canonicalFile): List<AgentWorktreeProposal> {
val metadataDir = metadataDir(projectRoot)
if (!Files.isDirectory(metadataDir)) return emptyList()
val files = Files.list(metadataDir).use { stream ->
stream
.filter { Files.isRegularFile(it) }
.filter { it.fileName.toString().endsWith(".json", ignoreCase = true) }
.toList()
}
return files
.mapNotNull { path ->
runCatching {
json.decodeFromString<AgentWorktreeProposal>(
Files.readString(path, StandardCharsets.UTF_8)
)
}.getOrNull()
}
.sortedWith(compareByDescending<AgentWorktreeProposal> { it.createdAtEpochMs }.thenBy { it.id })
}
fun createProposal(projectRoot: File, taskName: String, label: String = taskName): AgentWorktreeProposal
fun createProposal(projectRoot: File, taskName: String, label: String = taskName): AgentWorktreeProposal {
val canonicalRoot = projectRoot.canonicalFile
val root = worktreeRoot(canonicalRoot)
Files.createDirectories(root)
Files.createDirectories(metadataDir(canonicalRoot))
val now = Instant.now()
val id = "${safeToken(taskName)}-${idFormatter.format(now)}-${Integer.toHexString(now.nano)}"
val path = root.resolve(id).toAbsolutePath().normalize()
val baseBranch = runGit(canonicalRoot, "rev-parse", "--abbrev-ref", "HEAD").trim()
val baseCommit = runGit(canonicalRoot, "rev-parse", "HEAD").trim()
val branch = "agent/${safeToken(taskName)}/$id"
runGit(canonicalRoot, "worktree", "add", "-b", branch, path.toString(), "HEAD")
val proposal = AgentWorktreeProposal(
id = id,
label = label,
path = path.absolutePathString(),
branch = branch,
baseBranch = baseBranch,
baseCommit = baseCommit,
createdAtEpochMs = now.toEpochMilli(),
updatedAtEpochMs = now.toEpochMilli(),
status = "created",
taskName = taskName,
message = "Created generated worktree ${path.name}."
)
writeProposal(canonicalRoot, proposal)
return proposal
}
fun updateProposal
fun updateProposal(
projectRoot: File,
id: String,
status: String,
message: String = "",
reportPath: String = "",
dryRunPatchPath: String = ""
): AgentWorktreeProposal? {
val canonicalRoot = projectRoot.canonicalFile
val proposal = readProposal(canonicalRoot, id) ?: return null
val updated = proposal.copy(
status = status,
message = message.ifBlank { proposal.message },
reportPath = reportPath.ifBlank { proposal.reportPath },
dryRunPatchPath = dryRunPatchPath.ifBlank { proposal.dryRunPatchPath },
updatedAtEpochMs = Instant.now().toEpochMilli()
)
writeProposal(canonicalRoot, updated)
return updated
}
fun deleteProposal(projectRoot: File, id: String): AgentWorktreeActionResponse
fun deleteProposal(projectRoot: File, id: String): AgentWorktreeActionResponse {
val canonicalRoot = projectRoot.canonicalFile
val proposal = readProposal(canonicalRoot, id)
?: return AgentWorktreeActionResponse(false, message = "Unknown generated worktree '$id'.")
val path = Paths.get(proposal.path).toAbsolutePath().normalize()
runCatching {
if (Files.exists(path)) {
runGit(canonicalRoot, "worktree", "remove", "--force", path.toString())
}
}.onFailure {
return AgentWorktreeActionResponse(false, proposal, "Could not remove worktree: ${it.message}")
}
runCatching {
if (proposal.branch.isNotBlank()) runGit(canonicalRoot, "branch", "-D", proposal.branch)
}
Files.deleteIfExists(metadataPath(canonicalRoot, id))
return AgentWorktreeActionResponse(true, proposal, "Deleted generated worktree ${proposal.id}.")
}
fun mergeProposal(projectRoot: File, id: String): AgentWorktreeActionResponse
body — 45 lines, from line 150
fun mergeProposal(projectRoot: File, id: String): AgentWorktreeActionResponse {
val canonicalRoot = projectRoot.canonicalFile
var proposal = readProposal(canonicalRoot, id)
?: return AgentWorktreeActionResponse(false, message = "Unknown generated worktree '$id'.")
val dirty = runGit(canonicalRoot, "status", "--porcelain").trim()
if (dirty.isNotBlank()) {
return AgentWorktreeActionResponse(
false,
proposal,
"Refusing to merge into a dirty checkout. Commit, stash, or clean the main worktree first."
)
}
if (proposal.branch.isBlank()) {
return AgentWorktreeActionResponse(false, proposal, "Generated worktree has no branch recorded.")
}
val proposalPath = Paths.get(proposal.path).toAbsolutePath().normalize().toFile()
if (!proposalPath.isDirectory) {
return AgentWorktreeActionResponse(false, proposal, "Generated worktree is missing: ${proposal.path}")
}
val finalized = finalizeProposalChanges(canonicalRoot, proposal.id)
if (!finalized.success) return finalized
proposal = finalized.proposal ?: proposal
val proposalHead = runGit(proposalPath, "rev-parse", "HEAD").trim()
val branchHead = runGit(canonicalRoot, "rev-parse", proposal.branch).trim()
if (proposalHead != branchHead) {
return AgentWorktreeActionResponse(
false,
proposal,
"Generated worktree branch ${proposal.branch} is not at the proposal worktree HEAD."
)
}
return try {
runGit(canonicalRoot, "merge", "--ff-only", proposal.branch)
val updated = updateProposal(
canonicalRoot,
id,
"merged",
"Merged ${proposal.branch} into ${proposal.baseBranch}."
) ?: proposal
AgentWorktreeActionResponse(true, updated, updated.message)
} catch (t: Throwable) {
AgentWorktreeActionResponse(false, proposal, "Merge failed: ${t.message}")
}
}
fun finalizeProposalChanges(projectRoot: File, id: String): AgentWorktreeActionResponse
body — 41 lines, from line 196
fun finalizeProposalChanges(projectRoot: File, id: String): AgentWorktreeActionResponse {
val canonicalRoot = projectRoot.canonicalFile
val proposal = readProposal(canonicalRoot, id)
?: return AgentWorktreeActionResponse(false, message = "Unknown generated worktree '$id'.")
val proposalPath = Paths.get(proposal.path).toAbsolutePath().normalize().toFile()
if (!proposalPath.isDirectory) {
return AgentWorktreeActionResponse(false, proposal, "Generated worktree is missing: ${proposal.path}")
}
return try {
// Generated proposal tasks should only commit reviewable source edits.
// Build outputs and reports stay as artifacts referenced from metadata.
runGit(proposalPath, "add", "src", "dev_docs", "public", "tools", "build.gradle.kts", "settings.gradle.kts")
val staged = runGitWithExit(proposalPath, "diff", "--cached", "--quiet").exitCode != 0
if (!staged) {
val cleanProposal = updateProposal(
canonicalRoot,
id,
"ready",
"Generated proposal has no source changes to commit."
) ?: proposal
return AgentWorktreeActionResponse(true, cleanProposal, cleanProposal.message)
}
runGit(
proposalPath,
"commit",
"-m",
"Apply generated ${proposal.label.ifBlank { proposal.taskName.ifBlank { "agent proposal" } }}"
)
val updated = updateProposal(
canonicalRoot,
id,
"ready",
"Committed generated proposal changes to ${proposal.branch}."
) ?: proposal
AgentWorktreeActionResponse(true, updated, updated.message)
} catch (t: Throwable) {
AgentWorktreeActionResponse(false, proposal, "Could not commit generated proposal changes: ${t.message}")
}
}
fun proposal(projectRoot: File, id: String): AgentWorktreeProposal?
fun proposal(projectRoot: File, id: String): AgentWorktreeProposal? =
readProposal(projectRoot.canonicalFile, id)
fun worktreeRoot(projectRoot: File): Path
body — 58 lines, from line 241
fun worktreeRoot(projectRoot: File): Path {
val configured = System.getProperty("afield.agentWorktreeRoot")
?: System.getenv("AFIELD_AGENT_WORKTREE_ROOT")
if (!configured.isNullOrBlank()) return Paths.get(configured).toAbsolutePath().normalize()
val parent = projectRoot.toPath().toAbsolutePath().normalize().parent
?: projectRoot.toPath().toAbsolutePath().normalize()
return parent.resolve("${projectRoot.name}-agent-worktrees").toAbsolutePath().normalize()
}
private fun metadataDir(projectRoot: File): Path =
worktreeRoot(projectRoot).resolve(".metadata").toAbsolutePath().normalize()
private fun metadataPath(projectRoot: File, id: String): Path =
metadataDir(projectRoot).resolve("${safeToken(id)}.json")
private fun readProposal(projectRoot: File, id: String): AgentWorktreeProposal? {
val path = metadataPath(projectRoot, id)
if (!Files.isRegularFile(path)) return null
return runCatching {
json.decodeFromString<AgentWorktreeProposal>(Files.readString(path, StandardCharsets.UTF_8))
}.getOrNull()
}
private fun writeProposal(projectRoot: File, proposal: AgentWorktreeProposal) {
val path = metadataPath(projectRoot, proposal.id)
Files.createDirectories(path.parent)
Files.writeString(path, json.encodeToString(proposal), StandardCharsets.UTF_8)
}
private fun safeToken(text: String): String =
text.trim()
.lowercase()
.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '-' }
.joinToString("")
.trim('-')
.ifBlank { "proposal" }
.take(80)
private fun runGit(cwd: File, vararg args: String): String {
val result = runGitWithExit(cwd, *args)
if (result.exitCode != 0) {
throw IllegalStateException("git ${args.joinToString(" ")} failed with ${result.exitCode}: ${result.output.trim()}")
}
return result.output
}
private fun runGitWithExit(cwd: File, vararg args: String): GitResult {
val process = ProcessBuilder(listOf("git") + args)
.directory(cwd)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader(StandardCharsets.UTF_8).readText()
val exit = process.waitFor()
return GitResult(exit, output)
}
private data class GitResult(val exitCode: Int, val output: String)
}