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
2 changes: 1 addition & 1 deletion docs/feature-map/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ Settings → Overview; the graph visualizer is a Developer internals view.
|---|---|---|---|---|---|
| Schedule | Cron jobs: recurring agent turns, scripts, commands | `/schedule` — rail **Schedule**; also created inline from the crew editor's "What wakes this crew" section (`/capabilities?tab=crews`) | `pages/SchedulePage.tsx`, `components/CrewWakeSection.tsx` | `handlers/cron.py` | `GET,POST /api/crons`, `DELETE /api/crons/{job_id}`, `GET /api/crons/history` |
| Cron secret grants | Owner-approved vault-secret env grants for script crons: agent requests via `cron_secret_request`, the owner approves/denies/revokes on the job's Secrets panel | `/schedule` → job → **Secrets** | `pages/SchedulePage.tsx` (`JobSecretsPanel`) | `handlers/cron.py` | `PUT /api/crons/{job_id}/secrets` |
| Monitor loops | Same-session bounded monitors and legacy nudge loops watching an external thing; a member's loop is also surfaced read-only on the Crew Members drawer | Agent/API for bounded monitors; Chat header → legacy loop popover; Crew Members → member drawer → Auto patrol | `components/AutoNudgePopover.tsx`, `components/autoNudgeLoop.ts`, `pages/members/MembersPage.tsx` (read-only) | `handlers/autonudge.py` | `GET,POST /api/monitors`, `PATCH /api/monitors/{id}`, `GET /api/monitors/slot/{slot_key}`, `POST /api/monitors/{id}/stop`, `POST /api/monitors/{id}/restart`, `GET,POST /api/autonudge`, `PATCH,DELETE /api/autonudge/{loop_id}` |
| Monitor loops | Same-session bounded monitors and legacy nudge loops watching an external thing; a member's loop is also surfaced read-only on the Crew Members drawer | Agent/API for bounded monitors; Chat header → legacy loop popover; Crew Members → member drawer → Auto patrol | `components/AutoNudgePopover.tsx`, `components/autoNudgeLoop.ts`, `pages/members/MembersPage.tsx` (read-only) | `handlers/autonudge.py` | `GET,POST /api/monitors`, `PATCH /api/monitors/{id}`, `GET /api/monitors/slot/{slot_key}`, `POST /api/monitors/{id}/stop`, `POST /api/monitors/{id}/restart`, `GET,POST /api/autonudge`, `PATCH,DELETE /api/autonudge/{loop_id}`, `POST /api/autonudge/{loop_id}/fire` |
| Session ledger | Durable per-session work state surviving compaction | Agent-written; no dashboard page | — | `handlers/session_ledger.py` | `GET /api/session-ledger`, `POST /api/session-ledger/record` |
| Session control | Create / stop / send-to a session from outside it | Agent and app callers, not a UI | — | `session_control.py` | `POST /api/session-control/create`, `.../stop`, `.../send`, `GET .../read` |

Expand Down
2 changes: 1 addition & 1 deletion docs/system-specs/modules/learn-cron-dashboard.md

Large diffs are not rendered by default.

89 changes: 89 additions & 0 deletions src/kiro_crew/autonudge.py
Original file line number Diff line number Diff line change
Expand Up @@ -4366,6 +4366,95 @@ async def _run_fire_cycle(self, loop: NudgeLoop) -> None:
if is_channel_key(loop.slot_key) and loop.active and loop.id in self._loops:
self._arm_from_deadline(loop)

async def fire_now(self, loop_id: str) -> tuple["NudgeLoop | None", str, int]:
"""Bring one loop's next cycle forward to now, out of band from its countdown.

Returns ``(loop, "", 200)`` once the cycle is armed to run, or
``(None, reason, status)`` on refusal — the ``(obj, error, status)``
shape the authz chokepoints in :mod:`kiro_crew.autonudge_authz` already
use, so the HTTP handler stays a thin mapping.

WHAT THIS DELIBERATELY DOES NOT DO: it does not deliver the nudge
itself. It re-arms through :meth:`_arm_timer`, so the cycle runs inside
the ordinary :meth:`_timer` body — the stop sentinel, the cycle cap, the
wall-clock budget, the approval-stall stop and the probe gate all apply
exactly as they do on a scheduled tick, and the delivery goes through the
one ``_on_fire`` path. Calling :meth:`_run_fire_cycle` directly would have
needed that whole ladder restated here, and a second copy of a
five-condition gate is a divergence waiting to happen.

``delay=0.0`` rather than :meth:`_arm_from_deadline`'s
``_OVERDUE_REARM_SECS`` beat. That beat exists so an elapsed deadline
does not ambush a user mid-conversation — they keep deferring it simply
by typing. A manual trigger IS the user asking, so the condition the beat
protects against is not present.

Three refusals, and each one is load-bearing rather than defensive:

* **Not registered** -> 404. Nothing to fire. This is also where the stop
SENTINEL lands: it goes through ``remove``, so the loop is gone rather
than merely inactive.
* **Not active** -> 409. The non-removing terminal bounds — the cycle
cap, the wall-clock budget and the approval stall — all leave the loop
registered but inactive, so this ONE condition covers them without
restating the list. A manual press must not buy a turn past a bound the
user armed.
* **Mid-fire** -> 409. :meth:`_arm_timer` cancels the existing timer
task, and during the fire window that task may be parked on
``_persist_locked()`` writing the delivered cycle; cancelling it there
loses the ``cycle_count`` bump. This is the same window
``notify_turn_complete``/``notify_user_input`` defer around, and the
same answer the sibling immediate-trigger route gives for a run
already in flight (``POST /api/crons/{id}/run`` -> 409).

NO SUSPENSION POINT, and that is the design rather than an omission.
``async def`` for the caller's convenience, but nothing inside awaits, so
the guards and the arm are atomic with respect to the event loop: between
reading ``loop`` and arming it, no other coroutine can run.

This shape was arrived at the hard way and the history is worth keeping.
An earlier revision wrote ``next_due_ts = time.time()`` and awaited a
DURABLE persist before arming, so a restart between the press and the
fire would resume overdue. That await was a suspension window, and this
module has several writers to ``next_due_ts`` that hold NO lock while
writing it — the quiet-tick re-arm on the gated-wake branch is one. Each
guard added to close one writer's window exposed the next: a concurrent
``remove`` arming a stale object, a cancelled caller abandoning the write,
a countdown entering ``_firing`` mid-persist, a quiet tick overwriting the
deadline, then the refused path leaving its own value durably committed.
Five rounds, each caused by the fix before it. The window is not closable
at this call site, because the racing writers take no lock at all.

SO THE WRITE IS GONE. ``_arm_timer(delay=0.0)`` is what brings the cycle
forward: :meth:`_timer` sleeps the delay it is given and fires WITHOUT
consulting ``next_due_ts``. The write was only ever for restart cosmetics,
and that is exactly what is given up — a gateway restart between the press
and the fire resumes on the loop's own schedule instead of overdue, and
the operator presses again. That is the same degradation
:meth:`_persist_soon` documents as acceptable for every other deadline
assignment in this class ("a lost write degrades to a fresh full countdown
after restart, never a premature or dropped fire"), and a far better trade
than a sixth guard on an uncloseable window.

The countdown reset issue #8212 asks for is UNAFFECTED, because it never
came from this write: a delivered cycle clears ``next_due_ts`` in
:meth:`_run_fire_cycle` and the re-arm then starts a fresh full interval.
"""
loop = self.get_by_id(loop_id)
if loop is None:
return None, "loop not found", 404
if not loop.active:
return None, "loop is not active", 409
if loop_id in self._firing:
return None, "loop is already firing", 409
self._arm_timer(loop, delay=0.0)
logger.info(
"AutoNudge: loop %s brought forward by hand — cycle %d armed to run now",
loop.id,
loop.cycle_count + 1,
)
return loop, "", 200


class _AutoNudgeMaintenanceView:
"""Store operations that are safe inside ``maintenance_service``'s lock."""
Expand Down
200 changes: 200 additions & 0 deletions src/kiro_crew/dashboard/handlers/autonudge.py
Original file line number Diff line number Diff line change
Expand Up @@ -690,3 +690,203 @@ async def api_autonudge_delete(request: web.Request) -> web.Response:
metadata={"loop_id": loop_id, "caller": request.remote or ""},
)
return web.json_response({"ok": True})


async def api_autonudge_fire(request: web.Request) -> web.Response:
"""POST /api/autonudge/{loop_id}/fire — run this loop's next cycle now.

The manual counterpart of the idle timer, for the case the loop's interval
cannot serve: the operator already knows the thing being waited on has
changed, so the remaining gap buys nothing. Spelled ``fire`` rather than the
cron sibling's ``run`` because ``fire`` is this subsystem's own verb
(``_on_fire``, ``_run_fire_cycle``, ``last_fire_ts``, the ``fired`` event).

Thin HTTP mapping, as everywhere else in this file: ``svc.fire_now`` owns the
schedule semantics and the not-registered / not-active / mid-fire refusals,
and documents why each is load-bearing. Two refusals belong here instead,
because they are about the transport's own subject rather than the loop:

* A **structured monitor** is refused with the same 409 code ``PATCH`` uses.
Those records are driven by ``_on_monitor_tick`` and owned by the monitor
API; the goal popover never sees one, since ``api_autonudge_get`` filters
them out.
* A **busy session** is refused rather than queued, and that is not a fresh
product decision — the fire path this route arms already made it, with its
reason written down at the site: queueing "would stack identical 3KB+
nudges and blow up the context window" (``_fire_dashboard_nudge``). The
predicate is the repository's canonical one, ``slot.running or
slot._in_stage_execution``, read here exactly as the cron-injection
handler reads it (``handlers/messaging.py``) — ``slot.running`` alone is
False between the stages of a multi-stage plan, so it would let this land
a concurrent turn on top of the plan. Note the two consumers of that
predicate diverge deliberately: the cron path QUEUES, this one REFUSES,
and the nudge path's stated reason is the one that applies here.

This check is an AFFORDANCE, not a guarantee: a turn that starts between
it and the fire is still refused by the fire path, which then re-arms with
backoff. Its whole job is to turn that silence into a 409 the popover can
show. A loop bound to a channel key has no dashboard slot, so the check
is skipped and that transport's own busy guard answers.

Authentication is the same as its ``POST`` / ``PATCH`` / ``DELETE`` siblings
on this path, deliberately: no new trust boundary, and this route is
strictly LESS powerful than the ``POST`` beside it, which arms a loop that
can spend turns until a bound stops it.

**Every outcome is audited, and the FIRE is audit-or-deny.** The split is the
one this repository already draws, not a new policy:

* The **fire** is gated on a ``critical=True`` write that lands BEFORE
``fire_now`` arms anything. The default path only ENQUEUES, and on the
event loop an enqueue failure drops the event with a warning
(``sel.py``), so a best-effort pre-audit would still let a model turn run
unrecorded -- the audit would be a hope, not a gate. ``critical`` writes
synchronously and re-raises, and the write is awaited through
``asyncio.to_thread`` because a synchronous flush on the loop would freeze
every session's turn (``no-blocking-call-on-event-loop``). This is the same
shape this subsystem's own ``autonudge_authz`` uses for ``monitor_update``
and ``monitor_stop``, and the 503 mirrors ``handlers/cron.py``'s
``audit_unavailable`` refusal for a grant it could not record.
* The **refusals** stay best-effort. An earlier revision audited only after
``fire_now`` returned, so the four guards below denied requests and left no
SEL event at all -- that was a real hole and is fixed. But making them
critical would trade an audit-sink problem for a different failure while
preventing nothing: the request is refused either way, so availability must
not hinge on SEL disk health. That is the disposition
``messaging/identity`` states for a deny and ``azure_client`` states for a
post-action outcome.
* The **terminal** event after ``fire_now`` is best-effort for the same
reason: by then the timer is armed and the ``invoked`` record has landed,
so raising would replace a real result with a logging error.

Routing every exit through these two helpers is what makes the property
structural rather than a habit: a guard added later cannot silently skip the
record, because there is no un-audited way out.
"""
# Read before the service check so the audit helpers can name the subject
# even on the disabled path. Pure ``match_info`` read; no service needed.
loop_id = request.match_info["loop_id"]

async def _audit(outcome: str, session_key: str, error: str) -> None:
"""Best-effort record for an outcome that did NOT start a turn.

OFF THE LOOP and failure-swallowing, both for stated reasons. The default
SEL path only enqueues, which is cheap -- but ``sel()`` itself may lazily
initialize the log, and on a degraded sink that initialization is
filesystem work that would run on the gateway's event loop and stall
every session (``no-blocking-call-on-event-loop``). And because this
record accompanies a request that is being REFUSED, its own failure must
not turn a clean 409 into a 500: the caller already learns the outcome
from the status, so the audit is best-effort by contract here, exactly as
the post-action outcome events are elsewhere in the codebase. The
write-ahead ``invoked`` record is the one that fails closed.
"""
try:
await asyncio.to_thread(
lambda: sel().log_tool_invocation(
session_key=session_key,
source="dashboard",
tool_name="autonudge_fire",
outcome=outcome,
metadata={
"loop_id": loop_id,
"caller": request.remote or "",
"error": error,
},
)
)
except Exception:
logger.warning(
"autonudge fire: refusal audit unavailable (outcome=%s)",
outcome,
exc_info=True,
)

async def _audit_or_deny(session_key: str) -> bool:
"""Write-ahead audit for the fire. False = do not fire.

``sel()`` is resolved INSIDE the worker: on a fresh gateway the lookup
lazily initializes the log, which is itself filesystem work that must not
run on the event loop.
"""
try:
await asyncio.to_thread(
lambda: sel().log_tool_invocation(
session_key=session_key,
source="dashboard",
tool_name="autonudge_fire",
outcome="invoked",
critical=True,
metadata={"loop_id": loop_id, "caller": request.remote or ""},
)
)
except Exception:
logger.error("autonudge fire denied: SEL audit unavailable", exc_info=True)
return False
return True

svc = _autonudge_get()
if svc is None:
await _audit("denied", "", "autonudge_disabled")
return web.json_response(
{"error": "auto-nudge disabled", "code": "autonudge_disabled"},
status=503,
)
existing = svc.get_by_id(loop_id)
if existing is None:
await _audit("denied", "", "autonudge_not_found")
return web.json_response(
{"error": "loop not found", "code": "autonudge_not_found"}, status=404
)
if is_structured_monitor_loop(existing):
await _audit("denied", existing.slot_key, "structured_monitor_requires_monitor_api")
return _monitor_error(
"structured monitors must use the monitor update API",
"structured_monitor_requires_monitor_api",
status=409,
)
state: DashboardState = request.app["state"]
slot = state.get_slot(existing.slot_key)
if slot is not None and (slot.running or slot._in_stage_execution):
# Names the OUTCOME and the NEXT STEP, not just the condition. "a turn is
# in flight" leaves a reader unable to tell a refusal from a delay, and
# the distinction is the whole point here: the press was refused, not
# queued, so trying again later is the action. "still working" rather
# than "mid-turn": a usability reader could only guess at the latter,
# which is jargon from this codebase's vocabulary and not the user's.
# Lowercase-first because all 13 error messages in this file are, and
# this body is rendered verbatim beside them.
await _audit("denied", existing.slot_key, "session_busy")
return web.json_response(
{
"error": "nudge not sent: the agent is still working, so try again when it finishes",
"code": "session_busy",
},
status=409,
)
if not await _audit_or_deny(existing.slot_key):
# Fail closed, with nothing armed: the deadline has not moved and no
# timer was re-armed, so the loop is exactly as the operator left it.
return web.json_response(
{
"error": "audit log unavailable: the nudge was NOT sent, "
"so fix the audit store and press again",
"code": "audit_unavailable",
},
status=503,
)
loop, error, status = await svc.fire_now(loop_id)
await _audit("success" if error == "" else "denied", existing.slot_key, error)
if error:
# Each arm carries a LITERAL status beside its code, rather than passing
# ``status=status`` through. The error-code contract caps dynamic
# statuses for a stated reason — computing one is how the coded-response
# ratchet gets defeated while looking like ordinary refactoring — so the
# pairing is written out where a reader and a static check can both see
# it. Both arms are kept even though this route answers ``not found``
# itself above: relying on the 404 being unreachable would make a later
# edit to that guard silently change this response's status.
if status == 404:
return web.json_response({"error": error, "code": "autonudge_not_found"}, status=404)
return web.json_response({"error": error, "code": "autonudge_not_fired"}, status=409)
return web.json_response({"ok": True, "loop": _serialize(loop)})
Loading
Loading