fix job web
This commit is contained in:
parent
807f549908
commit
6fb25edf0a
11
.env.example
11
.env.example
|
|
@ -24,6 +24,17 @@ LAZYBOY_COMPUTER_SUDO=false
|
|||
# Linux only (optional): point this at the host's LXCFS root to make htop/free
|
||||
# report the per-Agent cgroup quota. Leave the default empty directory on macOS.
|
||||
LAZYBOY_LXCFS_ROOT=./data/lxcfs
|
||||
# 任務長度政策:不再用固定輪數掐掉任務。正常任務一路做到驗證完成,只有
|
||||
# 真的鬼打牆(同一個動作重複、同一個錯誤一直失敗、很久沒有新的成功)才會被
|
||||
# 提示、接著暫停等你決定;最後兩個是防迴圈失控烧 token 的保險絲,不是額度。
|
||||
# soft turns:第 60 輪起,之後每 soft every 輪請模型自我交代「已完成/還缺/下一步」
|
||||
# cap turns / hard minutes:最後防火牆,正常任務不該碰到
|
||||
LAZYBOY_RUN_SOFT_TURNS=60
|
||||
LAZYBOY_RUN_SOFT_EVERY=120
|
||||
LAZYBOY_RUN_CAP_TURNS=1000
|
||||
LAZYBOY_RUN_SOFT_MINUTES=75
|
||||
LAZYBOY_RUN_HARD_MINUTES=240
|
||||
|
||||
LAZYBOY_MEMORY_ENABLED=true
|
||||
LAZYBOY_MEMORY_MODEL_CACHE=./data/fastembed
|
||||
LAZYBOY_MEMORY_TOP_K=8
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "serd
|
|||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||
rig-core = "0.42"
|
||||
async-trait = "0.1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "io-util", "fs", "signal", "time"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "io-util", "fs", "signal", "time", "sync"] }
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "fs"] }
|
||||
tracing = "0.1"
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ The guides below are currently in Traditional Chinese.
|
|||
| [Architecture](./docs/architecture.md) | Task flow, system architecture, computer lifecycle |
|
||||
| [Interactive diagram](./docs/workflow.html) | Zoomable, searchable HTML chart; download and open |
|
||||
| [Operations](./docs/operations.md) | Resources, env vars, security, site checks, sudo |
|
||||
| [Agent experience](./docs/agent-experience.md) | Turn limits, persistent terminal, live chat |
|
||||
| [Development](./docs/development.md) | Local dev, checks and tests, directory layout |
|
||||
| [Env example](./.env.example) | Environment variables and defaults |
|
||||
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ npm run dev
|
|||
| [架構與流程](./docs/architecture.md) | 任務流程圖、系統架構、電腦生命週期狀態機 |
|
||||
| [互動流程圖](./docs/workflow.html) | 可縮放、搜尋的 HTML 圖表;下載後開啟 |
|
||||
| [部署與操作](./docs/operations.md) | 資源、環境變數、安全設定、網站驗證、sudo |
|
||||
| [AI 使用體驗](./docs/agent-experience.md) | 輪次政策、持久終端機、聊天即時推送 |
|
||||
| [開發指南](./docs/development.md) | 本機開發、檢查與測試、目錄結構 |
|
||||
| [設定範例](./.env.example) | 環境變數與預設值 |
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -15,7 +15,7 @@
|
|||
.composer textarea{flex:1;min-height:42px;max-height:140px;resize:none;border:0;outline:0;background:transparent;color:var(--ink);padding:11px 4px}
|
||||
.composer-plus{background:transparent;color:var(--muted)}
|
||||
.composer svg{width:18px}
|
||||
.messages{min-width:0;padding-bottom:128px;padding-left:var(--chat-gutter);padding-right:var(--chat-gutter)}
|
||||
.messages{min-width:0;padding-bottom:24px;padding-left:var(--chat-gutter);padding-right:var(--chat-gutter)}
|
||||
.message{box-sizing:border-box;width:var(--chat-col);max-width:none;margin-inline:auto;overflow-wrap:anywhere}
|
||||
.message>span{max-width:82%}
|
||||
.thinking-row{display:flex;align-items:center;gap:10px;width:min(760px,100%);margin:0}
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
.thinking-dots i{width:5px;height:5px;border-radius:50%;background:var(--muted);animation:thinking-dot 1.15s ease-in-out infinite}
|
||||
.thinking-dots i:nth-child(2){animation-delay:.16s}
|
||||
.thinking-dots i:nth-child(3){animation-delay:.32s}
|
||||
.composer-dock{position:absolute;left:0;right:0;bottom:0;z-index:6;display:flex;flex-direction:column;align-items:center;gap:20px;padding:28px var(--chat-gutter) 20px;background:var(--main);pointer-events:none}
|
||||
.composer-dock{position:relative;z-index:6;display:flex;flex-direction:column;align-items:center;gap:20px;padding:28px var(--chat-gutter) 20px;background:var(--main);pointer-events:none;flex:none;width:100%}
|
||||
.composer-dock:before{content:"";position:absolute;left:0;right:0;bottom:100%;height:32px;background:linear-gradient(180deg,transparent,var(--main));pointer-events:none}
|
||||
.composer-dock>*{box-sizing:border-box;pointer-events:auto;width:var(--chat-col);max-width:760px;margin-inline:auto}
|
||||
.composer{position:relative;left:auto;right:auto;bottom:auto;width:var(--chat-col);min-height:62px;align-items:center;padding:9px 10px;transform:none}
|
||||
|
|
|
|||
|
|
@ -69,21 +69,10 @@
|
|||
.computer-power-menu small{display:block;padding:8px 10px 6px;border-top:1px solid var(--line);color:var(--muted);font-size:11px;line-height:1.6}
|
||||
.computer-overlay .computer-power-menu{top:calc(100% + 8px);bottom:auto}
|
||||
|
||||
/* Status belongs outside the remote pixels, including during connection/handoff. */
|
||||
/* HUD belongs outside the remote pixels, including during connection/handoff. */
|
||||
.computer-overlay{top:var(--visible-top,0px);bottom:auto;height:var(--visible-height,100dvh)}
|
||||
.computer-overlay>header{flex-shrink:0}
|
||||
.computer-hud{position:static;inset:auto;display:block;padding:7px 12px;border-radius:0;background:var(--surface);flex:none}
|
||||
.computer-hud .computer-signal{display:none}
|
||||
.overlay-error{position:static;transform:none;flex:none;margin:0 8px 8px}
|
||||
.run-status{flex:none;min-width:0;padding:8px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);font-size:12px;max-height:26vh;overflow:auto;margin:6px 0}
|
||||
.run-status-heading{display:flex;gap:8px;align-items:center}
|
||||
.run-status-heading small{margin-left:auto;color:var(--muted)}
|
||||
.run-status p{margin:5px 0;overflow-wrap:anywhere}
|
||||
.run-status summary{cursor:pointer;color:var(--muted);padding:4px 0}
|
||||
.run-status ol{padding-left:20px;max-height:180px;overflow:auto}
|
||||
.run-status li{padding:4px 0;overflow-wrap:anywhere}
|
||||
.run-status time{color:var(--muted)}
|
||||
.run-status.has-error,.run-status-issue{color:var(--danger-soft)}
|
||||
.run-status-wait{color:var(--muted)}
|
||||
.computer-overlay>.run-status{margin:6px 8px;max-height:22vh}
|
||||
@media(max-width:700px){.computer-overlay>.run-status{max-height:18vh}.overlay-screen{padding:4px}.computer-overlay>header{min-height:44px;gap:4px}.computer-overlay>header>div{gap:5px}.computer-overlay>header strong{font-size:12px}}
|
||||
@media(max-width:700px){.overlay-screen{padding:4px}.computer-overlay>header{min-height:44px;gap:4px}.computer-overlay>header>div{gap:5px}.computer-overlay>header strong{font-size:12px}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
// Live session feed. The API keeps a durable, ordered event cursor per session
|
||||
// and streams it as server-sent events, so the browser does not have to guess
|
||||
// when the transcript changed. This module owns only the transport half:
|
||||
// subscribe, decode, drop replays, and collapse a burst into one refresh.
|
||||
|
||||
export type SessionEventKind =
|
||||
| "message.created"
|
||||
| "run.started"
|
||||
| "run.paused"
|
||||
| "run.failed"
|
||||
| "run.completed"
|
||||
| "session.cleared";
|
||||
|
||||
/**
|
||||
* Kinds the app listens for. An SSE source cannot subscribe to "everything", so
|
||||
* a kind added on the server needs to appear here to be delivered live; the
|
||||
* caller's safety poll still picks anything new up within a few seconds.
|
||||
*/
|
||||
export const SESSION_EVENT_TYPES: SessionEventKind[] = [
|
||||
"message.created",
|
||||
"run.started",
|
||||
"run.paused",
|
||||
"run.failed",
|
||||
"run.completed",
|
||||
"session.cleared",
|
||||
];
|
||||
|
||||
export interface SessionEvent {
|
||||
kind: string;
|
||||
/** Server cursor (`events.seq`); 0 when the source sent no id. */
|
||||
id: number;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The slice of `EventSource` this module uses, so tests can fake the socket. */
|
||||
export interface EventSourceLike {
|
||||
addEventListener(type: string, listener: (event: { data?: string; lastEventId?: string }) => void): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface LiveFeed {
|
||||
/** Stop listening. Safe to call more than once. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export function sessionEventsUrl(sessionId: string): string {
|
||||
return `/api/sessions/${encodeURIComponent(sessionId)}/events`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the event stream for one session. `onStatus` reports whether the live
|
||||
* path is up, which lets the caller decide how often it needs to poll as a
|
||||
* backup; it stays silent about the browser's own reconnect attempts.
|
||||
*/
|
||||
export function subscribeToSession(
|
||||
sessionId: string,
|
||||
onEvent: (event: SessionEvent) => void,
|
||||
options: {
|
||||
source?: (url: string) => EventSourceLike;
|
||||
onStatus?: (connected: boolean) => void;
|
||||
} = {},
|
||||
): LiveFeed {
|
||||
const open = options.source ?? ((url: string) => new EventSource(url) as unknown as EventSourceLike);
|
||||
const source = open(sessionEventsUrl(sessionId));
|
||||
// A reconnect replays from the last id the browser saw, and the server replays
|
||||
// from its cursor, so the same event can legitimately arrive twice. Applying
|
||||
// it twice is only harmless work, but the cursor is free and keeps the caller
|
||||
// honest about doing real work once per event.
|
||||
let cursor = 0;
|
||||
const receive = (kind: string) => (event: { data?: string; lastEventId?: string }) => {
|
||||
const id = Number(event.lastEventId);
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
if (id <= cursor) return;
|
||||
cursor = id;
|
||||
}
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed: unknown = event.data ? JSON.parse(event.data) : {};
|
||||
if (parsed && typeof parsed === "object") payload = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
// A malformed frame is a dropped frame: the next event, or the poll,
|
||||
// delivers the same state.
|
||||
}
|
||||
onEvent({ kind, id: Number.isFinite(id) ? id : 0, payload });
|
||||
};
|
||||
for (const kind of SESSION_EVENT_TYPES) source.addEventListener(kind, receive(kind));
|
||||
source.addEventListener("open", () => options.onStatus?.(true));
|
||||
source.addEventListener("error", () => options.onStatus?.(false));
|
||||
let closed = false;
|
||||
return {
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
source.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface Coalescer {
|
||||
/** Ask for a run; a request inside an open window joins the pending one. */
|
||||
kick(): void;
|
||||
/** Forget a pending run, e.g. because the session was switched away from. */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a burst of "something changed" into a single run. One turn can move
|
||||
* several events at once, and each of them would otherwise fetch the whole
|
||||
* transcript again.
|
||||
*/
|
||||
export function createCoalescer(
|
||||
run: () => void,
|
||||
windowMs: number,
|
||||
schedule: (callback: () => void, ms: number) => number = (callback, ms) => setTimeout(callback, ms) as unknown as number,
|
||||
dismiss: (handle: number) => void = handle => clearTimeout(handle),
|
||||
): Coalescer {
|
||||
let handle: number | null = null;
|
||||
return {
|
||||
kick() {
|
||||
if (handle !== null) return;
|
||||
handle = schedule(() => {
|
||||
handle = null;
|
||||
run();
|
||||
}, windowMs);
|
||||
},
|
||||
cancel() {
|
||||
if (handle === null) return;
|
||||
dismiss(handle);
|
||||
handle = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -2,17 +2,6 @@ import type { zhTW } from "./zh-TW";
|
|||
|
||||
/** English UI copy. Keys must stay in lockstep with zh-TW. */
|
||||
export const en: { [K in keyof typeof zhTW]: string } = {
|
||||
liveCompleted: "Completed",
|
||||
liveFailed: "Task incomplete: an error occurred",
|
||||
liveCancelled: "Stopped: cancelled or interrupted",
|
||||
liveInput: "Your reply is needed to continue",
|
||||
liveTakeover: "Waiting for you; resumes after handoff",
|
||||
liveRunning: "Working",
|
||||
liveLoading: "Loading run status…",
|
||||
liveDisconnected: "Status unavailable; reconnecting. This does not mean the AI has stopped.",
|
||||
liveSlow: "This step has not returned yet. Timeouts, retries and any stop reason will be reported.",
|
||||
liveHistory: "Actions, errors and timings",
|
||||
|
||||
search: "Search",
|
||||
sharedComputer: "Shared computer",
|
||||
privateComputer: "Private computer",
|
||||
|
|
@ -467,6 +456,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
|
|||
aiTimeout: "The model timed out (150 seconds).",
|
||||
resumeMidTask: "Stopped halfway — your call",
|
||||
resumeBudget: "Turn budget spent, result unverified",
|
||||
resumeLoop: "Kept repeating itself — needs a nudge",
|
||||
resumeProgress: "{turns}/{limit} turns",
|
||||
resumeContinue: "Keep going",
|
||||
resumeStop: "Stop here",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,5 @@
|
|||
/** Traditional Chinese UI copy. Keep keys stable when adding another locale. */
|
||||
export const zhTW = {
|
||||
liveCompleted: "已完成",
|
||||
liveFailed: "任務未完成:發生錯誤",
|
||||
liveCancelled: "已停止:使用者取消或工作被中斷",
|
||||
liveInput: "需要你的回覆才能繼續",
|
||||
liveTakeover: "等待你操作,交回後繼續",
|
||||
liveRunning: "正在執行",
|
||||
liveLoading: "正在取得執行狀態…",
|
||||
liveDisconnected: "暫時無法取得狀態,正在重新連線;這不代表 AI 已停止。",
|
||||
liveSlow: "這一步仍未回報結果。系統會處理逾時並顯示重試或停止原因。",
|
||||
liveHistory: "查看做了什麼、錯誤與耗時",
|
||||
|
||||
search: "搜尋", sharedComputer: "共用電腦", privateComputer: "私人電腦",
|
||||
stopped: "已關閉", booting: "啟動中", running: "執行中", suspended: "休眠中", error: "發生錯誤",
|
||||
openComputer: "開啟電腦", stopTask: "停止任務", takeControl: "取得控制", takeOverNow: "接手操作", releaseControl: "釋放控制", done: "完成", skip: "略過",
|
||||
|
|
@ -162,6 +151,7 @@ export const zhTW = {
|
|||
aiTimeout: "AI 回應逾時(150 秒)",
|
||||
resumeMidTask: "做到一半,需要你決定",
|
||||
resumeBudget: "輪次用盡,尚未確認完成",
|
||||
resumeLoop: "卡在同一個動作,需要你給方向",
|
||||
resumeProgress: "{turns}/{limit} 輪",
|
||||
resumeContinue: "繼續",
|
||||
resumeStop: "就到這裡",
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@
|
|||
.chat-panel{--chat-gutter:max(34px,7vw);--chat-col:min(760px,100%)}
|
||||
|
||||
|
||||
.chat-panel:has(.composer-dock.has-status) .messages{padding-bottom:188px}
|
||||
.composer-dock .error-banner,.composer-dock .queue-hint{position:relative;left:auto;bottom:auto;transform:none;z-index:auto}
|
||||
.composer-dock .queue-hint{text-align:center}
|
||||
|
||||
|
||||
.spinner{width:17px;height:17px;animation:spin .8s linear infinite}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
@media(max-width:1050px){.app-shell{grid-template-columns:250px 1fr}.computer-panel{display:none}}
|
||||
@media(max-width:700px){.app-shell{display:block}.sidebar{position:fixed;z-index:40;inset:0 auto 0 0;width:min(300px,88vw);transform:translateX(-105%);transition:.2s;box-shadow:20px 0 60px #000}.sidebar.open{transform:none}.chat-panel{height:100%}.mobile-menu{display:inline-flex}.topbar{padding:0 12px}.messages{padding:24px 18px 130px}.composer{left:12px;right:12px}.computer-overlay>header{height:auto;min-height:64px;flex-wrap:wrap;padding:10px}.computer-overlay>header>div:last-child{flex-wrap:wrap;justify-content:flex-end}.overlay-screen{padding:8px}.mode-grid{grid-template-columns:1fr}}
|
||||
@media(max-width:700px){.app-shell{display:block}.sidebar{position:fixed;z-index:40;inset:0 auto 0 0;width:min(300px,88vw);transform:translateX(-105%);transition:.2s;box-shadow:20px 0 60px #000}.sidebar.open{transform:none}.chat-panel{height:100%}.mobile-menu{display:inline-flex}.topbar{padding:0 12px}.messages{padding:24px 18px 16px}.composer{left:12px;right:12px}.computer-overlay>header{height:auto;min-height:64px;flex-wrap:wrap;padding:10px}.computer-overlay>header>div:last-child{flex-wrap:wrap;justify-content:flex-end}.overlay-screen{padding:8px}.mode-grid{grid-template-columns:1fr}}
|
||||
@media(max-width:1200px){.app-shell{grid-template-columns:230px minmax(0,1fr) 330px}}
|
||||
@media(max-width:1050px){.app-shell{grid-template-columns:250px minmax(0,1fr)}}
|
||||
@media(max-width:700px){.chat-panel{--chat-gutter:18px}.app-shell{display:block}.message{width:100%}.message>span,.message>.message-body,.message-stack{max-width:90%}.composer-dock{padding:20px var(--chat-gutter) 16px}.composer{width:var(--chat-col);min-height:60px}.composer-files{padding:2px 4px 8px 8px}.file-card{max-width:100%;height:52px}.file-card-remove{flex-basis:32px;width:32px;height:32px}.message.with-files .msg-attachments{max-width:100%}.messages{padding-bottom:118px}.chat-panel:has(.composer-dock.has-status) .messages{padding-bottom:178px}}
|
||||
@media(max-width:700px){.chat-panel{--chat-gutter:18px}.app-shell{display:block}.message{width:100%}.message>span,.message>.message-body,.message-stack{max-width:90%}.composer-dock{padding:20px var(--chat-gutter) 16px}.composer{width:var(--chat-col);min-height:60px}.composer-files{padding:2px 4px 8px 8px}.file-card{max-width:100%;height:52px}.file-card-remove{flex-basis:32px;width:32px;height:32px}.message.with-files .msg-attachments{max-width:100%}.messages{padding-bottom:16px}}
|
||||
@media(prefers-reduced-motion:reduce){.avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after,.thinking-dots i{animation:none!important}}
|
||||
@media(max-width:1200px){.app-shell.right-open{grid-template-columns:230px minmax(0,1fr) 340px}.app-shell.right-collapsed{grid-template-columns:230px minmax(0,1fr) 52px}}
|
||||
@media(max-width:1050px){
|
||||
|
|
|
|||
|
|
@ -370,48 +370,3 @@ export function RunProbe({ runId, align = "start", label, children }: { runId?:
|
|||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/** A persistent readout: errors and waiting reasons stay visible after the run stops. */
|
||||
export function RunStatus({ runId }: { runId?: string | null }) {
|
||||
const [snapshot, setSnapshot] = useState<RunActivity | null>(null);
|
||||
const [stale, setStale] = useState(false);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
setSnapshot(null); setStale(false);
|
||||
if (!runId) return;
|
||||
let stopped = false;
|
||||
let timer = 0;
|
||||
const load = async () => {
|
||||
try {
|
||||
const next = await api<RunActivity>(`/api/runs/${runId}/activity?limit=30`, {signal: AbortSignal.timeout(10000)});
|
||||
if (stopped) return;
|
||||
setSnapshot(next); setStale(false); setNow(Date.now());
|
||||
} catch { if (!stopped) setStale(true); }
|
||||
// Poll sequentially; also notice a paused/failed run being resumed elsewhere.
|
||||
if (!stopped) timer = window.setTimeout(() => void load(), POLL_MS);
|
||||
};
|
||||
void load();
|
||||
return () => { stopped = true; window.clearTimeout(timer); };
|
||||
}, [runId]);
|
||||
if (!runId) return null;
|
||||
const entries = snapshot?.activity || [];
|
||||
const latest = entries.at(-1);
|
||||
const active = snapshot && ["queued", "leased", "running"].includes(snapshot.status);
|
||||
const age = snapshot?.stepAt ? Math.max(0, now - Date.parse(snapshot.stepAt)) : 0;
|
||||
const state = snapshot?.status || "loading";
|
||||
const title = t(({completed:"liveCompleted", failed:"liveFailed", cancelled:"liveCancelled", waiting_input:"liveInput", waiting_takeover:"liveTakeover", queued:"monitorQueued", running:"liveRunning", leased:"liveRunning"} as Record<string, MessageKey>)[state] || "liveLoading");
|
||||
const issue = [...entries].reverse().find(entry => entry.kind === "retry" || entry.status === "error" || entry.status === "timed_out");
|
||||
return <section className={`run-status ${snapshot?.error ? "has-error" : ""}`} aria-label={t("monitorTitle")}>
|
||||
<div className="run-status-heading" role="status"><strong>{title}</strong>{active && age > 0 ? <small>{shortDuration(age)}</small> : null}</div>
|
||||
{stale ? <p role="alert">{t("liveDisconnected")}</p> : null}
|
||||
{snapshot?.error ? <p role="alert">{snapshot.error.headline} {snapshot.error.action}</p> : <p>{active ? snapshot?.step || t("liveLoading") : latest ? trailText(latest) : t("monitorEmpty")}</p>}
|
||||
{active && age >= 20000 ? <p className="run-status-wait">{t("liveSlow")}</p> : null}
|
||||
{active && latest?.kind === "model" && latest.text ? <p>{latest.text}</p> : null}
|
||||
{issue && !snapshot?.error ? <p className="run-status-issue">{trailText(issue)}</p> : null}
|
||||
<details><summary>{t("liveHistory")}</summary>
|
||||
<ol>{entries.map(entry => <li key={entry.id}><time>{clockOf(entry.createdAt)}</time> {trailText(entry)}</li>)}</ol>
|
||||
<button type="button" onClick={() => void copyLog(logDump(snapshot, entries))}>{t("monitorCopy")}</button>
|
||||
</details>
|
||||
</section>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use lazyboy_harness::execution::{
|
|||
VERIFY_BEFORE_DONE, asks_for_input, goal_outcome, goal_request, stop_reason, GOAL_CONTINUE,
|
||||
GOAL_INSTRUCTIONS,
|
||||
};
|
||||
use lazyboy_harness::policy::{ActionObserved, LoopGuard, RunPolicy, Verdict};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
|
|
@ -42,6 +43,8 @@ Use tools only when the user wants something done on the computer: open a site,
|
|||
4) Opening a local file or non-browser app: use open_path or launch_app.
|
||||
5) Native GUI with no DOM (dialogs, file manager, XFCE): use computer_act by element id. Those ids are AT-SPI controls, not window boxes.
|
||||
|
||||
The shell is one real terminal that stays open between calls: same directory, same exports, same background jobs. cd where the work is and stay there. A long-running job (server, build, download) comes back as status=running and keeps going — read it with log_lines, stop it with keys \"C-c\", and never type a second command into a terminal that is still busy.
|
||||
|
||||
When you ARE using the desktop: the human watches the same live screen. Only the latest screenshot you received is current; they may have interacted since. Call computer_observe before coordinate clicks, after navigation, when the outcome is uncertain, and before describing what is on screen. Never guess the screen state from files, history or memory. Never kill or restart the browser, display, or desktop processes; if the browser tool reports it is unavailable, use computer_observe / computer_act on the existing window instead.
|
||||
|
||||
When you use the browser tool:
|
||||
|
|
@ -284,6 +287,11 @@ pub async fn send(
|
|||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
tx.commit().await.map_err(|error| error.to_string())?;
|
||||
// The user message and its event were written in the transaction above
|
||||
// rather than through `sessions::append_event`, so this is where the open
|
||||
// chat windows get told to read it: every other tab sees the message without
|
||||
// waiting for its next poll.
|
||||
state.wakes.wake(thread_id);
|
||||
if let Err(error) =
|
||||
crate::attachments::stage_for_bots(state, actor, &member_ids, &decoded).await
|
||||
{
|
||||
|
|
@ -304,8 +312,15 @@ type RetryCandidateRow = (String, String, String, String, String, String);
|
|||
pub async fn worker_loop(state: AppState) {
|
||||
let inflight = Arc::new(tokio::sync::Semaphore::new(16));
|
||||
let lease_owner = format!("api-{}", Uuid::new_v4());
|
||||
// Without a knock the loop sits on its 200 ms timer before it can see a run
|
||||
// that was queued a moment ago; going straight to the claim query is what
|
||||
// makes the thinking indicator follow the message instead of the timer.
|
||||
let mut wakes = state.wakes.subscribe();
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
tokio::select! {
|
||||
_ = wakes.wait_any() => {}
|
||||
_ = tokio::time::sleep(Duration::from_millis(200)) => {}
|
||||
}
|
||||
let interrupted: Vec<(String, String, String)> = sqlx::query_as(
|
||||
"WITH doomed AS (
|
||||
UPDATE runs SET status='failed',error=$1,completed_at=now(),
|
||||
|
|
@ -752,6 +767,14 @@ async fn execute_run(
|
|||
// One verification demand per run: enough to catch "I'm done" that isn't,
|
||||
// without trapping the model in an endless self-audit.
|
||||
let mut verified = false;
|
||||
// Round policy. A run gets no quota; it gets a watcher. `LoopGuard` looks
|
||||
// for the shape of going in circles - the same action, the same failure, a
|
||||
// long stretch with nothing new succeeding - and only the last-resort
|
||||
// breakers use turns or wall clock. Wall clock is measured per attempt: a
|
||||
// run that parked for three days waiting for a human starts fresh.
|
||||
let run_policy = RunPolicy::from_env();
|
||||
let mut guard = LoopGuard::with_watch(run_policy, turns);
|
||||
let attempt_clock = std::time::Instant::now();
|
||||
let memory = if ctx.memory_enabled {
|
||||
match state
|
||||
.memory
|
||||
|
|
@ -907,6 +930,55 @@ async fn execute_run(
|
|||
)
|
||||
.await;
|
||||
}
|
||||
// Watcher first: a checkpoint question belongs to the model turn that
|
||||
// is about to happen, and a derailment or breaker parks the run before
|
||||
// another model call is paid for.
|
||||
match guard.on_turn(turns, attempt_clock.elapsed()) {
|
||||
Verdict::Continue => {}
|
||||
Verdict::Reflect(text) => {
|
||||
tracing::info!(run_id, turn = turns, "round policy: coaching the run");
|
||||
crate::monitor::record(
|
||||
state,
|
||||
run_id,
|
||||
"notice",
|
||||
json!({
|
||||
"turn": turns,
|
||||
"text": format!("系統檢查點:{}", crate::monitor::snippet(&text, 160)),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Message::User { content } = &mut pending {
|
||||
content.push(UserContent::text(text));
|
||||
}
|
||||
}
|
||||
Verdict::Halt { reason, note } => {
|
||||
let limit = match execution_mode {
|
||||
ExecutionMode::Bounded(limit) => limit,
|
||||
ExecutionMode::Goal => 0,
|
||||
};
|
||||
return pause_for_answer(
|
||||
state,
|
||||
bot_id,
|
||||
thread_id,
|
||||
run_id,
|
||||
lease_owner,
|
||||
&ctx,
|
||||
PauseRequest {
|
||||
reason,
|
||||
draft: &final_text,
|
||||
history: &history,
|
||||
turns,
|
||||
steering_seq,
|
||||
limit,
|
||||
note: Some(note),
|
||||
screenshots,
|
||||
screenshot_bytes,
|
||||
used_gui,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
drop_history_screenshots(&mut history, &pending);
|
||||
set_run_progress(state, run_id, MODEL_STEP, turns, turn_limit).await;
|
||||
let model_started = std::time::Instant::now();
|
||||
|
|
@ -1080,6 +1152,7 @@ async fn execute_run(
|
|||
turns,
|
||||
steering_seq,
|
||||
limit,
|
||||
note: None,
|
||||
screenshots,
|
||||
screenshot_bytes,
|
||||
used_gui,
|
||||
|
|
@ -1188,6 +1261,7 @@ async fn execute_run(
|
|||
pause = outcome.pause,
|
||||
"tool call"
|
||||
);
|
||||
let status = tool_status(tool_timed_out, outcome.pause, &outcome.text);
|
||||
crate::monitor::record(
|
||||
state,
|
||||
run_id,
|
||||
|
|
@ -1196,12 +1270,63 @@ async fn execute_run(
|
|||
"turn": turns,
|
||||
"name": name.clone(),
|
||||
"step": step.clone(),
|
||||
"status": tool_status(tool_timed_out, outcome.pause, &outcome.text),
|
||||
"status": status,
|
||||
"elapsedMs": tool_started.elapsed().as_millis() as u64,
|
||||
"snippet": crate::monitor::snippet(&outcome.text, 200),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
match guard.on_action(&ActionObserved {
|
||||
name: call.function.name.as_str(),
|
||||
args: &call.function.arguments,
|
||||
label: &step,
|
||||
ok: status == "ok",
|
||||
turn: turns,
|
||||
changes_state: action_changes_state(&name, &call.function.arguments),
|
||||
}) {
|
||||
Verdict::Continue => {}
|
||||
Verdict::Reflect(text) => {
|
||||
tracing::info!(run_id, turn = turns, step = %step, "round policy: coaching mid-turn");
|
||||
crate::monitor::record(
|
||||
state,
|
||||
run_id,
|
||||
"notice",
|
||||
json!({
|
||||
"turn": turns,
|
||||
"text": format!("系統提示:{}", crate::monitor::snippet(&text, 160)),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
results.push(UserContent::text(text));
|
||||
}
|
||||
Verdict::Halt { reason, note } => {
|
||||
let limit = match execution_mode {
|
||||
ExecutionMode::Bounded(limit) => limit,
|
||||
ExecutionMode::Goal => 0,
|
||||
};
|
||||
return pause_for_answer(
|
||||
state,
|
||||
bot_id,
|
||||
thread_id,
|
||||
run_id,
|
||||
lease_owner,
|
||||
&ctx,
|
||||
PauseRequest {
|
||||
reason,
|
||||
draft: &final_text,
|
||||
history: &history,
|
||||
turns,
|
||||
steering_seq,
|
||||
limit,
|
||||
note: Some(note),
|
||||
screenshots,
|
||||
screenshot_bytes,
|
||||
used_gui,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
// xAI rejects images inside tool results. Attach a changed
|
||||
// screenshot as a following user image instead.
|
||||
if let Some(image) = outcome.image {
|
||||
|
|
@ -1322,6 +1447,7 @@ async fn execute_run(
|
|||
turns,
|
||||
steering_seq,
|
||||
limit,
|
||||
note: None,
|
||||
screenshots,
|
||||
screenshot_bytes,
|
||||
used_gui,
|
||||
|
|
@ -1918,6 +2044,9 @@ struct PauseRequest<'a> {
|
|||
turns: u32,
|
||||
steering_seq: i32,
|
||||
limit: u32,
|
||||
/// Concrete evidence from the round policy, shown instead of a generic
|
||||
/// category when the run parked on a loop.
|
||||
note: Option<String>,
|
||||
screenshots: u32,
|
||||
screenshot_bytes: u64,
|
||||
used_gui: bool,
|
||||
|
|
@ -1935,12 +2064,15 @@ async fn pause_for_answer(
|
|||
req: PauseRequest<'_>,
|
||||
) -> Result<(), String> {
|
||||
let draft = req.draft.replace(NEEDS_INPUT_MARKER, "").trim().to_string();
|
||||
let stall = match req.reason {
|
||||
StopReason::BudgetExhausted => "已達本輪執行上限",
|
||||
// The round policy arrives with concrete evidence ("the same click six
|
||||
// times in a row"), which always beats a category name for the human.
|
||||
let stall = req.note.clone().unwrap_or_else(|| match req.reason {
|
||||
StopReason::BudgetExhausted => "已達本輪執行上限".to_string(),
|
||||
StopReason::MidTaskText => {
|
||||
"模型多次未能提供可執行的下一步,系統已要求它重新確認並繼續,但仍無法推進"
|
||||
"模型多次未能提供可執行的下一步,系統已要求它重新確認並繼續,但仍無法推進".to_string()
|
||||
}
|
||||
};
|
||||
StopReason::LoopDetected => "重複同一個動作,任務沒有新的進展".to_string(),
|
||||
});
|
||||
let draft = if draft.is_empty() {
|
||||
match req.reason {
|
||||
StopReason::BudgetExhausted => format!(
|
||||
|
|
@ -1951,6 +2083,10 @@ async fn pause_for_answer(
|
|||
"模型多次未提供可執行的下一步,系統無法確認任務已完成。請補充下一步指示或接管確認現況後繼續。\n\n任務尚未確認完成。停止原因:{}。已保存操作進度;回覆下一步指示或接管確認現況後可繼續。",
|
||||
stall
|
||||
),
|
||||
StopReason::LoopDetected => format!(
|
||||
"我在這個任務上用了 {} 輪,一直在同一個動作上打轉,先停在目前的畫面。要我換個做法繼續嗎?\n\n任務尚未確認完成。停止原因:{}。已保存操作進度;告訴我該怎麼做,或直接接管電腦。",
|
||||
req.turns, stall
|
||||
),
|
||||
}
|
||||
} else if !asks_for_input(req.draft) {
|
||||
// A model's optimistic draft must not hide a harness-detected stall.
|
||||
|
|
@ -2821,7 +2957,22 @@ fn describe_step(name: &str, args: &Value) -> String {
|
|||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
"shell" => short(get("command").or(get("cmd")), 60),
|
||||
"shell" => {
|
||||
// The terminal does four different things; the feed says which.
|
||||
let session = get("session")
|
||||
.filter(|name| !name.trim().is_empty() && *name != "main");
|
||||
let suffix = session.map(|name| format!(" ·{name}")).unwrap_or_default();
|
||||
if args.get("reset").and_then(Value::as_bool).unwrap_or(false) {
|
||||
format!("重開終端機{suffix}")
|
||||
} else if let Some(keys) = get("keys") {
|
||||
format!("輸入 {}{suffix}", short(Some(keys), 20))
|
||||
} else {
|
||||
match get("command").or(get("cmd")).filter(|line| !line.trim().is_empty()) {
|
||||
Some(command) => format!("{}{suffix}", short(Some(command), 60)),
|
||||
None => format!("讀終端機{suffix}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
"wait" => format!(
|
||||
"{}s {}",
|
||||
args.get("seconds")
|
||||
|
|
@ -2957,6 +3108,24 @@ fn tool_status(timed_out: bool, pause: bool, text: &str) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
/// Does this tool call change the world? The round policy only counts
|
||||
/// mutating actions, so polling the screen, waiting, or reading a file can
|
||||
/// never look like a derailment on its own.
|
||||
fn action_changes_state(name: &str, args: &Value) -> bool {
|
||||
match name {
|
||||
"computer_act" | "shell" | "write_file" | "launch_app" | "open_path"
|
||||
| "create_schedule" | "cancel_schedule" | "remember" | "forget_memory" | "use_saved_login"
|
||||
| "request_takeover" => true,
|
||||
"browser" => matches!(
|
||||
args.get("action").and_then(Value::as_str),
|
||||
Some("click") | Some("type") | Some("navigate") | Some("press")
|
||||
),
|
||||
// Observations, waits, reads, and connected-service calls (mcp_*) are
|
||||
// allowed to repeat: they are how a run learns what changed.
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn set_run_step(state: &AppState, run_id: &str, step: &str) {
|
||||
let result = sqlx::query(
|
||||
"UPDATE runs SET checkpoint = COALESCE(checkpoint, '{}'::jsonb)
|
||||
|
|
@ -3136,6 +3305,19 @@ mod tests {
|
|||
describe_step("shell", &json!({"command":"ls\n-la"})),
|
||||
"shell: ls -la"
|
||||
);
|
||||
assert_eq!(describe_step("shell", &json!({})), "shell: 讀終端機");
|
||||
assert_eq!(
|
||||
describe_step("shell", &json!({"keys":"C-c","session":"build"})),
|
||||
"shell: 輸入 C-c ·build"
|
||||
);
|
||||
assert_eq!(
|
||||
describe_step("shell", &json!({"command":"make","session":"main"})),
|
||||
"shell: make"
|
||||
);
|
||||
assert_eq!(
|
||||
describe_step("shell", &json!({"reset":true,"session":"build"})),
|
||||
"shell: 重開終端機 ·build"
|
||||
);
|
||||
assert_eq!(
|
||||
describe_step("mcp_search", &json!({"query":"x"})),
|
||||
"mcp_search"
|
||||
|
|
|
|||
|
|
@ -457,6 +457,16 @@ async fn enqueue(state: &AppState, row: &ScheduleRow, from_tick: bool) -> Result
|
|||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
tx.commit().await.map_err(|error| error.to_string())?;
|
||||
// A scheduled run starts with nobody pressing send, so the transcript line
|
||||
// has to announce itself: without this an open chat stays blind to the run
|
||||
// until it happens to poll.
|
||||
let _ = crate::sessions::append_event(
|
||||
state,
|
||||
&thread_id,
|
||||
"message.created",
|
||||
json!({"id":message_id,"seq":seq,"role":"user","body":body,"runId":run_id}),
|
||||
)
|
||||
.await;
|
||||
Ok(run_id)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ use crate::state::AppState;
|
|||
|
||||
type ApiError = (StatusCode, Json<Value>);
|
||||
|
||||
/// Safety net for the event stream. A wake normally lands within milliseconds,
|
||||
/// so this poll only exists for the cases a wake cannot cover: a reader that
|
||||
/// lagged behind the channel, or a row written outside this process.
|
||||
const EVENT_FALLBACK_POLL: Duration = Duration::from_secs(5);
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
|
|
@ -381,10 +386,14 @@ async fn events(
|
|||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let stream_state = (state, id, actor, after, Vec::<(i32, String, Value)>::new());
|
||||
// Subscribe before the state moves into the stream: taking the subscription
|
||||
// afterwards would open a window in which a commit could knock on a channel
|
||||
// this reader is not listening to yet.
|
||||
let wakes = state.wakes.subscribe();
|
||||
let stream_state = (state, id, actor, after, Vec::<(i32, String, Value)>::new(), wakes);
|
||||
let output = stream::unfold(
|
||||
stream_state,
|
||||
|(state, id, actor, mut after, mut pending)| async move {
|
||||
|(state, id, actor, mut after, mut pending, mut wakes)| async move {
|
||||
loop {
|
||||
if let Some((seq, kind, payload)) = pending.pop() {
|
||||
after = seq;
|
||||
|
|
@ -393,7 +402,10 @@ async fn events(
|
|||
.event(kind)
|
||||
.json_data(payload)
|
||||
.unwrap_or_else(|_| Event::default().event("error").data("{}"));
|
||||
return Some((Ok(event), (state, id, actor, after, pending)));
|
||||
return Some((
|
||||
Ok(event),
|
||||
(state, id, actor, after, pending, wakes),
|
||||
));
|
||||
}
|
||||
match sqlx::query_as::<_, (i32, String, Value)>(
|
||||
"SELECT e.seq,e.type,e.payload FROM events e
|
||||
|
|
@ -412,7 +424,15 @@ async fn events(
|
|||
rows.reverse();
|
||||
pending = rows;
|
||||
}
|
||||
Ok(_) | Err(_) => tokio::time::sleep(Duration::from_millis(750)).await,
|
||||
// Idle means "wait to be knocked", not "sleep then guess":
|
||||
// the reader blocks on the wake channel and re-reads the
|
||||
// cursor the moment a writer commits.
|
||||
Ok(_) | Err(_) => {
|
||||
tokio::select! {
|
||||
_ = wakes.wait(&id) => {}
|
||||
_ = tokio::time::sleep(EVENT_FALLBACK_POLL) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -552,6 +572,10 @@ pub async fn append_event(
|
|||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
// Every event writer funnels through here, so one knock after the commit
|
||||
// covers messages, run state, and metrics without each call site having to
|
||||
// remember it.
|
||||
state.wakes.wake(thread_id);
|
||||
Ok(seq)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,87 @@ impl Drop for CallLease {
|
|||
}
|
||||
}
|
||||
|
||||
/// Capacity of the wake channel. A wake carries only a thread id, so a full
|
||||
/// channel means a reader stopped draining: it degrades to that reader's
|
||||
/// fallback poll rather than dropping a message, because the `events` table is
|
||||
/// still the source of truth for order and replay.
|
||||
const WAKE_CAPACITY: usize = 512;
|
||||
|
||||
/// Signals the session event stream that a thread's cursor moved. A wake only
|
||||
/// says "read it now"; it is what turns the browser's event feed from a poll
|
||||
/// into a push, which is what makes a chat reply feel instant.
|
||||
#[derive(Clone)]
|
||||
pub struct WakeBus {
|
||||
sender: tokio::sync::broadcast::Sender<String>,
|
||||
}
|
||||
|
||||
impl WakeBus {
|
||||
fn with_capacity(capacity: usize) -> Self {
|
||||
let (sender, _) = tokio::sync::broadcast::channel(capacity);
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
/// Never blocks and never fails a request: nobody listening, or a reader too
|
||||
/// slow to keep up, is a latency concern only.
|
||||
pub fn wake(&self, thread_id: &str) {
|
||||
let _ = self.sender.send(thread_id.to_string());
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> WakeSubscription {
|
||||
WakeSubscription {
|
||||
receiver: self.sender.subscribe(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WakeBus {
|
||||
fn default() -> Self {
|
||||
Self::with_capacity(WAKE_CAPACITY)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WakeSubscription {
|
||||
receiver: tokio::sync::broadcast::Receiver<String>,
|
||||
}
|
||||
|
||||
impl WakeSubscription {
|
||||
/// Resolves when `thread_id` moves. Every branch either returns or waits,
|
||||
/// and `recv` is cancel safe, so a `select!` that drops this future cannot
|
||||
/// swallow a wake: the message stays queued for the next call.
|
||||
pub async fn wait(&mut self, thread_id: &str) {
|
||||
loop {
|
||||
match self.receiver.recv().await {
|
||||
Ok(received) if received == thread_id => return,
|
||||
Ok(_) => continue,
|
||||
// A lagged reader already missed wakes, so let the caller re-read
|
||||
// the database rather than wait for a signal it cannot see.
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => return,
|
||||
// A closed channel can never signal again: parking here keeps the
|
||||
// caller on its fallback poll instead of spinning on a future
|
||||
// that resolves immediately.
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
std::future::pending::<()>().await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves on a wake for any thread, for a caller that only cares that
|
||||
/// something happened. Each subscriber owns its own receiver, so this never
|
||||
/// steals a wake from a thread-scoped one.
|
||||
pub async fn wait_any(&mut self) {
|
||||
loop {
|
||||
match self.receiver.recv().await {
|
||||
Ok(_) => return,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => return,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
std::future::pending::<()>().await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Db,
|
||||
|
|
@ -62,6 +143,7 @@ pub struct AppState {
|
|||
pub memory: MemoryService,
|
||||
pub mcp: McpHub,
|
||||
pub calls: CallRegistry,
|
||||
pub wakes: WakeBus,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
|
@ -84,6 +166,7 @@ impl AppState {
|
|||
memory: MemoryService::from_env(),
|
||||
mcp: McpHub::new(),
|
||||
calls: CallRegistry::default(),
|
||||
wakes: WakeBus::default(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -115,3 +198,65 @@ fn sandbox_from_env() -> Arc<dyn SandboxProvider> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod wake_bus_tests {
|
||||
use super::WakeBus;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_subscription_for_a_thread_observes_the_wake() {
|
||||
let bus = WakeBus::with_capacity(4);
|
||||
let mut first = bus.subscribe();
|
||||
let mut second = bus.subscribe();
|
||||
bus.wake("thread-1");
|
||||
for subscription in [&mut first, &mut second] {
|
||||
tokio::time::timeout(Duration::from_secs(1), subscription.wait("thread-1"))
|
||||
.await
|
||||
.expect("every subscriber sees the wake");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn another_threads_wake_does_not_wake_me() {
|
||||
let bus = WakeBus::with_capacity(4);
|
||||
let mut mine = bus.subscribe();
|
||||
bus.wake("someone-else");
|
||||
tokio::time::timeout(Duration::from_millis(50), mine.wait("mine"))
|
||||
.await
|
||||
.expect_err("an unrelated thread stays silent");
|
||||
bus.wake("mine");
|
||||
tokio::time::timeout(Duration::from_secs(1), mine.wait("mine"))
|
||||
.await
|
||||
.expect("the matching thread resolves");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_lagged_reader_is_released_so_the_database_can_be_re_read() {
|
||||
let bus = WakeBus::with_capacity(4);
|
||||
let mut slow = bus.subscribe();
|
||||
for index in 0..32 {
|
||||
bus.wake(&format!("thread-{index}"));
|
||||
}
|
||||
// The backlog overflowed, so the wait resolves instead of hanging on a
|
||||
// signal this reader can no longer reach.
|
||||
tokio::time::timeout(Duration::from_secs(1), slow.wait("never-sent"))
|
||||
.await
|
||||
.expect("lag releases the reader");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_any_answers_for_a_thread_a_waiter_ignored() {
|
||||
let bus = WakeBus::with_capacity(4);
|
||||
let mut scoped = bus.subscribe();
|
||||
let mut any = bus.subscribe();
|
||||
bus.wake("somewhere-else");
|
||||
tokio::time::timeout(Duration::from_secs(1), any.wait_any())
|
||||
.await
|
||||
.expect("any-waker answers for the thread the scoped one skipped");
|
||||
// The scoped subscription has its own receiver and is still waiting.
|
||||
tokio::time::timeout(Duration::from_millis(50), scoped.wait("mine"))
|
||||
.await
|
||||
.expect_err("the scoped subscription is untouched");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,14 +102,18 @@ pub fn tool_definitions(memory_enabled: bool) -> Vec<ToolDefinition> {
|
|||
},
|
||||
ToolDefinition {
|
||||
name: "shell".into(),
|
||||
description: "Run a command inside this bot's computer when the user asked you to do work there. Not for greetings, small talk, or questions you can answer in text.".into(),
|
||||
description: "Type a command into a terminal on this bot's computer when the user asked you to do work there. The terminal is a real, persistent one: it keeps its working directory, exported variables, and background jobs between calls, so `cd` once and later calls run there. Use a session name per piece of work (default \"main\"). A command that has not finished after wait_ms comes back as status=running with the output so far and keeps running in the terminal: poll it with log_lines, or stop it with keys \"C-c\". Never type a second command into a terminal that is still busy. Not for greetings, small talk, or questions you can answer in text.".into(),
|
||||
parameters: json!({
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"command":{"type":"string"},
|
||||
"cwd":{"type":"string"}
|
||||
},
|
||||
"required":["command"]
|
||||
"command":{"type":"string","description":"Command to type. Omit it to just read the terminal."},
|
||||
"session":{"type":"string","description":"Terminal name: the same name is the same terminal. Default main."},
|
||||
"wait_ms":{"type":"number","description":"How long to wait for the command, default 20000, max 110000. Longer jobs return status=running and keep going."},
|
||||
"log_lines":{"type":"number","description":"Read the last N lines of the terminal instead of typing anything."},
|
||||
"keys":{"type":"string","description":"Keys instead of a command, space separated: \"C-c\", \"q\", \"Escape Enter\"."},
|
||||
"reset":{"type":"boolean","description":"Start this terminal over: drops directory, exports, and jobs."},
|
||||
"cwd":{"type":"string","description":"Directory for this command; the terminal stays there."}
|
||||
}
|
||||
}),
|
||||
},
|
||||
ToolDefinition {
|
||||
|
|
@ -1049,39 +1053,161 @@ fn pack_observation(ctx: &ToolCtx, note: &str, observation: ComputerObservation)
|
|||
}
|
||||
}
|
||||
|
||||
/// How long a `shell` call waits for its command before handing back a
|
||||
/// terminal that keeps working. Long enough for a build step to finish, short
|
||||
/// enough that a server is reported as running rather than as a timeout.
|
||||
const SHELL_WAIT_MS_DEFAULT: u64 = 20_000;
|
||||
const SHELL_WAIT_MS_MAX: u64 = 110_000;
|
||||
/// The exec has to outlive the wait: the script returns as soon as the terminal
|
||||
/// prints its markers, so this only covers tmux being slow to answer.
|
||||
const SHELL_EXEC_SLACK_MS: u64 = 15_000;
|
||||
const SHELL_LOG_LINES_DEFAULT: u64 = 120;
|
||||
|
||||
fn shell_session(args: &Value) -> String {
|
||||
args.get("session")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or("main")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn shell_log_lines(args: &Value) -> u64 {
|
||||
args.get("log_lines")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(SHELL_LOG_LINES_DEFAULT)
|
||||
.clamp(1, 4_000)
|
||||
}
|
||||
|
||||
fn shell_wait_ms(args: &Value) -> u64 {
|
||||
args.get("wait_ms")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(SHELL_WAIT_MS_DEFAULT)
|
||||
.clamp(1_000, SHELL_WAIT_MS_MAX)
|
||||
}
|
||||
|
||||
/// Quote one argument for the shell inside the terminal.
|
||||
fn shell_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
/// One call, one mode: keys and log reads never type a command, and a command
|
||||
/// never waits longer than the model asked for.
|
||||
fn shell_argv(args: &Value, session: &str, cwd: Option<&str>) -> Result<Vec<String>, String> {
|
||||
let mut argv = vec!["lazyboy-shell".to_string()];
|
||||
if args.get("reset").and_then(Value::as_bool).unwrap_or(false) {
|
||||
argv.extend(["reset".to_string(), session.to_string()]);
|
||||
return Ok(argv);
|
||||
}
|
||||
if let Some(keys) = args.get("keys").and_then(Value::as_str) {
|
||||
let keys: Vec<String> = keys.split_whitespace().map(str::to_string).collect();
|
||||
if keys.is_empty() {
|
||||
return Err("`keys` needs a key to send, for example \"C-c\".".to_string());
|
||||
}
|
||||
argv.extend(["keys".to_string(), session.to_string()]);
|
||||
argv.extend(keys);
|
||||
return Ok(argv);
|
||||
}
|
||||
let command = args
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if command.is_empty() {
|
||||
// Nothing to type: show the terminal rather than guessing at a command.
|
||||
argv.extend([
|
||||
"log".to_string(),
|
||||
session.to_string(),
|
||||
shell_log_lines(args).to_string(),
|
||||
]);
|
||||
return Ok(argv);
|
||||
}
|
||||
let command = match cwd {
|
||||
Some(dir) => format!("cd -- {} && {{\n{}\n}}", shell_quote(dir), command),
|
||||
None => command,
|
||||
};
|
||||
argv.extend([
|
||||
"run".to_string(),
|
||||
session.to_string(),
|
||||
shell_wait_ms(args).to_string(),
|
||||
command,
|
||||
]);
|
||||
Ok(argv)
|
||||
}
|
||||
|
||||
/// Desktop images built before persistent terminals have no lazyboy-shell; the
|
||||
/// exit code of the missing command is the only signal worth matching on.
|
||||
fn shell_script_missing(code: i32, stderr: &str) -> bool {
|
||||
code == 127 && stderr.contains("lazyboy-shell")
|
||||
}
|
||||
|
||||
async fn shell(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
|
||||
let command = args.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
let cwd = args.get("cwd").and_then(Value::as_str);
|
||||
let cwd = resolve_bot_workspace_cwd(ctx.mode, &ctx.bot_id, cwd)
|
||||
.ok()
|
||||
.flatten();
|
||||
let session = shell_session(args);
|
||||
let argv = match shell_argv(args, &session, cwd.as_deref()) {
|
||||
Ok(argv) => argv,
|
||||
Err(message) => return text_outcome(message),
|
||||
};
|
||||
let timeout_ms = shell_wait_ms(args) + SHELL_EXEC_SLACK_MS;
|
||||
let result = ctx
|
||||
.sandbox
|
||||
.execute(
|
||||
&ctx.computer_ref(),
|
||||
CommandRequest {
|
||||
argv,
|
||||
cwd: None,
|
||||
timeout_ms: Some(timeout_ms),
|
||||
stdin: None,
|
||||
},
|
||||
&ctx.adapter(),
|
||||
)
|
||||
.await;
|
||||
let result = match result {
|
||||
Ok(result) if shell_script_missing(result.code, &result.stderr) => {
|
||||
// Older image: one shell per call, which is worse but not fatal.
|
||||
let Some(command) = args.get("command").and_then(Value::as_str) else {
|
||||
return text_outcome(
|
||||
"This desktop image has no terminal sessions. Rebuild it with `make computer`.",
|
||||
);
|
||||
};
|
||||
match ctx
|
||||
.sandbox
|
||||
.execute(
|
||||
&ctx.computer_ref(),
|
||||
CommandRequest {
|
||||
argv: vec!["bash".into(), "-lc".into(), command.into()],
|
||||
argv: vec!["bash".into(), "-lc".into(), command.to_string()],
|
||||
cwd,
|
||||
timeout_ms: Some(60_000),
|
||||
timeout_ms: Some(SHELL_WAIT_MS_MAX + SHELL_EXEC_SLACK_MS),
|
||||
stdin: None,
|
||||
},
|
||||
&ctx.adapter(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => ToolOutcome {
|
||||
text: format!("exit {}\n{}\n{}", result.code, result.stdout, result.stderr),
|
||||
image: None,
|
||||
pause: false,
|
||||
blocks: Vec::new(),
|
||||
},
|
||||
Err(error) => ToolOutcome {
|
||||
text: error.to_string(),
|
||||
image: None,
|
||||
pause: false,
|
||||
blocks: Vec::new(),
|
||||
},
|
||||
Ok(fallback) => {
|
||||
let note = "note: this desktop image has no persistent terminal, so the directory, exports, and background jobs of this command end with this call. Rebuild with `make computer` for a terminal that keeps them.";
|
||||
return text_outcome(format!(
|
||||
"{note}\nexit {}\n{}\n{}",
|
||||
fallback.code, fallback.stdout, fallback.stderr
|
||||
));
|
||||
}
|
||||
Err(error) => return text_outcome(error.to_string()),
|
||||
}
|
||||
}
|
||||
Ok(result) => result,
|
||||
Err(error) => return text_outcome(error.to_string()),
|
||||
};
|
||||
text_outcome(format!(
|
||||
"{}\n{}",
|
||||
result.stdout.trim_end(),
|
||||
result.stderr.trim_end()
|
||||
)
|
||||
.trim_end()
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn list_files(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
|
||||
|
|
@ -1505,6 +1631,7 @@ fn schedule_state(ctx: &ToolCtx) -> crate::state::AppState {
|
|||
memory: ctx.memory.clone(),
|
||||
mcp: ctx.mcp.clone(),
|
||||
calls: crate::state::CallRegistry::default(),
|
||||
wakes: crate::state::WakeBus::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1522,3 +1649,66 @@ mod connection_check_tests {
|
|||
assert!(!is_connection_check(&CdpPage { ok:false, text:"Cloudflare Verify you are human".into(), ..Default::default() }));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shell_session_tests {
|
||||
use super::*;
|
||||
|
||||
/// The same pairing the tool makes: a session name, then a mode.
|
||||
fn argv(args: Value) -> Vec<String> {
|
||||
shell_argv(&args, &shell_session(&args), None).expect("argv")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_runs_in_its_named_terminal_and_waits_its_own_time() {
|
||||
assert_eq!(
|
||||
argv(json!({"command":"ls -l","session":"build","wait_ms":45000})),
|
||||
["lazyboy-shell", "run", "build", "45000", "ls -l"]
|
||||
);
|
||||
let defaults = argv(json!({"command":"pwd"}));
|
||||
assert_eq!(defaults[1], "run");
|
||||
assert_eq!(defaults[2], "main");
|
||||
assert_eq!(defaults[3], SHELL_WAIT_MS_DEFAULT.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_without_a_command_reads_the_terminal_instead_of_typing() {
|
||||
assert_eq!(
|
||||
argv(json!({"session":"build","log_lines":40})),
|
||||
["lazyboy-shell", "log", "build", "40"]
|
||||
);
|
||||
assert_eq!(argv(json!({})), ["lazyboy-shell", "log", "main", "120"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_and_reset_never_type_a_command() {
|
||||
assert_eq!(
|
||||
argv(json!({"session":"build","keys":"C-c"})),
|
||||
["lazyboy-shell", "keys", "build", "C-c"]
|
||||
);
|
||||
assert_eq!(argv(json!({"reset":true})), ["lazyboy-shell", "reset", "main"]);
|
||||
assert!(shell_argv(&json!({"keys":" "}), "main", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_moves_this_command_into_a_directory_and_survives_quotes() {
|
||||
let argv = shell_argv(&json!({"command":"make\ntest"}), "main", Some("/tmp/a b'c"))
|
||||
.expect("argv");
|
||||
assert_eq!(argv[4], "cd -- '/tmp/a b'\\''c' && {\nmake\ntest\n}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waits_stay_inside_the_range_the_desktop_can_honour() {
|
||||
assert_eq!(shell_wait_ms(&json!({"wait_ms":900})), 1_000);
|
||||
assert_eq!(shell_wait_ms(&json!({"wait_ms":900_000})), SHELL_WAIT_MS_MAX);
|
||||
assert_eq!(shell_wait_ms(&json!({})), SHELL_WAIT_MS_DEFAULT);
|
||||
assert_eq!(shell_log_lines(&json!({"log_lines":0})), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_missing_script_falls_back_to_one_shot() {
|
||||
assert!(shell_script_missing(127, "bash: lazyboy-shell: command not found"));
|
||||
assert!(!shell_script_missing(127, "bash: whatever: command not found"));
|
||||
assert!(!shell_script_missing(1, "lazyboy-shell: nope"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,11 @@ pub enum StopReason {
|
|||
MidTaskText,
|
||||
/// The bounded turn budget is spent with work still outstanding.
|
||||
BudgetExhausted,
|
||||
/// The round policy saw the run repeating itself rather than moving. The
|
||||
/// difference from `BudgetExhausted` matters to the human: this is "it got
|
||||
/// stuck", not "it ran out of a quota", and the pause message carries the
|
||||
/// action it kept repeating.
|
||||
LoopDetected,
|
||||
}
|
||||
|
||||
impl StopReason {
|
||||
|
|
@ -71,6 +76,7 @@ impl StopReason {
|
|||
match self {
|
||||
Self::MidTaskText => "mid_task_text",
|
||||
Self::BudgetExhausted => "budget_exhausted",
|
||||
Self::LoopDetected => "loop_detected",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
mod resolve;
|
||||
pub mod execution;
|
||||
pub mod policy;
|
||||
mod voice;
|
||||
|
||||
pub use resolve::*;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,764 @@
|
|||
//! Round policy: how long a run may keep going, and when it must stop.
|
||||
//!
|
||||
//! Earlier every run got a fixed number of turns. A fixed quota punishes real
|
||||
//! work - a build, a 200-row sheet, a site that reloads slowly - and it teaches
|
||||
//! the agent to schedule its honesty around turn 39. Here turns are not the
|
||||
//! budget; evidence is. A run keeps going until the work is verified, the human
|
||||
//! says stop, or the guard sees the agent going in circles instead of moving.
|
||||
//! Only then, and with the evidence in hand, does the run park.
|
||||
//!
|
||||
//! Three layers, cheapest first:
|
||||
//! 1. Coaching (`Reflect`): the loop injects one pointed instruction and the run
|
||||
//! continues. Triggers are the same action again, the same failure again, no
|
||||
//! new success for a long time, or a soft checkpoint.
|
||||
//! 2. Derailment halt (`LoopDetected`): the same action keeps being repeated, or
|
||||
//! nothing has worked for a very long time. The run parks in `waiting_input`
|
||||
//! with the concrete evidence so the human can unblock it.
|
||||
//! 3. Circuit breaker (`BudgetExhausted`): a large turn and wall-clock ceiling
|
||||
//! that exists only so a bug cannot burn an API key overnight. It is not a
|
||||
//! task budget; reaching it is a bug report, not a result.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::execution::StopReason;
|
||||
|
||||
/// Turns before the first "where are you?" self-check. Generous on purpose:
|
||||
/// the first stretch of a hard task is normal, not suspicious.
|
||||
pub const SOFT_CHECKPOINT_TURNS: u32 = 60;
|
||||
/// Turns between later self-checks.
|
||||
pub const SOFT_CHECKPOINT_EVERY: u32 = 120;
|
||||
/// Circuit breaker. No honest task is 1000 model turns long; if it is, it
|
||||
/// belongs in a skill or a schedule, not in one run.
|
||||
pub const HARD_CAP_TURNS: u32 = 1_000;
|
||||
/// Wall clock before a run is asked to justify itself out loud.
|
||||
pub const SOFT_WALL_MINUTES: u64 = 75;
|
||||
/// Wall clock circuit breaker.
|
||||
pub const HARD_WALL_MINUTES: u64 = 240;
|
||||
|
||||
/// Same mutating action, in a row, before the run is coached.
|
||||
pub const REPEAT_WARN: u32 = 3;
|
||||
/// Same mutating action, in a row, before the run parks.
|
||||
pub const REPEAT_HALT: u32 = 6;
|
||||
/// Same mutating action, counted over the whole run.
|
||||
pub const REPEAT_HALT_TOTAL: u32 = 12;
|
||||
/// Same action failing, in a row.
|
||||
pub const FAILURE_WARN: u32 = 3;
|
||||
pub const FAILURE_HALT: u32 = 8;
|
||||
/// Anything failing, in a row, whatever the tool.
|
||||
pub const FAILURE_HALT_ANY: u32 = 14;
|
||||
/// Turns without a new success before a self-check.
|
||||
pub const STALE_REFLECT: u32 = 40;
|
||||
/// Turns without a new success before parking the run.
|
||||
pub const STALE_HALT: u32 = 150;
|
||||
/// Coaching has to stay rare enough that the model actually reads it.
|
||||
pub const MAX_REFLECTIONS: u32 = 8;
|
||||
|
||||
/// A run that repeats itself is not a run that waits or polls, so these tools
|
||||
/// reset the no-progress clock even when their arguments repeat.
|
||||
const PROGRESS_WHEN_REPEATED: [&str; 2] = ["shell", "wait"];
|
||||
|
||||
fn parse_u64(value: Option<String>) -> Option<u64> {
|
||||
value?.trim().parse::<u64>().ok().filter(|value| *value > 0)
|
||||
}
|
||||
|
||||
fn parse_u32(value: Option<String>) -> Option<u32> {
|
||||
parse_u64(value).and_then(|value| u32::try_from(value).ok())
|
||||
}
|
||||
|
||||
/// The knobs a deployment may want to move. Every one of them is a breaker,
|
||||
/// not a quota: the defaults sit far outside honest work.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RunPolicy {
|
||||
pub soft_checkpoint_turns: u32,
|
||||
pub soft_checkpoint_every: u32,
|
||||
pub hard_cap_turns: u32,
|
||||
pub soft_wall: Duration,
|
||||
pub hard_wall: Duration,
|
||||
}
|
||||
|
||||
impl Default for RunPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
soft_checkpoint_turns: SOFT_CHECKPOINT_TURNS,
|
||||
soft_checkpoint_every: SOFT_CHECKPOINT_EVERY,
|
||||
hard_cap_turns: HARD_CAP_TURNS,
|
||||
soft_wall: Duration::from_secs(SOFT_WALL_MINUTES * 60),
|
||||
hard_wall: Duration::from_secs(HARD_WALL_MINUTES * 60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RunPolicy {
|
||||
/// `LAZYBOY_RUN_SOFT_TURNS`, `LAZYBOY_RUN_SOFT_EVERY`,
|
||||
/// `LAZYBOY_RUN_CAP_TURNS`, `LAZYBOY_RUN_SOFT_MINUTES`,
|
||||
/// `LAZYBOY_RUN_HARD_MINUTES`. Unset or unparsable keeps the default.
|
||||
pub fn from_env() -> Self {
|
||||
Self::from_lookup(|name| std::env::var(name).ok())
|
||||
}
|
||||
|
||||
pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Self {
|
||||
let mut policy = Self::default();
|
||||
if let Some(value) = parse_u32(lookup("LAZYBOY_RUN_SOFT_TURNS")) {
|
||||
policy.soft_checkpoint_turns = value;
|
||||
}
|
||||
if let Some(value) = parse_u32(lookup("LAZYBOY_RUN_SOFT_EVERY")) {
|
||||
policy.soft_checkpoint_every = value;
|
||||
}
|
||||
if let Some(value) = parse_u32(lookup("LAZYBOY_RUN_CAP_TURNS")) {
|
||||
policy.hard_cap_turns = value;
|
||||
}
|
||||
if let Some(value) = parse_u64(lookup("LAZYBOY_RUN_SOFT_MINUTES")) {
|
||||
policy.soft_wall = Duration::from_secs(value * 60);
|
||||
}
|
||||
if let Some(value) = parse_u64(lookup("LAZYBOY_RUN_HARD_MINUTES")) {
|
||||
policy.hard_wall = Duration::from_secs(value * 60);
|
||||
}
|
||||
policy
|
||||
}
|
||||
}
|
||||
|
||||
/// What the loop should do with a verdict.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Continue,
|
||||
/// Inject one instruction into the next model turn and keep running.
|
||||
Reflect(String),
|
||||
/// Park the run and hand the reason, plus the evidence, to the human.
|
||||
Halt {
|
||||
reason: StopReason,
|
||||
note: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Verdict {
|
||||
pub fn is_halt(&self) -> bool {
|
||||
matches!(self, Self::Halt { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// One finished tool call, as far as the policy is concerned.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ActionObserved<'a> {
|
||||
pub name: &'a str,
|
||||
pub args: &'a Value,
|
||||
/// Human-readable rendering, reused verbatim in the pause message.
|
||||
pub label: &'a str,
|
||||
pub ok: bool,
|
||||
pub turn: u32,
|
||||
/// False for reads and observations: repeating those is how an agent looks
|
||||
/// at a page again, not how it gets stuck clicking.
|
||||
pub changes_state: bool,
|
||||
}
|
||||
|
||||
/// Key that identifies "the same action". Object keys are sorted so `{a,b}` and
|
||||
/// `{b,a}` match, while values are kept because `click #12` and `click #13` are
|
||||
/// genuinely different actions.
|
||||
fn signature(name: &str, args: &Value) -> String {
|
||||
let mut out = String::with_capacity(name.len() + 32);
|
||||
out.push_str(name);
|
||||
out.push('(');
|
||||
write_canonical(args, &mut out);
|
||||
out.push(')');
|
||||
const MAX_KEY_CHARS: usize = 400;
|
||||
let chars: Vec<char> = out.chars().collect();
|
||||
if chars.len() > MAX_KEY_CHARS {
|
||||
out = chars.iter().take(MAX_KEY_CHARS).collect();
|
||||
out.push('…');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn write_canonical(value: &Value, out: &mut String) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort();
|
||||
out.push('{');
|
||||
for (index, key) in keys.iter().enumerate() {
|
||||
if index > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(key);
|
||||
out.push(':');
|
||||
write_canonical(&map[*key], out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
Value::Array(items) => {
|
||||
out.push('[');
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
if index > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
write_canonical(item, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
other => out.push_str(other.to_string().as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-run derailment detector. A resumed run starts with a clean slate, because
|
||||
/// the human has just told it to continue.
|
||||
pub struct LoopGuard {
|
||||
policy: RunPolicy,
|
||||
counts: HashMap<String, u32>,
|
||||
failures: HashMap<String, u32>,
|
||||
last_key: Option<String>,
|
||||
repeat_streak: u32,
|
||||
any_failure_streak: u32,
|
||||
warned_repeat: bool,
|
||||
warned_failure: bool,
|
||||
watched_since: u32,
|
||||
/// Set by the first successful tool call. Until a run has actually done
|
||||
/// something, "no progress" is meaningless: turns spent talking or thinking
|
||||
/// are handled by the nudge limit, not by this guard.
|
||||
acted: bool,
|
||||
last_progress_turn: u32,
|
||||
last_stale_reflect_turn: u32,
|
||||
checkpoint_turn: u32,
|
||||
soft_wall_spoken: bool,
|
||||
reflections: u32,
|
||||
}
|
||||
|
||||
impl LoopGuard {
|
||||
pub fn new(policy: RunPolicy) -> Self {
|
||||
Self::with_watch(policy, 0)
|
||||
}
|
||||
|
||||
/// `watched_from` is the turn number the guard starts counting at, so a
|
||||
/// resumed run is not judged for work it already finished.
|
||||
pub fn with_watch(policy: RunPolicy, watched_from: u32) -> Self {
|
||||
Self {
|
||||
policy,
|
||||
counts: HashMap::new(),
|
||||
failures: HashMap::new(),
|
||||
last_key: None,
|
||||
repeat_streak: 0,
|
||||
any_failure_streak: 0,
|
||||
warned_repeat: false,
|
||||
warned_failure: false,
|
||||
watched_since: watched_from,
|
||||
acted: false,
|
||||
last_progress_turn: watched_from,
|
||||
last_stale_reflect_turn: watched_from,
|
||||
checkpoint_turn: 0,
|
||||
soft_wall_spoken: false,
|
||||
reflections: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn policy(&self) -> RunPolicy {
|
||||
self.policy
|
||||
}
|
||||
|
||||
/// Called once per model turn, before the model is asked for anything.
|
||||
pub fn on_turn(&mut self, turns: u32, elapsed: Duration) -> Verdict {
|
||||
if turns >= self.policy.hard_cap_turns {
|
||||
return Verdict::Halt {
|
||||
reason: StopReason::BudgetExhausted,
|
||||
note: format!(
|
||||
"已達到保險上限 {cap} 輪(這是迴圈失控的保護,不是任務做完)",
|
||||
cap = self.policy.hard_cap_turns
|
||||
),
|
||||
};
|
||||
}
|
||||
let minutes = elapsed.as_secs() / 60;
|
||||
if elapsed >= self.policy.hard_wall {
|
||||
return Verdict::Halt {
|
||||
reason: StopReason::BudgetExhausted,
|
||||
note: format!(
|
||||
"已執行 {minutes} 分鐘,超過 {hard} 分鐘的最後保護上限",
|
||||
hard = self.policy.hard_wall.as_secs() / 60
|
||||
),
|
||||
};
|
||||
}
|
||||
if let Some(halt) = self.stale_check(turns) {
|
||||
return halt;
|
||||
}
|
||||
if self.is_checkpoint(turns) {
|
||||
self.checkpoint_turn = turns;
|
||||
return self.reflect(checkpoint_text(turns, minutes));
|
||||
}
|
||||
if !self.soft_wall_spoken && elapsed >= self.policy.soft_wall {
|
||||
self.soft_wall_spoken = true;
|
||||
return self.reflect(wall_text(minutes, self.policy.hard_wall.as_secs() / 60));
|
||||
}
|
||||
Verdict::Continue
|
||||
}
|
||||
|
||||
/// Called after every tool result.
|
||||
pub fn on_action(&mut self, action: &ActionObserved<'_>) -> Verdict {
|
||||
let key = signature(action.name, action.args);
|
||||
if self.last_key.as_deref() == Some(key.as_str()) {
|
||||
self.repeat_streak = self.repeat_streak.saturating_add(1);
|
||||
} else {
|
||||
self.repeat_streak = 1;
|
||||
self.warned_repeat = false;
|
||||
}
|
||||
let seen = self
|
||||
.counts
|
||||
.get(&key)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1);
|
||||
self.counts.insert(key.clone(), seen);
|
||||
self.last_key = Some(key.clone());
|
||||
|
||||
if !action.ok {
|
||||
self.any_failure_streak = self.any_failure_streak.saturating_add(1);
|
||||
let failures = self
|
||||
.failures
|
||||
.get(&key)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1);
|
||||
self.failures.insert(key.clone(), failures);
|
||||
if failures >= FAILURE_HALT {
|
||||
return Verdict::Halt {
|
||||
reason: StopReason::LoopDetected,
|
||||
note: format!(
|
||||
"同一個動作「{label}」連續失敗 {failures} 次",
|
||||
label = action.label
|
||||
),
|
||||
};
|
||||
}
|
||||
if self.any_failure_streak >= FAILURE_HALT_ANY {
|
||||
return Verdict::Halt {
|
||||
reason: StopReason::LoopDetected,
|
||||
note: format!(
|
||||
"連續 {count} 個動作都沒有成功(最後一個:{label})",
|
||||
count = self.any_failure_streak,
|
||||
label = action.label,
|
||||
),
|
||||
};
|
||||
}
|
||||
if failures >= FAILURE_WARN && !self.warned_failure {
|
||||
self.warned_failure = true;
|
||||
return self.reflect(failure_text(action.label, failures));
|
||||
}
|
||||
return Verdict::Continue;
|
||||
}
|
||||
|
||||
self.any_failure_streak = 0;
|
||||
self.failures.remove(&key);
|
||||
self.warned_failure = false;
|
||||
self.acted = true;
|
||||
if seen == 1 || PROGRESS_WHEN_REPEATED.contains(&action.name) {
|
||||
self.last_progress_turn = action.turn;
|
||||
}
|
||||
|
||||
if action.changes_state {
|
||||
if self.repeat_streak >= REPEAT_HALT {
|
||||
return Verdict::Halt {
|
||||
reason: StopReason::LoopDetected,
|
||||
note: format!(
|
||||
"同一個動作「{label}」連續做了 {streak} 次,沒有新的進展",
|
||||
label = action.label,
|
||||
streak = self.repeat_streak,
|
||||
),
|
||||
};
|
||||
}
|
||||
if seen >= REPEAT_HALT_TOTAL {
|
||||
return Verdict::Halt {
|
||||
reason: StopReason::LoopDetected,
|
||||
note: format!(
|
||||
"同一個動作「{label}」在這輪任務裡已經做了 {seen} 次",
|
||||
label = action.label,
|
||||
),
|
||||
};
|
||||
}
|
||||
if self.repeat_streak >= REPEAT_WARN && !self.warned_repeat {
|
||||
self.warned_repeat = true;
|
||||
return self.reflect(repeat_text(action.label, self.repeat_streak));
|
||||
}
|
||||
}
|
||||
Verdict::Continue
|
||||
}
|
||||
|
||||
/// Nothing new has succeeded for a long stretch: either the agent is
|
||||
/// circling the same wall, or it is honestly waiting and should say so.
|
||||
fn stale_check(&mut self, turns: u32) -> Option<Verdict> {
|
||||
if !self.acted {
|
||||
return None;
|
||||
}
|
||||
let since = turns.saturating_sub(self.last_progress_turn);
|
||||
if since < STALE_REFLECT {
|
||||
return None;
|
||||
}
|
||||
if since >= STALE_HALT {
|
||||
return Some(Verdict::Halt {
|
||||
reason: StopReason::LoopDetected,
|
||||
note: format!(
|
||||
"從第 {watched} 輪起,連續 {since} 輪沒有任何新的成功動作,看起來在鬼打牆",
|
||||
watched = self.watched_since
|
||||
),
|
||||
});
|
||||
}
|
||||
if turns.saturating_sub(self.last_stale_reflect_turn) >= STALE_REFLECT {
|
||||
self.last_stale_reflect_turn = turns;
|
||||
return Some(self.reflect(stale_text(turns, since)));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_checkpoint(&self, turns: u32) -> bool {
|
||||
if turns < self.policy.soft_checkpoint_turns || turns <= self.checkpoint_turn {
|
||||
return false;
|
||||
}
|
||||
let every = self.policy.soft_checkpoint_every.max(1);
|
||||
turns == self.policy.soft_checkpoint_turns
|
||||
|| turns
|
||||
.saturating_sub(self.policy.soft_checkpoint_turns)
|
||||
.is_multiple_of(every)
|
||||
}
|
||||
|
||||
/// Coaching is capped so a confused model is never nagged forever; the
|
||||
/// breakers above still work after the cap is reached.
|
||||
fn reflect(&mut self, text: String) -> Verdict {
|
||||
if self.reflections >= MAX_REFLECTIONS {
|
||||
return Verdict::Continue;
|
||||
}
|
||||
self.reflections = self.reflections.saturating_add(1);
|
||||
Verdict::Reflect(text)
|
||||
}
|
||||
}
|
||||
|
||||
fn checkpoint_text(turns: u32, minutes: u64) -> String {
|
||||
format!(
|
||||
"Checkpoint: {turns} turns, {minutes} minutes in. Write exactly three short lines \
|
||||
(已完成 / 還缺 / 下一個動作) and then make that next action with a tool call in the \
|
||||
same reply. Do not repeat an action that already produced the same result; if the \
|
||||
current plan cannot work, replace it with a different route."
|
||||
)
|
||||
}
|
||||
|
||||
fn wall_text(minutes: u64, hard_minutes: u64) -> String {
|
||||
format!(
|
||||
"Time check: {minutes} minutes on this task. If the work is genuinely long (a build, \
|
||||
a download, a queue), say so in one line and continue with a tool. If you are stuck, \
|
||||
stop guessing: state what you have tried and what you need, then end with a \
|
||||
standalone [NEEDS_INPUT] line. Nothing stops at {hard_minutes} minutes except you."
|
||||
)
|
||||
}
|
||||
|
||||
fn repeat_text(label: &str, streak: u32) -> String {
|
||||
format!(
|
||||
"You have sent the same action {streak} times in a row: {label}. The result you already \
|
||||
have is the result you will get again. Do not send it again unchanged: read the last \
|
||||
tool result and change something - observe the screen again, scroll, target a selector \
|
||||
instead of an id, reload, or use the shell. If only the human can unblock this, say \
|
||||
what you need and end with a standalone [NEEDS_INPUT] line."
|
||||
)
|
||||
}
|
||||
|
||||
fn failure_text(label: &str, failures: u32) -> String {
|
||||
format!(
|
||||
"{label} has failed {failures} times in a row. Use the error text: different arguments, \
|
||||
a different tool, or a fresh observation of the current state before you retry. If the \
|
||||
blocker is outside your reach, say exactly what is missing and end with a standalone \
|
||||
[NEEDS_INPUT] line."
|
||||
)
|
||||
}
|
||||
|
||||
fn stale_text(turns: u32, since: u32) -> String {
|
||||
format!(
|
||||
"{turns} turns in, nothing new has succeeded for {since} turns. Step back before the \
|
||||
next call: state what you have actually verified, what is still missing, then take one \
|
||||
action you have not tried yet. If the task cannot move without the human, summarise the \
|
||||
state and end with a standalone [NEEDS_INPUT] line."
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn act<'a>(
|
||||
name: &'a str,
|
||||
args: &'a Value,
|
||||
turn: u32,
|
||||
ok: bool,
|
||||
changes_state: bool,
|
||||
) -> ActionObserved<'a> {
|
||||
ActionObserved {
|
||||
name,
|
||||
args,
|
||||
label: "browser: click #12",
|
||||
ok,
|
||||
turn,
|
||||
changes_state,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_long_run_is_never_stopped() {
|
||||
let mut guard = LoopGuard::new(RunPolicy::default());
|
||||
for turn in 1..=39 {
|
||||
assert_eq!(
|
||||
guard.on_turn(turn, Duration::from_secs(60 * turn as u64)),
|
||||
Verdict::Continue,
|
||||
"turn {turn}"
|
||||
);
|
||||
let args = json!({"command": format!("step {turn}")});
|
||||
assert_eq!(
|
||||
guard.on_action(&act("shell", &args, turn, true, true)),
|
||||
Verdict::Continue,
|
||||
"action {turn}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_in_the_default_policy_stops_a_run_near_turn_forty() {
|
||||
// The complaint this replaces was "40 輪真的太少了".
|
||||
let policy = RunPolicy::default();
|
||||
assert!(policy.hard_cap_turns > 200);
|
||||
assert!(policy.hard_wall > Duration::from_secs(120 * 60));
|
||||
let mut guard = LoopGuard::new(policy);
|
||||
assert_eq!(
|
||||
guard.on_turn(40, Duration::from_secs(600)),
|
||||
Verdict::Continue
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looking_at_the_screen_again_is_not_a_loop() {
|
||||
let mut guard = LoopGuard::new(RunPolicy::default());
|
||||
let args = json!({});
|
||||
for turn in 1..=20 {
|
||||
assert_eq!(
|
||||
guard.on_action(&act("computer_observe", &args, turn, true, false)),
|
||||
Verdict::Continue,
|
||||
"turn {turn}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_the_same_control_learns_to_stop() {
|
||||
let mut guard = LoopGuard::new(RunPolicy::default());
|
||||
let args = json!({"action": "click", "element": 12});
|
||||
for turn in 1..REPEAT_WARN {
|
||||
assert_eq!(
|
||||
guard.on_action(&act("browser", &args, turn, true, true)),
|
||||
Verdict::Continue
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
guard.on_action(&act("browser", &args, REPEAT_WARN, true, true)),
|
||||
Verdict::Reflect(_)
|
||||
));
|
||||
for turn in REPEAT_WARN + 1..REPEAT_HALT {
|
||||
assert_eq!(
|
||||
guard.on_action(&act("browser", &args, turn, true, true)),
|
||||
Verdict::Continue
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
guard.on_action(&act("browser", &args, REPEAT_HALT, true, true)),
|
||||
Verdict::Halt {
|
||||
reason: StopReason::LoopDetected,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn argument_order_does_not_make_the_same_action_look_new() {
|
||||
assert_eq!(
|
||||
signature("browser", &json!({"element": 12, "action": "click"})),
|
||||
signature("browser", &json!({"action": "click", "element": 12}))
|
||||
);
|
||||
assert_ne!(
|
||||
signature("browser", &json!({"action": "click", "element": 12})),
|
||||
signature("browser", &json!({"action": "click", "element": 13}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeated_failure_is_coached_then_parked() {
|
||||
let mut guard = LoopGuard::new(RunPolicy::default());
|
||||
let args = json!({"command": "npm test"});
|
||||
for turn in 1..FAILURE_WARN {
|
||||
assert_eq!(
|
||||
guard.on_action(&act("shell", &args, turn, false, true)),
|
||||
Verdict::Continue
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
guard.on_action(&act("shell", &args, FAILURE_WARN, false, true)),
|
||||
Verdict::Reflect(_)
|
||||
));
|
||||
for turn in FAILURE_WARN + 1..FAILURE_HALT {
|
||||
guard.on_action(&act("shell", &args, turn, false, true));
|
||||
}
|
||||
assert!(
|
||||
guard
|
||||
.on_action(&act("shell", &args, FAILURE_HALT, false, true))
|
||||
.is_halt()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_batch_of_different_failures_also_stops() {
|
||||
let mut guard = LoopGuard::new(RunPolicy::default());
|
||||
for turn in 1..FAILURE_HALT_ANY {
|
||||
let args = json!({"command": format!("try {turn}")});
|
||||
let verdict = guard.on_action(&act("shell", &args, turn, false, true));
|
||||
assert!(!verdict.is_halt(), "halted too early at {turn}");
|
||||
}
|
||||
let args = json!({"command": "last try"});
|
||||
assert!(
|
||||
guard
|
||||
.on_action(&act("shell", &args, FAILURE_HALT_ANY, false, true))
|
||||
.is_halt()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polling_a_build_or_a_queue_is_not_treated_as_spinning() {
|
||||
let mut guard = LoopGuard::new(RunPolicy::default());
|
||||
let args = json!({"ms": 30000});
|
||||
for turn in 1..=160 {
|
||||
assert_eq!(
|
||||
guard.on_action(&act("wait", &args, turn, true, false)),
|
||||
Verdict::Continue,
|
||||
"turn {turn}"
|
||||
);
|
||||
// A checkpoint question is fine; what a polling run must never get
|
||||
// is a halt.
|
||||
assert!(
|
||||
!guard.on_turn(turn, Duration::from_secs(30)).is_halt(),
|
||||
"a polling run was halted at turn {turn}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_observing_the_same_screen_eventually_stops_asking() {
|
||||
let mut guard = LoopGuard::new(RunPolicy::default());
|
||||
let first = json!({"command": "ls"});
|
||||
guard.on_action(&act("shell", &first, 1, true, true));
|
||||
let eye = json!({});
|
||||
let mut reflected = 0;
|
||||
for turn in 2..=150 {
|
||||
guard.on_action(&act("computer_observe", &eye, turn, true, false));
|
||||
if matches!(
|
||||
guard.on_turn(turn, Duration::from_secs(10)),
|
||||
Verdict::Reflect(_)
|
||||
) {
|
||||
reflected += 1;
|
||||
}
|
||||
}
|
||||
assert!(reflected >= 1, "the run should be asked to step back");
|
||||
assert!(guard.on_turn(152, Duration::from_secs(10)).is_halt());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkpoints_are_asks_not_stops() {
|
||||
let mut guard = LoopGuard::new(RunPolicy::default());
|
||||
assert!(matches!(
|
||||
guard.on_turn(SOFT_CHECKPOINT_TURNS, Duration::from_secs(600)),
|
||||
Verdict::Reflect(_)
|
||||
));
|
||||
assert_eq!(
|
||||
guard.on_turn(SOFT_CHECKPOINT_TURNS + 1, Duration::from_secs(601)),
|
||||
Verdict::Continue
|
||||
);
|
||||
let next = SOFT_CHECKPOINT_TURNS + SOFT_CHECKPOINT_EVERY;
|
||||
assert!(matches!(
|
||||
guard.on_turn(next, Duration::from_secs(700)),
|
||||
Verdict::Reflect(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coaching_is_rare_and_the_breaker_still_works_after_it() {
|
||||
let policy = RunPolicy {
|
||||
soft_checkpoint_turns: 10,
|
||||
soft_checkpoint_every: 10,
|
||||
..RunPolicy::default()
|
||||
};
|
||||
let mut guard = LoopGuard::new(policy);
|
||||
let mut asks = 0;
|
||||
for turn in 1..=120 {
|
||||
if matches!(
|
||||
guard.on_turn(turn, Duration::from_secs(2)),
|
||||
Verdict::Reflect(_)
|
||||
) {
|
||||
asks += 1;
|
||||
}
|
||||
let args = json!({"command": format!("step {turn}")});
|
||||
guard.on_action(&act("shell", &args, turn, true, true));
|
||||
}
|
||||
assert_eq!(asks, MAX_REFLECTIONS as usize, "coaching must stop nagging");
|
||||
assert_eq!(
|
||||
guard.on_turn(HARD_CAP_TURNS, Duration::from_secs(60)),
|
||||
Verdict::Halt {
|
||||
reason: StopReason::BudgetExhausted,
|
||||
note: format!(
|
||||
"已達到保險上限 {HARD_CAP_TURNS} 輪(這是迴圈失控的保護,不是任務做完)"
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_clock_breaks_last_of_all() {
|
||||
let mut guard = LoopGuard::new(RunPolicy::default());
|
||||
assert!(matches!(
|
||||
guard.on_turn(5, Duration::from_secs(SOFT_WALL_MINUTES * 60)),
|
||||
Verdict::Reflect(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
guard.on_turn(6, Duration::from_secs(HARD_WALL_MINUTES * 60 + 1)),
|
||||
Verdict::Halt {
|
||||
reason: StopReason::BudgetExhausted,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resumed_run_is_not_judged_for_the_turns_it_already_has() {
|
||||
let mut guard = LoopGuard::with_watch(RunPolicy::default(), 120);
|
||||
for turn in 120..150 {
|
||||
assert_eq!(
|
||||
guard.on_turn(turn, Duration::from_secs(60)),
|
||||
Verdict::Continue,
|
||||
"turn {turn}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrides_are_opt_in_and_tolerant() {
|
||||
assert_eq!(RunPolicy::from_lookup(|_| None), RunPolicy::default());
|
||||
assert_eq!(
|
||||
RunPolicy::from_lookup(
|
||||
|name| (name == "LAZYBOY_RUN_CAP_TURNS").then(|| "not-a-number".to_string())
|
||||
)
|
||||
.hard_cap_turns,
|
||||
HARD_CAP_TURNS
|
||||
);
|
||||
assert_eq!(
|
||||
RunPolicy::from_lookup(
|
||||
|name| (name == "LAZYBOY_RUN_CAP_TURNS").then(|| "25".to_string())
|
||||
)
|
||||
.hard_cap_turns,
|
||||
25
|
||||
);
|
||||
assert_eq!(
|
||||
RunPolicy::from_lookup(
|
||||
|name| (name == "LAZYBOY_RUN_HARD_MINUTES").then(|| "30".to_string())
|
||||
)
|
||||
.hard_wall,
|
||||
Duration::from_secs(30 * 60)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -101,6 +101,11 @@ services:
|
|||
LAZYBOY_RECORDING_RETENTION_DAYS: ${LAZYBOY_RECORDING_RETENTION_DAYS:-30}
|
||||
LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS: ${LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS:-90}
|
||||
LAZYBOY_DB_WARN_MB: ${LAZYBOY_DB_WARN_MB:-1024}
|
||||
LAZYBOY_RUN_SOFT_TURNS: ${LAZYBOY_RUN_SOFT_TURNS:-60}
|
||||
LAZYBOY_RUN_SOFT_EVERY: ${LAZYBOY_RUN_SOFT_EVERY:-120}
|
||||
LAZYBOY_RUN_CAP_TURNS: ${LAZYBOY_RUN_CAP_TURNS:-1000}
|
||||
LAZYBOY_RUN_SOFT_MINUTES: ${LAZYBOY_RUN_SOFT_MINUTES:-75}
|
||||
LAZYBOY_RUN_HARD_MINUTES: ${LAZYBOY_RUN_HARD_MINUTES:-240}
|
||||
LAZYBOY_WEB_DIR: /web
|
||||
LAZYBOY_SCREEN_UPSTREAM: host.docker.internal
|
||||
ORT_DYLIB_PATH: /usr/local/lib/libonnxruntime.so
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
# AI 使用體驗
|
||||
|
||||
[← 回到 README](../README.zh-TW.md)
|
||||
|
||||
這份文件記錄 LazyBoy 怎麼讓「AI 用電腦」這件事變得跟人一樣順,以及背後的取捨。
|
||||
三個主題其實是同一件事:系統如果把 AI 使用電腦當成一系列無狀態的請求,AI 就必須每輪重新
|
||||
交代自己在哪裡、在做什麼,界面也只能用輪詢去猜現在發生了什麼。
|
||||
|
||||
| 主題 | 一句話 | 細節 |
|
||||
| --- | --- | --- |
|
||||
| 任務長度 | 沒有輪數額度,只有偵測鬼打牆的保險絲 | [輪次政策](./operations.md#任務跑多久輪次政策) |
|
||||
| 容器內終端機 | 一台活的 tmux,人和 AI 看同一個 shell | [終端機](./operations.md#容器內終端機) |
|
||||
| 聊天即時性 | 事件推送,輪詢只是兜底 | 本文件下面 |
|
||||
|
||||
## 輪數不是額度,是偵錯工具
|
||||
|
||||
以前一個 run 固定 40 輪,等於假設所有任務一樣長:洗資料這種事情做不完,但也不會因此變成
|
||||
錯誤,只是被腰斬。現在的正常結局是**把事情做完**(模型提出驗證,或明白說它卡在哪裡),會
|
||||
被停下來的只有鬼打牆:同一個會改變狀態的動作連做六次、同一個錯誤連錯八次、連續十四個動作
|
||||
全部失敗。觀察、等待、輪詢不算重複動作,所以等一個長 build、等下載、輪詢佇列都不會被打斷。
|
||||
|
||||
設計上要記住兩件事:
|
||||
|
||||
- 提示(「你已經做同一件事三次了」)永远不會停掉任務,只有重複到像故障才會暫停,暫停也是停在
|
||||
現況、按「繼續」接下去,不重來。
|
||||
- 保險絲(`budget_exhausted`)代表迴圈失控,是故障不是成績;它照樣可以續跑,但值得去看執行記錄。
|
||||
|
||||
純聊天仍然是 4 輪上限:那是避免模型在閒聊裡燒額度,跟任務長度無關。
|
||||
|
||||
## 終端機是一台活的 tmux
|
||||
|
||||
`shell` 工具以前每條命令都是一次性的 `bash -lc`:`cd` 留不住、`export` 留不住、背景起的服務
|
||||
跟著一起死,模型每次都要重新走回工作目錄,遇到互動式程式就整個卡死。現在每個終端機名字對應
|
||||
容器內一個 tmux session(`lazyboy-main`),AI 用的就是人在桌面上會用的那個 shell。
|
||||
|
||||
有了持久終端機,模型才做得順這些事:
|
||||
|
||||
- `python -m venv .venv && source .venv/bin/activate` 之後,下一條命令還在同一個環境裡。
|
||||
- `npm run dev &` 之後可以繼續做別的,回來用 `log_lines` 讀同一個終端機的輸出。
|
||||
- `ssh`、`gdb`、`psql`、Python REPL 這類會問你話的程式,用 `keys` 回它,而不是直接超時。
|
||||
- 按錯 Ctrl-C 只會中斷那台終端機裡正在跑的東西,不會把整個工作環境帶走。
|
||||
|
||||
**完成是怎麼判定的。** API 進到容器是一次性的 exec(argv 進、stdout 出,沒有串流),所以
|
||||
「跑完了沒」不是看 exec 有沒有結束,而是看終端機裡印了什麼:每次呼叫會在 shell 內 source 一個
|
||||
檔名隨機的腳本,腳本用 `trap ... EXIT` 印出 `LB_END <nonce> rc=<code>`;還看到 `LB_READY`
|
||||
代表上一件事已經被中斷、終端機是可用的。標記只在**行首**比對——命令本身會被 shell echo 一次,
|
||||
那行也帶有標記,比錯就會以為已經跑完了。
|
||||
|
||||
**死了會自己站起來。** `exit`、`exec bash`、被 kill 掉都會讓 pane 不見;這時候終端機重新
|
||||
`respawn`,並把剛才的工作目錄與 `export` 過的環境變數還原回去,模型不需要重新交代。
|
||||
|
||||
人可以隨時看同一台終端機,或直接接手(指令見
|
||||
[容器內終端機](./operations.md#容器內終端機))。這不隻是好看:AI 卡在同一個地方時,你看到的
|
||||
就是它看到的那個 shell。
|
||||
|
||||
行為用 `python3 tests/shell-session.test.py` 驗證(10 個案例,含 exit code 還原、自我重生、
|
||||
中斷後可續用)。
|
||||
|
||||
## 聊天是推送,不是輪詢
|
||||
|
||||
事件早就寫進 `events` 表,也有 SSE 端點,但兩端都在猜:伺服器每 750 ms 掃一次資料庫,瀏覽器
|
||||
每 2 秒重刷一次全部狀態(訊息、電腦狀態、螢幕、技能、心跳一起打)。所以一個回覆最快也要兩輪
|
||||
輪詢才被看見。
|
||||
|
||||
現在的管線:
|
||||
|
||||
1. 寫事件的事實不變——`events` 表仍然是順序與重放的依據(游標是 `threads.next_event_seq`,
|
||||
斷線重連用 `Last-Event-ID` 從資料庫重放,不會漏也不會重複)。
|
||||
2. 提交之後往進程內的 wake channel 敲一下(`WakeBus`)。SSE 端點不再固定間隔輪詢,而是等敲;
|
||||
敲不到時(多副本、讀者落後)以 5 秒兜底輪詢補上。
|
||||
3. 瀏覽器用 `EventSource` 訂閱 `/api/sessions/{id}/events`,事件在 120 ms 的窗口裡合流,
|
||||
一回合變動只刷新一次。
|
||||
|
||||
實測(本機 loopback、丟棄式資料庫、同一台機器):
|
||||
|
||||
| 事件 | 以前 | 現在 |
|
||||
| --- | --- | --- |
|
||||
| 自己發的訊息出現在畫面 | 最多 2 秒 | 送出去後約 25 ms |
|
||||
| 「思考中」指示出現 | 最多 2 秒 | 約 32 ms(worker 也被敲醒,不再等 200 ms 定時器) |
|
||||
| 模型回覆出現 | 寫入後最多 2.75 秒 | 寫入後約 25 ms(模型本身要幾秒是另一回事) |
|
||||
|
||||
順帶把浪費掉的工作拿掉:分頁藏在後面時不再刷新(回到前景立刻補一次),心跳從 2 秒改成
|
||||
60 秒——一次心跳買的是 15 分鐘控制租約、避免 10 分鐘閒置暫停,60 秒是很寬的餘裕。
|
||||
|
||||
**壞了會怎樣。** SSE 被 proxy 擋住或斷線,瀏覽器察覺後自動回到 2 秒輪詢,等於退回以前的行為,
|
||||
不會變成不更新;伺服器端事件順序仍然只以資料庫為準,wake 不見得可靠,只是慢。
|
||||
|
||||
自己量一次:
|
||||
|
||||
```bash
|
||||
curl -N -b cookies.txt http://127.0.0.1:3101/api/sessions/<id>/events
|
||||
# 另開一個終端機送訊息,看事件幾毫秒後出現在這條串流裡
|
||||
```
|
||||
|
||||
## 已知取捨
|
||||
|
||||
- 模型回覆還是「整個完成」才出現,沒有串流 token。思考中的狀態有顯示,但要像 ChatGPT 那樣逐字
|
||||
冒出,需要把 completion 換成串流並處理中斷/接續,這是下一件事。
|
||||
- 電腦狀態(執行中/暫停、誰在控制)沒有自己的事件,只能靠兜底輪詢更新,`LIVE_POLL_MS` 目前是 4 秒;
|
||||
桌面畫面本身是即時串流,只有狀態列會慢幾秒。要真正事件化得把 `computers` 的狀態變更也寫進
|
||||
`events`(狀態是 bot 層、事件是執行緒層,要先決定寫到哪條執行緒)。
|
||||
- 滑鼠停在頭像上看到的執行記錄泡泡,開啟時仍是輪詢(它只在滑鼠停留時開啟,量很小)。
|
||||
- wake channel 是單進程的。多副本部署時,其他副本靠 5 秒安全輪詢追上;真要横向擴充應改用
|
||||
Postgres `LISTEN/NOTIFY`。
|
||||
- 持久終端機需要桌面映像檔裡的 `tmux`;舊映像檔會退回一次性 shell,`docker compose build computer`
|
||||
之後就有了。
|
||||
- 容器執行檔仍以 uid 1000、限制環境變數的方式進入容器,持久化的是 shell 狀態,不是憑證。
|
||||
|
|
@ -36,6 +36,7 @@ cd ../..
|
|||
node --test tests/frontend.test.mjs
|
||||
python3 tests/control.test.py
|
||||
python3 tests/log-rotation.test.py
|
||||
python3 tests/shell-session.test.py # 持久終端機腳本,只需要 tmux
|
||||
|
||||
# Python 整合測試用 docker compose exec 連進 Postgres,自己建一次性資料庫後清掉
|
||||
python3 tests/retention.test.py
|
||||
|
|
|
|||
|
|
@ -108,6 +108,69 @@ docker compose up -d api
|
|||
每小時清理一次。想留更久的除錯軌跡就調大,在意資料庫體積就調小;run 本身被
|
||||
`LAZYBOY_RUN_RETENTION_DAYS` 清掉時,它的記錄一併消失。
|
||||
|
||||
## 任務跑多久:輪次政策
|
||||
|
||||
一輪就是一次模型呼叫。任務**沒有輪數額度**:電腦工作會一直做到模型提出驗證、或明白說卡在哪裡。
|
||||
停下來只有三種原因,由輕到重:
|
||||
|
||||
| 層 | 觸發 | 結果 |
|
||||
| --- | --- | --- |
|
||||
| 提示 | 同一個動作連做 3 次、同一個錯誤連錯 3 次、40 輪沒有新的成功、到檢查點(第 60 輪起每 120 輪)、跑超過 `LAZYBOY_RUN_SOFT_MINUTES` | 系統在下一輪前塞一句具體提醒,任務**繼續**;一次最多提示 8 次,不會變噪音 |
|
||||
| 暫停(`loop_detected`) | 同一個**會改變狀態**的動作連做 6 次或整輪累計 12 次、同一個動作連錯 8 次、連續 14 個動作都失敗、150 輪沒有任何新的成功 | 任務停在目前畫面,訊息裡附上「哪個動作重複了幾次」,按「繼續」就從中斷點接下去 |
|
||||
| 保險絲(`budget_exhausted`) | 超過 `LAZYBOY_RUN_CAP_TURNS` 輪或 `LAZYBOY_RUN_HARD_MINUTES` 分鐘 | 代表迴圈失控,是故障不是成績;照樣可續跑,但要去看執行記錄 |
|
||||
|
||||
- 「看畫面、等待、讀檔」不算鬼打牆:觀察與輪詢不會被當成重複動作,等待長 build、下載、佇列都是正常的。
|
||||
- 計時從**本次嘗試**開始算:任務停下來等你回話三天,續跑時時鐘歸零。
|
||||
- 聊天(沒有電腦工作的純對話)仍然是 4 輪上限,那是避免模型在閒聊中燒掉額度,跟任務長度無關。
|
||||
|
||||
微調方式寫在 `.env`(改完重建 api 容器生效):
|
||||
|
||||
```bash
|
||||
LAZYBOY_RUN_SOFT_TURNS=60 # 第一次自我檢查點的輪數
|
||||
LAZYBOY_RUN_SOFT_EVERY=120 # 之後每隔多少輪再檢查一次
|
||||
LAZYBOY_RUN_CAP_TURNS=1000 # 輪數保險絲
|
||||
LAZYBOY_RUN_SOFT_MINUTES=75 # 自我檢查的時間點
|
||||
LAZYBOY_RUN_HARD_MINUTES=240 # 時間保險絲
|
||||
```
|
||||
|
||||
真的很久的工作(大計畫、批量資料處理)不該塞在一個 run 裡,改用排程工作分段跑,比較容易驗證也比較省 token。
|
||||
|
||||
## 容器內終端機
|
||||
|
||||
AI 的 `shell` 不再每條命令開一個新的 `bash -lc`,而是用**有名字的 tmux 終端機**:工作目錄、
|
||||
`export`、背景程序、互動式程式都留在原處,跟人用同一台終端機一樣。
|
||||
|
||||
| 參數 | 作用 |
|
||||
| --- | --- |
|
||||
| `command` | 要執行的命令 |
|
||||
| `session` | 終端機名稱,預設 `main`(tmux 內是 `lazyboy-main`);不同的工作可以分台跑 |
|
||||
| `wait_ms` | 最多等多久,預設 20 秒、上限 110 秒。時間到還沒結束就回傳「還在跑」 |
|
||||
| `log_lines` | 不給 `command` 時,讀終端機目前顯示的內容 |
|
||||
| `keys` | 送按鍵或文字:`C-c`、`Enter`,或逐字輸入給互動式提示 |
|
||||
| `reset` | 收掉這台終端機重開一台 |
|
||||
| `cwd` | 先切到這個目錄再執行 |
|
||||
|
||||
- 長工作**不要**把 `wait_ms` 拉很長:先讓它回傳「還在跑」,之後用 `log_lines` 續讀。觀察與輪詢
|
||||
不被當成鬼打牆(見[任務跑多久:輪次政策](#任務跑多久輪次政策))。
|
||||
- 終端機被 `exit`、`exec bash` 或 Ctrl-C 打死時會自動重建,並回到原本的目錄、重新載入原本
|
||||
`export` 的環境變數。
|
||||
- 人可以隨時看同一台終端機。終端機跑在容器裡的 uid 1000,容器名稱是 `lb-<主線>`:
|
||||
|
||||
```bash
|
||||
docker exec -it -u 1000:1000 lb-team-local-space tmux ls
|
||||
docker exec -it -u 1000:1000 lb-team-local-space tmux attach -t lazyboy-main # Ctrl-b d 離開
|
||||
```
|
||||
|
||||
聊天時直接叫 AI「把終端機開給我看」也可以,它會用 `lazyboy-shell show` 在桌面開一個視窗,
|
||||
你按同一段鍵盤就能接手。
|
||||
|
||||
- 桌面映像檔需要 `tmux`。映像檔裡沒有 `lazyboy-shell` 時,命令退回一次性 `bash -lc`(可用,
|
||||
但不保留狀態),重建後再請 AI `reset` 一次即可:
|
||||
|
||||
```bash
|
||||
docker compose build computer # 或 make computer
|
||||
```
|
||||
|
||||
## 網站連線驗證
|
||||
|
||||
AI 遇到可辨識的 Cloudflare 連線驗證頁時,會先取得最新桌面截圖,再使用
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ RUN printf '%s\n' \
|
|||
python3 \
|
||||
python3-websocket \
|
||||
sudo \
|
||||
tmux \
|
||||
gosu \
|
||||
util-linux \
|
||||
websockify \
|
||||
|
|
@ -103,6 +104,7 @@ COPY --chmod=755 image/computer/rotate-logs.py /usr/local/bin/lazyboy-rotate-log
|
|||
COPY --chmod=755 image/computer/lazyboy-screen /usr/local/bin/lazyboy-screen
|
||||
COPY --chmod=755 image/computer/lazyboy-browser /usr/local/bin/lazyboy-browser
|
||||
COPY --chmod=755 image/computer/lazyboy-terminal /usr/local/bin/lazyboy-terminal
|
||||
COPY --chmod=755 image/computer/lazyboy-shell /usr/local/bin/lazyboy-shell
|
||||
COPY --chmod=644 apps/web/vnc.html /usr/share/novnc/vnc_lite.html
|
||||
COPY --chmod=644 apps/web/vnc.html /usr/share/novnc/index.html
|
||||
COPY --chmod=644 image/computer/fonts.conf /etc/fonts/conf.d/99-lazyboy-cjk.conf
|
||||
|
|
|
|||
|
|
@ -0,0 +1,441 @@
|
|||
#!/bin/sh
|
||||
# Persistent terminals for the agent, on top of tmux.
|
||||
#
|
||||
# A `shell` call used to be a fresh `bash -lc`: the working directory, exports,
|
||||
# and anything started in the background died with the call, so the model had to
|
||||
# re-derive its way back to a working shell every time. A named tmux session per
|
||||
# agent terminal keeps what a human keeps - one place to stand, jobs that stay
|
||||
# alive, a Ctrl-C that interrupts the right thing - and the human can watch the
|
||||
# same terminal on the desktop with `show`.
|
||||
#
|
||||
# The API reaches it through the container exec path (one-shot, argv only, no
|
||||
# streaming), so finishing is detected by markers in the pane rather than by the
|
||||
# exit of the exec itself. Markers are matched only at the start of a line: the
|
||||
# shell echoes the typed command, and that echo contains both markers too.
|
||||
#
|
||||
# lazyboy-shell run <session> <wait_ms> <command> run, wait, print output + exit code
|
||||
# lazyboy-shell log <session> [lines] what the terminal shows right now
|
||||
# lazyboy-shell keys <session> <key>... C-c / Enter / literal text
|
||||
# lazyboy-shell show <session> open it on the desktop for a human
|
||||
# lazyboy-shell reset <session> drop the session and start clean
|
||||
# lazyboy-shell list sessions and their state
|
||||
#
|
||||
# Every call is sourced into the live shell (`. file`), which is what makes
|
||||
# state survive and also what would let `exit` take the terminal down; a trap on
|
||||
# EXIT turns that into a normal end marker and the pane is restarted.
|
||||
|
||||
set -eu
|
||||
|
||||
# Wide enough that compiler output and `ls -l` do not wrap, short enough to stay
|
||||
# cheap to capture after every call.
|
||||
COLS=220
|
||||
ROWS=50
|
||||
HISTORY=50000
|
||||
BACK=4000
|
||||
# Guard the model's context: keep the head and the tail of very chatty commands.
|
||||
MAX_OUT=20000
|
||||
# A marker that scrolled away must not lock a terminal forever.
|
||||
STALE_AFTER=600
|
||||
|
||||
export LANG="${LANG:-zh_TW.UTF-8}"
|
||||
export LC_ALL="${LC_ALL:-zh_TW.UTF-8}"
|
||||
export TMUX_TMPDIR="${TMUX_TMPDIR:-/tmp}"
|
||||
STATE_DIR="${LAZYBOY_SHELL_STATE:-${TMPDIR:-/tmp}/lazyboy-shell}"
|
||||
|
||||
die() {
|
||||
printf 'error: %s\n' "$1" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
need_tmux() {
|
||||
command -v tmux >/dev/null 2>&1 ||
|
||||
die "tmux is missing from this desktop image (rebuild with: make computer)"
|
||||
}
|
||||
|
||||
# Paths end up inside the shell code the pane sources; single quotes are the
|
||||
# only thing that has to survive.
|
||||
sq() {
|
||||
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
|
||||
}
|
||||
|
||||
# Session names are model input: keep them boring, and namespace them so an
|
||||
# agent terminal never collides with a tmux session the human started.
|
||||
session_of() {
|
||||
case "$1" in
|
||||
'' | *[!A-Za-z0-9._-]*) die "session name must use [A-Za-z0-9._-], got: $1" ;;
|
||||
esac
|
||||
printf 'lazyboy-%s' "$1"
|
||||
}
|
||||
|
||||
# Pane commands name an explicit pane: a detached tmux server has no current
|
||||
# pane for the session-only forms to resolve against, and set-option reads the
|
||||
# target as a window, so even session options go through the pane.
|
||||
pane_target() {
|
||||
printf '=%s:0.0' "$1"
|
||||
}
|
||||
|
||||
pane_of() {
|
||||
tmux capture-pane -p -J -S "-$BACK" -t "$(pane_target "$1")" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Tmux paints; the model reads text.
|
||||
clean() {
|
||||
sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' -e 's/\x1b[()][A-Za-z0-9]//g' -e 's/\x1b[>=]//g' |
|
||||
tr -d '\r'
|
||||
}
|
||||
|
||||
# A pane is a fixed grid, so below the prompt there are only empty rows. Tailing
|
||||
# one without trimming reads as a blank screen.
|
||||
trim_blanks() {
|
||||
awk '{ line[NR] = $0; if ($0 !~ /^[ \t]*$/) last = NR }
|
||||
END { for (i = 1; i <= last; i++) print line[i] }'
|
||||
}
|
||||
|
||||
# Everything the terminal showed since this call printed its start marker.
|
||||
after_start() {
|
||||
printf '%s\n' "$1" | awk -v start="LB_START $2" '
|
||||
index($0, start) == 1 { keep = 1; next }
|
||||
keep { print }'
|
||||
}
|
||||
|
||||
finished_in() {
|
||||
printf '%s\n' "$1" | grep -q "^LB_END $2 rc=[0-9][0-9]*$"
|
||||
}
|
||||
|
||||
exit_code_in() {
|
||||
printf '%s\n' "$1" | sed -n "s/^LB_END $2 rc=\\([0-9][0-9]*\\)\$/\\1/p" | tail -n 1
|
||||
}
|
||||
|
||||
# The shell is back at its prompt for this call, without having printed an end
|
||||
# marker: the command was interrupted (or abandoned the runner). Either way the
|
||||
# terminal belongs to the next command.
|
||||
ready_in() {
|
||||
after_start "$1" "$2" | grep -q '^LB_READY$'
|
||||
}
|
||||
|
||||
is_dead() {
|
||||
[ "$(tmux display-message -p -t "$(pane_target "$1")" '#{pane_dead}' 2>/dev/null)" = "1" ]
|
||||
}
|
||||
|
||||
# Fallback for when the integration itself is gone (the model ran `exec bash`,
|
||||
# replaced PROMPT_COMMAND, ...): a shell sitting on its prompt is free.
|
||||
at_prompt() {
|
||||
case "$(tmux display-message -p -t "$(pane_target "$1")" '#{pane_current_command}' 2>/dev/null)" in
|
||||
bash | -bash | zsh | -zsh | sh | dash | '' ) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
after_start "$(pane_of "$1")" "${2:-}" | clean | trim_blanks | tail -n 1 |
|
||||
grep -qE '[#$%>][[:space:]]*$'
|
||||
}
|
||||
|
||||
# Free for the next command even though this call never reported a result:
|
||||
# interrupted at a prompt, replaced its own shell, or died outright.
|
||||
released() {
|
||||
# Distinct names: sh functions share the caller's variables.
|
||||
lb_pane="$1"
|
||||
lb_pending="$2"
|
||||
lb_name="$3"
|
||||
if ready_in "$lb_pane" "$lb_pending"; then
|
||||
return 0
|
||||
fi
|
||||
is_dead "$lb_name" || at_prompt "$lb_name" "$lb_pending"
|
||||
}
|
||||
|
||||
truncate_out() {
|
||||
awk -v max="$MAX_OUT" '
|
||||
{ if (length(all) < max) all = all $0 "\n"; else dropped = 1 }
|
||||
END {
|
||||
printf "%s", all
|
||||
if (dropped) printf "\n…(輸出過長,已截斷尾段;完整輸出請在 shell 裡用 > 寫進檔案再讀)\n"
|
||||
}'
|
||||
}
|
||||
|
||||
# Text between this call's start marker and its end marker.
|
||||
output_between() {
|
||||
printf '%s\n' "$1" | awk -v start="LB_START $2" -v end="LB_END $2 " '
|
||||
index($0, start) == 1 { keep = 1; next }
|
||||
index($0, end) == 1 { keep = 0 }
|
||||
keep { print }'
|
||||
}
|
||||
|
||||
state_dir_ready() {
|
||||
mkdir -p "$STATE_DIR"
|
||||
# Snapshots hold exported variables, so keep them out of other users' reach.
|
||||
chmod 700 "$STATE_DIR" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# The shell in a dead pane is restarted where it left off: same directory, and
|
||||
# the next command re-loads the exported variables from the last snapshot.
|
||||
revive_pane() {
|
||||
name="$1"
|
||||
env_file="$STATE_DIR/$name.env"
|
||||
dir="${HOME:-/tmp}"
|
||||
if [ -f "$env_file" ]; then
|
||||
saved=$(sed -n '1p' "$env_file" 2>/dev/null || true)
|
||||
if [ -n "$saved" ] && [ -d "$saved" ]; then
|
||||
dir="$saved"
|
||||
fi
|
||||
fi
|
||||
tmux respawn-pane -k -t "$(pane_target "$name")" -c "$dir" /bin/bash -i 2>/dev/null || true
|
||||
: >"$STATE_DIR/$name.revive"
|
||||
}
|
||||
|
||||
# REVIVED tells the caller whether it has to say something about the restart.
|
||||
REVIVED=no
|
||||
ensure_session() {
|
||||
name="$1"
|
||||
state_dir_ready
|
||||
if ! tmux has-session -t "=$name" 2>/dev/null; then
|
||||
tmux new-session -d -s "$name" -x "$COLS" -y "$ROWS" -c "${HOME:-/tmp}" "/bin/bash -i"
|
||||
fi
|
||||
target=$(pane_target "$name")
|
||||
# remain-on-exit keeps a dead pane readable, so `exit` in a command does not
|
||||
# swallow the output the model still needs; the history has to outlive a
|
||||
# compile.
|
||||
tmux set-option -t "$target" history-limit "$HISTORY" 2>/dev/null || true
|
||||
tmux set-option -t "$target" remain-on-exit on 2>/dev/null || true
|
||||
if is_dead "$name"; then
|
||||
revive_pane "$name"
|
||||
REVIVED=yes
|
||||
fi
|
||||
}
|
||||
|
||||
# The shell code a call types into the pane. It is sourced, so `cd` and exports
|
||||
# land in the terminal's own shell; it carries its own integration, so a model
|
||||
# that clobbers PROMPT_COMMAND or replaces the shell only loses it for one call.
|
||||
write_runner() {
|
||||
run_file="$1"
|
||||
name="$2"
|
||||
nonce="$3"
|
||||
command="$4"
|
||||
env_file="$STATE_DIR/$name.env"
|
||||
{
|
||||
printf '%s\n' '# LazyBoy agent terminal (see image/computer/lazyboy-shell)'
|
||||
printf 'LB_STATE_DIR=%s\n' "$(sq "$STATE_DIR")"
|
||||
printf '%s\n' 'lb_ready() { printf "\nLB_READY\n"; }'
|
||||
printf 'LB_ENV=%s\n' "$(sq "$env_file")"
|
||||
printf '%s\n' 'lb_snapshot() { { pwd -P; export -p; } >"$LB_ENV" 2>/dev/null; }'
|
||||
printf '%s\n' 'lb_exit() { printf "\nLB_END %s rc=%s\n" "${LB_NONCE:-shell}" "$1"; lb_snapshot; }'
|
||||
printf '%s\n' 'case "${PROMPT_COMMAND-}" in *lb_ready*) : ;; *) PROMPT_COMMAND="lb_ready${PROMPT_COMMAND:+;$PROMPT_COMMAND}" ;; esac'
|
||||
printf '%s\n' 'trap '"'"'lb_exit "$?"'"'"' EXIT'
|
||||
if [ -f "$STATE_DIR/$name.revive" ] && [ -f "$env_file" ]; then
|
||||
printf '%s\n' '{ cd "$(sed -n 1p -- '"$(sq "$env_file")"')" 2>/dev/null;' \
|
||||
'eval "$(tail -n +2 -- '"$(sq "$env_file")"' 2>/dev/null)"; } >/dev/null 2>&1 || true'
|
||||
rm -f "$STATE_DIR/$name.revive"
|
||||
fi
|
||||
printf 'LB_NONCE=%s\n' "$(sq "$nonce")"
|
||||
printf 'printf %s\n' "'\nLB_START $nonce\n'"
|
||||
printf '%s\n' "$command"
|
||||
printf '%s\n' '__lb_rc=$?'
|
||||
printf 'printf %s\n' "'\nLB_END $nonce rc=%s\n' \"\$__lb_rc\""
|
||||
} >"$run_file"
|
||||
}
|
||||
|
||||
do_run() {
|
||||
[ "$#" -ge 3 ] || die "run needs <session> <wait_ms> <command>"
|
||||
session="$1"
|
||||
wait_ms="$2"
|
||||
command="$3"
|
||||
case "$wait_ms" in '' | *[!0-9]*) die "wait_ms must be a number" ;; esac
|
||||
need_tmux
|
||||
name=$(session_of "$session")
|
||||
ensure_session "$name"
|
||||
nonce="$(date +%s%N)-$$"
|
||||
pending_file="$STATE_DIR/$name.pending"
|
||||
run_file="$STATE_DIR/$name.sh"
|
||||
|
||||
# An earlier call may still own this terminal. Typing now would feed the
|
||||
# running program instead of the shell, so say so rather than corrupt it.
|
||||
pending=$(sed -n 's/^nonce=\(.*\)$/\1/p' "$pending_file" 2>/dev/null || true)
|
||||
started=$(sed -n 's/^started=\(.*\)$/\1/p' "$pending_file" 2>/dev/null || true)
|
||||
if [ -n "$pending" ]; then
|
||||
pane=$(pane_of "$name")
|
||||
if finished_in "$pane" "$pending" || released "$pane" "$pending" "$name"; then
|
||||
# Finished, interrupted, or crashed: the marker is gone or meaningless,
|
||||
# and the next command can have the terminal.
|
||||
pending=""
|
||||
rm -f "$pending_file"
|
||||
elif [ -n "$started" ] && [ $(( $(date +%s) - started )) -ge "$STALE_AFTER" ] &&
|
||||
printf '%s\n' "$pane" | clean | trim_blanks | tail -n 3 | grep -qE '[\$#>] ?$'; then
|
||||
# A marker that scrolled out of the capture window would otherwise lock
|
||||
# this terminal forever; an idle prompt after ten minutes means free.
|
||||
pending=""
|
||||
rm -f "$pending_file"
|
||||
fi
|
||||
if [ -n "$pending" ]; then
|
||||
printf 'status=running session=%s\n' "$session"
|
||||
printf 'This terminal is still busy with an earlier command, so nothing was typed.\n'
|
||||
printf 'Read it with shell {"session":"%s","logLines":120}, interrupt it with\n' "$session"
|
||||
printf '{"session":"%s","keys":"C-c"}, or reset it with {"session":"%s","reset":true}.\n' "$session" "$session"
|
||||
printf -- '--- terminal ---\n'
|
||||
pane_of "$name" | clean | trim_blanks | tail -n 60 | truncate_out
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
write_runner "$run_file" "$name" "$nonce" "$command"
|
||||
{
|
||||
printf 'nonce=%s\n' "$nonce"
|
||||
printf 'started=%s\n' "$(date +%s)"
|
||||
} >"$pending_file"
|
||||
|
||||
# One short typed line: the runner itself lives in a file, so nothing about
|
||||
# the command needs quoting and long commands cannot outgrow send-keys.
|
||||
target=$(pane_target "$name")
|
||||
tmux send-keys -t "$target" -l -- ". $(sq "$run_file")"
|
||||
tmux send-keys -t "$target" Enter
|
||||
|
||||
deadline=$(( $(date +%s) + wait_ms / 1000 + 1 ))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
pane=$(pane_of "$name")
|
||||
if finished_in "$pane" "$nonce"; then
|
||||
rm -f "$pending_file"
|
||||
printf 'status=done session=%s exit=%s\n' "$session" "$(exit_code_in "$pane" "$nonce")"
|
||||
printf -- '--- output ---\n'
|
||||
output_between "$pane" "$nonce" | clean | truncate_out
|
||||
# `exit` in a command, or a shell that died by itself: hand the next
|
||||
# command a terminal again instead of a dead pane.
|
||||
if is_dead "$name"; then
|
||||
revive_pane "$name"
|
||||
printf 'note: the shell in this terminal exited; it was restarted in the same directory,\n'
|
||||
printf 'so cd and exported variables are back but background jobs of that shell are gone.\n'
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
if is_dead "$name"; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
if is_dead "$name"; then
|
||||
rm -f "$pending_file"
|
||||
printf 'status=closed session=%s\n' "$session"
|
||||
printf 'The shell in this terminal exited before it could report a result.\n'
|
||||
printf -- '--- terminal ---\n'
|
||||
pane_of "$name" | clean | trim_blanks | tail -n 60 | truncate_out
|
||||
revive_pane "$name"
|
||||
printf 'note: it was restarted in the same directory and is ready for the next command.\n'
|
||||
return 0
|
||||
fi
|
||||
printf 'status=running session=%s waitedMs=%s\n' "$session" "$wait_ms"
|
||||
printf 'The command is still running; the output so far follows. Do not type another command into\n'
|
||||
printf 'this terminal - poll with shell {"session":"%s","logLines":120} or interrupt with {"keys":"C-c"}.\n' "$session"
|
||||
printf -- '--- terminal ---\n'
|
||||
pane_of "$name" | clean | trim_blanks | tail -n 60 | truncate_out
|
||||
}
|
||||
|
||||
do_log() {
|
||||
[ "$#" -ge 1 ] || die "log needs <session>"
|
||||
session="$1"
|
||||
lines="${2:-80}"
|
||||
case "$lines" in '' | *[!0-9]*) die "lines must be a number" ;; esac
|
||||
need_tmux
|
||||
name=$(session_of "$session")
|
||||
tmux has-session -t "=$name" 2>/dev/null || {
|
||||
printf 'status=idle session=%s\nThis terminal has not been used yet.\n' "$session"
|
||||
return 0
|
||||
}
|
||||
pending=$(sed -n 's/^nonce=\(.*\)$/\1/p' "$STATE_DIR/$name.pending" 2>/dev/null || true)
|
||||
pane=$(pane_of "$name")
|
||||
if is_dead "$name"; then
|
||||
printf 'status=closed session=%s\n' "$session"
|
||||
elif [ -n "$pending" ] && ! finished_in "$pane" "$pending" &&
|
||||
! released "$pane" "$pending" "$name"; then
|
||||
printf 'status=running session=%s\nThe command from the earlier call is still running.\n' "$session"
|
||||
else
|
||||
rm -f "$STATE_DIR/$name.pending"
|
||||
printf 'status=idle session=%s\n' "$session"
|
||||
fi
|
||||
printf -- '--- terminal ---\n'
|
||||
printf '%s\n' "$pane" | clean | trim_blanks | tail -n "$lines" | truncate_out
|
||||
}
|
||||
|
||||
# A key name tmux understands is sent as a key; anything else is typed as text.
|
||||
send_one() {
|
||||
target=$(pane_target "$name")
|
||||
case "$1" in
|
||||
C-* | M-* | Enter | Return | Escape | Esc | Tab | BSpace | DC | IC | \
|
||||
Up | Down | Left | Right | Home | End | PageUp | PageDown | F[1-9] | F1[0-2])
|
||||
tmux send-keys -t "$target" "$1"
|
||||
;;
|
||||
*)
|
||||
tmux send-keys -t "$target" -l -- "$1"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
do_keys() {
|
||||
[ "$#" -ge 2 ] || die "keys needs <session> <key>..."
|
||||
session="$1"
|
||||
shift
|
||||
need_tmux
|
||||
name=$(session_of "$session")
|
||||
ensure_session "$name"
|
||||
for key in "$@"; do
|
||||
case "$key" in
|
||||
# Ctrl-C throws the end marker away with the command, which would leave
|
||||
# the terminal looking busy forever; the interrupt *is* the release.
|
||||
C-c | C-\\ | C-z) rm -f "$STATE_DIR/$name.pending" ;;
|
||||
esac
|
||||
send_one "$key"
|
||||
done
|
||||
printf 'status=sent session=%s keys=%s\n' "$session" "$*"
|
||||
printf 'Use shell {"session":"%s","logLines":60} to see what it did.\n' "$session"
|
||||
}
|
||||
|
||||
do_show() {
|
||||
[ "$#" -ge 1 ] || die "show needs <session>"
|
||||
session="$1"
|
||||
need_tmux
|
||||
name=$(session_of "$session")
|
||||
ensure_session "$name"
|
||||
command -v xfce4-terminal >/dev/null 2>&1 ||
|
||||
die "no desktop terminal available to show this session"
|
||||
# Detached so the window outlives this exec: the human sees the same terminal
|
||||
# the agent types in and can click into it to take over.
|
||||
setsid nohup xfce4-terminal --disable-server --geometry=112x30+64+64 \
|
||||
--title="終端機 $session" --command="tmux attach -t $name" >/dev/null 2>&1 &
|
||||
printf 'status=shown session=%s\nThe terminal is open on the desktop screen.\n' "$session"
|
||||
}
|
||||
|
||||
do_reset() {
|
||||
[ "$#" -ge 1 ] || die "reset needs <session>"
|
||||
need_tmux
|
||||
name=$(session_of "$1")
|
||||
tmux kill-session -t "=$name" 2>/dev/null || true
|
||||
rm -f "$STATE_DIR/$name.pending" "$STATE_DIR/$name.revive" "$STATE_DIR/$name.env"
|
||||
ensure_session "$name"
|
||||
printf 'status=reset session=%s\n' "$1"
|
||||
}
|
||||
|
||||
do_list() {
|
||||
need_tmux
|
||||
panes=$(tmux list-panes -a -F '#{session_name}|#{pane_dead}' 2>/dev/null || true)
|
||||
if [ -z "$panes" ]; then
|
||||
printf 'status=empty\nNo agent terminals are running.\n'
|
||||
return 0
|
||||
fi
|
||||
printf '%s\n' "$panes" | while IFS='|' read -r name dead; do
|
||||
case "$name" in
|
||||
lazyboy-*)
|
||||
if [ "$dead" = 1 ]; then
|
||||
printf 'session=%s state=closed\n' "${name#lazyboy-}"
|
||||
else
|
||||
printf 'session=%s state=open\n' "${name#lazyboy-}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
program=${0##*/}
|
||||
case "${1:-}" in
|
||||
run) shift; do_run "$@" ;;
|
||||
log) shift; do_log "$@" ;;
|
||||
keys) shift; do_keys "$@" ;;
|
||||
show) shift; do_show "$@" ;;
|
||||
reset) shift; do_reset "$@" ;;
|
||||
list) shift; do_list ;;
|
||||
*) die "usage: $program run|log|keys|show|reset|list ..." ;;
|
||||
esac
|
||||
|
|
@ -276,13 +276,18 @@ test('mobile shortcut row sends modifiers and hides with the keyboard',()=>{
|
|||
});
|
||||
test('status and HUD sit outside the remote pixels; screenshots stay opt-in',()=>{
|
||||
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
|
||||
assert.match(app,/\{!computerOpen && <RunStatus runId=\{liveRunId\}\/>\}/);
|
||||
assert.match(app,/<RunStatus runId=\{liveRunId\}\/>\{hud\}/);
|
||||
assert.doesNotMatch(app,/RunStatus/);
|
||||
assert.match(app,/<RunProbe runId=\{computer\.busyRunId\|\|computer\.waitingRunId\}><Avatar/);
|
||||
assert.match(app,/\{!computerOpen&&hud\}<div className="preview">/);
|
||||
assert.match(app,/--visible-height/);
|
||||
assert.match(app,/<div className=\{`composer-dock \$\{statusMembers\.length\?"has-status":""\}`\}>\s*\{error&&<div className="error-banner"/);
|
||||
const css=fs.readFileSync('apps/web/src/computer.css','utf8');
|
||||
assert.match(css,/\.computer-overlay>\.run-status/);
|
||||
assert.doesNotMatch(css,/\.run-status/);
|
||||
assert.match(css,/\.computer-hud\{position:static/);
|
||||
const chat=fs.readFileSync('apps/web/src/chat.css','utf8');
|
||||
assert.match(chat,/\.composer-dock\{position:relative/);
|
||||
assert.doesNotMatch(chat,/\.composer-dock\{position:absolute/);
|
||||
assert.match(chat,/\.messages\{min-width:0;padding-bottom:24px/);
|
||||
const tools=fs.readFileSync('crates/api/src/tools.rs','utf8');
|
||||
assert.match(tools,/screenshots are opt-in with observe:true/);
|
||||
assert.match(tools,/if args\.get\("observe"\)\.and_then\(Value::as_bool\) != Some\(true\)/);
|
||||
|
|
@ -376,7 +381,69 @@ test('the bubble reads the run activity endpoint the API actually mounts',()=>{
|
|||
assert.match(app,/<RunProbe runId=\{member\.id===computer\.botId\?computer\.busyRunId:null\}><Avatar/);
|
||||
const probe=fs.readFileSync('apps/web/src/run-monitor.tsx','utf8');
|
||||
assert.match(probe,/`\/api\/runs\/\$\{runId\}\/activity\$\{after\}`/);
|
||||
assert.match(probe,/export function RunStatus/);
|
||||
assert.match(probe,/`\/api\/runs\/\$\{runId\}\/activity\?limit=30`/);
|
||||
assert.doesNotMatch(probe,/export function RunStatus/);
|
||||
assert.match(fs.readFileSync('apps/web/src/main.tsx','utf8'),/import "\.\/monitor\.css";/);
|
||||
});
|
||||
|
||||
const liveJs=ts.transpileModule(fs.readFileSync('apps/web/src/live.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
|
||||
const liveBox={exports:{}};vm.runInNewContext(liveJs,liveBox);
|
||||
const {sessionEventsUrl,subscribeToSession,SESSION_EVENT_TYPES,createCoalescer}=liveBox.exports;
|
||||
|
||||
test('the browser listens for every event kind the api can append',()=>{
|
||||
const rust=['crates/api/src/runs.rs','crates/api/src/sessions.rs','crates/api/src/schedules.rs','crates/api/src/voice_call.rs'].map(file=>fs.readFileSync(file,'utf8')).join('\n');
|
||||
const emitted=[...rust.matchAll(/["']((?:message|run|session)\.[a-z_]+)["']/g)].map(match=>match[1]);
|
||||
assert.ok(emitted.length>=4,'the api should emit session events');
|
||||
for(const kind of new Set(emitted))assert.ok(SESSION_EVENT_TYPES.includes(kind),`${kind} is emitted but never listened for`);
|
||||
});
|
||||
|
||||
test('the live feed reads its own session, drops replays, and survives a bad frame',()=>{
|
||||
const listeners={};let closed=0,url='';
|
||||
const source={addEventListener:(type,listener)=>{listeners[type]=listener},close:()=>{closed+=1}};
|
||||
const seen=[],statuses=[];
|
||||
const feed=subscribeToSession('s/1',event=>seen.push(event),{source:candidate=>{url=candidate;return source},onStatus:connected=>statuses.push(connected)});
|
||||
assert.equal(url,'/api/sessions/s%2F1/events');
|
||||
assert.ok(listeners['message.created']&&listeners['run.completed']&&listeners['session.cleared'],'every kind gets a listener');
|
||||
listeners['message.created']({lastEventId:'5',data:'{"seq":5,"role":"assistant"}'});
|
||||
// The event object was built inside a vm realm, so compare fields, not prototypes.
|
||||
assert.equal(seen.length,1);
|
||||
assert.equal(seen[0].kind,'message.created');assert.equal(seen[0].id,5);
|
||||
assert.equal(seen[0].payload.seq,5);assert.equal(seen[0].payload.role,'assistant');
|
||||
listeners['message.created']({lastEventId:'5',data:'{}'});
|
||||
listeners['message.created']({lastEventId:'4',data:'{}'});
|
||||
assert.equal(seen.length,1,'a reconnect replay must not be applied twice');
|
||||
listeners['run.started']({lastEventId:'6',data:'not json'});
|
||||
assert.equal(seen.length,2);assert.equal(seen[1].kind,'run.started');assert.equal(seen[1].id,6);
|
||||
assert.equal(Object.keys(seen[1].payload).length,0,'a malformed frame still reports the event');
|
||||
listeners['open']();listeners['error']();
|
||||
assert.deepEqual(statuses,[true,false]);
|
||||
feed.close();feed.close();
|
||||
assert.equal(closed,1,'close is idempotent');
|
||||
assert.equal(sessionEventsUrl('abc'),'/api/sessions/abc/events');
|
||||
});
|
||||
|
||||
test('a burst of session events settles into one refresh',()=>{
|
||||
const timers=[];let runs=0;
|
||||
const schedule=(callback,ms)=>{timers.push({callback,ms});return timers.length-1};
|
||||
const dismiss=handle=>{if(timers[handle])timers[handle].cancelled=true};
|
||||
const coalescer=createCoalescer(()=>{runs+=1},120,schedule,dismiss);
|
||||
coalescer.kick();coalescer.kick();coalescer.kick();
|
||||
assert.equal(runs,0);
|
||||
assert.equal(timers.filter(entry=>!entry.cancelled).length,1,'one pending run');
|
||||
assert.equal(timers[0].ms,120);
|
||||
timers[0].callback();
|
||||
assert.equal(runs,1);
|
||||
coalescer.kick();coalescer.cancel();coalescer.cancel();
|
||||
assert.equal(timers[1].cancelled,true);
|
||||
assert.equal(runs,1,'a cancelled run never fires');
|
||||
coalescer.kick();timers[2].callback();
|
||||
assert.equal(runs,2,'the coalescer is reusable after a cancel');
|
||||
});
|
||||
|
||||
test('chat follows the event stream instead of a fixed two second poll',()=>{
|
||||
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
|
||||
assert.match(app,/subscribeToSession\(activeSessionId,\(\)=>settle\.kick\(\)/);
|
||||
assert.match(app,/createCoalescer\(\(\)=>\{if\(!document\.hidden\)refresh\(\)\.catch\(\(\)=>\{\}\)\},EVENT_SETTLE_MS\)/);
|
||||
assert.match(app,/document\.addEventListener\("visibilitychange",resume\)/);
|
||||
assert.doesNotMatch(app,/const timer=setInterval\(\(\)=>\{refresh\(\)/,'the 2s transcript poll should be gone');
|
||||
assert.match(app,/const heartbeat=window\.setInterval\(\(\)=>\{const beat=roomsRef\.current[\s\S]*\},HEARTBEAT_MS\)/,'the heartbeat keeps its own minute cadence');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
"""The agent's persistent terminal (image/computer/lazyboy-shell).
|
||||
|
||||
Run on the host or inside the desktop image: it needs tmux only, and talks to a
|
||||
private tmux socket in a temp directory so it never touches a real session.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / 'image' / 'computer' / 'lazyboy-shell'
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('tmux'), 'tmux is not installed')
|
||||
class ShellSessionTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temp = tempfile.TemporaryDirectory()
|
||||
cls.env = {
|
||||
'PATH': os.environ.get('PATH', '/usr/bin:/bin'),
|
||||
'HOME': cls.temp.name,
|
||||
'TMUX_TMPDIR': f'{cls.temp.name}/tmux',
|
||||
'LAZYBOY_SHELL_STATE': f'{cls.temp.name}/state',
|
||||
'LANG': 'C.UTF-8',
|
||||
'LC_ALL': 'C.UTF-8',
|
||||
}
|
||||
os.makedirs(cls.env['TMUX_TMPDIR'], exist_ok=True)
|
||||
os.makedirs(cls.env['LAZYBOY_SHELL_STATE'], exist_ok=True)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
subprocess.run(['tmux', 'kill-server'], env=cls.env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
cls.temp.cleanup()
|
||||
|
||||
def shell(self, *argv, timeout=40):
|
||||
return subprocess.run([str(SCRIPT), *argv], env=self.env,
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
def run_(self, command, session='main', wait_ms=8000):
|
||||
return self.shell('run', session, str(wait_ms), command)
|
||||
|
||||
def test_reports_output_and_exit_code(self):
|
||||
result = self.run_('echo ready')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('status=done session=main exit=0', result.stdout)
|
||||
self.assertIn('ready', result.stdout)
|
||||
failed = self.run_('echo boom >&2; exit 7')
|
||||
self.assertIn('exit=7', failed.stdout)
|
||||
self.assertIn('boom', failed.stdout)
|
||||
|
||||
def test_working_directory_and_env_survive_between_calls(self):
|
||||
# The whole point: this is one terminal, not a fresh process per call.
|
||||
self.run_('cd /tmp && export LAZYBOY_MARKER=kept')
|
||||
here = self.run_('pwd; echo $LAZYBOY_MARKER')
|
||||
self.assertIn('/tmp', here.stdout)
|
||||
self.assertIn('kept', here.stdout)
|
||||
|
||||
def test_a_still_running_command_is_reported_not_typed_over(self):
|
||||
slow = self.run_('echo starting; sleep 30', wait_ms=500)
|
||||
self.assertIn('status=running', slow.stdout)
|
||||
self.assertIn('starting', slow.stdout)
|
||||
crowded = self.run_('echo second', wait_ms=1000)
|
||||
self.assertIn('status=running', crowded.stdout)
|
||||
self.assertIn('nothing was typed', crowded.stdout)
|
||||
self.assertNotIn('second', crowded.stdout)
|
||||
# Ctrl-C is the human answer: it releases the terminal, and the next
|
||||
# command runs in the same shell.
|
||||
self.assertIn('status=sent', self.shell('keys', 'main', 'C-c').stdout)
|
||||
after = self.run_('echo usable', wait_ms=8000)
|
||||
self.assertIn('status=done', after.stdout)
|
||||
self.assertIn('usable', after.stdout)
|
||||
|
||||
def test_log_reads_the_terminal_without_typing_anything(self):
|
||||
self.run_('echo logged', session='poll')
|
||||
logged = self.shell('log', 'poll', '40')
|
||||
self.assertIn('status=idle', logged.stdout)
|
||||
self.assertIn('logged', logged.stdout)
|
||||
|
||||
def test_a_command_that_exits_the_shell_leaves_a_usable_terminal(self):
|
||||
# `exit` is a command like any other: the code is reported, and the next
|
||||
# call gets the same terminal back where it stood.
|
||||
exited = self.run_('export LB_KEEP=through_exit; cd /etc; exit 3',
|
||||
session='exiter')
|
||||
self.assertIn('exit=3', exited.stdout)
|
||||
self.assertIn('restarted', exited.stdout)
|
||||
after = self.run_('pwd; echo marker=$LB_KEEP', session='exiter')
|
||||
self.assertIn('status=done', after.stdout)
|
||||
self.assertIn('/etc', after.stdout)
|
||||
self.assertIn('marker=through_exit', after.stdout)
|
||||
|
||||
def test_a_shell_replaced_by_the_command_is_adopted(self):
|
||||
# `exec bash` swallows the end marker; the terminal must not stay locked.
|
||||
self.run_('exec bash', session='swapped', wait_ms=1500)
|
||||
after = self.run_('echo recovered', session='swapped')
|
||||
self.assertIn('status=done', after.stdout)
|
||||
self.assertIn('recovered', after.stdout)
|
||||
|
||||
def test_log_reports_whether_the_command_is_still_running(self):
|
||||
self.run_('echo starting; sleep 30', session='watched', wait_ms=500)
|
||||
busy = self.shell('log', 'watched', '20')
|
||||
self.assertIn('status=running', busy.stdout)
|
||||
self.assertIn('starting', busy.stdout)
|
||||
self.shell('keys', 'watched', 'C-c')
|
||||
idle = self.shell('log', 'watched', '20')
|
||||
self.assertIn('status=idle', idle.stdout)
|
||||
|
||||
def test_sessions_are_listed_and_resettable(self):
|
||||
self.run_('echo hi', session='alpha')
|
||||
self.assertIn('session=alpha state=open', self.shell('list').stdout)
|
||||
self.assertIn('session=alpha', self.shell('reset', 'alpha').stdout)
|
||||
self.run_('pwd', session='alpha')
|
||||
|
||||
def test_rejects_session_names_that_are_not_boring(self):
|
||||
for bad in ('', 'two words', 'a;rm -rf /'):
|
||||
result = self.run_('echo nope', session=bad)
|
||||
self.assertEqual(result.returncode, 2, bad)
|
||||
self.assertIn('session name', result.stderr)
|
||||
|
||||
def test_unknown_subcommand_is_an_error(self):
|
||||
result = self.shell('nonsense')
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn('usage:', result.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue