feat/cua-driver-poc #7

Merged
daniel.w merged 9 commits from feat/cua-driver-poc into main 2026-09-08 16:57:24 +00:00
13 changed files with 939 additions and 93 deletions
Showing only changes of commit bb79e292c7 - Show all commits

View File

@ -7,6 +7,8 @@ SANDBOX_SUPERVISOR_TOKEN=
# Keep this key stable when rotating the app login token.
LAZYBOY_VAULT_KEY=
POSTGRES_PASSWORD=lazyboy
# 127.0.0.1 = 只有本機0.0.0.0 = 開放區網(需 LAZYBOY_APP_TOKEN >= 32 字元);
# 也可填單一網卡的 IP把監聽限制在那個介面。
LAZYBOY_BIND_IP=127.0.0.1
SANDBOX_PROVIDER=docker
DATABASE_URL=postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy
@ -34,3 +36,7 @@ LAZYBOY_RUN_RETENTION_DAYS=90
LAZYBOY_RECORDING_RETENTION_DAYS=30
LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS=90
LAZYBOY_DB_WARN_MB=1024
# Docker 網路名稱API 與 Agent 電腦的 noVNC 透過它相通(容器內用,不對外)。
# 同時跑多組 LazyBoy 時改這個名字避免相撞。
LAZYBOY_SCREEN_NETWORK=lazyboy_screen

File diff suppressed because one or more lines are too long

View File

@ -453,5 +453,11 @@ export const en: { [K in keyof typeof zhTW]: string } = {
schedHumanElapsed: "Every {n} minutes (elapsed)",
schedCalendar: "Calendar schedule: {expr}",
teachInProgress: "A demo is in progress. Finish or cancel it before sending a message.",
aiTimeout: "The model timed out (120 seconds).",
aiTimeout: "The model timed out (150 seconds).",
resumeMidTask: "Stopped halfway — your call",
resumeBudget: "Turn budget spent, result unverified",
resumeProgress: "{turns}/{limit} turns",
resumeContinue: "Keep going",
resumeStop: "Stop here",
resumeSent: "Keep going and finish it.",
};

View File

@ -148,5 +148,11 @@ export const zhTW = {
schedHumanElapsed: "每隔 {n} 分鐘(固定間隔)",
schedCalendar: "日曆排程:{expr}",
teachInProgress: "示範進行中:先按「完成示範」或「取消」,再送訊息。",
aiTimeout: "AI 回應逾時120 秒)",
aiTimeout: "AI 回應逾時150 秒)",
resumeMidTask: "做到一半,需要你決定",
resumeBudget: "輪次用盡,尚未確認完成",
resumeProgress: "{turns}/{limit} 輪",
resumeContinue: "繼續",
resumeStop: "就到這裡",
resumeSent: "繼續,把它做完。",
} as const;

View File

@ -2,6 +2,7 @@
.login-chip .login-label,.sched-chip .sched-label{color:var(--muted);font-size:12px}
.login-chip .login-site{font-weight:600}
.login-chip .login-why{color:var(--muted);font-size:13px}
.resume-chip .resume-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:2px}
.sched-list{display:grid;gap:8px;min-height:0;overflow:auto}
.sched-list-head{display:flex;align-items:center;justify-content:space-between;color:var(--muted);font-size:13px}
.sched-empty{margin:0;color:var(--faint);font-size:13px}

View File

@ -1,4 +1,8 @@
use lazyboy_harness::execution::{ExecutionMode, GoalOutcome, goal_request, goal_outcome, GOAL_INSTRUCTIONS, GOAL_CONTINUE};
use lazyboy_harness::execution::{
ExecutionMode, GoalOutcome, MAX_NUDGES_GOAL, MAX_NUDGES_PLAIN, NEEDS_INPUT_MARKER, StopReason,
VERIFY_BEFORE_DONE, asks_for_input, goal_outcome, goal_request, stop_reason, GOAL_CONTINUE,
GOAL_INSTRUCTIONS,
};
use std::sync::Arc;
use std::time::Duration;
@ -17,7 +21,7 @@ use uuid::Uuid;
use crate::computer::{self, adapter_context_for};
use crate::db::{Actor, parse_mode};
use crate::state::AppState;
use crate::tools::{ToolCtx, dispatch, tool_definitions};
use crate::tools::{ToolCtx, ToolOutcome, dispatch, tool_definitions};
const SCREENSHOT_CAPTION: &str = "Desktop screenshot (1280x800) with yellow numbered marks. Click by those element ids. The live VNC view has no marks.";
@ -55,6 +59,8 @@ Waiting is a tool call, never a reply. Ending your turn with \"waiting for X\" s
Multi-step tasks and taught skills: you are done only when the playbook's check passes (for example the course shows completed, the form shows a confirmation). Do not stop with a status sentence in the middle; keep calling tools until the check passes or you are truly blocked, then say exactly why. Never repeat an earlier reply word for word; describe the current screen.
When you genuinely must stop and need the human a decision only they can make, a credential you do not have, a file that is missing, or a result they must approve first say what you already did and what the next step would be, then end that reply with a standalone [NEEDS_INPUT] line. The run pauses for their answer and resumes exactly where you stopped. Never use it when the task is simply finished.
Never ask for passwords, codes, or tokens in chat. At a login wall: call list_accounts, then use_saved_login {accountId} when a saved account matches. For a simple Cloudflare connection-check checkbox, first take computer_observe and use connection_check once with coordinates from that screenshot. Check the returned page content before continuing; disappearance of the checkbox alone is not success. Never reload repeatedly or restart the browser to retry. For other CAPTCHA, 2FA, an unsuccessful connection check, or no matching saved login, call request_takeover with site and why so the human signs in on YOUR screen. Recurring work uses create_schedule (five-field cron, Asia/Taipei unless told otherwise).
computer_act examples (native windows only):
@ -131,16 +137,24 @@ pub async fn send(
}));
}
}
// A message sent while a single-bot /goal is active is steering for that
// run. Reuse its id so it is delivered by the persistent loop rather than
// creating a duplicate queued run that would repeat the work afterwards.
let merged_goal_run: Option<String> = if room_id.is_none() {
// A message that continues an existing task belongs to that task: reuse its
// run id so the loop resumes from the saved harness state instead of
// starting over and fighting for the same desktop. That covers /goal
// steering and every run paused while waiting for an answer. A human who
// still holds the mouse is never interrupted by an incoming message.
let merged_run: Option<String> = if room_id.is_none() {
sqlx::query_scalar(
"SELECT id FROM runs
WHERE bot_id=$1 AND thread_id=$2
AND status IN ('queued','leased','running','waiting_input','waiting_takeover')
AND btrim(prompt) ~ '^/goal($|[[:space:]])'
ORDER BY created_at ASC LIMIT 1",
"SELECT r.id FROM runs r
WHERE r.bot_id=$1 AND r.thread_id=$2
AND (
(r.status IN ('queued','leased','running','waiting_input','waiting_takeover')
AND btrim(r.prompt) ~ '^/goal($|[[:space:]])')
OR r.status='waiting_input'
OR (r.status='waiting_takeover' AND NOT EXISTS (
SELECT 1 FROM computers c JOIN bots b ON b.computer_id=c.id
WHERE b.id=r.bot_id AND c.control_holder='user'))
)
ORDER BY r.created_at ASC LIMIT 1",
)
.bind(bot_id)
.bind(thread_id)
@ -150,10 +164,16 @@ pub async fn send(
} else {
None
};
let merged_goal = merged_goal_run.is_some();
let run_id = merged_goal_run.unwrap_or_else(|| Uuid::new_v4().to_string());
if merged_goal {
sqlx::query("UPDATE runs SET status='queued', retry_count=0, updated_at=now() WHERE id=$1 AND status='waiting_input'")
let merged = merged_run.is_some();
let run_id = merged_run.unwrap_or_else(|| Uuid::new_v4().to_string());
if merged {
// The answer itself arrives through the steering read; waking the run
// only makes it claimable again and retires the pending question.
sqlx::query(
"UPDATE runs SET status='queued', retry_count=0,
checkpoint=checkpoint-'awaitResume', updated_at=now()
WHERE id=$1 AND status IN ('waiting_input','waiting_takeover')",
)
.bind(&run_id).execute(&mut *tx).await.map_err(|error| error.to_string())?;
}
let message_id = Uuid::new_v4().to_string();
@ -201,7 +221,7 @@ pub async fn send(
member_ids.push(bot_id.to_string());
}
for (index, member_id) in member_ids.iter().enumerate() {
if merged_goal && index == 0 {
if merged && index == 0 {
continue;
}
let member_run = if index == 0 {
@ -241,7 +261,7 @@ pub async fn send(
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
let queued_behind_active = if merged_goal {
let queued_behind_active = if merged {
true
} else {
sqlx::query_scalar(
@ -496,6 +516,16 @@ async fn execute_run(
.execute(state.pool())
.await;
}
// The human answered a task that had paused to ask. Their message is an
// answer, not a new request, so the loop feeds it in before the next model
// turn instead of letting the run restart from scratch.
let await_resume = checkpoint.get("awaitResume").is_some();
if await_resume {
let _ = sqlx::query("UPDATE runs SET checkpoint = checkpoint - 'awaitResume' WHERE id=$1")
.bind(run_id)
.execute(state.pool())
.await;
}
let history_end = if resume_after_takeover {
i32::MAX
} else {
@ -685,6 +715,10 @@ async fn execute_run(
// the bot lease.
let mut steering_seq = checkpoint.get("steeringSeq").and_then(Value::as_i64).map(|seq| seq as i32).unwrap_or(current_seq);
let mut used_gui = false;
let mut did_work = false;
// 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;
let memory = if ctx.memory_enabled {
match state
.memory
@ -773,7 +807,7 @@ async fn execute_run(
while execution_mode.allows_turn(turns) {
turns = turns.saturating_add(1);
if goal_mode {
if goal_mode || (await_resume && !resume_after_takeover) {
let steering: Vec<(i32, String)> = sqlx::query_as(
"SELECT seq, body FROM messages
WHERE thread_id=$1 AND role='user' AND seq>$2
@ -792,10 +826,17 @@ async fn execute_run(
.filter(|body| !body.is_empty())
.collect::<Vec<_>>();
if !guidance.is_empty() {
let text = format!(
"The user added this guidance in the same goal thread. Incorporate it into the current goal and continue verifying the result:\n{}",
guidance.join("\n")
);
let text = if goal_mode {
format!(
"The user added this guidance in the same goal thread. Incorporate it into the current goal and continue verifying the result:\n{}",
guidance.join("\n")
)
} else {
format!(
"The human answered your question about the paused task. Continue the original work from where it stopped with tools: do not repeat finished steps and do not start over.\n{}",
guidance.join("\n")
)
};
if let Message::User { content } = &mut pending {
content.push(UserContent::text(text));
}
@ -861,10 +902,17 @@ async fn execute_run(
if calls.is_empty() {
// A model that quits a playbook early, or parrots an earlier reply
// instead of describing the current screen, gets pushed back to
// the tools a couple of times before we accept the text.
// the tools a few times before the text is accepted.
let parroted = earlier_replies
.iter()
.any(|earlier| earlier == final_text.trim());
let declared_done = goal_mode
&& matches!(
goal_outcome(&final_text),
GoalOutcome::Complete | GoalOutcome::NeedsInput
);
let nudge_limit = if goal_mode { MAX_NUDGES_GOAL } else { MAX_NUDGES_PLAIN };
let mut verify_chosen = false;
let nudge = if goal_mode {
match goal_outcome(&final_text) {
GoalOutcome::Continue => Some(GOAL_CONTINUE.to_string()),
@ -874,45 +922,82 @@ async fn execute_run(
Some(
"Your reply repeats an earlier message word for word, so it cannot describe the current screen. Below is what the screen shows RIGHT NOW. Act on it with a tool call. Waiting is done by calling wait or by clicking the control (the click waits for it to enable), never by replying. Reply in text only once the task is finished or you are truly blocked (say why).".to_string(),
)
} else if let Some(check) = &skill_check {
Some(format!(
"The run is not finished; your text reply ended nothing but your own turn. Check: {check}\nBelow is the current screen. If the next control is [disabled], click it anyway — the click waits up to 45s for it to enable — or call wait. Ids marked [below viewport] scroll automatically. Only reply in text when the check passes or you are truly blocked, and then say exactly what blocks you."
))
} else if did_work && !verified && !asks_for_input(&final_text) {
// First stop attempt of a run that already moved something: make
// it prove the work is finished, or ask the human properly.
verify_chosen = true;
Some(VERIFY_BEFORE_DONE.to_string())
} else {
skill_check.as_ref().map(|check| {
format!(
"The run is not finished; your text reply ended nothing but your own turn. Check: {check}\nBelow is the current screen. If the next control is [disabled], click it anyway — the click waits up to 45s for it to enable — or call wait. Ids marked [below viewport] scroll automatically. Only reply in text when the check passes or you are truly blocked, and then say exactly what blocks you."
)
})
None
};
match nudge {
Some(text) if goal_mode || (nudges < 6 && execution_mode.allows_turn(turns.saturating_add(2))) => {
nudges = nudges.saturating_add(1);
tracing::info!(
run_id,
turn = turns,
parroted,
"nudging model back to tools"
);
earlier_replies.push(final_text.trim().to_string());
final_text.clear();
let mut content = vec![UserContent::text(text)];
if used_gui || skill_check.is_some() {
prepare_run_computer(state, actor, bot_id, run_id, &ctx, true).await?;
}
if (used_gui || skill_check.is_some())
&& ctx.gui_block.lock().unwrap().is_none()
{
set_run_step(state, run_id, "computer_observe: 重新確認畫面").await;
let outcome = dispatch(&ctx, "computer_observe", &json!({})).await;
content.push(UserContent::text(outcome.text));
if let Some(image) = outcome.image {
screenshot_bytes += image.len() as u64;
screenshots += 1;
content.extend(screenshot_parts(image));
}
}
pending = Message::User { content };
continue;
let may_nudge =
nudges < nudge_limit && execution_mode.allows_turn(turns.saturating_add(2));
if let Some(text) = may_nudge.then_some(nudge).flatten() {
nudges = nudges.saturating_add(1);
verified |= verify_chosen;
tracing::info!(
run_id,
turn = turns,
parroted,
verify = verify_chosen,
"nudging model back to tools"
);
earlier_replies.push(final_text.trim().to_string());
final_text.clear();
let mut content = vec![UserContent::text(text)];
if used_gui || skill_check.is_some() {
prepare_run_computer(state, actor, bot_id, run_id, &ctx, true).await?;
}
_ => break,
if (used_gui || skill_check.is_some())
&& ctx.gui_block.lock().unwrap().is_none()
{
set_run_step(state, run_id, "computer_observe: 重新確認畫面").await;
let outcome = dispatch(&ctx, "computer_observe", &json!({})).await;
content.push(UserContent::text(outcome.text));
if let Some(image) = outcome.image {
screenshot_bytes += image.len() as u64;
screenshots += 1;
content.extend(screenshot_parts(image));
}
}
pending = Message::User { content };
continue;
}
// Nothing left to retry: report where the work stands and let the
// human decide instead of ending the task in silence.
let stalled = nudges >= nudge_limit && !declared_done;
if let Some(reason) = stop_reason(execution_mode, turns, &final_text, did_work, stalled)
{
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,
screenshots,
screenshot_bytes,
used_gui,
},
)
.await;
}
break;
}
final_text.clear();
let mut results = Vec::new();
@ -960,6 +1045,7 @@ async fn execute_run(
| "use_saved_login"
| "request_takeover"
);
did_work = true;
let step = describe_step(&name, &call.function.arguments);
set_run_step(state, run_id, &step).await;
let fence=sqlx::query("UPDATE runs SET checkpoint=COALESCE(checkpoint,'{}'::jsonb)||jsonb_build_object('toolsStarted',true) WHERE id=$1 AND lease_owner=$2 AND status='running'")
@ -988,7 +1074,14 @@ async fn execute_run(
dispatch(&ctx, &name, &call.function.arguments),
) => match outcome {
Ok(outcome) => outcome,
Err(_) => return Err(format!("tool {name} timed out; its effects are unknown. Inspect the current state before continuing")),
// A slow tool is a normal turn, not a dead run: say the
// effects are unknown and let the model re-observe.
Err(_) => ToolOutcome {
text: format!("tool {name} timed out after 150 seconds. Its effects are unknown: observe the current screen or files before anything else, and never repeat a step that already worked."),
image: None,
pause: false,
blocks: Vec::new(),
},
}
};
tracing::info!(
@ -1090,6 +1183,35 @@ async fn execute_run(
)
.await;
}
// The turn budget ran out straight after a tool batch, so the model never
// got a turn to explain itself. Park the run with its state instead of
// delivering an empty answer that looks like a finished task.
if let Some(reason) = stop_reason(execution_mode, turns, "", did_work, false) {
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,
screenshots,
screenshot_bytes,
used_gui,
},
)
.await;
}
let needs_input = goal_mode && goal_outcome(&final_text) == GoalOutcome::NeedsInput;
if needs_input {
let next = Message::User { content: vec![UserContent::text("The goal was paused for required user input. Read the user's new information and continue from completed work.")] };
@ -1218,8 +1340,9 @@ async fn complete_with_retry(
) -> Result<Vec<AssistantContent>, String> {
let mut last = String::new();
for attempt in 0..3 {
let started = std::time::Instant::now();
let result = tokio::time::timeout(
Duration::from_secs(60),
Duration::from_secs(165),
complete_once(model, pending.clone(), preamble, history, defs),
)
.await;
@ -1229,9 +1352,17 @@ async fn complete_with_retry(
if !retryable_run_error(&error) {
return Err(error);
}
tracing::warn!(
attempt = attempt + 1,
elapsed_ms = started.elapsed().as_millis() as u64,
"model attempt failed: {error}"
);
last = error;
}
Err(_) => last = "model request timed out after 60 seconds".into(),
Err(_) => {
tracing::warn!(attempt = attempt + 1, "model attempt exceeded its 165 second budget");
last = "model request timed out after 165 seconds".into();
}
}
if attempt < 2 {
tokio::time::sleep(Duration::from_millis(500 * (1 << attempt))).await;
@ -1256,9 +1387,11 @@ where
.messages(history.to_vec())
.tools(defs.to_vec())
.build();
let response = tokio::time::timeout(Duration::from_secs(120), model.completion(request))
// One budget per attempt, kept just below the caller's, so the model's own
// timeout is what gets reported instead of a generic outer cancellation.
let response = tokio::time::timeout(Duration::from_secs(150), model.completion(request))
.await
.map_err(|_| "AI 回應逾時120 秒)".to_string())?
.map_err(|_| "AI 回應逾時150 秒)".to_string())?
.map_err(|error| error.to_string())?;
Ok(response.choice.into_iter().collect())
}
@ -1313,6 +1446,40 @@ fn assistant_texts(history: &[Message]) -> Vec<String> {
.collect()
}
/// A checkpoint that cannot be written freezes the run: progress stops and every
/// later hiccup turns fatal. Drop the oldest turns and cap long tool dumps until
/// the row fits again.
fn shrink_checkpoint(history: &mut Vec<Message>, pending: &mut Message) {
const LIMIT: usize = 768 * 1024;
const MAX_PART: usize = 48 * 1024;
let cap_parts = |message: &mut Message| {
let Message::User { content } = message else {
return;
};
for part in content.iter_mut() {
let UserContent::Text(text) = part else {
continue;
};
if text.text.len() > MAX_PART {
let mut cut = MAX_PART;
while cut > 0 && !text.text.is_char_boundary(cut) {
cut -= 1;
}
text.text.truncate(cut);
text.text.push_str("\n…(truncated)");
}
}
};
cap_parts(pending);
history.iter_mut().for_each(cap_parts);
let fits = |turns: &[Message]| {
json!({"harnessHistory": turns, "harnessPending": pending}).to_string().len() <= LIMIT
};
while !fits(history) && history.len() > 1 {
history.remove(0);
}
}
async fn save_harness_checkpoint(
state: &AppState,
run_id: &str,
@ -1332,9 +1499,12 @@ async fn save_harness_checkpoint(
});
}
}
shrink_checkpoint(&mut history, &mut pending);
let value = json!({"harnessHistory":history,"harnessPending":pending,"toolsStarted":false,"harnessTurns":turns,"steeringSeq":steering_seq});
// Large/unsupported checkpoints fail closed: keep the uncertain-effects flag.
// Only a checkpoint that still cannot fit after shrinking fails closed, and
// loudly: the run keeps its uncertain-effects flag instead of freezing.
if value.to_string().len() > 1024 * 1024 {
tracing::error!(run_id, "harness checkpoint still exceeds 1 MB after shrinking");
return Ok(());
}
let result=sqlx::query("UPDATE runs SET checkpoint=COALESCE(checkpoint,'{}'::jsonb)||$3,updated_at=now() WHERE id=$1 AND lease_owner=$2 AND status='running'")
@ -1414,6 +1584,12 @@ pub(crate) async fn append_bot_message_with(
body: &str,
blocks: Value,
) -> Result<(), String> {
// A stop with nothing to say is a bug, not a message. An empty assistant
// bubble is exactly what made "finished" and "died mid-task" look alike.
let has_blocks = blocks.as_array().is_some_and(|items| !items.is_empty());
if body.trim().is_empty() && !has_blocks {
return Ok(());
}
let mut tx = state
.pool()
.begin()
@ -1481,7 +1657,7 @@ async fn wait_for_halt(state: &AppState, run_id: &str) -> RunHalt {
if let Ok(Some(halt)) = run_status_halt(state, run_id).await {
return halt;
}
tokio::time::sleep(Duration::from_millis(200)).await;
tokio::time::sleep(Duration::from_millis(750)).await;
}
}
@ -1545,6 +1721,123 @@ async fn finish_halt(
}
}
/// Work that stopped before it was verified as finished. The run parks in
/// `waiting_input` with a one-click question and keeps its harness state, so the
/// human's next message resumes exactly where the tools left off.
struct PauseRequest<'a> {
reason: StopReason,
draft: &'a str,
history: &'a [Message],
turns: u32,
steering_seq: i32,
limit: u32,
screenshots: u32,
screenshot_bytes: u64,
used_gui: bool,
}
const PAUSED_PENDING: &str = "This run is paused for the human's answer. When their reply arrives, continue the work that has already started: observe the current screen before any new mutation and never repeat finished steps.";
async fn pause_for_answer(
state: &AppState,
bot_id: &str,
thread_id: &str,
run_id: &str,
lease_owner: &str,
ctx: &ToolCtx,
req: PauseRequest<'_>,
) -> Result<(), String> {
let draft = req.draft.replace(NEEDS_INPUT_MARKER, "").trim().to_string();
let draft = if draft.is_empty() {
match req.reason {
StopReason::BudgetExhausted => format!(
"我在這個任務上用了 {} 輪,還沒有做到可以幫你確認完成的地步,先停在目前的畫面。要我繼續嗎?",
req.turns
),
StopReason::MidTaskText => {
"我先到這裡,需要你的決定或資料才能繼續。要我接著做嗎?".to_string()
}
}
} else {
draft
};
let next = Message::User {
content: vec![UserContent::text(PAUSED_PENDING)],
};
save_harness_checkpoint(
state,
run_id,
lease_owner,
req.history,
&next,
req.turns,
req.steering_seq,
)
.await?;
let paused = sqlx::query(
"UPDATE runs
SET status='waiting_input', lease_owner=NULL, lease_expires_at=NULL, updated_at=now(),
checkpoint=COALESCE(checkpoint,'{}'::jsonb)||jsonb_build_object('awaitResume',
jsonb_build_object('reason',$3,'turns',$4,'limit',$5))
WHERE id=$1 AND lease_owner=$2 AND status='running'",
)
.bind(run_id)
.bind(lease_owner)
.bind(req.reason.as_str())
.bind(req.turns as i64)
.bind(req.limit as i64)
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
if paused.rows_affected() != 1 {
return Err("run lease was lost before pausing".into());
}
append_bot_message_with(
state,
thread_id,
run_id,
bot_id,
&draft,
json!([{
"kind": "resume",
"reason": req.reason.as_str(),
"turns": req.turns,
"limit": req.limit,
}]),
)
.await?;
let click_misses = *ctx.click_misses.lock().unwrap();
record_run_metrics(
state,
thread_id,
run_id,
"run.paused",
req.turns,
req.screenshots,
req.screenshot_bytes,
click_misses,
req.used_gui,
false,
)
.await;
computer::release_screen_execution(state, run_id).await?;
sqlx::query(
"UPDATE computers SET execution_bot_id = NULL, execution_run_id = NULL, execution_lease_expires_at = NULL, updated_at = now()
WHERE execution_run_id = $1",
)
.bind(run_id)
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
tracing::info!(
run_id,
reason = req.reason.as_str(),
turns = req.turns,
"run paused for an answer"
);
Ok(())
}
async fn renew_lease(state: &AppState, run_id: &str, lease_owner: &str) -> Result<(), String> {
let renewed = sqlx::query(
"UPDATE runs SET lease_expires_at=now()+interval '5 minutes',updated_at=now()
@ -1897,6 +2190,93 @@ fn has_task_verb(normalized: &str) -> bool {
VERBS.iter().any(|verb| normalized.contains(verb))
}
/// Plain doing-words that never show up in a greeting. `prompt_needs_desktop`
/// cannot name every app, site, or file, so a request is also a task when it
/// asks for an action to be performed on something.
fn has_work_verb(normalized: &str) -> bool {
const VERBS: &[&str] = &[
"整理",
"彙整",
"彙總",
"存到",
"存進",
"存入",
"儲存",
"存檔",
"建立",
"新增",
"產生",
"產出",
"改名",
"重命名",
"移動",
"複製",
"刪除",
"刪掉",
"翻譯",
"摘要",
"總結",
"歸納",
"比對",
"比較",
"填入",
"填寫",
"提交",
"送出",
"歸檔",
"轉檔",
"轉換",
"壓縮",
"解凍",
"分割",
"合併",
"報名",
"預訂",
"預約",
"訂閱",
"退訂",
"追蹤",
"回覆",
"寄送",
"領取",
"打卡",
"簽到",
"紀錄",
"記錄",
"檢查",
"測試",
"執行",
"下載",
"上傳",
"安裝",
"更新",
"設定",
"organize",
"rename",
"move ",
"copy",
"delete",
"save",
"create",
"generate",
"download",
"upload",
"translate",
"summarize",
"submit",
"book",
"reserve",
"archive",
"convert",
"compare",
"send",
"reply",
"schedule",
"install",
];
VERBS.iter().any(|verb| normalized.contains(verb))
}
fn is_greeting(normalized: &str) -> bool {
const EXACT: &[&str] = &[
"hi",
@ -2039,10 +2419,14 @@ fn is_plain_chat(prompt: &str) -> bool {
return true;
}
let chars = normalized.chars().count();
if chars <= 24 && !has_task_verb(&normalized) {
// Withholding every tool is the most damaging mistake this function can
// make: the task silently degrades into a paragraph and looks like the run
// gave up. Only an obvious pleasantry counts as chat; anything with a work
// verb keeps its tools.
if chars <= 12 && !has_task_verb(&normalized) && !has_work_verb(&normalized) {
return true;
}
is_chat_intent(&normalized) && chars <= 48
is_chat_intent(&normalized) && chars <= 24 && !has_work_verb(&normalized)
}
fn tool_needs_sandbox(name: &str) -> bool {
@ -2383,6 +2767,26 @@ mod tests {
}
}
#[test]
fn a_work_verb_outranks_a_short_prompt() {
// The old length rule silently stripped every tool from a short
// imperative; that is the "it stopped halfway" bug in its purest form.
for prompt in [
"把這張圖壓縮到 800px",
"把報價整理成表格",
"翻譯這段",
"幫我把檔案改名",
"幫我把这份報告存成 pdf",
"rename the screenshots",
"submit the form",
] {
assert!(!is_plain_chat(prompt), "{prompt} must keep its tools");
}
for prompt in ["你好", "YouTube 是什麼?", "今天天氣如何", "寫一首詩"] {
assert!(is_plain_chat(prompt), "{prompt} should stay in chat");
}
}
#[test]
fn step_labels_summarize_tool_arguments() {
assert_eq!(

View File

@ -44,14 +44,14 @@ async fn proxy(state: AppState, bot_id: String, rest: String, req: Request) -> R
return StatusCode::NOT_FOUND.into_response();
}
let ensure = upgrade || is_viewer_page(&rest) || rest.contains("websockify");
let port = match upstream_port(&state, &bot_id, ensure).await {
Ok(port) => port,
let (host, port) = match upstream_target(&state, &bot_id, ensure).await {
Ok(target) => target,
Err(status) => return status.into_response(),
};
if upgrade {
return match WebSocketUpgrade::from_request(req, &state).await {
Ok(ws) => ws
.on_upgrade(move |socket| proxy_socket(socket, port, rest))
.on_upgrade(move |socket| proxy_socket(socket, host, port, rest))
.into_response(),
Err(error) => error.into_response(),
};
@ -86,7 +86,11 @@ async fn viewer_page() -> Response {
(StatusCode::OK, headers, html).into_response()
}
async fn upstream_port(state: &AppState, bot_id: &str, ensure: bool) -> Result<u16, StatusCode> {
async fn upstream_target(
state: &AppState,
bot_id: &str,
ensure: bool,
) -> Result<(String, u16), StatusCode> {
let actor = state
.bootstrap()
.await
@ -130,14 +134,37 @@ async fn upstream_port(state: &AppState, bot_id: &str, ensure: bool) -> Result<u
.await
.map_err(|_| StatusCode::BAD_GATEWAY)?;
let url = session.url.ok_or(StatusCode::NOT_FOUND)?;
let rewritten = rewrite_upstream(&url);
let uri: Uri = rewritten.parse().map_err(|_| StatusCode::BAD_GATEWAY)?;
uri.port_u16().ok_or(StatusCode::BAD_GATEWAY)
upstream_authority(&url)
}
fn rewrite_upstream(url: &str) -> String {
let host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into());
url.replace("127.0.0.1", &host).replace("localhost", &host)
/// Splits the supervisor's noVNC URL into the host the API must dial and its
/// port. The host is a container name on the shared screen network, or loopback
/// plus a published host port when the API runs outside Docker.
fn upstream_authority(url: &str) -> Result<(String, u16), StatusCode> {
let uri: Uri = url.parse().map_err(|_| StatusCode::BAD_GATEWAY)?;
let host = uri
.host()
.map(str::to_string)
.ok_or(StatusCode::BAD_GATEWAY)?;
let port = uri.port_u16().ok_or(StatusCode::BAD_GATEWAY)?;
Ok((dial_host(&host), port))
}
/// Maps a loopback authority onto the host the API can actually reach, which
/// matters when the API itself runs inside a container. Container names on the
/// shared screen network are dialled verbatim.
fn dial_host(authority: &str) -> String {
if !is_loopback(authority) {
return authority.to_string();
}
match std::env::var("LAZYBOY_SCREEN_UPSTREAM") {
Ok(host) if !host.is_empty() && host != authority => host,
_ => authority.to_string(),
}
}
fn is_loopback(host: &str) -> bool {
matches!(host, "127.0.0.1" | "localhost" | "::1" | "[::1]")
}
fn safe_asset(rest: &str) -> bool {
@ -173,8 +200,7 @@ async fn trusted_asset(rest: &str) -> Response {
}
}
async fn proxy_socket(mut client: WebSocket, port: u16, rest: String) {
let host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into());
async fn proxy_socket(mut client: WebSocket, host: String, port: u16, rest: String) {
let path = if rest.is_empty() {
"websockify".into()
} else {
@ -245,3 +271,29 @@ mod asset_tests {
}
}
}
#[cfg(test)]
mod upstream_tests {
use super::*;
#[test]
fn upstream_authority_follows_the_supervisor_url() {
assert_eq!(
upstream_authority("http://lb-team-local-space:6081/vnc_lite.html?view_only=true")
.unwrap(),
("lb-team-local-space".to_string(), 6081)
);
assert_eq!(
upstream_authority("http://127.0.0.1:32905/").unwrap(),
("127.0.0.1".to_string(), 32905)
);
assert_eq!(
upstream_authority("http://lb-localhost:6082/vnc_lite.html").unwrap(),
("lb-localhost".to_string(), 6082)
);
assert_eq!(
upstream_authority("http://127.0.0.1/vnc_lite.html").err(),
Some(StatusCode::BAD_GATEWAY)
);
}
}

View File

@ -38,10 +38,118 @@ pub fn goal_outcome(reply: &str) -> GoalOutcome {
pub const GOAL_INSTRUCTIONS: &str = "Persistent goal execution: plan the requested work, execute it, and verify each requested outcome. Intermediate progress replies do not finish the run. Preserve completed work and incorporate user steering. End your final reply with a standalone [GOAL_COMPLETE] line only when all outcomes are verified; explain the verification. When required information or human action is missing, explain exactly what is needed and end with a standalone [GOAL_BLOCKED] line. For a simple Cloudflare connection-check checkbox, observe the current screen and try connection_check once, then verify the requested content. For other CAPTCHA, failed verification, login or 2FA use request_takeover. Never claim completion merely because you planned the work.";
pub const GOAL_CONTINUE: &str = "The goal remains active. Continue the plan with tools and verify the outcome. Finish only with a standalone [GOAL_COMPLETE] line after verification, or [GOAL_BLOCKED] when required human input is missing.";
/// A run that stops in the middle must say so instead of going quiet. The
/// model marks the moment; the loop turns the marker into a paused run the
/// human can continue with one click.
pub const NEEDS_INPUT_MARKER: &str = "[NEEDS_INPUT]";
/// Turn budgets for self-correction. A goal run is expected to fight through
/// obstacles, so it gets more attempts than a plain task.
pub const MAX_NUDGES_PLAIN: u32 = 6;
pub const MAX_NUDGES_GOAL: u32 = 8;
/// Sent once per stop attempt: the cheapest way to tell "the work is done" from
/// "the model just ran out of sentences".
pub const VERIFY_BEFORE_DONE: &str = "Before you finish, verify the result against the CURRENT screen or file contents: say what you checked and what you still owe. If any requested outcome is missing, act on it now with a tool call. If the human must decide, supply something, or do a step you cannot do, say what you did so far and what the next step is, then end with a standalone [NEEDS_INPUT] line. Never end a half-finished task with a plain status sentence.";
/// Why the loop is handing the turn back to the human.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopReason {
/// The model stopped mid-task and asked the human to take it from here.
MidTaskText,
/// The bounded turn budget is spent with work still outstanding.
BudgetExhausted,
}
impl StopReason {
pub fn as_str(self) -> &'static str {
match self {
Self::MidTaskText => "mid_task_text",
Self::BudgetExhausted => "budget_exhausted",
}
}
}
/// Only a standalone marker line asks for input, never a quoted mention.
pub fn asks_for_input(reply: &str) -> bool {
reply
.trim()
.lines()
.last()
.is_some_and(|line| line.trim() == NEEDS_INPUT_MARKER)
}
/// A run that never touched a tool answered in prose, which is a complete
/// reply. Once a run has done work, ending is a decision the human gets to
/// make: report where the work stands, then ask before stopping.
pub fn stop_reason(
mode: ExecutionMode,
turns: u32,
reply: &str,
did_work: bool,
stalled: bool,
) -> Option<StopReason> {
if !did_work {
return None;
}
if !mode.allows_turn(turns.saturating_add(1)) {
return Some(StopReason::BudgetExhausted);
}
(stalled || asks_for_input(reply)).then_some(StopReason::MidTaskText)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_prose_answer_is_not_a_stop() {
assert_eq!(stop_reason(ExecutionMode::Bounded(40), 3, "done", false, false), None);
}
#[test]
fn only_a_standalone_marker_asks_for_input() {
assert!(asks_for_input("我做到一半。\n[NEEDS_INPUT]"));
assert!(!asks_for_input("我不会用 [NEEDS_INPUT] 这种标记"));
assert!(!asks_for_input("任务完成"));
}
#[test]
fn a_stalled_or_asking_run_hands_the_turn_to_the_human() {
let mode = ExecutionMode::Bounded(40);
assert_eq!(
stop_reason(mode, 3, "需要密码\n[NEEDS_INPUT]", true, false),
Some(StopReason::MidTaskText)
);
assert_eq!(stop_reason(mode, 3, "我先跳到下一步", true, true), Some(StopReason::MidTaskText));
assert_eq!(stop_reason(mode, 3, "看起来好了", true, false), None);
}
#[test]
fn a_spent_budget_reports_before_a_mid_task_stop() {
assert_eq!(
stop_reason(ExecutionMode::Bounded(40), 40, "", true, false),
Some(StopReason::BudgetExhausted)
);
assert_eq!(
stop_reason(ExecutionMode::Bounded(40), 40, "需要密码\n[NEEDS_INPUT]", true, false),
Some(StopReason::BudgetExhausted)
);
// A goal run that left the loop on its own terms reported its outcome;
// the after-loop guard must not invent a second stop for it.
assert_eq!(stop_reason(ExecutionMode::Goal, 4000, "", true, false), None);
assert_eq!(
stop_reason(ExecutionMode::Goal, 4000, "我先跳过这一步", true, true),
Some(StopReason::MidTaskText)
);
}
#[test]
fn goal_runs_get_more_self_correction_than_plain_runs() {
assert_eq!(MAX_NUDGES_GOAL, 8);
assert_eq!(MAX_NUDGES_PLAIN, 6);
}
#[test]
fn goals_are_unbounded_while_normal_runs_remain_bounded() {
assert!(ExecutionMode::Goal.allows_turn(40));

View File

@ -9,8 +9,8 @@ use bollard::container::{
StartContainerOptions, StopContainerOptions,
};
use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults};
use bollard::models::{HostConfig, HostConfigLogConfig, PortBinding};
use bollard::network::CreateNetworkOptions;
use bollard::models::{EndpointSettings, HostConfig, HostConfigLogConfig, PortBinding};
use bollard::network::{ConnectNetworkOptions, CreateNetworkOptions};
use futures_util::StreamExt;
use lazyboy_control::{
ActionRequest, CommandRequest, CommandResult, EnsureScreenRequest, EnsureScreenResult, HOME,
@ -439,11 +439,65 @@ impl DockerHost {
interactive: bool,
) -> Result<String, String> {
let layout = screen_layout(slot).map_err(|error| error.to_string())?;
if let Some(network) = screen_network() {
match self.screen_container_name(id, &network).await {
Ok(name) => {
let authority = format!("{name}:{}", layout.view_port);
return Ok(view_url(&authority, interactive));
}
Err(error) => tracing::warn!(
"screen network {network} unusable for {id}: {error}; using host ports"
),
}
}
let port = self.published_host_port(id, layout.view_port).await?;
let view = if interactive { "false" } else { "true" };
Ok(format!(
"http://127.0.0.1:{port}/vnc_lite.html?resize=scale&view_only={view}"
))
Ok(view_url(&format!("127.0.0.1:{port}"), interactive))
}
/// Resolves the computer's container name and joins it to the shared screen
/// network on demand, so computers started before that network existed keep
/// working without reprovisioning.
async fn screen_container_name(&self, id: &str, network: &str) -> Result<String, String> {
for _ in 0..20 {
let info = self
.docker
.inspect_container(id, None)
.await
.map_err(|error| error.to_string())?;
if info.state.as_ref().and_then(|state| state.running) == Some(true) {
let name = info
.name
.unwrap_or_default()
.trim_matches('/')
.to_string();
if name.is_empty() {
return Err("computer container has no name".into());
}
self.attach_screen_network(&name, network).await;
return Ok(name);
}
sleep(Duration::from_millis(100)).await;
}
Err("computer is not running".into())
}
async fn attach_screen_network(&self, name: &str, network: &str) {
let result = self
.docker
.connect_network(
network,
ConnectNetworkOptions {
container: name.to_string(),
endpoint_config: EndpointSettings::default(),
},
)
.await;
if let Err(error) = result {
let text = error.to_string();
if !text.to_lowercase().contains("already") {
tracing::warn!("attach {name} to screen network {network}: {text}");
}
}
}
async fn published_host_port(&self, id: &str, view_port: u16) -> Result<String, String> {
@ -1071,6 +1125,21 @@ fn network_name(home_key: &str) -> String {
format!("lbnet-{}", container_name(home_key))
}
/// `LAZYBOY_SCREEN_NETWORK` places the API and the computer containers on one
/// shared Docker network. Without it the desktop proxy must reach published host
/// ports, which bind the host loopback and are unreachable from another container.
fn screen_network() -> Option<String> {
std::env::var("LAZYBOY_SCREEN_NETWORK")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn view_url(authority: &str, interactive: bool) -> String {
let view = if interactive { "false" } else { "true" };
format!("http://{authority}/vnc_lite.html?resize=scale&view_only={view}")
}
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r#"'"'"'"#))
}
@ -1097,3 +1166,20 @@ mod credential_tests {
assert_eq!(a, scoped_control_token(master, "a"));
}
}
#[cfg(test)]
mod screen_url_tests {
use super::*;
#[test]
fn desktop_urls_keep_authority_and_view_mode() {
assert_eq!(
view_url("lb-team-local-space:6080", false),
"http://lb-team-local-space:6080/vnc_lite.html?resize=scale&view_only=true"
);
assert_eq!(
view_url("127.0.0.1:32905", true),
"http://127.0.0.1:32905/vnc_lite.html?resize=scale&view_only=false"
);
}
}

View File

@ -55,6 +55,7 @@ services:
SUPERVISOR_BIND: 0.0.0.0:7091
DATA_DIR: /data
HOST_DATA_DIR: ${LAZYBOY_HOST_DATA_DIR:-${PWD}/data}
LAZYBOY_SCREEN_NETWORK: ${LAZYBOY_SCREEN_NETWORK:-lazyboy_screen}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/data
@ -68,7 +69,7 @@ services:
api:
restart: unless-stopped
init: true
networks: [database, control, egress]
networks: [database, control, egress, screen]
logging: *bounded-logs
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
@ -124,3 +125,7 @@ networks:
control:
internal: true
egress: {}
# API ↔ 電腦 noVNC 專用。桌面代理因此不必繞經只綁主機 loopback 的發布埠。
screen:
name: ${LAZYBOY_SCREEN_NETWORK:-lazyboy_screen}
internal: true

View File

@ -30,15 +30,18 @@ flowchart LR
</div>
主流程之外還有個重要迴圈:
主流程之外還有個重要迴圈:
1. **接管迴圈**:使用者接管時,進行中的 run 進入等待;釋放後從目前畫面重新排隊執行。
2. **技能迴圈**:示範期間記錄控制項與頁面情境,模型整理成 playbook往後仍在當下畫面重新尋找元素不重播舊座標。
3. **暫停迴圈**:動過工具的 run 不準用一句狀態結尾。模型第一次想停先被要求對著當下畫面自我驗證真的缺決定、缺資料時改附中斷run 進入 `waiting_input` 並在對話留下「繼續」按鈕。使用者的下一則訊息直接接回同一個 run`checkpoint.awaitResume`),不另開新任務;輪次預算用盡也走同一條路回報,不會送出空訊息。
## 系統架構
LazyBoy 的公開入口只有 API。Supervisor 位於 Compose 內部 control network不直接對主機開埠Agent 桌面也不掛載主機 Docker socket。
桌面畫面同樣不發布主機埠API 透過一條 internal 的 `lazyboy_screen` 網路,直接用容器名稱連到該電腦的 websockify再由 `/view/<bot>/` 轉給瀏覽器。這樣 API 無論跑在主機或容器內都走同一條路,也不會把沒有密碼的 VNC 暴露到主機網路。
```text
Browser
│ HTTP / WebSocket / authenticated screen proxy

View File

@ -51,6 +51,8 @@
| `LAZYBOY_VAULT_KEY` | 憑證庫加密 key | 必填且必須保持穩定 |
| `LAZYBOY_BIND_IP` | 主機監聽位址 | `127.0.0.1` |
| `LAZYBOY_SECURE_COOKIE` | HTTPS-only cookie | `false` |
| `LAZYBOY_SCREEN_NETWORK` | API ↔ 電腦 noVNC 的 Docker 內網名稱 | `lazyboy_screen` |
| `LAZYBOY_SCREEN_UPSTREAM` | 只取代 loopback 位址的 noVNC 除錯用 host容器名稱不受影響 | `127.0.0.1` |
| `LAZYBOY_COMPUTER_CPUS` | 每台電腦 CPU | `2` |
| `LAZYBOY_COMPUTER_MEMORY_MB` | 每台電腦記憶體 | `2048` |
| `LAZYBOY_COMPUTER_PIDS` | 每台電腦 PID 上限 | `2048` |
@ -59,6 +61,22 @@
完整清單與保留政策請見 [`.env.example`](../.env.example)。
### 從其他裝置連線
`LAZYBOY_BIND_IP` 決定主機在哪個位址發布 `:3101`,改完重建 api 容器生效:
```bash
LAZYBOY_BIND_IP=0.0.0.0 # 區網所有介面可連
LAZYBOY_BIND_IP=10.0.33.1 # 只開放指定網卡
docker compose up -d api
```
- 綁非 loopback 時 `LAZYBOY_APP_TOKEN` 必須至少 32 字元,否則 api 拒絕啟動。
- Origin 檢查比對 `Origin``Host`:直接開 `http://<主機IP>:3101` 可正常使用,從其他網域嵌入會被 `403 cross-origin request rejected` 擋下。
- 純 HTTP 下 session cookie 以明碼走區網;長期或跨網際網路使用請放到 HTTPS reverse proxy 後面,並設定 `LAZYBOY_SECURE_COOKIE=true`
- 暫時性跨網存取建議維持 `127.0.0.1` 綁定改用隧道:`ssh -L 3101:127.0.0.1:3101 <host>`。
- 桌面 noVNC 走 `LAZYBOY_SCREEN_NETWORK` 這條 internal 網路,不佔主機埠;同機跑多組 LazyBoy 時請為每組取不同名稱compose 與 supervisor 會共用同一個值。
## 網站連線驗證
AI 遇到可辨識的 Cloudflare 連線驗證頁時,會先取得最新桌面截圖,再使用

142
tests/run-resume.test.py Normal file
View File

@ -0,0 +1,142 @@
"""Integration regression: a run that paused mid-task must be woken by the next
message, and only the runs that are allowed to be woken.
Requires the project's Postgres Compose service; never modifies the app database.
"""
from pathlib import Path
import re
import subprocess
import uuid
ROOT = Path(__file__).resolve().parents[1]
DB = 'run_resume_test_' + uuid.uuid4().hex
RUNS_RS = (ROOT/'crates/api/src/runs.rs').read_text()
def sql(text, database=DB):
result = subprocess.run(['docker','compose','exec','-T','postgres','psql','-X','-q','-v','ON_ERROR_STOP=1','-U','lazyboy','-d',database], input=text, text=True, capture_output=True, cwd=ROOT)
if result.returncode:
raise AssertionError(result.stderr)
return result.stdout.strip()
def query(text):
result = subprocess.run(['docker','compose','exec','-T','postgres','psql','-X','-q','-t','-A','-U','lazyboy','-d',DB], input=text, text=True, capture_output=True, cwd=ROOT)
if result.returncode:
raise AssertionError(result.stderr)
return result.stdout.strip()
def check(condition, label):
if query(f"SELECT ({condition});") != 't':
raise AssertionError(f'run-resume assertion failed: {label}')
def flatten(text):
return re.sub(r'\s+',' ',text).strip()
# The wake rules live in the run loop. If that SQL moves, this test must move
# with it, so pin the shapes it depends on instead of silently testing nothing.
source = flatten(RUNS_RS)
for fragment in [
"r.status='waiting_input'",
"r.status='waiting_takeover' AND NOT EXISTS",
"c.control_holder='user'",
"UPDATE runs SET status='queued', retry_count=0, checkpoint=checkpoint-'awaitResume', updated_at=now() WHERE id=$1 AND status IN ('waiting_input','waiting_takeover')",
"SET status='waiting_input', lease_owner=NULL, lease_expires_at=NULL, updated_at=now(),",
'"kind": "resume"',
]:
assert flatten(fragment) in source, f'run loop SQL changed, update tests/run-resume.test.py: {fragment}'
WAKE = """
SELECT r.id FROM runs r
WHERE r.bot_id=$BOT AND r.thread_id=$THREAD
AND (
(r.status IN ('queued','leased','running','waiting_input','waiting_takeover')
AND btrim(r.prompt) ~ '^/goal($|[[:space:]])')
OR r.status='waiting_input'
OR (r.status='waiting_takeover' AND NOT EXISTS (
SELECT 1 FROM computers c JOIN bots b ON b.computer_id=c.id
WHERE b.id=r.bot_id AND c.control_holder='user'))
)
ORDER BY r.created_at ASC LIMIT 1
"""
WAKE_RUN = """
UPDATE runs SET status='queued', retry_count=0,
checkpoint=checkpoint-'awaitResume', updated_at=now()
WHERE id=$1 AND status IN ('waiting_input','waiting_takeover')
"""
def wake(bot, thread):
found = query(WAKE.replace('$BOT', f"'{bot}'").replace('$THREAD', f"'{thread}'"))
return found or '<null>'
sql(f'CREATE DATABASE {DB};','postgres')
try:
for migration in sorted((ROOT/'migrations').glob('*.sql')):
sql(migration.read_text())
sql("""
INSERT INTO users(id,name) VALUES ('u','test');
INSERT INTO spaces(id,user_id,name) VALUES ('s','u','test');
INSERT INTO computers(id,space_id,user_id,scope,scope_key,home_key,control_holder)
VALUES ('c_free','s','u','bot','c_free','c_free','none'),
('c_held','s','u','bot','c_held','c_held','user');
INSERT INTO bots(id,space_id,user_id,name,computer_id) VALUES
('b1','s','u','one','c_free'),('b2','s','u','two','c_held'),
('b3','s','u','three','c_free'),('b4','s','u','four','c_free'),
('b5','s','u','five','c_free'),('b6','s','u','six','c_free');
INSERT INTO threads(id,space_id,bot_id,user_id) VALUES
('t1','s','b1','u'),('t2','s','b2','u'),('t3','s','b3','u'),
('t4','s','b4','u'),('t5','s','b5','u'),('t6','s','b6','u');
-- The paused question: work already happened, an answer is owed.
INSERT INTO runs(id,space_id,bot_id,thread_id,user_id,status,prompt,retry_count,checkpoint)
VALUES ('r_paused','s','b1','t1','u','waiting_input','把報價整理成表格',3,
'{"harnessHistory":[{"a":1}],"harnessTurns":12,
"awaitResume":{"reason":"mid_task_text","turns":12,"limit":40}}');
-- A human holding the mouse is never interrupted by an incoming message.
INSERT INTO runs(id,space_id,bot_id,thread_id,user_id,status,prompt)
VALUES ('r_takeover_user','s','b2','t2','u','waiting_takeover','等我登入');
INSERT INTO runs(id,space_id,bot_id,thread_id,user_id,status,prompt)
VALUES ('r_takeover_free','s','b3','t3','u','waiting_takeover','等我登入');
INSERT INTO runs(id,space_id,bot_id,thread_id,user_id,status,prompt)
VALUES ('r_running','s','b4','t4','u','running','整理報價');
INSERT INTO runs(id,space_id,bot_id,thread_id,user_id,status,prompt)
VALUES ('r_goal','s','b5','t5','u','running','/goal 每天彙整日報');
INSERT INTO runs(id,space_id,bot_id,thread_id,user_id,status,prompt)
VALUES ('r_done','s','b6','t6','u','completed','昨天的事');
""")
assert wake('b1','t1') == 'r_paused', 'a paused run must collect the next message'
assert wake('b2','t2') == '<null>', 'a run whose human holds control stays parked'
assert wake('b3','t3') == 'r_takeover_free', 'a released takeover run resumes on reply'
assert wake('b4','t4') == '<null>', 'a plain running run starts its own task'
assert wake('b5','t5') == 'r_goal', 'goal steering still joins the live goal run'
assert wake('b6','t6') == '<null>', 'a finished run is never reopened'
sql(WAKE_RUN.replace('$1', "'r_paused'"))
check("(SELECT status FROM runs WHERE id='r_paused')='queued'", 'paused run must be claimable again')
check("(SELECT retry_count FROM runs WHERE id='r_paused')=0", 'a human answer is not a retry')
check("(SELECT checkpoint ? 'awaitResume' FROM runs WHERE id='r_paused')=false", 'the pending question must be retired')
check("(SELECT checkpoint->>'harnessTurns' FROM runs WHERE id='r_paused')='12'", 'harness state survives the pause')
sql(WAKE_RUN.replace('$1', "'r_running'"))
check("(SELECT status FROM runs WHERE id='r_running')='running'", 'the wake must not disturb a running run')
# The question the UI renders: reason, turns and limit all have to survive.
sql("""
INSERT INTO runs(id,space_id,bot_id,thread_id,user_id,status,prompt,checkpoint)
VALUES ('r_budget','s','b1','t1','u','waiting_input','把報價整理成表格',
'{"awaitResume":{"reason":"budget_exhausted","turns":40,"limit":40}}');
INSERT INTO messages(id,thread_id,seq,role,body,blocks,run_id)
VALUES ('q','t1',1,'assistant','我在這個任務上用了 40 輪…',
'[{"kind":"resume","reason":"budget_exhausted","turns":40,"limit":40}]','r_budget');
""")
check("""(SELECT blocks->0->>'reason' FROM messages WHERE id='q')='budget_exhausted'
AND (SELECT blocks->0->>'limit' FROM messages WHERE id='q')='40'""",
'the resume chip needs reason, turns and limit')
print('PASS: paused runs resume on the next message; human control, live runs and finished runs are never hijacked; harness state and resume chip survive')
finally:
sql(f'DROP DATABASE {DB};','postgres')