Merge pull request #17 from 0xWheatyz/claude/mise-tooling-repo-init-attcr2

Add mise-init bootstrap agent for automatic tooling setup
This commit is contained in:
Wyatt
2026-07-16 18:18:22 -04:00
committed by GitHub
38 changed files with 1083 additions and 54 deletions
+51 -24
View File
@@ -2,10 +2,10 @@
* Spawning enqueues a control command that the worker turns into a tmux + claude process. */
"use client";
import { useMemo, useState } from "react";
import { Fragment, useMemo, useState } from "react";
import { useDashboard } from "@/components/store";
import { Badge, Button, Card, Input, Select, StatusBadge, Textarea } from "@/components/ui";
import { fmtFull } from "@/lib/format";
import { fmtFull, timeAgo } from "@/lib/format";
const ROLE_OPTS = [
{ value: "", label: "Role — none" },
@@ -121,28 +121,55 @@ export function AgentsSection() {
</thead>
<tbody>
{agents.map((a) => (
<tr key={a.id}>
<td className="mono">{a.name}</td>
<td>{a.role ? <Badge tone="info">{a.role}</Badge> : "—"}</td>
<td>
<StatusBadge status={a.status} />
</td>
<td className="mono faint">{a.working_dir}</td>
<td className="faint nowrap">{fmtFull(a.created_at)}</td>
<td className="nowrap">
<div className="hstack">
<Button size="sm" variant="ghost" onClick={() => s.selectRun(a.project_id, a.name)}>
Open
</Button>
<Button size="sm" variant="secondary" onClick={() => s.killAgent(a.project_id, a.name)}>
Kill
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteAgent(a.project_id, a.name)}>
Delete
</Button>
</div>
</td>
</tr>
<Fragment key={a.id}>
<tr>
<td className="mono">{a.name}</td>
<td>{a.role ? <Badge tone="info">{a.role}</Badge> : "—"}</td>
<td>
<StatusBadge status={a.status} />
</td>
<td className="mono faint">{a.working_dir}</td>
<td className="faint nowrap">{fmtFull(a.created_at)}</td>
<td className="nowrap">
<div className="hstack">
<Button size="sm" variant="ghost" onClick={() => s.selectRun(a.project_id, a.name)}>
Open
</Button>
<Button size="sm" variant="secondary" onClick={() => s.killAgent(a.project_id, a.name)}>
Kill
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteAgent(a.project_id, a.name)}>
Delete
</Button>
</div>
</td>
</tr>
{a.status === "working" && a.last_output?.trim() && (
<tr>
<td colSpan={6} style={{ paddingTop: 0 }}>
<div className="faint" style={{ fontSize: "var(--text-xs)", marginBottom: 4 }}>
live output{a.output_at ? ` · ${timeAgo(a.output_at)}` : ""}
</div>
<pre
className="mono"
style={{
margin: 0,
padding: "8px 10px",
background: "var(--surface-2, rgba(0,0,0,0.25))",
borderRadius: 6,
fontSize: "var(--text-xs)",
lineHeight: 1.4,
maxHeight: 220,
overflow: "auto",
whiteSpace: "pre",
}}
>
{a.last_output}
</pre>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
@@ -22,6 +22,7 @@ const empty: NewProjectBody = {
root_dir: "",
git_remote: "",
credential_ref: "",
init_mise: false,
};
export function RepositoriesSection() {
@@ -163,6 +164,28 @@ export function RepositoriesSection() {
</>
)}
{!editing && (
<label className="hstack" style={{ gap: 8, cursor: "pointer", marginTop: 14 }}>
<input
type="checkbox"
checked={form.init_mise}
onChange={(e) => setForm({ ...form, init_mise: e.target.checked })}
/>
<span style={{ fontSize: "var(--text-sm)" }}>
Initialize mise after the clone, run an agent that writes a{" "}
<span className="mono">.mise.toml</span> with a{" "}
<span className="mono">[tasks.test]</span> task for this repo&rsquo;s stack, then
commits and pushes it. Needed for repos that don&rsquo;t define one yet.
</span>
</label>
)}
{!editing && form.init_mise && form.mode === "manual" && !form.git_remote.trim() && (
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "6px 0 0" }}>
A git remote is required to push the new <span className="mono">.mise.toml</span>
add one above, or mise won&rsquo;t be initialized.
</p>
)}
<div className="hstack mt14">
<Button variant="primary" disabled={s.cmd.busy || !canSave} onClick={save}>
{editing ? "Save changes" : form.mode === "server" ? "Add & pull" : "Register"}
+34
View File
@@ -146,6 +146,9 @@ export interface NewProjectBody {
root_dir: string;
git_remote: string;
credential_ref: string;
/* Bootstrap tooling: after the clone, run an agent that writes + commits + pushes a
* .mise.toml with a [tasks.test] task for the repo's stack. Needs a git remote. */
init_mise: boolean;
}
export interface ScheduleBody {
name_prefix: string;
@@ -535,12 +538,14 @@ export function DashboardProvider({
repo: b.repo.trim(),
id: b.id.trim() || null,
credential_ref: b.credential_ref.trim() || null,
init_mise: b.init_mise,
}
: {
id: b.id.trim(),
root_dir: b.root_dir.trim(),
git_remote: b.git_remote.trim() || null,
credential_ref: b.credential_ref.trim() || null,
init_mise: b.init_mise,
};
const created = await clientRef.current.api<Project>("/projects", {
method: "POST",
@@ -573,6 +578,35 @@ export function DashboardProvider({
} else {
setCmd({ text: `repository '${created.id}' registered`, error: false, busy: false });
}
// "Initialize mise": follow the bootstrap agent's launch so the operator knows a
// .mise.toml is being written, committed, and pushed for them.
if (created.mise_init_command_id != null) {
setCmd({
text: `repository '${created.id}': launching a mise-init agent to create .mise.toml…`,
error: false,
busy: true,
});
const mise = await clientRef.current.trackCommand(created.mise_init_command_id);
if (!mise) {
setCmd({
text: `repository '${created.id}' registered; mise-init still starting (see Activity). Is the worker up?`,
error: false,
busy: false,
});
} else if (mise.status === "done") {
setCmd({
text: `repository '${created.id}' registered; a mise-init agent is now writing, committing, and pushing .mise.toml (watch it in Runs).`,
error: false,
busy: false,
});
} else {
setCmd({
text: `repository '${created.id}' registered but the mise-init agent failed to launch — ${mise.error ?? ""}`,
error: true,
busy: false,
});
}
}
return true;
} catch (e) {
if (e instanceof AuthError) return false;
+7
View File
@@ -18,6 +18,9 @@ export interface Project {
created_at: string;
/* Present on the registration response in git-server mode: the enqueued clone. */
sync_command_id?: number | null;
/* Present on the registration response when "Initialize mise" was ticked: the
* enqueued bootstrap agent that writes + commits + pushes a .mise.toml. */
mise_init_command_id?: number | null;
}
export interface Agent {
@@ -27,6 +30,10 @@ export interface Agent {
working_dir: string;
status: string;
role?: string | null;
/* Latest tmux pane-tail snapshot from the worker, so the UI can show what a running
* agent is actually doing (and expose one wedged on an interactive prompt). */
last_output?: string | null;
output_at?: string | null;
created_at: string;
}
+16 -1
View File
@@ -100,7 +100,22 @@ def create_project(body: ProjectIn, conn: Connection = Depends(db_conn)) -> dict
conn, "sync", project_id=project_id, requested_by="operator:web"
)
sync_command_id = command["id"]
return {**project, "sync_command_id": sync_command_id}
# "Initialize mise": queue a bootstrap agent *after* the clone (FIFO by id, so the
# sync runs first) to author a .mise.toml with a [tasks.test] task for the repo's
# stack and commit + push it. It needs a remote to push, so skip when there is none.
mise_init_command_id = None
if body.init_mise and git_remote:
mise_command = repo.enqueue_command(
conn, "mise_init", project_id=project_id, requested_by="operator:web"
)
mise_init_command_id = mise_command["id"]
return {
**project,
"sync_command_id": sync_command_id,
"mise_init_command_id": mise_init_command_id,
}
@router.patch("/{project_id}", response_model=ProjectOut, dependencies=[Depends(require_admin)])
+11 -1
View File
@@ -58,6 +58,10 @@ class ProjectIn(BaseModel):
credential_ref: str | None = None
git_server: str | None = None
repo: str | None = None
# Bootstrap tooling: enqueue a mise-init agent after the clone that writes a
# ``.mise.toml`` with a ``[tasks.test]`` task for the repo's stack, then commits + pushes
# it. Only acts when the project has a git_remote (the agent must push its work).
init_mise: bool = False
@field_validator("credential_ref")
@classmethod
@@ -101,9 +105,11 @@ class ProjectOut(BaseModel):
class ProjectCreatedOut(ProjectOut):
"""Registration response; carries the enqueued clone command in git-server mode."""
"""Registration response; carries the enqueued clone command in git-server mode, and
the mise-init bootstrap command when ``init_mise`` was requested."""
sync_command_id: int | None = None
mise_init_command_id: int | None = None
class AgentIn(BaseModel):
@@ -122,6 +128,10 @@ class AgentOut(BaseModel):
working_dir: str
status: str
role: Role | None = None
# Latest tmux pane-tail snapshot (worker poll loop) so the UI can show what a running
# agent is doing — including one wedged on an interactive prompt no one can answer.
last_output: str | None = None
output_at: datetime | None = None
created_at: datetime
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/bbd6ee1e7265be74.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-29957205745576aa.js"/><script src="/_next/static/chunks/fd9d1056-8edc3f3573e7d5e5.js" async=""></script><script src="/_next/static/chunks/117-e7bb738621b70d3f.js" async=""></script><script src="/_next/static/chunks/main-app-8321800782c5a11e.js" async=""></script><script src="/_next/static/chunks/app/page-5b19e394a5d5460f.js" async=""></script><title>Handler · Claude Activity</title><meta name="description" content="Monitor and manage Claude Code agents across projects."/><link rel="icon" href="/icon.svg?bab509b45a421e43" type="image/svg+xml" sizes="any"/><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body><div class="gate"><form class="gate-card"><div class="gate-brand"><span class="logo" style="width:26px;height:26px;border-radius:7px"></span>Claude Monitor</div><p class="muted" style="font-size:var(--text-sm);margin:0">Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token.</p><input class="input" type="password" autoComplete="current-password" placeholder="API token" autofocus="" value=""/><button class="btn btn-primary" type="submit">Continue</button></form></div><script src="/_next/static/chunks/webpack-29957205745576aa.js" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script><script>self.__next_f.push([1,"1:HL[\"/_next/static/css/bbd6ee1e7265be74.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"2:I[2846,[],\"\"]\n4:I[9107,[],\"ClientPageRoot\"]\n5:I[9859,[\"931\",\"static/chunks/app/page-5b19e394a5d5460f.js\"],\"default\",1]\n6:I[4707,[],\"\"]\n7:I[6423,[],\"\"]\n9:I[1060,[],\"\"]\na:[]\n0:[\"$\",\"$L2\",null,{\"buildId\":\"UFaQ4h6X_9jNfr8PwK2IS\",\"assetPrefix\":\"\",\"urlParts\":[\"\",\"\"],\"initialTree\":[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"__PAGE__\",{},[[\"$L3\",[\"$\",\"$L4\",null,{\"props\":{\"params\":{},\"searchParams\":{}},\"Component\":\"$5\"}],null],null],null]},[[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/bbd6ee1e7265be74.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"children\":[\"$\",\"$L6\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L7\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],\"notFoundStyl"])</script><script>self.__next_f.push([1,"es\":[]}]}]}]],null],null],\"couldBeIntercepted\":false,\"initialHead\":[null,\"$L8\"],\"globalErrorComponent\":\"$9\",\"missingSlots\":\"$Wa\"}]\n"])</script><script>self.__next_f.push([1,"8:[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"1\",{\"charSet\":\"utf-8\"}],[\"$\",\"title\",\"2\",{\"children\":\"Handler · Claude Activity\"}],[\"$\",\"meta\",\"3\",{\"name\":\"description\",\"content\":\"Monitor and manage Claude Code agents across projects.\"}],[\"$\",\"link\",\"4\",{\"rel\":\"icon\",\"href\":\"/icon.svg?bab509b45a421e43\",\"type\":\"image/svg+xml\",\"sizes\":\"any\"}]]\n3:null\n"])</script></body></html>
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/bbd6ee1e7265be74.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-29957205745576aa.js"/><script src="/_next/static/chunks/fd9d1056-e47440ca368281f5.js" async=""></script><script src="/_next/static/chunks/117-d9d1ed00b6332a85.js" async=""></script><script src="/_next/static/chunks/main-app-8321800782c5a11e.js" async=""></script><script src="/_next/static/chunks/app/page-a511d608db773c67.js" async=""></script><title>Handler · Claude Activity</title><meta name="description" content="Monitor and manage Claude Code agents across projects."/><link rel="icon" href="/icon.svg?bab509b45a421e43" type="image/svg+xml" sizes="any"/><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body><div class="gate"><form class="gate-card"><div class="gate-brand"><span class="logo" style="width:26px;height:26px;border-radius:7px"></span>Claude Monitor</div><p class="muted" style="font-size:var(--text-sm);margin:0">Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token.</p><input class="input" type="password" autoComplete="current-password" placeholder="API token" autofocus="" value=""/><button class="btn btn-primary" type="submit">Continue</button></form></div><script src="/_next/static/chunks/webpack-29957205745576aa.js" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script><script>self.__next_f.push([1,"1:HL[\"/_next/static/css/bbd6ee1e7265be74.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"2:I[2846,[],\"\"]\n4:I[9107,[],\"ClientPageRoot\"]\n5:I[9859,[\"931\",\"static/chunks/app/page-a511d608db773c67.js\"],\"default\",1]\n6:I[4707,[],\"\"]\n7:I[6423,[],\"\"]\n9:I[1060,[],\"\"]\na:[]\n0:[\"$\",\"$L2\",null,{\"buildId\":\"tQnVLKX-Dr8H3frHzPrCa\",\"assetPrefix\":\"\",\"urlParts\":[\"\",\"\"],\"initialTree\":[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"__PAGE__\",{},[[\"$L3\",[\"$\",\"$L4\",null,{\"props\":{\"params\":{},\"searchParams\":{}},\"Component\":\"$5\"}],null],null],null]},[[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/bbd6ee1e7265be74.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"children\":[\"$\",\"$L6\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L7\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],\"notFoundStyl"])</script><script>self.__next_f.push([1,"es\":[]}]}]}]],null],null],\"couldBeIntercepted\":false,\"initialHead\":[null,\"$L8\"],\"globalErrorComponent\":\"$9\",\"missingSlots\":\"$Wa\"}]\n"])</script><script>self.__next_f.push([1,"8:[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"1\",{\"charSet\":\"utf-8\"}],[\"$\",\"title\",\"2\",{\"children\":\"Handler · Claude Activity\"}],[\"$\",\"meta\",\"3\",{\"name\":\"description\",\"content\":\"Monitor and manage Claude Code agents across projects.\"}],[\"$\",\"link\",\"4\",{\"rel\":\"icon\",\"href\":\"/icon.svg?bab509b45a421e43\",\"type\":\"image/svg+xml\",\"sizes\":\"any\"}]]\n3:null\n"])</script></body></html>
+2 -2
View File
@@ -1,7 +1,7 @@
2:I[9107,[],"ClientPageRoot"]
3:I[9859,["931","static/chunks/app/page-5b19e394a5d5460f.js"],"default",1]
3:I[9859,["931","static/chunks/app/page-a511d608db773c67.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
0:["UFaQ4h6X_9jNfr8PwK2IS",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L6",null]]]]
0:["tQnVLKX-Dr8H3frHzPrCa",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L6",null]]]]
6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
+78
View File
@@ -0,0 +1,78 @@
"""Seed the user-level Claude Code config so a spawned agent never blocks on first-run
onboarding.
A freshly-installed ``claude`` opens an interactive setup — the theme picker, then a
"do you trust the files in this folder?" prompt — before it reaches the REPL. A spawned
agent runs detached in tmux with no human at the TTY, so it would sit on the theme picker
forever (the observed wedge: ``agents.status='working'`` while nothing happens). The
``/login`` flow drives those screens by hand; a spawned agent can't, so we mark onboarding
complete on disk *before* launching claude.
The write is a merge, never a clobber: ``~/.claude.json`` also holds the ``oauthAccount``
the login flow wrote, and losing that would log the agent out. We only fill in the keys
that gate first-run and leave everything else — including a theme the operator already
chose — untouched.
"""
from __future__ import annotations
import json
import os
def _home() -> str:
return os.path.expanduser("~") or "/tmp"
def config_path(home: str | None = None) -> str:
return os.path.join(home or _home(), ".claude.json")
def _load(path: str) -> dict:
if not os.path.exists(path):
return {}
try:
with open(path) as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def _atomic_write(path: str, data: dict) -> None:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.handler.tmp"
with open(tmp, "w") as fh:
json.dump(data, fh, indent=2)
os.replace(tmp, path)
def ensure_onboarded(working_dir: str | None = None, home: str | None = None) -> str:
"""Mark Claude Code onboarding complete in ``~/.claude.json`` and (when given) trust
``working_dir``, so a detached agent boots straight to the REPL. Returns the path.
Idempotent and merge-only: ``hasCompletedOnboarding`` is forced true (a stale ``false``
from a half-finished run would otherwise re-trigger setup), while ``theme`` is only
filled in when absent so an operator's chosen theme survives.
"""
path = config_path(home)
data = _load(path)
data["hasCompletedOnboarding"] = True
data.setdefault("theme", "dark")
if working_dir:
projects = data.get("projects")
if not isinstance(projects, dict):
projects = {}
entry = projects.get(working_dir)
if not isinstance(entry, dict):
entry = {}
# The per-directory trust prompt claude shows on first entry into a new folder.
entry["hasTrustDialogAccepted"] = True
entry.setdefault("hasCompletedProjectOnboarding", True)
projects[working_dir] = entry
data["projects"] = projects
_atomic_write(path, data)
return path
+21
View File
@@ -61,6 +61,27 @@ def config_local(cwd: str, key: str, value: str) -> tuple[bool, str]:
return _run(["config", "--local", key, value], cwd)
def is_clean(cwd: str) -> bool:
"""True when the working tree has no staged or unstaged changes."""
ok, out = _run(["status", "--porcelain"], cwd)
return ok and out == ""
def ahead_count(cwd: str) -> int | None:
"""Commits HEAD is ahead of its upstream, or ``None`` when no upstream is set.
A ``None`` distinguishes "never pushed / no tracking branch" (the mise-init gate
treats it as unpushed, prompting ``git push -u``) from "0 commits ahead" (pushed).
"""
ok, out = _run(["rev-list", "--count", "@{upstream}..HEAD"], cwd)
if not ok:
return None
try:
return int(out.strip())
except ValueError:
return None
def add(cwd: str, paths: list[str]) -> tuple[bool, str]:
return _run(["add", *paths], cwd)
+58
View File
@@ -0,0 +1,58 @@
"""The mise-config gate — one source of truth for "does this project define the canonical
``[tasks.test]`` task".
Shared by the spawn gate (``control.spawn.require_test_task``, which refuses to launch an
agent against a project with no test task) and the mise-init Stop hook (which refuses to
let the bootstrap agent finish until the task exists and is committed + pushed).
mise reads several config filenames, not just ``.mise.toml`` — a repo may ship ``mise.toml``
(no leading dot) or keep config under ``.config/mise/`` — so the gate accepts any of them
and treats a ``[tasks.test]`` in *any* present config as satisfying the requirement.
"""
from __future__ import annotations
import os
import tomllib
# The config filenames mise itself looks for, in the rough precedence order it uses. A
# project only needs one; we scan all present ones for the test task.
CONFIG_NAMES = (
"mise.toml",
".mise.toml",
"mise.local.toml",
".mise.local.toml",
os.path.join(".config", "mise.toml"),
os.path.join(".config", "mise", "config.toml"),
)
def config_paths(working_dir: str) -> list[str]:
return [os.path.join(working_dir, name) for name in CONFIG_NAMES]
def existing_config(working_dir: str) -> str | None:
"""Path to the first present mise config file under ``working_dir``, else ``None``."""
for path in config_paths(working_dir):
if os.path.exists(path):
return path
return None
def has_config(working_dir: str) -> bool:
return existing_config(working_dir) is not None
def has_test_task(working_dir: str) -> bool:
"""True when any present mise config defines a ``[tasks.test]`` task."""
for path in config_paths(working_dir):
if not os.path.exists(path):
continue
try:
with open(path, "rb") as fh:
data = tomllib.load(fh)
except (OSError, tomllib.TOMLDecodeError):
continue
if "test" in (data.get("tasks") or {}):
return True
return False
+39 -15
View File
@@ -10,12 +10,21 @@ process launched, so a project without a canonical test task never gets an agent
from __future__ import annotations
import os
import tomllib
from ..config import get_settings
from ..db import repository as repo
from ..db.engine import connection
from . import credentials, forge, gitops, reposync, settings_gen, tmux, worktree
from . import (
claude_config,
credentials,
forge,
gitops,
mise,
reposync,
settings_gen,
tmux,
worktree,
)
class SpawnError(Exception):
@@ -23,19 +32,15 @@ class SpawnError(Exception):
def require_test_task(working_dir: str) -> None:
"""Hard gate: refuse to spawn unless ``.mise.toml`` defines ``[tasks.test]``."""
mise_path = os.path.join(working_dir, ".mise.toml")
if not os.path.exists(mise_path):
"""Hard gate: refuse to spawn unless a mise config defines ``[tasks.test]``."""
if not mise.has_config(working_dir):
raise SpawnError(
f"no .mise.toml in {working_dir}: a project must define a [tasks.test] task "
"before an agent can run against it"
f"no mise config (mise.toml / .mise.toml) in {working_dir}: a project must "
"define a [tasks.test] task before an agent can run against it"
)
with open(mise_path, "rb") as fh:
data = tomllib.load(fh)
tasks = data.get("tasks", {})
if "test" not in tasks:
if not mise.has_test_task(working_dir):
raise SpawnError(
f".mise.toml in {working_dir} has no [tasks.test]: the verification gate "
f"mise config in {working_dir} has no [tasks.test]: the verification gate "
"requires a canonical test task"
)
@@ -77,8 +82,17 @@ def spawn(
worktree_branch: str | None = None,
task: str | None = None,
role: str | None = None,
require_tests: bool = True,
mise_init: bool = False,
) -> dict:
"""Create and launch an agent. Returns the agent row."""
"""Create and launch an agent. Returns the agent row.
``require_tests`` is the ``[tasks.test]`` gate; the mise-init bootstrap agent runs with
it off, because a project with no ``.mise.toml`` yet is exactly what it exists to fix.
``mise_init`` marks the launched agent (via ``HANDLER_MISE_INIT``) so its hooks enforce
the bootstrap contract — create the test task, commit, and push — instead of the normal
test gate.
"""
sync_note = None
with connection() as conn:
project = repo.get_project(conn, project_id)
@@ -111,8 +125,10 @@ def spawn(
# Hard gates before any state is written or process launched: the test task must
# exist, and configured credentials must actually resolve — a broken pointer
# should fail fast, not leave an orphaned agent row behind.
require_test_task(working_dir)
# should fail fast, not leave an orphaned agent row behind. The mise-init agent
# skips the test-task gate (it's here to create that very task).
if require_tests:
require_test_task(working_dir)
try:
token = credentials.resolve_for_project(project, conn)
except credentials.CredentialError as exc:
@@ -137,6 +153,9 @@ def spawn(
}
if role:
env["HANDLER_AGENT_ROLE"] = role
if mise_init:
# Read by the Stop / git-push hooks to enforce the bootstrap contract.
env["HANDLER_MISE_INIT"] = "1"
# A short read connection lets credential/host resolution consult the forge_hosts
# registry (falling back to the built-in host map when a host has no row).
with connection() as conn:
@@ -154,6 +173,11 @@ def spawn(
# touches forge and the base image is the real pin (README 3.6, Phase 2).
forge_note = _check_forge_version(working_dir)
# Mark Claude Code onboarding complete + trust the working dir before launching, so the
# detached agent boots straight to the REPL instead of wedging on the first-run theme
# picker / trust prompt with no human at the tmux TTY to answer it.
claude_config.ensure_onboarded(working_dir)
session = tmux.session_name(project_id, name)
command = _claude_command(task, settings_path)
tmux.new_session(session, cwd=working_dir, command=command, env=env)
+93 -2
View File
@@ -20,7 +20,7 @@ from datetime import UTC, datetime, timedelta
from ..db import repository as repo
from ..db.engine import connection
from . import gitops, login, poller, reposync, skills_gen, spawn
from . import gitops, login, poller, reposync, skills_gen, spawn, tmux
class CommandError(Exception):
@@ -150,6 +150,50 @@ def _cmd_forge_init(command: dict) -> dict:
return result
# The prompt the bootstrap agent starts with when an operator ticks "Initialize mise" on
# the add-repo step. It launches with the [tasks.test] gate off (there's no .mise.toml yet)
# and the HANDLER_MISE_INIT marker on, so its hooks enforce the "commit + push" contract.
_MISE_INIT_TASK = (
"This repository has no mise tooling yet. Detect the project's stack by inspecting the "
"repo (e.g. package.json -> npm/pnpm/yarn, pyproject.toml or setup.py -> pytest, "
"Cargo.toml -> cargo, go.mod -> go, a Makefile -> make, a Gemfile -> bundler), then "
"write a `.mise.toml` at the repository root that pins the runtime under `[tools]` and "
"defines a canonical `[tasks.test]` task running that stack's test command (add `lint` "
"and `verify` tasks too when the stack has an obvious linter). Then commit the "
"`.mise.toml` and push it to the remote. Do not finish until the change is committed AND "
"pushed — the checkpoint gate will keep blocking otherwise."
)
def _cmd_mise_init(command: dict) -> dict:
"""Bootstrap mise tooling: launch an agent that writes, commits, and pushes a
``.mise.toml`` with a ``[tasks.test]`` task for the project's stack.
Runs with ``require_tests=False`` (the project has no test task yet — creating one is
the point) and ``mise_init=True`` (its hooks enforce the commit + push contract).
"""
project_id = command.get("project_id")
if not project_id:
raise CommandError("mise_init requires project_id")
p = _payload(command)
name = command.get("agent_name") or p.get("name") or "mise-init"
try:
agent = spawn.spawn(
project_id,
name,
task=p.get("task") or _MISE_INIT_TASK,
require_tests=False,
mise_init=True,
)
except spawn.SpawnError as exc:
raise CommandError(str(exc)) from exc
return {
"agent_id": agent["id"],
"name": agent["name"],
"working_dir": agent["working_dir"],
}
def _cmd_poll_ci(command: dict) -> dict:
return poller.sweep(project_id=command.get("project_id"))
@@ -199,6 +243,7 @@ _DISPATCH = {
"approve": _cmd_approve,
"reject": _cmd_reject,
"forge_init": _cmd_forge_init,
"mise_init": _cmd_mise_init,
"poll_ci": _cmd_poll_ci,
"sync": _cmd_sync,
"login_start": _cmd_login_start,
@@ -268,6 +313,43 @@ def fire_due_schedules(now: datetime | None = None) -> int:
return fired
# How many trailing pane lines to snapshot — enough to show the current screen (a menu, a
# prompt, the tail of the last command) without bloating the row.
_PANE_TAIL_LINES = 40
def _pane_tail(pane: str, lines: int = _PANE_TAIL_LINES) -> str:
"""The last ``lines`` of a captured pane, trailing blank lines trimmed so an idle
screen doesn't store as a wall of whitespace."""
rows = (pane or "").splitlines()
while rows and not rows[-1].strip():
rows.pop()
return "\n".join(rows[-lines:])
def capture_agent_output() -> int:
"""Snapshot each working agent's live tmux pane tail into the DB.
The tmux socket lives only in the control container, so this is the one channel the
API/UI have onto what a running — or wedged — agent is actually doing: an agent stuck
on claude's first-run theme picker surfaces as that screen instead of a misleading
green 'working'. A missing session is skipped (its process is gone). Returns the count
updated.
"""
with connection() as conn:
working = repo.list_agents_by_status(conn, "working")
updated = 0
for agent in working:
session = tmux.session_name(agent["project_id"], agent["name"])
if not tmux.has_session(session):
continue
tail = _pane_tail(tmux.capture_pane(session))
with connection() as conn:
repo.update_agent_output(conn, agent["id"], tail)
updated += 1
return updated
def drain(worker_id: str, limit: int | None = None) -> int:
"""Claim and run queued commands until the queue is empty (or ``limit`` reached).
@@ -290,15 +372,18 @@ def run(
worker_id: str | None = None,
poll_interval: float = 2.0,
ci_interval: float = 30.0,
capture_interval: float = 2.0,
iterations: int | None = None,
) -> None:
"""The control-container main loop: drain the command queue + sweep CI periodically.
"""The control-container main loop: drain the command queue, snapshot live agent
output, and sweep CI periodically.
``iterations`` bounds the loop for tests; production runs unbounded. Sleeps
``poll_interval`` only when a pass found no commands, so bursts drain promptly.
"""
worker_id = worker_id or f"worker-{os.getpid()}"
last_ci = 0.0
last_capture = 0.0
count = 0
while iterations is None or count < iterations:
try:
@@ -307,6 +392,12 @@ def run(
pass
did_work = drain(worker_id) > 0
now = time.monotonic()
if capture_interval > 0 and now - last_capture >= capture_interval:
try:
capture_agent_output()
except Exception: # noqa: BLE001 - a capture hiccup must not kill the worker
pass
last_capture = now
if ci_interval > 0 and now - last_ci >= ci_interval:
try:
poller.sweep()
+64
View File
@@ -219,6 +219,23 @@ def set_agent_status(conn: Connection, agent_id: int, status: str) -> None:
conn.execute(agents.update().where(agents.c.id == agent_id).values(status=status))
def list_agents_by_status(conn: Connection, status: str) -> list[dict]:
"""Every agent in a given status, across all projects (the worker's capture input)."""
rows = conn.execute(
select(agents).where(agents.c.status == status).order_by(agents.c.id)
).all()
return [dict(r._mapping) for r in rows]
def update_agent_output(conn: Connection, agent_id: int, output: str) -> None:
"""Store the latest tmux pane-tail snapshot for an agent (worker poll loop)."""
conn.execute(
agents.update()
.where(agents.c.id == agent_id)
.values(last_output=output, output_at=_now())
)
def insert_log_entry(conn: Connection, agent_id: int, status: str, **fields: Any) -> int:
values = {"agent_id": agent_id, "status": status, "created_at": _now(), **fields}
result = conn.execute(log_entries.insert().values(**values))
@@ -337,12 +354,59 @@ def update_project(conn: Connection, project_id: str, **fields: Any) -> dict | N
return get_project(conn, project_id)
def _purge_agent_dependents(conn: Connection, agent_ids: list[int]) -> None:
"""Delete (or detach) everything that references the given agents, so the agent rows
themselves can be removed without tripping a foreign key.
``shared_context`` is a *global* table keyed by ``key`` — its rows outlive any one
agent, so we only null the ``set_by_agent_id`` attribution rather than delete them.
``checkmarks`` must go before ``log_entries`` (it carries an FK to ``log_entries`` via
the ``use_alter`` cycle). Approvals authored by these agents go with them.
"""
if not agent_ids:
return
conn.execute(
shared_context.update()
.where(shared_context.c.set_by_agent_id.in_(agent_ids))
.values(set_by_agent_id=None)
)
conn.execute(approvals.delete().where(approvals.c.approved_by_agent_id.in_(agent_ids)))
conn.execute(checkmarks.delete().where(checkmarks.c.agent_id.in_(agent_ids)))
conn.execute(log_entries.delete().where(log_entries.c.agent_id.in_(agent_ids)))
def delete_project(conn: Connection, project_id: str) -> bool:
"""Remove a project and everything scoped to it.
Every project accumulates FK-referencing rows (at minimum the ``sync`` command queued
at registration, plus each agent's log/checkmark history), so the bare project delete
would violate ``commands``/``agents``/``approvals``/``schedules`` foreign keys. Clear
the dependents in FK-safe order first: agent-owned rows, then ``schedules`` (which
reference ``commands`` via ``last_command_id``), then the remaining project-scoped
rows, then the agents, then the project itself.
"""
agent_ids = [
row[0]
for row in conn.execute(
select(agents.c.id).where(agents.c.project_id == project_id)
).all()
]
_purge_agent_dependents(conn, agent_ids)
conn.execute(schedules.delete().where(schedules.c.project_id == project_id))
conn.execute(approvals.delete().where(approvals.c.project_id == project_id))
conn.execute(commands.delete().where(commands.c.project_id == project_id))
conn.execute(agents.delete().where(agents.c.project_id == project_id))
result = conn.execute(projects.delete().where(projects.c.id == project_id))
return result.rowcount > 0
def delete_agent(conn: Connection, project_id: str, name: str) -> bool:
"""Remove a single agent row, first clearing its log/checkmark/approval history so the
``log_entries``/``checkmarks``/``approvals`` foreign keys don't block the delete."""
agent = get_agent_by_name(conn, project_id, name)
if agent is None:
return False
_purge_agent_dependents(conn, [agent["id"]])
result = conn.execute(
agents.delete().where(agents.c.project_id == project_id, agents.c.name == name)
)
+6
View File
@@ -46,6 +46,7 @@ COMMAND_TYPES = (
"approve",
"reject",
"forge_init",
"mise_init",
"poll_ci",
"sync",
"login_start",
@@ -83,6 +84,11 @@ agents = Table(
# Optional workflow role (junior | senior | deploy) — informational, drives which
# forge skill an agent follows; the approval gate keys on identity, not role.
Column("role", String),
# A periodic snapshot of the agent's live tmux pane tail (last ~40 lines), refreshed by
# the control worker's poll loop. The tmux socket lives only in the control container,
# so this DB column is how the API/UI see what a running — or wedged — agent is doing.
Column("last_output", String),
Column("output_at", PortableTimestamp),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
UniqueConstraint("project_id", "name", name="uq_agents_project_name"),
CheckConstraint(_in("status", AGENT_STATUSES), name="ck_agents_status"),
+68
View File
@@ -13,12 +13,80 @@ from datetime import UTC, datetime
from sqlalchemy import Connection
from ..control import gitops, mise
from ..db import repository as repo
from . import verify
from .context import HookInput, Identity, emit
def _mise_init_blocker(working_dir: str) -> str | None:
"""Why a mise-init agent may not finish yet — ``None`` once the contract is met.
The bootstrap is complete only when ``.mise.toml`` defines ``[tasks.test]`` and that
file is committed (clean tree) and pushed (no commits ahead of, and an, upstream).
"""
if not mise.has_test_task(working_dir):
return "no mise config (mise.toml / .mise.toml) defines a [tasks.test] task yet"
if not gitops.is_clean(working_dir):
return "there are uncommitted changes — commit the .mise.toml"
ahead = gitops.ahead_count(working_dir)
if ahead is None:
return "the branch has no upstream yet — push it with `git push -u`"
if ahead > 0:
return f"{ahead} commit(s) have not been pushed to the remote"
return None
def handle_mise_init_stop(conn: Connection, ident: Identity, hook_input: HookInput) -> dict:
"""Stop gate for the mise-init bootstrap agent: block until the ``.mise.toml`` test
task exists and is committed + pushed (not the normal ``mise run test`` gate there
may be no working suite yet, which is exactly what this agent is bootstrapping)."""
working_dir = ident.working_dir or hook_input.cwd or "."
now = datetime.now(UTC)
blocker = _mise_init_blocker(working_dir)
status = "done" if blocker is None else "blocked"
summary = (
"checkpoint: .mise.toml committed and pushed"
if blocker is None
else f"mise-init blocked: {blocker}"
)
log_id = repo.insert_log_entry(
conn,
agent_id=ident.agent_id,
status=status,
session_id=hook_input.session_id,
summary=summary,
)
repo.upsert_checkmark_row(
conn,
agent_id=ident.agent_id,
checkpoint_at=now,
status=status,
where_it_stopped=summary,
log_entry_id=log_id,
)
repo.set_agent_status(conn, ident.agent_id, status)
if blocker is not None:
# Same infinite-block guard as the test gate: if we already re-invoked once,
# record the state but let the turn end rather than looping forever.
if hook_input.stop_hook_active:
return {}
return {
"decision": "block",
"reason": (
f"mise initialization is not complete: {blocker}. Write a `.mise.toml` with "
"a [tasks.test] task for this project's stack, commit it, and push it before "
"finishing."
),
}
return {}
def handle_stop(conn: Connection, ident: Identity, hook_input: HookInput) -> dict:
if ident.mise_init:
return handle_mise_init_stop(conn, ident, hook_input)
working_dir = ident.working_dir or hook_input.cwd or "."
ok, output = verify.run_test(working_dir)
now = datetime.now(UTC)
+6 -1
View File
@@ -59,6 +59,10 @@ class Identity:
project_id: str
agent_name: str
working_dir: str | None = None
# True for the mise-init bootstrap agent (env ``HANDLER_MISE_INIT``): its Stop and
# git-push hooks enforce the "write .mise.toml, commit, push" contract rather than the
# normal test gate.
mise_init: bool = False
extra: dict = field(default_factory=dict)
@@ -74,10 +78,11 @@ def resolve_identity(conn: Connection, hook_input: HookInput) -> Identity | None
project_id = os.environ.get("HANDLER_PROJECT_ID")
agent_name = os.environ.get("HANDLER_AGENT_NAME")
mise_init = bool(os.environ.get("HANDLER_MISE_INIT"))
if agent_id and project_id and agent_name:
row = conn.execute(select(agents).where(agents.c.id == int(agent_id))).first()
working_dir = row._mapping["working_dir"] if row else None
return Identity(int(agent_id), project_id, agent_name, working_dir)
return Identity(int(agent_id), project_id, agent_name, working_dir, mise_init=mise_init)
# Fallback: match by working_dir == cwd.
if hook_input.cwd:
+17
View File
@@ -96,6 +96,23 @@ def handle_git_push(conn: Connection, ident: Identity, hook_input: HookInput) ->
working_dir = ident.working_dir or hook_input.cwd or "."
now = datetime.now(UTC)
# The mise-init bootstrap agent is pushing the .mise.toml it just wrote; there is no
# working test task to gate on yet (creating it is the point), and it must reach the
# remote so the [tasks.test] gate is satisfied for the agents that follow. Record the
# push and let it through, skipping the test/build/approval gates.
if ident.mise_init:
sha = gitops.head_sha(working_dir)
if sha:
repo.insert_log_entry(
conn,
agent_id=ident.agent_id,
status="working",
session_id=hook_input.session_id,
summary=f"mise-init push: {sha[:12]}",
push_sha=sha,
)
return _allow("mise initialization push (test gate bypassed)")
# Cheap check first: tests. Only on success do we pay for the image build.
tests_ok, tests_out = verify.run_test(working_dir)
if not tests_ok:
@@ -0,0 +1,44 @@
"""mise-init command type
Revision ID: 0006_mise_init_command
Revises: 0005_claude_login_commands
Create Date: 2026-07-16
Adds the ``mise_init`` command type so the dashboard's "Initialize mise" option on the
add-repo step can enqueue a bootstrap agent that writes a ``.mise.toml`` with a canonical
``[tasks.test]`` task for the project's stack, then commits and pushes it. The commands
CHECK constraint change goes through ``batch_alter_table`` so SQLite recreates the table
while Postgres alters in place (same pattern as 0004's ``sync`` and 0005's login types).
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0006_mise_init_command"
down_revision: str | None = "0005_claude_login_commands"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
OLD_COMMAND_TYPES = (
"'spawn', 'kill', 'resume', 'approve', 'reject', 'forge_init', 'poll_ci', 'sync', "
"'login_start', 'login_submit'"
)
NEW_COMMAND_TYPES = (
"'spawn', 'kill', 'resume', 'approve', 'reject', 'forge_init', 'mise_init', 'poll_ci', "
"'sync', 'login_start', 'login_submit'"
)
def upgrade() -> None:
with op.batch_alter_table("commands", schema=None) as batch_op:
batch_op.drop_constraint("ck_commands_type", type_="check")
batch_op.create_check_constraint("ck_commands_type", f"type IN ({NEW_COMMAND_TYPES})")
def downgrade() -> None:
with op.batch_alter_table("commands", schema=None) as batch_op:
batch_op.drop_constraint("ck_commands_type", type_="check")
batch_op.create_check_constraint("ck_commands_type", f"type IN ({OLD_COMMAND_TYPES})")
@@ -0,0 +1,36 @@
"""agent live-output snapshot
Revision ID: 0007_agent_pane_output
Revises: 0006_mise_init_command
Create Date: 2026-07-16
Adds ``last_output`` (text) and ``output_at`` (timestamp) to ``agents``. The control
worker snapshots each working agent's tmux pane tail into these columns on its poll loop,
so the API/UI can show what a live or wedged agent is actually doing (the tmux socket
lives only in the control container, so the DB is the one channel the API can read). Both
are nullable and back-fill lazily on the next poll, so the migration needs no data step.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from handler.db.types import PortableTimestamp
revision: str = "0007_agent_pane_output"
down_revision: str | None = "0006_mise_init_command"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("agents", sa.Column("last_output", sa.String()))
op.add_column("agents", sa.Column("output_at", PortableTimestamp))
def downgrade() -> None:
op.drop_column("agents", "output_at")
op.drop_column("agents", "last_output")
+3
View File
@@ -33,6 +33,9 @@ def env(tmp_path, monkeypatch):
monkeypatch.setenv("SHARED_CONTEXT_WRITE_TOKEN", "shared-token")
monkeypatch.setenv("PROJECTS_ROOT", str(tmp_path / "projects"))
monkeypatch.delenv("WEBHOOK_URL", raising=False)
# Isolate any home-dir writes (e.g. control.claude_config seeding ~/.claude.json at
# spawn) to the per-test tmp dir, so tests never touch the real user's config.
monkeypatch.setenv("HOME", str(tmp_path))
_reset_caches()
cfg = Config(str(REPO_ROOT / "alembic.ini"))
+18
View File
@@ -32,6 +32,24 @@ def test_create_and_list_agent(client, auth):
assert [a["name"] for a in agents] == ["api"]
def test_agent_serializes_live_output_snapshot(client, auth, engine):
from handler.db import repository as repo
_mk_project(client, auth)
client.post(
"/projects/proj/agents",
json={"name": "api", "working_dir": "/tmp/proj/api", "status": "working"},
headers=auth,
)
with engine.begin() as conn:
agent = repo.get_agent_by_name(conn, "proj", "api")
repo.update_agent_output(conn, agent["id"], "boot\nTheme picker")
listed = client.get("/projects/proj/agents", headers=auth).json()[0]
assert listed["last_output"] == "boot\nTheme picker"
assert listed["output_at"] is not None
def test_agent_under_missing_project_is_404(client, auth):
r = client.get("/projects/ghost/agents", headers=auth)
assert r.status_code == 404
+47
View File
@@ -0,0 +1,47 @@
"""Seeding Claude Code onboarding so a detached agent boots straight to the REPL."""
from __future__ import annotations
import json
from pathlib import Path
from handler.control import claude_config
def _read(home: Path) -> dict:
return json.loads((home / ".claude.json").read_text())
def test_ensure_onboarded_seeds_defaults_on_fresh_home(tmp_path):
claude_config.ensure_onboarded(home=str(tmp_path))
data = _read(tmp_path)
assert data["hasCompletedOnboarding"] is True
assert data["theme"] == "dark"
def test_ensure_onboarded_merges_without_clobbering(tmp_path):
# The login flow's oauthAccount (and a chosen theme) must survive the seeding.
(tmp_path / ".claude.json").write_text(
json.dumps({"oauthAccount": {"emailAddress": "a@b.c"}, "theme": "light"})
)
claude_config.ensure_onboarded(home=str(tmp_path))
data = _read(tmp_path)
assert data["oauthAccount"] == {"emailAddress": "a@b.c"} # preserved
assert data["theme"] == "light" # operator's chosen theme untouched
assert data["hasCompletedOnboarding"] is True
def test_ensure_onboarded_forces_flag_and_trusts_working_dir(tmp_path):
# A stale false must not leave onboarding armed; the working dir gets trusted.
(tmp_path / ".claude.json").write_text(json.dumps({"hasCompletedOnboarding": False}))
wd = "/var/lib/handler/projects/x"
claude_config.ensure_onboarded(working_dir=wd, home=str(tmp_path))
data = _read(tmp_path)
assert data["hasCompletedOnboarding"] is True
assert data["projects"][wd]["hasTrustDialogAccepted"] is True
def test_ensure_onboarded_survives_corrupt_file(tmp_path):
(tmp_path / ".claude.json").write_text("{ not valid json")
claude_config.ensure_onboarded(home=str(tmp_path))
assert _read(tmp_path)["hasCompletedOnboarding"] is True
+39 -1
View File
@@ -37,10 +37,22 @@ def test_spawn_refuses_without_mise_file(env, fake_tmux):
root = env["tmp"] / "proj"
root.mkdir(parents=True, exist_ok=True)
_register_project(root)
with pytest.raises(spawn.SpawnError, match="no .mise.toml"):
with pytest.raises(spawn.SpawnError, match="no mise config"):
spawn.spawn("proj", "api")
def test_spawn_accepts_dotless_mise_toml(env, fake_tmux):
# mise also reads `mise.toml` (no leading dot); the gate must honor it too.
root = env["tmp"] / "proj"
root.mkdir(parents=True, exist_ok=True)
(root / "mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n")
_register_project(root)
agent = spawn.spawn("proj", "api")
with get_engine().begin() as conn:
assert repo.get_agent_by_name(conn, "proj", "api")["id"] == agent["id"]
def test_spawn_creates_agent_settings_and_session(env, fake_tmux):
root = env["tmp"] / "proj"
_write_mise(root, with_test=True)
@@ -68,6 +80,32 @@ def test_spawn_creates_agent_settings_and_session(env, fake_tmux):
assert call["env"]["DATABASE_URL"] == env["url"]
def test_spawn_mise_init_skips_test_gate_and_marks_env(env, fake_tmux):
# A repo with no .mise.toml at all: the normal gate would refuse, but the mise-init
# bootstrap agent must launch anyway (creating that file is its whole job).
root = env["tmp"] / "proj"
root.mkdir(parents=True, exist_ok=True)
_register_project(root)
agent = spawn.spawn("proj", "mise-init", require_tests=False, mise_init=True)
with get_engine().begin() as conn:
assert repo.get_agent_by_name(conn, "proj", "mise-init")["id"] == agent["id"]
# The launched session carries HANDLER_MISE_INIT so its hooks enforce commit + push.
call = fake_tmux["calls"]["new_session"][0]
assert call["env"]["HANDLER_MISE_INIT"] == "1"
def test_spawn_still_gates_without_mise_init_flag(env, fake_tmux):
root = env["tmp"] / "proj"
root.mkdir(parents=True, exist_ok=True)
_register_project(root)
# require_tests defaults on, so a normal spawn against a mise-less repo still refuses.
with pytest.raises(spawn.SpawnError, match="no mise config"):
spawn.spawn("proj", "api")
assert fake_tmux["calls"]["new_session"] == []
def test_kill_sets_done_and_kills_session(env, fake_tmux):
root = env["tmp"] / "proj"
_write_mise(root, with_test=True)
+65 -2
View File
@@ -2,15 +2,22 @@
from __future__ import annotations
from handler.control import gitops, mise
from handler.db import repository as repo
from handler.hooks import checkpoint, verify
from handler.hooks.context import HookInput, Identity
def _seed(conn):
def _seed(conn, mise_init=False):
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
return Identity(a["id"], "p", "a", "/tmp/p/a")
return Identity(a["id"], "p", "a", "/tmp/p/a", mise_init=mise_init)
def _fake_mise_state(monkeypatch, *, has_test, clean, ahead):
monkeypatch.setattr(mise, "has_test_task", lambda cwd: has_test)
monkeypatch.setattr(gitops, "is_clean", lambda cwd: clean)
monkeypatch.setattr(gitops, "ahead_count", lambda cwd: ahead)
def test_stop_blocks_on_failing_tests(conn, monkeypatch):
@@ -50,6 +57,62 @@ def test_stop_does_not_reblock_when_already_active(conn, monkeypatch):
assert repo.get_checkmark(conn, ident.agent_id)["tests_status"] == "fail"
def test_mise_init_stop_blocks_when_no_test_task(conn, monkeypatch):
ident = _seed(conn, mise_init=True)
# The normal test gate must NOT run for a mise-init agent.
monkeypatch.setattr(
verify, "run_test", lambda cwd: (_ for _ in ()).throw(AssertionError("test gate ran"))
)
_fake_mise_state(monkeypatch, has_test=False, clean=True, ahead=0)
result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop"))
assert result["decision"] == "block"
assert "[tasks.test]" in result["reason"]
assert repo.get_agent_by_name(conn, "p", "a")["status"] == "blocked"
def test_mise_init_stop_blocks_on_uncommitted_changes(conn, monkeypatch):
ident = _seed(conn, mise_init=True)
_fake_mise_state(monkeypatch, has_test=True, clean=False, ahead=0)
result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop"))
assert result["decision"] == "block"
assert "uncommitted" in result["reason"]
def test_mise_init_stop_blocks_when_no_upstream(conn, monkeypatch):
ident = _seed(conn, mise_init=True)
_fake_mise_state(monkeypatch, has_test=True, clean=True, ahead=None)
result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop"))
assert result["decision"] == "block"
assert "upstream" in result["reason"]
def test_mise_init_stop_blocks_on_unpushed_commits(conn, monkeypatch):
ident = _seed(conn, mise_init=True)
_fake_mise_state(monkeypatch, has_test=True, clean=True, ahead=2)
result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop"))
assert result["decision"] == "block"
assert "not been pushed" in result["reason"]
def test_mise_init_stop_allows_when_committed_and_pushed(conn, monkeypatch):
ident = _seed(conn, mise_init=True)
_fake_mise_state(monkeypatch, has_test=True, clean=True, ahead=0)
result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop"))
assert result == {} # contract met — the turn may end
cm = repo.get_checkmark(conn, ident.agent_id)
assert cm["status"] == "done"
assert repo.get_agent_by_name(conn, "p", "a")["status"] == "done"
def test_mise_init_stop_does_not_reblock_when_already_active(conn, monkeypatch):
ident = _seed(conn, mise_init=True)
_fake_mise_state(monkeypatch, has_test=False, clean=True, ahead=0)
hi = HookInput({"session_id": "s1", "stop_hook_active": True}, "stop")
result = checkpoint.handle_stop(conn, ident, hi)
assert result == {} # recorded, but not an infinite block
def test_session_end_records_without_gate(conn, monkeypatch):
ident = _seed(conn)
# Even if tests would fail, SessionEnd must not run the gate or block.
+23 -2
View File
@@ -2,15 +2,16 @@
from __future__ import annotations
from handler.control import gitops
from handler.db import repository as repo
from handler.hooks import gate, verify
from handler.hooks.context import HookInput, Identity
def _seed(conn):
def _seed(conn, mise_init=False):
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
return Identity(a["id"], "p", "a", "/tmp/p/a")
return Identity(a["id"], "p", "a", "/tmp/p/a", mise_init=mise_init)
def _decision(result):
@@ -73,6 +74,26 @@ def test_git_push_allowed_when_both_pass(conn, monkeypatch):
assert _decision(result) == "allow"
def test_mise_init_push_bypasses_test_gate(conn, monkeypatch):
ident = _seed(conn, mise_init=True)
# The mise-init agent pushes the .mise.toml it just wrote; the test/build gate must
# not run (there may be no working suite yet), and the push is recorded + allowed.
monkeypatch.setattr(
verify, "run_test", lambda cwd: (_ for _ in ()).throw(AssertionError("test gate ran"))
)
monkeypatch.setattr(gitops, "head_sha", lambda cwd: "sha123456789")
hi = HookInput(
{"tool_name": "Bash", "tool_input": {"command": "git push -u origin main"},
"session_id": "s1"},
"pre_tool_use",
)
result = gate.handle_git_push(conn, ident, hi)
assert _decision(result) == "allow"
# The push is recorded in the log so the run shows it landed.
entries = repo.get_log(conn, ident.agent_id, limit=10, offset=0)
assert any(e["push_sha"] == "sha123456789" for e in entries)
def test_non_push_bash_is_ignored(conn):
ident = _seed(conn)
hi = HookInput({"tool_name": "Bash", "tool_input": {"command": "ls -la"}}, "pre_tool_use")
+40
View File
@@ -0,0 +1,40 @@
"""The mise-config gate: which filenames count, and the [tasks.test] check."""
from __future__ import annotations
from handler.control import mise
def test_no_config_is_no_config(tmp_path):
assert mise.has_config(str(tmp_path)) is False
assert mise.has_test_task(str(tmp_path)) is False
def test_dotless_mise_toml_is_accepted(tmp_path):
(tmp_path / "mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n")
assert mise.has_config(str(tmp_path)) is True
assert mise.has_test_task(str(tmp_path)) is True
def test_dotted_mise_toml_is_accepted(tmp_path):
(tmp_path / ".mise.toml").write_text("[tasks.test]\nrun = 'go test ./...'\n")
assert mise.has_test_task(str(tmp_path)) is True
def test_config_under_dot_config_dir(tmp_path):
cfg = tmp_path / ".config" / "mise"
cfg.mkdir(parents=True)
(cfg / "config.toml").write_text("[tasks.test]\nrun = 'pytest'\n")
assert mise.has_test_task(str(tmp_path)) is True
def test_config_without_test_task_fails_the_gate(tmp_path):
(tmp_path / "mise.toml").write_text("[tasks.lint]\nrun = 'ruff check .'\n")
assert mise.has_config(str(tmp_path)) is True
assert mise.has_test_task(str(tmp_path)) is False
def test_corrupt_config_is_not_a_test_task(tmp_path):
(tmp_path / "mise.toml").write_text("this = = not valid toml")
assert mise.has_config(str(tmp_path)) is True
assert mise.has_test_task(str(tmp_path)) is False
+40
View File
@@ -50,6 +50,46 @@ def test_create_project_from_server_with_ssh_key(client, auth, env, secret_key):
assert cmd["project_id"] == "coolproj"
def test_create_project_with_init_mise_enqueues_bootstrap(client, auth, env, secret_key):
_add_server(client, auth, generate_ssh_key=True)
r = client.post(
"/projects",
json={"git_server": "github.com", "repo": "me/coolproj", "init_mise": True},
headers=auth,
)
assert r.status_code == 201, r.text
body = r.json()
assert body["sync_command_id"] is not None
assert body["mise_init_command_id"] is not None
# The clone is queued before the bootstrap so it runs first (FIFO by id).
assert body["mise_init_command_id"] > body["sync_command_id"]
cmd = client.get(f"/commands/{body['mise_init_command_id']}", headers=auth).json()
assert cmd["type"] == "mise_init"
assert cmd["project_id"] == "coolproj"
def test_create_project_without_init_mise_skips_bootstrap(client, auth, env, secret_key):
_add_server(client, auth, generate_ssh_key=True)
r = client.post(
"/projects", json={"git_server": "github.com", "repo": "me/plain"}, headers=auth
)
assert r.status_code == 201, r.text
assert r.json()["mise_init_command_id"] is None
def test_init_mise_without_remote_does_not_enqueue(client, auth, env, tmp_path):
# Manual mode with no git_remote: nothing to push to, so no bootstrap is queued.
root = tmp_path / "local"
root.mkdir()
r = client.post(
"/projects",
json={"id": "local", "root_dir": str(root), "init_mise": True},
headers=auth,
)
assert r.status_code == 201, r.text
assert r.json()["mise_init_command_id"] is None
def test_create_project_from_server_https_without_key(client, auth, env):
_add_server(client, auth, hostname="git.corp", forge_type="gitea",
base_url="https://git.corp:8443")
+45
View File
@@ -24,6 +24,51 @@ def test_delete_agent_row(conn):
assert repo.get_agent_by_name(conn, "p", "api") is None
def test_delete_project_cascades_all_dependents(conn):
"""A real project always has FK-referencing rows (the sync command, agent history,
approvals, schedules). Deleting it must clear them, not raise a ForeignKeyViolation."""
from datetime import UTC, datetime
repo.create_project(conn, "p", "/tmp/p", git_remote="https://github.com/me/p.git")
# The sync command queued at registration — the exact row that blocked the delete.
repo.enqueue_command(conn, "sync", project_id="p", requested_by="operator:web")
agent = repo.create_agent(conn, "p", "api", "/tmp/p/api")
repo.insert_log_entry(conn, agent_id=agent["id"], status="working", summary="did work")
repo.upsert_checkmark_row(conn, agent_id=agent["id"], status="working")
repo.set_shared_context(conn, "db", "postgres", agent["id"])
repo.record_approval(conn, "p", "feat/x", "approved", approved_by_agent_id=agent["id"])
repo.create_schedule(
conn, "p", "nightly", "run the thing", 3600, datetime.now(UTC)
)
assert repo.delete_project(conn, "p") is True
# Project and everything scoped to it are gone.
assert repo.get_project(conn, "p") is None
assert repo.get_agent_by_name(conn, "p", "api") is None
assert repo.list_commands(conn, project_id="p") == []
assert repo.list_approvals(conn, "p") == []
assert repo.list_schedules(conn) == []
# The global shared-context row survives, with its agent attribution cleared.
ctx = repo.get_shared_context_key(conn, "db")
assert ctx is not None and ctx["value"] == "postgres"
assert ctx["set_by_agent_id"] is None
def test_delete_agent_cascades_log_and_checkmark(conn):
"""Every spawned agent accrues a checkmark + log entries via the hooks; removing the
agent must clear them rather than trip the log_entries/checkmarks foreign keys."""
repo.create_project(conn, "p", "/tmp/p")
agent = repo.create_agent(conn, "p", "api", "/tmp/p/api")
repo.insert_log_entry(conn, agent_id=agent["id"], status="working", summary="x")
repo.upsert_checkmark_row(conn, agent_id=agent["id"], status="working")
assert repo.delete_agent(conn, "p", "api") is True
assert repo.get_agent_by_name(conn, "p", "api") is None
assert repo.get_checkmark(conn, agent["id"]) is None
assert repo.get_log(conn, agent["id"]) == []
def test_enqueue_get_and_list_command(conn):
repo.create_project(conn, "p", "/tmp/p")
cmd = repo.enqueue_command(
+86
View File
@@ -95,6 +95,53 @@ def test_approve_command_records_operator_verdict_with_head_sha(env, fake_gitops
assert ap["approved_sha"] == fake_gitops["sha"] # read from the agent's working dir
def test_mise_init_command_spawns_bootstrap_agent(env, monkeypatch):
_seed_project()
calls = {}
def fake_spawn(project_id, name, **kw):
calls.update(project_id=project_id, name=name, **kw)
return {"id": 7, "name": name, "working_dir": "/tmp/p/mise-init"}
monkeypatch.setattr(spawn, "spawn", fake_spawn)
cmd = _enqueue(type="mise_init", project_id="p")
assert worker.drain("w") == 1
done = _get(cmd["id"])
assert done["status"] == "done"
assert done["result"]["agent_id"] == 7
assert done["result"]["name"] == "mise-init"
# Launched with the test gate off and the bootstrap marker on, with the default task.
assert calls["require_tests"] is False
assert calls["mise_init"] is True
assert ".mise.toml" in calls["task"]
def test_mise_init_command_honors_payload_overrides(env, monkeypatch):
_seed_project()
calls = {}
def fake_spawn(project_id, name, **kw):
calls.update(name=name, **kw)
return {"id": 8, "name": name, "working_dir": "/tmp/p/x"}
monkeypatch.setattr(spawn, "spawn", fake_spawn)
cmd = _enqueue(
type="mise_init", project_id="p", payload={"name": "boot", "task": "custom task"}
)
worker.drain("w")
assert _get(cmd["id"])["status"] == "done"
assert calls["name"] == "boot"
assert calls["task"] == "custom task"
def test_mise_init_without_project_is_failed(env):
cmd = _enqueue(type="mise_init")
assert worker.drain("w") == 1
assert _get(cmd["id"])["status"] == "failed"
def test_poll_ci_command_returns_summary(env, monkeypatch):
_seed_project()
summary = {"checked": 0, "resolved": 0, "pending": 0}
@@ -164,6 +211,45 @@ def test_bad_command_is_recorded_failed_not_raised(env):
assert "agent name" in failed["error"]
def test_capture_agent_output_snapshots_working_agents(env, monkeypatch):
with get_engine().begin() as conn:
repo.create_project(conn, "p", "/tmp/p")
agent = repo.create_agent(conn, "p", "api", "/tmp/p/api", status="working")
monkeypatch.setattr(worker.tmux, "has_session", lambda name: True)
monkeypatch.setattr(
worker.tmux, "capture_pane", lambda name, escapes=False: "boot\nTheme picker\n\n\n"
)
assert worker.capture_agent_output() == 1
with get_engine().begin() as conn:
row = repo.get_agent_by_id(conn, agent["id"])
# The tail is stored with trailing blank lines trimmed.
assert row["last_output"] == "boot\nTheme picker"
assert row["output_at"] is not None
def test_capture_agent_output_skips_dead_sessions_and_nonworking(env, monkeypatch):
with get_engine().begin() as conn:
repo.create_project(conn, "p", "/tmp/p")
repo.create_agent(conn, "p", "gone", "/tmp/p/gone", status="working")
done = repo.create_agent(conn, "p", "done", "/tmp/p/done", status="done")
captured = []
monkeypatch.setattr(worker.tmux, "has_session", lambda name: False)
monkeypatch.setattr(
worker.tmux,
"capture_pane",
lambda name, escapes=False: captured.append(name) or "x",
)
# The working agent's session is dead (skipped); the done agent isn't queried at all.
assert worker.capture_agent_output() == 0
assert captured == []
with get_engine().begin() as conn:
assert repo.get_agent_by_id(conn, done["id"])["last_output"] is None
def test_drain_processes_multiple_then_stops(env, monkeypatch):
_seed_project()
monkeypatch.setattr(poller, "sweep", lambda project_id=None: {"checked": 0})