Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions docker/base-image/agent_server/services/error_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,12 +225,17 @@ def _classify_signal_exit(
sig_name = _SIGNAL_EXIT_NAMES.get(signum, f"signal {signum}")
tool_count = metadata.tool_count if metadata else 0
num_turns = metadata.num_turns if (metadata and metadata.num_turns) else 0
# #929: agent cap is now the schedule ceiling (write-time validation on
# the backend), so the SIGKILL cause set is bounded: schedule timeout,
# OOM, or operator cancel. Drop the misleading "schedule/agent" disjunction
# — the agent cap never silently truncates a schedule under Approach A.
detail = (
f"Execution terminated by {sig_name} after {tool_count} tool calls "
f"/ {num_turns} turns (exit code {return_code}). "
f"Likely cause: schedule/agent timeout exceeded, OOM kill, or operator cancel. "
f"Increase the schedule's timeout_seconds, raise agent memory, "
f"or split the skill into smaller steps."
f"Likely cause: schedule timeout exceeded, OOM kill, or operator cancel. "
f"To allow longer runs raise the schedule's timeout_seconds "
f"(bounded by the agent's execution_timeout_seconds cap); "
f"for OOM raise the agent memory limit; otherwise split the skill into smaller steps."
)
return (504, detail)

Expand Down
6 changes: 3 additions & 3 deletions docs/memory/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ picks up on its next poll. (#389 S1a)
| GET | `/api/agents/{name}/read-only` | Get read-only mode status and config (NEW: 2026-02-17) |
| PUT | `/api/agents/{name}/read-only` | Enable/disable read-only mode (blocks source file writes) |
| GET | `/api/agents/{name}/timeout` | Get execution timeout setting (NEW: 2026-03-12) |
| PUT | `/api/agents/{name}/timeout` | Set execution timeout (60-7200s, default 3600s = 60min, #665) |
| PUT | `/api/agents/{name}/timeout` | Set execution timeout (60-7200s, default 3600s = 60min, #665). 400 with `error=agent_timeout_below_active_schedules` if the new cap would drop below any non-deleted schedule's `timeout_seconds` (#929). |
| GET | `/api/agents/{name}/guardrails` | Get per-agent guardrails config (NEW: 2026-04-15) |
| PUT | `/api/agents/{name}/guardrails` | Set per-agent guardrails overrides (GUARD-001) |
| GET | `/api/agents/{name}/file-sharing` | Get outbound file-sharing status + quota (NEW: 2026-04-24, FILES-001) |
Expand Down Expand Up @@ -600,9 +600,9 @@ picks up on its next poll. (#389 S1a)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/agents/{name}/schedules` | List schedules |
| POST | `/api/agents/{name}/schedules` | Create schedule |
| POST | `/api/agents/{name}/schedules` | Create schedule. 400 with `error=schedule_timeout_exceeds_agent_cap` if `body.timeout_seconds > agent.execution_timeout_seconds` (#929). |
| GET | `/api/agents/{name}/schedules/{id}` | Get schedule |
| PUT | `/api/agents/{name}/schedules/{id}` | Update schedule |
| PUT | `/api/agents/{name}/schedules/{id}` | Update schedule. Same 400 when the update touches `timeout_seconds` and the new value exceeds the agent cap (#929). |
| DELETE | `/api/agents/{name}/schedules/{id}` | Delete schedule |
| POST | `/api/agents/{name}/schedules/{id}/enable` | Enable schedule |
| POST | `/api/agents/{name}/schedules/{id}/disable` | Disable schedule |
Expand Down
65 changes: 65 additions & 0 deletions docs/memory/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -2361,6 +2361,71 @@ Standalone mobile-friendly admin page for managing agents on the go. Designed as

---

## 35. Schedule Timeout Validation (#929)

### 35.1 Agent Cap as Schedule Ceiling (#929)
- **Status**: 🚧 In Progress
- **Implements**: Issue #929
- **Description**: `agent_ownership.execution_timeout_seconds` becomes
a hard ceiling for `agent_schedules.timeout_seconds`. The two
settings previously coexisted as independent knobs with no
enforcement between them — schedules silently won, and the agent
cap applied only to the chat/ad-hoc fallback path. That divergence
trapped operators who assumed `min(agent, schedule)` semantics from
the side-by-side UI. Approach A from #929: validate at write time
so the operator's mental model snaps into place — the agent cap is
a real ceiling, exceeded values fail fast at config time instead
of silently surviving until SIGKILL.
- **Validation rules**:
- `POST /api/agents/{name}/schedules` — 400 if
`body.timeout_seconds > agent.execution_timeout_seconds`.
- `PUT /api/agents/{name}/schedules/{id}` — 400 if the new
`timeout_seconds` would exceed the agent cap.
- `PUT /api/agents/{name}/timeout` — 400 if the new agent cap
would drop below any non-deleted schedule's `timeout_seconds`
(caller must raise the cap before lowering individual schedules,
or vice versa).
- **Error contract**: 400 responses use FastAPI `HTTPException` with
a structured detail dict so clients can branch on the cause:
```json
{
"error": "schedule_timeout_exceeds_agent_cap",
"message": "Schedule timeout 7200s exceeds agent execution_timeout_seconds 3600s. Raise the agent cap via PUT /api/agents/{name}/timeout first.",
"agent_cap_seconds": 3600,
"requested_seconds": 7200
}
```
and respectively `agent_timeout_below_active_schedules` for the
agent-cap-lowering path (carries
`max_schedule_timeout_seconds` + the offending schedule list).
- **DB accessor**:
`db.find_active_schedules_exceeding_timeout(agent_name, ceiling)` —
returns `[{id, name, timeout_seconds}, …]` for every non-soft-deleted
schedule whose `timeout_seconds > ceiling`, ordered DESC. Powers the
agent-timeout endpoint's 400 detail payload (operator sees which
schedules block the cap-lowering). Schedule endpoints compare
directly against `db.get_execution_timeout(agent_name)`.
- **No retro-validation**: pre-existing rows that violate the
invariant (`schedule.timeout_seconds > agent.execution_timeout_seconds`)
are left alone — the migration story is "next edit fixes it." The
agent-cap-lowering check still sees those rows so the operator can't
make the gap *worse*.
- **Orthogonal SIGKILL error-message fix** (same PR): the agent-side
signal-exit classifier in
`docker/base-image/agent_server/services/error_classifier.py`
emitted `"Likely cause: schedule/agent timeout exceeded, OOM kill,
or operator cancel."`. With the cap enforced at write time, the
"/agent" disjunction is dead — schedules can never run past the
cap. Message simplified to surface the schedule timeout
unambiguously.
- **Out of scope**: exposing `timeout_seconds_effective` /
`capped_by` on the schedule response (Approach B from #929 —
would be trivially identical to `timeout_seconds` under A and
pure clutter); retrofitting the SIGKILL message to know whether
OOM vs timeout fired (agent has no signal for that distinction).

---

## Out of Scope

- Multi-tenant deployment (single org only)
Expand Down
5 changes: 5 additions & 0 deletions src/backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,11 @@ def get_schedule(self, schedule_id: str):
def list_agent_schedules(self, agent_name: str):
return self._schedule_ops.list_agent_schedules(agent_name)

def find_active_schedules_exceeding_timeout(self, agent_name: str, ceiling_seconds: int):
return self._schedule_ops.find_active_schedules_exceeding_timeout(
agent_name, ceiling_seconds
)

def list_all_enabled_schedules(self):
return self._schedule_ops.list_all_enabled_schedules()

Expand Down
24 changes: 24 additions & 0 deletions src/backend/db/schedules.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,30 @@ def list_agent_schedules(self, agent_name: str) -> List[Schedule]:
""", (agent_name,))
return [self._row_to_schedule(row) for row in cursor.fetchall()]

def find_active_schedules_exceeding_timeout(
self, agent_name: str, ceiling_seconds: int
) -> List[Dict]:
"""Active schedules whose ``timeout_seconds > ceiling_seconds`` (#929).

Returns a thin list of ``{id, name, timeout_seconds}`` dicts for
the agent-cap-lowering error payload — the caller surfaces them
so the operator knows which schedules need editing first.
"""
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id, name, timeout_seconds
FROM agent_schedules
WHERE agent_name = ?
AND deleted_at IS NULL
AND timeout_seconds > ?
ORDER BY timeout_seconds DESC
""", (agent_name, ceiling_seconds))
return [
{"id": row["id"], "name": row["name"], "timeout_seconds": row["timeout_seconds"]}
for row in cursor.fetchall()
]

def list_all_enabled_schedules(self) -> List[Schedule]:
"""List all enabled schedules (for scheduler initialization).

Expand Down
24 changes: 24 additions & 0 deletions src/backend/routers/agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,30 @@ async def set_agent_timeout(
detail="execution_timeout_seconds must be an integer between 60 and 7200 (1 min to 2 hours)"
)

# #929 Approach A: the agent cap is the schedule ceiling, so refuse to
# lower it below any active schedule's timeout. Caller must either raise
# the cap above the affected schedules or shrink those schedules first.
blocking_schedules = db.find_active_schedules_exceeding_timeout(
agent_name, timeout_seconds
)
if blocking_schedules:
max_blocking = max(s["timeout_seconds"] for s in blocking_schedules)
raise HTTPException(
status_code=400,
detail={
"error": "agent_timeout_below_active_schedules",
"message": (
f"Cannot lower agent timeout to {timeout_seconds}s — "
f"{len(blocking_schedules)} active schedule(s) on '{agent_name}' "
f"have timeout_seconds up to {max_blocking}s. Edit those "
f"schedules first, then retry."
),
"requested_seconds": timeout_seconds,
"max_schedule_timeout_seconds": max_blocking,
"blocking_schedules": blocking_schedules,
},
)

# Update database
success = db.set_execution_timeout(agent_name, timeout_seconds)
if not success:
Expand Down
36 changes: 36 additions & 0 deletions src/backend/routers/schedules.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,31 @@ class Config:

# Schedule CRUD Endpoints


def _enforce_timeout_below_agent_cap(agent_name: str, requested_seconds: int) -> None:
"""#929 Approach A: refuse a schedule timeout above the agent cap.

`agent_ownership.execution_timeout_seconds` is the hard ceiling.
Raises HTTPException(400) with a structured `detail` dict so clients
can branch on `detail["error"] == "schedule_timeout_exceeds_agent_cap"`.
"""
cap = db.get_execution_timeout(agent_name)
if requested_seconds > cap:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "schedule_timeout_exceeds_agent_cap",
"message": (
f"Schedule timeout {requested_seconds}s exceeds agent "
f"execution_timeout_seconds {cap}s. Raise the agent cap "
f"first via PUT /api/agents/{agent_name}/timeout."
),
"agent_cap_seconds": cap,
"requested_seconds": requested_seconds,
},
)


@router.get("/{name}/schedules", response_model=List[ScheduleResponse])
async def list_agent_schedules(name: AuthorizedAgent):
"""List all schedules for an agent."""
Expand All @@ -239,6 +264,10 @@ async def create_schedule(
detail=f"Invalid cron expression: {str(e)}"
)

# #929: schedule timeout cannot exceed the agent cap. Validated here so
# the operator gets the 400 at config time instead of a SIGKILL at run time.
_enforce_timeout_below_agent_cap(name, schedule_data.timeout_seconds)

schedule = db.create_schedule(name, current_user.username, schedule_data)
if not schedule:
raise HTTPException(
Expand Down Expand Up @@ -294,6 +323,13 @@ async def update_schedule(
detail=f"Invalid cron expression: {str(e)}"
)

# #929: validate timeout against agent cap only when this PUT actually
# touches `timeout_seconds`. exclude_unset semantics: a write that
# doesn't include the field doesn't get re-checked (existing rows that
# predate the validation stay editable).
if updates.timeout_seconds is not None:
_enforce_timeout_below_agent_cap(name, updates.timeout_seconds)

# Build updates dict — use exclude_unset to distinguish "not provided" from "explicitly set to null"
update_dict = updates.model_dump(exclude_unset=True)

Expand Down
Loading
Loading