mirror of
https://github.com/Heretek-AI/IUMBTEMS.git
synced 2026-11-07 10:56:00 -10:00
fix(): agent-safe leased worktree create/release pair with seat policy (Refs #340)
This commit is contained in:
1 parent
71e42150de
commit
f480221743
4 files changed
+327
-1
No files matched your search
@@ -49,6 +49,7 @@ spike as proof).
|
||||
(repoints node_modules symlinks; `bun install` restores them)
|
||||
- `es <status|runs|watch|approve|trust|waive|gates|factory|research|brainstorm|harvest|design|config|lsp|hooks|audit|scout|mcp|improve>`
|
||||
- `es factory run --headless [--driver opencode] [--max-turns N] [--log-level quiet|info|debug] [--events jsonl] [--events-file <path>] [--turn-timeout S] [--cwd <dir>]` (SIGINT/SIGTERM cancel, exit 130; detached HEADs and other runs' seat worktrees refused; launching one stays human-only, spend-bearing like audit/scout runs; `docs/HEADLESS.md`)
|
||||
- Human-process worktrees live under `.worktrees/<slug>` under a heartbeat lease (`es worktree create|release|prune`, agent-safe); never coordinate them with shell `flock` or `.git_lock`. Phase worktrees (`.factory/worktrees/`) and task worktrees (`.fleet/worktrees/`) are held by the engine — seats manage neither from the shell.
|
||||
- `es mcp` pins caller identity to the adapter environment (`ES_MCP_AGENT`, else legacy `ES_AGENT`): the per-call `agent` argument is ignored; without either the caller is `mcp` (no seat)
|
||||
- The CLI grammar lives in core (`packages/core/src/util/args.ts`): `parseArgs`, `flag`, `Args`, `BOOLEAN_FLAGS` — the human-only policy reads the same grammar (argv parity, #97)
|
||||
- One package list (`bun scripts/packages.ts [--release] [--tsconfig] [--browser] [--no-browser]`): the typecheck loop, `pack-smoke.sh`, `publish.yml` and `deps.ts` all read it; new packages declare `esRelease.order` and `BOUNDARIES` (#102); the `bun --conditions=browser` test split comes from each manifest's `esTest.conditions`
|
||||
|
||||
+116
-1
@@ -6,6 +6,7 @@ import { appendFile, readFile, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import {
|
||||
type Args,
|
||||
addWorktree,
|
||||
aliasReuseRatio,
|
||||
atomicWrite,
|
||||
BOOLEAN_FLAGS,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
readProbes,
|
||||
readTokens,
|
||||
recordConsent,
|
||||
removeWorktree,
|
||||
renderBrainstorm,
|
||||
renderGuide,
|
||||
renderResearchRun,
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
validateTokens,
|
||||
verifyAuditChain,
|
||||
verifyBrief,
|
||||
WorktreeLeaseManager,
|
||||
writeArtifacts,
|
||||
writeProfile,
|
||||
} from "@heretek-ai/es-core"
|
||||
@@ -171,6 +174,10 @@ Other
|
||||
scout show [--json] The scout's ranked verdicts
|
||||
mcp Serve the factory tools over MCP (stdio)
|
||||
worktree prune Drop stale worktree leases and remove orphaned .worktrees/ checkouts
|
||||
worktree create <slug> [--branch <branch>] [--from <ref>]
|
||||
Create a .worktrees/ checkout under a heartbeat lease (agent-safe)
|
||||
worktree release <slug> Release the lease and remove the checkout (never a live
|
||||
holder's checkout, never the branch; agent-safe)
|
||||
rebase cascade --from <branch> --to <branch> [--no-verify]
|
||||
Replay <to>'s commits onto <from> with TS/JSON conflict
|
||||
resolution; <to> moves only when post-rebase checks pass
|
||||
@@ -209,6 +216,24 @@ async function projectRoot(cwd: string): Promise<string> {
|
||||
return top.code === 0 ? top.stdout.trim() : cwd
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeat lease managers for the agent-safe worktree pair (#340): one per
|
||||
* repo root, shared process-wide so a create in one call is releasable in
|
||||
* the next. A release from another process never steals: a live lease held
|
||||
* elsewhere is refused, a dead or expired one reclaimed.
|
||||
*/
|
||||
const worktreeLeaseManagers = new Map<string, WorktreeLeaseManager>()
|
||||
const worktreeLeases = (root: string): WorktreeLeaseManager => {
|
||||
const cached = worktreeLeaseManagers.get(root)
|
||||
if (cached) return cached
|
||||
const manager = new WorktreeLeaseManager(root)
|
||||
worktreeLeaseManagers.set(root, manager)
|
||||
return manager
|
||||
}
|
||||
|
||||
const WORKTREE_USAGE =
|
||||
"Usage: es worktree prune | es worktree create <slug> [--branch <branch>] [--from <ref>] | es worktree release <slug>"
|
||||
|
||||
export async function main(argv: readonly string[], io: MainIO): Promise<number> {
|
||||
const args = parseArgs(argv, BOOLEAN_FLAGS)
|
||||
const [command, sub, ...rest] = args.positionals
|
||||
@@ -853,7 +878,97 @@ export async function main(argv: readonly string[], io: MainIO): Promise<number>
|
||||
)
|
||||
return 0
|
||||
}
|
||||
io.print("Usage: es worktree prune")
|
||||
if (sub === "create") {
|
||||
const slug = rest[0]
|
||||
if (!slug) {
|
||||
io.print("Usage: es worktree create <slug> [--branch <branch>] [--from <ref>]")
|
||||
return 2
|
||||
}
|
||||
const branch = flag(args, "branch") ?? slug
|
||||
const fromFlag = flag(args, "from")
|
||||
const head = fromFlag ? undefined : await git(root, ["rev-parse", "HEAD"], { allowFail: true })
|
||||
if (!fromFlag && head!.code !== 0) {
|
||||
io.print(`Error: cannot resolve a base commit in ${root} (pass --from <ref>).`)
|
||||
return 1
|
||||
}
|
||||
const from = fromFlag ?? head!.stdout.trim()
|
||||
const dir = path.join(root, ".worktrees", slug)
|
||||
const manager = worktreeLeases(root)
|
||||
let ttlMs: number
|
||||
try {
|
||||
ttlMs = (await manager.acquire(slug, { branch, dir })).ttlMs
|
||||
} catch (error) {
|
||||
io.print(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return 1
|
||||
}
|
||||
try {
|
||||
await addWorktree(root, dir, branch, from)
|
||||
} catch (error) {
|
||||
await manager.release(slug).catch(() => undefined)
|
||||
io.print(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return 1
|
||||
}
|
||||
io.print(
|
||||
`Created ${dir} on branch ${branch} under lease ${JSON.stringify(slug)} (heartbeat every ${ttlMs}ms).`,
|
||||
)
|
||||
return 0
|
||||
}
|
||||
if (sub === "release") {
|
||||
const slug = rest[0]
|
||||
if (!slug) {
|
||||
io.print("Usage: es worktree release <slug>")
|
||||
return 2
|
||||
}
|
||||
const manager = worktreeLeases(root)
|
||||
const record = (await manager.list()).find((lease) => lease.slug === slug)
|
||||
if (record && manager.isLive(record)) {
|
||||
// A live lease may be ours (same process, token held) or another
|
||||
// holder's: heartbeat proves ownership, and a failed heartbeat
|
||||
// writes nothing, so refusal has no side effects.
|
||||
const owned = await manager.heartbeat(slug).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
if (!owned) {
|
||||
io.print(
|
||||
`Refusing: worktree ${JSON.stringify(slug)} is leased by a live holder (pid ${record.pid} on ${record.host}, heartbeat ${record.heartbeatAt}). Only a stale (expired or dead-holder) lease can be reclaimed.`,
|
||||
)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
const dir = record?.dir ?? path.join(root, ".worktrees", slug)
|
||||
const branch = record?.branch ?? slug
|
||||
let acquiredHere = false
|
||||
if (record && !manager.isLive(record)) {
|
||||
// Reclaim the dead lease so this process holds the token. A lease
|
||||
// that turned live in the meantime refuses here instead of stealing.
|
||||
try {
|
||||
await manager.acquire(slug, { branch, dir })
|
||||
acquiredHere = true
|
||||
} catch (error) {
|
||||
io.print(`Refusing: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
try {
|
||||
await removeWorktree(root, dir).catch(async () => {
|
||||
await git(root, ["worktree", "remove", "--force", dir])
|
||||
})
|
||||
} catch (error) {
|
||||
// A checkout we cannot remove keeps its lease: drop only a lease
|
||||
// reclaimed above, never one we already held.
|
||||
if (acquiredHere) await manager.release(slug).catch(() => undefined)
|
||||
io.print(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return 1
|
||||
}
|
||||
// Prune invariant: the checkout goes, the branch stays. Our own
|
||||
// lease (held or just reclaimed) is dropped; another holder's live
|
||||
// lease was refused above and is never touched.
|
||||
if (record) await manager.release(slug).catch(() => undefined)
|
||||
io.print(`Released ${JSON.stringify(slug)}: removed ${dir} (branch ${branch} kept).`)
|
||||
return 0
|
||||
}
|
||||
io.print(WORKTREE_USAGE)
|
||||
return 2
|
||||
}
|
||||
case "factory": {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// Agent-safe leased worktree CLI pair (#340): `es worktree create` takes a
|
||||
// heartbeat lease around a `.worktrees/<slug>` checkout and
|
||||
// `es worktree release` drops it. Release never removes another holder's live
|
||||
// checkout and never deletes branches.
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { evaluateShell, exists, invokesHumanOnly, WorktreeLeaseManager } from "@heretek-ai/es-core"
|
||||
import { main } from "../src/main.ts"
|
||||
import type { ConfirmIO } from "../src/tty.ts"
|
||||
|
||||
let root: string
|
||||
let state: string
|
||||
const realXdgConfig = process.env.XDG_CONFIG_HOME
|
||||
|
||||
const git = async (...args: string[]) => {
|
||||
const proc = Bun.spawn(["git", ...args], { cwd: root, stdout: "pipe", stderr: "pipe" })
|
||||
await proc.exited
|
||||
return proc
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), "es-worktree-cli-"))
|
||||
state = await mkdtemp(path.join(tmpdir(), "es-worktree-cli-state-"))
|
||||
process.env.XDG_CONFIG_HOME = path.join(state, "xdg-config")
|
||||
await git("init", "-q", "-b", "main")
|
||||
await writeFile(path.join(root, "README.md"), "# x\n")
|
||||
await git("add", "-A")
|
||||
await git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "init")
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (realXdgConfig === undefined) delete process.env.XDG_CONFIG_HOME
|
||||
else process.env.XDG_CONFIG_HOME = realXdgConfig
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(state, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const pipe: ConfirmIO = { interactive: false, write: () => {}, readLine: async () => "y", readSecret: async () => "" }
|
||||
|
||||
async function run(argv: string[]) {
|
||||
const printed: string[] = []
|
||||
const code = await main(argv, { print: (text) => printed.push(text), confirm: pipe, cwd: root, stateDir: state })
|
||||
return { code, out: printed.join("\n") }
|
||||
}
|
||||
|
||||
const branchExists = async (branch: string) => {
|
||||
const proc = Bun.spawn(["git", "show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
||||
cwd: root,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
await proc.exited
|
||||
return proc.exitCode === 0
|
||||
}
|
||||
|
||||
const leaseFile = (slug: string) => path.join(root, ".worktrees", ".leases", `${slug}.json`)
|
||||
|
||||
describe("es worktree create/release (#340)", () => {
|
||||
test("create takes a live lease; release drops it and removes the checkout", async () => {
|
||||
const created = await run(["worktree", "create", "demo", "--branch", "demo-branch"])
|
||||
expect(created.code).toBe(0)
|
||||
expect(await exists(path.join(root, ".worktrees", "demo"))).toBe(true)
|
||||
const manager = new WorktreeLeaseManager(root)
|
||||
const record = (await manager.list()).find((lease) => lease.slug === "demo")
|
||||
expect(record && manager.isLive(record)).toBe(true)
|
||||
|
||||
const released = await run(["worktree", "release", "demo"])
|
||||
expect(released.code).toBe(0)
|
||||
expect(await exists(path.join(root, ".worktrees", "demo"))).toBe(false)
|
||||
expect(await Bun.file(leaseFile("demo")).exists()).toBe(false)
|
||||
// Prune invariant: checkouts go, branches stay.
|
||||
expect(await branchExists("demo-branch")).toBe(true)
|
||||
})
|
||||
|
||||
test("creating twice refuses the second holder", async () => {
|
||||
expect((await run(["worktree", "create", "demo", "--branch", "demo-branch"])).code).toBe(0)
|
||||
const second = await run(["worktree", "create", "demo", "--branch", "demo-branch"])
|
||||
expect(second.code).toBe(1)
|
||||
expect(second.out).toMatch(/leas|held/i)
|
||||
})
|
||||
|
||||
test("release refuses another holder's live checkout and leaves it alone", async () => {
|
||||
const dir = path.join(root, ".worktrees", "demo")
|
||||
const add = Bun.spawn(["git", "worktree", "add", "-B", "demo-branch", dir, "HEAD"], {
|
||||
cwd: root,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
await add.exited
|
||||
expect(add.exitCode).toBe(0)
|
||||
// Another holder object (no shared token): the lease is live, pid alive.
|
||||
const holder = new WorktreeLeaseManager(root)
|
||||
await holder.acquire("demo", { branch: "demo-branch", dir })
|
||||
const refused = await run(["worktree", "release", "demo"])
|
||||
expect(refused.code).toBe(1)
|
||||
expect(refused.out).toMatch(/held|live/i)
|
||||
expect(await exists(dir)).toBe(true)
|
||||
expect(await Bun.file(leaseFile("demo")).exists()).toBe(true)
|
||||
await holder.release("demo")
|
||||
const released = await run(["worktree", "release", "demo"])
|
||||
expect(released.code).toBe(0)
|
||||
expect(await exists(dir)).toBe(false)
|
||||
expect(await branchExists("demo-branch")).toBe(true)
|
||||
})
|
||||
|
||||
test("release reclaims a crashed holder and keeps the branch", async () => {
|
||||
const dir = path.join(root, ".worktrees", "doomed")
|
||||
const add = Bun.spawn(["git", "worktree", "add", "-B", "doomed-branch", dir, "HEAD"], {
|
||||
cwd: root,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
await add.exited
|
||||
expect(add.exitCode).toBe(0)
|
||||
const holder = new WorktreeLeaseManager(root)
|
||||
await holder.acquire("doomed", { branch: "doomed-branch", dir })
|
||||
// Simulate SIGKILL: the lease file keeps a fresh heartbeat but a dead pid.
|
||||
const raw = JSON.parse(await readFile(leaseFile("doomed"), "utf8")) as Record<string, unknown>
|
||||
await writeFile(leaseFile("doomed"), `${JSON.stringify({ ...raw, pid: 2 ** 30 })}\n`)
|
||||
const released = await run(["worktree", "release", "doomed"])
|
||||
expect(released.code).toBe(0)
|
||||
expect(await exists(dir)).toBe(false)
|
||||
expect(await branchExists("doomed-branch")).toBe(true)
|
||||
await holder.release("doomed").catch(() => undefined)
|
||||
})
|
||||
|
||||
test("release of an orphaned checkout (no lease) removes it and keeps the branch", async () => {
|
||||
const add = Bun.spawn(
|
||||
["git", "worktree", "add", "-B", "orphan-branch", path.join(root, ".worktrees", "orphan"), "HEAD"],
|
||||
{
|
||||
cwd: root,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
)
|
||||
await add.exited
|
||||
expect(add.exitCode).toBe(0)
|
||||
const released = await run(["worktree", "release", "orphan"])
|
||||
expect(released.code).toBe(0)
|
||||
expect(await exists(path.join(root, ".worktrees", "orphan"))).toBe(false)
|
||||
expect(await branchExists("orphan-branch")).toBe(true)
|
||||
})
|
||||
|
||||
test("missing slug is usage, not an error", async () => {
|
||||
expect((await run(["worktree", "create"])).code).toBe(2)
|
||||
expect((await run(["worktree", "release"])).code).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("worktree CLI pair policy (#340)", () => {
|
||||
const ctx = () => ({ root, stateDir: state })
|
||||
const shell = (agent: string | undefined, command: string) =>
|
||||
evaluateShell(ctx(), agent, command, { sandboxAvailable: true })
|
||||
|
||||
test("the pair is agent-safe (not human-only)", () => {
|
||||
expect(invokesHumanOnly("es worktree create demo --branch demo-branch")).toBe(false)
|
||||
expect(invokesHumanOnly("es worktree release demo")).toBe(false)
|
||||
expect(invokesHumanOnly("es worktree prune")).toBe(false)
|
||||
})
|
||||
|
||||
test("seats may not manage worktrees from the shell; user agents may", () => {
|
||||
expect(shell("es-programmer", "es worktree create demo --branch b").effect).toBe("deny")
|
||||
expect(shell("es-programmer", "es worktree release demo").effect).toBe("deny")
|
||||
expect(shell("factory", "es worktree release demo").effect).toBe("deny")
|
||||
expect(shell(undefined, "es worktree create demo --branch b").effect).toBe("allow")
|
||||
expect(shell(undefined, "es worktree release demo").effect).toBe("allow")
|
||||
})
|
||||
|
||||
test("seats keep the agent-safe prune and plain --help", () => {
|
||||
expect(shell("es-programmer", "es worktree prune").effect).toBe("allow")
|
||||
expect(shell("es-programmer", "es worktree create demo --help").effect).toBe("allow")
|
||||
})
|
||||
})
|
||||
@@ -384,6 +384,30 @@ const seatForbiddenGit = (command: string) =>
|
||||
(sub === "branch" && args.some((arg) => /^-[a-zA-Z]*D/.test(arg))),
|
||||
)
|
||||
|
||||
/** `es worktree` verbs that create or drop checkouts (prune stays agent-safe, #160). */
|
||||
const ES_WORKTREE_MANAGING = new Set(["create", "release"])
|
||||
|
||||
/**
|
||||
* `es worktree create|release` calls in a command, however flagged
|
||||
* (`es --cwd . worktree create …`). Reads the same argv grammar as the CLI
|
||||
* (#97): unknown flags count both as taking a value and not, like the
|
||||
* human-only check above.
|
||||
*/
|
||||
function seatForbiddenEsWorktree(command: string): { verb: string } | undefined {
|
||||
for (const segment of segments(unquoteShell(command))) {
|
||||
const tokens = words(segment)
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
if (!ES_BINARY.test(tokens[i]!)) continue
|
||||
for (const verb of nextPositionals(tokens, { at: i + 1, literal: false })) {
|
||||
if (tokens[verb.at] !== "worktree") continue
|
||||
for (const sub of nextPositionals(tokens, { at: verb.at + 1, literal: verb.literal }))
|
||||
if (ES_WORKTREE_MANAGING.has(tokens[sub.at] ?? "")) return { verb: tokens[sub.at]! }
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Commands that only read (each pipeline segment). No `env` (it runs any
|
||||
* program), and no tool with an output-file option: `sort -o`, `uniq IN OUT`,
|
||||
@@ -447,6 +471,18 @@ export function evaluateShell(
|
||||
reason:
|
||||
"Factory seats may not reference other tasks' fleet worktrees (.fleet/) from the shell; work only inside your own task worktree.",
|
||||
}
|
||||
// Leased worktrees (#340): phase worktrees are held by the engine and
|
||||
// `.worktrees/` checkouts by whoever ran `es worktree create`. Seats
|
||||
// manage neither from the shell; user agents may use the agent-safe
|
||||
// create/release pair (a live checkout held elsewhere is refused there).
|
||||
if (!isPlainHelp(command)) {
|
||||
const managed = seatForbiddenEsWorktree(command)
|
||||
if (managed)
|
||||
return {
|
||||
effect: "deny",
|
||||
reason: `Factory seats may not manage worktrees from the shell (es worktree ${managed.verb}); phase worktrees are held under a heartbeat lease by the engine.`,
|
||||
}
|
||||
}
|
||||
const git = seatForbiddenGit(command)
|
||||
if (git)
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user