mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-31 19:26:25 +00:00
fix(mise-init): unblock the bootstrap agent + surface live agent output
The mise-init agent wedged on launch and the UI reported it green. Three distinct problems, fixed together: 1. Onboarding wedge (the proximate bug). A freshly-installed claude opens interactive setup — theme picker, then a folder-trust prompt — before the REPL. A detached tmux agent has no one to answer it, so it sat on the theme picker forever while agents.status said 'working'. New control.claude_config.ensure_onboarded() marks onboarding complete and trusts the working dir in ~/.claude.json (merge-only, so the login flow's oauthAccount survives); spawn() calls it before launching. 2. Config-name gate. control/mise.py only recognized `.mise.toml`, so a repo shipping `mise.toml` (no dot) — or config under `.config/mise/` — failed the [tasks.test] gate even when healthy. It now accepts the filenames mise itself reads and scans them all for the test task. 3. "Done" != done (the design gap). A spawned agent's real state lives in its tmux pane, but the socket is control-container-only, so the API couldn't see it. The worker now snapshots each working agent's pane tail (last ~40 lines) into two new agents columns (last_output, output_at, migration 0007) on its existing poll loop; the API serializes them and AgentsSection renders a live-output <pre> under each running agent. A wedged agent now shows the theme picker instead of a misleading green badge. Tests: home-dir writes are isolated to tmp in conftest; added coverage for claude_config seeding/merge, the mise filename set, the worker capture (including dead-session skip), and the API serialization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BxKY28XKCM6o4ag3nmaVsZ
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -30,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -128,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 @@
|
||||
<!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-4aea2dcf515b45ea.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-4aea2dcf515b45ea.js\"],\"default\",1]\n6:I[4707,[],\"\"]\n7:I[6423,[],\"\"]\n9:I[1060,[],\"\"]\na:[]\n0:[\"$\",\"$L2\",null,{\"buildId\":\"j4wMy4OdPdBURLtWQmeHO\",\"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>
|
||||
@@ -1,7 +1,7 @@
|
||||
2:I[9107,[],"ClientPageRoot"]
|
||||
3:I[9859,["931","static/chunks/app/page-4aea2dcf515b45ea.js"],"default",1]
|
||||
3:I[9859,["931","static/chunks/app/page-a511d608db773c67.js"],"default",1]
|
||||
4:I[4707,[],""]
|
||||
5:I[6423,[],""]
|
||||
0:["j4wMy4OdPdBURLtWQmeHO",[[["",{"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
|
||||
|
||||
@@ -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
|
||||
+43
-14
@@ -1,9 +1,13 @@
|
||||
"""The ``.mise.toml`` gate — one source of truth for "does this project define the
|
||||
canonical ``[tasks.test]`` task".
|
||||
"""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
|
||||
@@ -11,19 +15,44 @@ 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 mise_path(working_dir: str) -> str:
|
||||
return os.path.join(working_dir, ".mise.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 ``.mise.toml`` exists and defines a ``[tasks.test]`` task."""
|
||||
path = mise_path(working_dir)
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
data = tomllib.load(fh)
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
return False
|
||||
return "test" in (data.get("tasks") or {})
|
||||
"""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
|
||||
|
||||
@@ -14,7 +14,17 @@ import os
|
||||
from ..config import get_settings
|
||||
from ..db import repository as repo
|
||||
from ..db.engine import connection
|
||||
from . import credentials, forge, gitops, mise, reposync, settings_gen, tmux, worktree
|
||||
from . import (
|
||||
claude_config,
|
||||
credentials,
|
||||
forge,
|
||||
gitops,
|
||||
mise,
|
||||
reposync,
|
||||
settings_gen,
|
||||
tmux,
|
||||
worktree,
|
||||
)
|
||||
|
||||
|
||||
class SpawnError(Exception):
|
||||
@@ -22,15 +32,15 @@ class SpawnError(Exception):
|
||||
|
||||
|
||||
def require_test_task(working_dir: str) -> None:
|
||||
"""Hard gate: refuse to spawn unless ``.mise.toml`` defines ``[tasks.test]``."""
|
||||
if not os.path.exists(mise.mise_path(working_dir)):
|
||||
"""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"
|
||||
)
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -163,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)
|
||||
|
||||
@@ -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):
|
||||
@@ -313,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).
|
||||
|
||||
@@ -335,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:
|
||||
@@ -352,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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -84,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"),
|
||||
|
||||
@@ -26,7 +26,7 @@ def _mise_init_blocker(working_dir: str) -> str | None:
|
||||
file is committed (clean tree) and pushed (no commits ahead of, and an, upstream).
|
||||
"""
|
||||
if not mise.has_test_task(working_dir):
|
||||
return "`.mise.toml` does not yet define a [tasks.test] task"
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -89,7 +101,7 @@ def test_spawn_still_gates_without_mise_init_flag(env, fake_tmux):
|
||||
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.toml"):
|
||||
with pytest.raises(spawn.SpawnError, match="no mise config"):
|
||||
spawn.spawn("proj", "api")
|
||||
assert fake_tmux["calls"]["new_session"] == []
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -211,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})
|
||||
|
||||
Reference in New Issue
Block a user