Synced from monorepo

Synced from monorepo

Changes:
- Workspace task snapshots only list incomplete backgrounded tasks
- Quiet auth, LSP, and config warnings in the shell
- Fix observability attributes for warm store errors, restore setup, remote tools, and preview denials
- Fail closed when soak metrics are missing
- Run plan-mode exit last in mixed tool batches
- Allow /loop to store prompts that can terminate the loop
- Make subagent maximum nesting depth configurable
- Security: apply sandbox profile to the leader process that executes tools

Source-Revision: 1adcd1f477870e4a97bacbd6be78c8a3bfbac46d
This commit is contained in:
grokkybara[bot] 2026-07-27 17:54:34 +00:00
commit 02d9359435
96 changed files with 2346 additions and 351 deletions

View file

@ -15,37 +15,81 @@ pub fn loop_usage_message() -> &'static str {
Tell me how often it should run (e.g. 30m, 1 hour, every 2 days)."
}
/// Where a scheduled fire runs, which decides what the stored prompt can rely on.
///
/// Resolved from `[scheduler] background_loops` (env, config, managed policy and
/// remote settings all feed it), so `/loop` describes the runtime the user
/// actually has rather than hedging across both.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoopFireMode {
/// Each fire runs in a detached background subagent that cannot see this
/// conversation. The default.
Detached,
/// Each fire runs as a turn in this conversation, where earlier results from
/// the same task may still be visible.
InSession,
}
/// Build the model instruction that `/loop` expands into for `args`.
///
/// The model, not brittle host parsing, turns the request into the
/// `scheduler_create` interval, accepting every natural phrasing and erroring
/// on bad input rather than silently defaulting. See [`loop_usage_message`].
pub fn loop_schedule_instruction(args: &str) -> String {
///
/// Only the framing differs by `mode`; the stop condition and length guidance
/// are identical, because both hold wherever the fire runs.
pub fn loop_schedule_instruction(args: &str, mode: LoopFireMode) -> String {
let fire_context = match mode {
LoopFireMode::Detached => {
"Each fire runs in a detached background subagent, not in this conversation,\n\
so the prompt you store must stand on its own.\n\n\
## Writing a prompt that survives a fresh fire\n\
- Inline the state a fire needs: paths, job/PR/branch ids, the command that checks\n\
status, and what \"healthy\" looks like. A fire cannot see this conversation, and\n\
a long-running task restarts from a short summary every few iterations.\n\
- Only a short status comes back here, so say what that status must contain."
}
LoopFireMode::InSession => {
"Each fire arrives as a new turn in this conversation, and earlier results from\n\
the same task may still be above it. The stored prompt is re-sent verbatim every\n\
time, so write a standing order rather than a one-off request.\n\n\
## Writing a prompt that reads well on every fire\n\
- Name the state that must not be guessed: paths, job/PR/branch ids, the command\n\
that checks status, and what \"healthy\" looks like. This conversation is\n\
compacted as it grows, so do not rely on details staying visible.\n\
- Earlier fires may be above you: continue from them instead of restarting."
}
};
format!(
"# /loop -- schedule a recurring prompt\n\n\
Parse the input below into an interval and a prompt, then schedule it with scheduler_create.\n\n\
Turn the input below into a scheduler_create call. {fire_context}\n\
- Say what one fire does and when it bails: \"if still pending, report one line and\n\
stop.\" A fire must not poll inline.\n\
- Give it a stop condition and an exit: \"when <condition> holds, report it and call\n\
scheduler_delete <task_id>.\" Without that the loop runs until it expires.\n\
- Keep it short and concrete -- the stored prompt is re-sent on every fire.\n\n\
## Deriving the interval\n\
Read how often to run from the user's request however they phrase it and convert it\n\
to a compact `<number><unit>` string, where unit is one of `s` (seconds), `m` (minutes),\n\
`h` (hours), or `d` (days). The interval may appear at the start or end of the request;\n\
extract it and use the remaining text as the prompt.\n\n\
The minimum interval is 60 seconds; shorter values are raised to 60s, so tell the user if that applies.\n\n\
If the request contains no interval at all, ask the user how often it should run before\n\
scheduling. Do NOT invent or assume a default interval.\n\n\
Convert the user's cadence -- however phrased, at either end of the request -- into a\n\
compact `<number><unit>` string (`s`/`m`/`h`/`d`); the remaining text is the prompt.\n\
The minimum is 60 seconds and shorter values are raised, so say so when it applies.\n\
If no cadence is given, ask the user how often it should run -- never invent one.\n\n\
## Action\n\
1. Call scheduler_create with: interval (the compact string you derived), prompt,\n\
fire_immediately: true. If the interval is unparseable, the tool\n\
returns an error fix the interval string rather than guessing.\n\
2. Confirm: what's scheduled, the cadence, that it auto-expires after 7 days,\n\
and that they can cancel with scheduler_delete (include the job ID).\n\
3. Do NOT execute the prompt inline. The scheduler will fire it immediately.\n\n\
Schedule from what the user already gave you \u{2014} do not explore the workspace or run\n\
checks before scheduling; the first fire does that.\n\
1. Call scheduler_create with the interval, the prompt, and fire_immediately: true.\n\
If the interval is rejected, fix the string rather than guessing.\n\
2. Confirm what's scheduled, the cadence, its stop condition, that it auto-expires\n\
after 7 days, and the task_id to cancel with scheduler_delete.\n\
3. Do NOT execute the prompt inline. The scheduler fires it immediately.\n\n\
## Wrong tool for the job\n\
- \"Tell me when X finishes\" -> a background command or watch tool that wakes you on\n\
the event, not a recurring loop that re-checks on a timer.\n\
- \"Do X once in N minutes\" -> background `sleep <secs> && <command>`; scheduling is\n\
recurring-only.\n\n\
## Changing an existing loop\n\
Call scheduler_create with its task_id and the fields that change; do not\n\
Call scheduler_create with its task_id and only the changed fields; do not\n\
delete and recreate. If later work changes what a loop should do, update its\n\
prompt the same way.\n\n\
## One-time delayed work\n\
Scheduling is recurring-only. For \"do X once in N minutes\", run a background\n\
terminal command (`sleep <secs> && <command>`); its completion notifies you.\n\n\
## Input\n\
{args}"
)
@ -197,23 +241,50 @@ mod tests {
#[test]
fn instruction_carries_args_and_contract_tokens() {
let text = loop_schedule_instruction("every 30 minutes do x");
assert!(text.contains("every 30 minutes do x"));
assert!(text.contains("<number><unit>"));
assert!(text.contains("ask the user how often"));
assert!(!text.contains("10m"), "no host-side default interval");
assert!(
!text.contains("recurring:"),
"the retired one-shot flag must not be referenced"
);
assert!(
text.contains("task_id"),
"must teach in-place updates via task_id"
);
assert!(
text.contains("delete and recreate"),
"must steer away from delete+recreate"
);
for mode in [LoopFireMode::Detached, LoopFireMode::InSession] {
let text = loop_schedule_instruction("every 30 minutes do x", mode);
assert!(text.contains("every 30 minutes do x"), "{mode:?}");
assert!(text.contains("<number><unit>"), "{mode:?}");
assert!(text.contains("ask the user how often"), "{mode:?}");
assert!(
!text.contains("10m"),
"no host-side default interval: {mode:?}"
);
assert!(
!text.contains("recurring:"),
"the retired one-shot flag must not be referenced: {mode:?}"
);
assert!(
text.contains("task_id"),
"must teach in-place updates via task_id: {mode:?}"
);
assert!(
text.contains("delete and recreate"),
"must steer away from delete+recreate: {mode:?}"
);
assert!(
text.contains("scheduler_delete <task_id>"),
"every mode must authorize the fire to end the task: {mode:?}"
);
}
}
#[test]
fn each_fire_mode_describes_its_own_runtime() {
let detached = loop_schedule_instruction("5m check ci", LoopFireMode::Detached);
let in_session = loop_schedule_instruction("5m check ci", LoopFireMode::InSession);
assert!(detached.contains("cannot see this conversation"));
assert!(!detached.contains("arrives as a new turn in this conversation"));
assert!(in_session.contains("arrives as a new turn in this conversation"));
assert!(!in_session.contains("cannot see this conversation"));
// The two levers the A/B showed carry the behavior are mode-independent.
for text in [&detached, &in_session] {
assert!(text.contains("report it and call"));
assert!(text.contains("Keep it short and concrete"));
}
}
#[test]