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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@ should always use the MCP tool equivalents.
| — | `local_knowledge_search` | kirocrew-core |
| — | `file_send` | kirocrew-core |
| — | `autonudge_stop` | kirocrew-core |
| — | `ask_question` | kirocrew-core |
| — | `artifact_folder_list` | kirocrew-core |
| — | `artifact_folder_create` | kirocrew-core |
| — | `artifact_folder_rename` | kirocrew-core |
Expand Down
21 changes: 21 additions & 0 deletions docs/system-specs/modules/learn-cron-dashboard.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/kiro_crew/config/prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ These MCP tools are provided by KiroCrew (use directly, never via bash):
- `cron_add` — schedule recurring or one-shot jobs. Use when user says "every", "daily", "remind me", "check regularly". When `script` is set, the cron executes a Python function directly (no LLM, zero tokens). Use for deterministic polling where reasoning adds no value. Scripts must live under `~/.kirocrew/crons/` (write the file first, then register with `script='~/.kirocrew/crons/file.py:function'`). Pass arguments via the `message` field — scripts read them as `ctx.message`. Use `ctx.notify()` to deliver messages, `raise Skip()` to retry, `raise Done(msg)` to deliver and remove the job, `raise Report(msg)` to deliver and keep the job running. Use `ctx.call_tool(server, tool, args)` to invoke MCP tools. When `command` is set, the cron executes a shell command directly (no LLM, zero tokens). Mutually exclusive with `script`. To dry-run a script cron during development, use `kirocrew cron preview <script:function> -m <message>` (real MCP tools, Done/Report/Skip printed not delivered; runs in-process for debuggability, not sandboxed).
- `cron_list` — show all scheduled jobs
- `cron_remove` / `cron_remove_all` / `cron_pause` / `cron_resume` — manage jobs
- `ask_question` — ask the dashboard user 1–4 multiple-choice questions and pause the current turn until they answer. Use it only for a blocking decision needed before you can continue; when ending the turn, prefer a final `[OPTIONS: choice1 | choice2]` line instead. Dashboard sessions only.
- `spawn_run` — spawn subagent(s) and wait for results. Pass `tasks` array for parallel work. This is the ONLY way to spawn subagents — do NOT use any other mechanism.
- `spawn_list` — list running subagents

Expand Down
23 changes: 14 additions & 9 deletions src/kiro_crew/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -1825,19 +1825,24 @@ def build_message(
"in the user's voice as an instruction to you — \"Merge it now\", not "
"\"I'll merge it\".)"
)
# Dashboard-only, situational nudge for the suggest_followup tool.
# Gated to dashboard sessions because the tool rejects Slack/cron/
# subagent contexts (they have no card surface). Deliberately framed
# as OPTIONAL and turn-END, not per-turn: the tool's own description
# carries the full contract, and with MCP Tool Search on the model
# otherwise may never surface it. This is awareness, not a mandate —
# it must not become noise on every reply. Distinct from [OPTIONS:]
# above: those are inline choices for THIS conversation; a follow-up
# card is a concrete NEXT task handed off (optionally to a worktree).
# Dashboard-only, situational nudges for tools that may otherwise
# never surface with MCP Tool Search. Gated here because both tools
# reject Slack/cron/subagent contexts (they have no card surface).
# ask_question is a MID-turn blocking decision; [OPTIONS:] remains
# the cheaper END-turn choice mechanism on every interactive surface.
if session_key and (
session_key.startswith("dashboard:")
or session_key.startswith("dashboard_")
):
parts.append(
"\n\n(If you need the user's answer to a blocking question BEFORE "
"you can continue the current turn, use the ask_question tool — it "
"pauses and returns the answer as the tool result. This is situational, "
"not per-turn: when you are ENDING your turn, use the final [OPTIONS:] "
"line instead, and do not interrupt the user for a non-blocking choice.)"
)
# A follow-up card is distinct from both: it offers concrete NEXT
# tasks after work is done, optionally handing one to a worktree.
parts.append(
"\n\n(When you have FINISHED a substantive piece of work and see "
"concrete, worth-doing next steps, you MAY offer them with the "
Expand Down
75 changes: 64 additions & 11 deletions src/kiro_crew/dashboard/chat_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,52 @@ def _reject_pending_approvals(slot: _ChatSlot) -> None:
)


def _unblock_pending_waits(state: DashboardState, slot: _ChatSlot) -> None:
"""Unblock EVERY thing a stop/interrupt could leave the runner waiting on.

Two independent blocking waits exist per slot and both must be released or
the cooperative cancel times out into a hard kill:

* pending tool approvals (:func:`_reject_pending_approvals`)
* pending agent questions from the ``ask_question`` tool
(:meth:`DashboardState.cancel_questions_for_slot`) — the blocked HTTP
request holds an MCP worker, so resolving the future is what lets that
socket close and the tool call return.

They are combined here deliberately: a new blocking wait added later must
be released from every stop path, and three separate call sites each
needing their own second line is how one of them gets missed.
"""
_reject_pending_approvals(slot)
cancelled = state.cancel_questions_for_slot(slot.key)
if cancelled:
logger.info(
"Stop: cancelled %d pending question(s) on slot %s", cancelled, slot.key
)


async def _reset_slot_session(
state: DashboardState, slot: _ChatSlot, session_key: str
) -> None:
"""Reset a slot's agent session, releasing anything blocked on the old one.

The switch handlers (agent, model, bulk model, reasoning effort, workspace)
reset the session so the next message starts under the new setting. That
tears down the agent process — but a pending ``ask_question`` lives in
dashboard state, not in the session, so without this it survives the reset:
the card stays on screen inviting an answer, and the blocked HTTP request
holds an MCP worker until its own timeout with no agent left to receive the
answer it eventually returns.

Routing every reset through one helper rather than adding a second call at
each site is deliberate, and is the same reasoning as
:func:`_unblock_pending_waits`: five call sites each having to remember an
extra line is how one of them gets missed.
"""
_unblock_pending_waits(state, slot)
await state.sessions.reset(session_key)


def _resolve_stop_event(slot: _ChatSlot, outcome: str) -> None:
"""Update the in-flight stop_event message in place with final state."""
stop_id = slot._stop_event_id
Expand Down Expand Up @@ -851,8 +897,9 @@ async def _on_hard_force() -> None:
slot._stop_state = "idle"
state.push_slots_update()

# Unblock chat runner if it's suspended waiting for tool approval.
_reject_pending_approvals(slot)
# Unblock chat runner if it's suspended waiting for tool approval or on
# a pending ask_question card.
_unblock_pending_waits(state, slot)
await state.sessions.stop_turn(_history_key_for(name), force=True, on_hard=_on_hard_force)
sel().log_tool_invocation(
session_key=_history_key_for(name),
Expand Down Expand Up @@ -950,8 +997,9 @@ async def _on_hard() -> None:
slot._stop_state = "idle"
state.push_slots_update()

# Unblock chat runner if it's suspended waiting for tool approval.
_reject_pending_approvals(slot)
# Unblock chat runner if it's suspended waiting for tool approval or on a
# pending ask_question card.
_unblock_pending_waits(state, slot)

outcome = await state.sessions.stop_turn(
_history_key_for(name), force=False, preserve_queue=True, on_soft=_on_soft, on_hard=_on_hard
Expand Down Expand Up @@ -1065,8 +1113,9 @@ async def _on_hard() -> None:
slot.append("system", stop_msg, stop_msg)
state.push_slots_update()

# Unblock chat runner if it's suspended waiting for tool approval.
_reject_pending_approvals(slot)
# Unblock chat runner if it's suspended waiting for tool approval or on a
# pending ask_question card.
_unblock_pending_waits(state, slot)

outcome = await state.sessions.stop_turn(
_history_key_for(name),
Expand Down Expand Up @@ -1264,6 +1313,10 @@ async def api_chat_slot_delete(request: web.Request) -> web.Response:

# Remove from dict before async operations
state._slots.pop(name, None)
# Release any blocking wait before cancelling the task: a pending
# ask_question holds an MCP worker on a blocked HTTP request, and the slot
# is going away, so nobody will ever answer its card.
_unblock_pending_waits(state, slot)
if slot.running and slot.task is not None:
slot.task.cancel()
try:
Expand Down Expand Up @@ -1453,7 +1506,7 @@ async def api_chat_slot_agent(request: web.Request) -> web.Response:

# Reset session so next message uses the new agent
logger.info("Slot %s agent switched to %r, resetting session", name, agent_name or "kirocrew")
await state.sessions.reset(_history_key_for(name))
await _reset_slot_session(state, slot, _history_key_for(name))
# Persist the new agent so the session resumes under the correct agent
# after a gateway restart. Written after reset succeeds so we never
# advertise an agent we couldn't actually switch to.
Expand Down Expand Up @@ -1524,7 +1577,7 @@ async def api_chat_slot_model(request: web.Request) -> web.Response:
return web.json_response({"ok": True, "model": model_name})
slot.model = model_name
logger.info("Slot %s model switched to %r, resetting session", name, model_name or "auto")
await state.sessions.reset(_history_key_for(name))
await _reset_slot_session(state, slot, _history_key_for(name))
state.push_slots_update()
return web.json_response({"ok": True, "model": model_name})

Expand Down Expand Up @@ -1589,7 +1642,7 @@ async def api_chat_slots_model(request: web.Request) -> web.Response:
# the new model with stale history (the model/history inconsistency), and a
# single failure doesn't abort the whole bulk switch.
try:
await state.sessions.reset(_history_key_for(name))
await _reset_slot_session(state, slot, _history_key_for(name))
except Exception:
logger.error("Bulk model switch: session reset failed for %s", name, exc_info=True)
failed.append(name)
Expand Down Expand Up @@ -1696,7 +1749,7 @@ async def api_chat_slot_reasoning_effort(request: web.Request) -> web.Response:
if not _updated_live:
# No live session (or live update failed): reset so the next cold
# start picks up the new effort via the provider factory/overlay.
await state.sessions.reset(session_key)
await _reset_slot_session(state, slot, session_key)
state.push_slots_update()
return web.json_response({"ok": True, "reasoning_effort": effort})

Expand Down Expand Up @@ -1724,7 +1777,7 @@ async def api_chat_slot_workspace(request: web.Request) -> web.Response:
slot.workspace = ws_name
slot.project = default_project_dir(ws_name)
logger.info("Slot %s workspace switched to %r, resetting session", name, ws_name)
await state.sessions.reset(_history_key_for(name))
await _reset_slot_session(state, slot, _history_key_for(name))
state.push_slots_update()
return web.json_response({"ok": True, "workspace": ws_name})

Expand Down
Loading
Loading