@saadnvd1/agent-os 0.3.483 → 0.3.485

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -363,6 +363,24 @@ fired `mid_turn` or `between_turns`, and the context before and after), so cost
363
363
  menu, dialog or the command list is open (those close first). On a phone the
364
364
  Stop button does the same. A stopped turn keeps what it wrote.
365
365
 
366
+ Stop ends the turn even while the model is still thinking, before any tool
367
+ call: the turn is interrupted, and if it hasn't ended within a few seconds the
368
+ agent process is ended and restarted for the next message (not if the chat is
369
+ waiting on an approval or question, or if a new message has started a new
370
+ turn meanwhile). The conversation and
371
+ worktree are kept, and whatever is sent next (yours, another agent's, the
372
+ orchestrator's) runs as the next turn, never queued behind the stopped one.
373
+ The UI, the orchestrator's `stop` tool and `aos stop` all take this one path
374
+ and report "stopped" or a real error.
375
+
376
+ A turn that has produced nothing (no token, tool call or result) for 8 minutes
377
+ shows as **Stalled** in the session list, and its workspace's orchestrator gets
378
+ one event naming the session, how long it has been silent and its last action.
379
+ It fires once per stall and starts over when the session does something. A
380
+ foreground tool call gets four times as long, and a session waiting on a
381
+ background command it started isn't flagged. Set the time (2 to 120 minutes, 0 for off)
382
+ in Settings → Workspaces → Stall alerts.
383
+
366
384
  The access picker under the composer sets what the agent may do on its own:
367
385
  **Ask first** (an approval card in the chat for anything not already allowed),
368
386
  **Accept edits** (edits files, asks before other commands) or **Full access**
@@ -852,6 +870,7 @@ aos schedule run <name> # run a schedule now
852
870
  aos schedule add --session <s> --every 30m "prompt" # check in on a session
853
871
  aos notify "text" # push a message to your phone
854
872
  aos heavy -- npm test # run it now; says first what else heavy is running
873
+ aos stop <session> # stop its turn now; the next message runs as a new turn
855
874
  aos done <session> # finished: merge through the gates, archive
856
875
  aos done --all-idle # the same for every idle session around you
857
876
  aos docs [query] # LumifyHub pages, when the workspace is linked
@@ -0,0 +1,26 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { resolveSession } from "@/lib/bus";
3
+ import { getDoneTarget } from "@/lib/done";
4
+ import { inScope, scopeOf } from "@/lib/done/bulk";
5
+ import { stopSession } from "@/lib/sessions/stop";
6
+
7
+ // `aos stop <session>`, from inside a session: ends the turn in progress
8
+ // (or the terminal agent) and keeps the conversation and worktree. Reaches
9
+ // only the caller's workspace (or its project when it has none).
10
+ export async function POST(request: NextRequest) {
11
+ try {
12
+ const { from = null, session } = await request.json();
13
+ if (typeof from !== "string" || !from)
14
+ throw new Error("aos stop runs from inside an AgentOS session");
15
+ const caller = getDoneTarget(from);
16
+ const target = resolveSession(String(session ?? ""));
17
+ if (!inScope(scopeOf(caller), target))
18
+ throw new Error(
19
+ `${target.name} isn't in ${caller.name}'s workspace or project; aos stop reaches only those`
20
+ );
21
+ return NextResponse.json({ summary: await stopSession(target) });
22
+ } catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ return NextResponse.json({ error: message }, { status: 409 });
25
+ }
26
+ }
@@ -0,0 +1,18 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { setStallMinutes, stallMinutes } from "@/lib/chat/stall";
3
+
4
+ // How long a chat turn may be silent before it's flagged stalled and its
5
+ // workspace's orchestrator is told (lib/chat/stall); 0 for never.
6
+ export async function GET() {
7
+ return NextResponse.json({ minutes: stallMinutes() });
8
+ }
9
+
10
+ export async function PUT(request: NextRequest) {
11
+ try {
12
+ const body = await request.json();
13
+ return NextResponse.json({ minutes: setStallMinutes(body.minutes) });
14
+ } catch (error) {
15
+ const message = error instanceof Error ? error.message : String(error);
16
+ return NextResponse.json({ error: message }, { status: 400 });
17
+ }
18
+ }
@@ -41,6 +41,12 @@ export function RowBadge({ row }: { row: SidebarRow }) {
41
41
  </span>
42
42
  );
43
43
  }
44
+ if (row.status?.stalled)
45
+ return (
46
+ <span className="rounded-md bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-700 dark:text-amber-400">
47
+ Stalled
48
+ </span>
49
+ );
44
50
  if (row.unread) return <UnreadDot />;
45
51
  return null;
46
52
  }
@@ -9,6 +9,7 @@ export interface SessionStatus {
9
9
  asks?: number;
10
10
  need?: SessionNeed | null;
11
11
  unread?: boolean;
12
+ stalled?: boolean;
12
13
  }
13
14
 
14
15
  export interface SessionListProps {
@@ -0,0 +1,101 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { toast } from "sonner";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Switch } from "@/components/ui/switch";
8
+ import { useStallQuery, useUpdateStall } from "@/data/stall";
9
+ import {
10
+ DEFAULT_STALL_MINUTES,
11
+ MAX_STALL_MINUTES,
12
+ MIN_STALL_MINUTES,
13
+ } from "@/lib/chat/stall-limits";
14
+
15
+ // How long a turn may produce nothing before it's flagged stalled and the
16
+ // workspace's orchestrator is told: app-wide.
17
+ export function StallForm() {
18
+ const { data, isPending, isError, error } = useStallQuery();
19
+ if (isPending)
20
+ return <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />;
21
+ if (isError)
22
+ return <p className="text-destructive text-sm">{error.message}</p>;
23
+ return <Form key={data.minutes} saved={data.minutes} />;
24
+ }
25
+
26
+ function Form({ saved }: { saved: number }) {
27
+ const update = useUpdateStall();
28
+ const [on, setOn] = useState(saved > 0);
29
+ const [text, setText] = useState(
30
+ String(saved > 0 ? saved : DEFAULT_STALL_MINUTES)
31
+ );
32
+ const n = Number(text);
33
+ const valid =
34
+ Number.isInteger(n) && n >= MIN_STALL_MINUTES && n <= MAX_STALL_MINUTES;
35
+ const minutes = on ? n : 0;
36
+ const error =
37
+ on && !valid
38
+ ? `Between ${MIN_STALL_MINUTES} and ${MAX_STALL_MINUTES} minutes`
39
+ : null;
40
+ const dirty = !error && minutes !== saved;
41
+
42
+ return (
43
+ <form
44
+ onSubmit={(e) => {
45
+ e.preventDefault();
46
+ if (error) return;
47
+ update.mutate(minutes, {
48
+ onSuccess: () => toast.success("Stall alert saved"),
49
+ onError: (err) => toast.error(err.message),
50
+ });
51
+ }}
52
+ className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
53
+ >
54
+ <h3 className="text-sm font-medium">Stalled turns</h3>
55
+ <p className="text-muted-foreground text-sm">
56
+ A turn that produces nothing for this long (no text, tool call or
57
+ result) shows as stalled, and the workspace&apos;s orchestrator is told
58
+ once. A long command that is still printing, or a background command the
59
+ agent is waiting on, isn&apos;t a stall.
60
+ </p>
61
+ <label className="flex min-h-11 items-center justify-between gap-3 text-sm md:min-h-9">
62
+ Flag stalled turns
63
+ <Switch
64
+ checked={on}
65
+ onCheckedChange={setOn}
66
+ aria-label="Flag stalled turns"
67
+ />
68
+ </label>
69
+ {on && (
70
+ <label className="flex items-center justify-between gap-3 text-sm">
71
+ <span className="text-muted-foreground">After</span>
72
+ <span className="flex items-center gap-2">
73
+ <Input
74
+ type="number"
75
+ inputMode="numeric"
76
+ min={MIN_STALL_MINUTES}
77
+ max={MAX_STALL_MINUTES}
78
+ value={text}
79
+ onChange={(e) => setText(e.target.value)}
80
+ aria-label="Stall threshold, minutes"
81
+ aria-invalid={!!error}
82
+ className="h-11 w-24 text-base sm:h-9 md:text-sm"
83
+ />
84
+ <span className="text-muted-foreground">minutes</span>
85
+ </span>
86
+ </label>
87
+ )}
88
+ {error && <p className="text-destructive text-xs">{error}</p>}
89
+ <div className="flex justify-end">
90
+ <Button
91
+ type="submit"
92
+ size="sm"
93
+ className="h-11 sm:h-8"
94
+ disabled={!!error || !dirty || update.isPending}
95
+ >
96
+ Save
97
+ </Button>
98
+ </div>
99
+ </form>
100
+ );
101
+ }
@@ -5,6 +5,7 @@ import { useUpdateWorkspace, useWorkspacesQuery } from "@/data/workspaces";
5
5
  import { TaskLimitForm } from "./TaskLimitForm";
6
6
  import { RulesForm } from "./RulesForm";
7
7
  import { FreshStartForm } from "./FreshStartForm";
8
+ import { StallForm } from "./StallForm";
8
9
  import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
9
10
  import { AwayWindowForm } from "@/components/Away/AwayWindowForm";
10
11
 
@@ -53,6 +54,7 @@ export function WorkspaceSettings() {
53
54
  </section>
54
55
  ))}
55
56
  <FreshStartForm />
57
+ <StallForm />
56
58
  <AwayWindowForm />
57
59
  </div>
58
60
  );
@@ -0,0 +1,33 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+
3
+ // How long a turn may be silent before it's stalled (lib/chat/stall).
4
+ export const stallKeys = { all: ["stall"] as const };
5
+
6
+ async function json<T>(res: Response): Promise<T> {
7
+ const data = await res.json().catch(() => ({}));
8
+ if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
9
+ return data;
10
+ }
11
+
12
+ export function useStallQuery() {
13
+ return useQuery({
14
+ queryKey: stallKeys.all,
15
+ queryFn: async () => json<{ minutes: number }>(await fetch("/api/stall")),
16
+ staleTime: 60000,
17
+ });
18
+ }
19
+
20
+ export function useUpdateStall() {
21
+ const queryClient = useQueryClient();
22
+ return useMutation({
23
+ mutationFn: async (minutes: number) =>
24
+ json<{ minutes: number }>(
25
+ await fetch("/api/stall", {
26
+ method: "PUT",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify({ minutes }),
29
+ })
30
+ ),
31
+ onSuccess: (data) => queryClient.setQueryData(stallKeys.all, data),
32
+ });
33
+ }
@@ -1,23 +1,48 @@
1
- import type { ChatState } from "./events";
1
+ import type { ChatItem, ChatState } from "./events";
2
2
  import { formatElapsed } from "./elapsed";
3
3
  import { registry, type Live } from "./registry";
4
+ import { stallThresholdMs } from "./stall";
4
5
  import { listItems } from "./store";
5
6
  import type { WorkerEvent } from "./worker/protocol";
6
7
 
8
+ const lastWhat = (i: ChatItem): string =>
9
+ i.kind === "tool"
10
+ ? i.status === "running"
11
+ ? i.title
12
+ : `finished ${i.title}`
13
+ : i.kind === "assistant"
14
+ ? "writing a reply"
15
+ : i.kind === "reasoning"
16
+ ? "thinking"
17
+ : i.kind === "user"
18
+ ? "your message"
19
+ : i.kind;
20
+
7
21
  // Keeps a conversation's "what it's doing now" current from its events.
8
22
  export function track(live: Live, e: WorkerEvent): void {
9
23
  const a = live.activity;
10
24
  if (e.type === "state") {
11
- if (e.state === "running" && !a.turnStartedAt) a.turnStartedAt = Date.now();
25
+ if (e.state === "running" && !a.turnStartedAt) {
26
+ a.turnStartedAt = Date.now();
27
+ a.lastAt = a.turnStartedAt;
28
+ a.lastWhat = "starting the turn";
29
+ }
12
30
  if (e.state === "idle") {
13
31
  a.turnStartedAt = undefined;
32
+ a.lastAt = a.lastWhat = undefined;
14
33
  a.tools.clear();
15
34
  }
16
35
  } else if (e.type === "item" && e.item.kind === "turn_end") {
17
36
  // A queued message starts the next turn with no idle in between.
18
37
  a.turnStartedAt = undefined;
38
+ a.lastAt = a.lastWhat = undefined;
19
39
  a.tools.clear();
20
- } else if (e.type === "item" && e.item.kind === "tool") {
40
+ } else if (e.type === "item" || e.type === "delta") {
41
+ a.lastAt = Date.now();
42
+ a.lastWhat = e.type === "delta" ? "writing" : lastWhat(e.item);
43
+ }
44
+ if (e.type === "item" && e.item.kind === "user") a.sends = (a.sends ?? 0) + 1;
45
+ if (e.type === "item" && e.item.kind === "tool") {
21
46
  if (e.item.status === "running")
22
47
  a.tools.set(e.item.id, { label: e.item.title, since: e.item.createdAt });
23
48
  else a.tools.delete(e.item.id);
@@ -35,6 +60,11 @@ export function restoreActivity(sessionId: string, state: ChatState) {
35
60
  activity.turnStartedAt = items.findLast(
36
61
  (i) => i.kind === "user"
37
62
  )?.createdAt;
63
+ if (state !== "idle") {
64
+ const last = items.at(-1);
65
+ activity.lastAt = last?.createdAt ?? activity.turnStartedAt;
66
+ activity.lastWhat = last ? lastWhat(last) : undefined;
67
+ }
38
68
  for (const i of items) {
39
69
  if (i.kind === "tool" && i.status === "running" && state !== "idle")
40
70
  activity.tools.set(i.id, { label: i.title, since: i.createdAt });
@@ -44,11 +74,50 @@ export function restoreActivity(sessionId: string, state: ChatState) {
44
74
  return activity;
45
75
  }
46
76
 
77
+ export interface Stall {
78
+ silentMs: number;
79
+ // What it last did.
80
+ last: string;
81
+ // When that was: one stall per value, so a condition keyed on it fires
82
+ // once and a new one starts only after activity resumes.
83
+ since: number;
84
+ }
85
+
86
+ // A turn that has produced nothing (no token, item or result) for the
87
+ // threshold. Never one waiting on an answer, nor one with a background task
88
+ // running: a command it started can legitimately be quiet.
89
+ const TOOL_STALL_FACTOR = 4;
90
+
91
+ export function stallOf(
92
+ live: Live,
93
+ now: number,
94
+ thresholdMs: number
95
+ ): Stall | null {
96
+ const a = live.activity;
97
+ if (live.state !== "running" || thresholdMs <= 0 || a.tasks.size > 0)
98
+ return null;
99
+ // A foreground tool call (a long test run) says nothing while it works:
100
+ // it gets a much longer allowance before it counts as stuck.
101
+ if (a.tools.size > 0) thresholdMs *= TOOL_STALL_FACTOR;
102
+ const since = a.lastAt ?? a.turnStartedAt;
103
+ if (!since || now - since < thresholdMs) return null;
104
+ return { silentMs: now - since, last: a.lastWhat ?? "unknown", since };
105
+ }
106
+
107
+ // Whether a chat on this machine is stalled now, by the app-wide threshold.
108
+ export function chatStall(sessionId: string): Stall | null {
109
+ const live = registry.live.get(sessionId);
110
+ return live ? stallOf(live, Date.now(), stallThresholdMs()) : null;
111
+ }
112
+
47
113
  // One line on what a chat session is doing, for the session list and
48
114
  // notifications: the step running now and since when.
49
- export function chatActivity(
50
- sessionId: string
51
- ): { label: string; since?: number; background: number } | null {
115
+ export function chatActivity(sessionId: string): {
116
+ label: string;
117
+ since?: number;
118
+ background: number;
119
+ stalled?: Stall;
120
+ } | null {
52
121
  const live = registry.live.get(sessionId);
53
122
  if (!live) return null;
54
123
  const a = live.activity;
@@ -57,6 +126,9 @@ export function chatActivity(
57
126
  return { label: "Needs your answer", background };
58
127
  if (live.state !== "running")
59
128
  return background ? { label: "Idle", background } : null;
129
+ const stalled = stallOf(live, Date.now(), stallThresholdMs()) ?? undefined;
130
+ if (stalled)
131
+ return { label: "Stalled", since: stalled.since, background, stalled };
60
132
  const tool = [...a.tools.values()].at(-1);
61
133
  return tool
62
134
  ? { label: tool.label, since: tool.since, background }
@@ -71,6 +143,8 @@ export function chatActivityLine(sessionId: string): string | null {
71
143
  ? `${a.background} running in background`
72
144
  : "";
73
145
  if (a.label === "Idle") return background || null;
146
+ if (a.stalled)
147
+ return `Stalled · no output for ${formatElapsed(a.stalled.silentMs)} · last: ${a.stalled.last}`;
74
148
  const parts = [a.label];
75
149
  if (a.since) parts.push(formatElapsed(Date.now() - a.since));
76
150
  if (background) parts.push(background);
@@ -354,7 +354,7 @@ export type ChatClientMessage =
354
354
  // `during`: the user message whose turn was running when it was tapped.
355
355
  | { type: "queue_send_now"; id: string; during?: string }
356
356
  | { type: "files"; reqId: string; query: string }
357
- | { type: "interrupt" }
357
+ | { type: "interrupt"; hard?: boolean }
358
358
  | { type: "set_model"; model: string }
359
359
  | { type: "set_access"; access: ChatAccess }
360
360
  | { type: "set_plan"; plan: boolean }
@@ -34,6 +34,13 @@ export interface Live {
34
34
 
35
35
  export interface ChatActivity {
36
36
  turnStartedAt?: number;
37
+ // When the turn last produced anything (a token, an item, a tool result)
38
+ // and what that was; unset between turns. Silence from here is a stall.
39
+ lastAt?: number;
40
+ lastWhat?: string;
41
+ // Messages the user side has sent this conversation: a change while a stop
42
+ // waits means a new turn began, which the stop must leave alone.
43
+ sends?: number;
37
44
  // Running tool calls by id: their label and when they started.
38
45
  tools: Map<string, { label: string; since: number }>;
39
46
  // Running background tasks (not housekeeping ones).
@@ -697,6 +697,8 @@ export async function undoChat(
697
697
  reply({ type: "undone", from, text: target.text });
698
698
  }
699
699
 
700
+ // A plain Esc: ends the turn the way the agent would, and leaves a queue
701
+ // behind it alone. Stop (./stop stopChatTurn) is the one that must end it.
700
702
  export async function interruptChat(sessionId: string): Promise<void> {
701
703
  registry.live.get(sessionId)?.worker.command({ type: "interrupt" });
702
704
  }
@@ -16,7 +16,6 @@ import {
16
16
  chatToolBody,
17
17
  deleteQueuedChat,
18
18
  editQueuedChat,
19
- interruptChat,
20
19
  moveQueuedChat,
21
20
  refreshCapabilities,
22
21
  respondChat,
@@ -29,6 +28,7 @@ import {
29
28
  undoChat,
30
29
  watchChat,
31
30
  } from "./runner";
31
+ import { stopChatTurn } from "./stop";
32
32
  import { clientSend } from "./client-send";
33
33
  import { searchChat } from "./search";
34
34
  import { DEMO_CHAT_READS, handleDemoChat } from "./demo";
@@ -175,7 +175,7 @@ function onChatMessage(
175
175
  .catch(() => [])
176
176
  .then(answer)
177
177
  );
178
- } else if (msg.type === "interrupt") run(interruptChat(sessionId));
178
+ } else if (msg.type === "interrupt") run(stopChatTurn(sessionId));
179
179
  else if (msg.type === "set_model") run(setChatModel(sessionId, msg.model));
180
180
  else if (msg.type === "set_access") run(setChatAccess(sessionId, msg.access));
181
181
  else if (msg.type === "set_plan") run(setChatPlan(sessionId, !!msg.plan));
@@ -0,0 +1,5 @@
1
+ // The range the stall alert may take (lib/chat/stall), shared with the
2
+ // settings form.
3
+ export const DEFAULT_STALL_MINUTES = 8;
4
+ export const MIN_STALL_MINUTES = 2;
5
+ export const MAX_STALL_MINUTES = 120;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Stall alerts: a chat turn silent for this long (no token, tool call or
3
+ * result) is flagged "stalled" in the UI and reported once to its workspace's
4
+ * orchestrator (lib/orchestrator/conditions). App-wide, in Settings →
5
+ * Workspaces. 0 is off.
6
+ */
7
+
8
+ import { db } from "../db";
9
+ import {
10
+ DEFAULT_STALL_MINUTES,
11
+ MAX_STALL_MINUTES,
12
+ MIN_STALL_MINUTES,
13
+ } from "./stall-limits";
14
+
15
+ export const STALL_KEY = "chat.stall_minutes";
16
+
17
+ export function stallMinutes(): number {
18
+ const row = db
19
+ .prepare(`SELECT value FROM settings WHERE key = ?`)
20
+ .get(STALL_KEY) as { value: string } | undefined;
21
+ const n = row ? Number(row.value) : DEFAULT_STALL_MINUTES;
22
+ return Number.isInteger(n) && n >= 0 ? n : DEFAULT_STALL_MINUTES;
23
+ }
24
+
25
+ export const stallThresholdMs = () => stallMinutes() * 60_000;
26
+
27
+ // 0 (off) or a whole number of minutes in range. Throws a plain message
28
+ // otherwise.
29
+ export function checkStallMinutes(value: unknown): number {
30
+ if (value === 0) return 0;
31
+ if (
32
+ typeof value !== "number" ||
33
+ !Number.isInteger(value) ||
34
+ value < MIN_STALL_MINUTES ||
35
+ value > MAX_STALL_MINUTES
36
+ )
37
+ throw new Error(
38
+ `The stall alert is off or between ${MIN_STALL_MINUTES} and ${MAX_STALL_MINUTES} minutes`
39
+ );
40
+ return value;
41
+ }
42
+
43
+ export function setStallMinutes(value: unknown): number {
44
+ const minutes = checkStallMinutes(value);
45
+ db.prepare(
46
+ `INSERT INTO settings (key, value) VALUES (?, ?)
47
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
48
+ ).run(STALL_KEY, String(minutes));
49
+ return minutes;
50
+ }
package/lib/chat/stop.ts CHANGED
@@ -68,3 +68,70 @@ export async function stopChatAtTurnEnd(
68
68
  await sleep(POLL_MS);
69
69
  }
70
70
  }
71
+
72
+ export type TurnStop =
73
+ | { stopped: true; killed: boolean }
74
+ | { stopped: false; reason: string };
75
+
76
+ export interface TurnStopOptions {
77
+ // How long the turn gets to end after the interrupt before its agent is
78
+ // killed.
79
+ graceMs?: number;
80
+ sleep?: (ms: number) => Promise<void>;
81
+ }
82
+
83
+ const TURN_POLL_MS = 250;
84
+ // Idle has to hold this long: the CLI answers an interruption with a short
85
+ // turn of its own, which the host cuts short, and that must be over too.
86
+ const SETTLE_MS = 2500;
87
+
88
+ // The one way every caller (the stop button, the orchestrator's stop, aos
89
+ // stop, a restack) ends the turn in progress. The turn is interrupted, even
90
+ // while the model is thinking before any tool call; if it hasn't ended within
91
+ // the grace period the agent is killed. The conversation and worktree are
92
+ // kept, and the next message runs as the next turn.
93
+ export async function stopChatTurn(
94
+ sessionId: string,
95
+ { graceMs = 8000, sleep = defaultSleep }: TurnStopOptions = {}
96
+ ): Promise<TurnStop> {
97
+ let state;
98
+ try {
99
+ await registry.connecting.get(sessionId);
100
+ state = await chatStateNow(sessionId);
101
+ } catch {
102
+ return {
103
+ stopped: false,
104
+ reason: "its chat worker is running but isn't answering",
105
+ };
106
+ }
107
+ if (!state || state === "idle") return { stopped: true, killed: false };
108
+ // Its agent is asked a question: killing it would lose the card.
109
+ if (state === "waiting")
110
+ return {
111
+ stopped: false,
112
+ reason:
113
+ "it's waiting on an approval or question in its chat; answer or dismiss that first",
114
+ };
115
+ const sendsAtStop = registry.live.get(sessionId)?.activity.sends ?? 0;
116
+ registry.live
117
+ .get(sessionId)
118
+ ?.worker.command({ type: "interrupt", hard: true });
119
+ let idleFor = 0;
120
+ for (let waited = 0; waited < graceMs + SETTLE_MS; waited += TURN_POLL_MS) {
121
+ await sleep(TURN_POLL_MS);
122
+ // A message sent meanwhile has started a new turn: not ours to kill.
123
+ if ((registry.live.get(sessionId)?.activity.sends ?? 0) !== sendsAtStop)
124
+ return { stopped: true, killed: false };
125
+ const now = await chatStateNow(sessionId).catch(
126
+ () => "unreachable" as const
127
+ );
128
+ if (now === null || now === "idle") {
129
+ idleFor += TURN_POLL_MS;
130
+ if (idleFor >= SETTLE_MS) return { stopped: true, killed: false };
131
+ } else idleFor = 0;
132
+ if (waited >= graceMs && idleFor === 0) break;
133
+ }
134
+ stopChat(sessionId);
135
+ await waitForExit(sessionId);
136
+ return { stopped: true, killed: true };
137
+ }
@@ -50,6 +50,10 @@ import type { WorkerCommand, WorkerEvent } from "./protocol";
50
50
  // still counts as part of the stop: the agent starts what it queued the
51
51
  // moment a turn ends.
52
52
  const AFTER_STOP_MS = 2000;
53
+ // After a hard stop (Stop, the orchestrator's stop, aos stop), how long the
54
+ // agent's own wind-down turn is cut short and any sender's message reads as
55
+ // the next turn.
56
+ const HARD_STOP_MS = 30_000;
53
57
 
54
58
  // How long a retiring worker waits, once at rest with nothing queued, for
55
59
  // the agent's guess at the next message before it goes.
@@ -104,6 +108,7 @@ export class ChatHost {
104
108
  // should read next.
105
109
  private stopping: "turn" | "after" | null = null;
106
110
  private stoppedAt = 0;
111
+ private hardStopAt = 0;
107
112
  // On an older build than the server's (a "retire"): it closes at the next
108
113
  // turn boundary, once the agent holds no message it hasn't run and runs
109
114
  // no background work, so a current worker resumes the conversation.
@@ -327,6 +332,10 @@ export class ChatHost {
327
332
  this.settleCompactedLine(e.context.usedTokens);
328
333
  this.refreshIfFull(e.context.usedTokens);
329
334
  } else if (e.type === "turn_start") {
335
+ // The agent starting a turn of its own right after a hard stop
336
+ // (the CLI winds down with a reply to the interruption) is cut
337
+ // short, so what's sent next isn't queued behind it.
338
+ if (this.hardStopped()) void this.conversation.interrupt();
330
339
  this.turnsStarted++;
331
340
  this.turnStartedAt ??= Date.now();
332
341
  if (this.behind) this.behind--;
@@ -437,6 +446,7 @@ export class ChatHost {
437
446
  ): void {
438
447
  this.sent.add(m.id);
439
448
  this.currentTurn = m.id;
449
+ this.hardStopAt = 0;
440
450
  if (this.busy()) this.behind++;
441
451
  // Sent now, it stops the running turn, which still ends after this.
442
452
  if (!this.busy()) now = false;
@@ -562,9 +572,7 @@ export class ChatHost {
562
572
  // Another agent's message doesn't speak for the reader.
563
573
  if (
564
574
  this.stopping &&
565
- !cmd.from &&
566
- !cmd.peer &&
567
- !cmd.origin &&
575
+ (this.hardStopped() || (!cmd.from && !cmd.peer && !cmd.origin)) &&
568
576
  this.busy() &&
569
577
  !listQueue(this.session.id).length
570
578
  ) {
@@ -624,6 +632,12 @@ export class ChatHost {
624
632
  return;
625
633
  case "interrupt":
626
634
  await this.modeChange.catch(() => {});
635
+ if (cmd.hard) {
636
+ this.hardStopAt = Date.now();
637
+ this.behind = 0;
638
+ this.stopping = "turn";
639
+ return this.conversation.interrupt();
640
+ }
627
641
  if (!this.busy()) return this.conversation.interrupt();
628
642
  // What's queued goes on once the turn stops: it goes with the stop,
629
643
  // so it's what the agent reads next.
@@ -663,6 +677,10 @@ export class ChatHost {
663
677
  }
664
678
  }
665
679
 
680
+ private hardStopped(): boolean {
681
+ return this.hardStopAt > 0 && Date.now() - this.hardStopAt < HARD_STOP_MS;
682
+ }
683
+
666
684
  // A retiring worker goes once it's at a turn boundary: no turn running,
667
685
  // no message handed to the agent still to run, no background work (it
668
686
  // lives in the agent, and would be lost). With nothing queued it first
@@ -46,7 +46,8 @@ export type WorkerCommand =
46
46
  // Send the queue if no turn is running; a running one sends it as it ends.
47
47
  | { type: "drain" }
48
48
  | { type: "files"; reqId: string; query: string }
49
- | { type: "interrupt" }
49
+ // hard: a stop that must end the turn (and the agent's wind-down after it).
50
+ | { type: "interrupt"; hard?: boolean }
50
51
  | { type: "set_model"; model: string }
51
52
  | { type: "set_access"; access: ChatAccess }
52
53
  | { type: "set_plan"; plan: boolean }
@@ -6,9 +6,9 @@
6
6
 
7
7
  import { parseOverride, type ModelOverride } from "../models/resolve";
8
8
  import { sendMessage } from "../bus";
9
- import { chatState, interruptChat } from "../chat/runner";
9
+ import { chatState } from "../chat/runner";
10
+ import { stopSession } from "../sessions/stop";
10
11
  import { isRemoteHost } from "../hosts";
11
- import { endTmuxSession } from "../tmux/end";
12
12
  import { spawnSession } from "../agents/spawn";
13
13
  import {
14
14
  DEFAULT_START_VIEW,
@@ -226,11 +226,5 @@ export async function done(workspaceId: string, ref: string): Promise<string> {
226
226
  // Stops the agent and keeps everything else: a chat's turn is interrupted,
227
227
  // a terminal's tmux session ends. Its worktree and branch stay.
228
228
  export async function stop(workspaceId: string, ref: string): Promise<string> {
229
- const s = findWorkspaceSession(workspaceId, ref);
230
- if (s.view === "chat") {
231
- await interruptChat(s.id);
232
- return `Stopped ${s.name}'s turn.`;
233
- }
234
- await endTmuxSession(s.host_id, s.tmux_name);
235
- return `Stopped ${s.name}: its agent is ended, its worktree and branch are kept.`;
229
+ return stopSession(findWorkspaceSession(workspaceId, ref));
236
230
  }
@@ -123,6 +123,7 @@ function fingerprint(
123
123
  return [
124
124
  head,
125
125
  pr.checks,
126
+ pr.mergeable,
126
127
  (f.task?.ciSettleIn ?? 0) > 0,
127
128
  getCheck(task.id, head, "review")?.status,
128
129
  getCheck(task.id, head, "scope")?.status,
@@ -89,6 +89,13 @@ function sessionConditions(
89
89
  ciKey(pr),
90
90
  `${who}: ${ciWord(pr)}${pr.checks === "pass" ? " and settled" : ""}`
91
91
  );
92
+ // Mergeable for a long while with no check in a repository that has CI:
93
+ // CI probably wasn't triggered. News once per head, never an escalation.
94
+ if (pr.checks === "none" && f.task?.ciSilentMs !== undefined)
95
+ add(
96
+ `ci-silent:${pr.number}:${pr.head ?? ""}`,
97
+ `${who}: PR #${pr.number} at ${(pr.head ?? "").slice(0, 7)} has been mergeable for ${Math.floor(f.task.ciSilentMs / 60000)}m with no CI check registered; CI may not have been triggered on this head`
98
+ );
92
99
  }
93
100
 
94
101
  const blocked = f.task?.blocked ?? null;
@@ -109,6 +116,17 @@ function sessionConditions(
109
116
  true
110
117
  );
111
118
 
119
+ // Keyed on when it last did anything, so it is one event per stall and
120
+ // anything the session does after it makes the next stall a new one.
121
+ if (f.stalled)
122
+ add(
123
+ `stalled:${f.stalled.since}`,
124
+ `${who}: stalled: no output for ${Math.floor(f.stalled.silentMs / 60000)}m mid-turn (last: ${untrusted(f.name, f.stalled.last)}). Read it; stop it if it's stuck.`,
125
+ false,
126
+ false,
127
+ true
128
+ );
129
+
112
130
  // A quick change is the orchestrator's own: nothing to tell it.
113
131
  const shouldPR = (!!f.task || !!f.branch) && !f.quick;
114
132
  const finished = !!f.task && isFinished(f.task.state);
@@ -17,7 +17,9 @@ export function ciWord(pr: TaskPR): string {
17
17
  ? `CI failed: ${untrusted("CI", pr.failing)}`
18
18
  : "CI failed";
19
19
  if (pr.checks === "pending") return "CI pending";
20
- return "no CI";
20
+ return pr.mergeable === "CONFLICTING"
21
+ ? "conflicts with its base, no CI yet"
22
+ : "no checks yet";
21
23
  }
22
24
 
23
25
  export const shortId = (id: string) => id.slice(0, 8);
@@ -75,6 +75,8 @@ export async function judgeExternal(
75
75
  checkCount: pr.checkCount ?? 0,
76
76
  committedAt: await commitTime(ext.repo, sha),
77
77
  }),
78
+ mergeable: pr.mergeable,
79
+ baseName: ext.base,
78
80
  review,
79
81
  blocked: null,
80
82
  waitingOn: null,
@@ -63,7 +63,7 @@ export type GateTarget =
63
63
  | { kind: "external"; pr: ExternalPR };
64
64
 
65
65
  const FIELDS =
66
- "number,url,state,headRefOid,headRefName,baseRefName,isDraft,labels,isCrossRepository,statusCheckRollup,body,title";
66
+ "number,url,state,headRefOid,headRefName,baseRefName,isDraft,labels,isCrossRepository,statusCheckRollup,body,title,mergeable";
67
67
 
68
68
  interface ViewedPR extends ListedPR {
69
69
  baseRefName?: string;
@@ -10,7 +10,7 @@ import { statusDetector } from "../status-detector";
10
10
  import { hostLink } from "../hosts/remote-api";
11
11
  import { peerStatus } from "../hosts/peer-sessions";
12
12
  import { chatState } from "../chat/runner";
13
- import { chatActivityLine } from "../chat/activity";
13
+ import { chatActivityLine, chatStall, type Stall } from "../chat/activity";
14
14
  import { lastUserTask } from "../chat/store";
15
15
  import { needsYou } from "../needs-you";
16
16
  import { programSummary } from "../program-status/store";
@@ -18,7 +18,8 @@ import { sessionRowInfo } from "../session-meta";
18
18
  import { taskView, type TaskPR, type TaskState } from "../tasks";
19
19
  import { storedPR } from "../tasks/session";
20
20
  import { commitTime, repoOf } from "./repo";
21
- import { ciSettleIn } from "./task-state";
21
+ import { CHECKLESS_MS, checklessFor, ciSettleIn } from "./task-state";
22
+ import { repoHasCI } from "./repo-ci";
22
23
 
23
24
  export type FactStatus = "running" | "waiting" | "idle" | "dead";
24
25
 
@@ -32,6 +33,8 @@ export interface SessionFacts {
32
33
  // Really stopped on an answer: a chat's approval or question card, or a
33
34
  // terminal at its prompt. A chat that merely finished doesn't count.
34
35
  needsInput: boolean;
36
+ // A chat turn silent past the stall threshold (lib/chat/stall).
37
+ stalled?: Stall | null;
35
38
  // Last sign of life, in ms.
36
39
  lastActive: number;
37
40
  // Work that should end in a PR: a task, or a session on its own branch.
@@ -45,6 +48,9 @@ export interface SessionFacts {
45
48
  // With CI green on the PR's head: seconds until it counts as settled
46
49
  // (task-state.ts), when the CI-green event goes out.
47
50
  ciSettleIn?: number;
51
+ // Set once a mergeable head in a repository with CI has had no checks
52
+ // for CHECKLESS_MS (task-state.ts): ms it has been without.
53
+ ciSilentMs?: number;
48
54
  } | null;
49
55
  stack: {
50
56
  id: string;
@@ -211,6 +217,20 @@ export async function settleIn(
211
217
  });
212
218
  }
213
219
 
220
+ async function silentFor(
221
+ s: Session,
222
+ pr: TaskPR | null
223
+ ): Promise<number | undefined> {
224
+ if (!pr) return;
225
+ const silent = checklessFor(s.id, pr);
226
+ if (silent === undefined || silent < CHECKLESS_MS) return;
227
+ const ci = await repoHasCI(
228
+ repoOf(s),
229
+ `origin/${pr.base ?? s.base_branch ?? "main"}`
230
+ ).catch(() => "unknown");
231
+ return ci === "yes" ? silent : undefined;
232
+ }
233
+
214
234
  // A finished task's state is in the database: no gh call for it.
215
235
  async function taskFacts(
216
236
  workspaceId: string,
@@ -228,6 +248,7 @@ async function taskFacts(
228
248
  pr: view.pr,
229
249
  blocked: view.blocked,
230
250
  ciSettleIn: await settleIn(workspaceId, s, view.pr),
251
+ ciSilentMs: await silentFor(s, view.pr),
231
252
  };
232
253
  }
233
254
 
@@ -249,6 +270,8 @@ export async function sessionFacts(
249
270
  status,
250
271
  activity,
251
272
  needsInput,
273
+ stalled:
274
+ s.view === "chat" && !hostLink(s.host_id) ? chatStall(s.id) : null,
252
275
  lastActive: Math.max(
253
276
  sqliteMs(s.updated_at),
254
277
  statusDetector.getTimestamp(s.tmux_name) * 1000,
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The merge gates, ported from dispatch orchestrate. Each one passes, waits
3
3
  * (not ready yet: CI running or settling, no review of this commit yet),
4
- * fails, or goes straight to Saad (no CI at all). Only a failure counts
4
+ * fails, or goes straight to Saad (the repository has no CI at all, never
5
+ * just a head with no checks). Only a failure counts
5
6
  * against the task, and a gate failing again on a different commit goes to
6
7
  * Saad instead of being retried. A failure counts once per task, gate and
7
8
  * head commit, whichever path judged it (the armed watcher, CI finishing,
@@ -12,9 +13,10 @@
12
13
 
13
14
  import { db } from "../db";
14
15
  import type { CodeReviewGate } from "../tasks/code-review";
15
- import type { ChecksVerdict } from "../tasks/state";
16
+ import type { ChecksVerdict, TaskPR } from "../tasks/state";
16
17
  import type { CheckRow } from "./checks";
17
18
  import { APPROVAL_GATES, mergeApprovalsOn } from "./merge-approvals";
19
+ import type { RepoCI } from "./repo-ci";
18
20
  import { untrusted } from "./untrusted";
19
21
 
20
22
  export type GateName =
@@ -37,6 +39,11 @@ export interface GateInput {
37
39
  failing?: string | null;
38
40
  // Seconds until CI on this commit counts as settled (tasks/task-state).
39
41
  settleIn: number;
42
+ // GitHub's word on merging the PR into its base, and that base's name.
43
+ mergeable?: TaskPR["mergeable"];
44
+ baseName?: string | null;
45
+ // Whether the repository has CI; only read when the head has no checks.
46
+ repoCI?: RepoCI;
40
47
  review: CheckRow | null;
41
48
  // A BLOCKED: line, or an approval or question the agent is waiting on.
42
49
  blocked: string | null;
@@ -68,22 +75,48 @@ function ciGate(i: GateInput): GateOutcome {
68
75
  state: "fail",
69
76
  reason: `CI failed on ${at}${i.failing ? ` (${untrusted("CI", i.failing)})` : ""}`,
70
77
  };
78
+ if (i.checks === "none") return noChecks(i, at);
71
79
  if (i.settleIn > 0)
72
80
  return {
73
81
  gate,
74
82
  state: "wait",
75
- reason:
76
- i.checks === "none"
77
- ? `no CI checks have registered on ${at} yet (looking again in ${i.settleIn}s)`
78
- : `CI is green on ${at} but settling: ${i.settleIn}s more with no new check`,
83
+ reason: `CI is green on ${at} but settling: ${i.settleIn}s more with no new check`,
79
84
  };
80
- if (i.checks === "none")
85
+ return { gate, state: "pass" };
86
+ }
87
+
88
+ // A head with no checks. Only a repository with no CI at all has nothing to
89
+ // gate on; in one with CI the head is not yet, whatever the reason.
90
+ function noChecks(i: GateInput, at: string): GateOutcome {
91
+ const gate = "ci";
92
+ const wait = (reason: string): GateOutcome => ({
93
+ gate,
94
+ state: "wait",
95
+ reason,
96
+ });
97
+ if (i.mergeable === "CONFLICTING")
98
+ return wait(
99
+ `PR conflicts with ${i.baseName ?? "its base"}; CI can't run on ${at} until it's rebased`
100
+ );
101
+ if (i.settleIn > 0)
102
+ return wait(
103
+ `no CI checks have registered on ${at} yet (looking again in ${i.settleIn}s)`
104
+ );
105
+ if (i.repoCI === "no")
81
106
  return {
82
107
  gate,
83
108
  state: "escalate",
84
- reason: `no CI ran on ${at}: this repository has no checks to gate a merge on`,
109
+ reason: `no CI ran on ${at}: this repository has no checks to gate a merge on (no workflow files, and no check has run on its default branch or recent PRs)`,
85
110
  };
86
- return { gate, state: "pass" };
111
+ if (i.repoCI === "yes")
112
+ return wait(
113
+ i.mergeable === "UNKNOWN"
114
+ ? `no CI checks on ${at} yet: GitHub is still working out whether it merges cleanly`
115
+ : `no CI checks on ${at} yet: CI hasn't been triggered on this head`
116
+ );
117
+ return wait(
118
+ `no CI checks on ${at}, and whether this repository has CI couldn't be read; looking again`
119
+ );
87
120
  }
88
121
 
89
122
  // A stored check of this exact commit: missing, running or broken waits.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Whether a repository has CI at all, decided from the repository and never
3
+ * from one head: workflow files on the base, else any check or status that
4
+ * ever ran on the default branch or the recent PRs. A head with no checks in
5
+ * a repository that has CI is not yet (its PR conflicts, or CI hasn't been
6
+ * triggered); only a repository without CI is one with nothing to gate on.
7
+ */
8
+
9
+ import { getDefaultBranch } from "../git";
10
+ import { git } from "../git-exec";
11
+ import { run } from "../tasks/gh";
12
+ import { repoSlug } from "./repo-slug";
13
+
14
+ export type RepoCI = "yes" | "no" | "unknown";
15
+
16
+ const YES_TTL_MS = 60 * 60 * 1000;
17
+ const NO_TTL_MS = 10 * 60 * 1000;
18
+ const cache = new Map<string, { at: number; ci: "yes" | "no" }>();
19
+
20
+ export function clearRepoCI(): void {
21
+ cache.clear();
22
+ }
23
+
24
+ export interface RepoCIDeps {
25
+ // The files under .github/workflows on `ref`.
26
+ workflows: (repo: string, ref: string) => Promise<string[]>;
27
+ // How many checks or statuses ran on the default branch and the recent
28
+ // PRs; null when GitHub can't say.
29
+ history: (repo: string) => Promise<number | null>;
30
+ now: () => number;
31
+ }
32
+
33
+ const count = (out: string) => {
34
+ const n = Number(out.trim());
35
+ return out.trim() !== "" && Number.isInteger(n) && n >= 0 ? n : null;
36
+ };
37
+
38
+ export const repoCIDeps: RepoCIDeps = {
39
+ workflows: async (repo, ref) =>
40
+ (
41
+ await git(repo, ["ls-tree", "--name-only", ref, ".github/workflows/"], {
42
+ timeout: 10000,
43
+ })
44
+ )
45
+ .split("\n")
46
+ .filter((f) => /\.ya?ml$/i.test(f.trim())),
47
+ history: async (repo) => {
48
+ const slug = await repoSlug(repo);
49
+ if (!slug) return null;
50
+ const branch = await getDefaultBranch(repo);
51
+ const ask = (args: string[]) =>
52
+ run("gh", args, repo, 20000).then(count, () => null);
53
+ const counts = await Promise.all([
54
+ ask([
55
+ "api",
56
+ `repos/${slug}/commits/${branch}/check-runs?per_page=1`,
57
+ "--jq",
58
+ ".total_count",
59
+ ]),
60
+ ask([
61
+ "api",
62
+ `repos/${slug}/commits/${branch}/status`,
63
+ "--jq",
64
+ ".statuses | length",
65
+ ]),
66
+ ask([
67
+ "pr",
68
+ "list",
69
+ "--state",
70
+ "all",
71
+ "--limit",
72
+ "20",
73
+ "--json",
74
+ "statusCheckRollup",
75
+ "--jq",
76
+ "[.[] | (.statusCheckRollup // []) | length] | add // 0",
77
+ ]),
78
+ ]);
79
+ if (counts.some((c) => c !== null && c > 0)) return 1;
80
+ return counts.every((c) => c === 0) ? 0 : null;
81
+ },
82
+ now: Date.now,
83
+ };
84
+
85
+ // "unknown" when it can't be told (GitHub unreachable or backed off): the
86
+ // ci gate waits on that, it never escalates or passes on a guess.
87
+ export async function repoHasCI(
88
+ repo: string,
89
+ baseRef: string,
90
+ deps: RepoCIDeps = repoCIDeps
91
+ ): Promise<RepoCI> {
92
+ const hit = cache.get(repo);
93
+ if (hit && deps.now() - hit.at < (hit.ci === "yes" ? YES_TTL_MS : NO_TTL_MS))
94
+ return hit.ci;
95
+ const found = (ci: "yes" | "no") => {
96
+ cache.set(repo, { at: deps.now(), ci });
97
+ return ci;
98
+ };
99
+ if ((await deps.workflows(repo, baseRef).catch(() => [])).length)
100
+ return found("yes");
101
+ const seen = await deps.history(repo).catch(() => null);
102
+ if (seen === null) return "unknown";
103
+ return found(seen > 0 ? "yes" : "no");
104
+ }
@@ -3,8 +3,9 @@
3
3
  * commit that was judged. With merge approvals on (merge-approvals.ts),
4
4
  * anything touching CI config, build and hook scripts, agent config, deploys
5
5
  * or secrets handling goes to Saad whatever the gates say. A repository with
6
- * no CI and a gate failing on two different commits go to him either way. Once a task
7
- * is with Saad the orchestrator doesn't merge it; he signs it off or drops it.
6
+ * no CI (decided from the repository, never from one head with no checks)
7
+ * and a gate failing on two different commits go to him either way. Once a
8
+ * task is with Saad the orchestrator doesn't merge it; he signs it off or drops it.
8
9
  */
9
10
 
10
11
  import { MERGED_AS, type MergeMethod } from "../tasks/merge-policy";
@@ -32,6 +33,7 @@ import { heldVerdict } from "./held";
32
33
  import { mergeApprovalsOn } from "./merge-approvals";
33
34
  import { isPaused } from "./pause";
34
35
  import { commitTime, fetchRefs, repoOf } from "./repo";
36
+ import { repoHasCI } from "./repo-ci";
35
37
  import { DIFF_CAP, review } from "./review";
36
38
  import { workspaceStack, workspaceTask } from "./targets";
37
39
  import { ciSettleIn, waitingState } from "./task-state";
@@ -112,6 +114,8 @@ export async function judge(
112
114
  checkCount: pr.checkCount ?? 0,
113
115
  committedAt: await commitTime(repo, sha),
114
116
  }),
117
+ mergeable: pr.mergeable,
118
+ baseName: pr.base ?? task.base_branch,
115
119
  review: getCheck(task.id, sha, "review"),
116
120
  ...(await waitingState(task)),
117
121
  fromCard: !!task.lh_card_id,
@@ -203,11 +207,17 @@ export async function gateVerdict(
203
207
  }
204
208
  }
205
209
 
206
- const outcomes = evaluateGates({
207
- sha,
208
- ruleBreaks: ruleBreaks(files, await addedLines(repo, base, sha)),
209
- ...(await inputs()),
210
- });
210
+ const ruled = ruleBreaks(files, await addedLines(repo, base, sha));
211
+ const live = await inputs();
212
+ // Asked only when it can change the answer: a head with no checks that
213
+ // isn't settling or conflicting.
214
+ const repoCI =
215
+ live.checks === "none" &&
216
+ live.settleIn === 0 &&
217
+ live.mergeable !== "CONFLICTING"
218
+ ? await repoHasCI(repo, base)
219
+ : undefined;
220
+ const outcomes = evaluateGates({ sha, ruleBreaks: ruled, ...live, repoCI });
211
221
  const head = `${work.name} (PR #${pr.number} at ${short(sha)}) can't merge:`;
212
222
  const toSaad = outcomes.find((o) => o.state === "escalate");
213
223
  if (toSaad)
@@ -2,6 +2,7 @@
2
2
  // live inputs to the blocked and ci gates.
3
3
 
4
4
  import type { Session } from "../db";
5
+ import type { TaskPR } from "../tasks/state";
5
6
  import { chatStateNow } from "../chat/runner";
6
7
  import { saidSinceLastMessage } from "../chat/store";
7
8
  import { blockedReason } from "../tasks/state";
@@ -139,3 +140,33 @@ export function ciSettleIn(input: {
139
140
  const settledAt = Math.max(input.committedAt, changedAt) + SETTLE_S;
140
141
  return Math.max(0, settledAt - now);
141
142
  }
143
+
144
+ // A head with no checks for this long after its PR became mergeable, in a
145
+ // repository with CI, is news for the orchestrator (never an escalation).
146
+ export const CHECKLESS_MS = 30 * 60 * 1000;
147
+
148
+ const checkless = new Map<string, number>();
149
+
150
+ // How long this head has had no checks while its PR could merge cleanly, in
151
+ // ms, or undefined when it isn't in that state. In memory: a restart only
152
+ // restarts the clock.
153
+ export function checklessFor(
154
+ taskId: string,
155
+ pr: Pick<TaskPR, "state" | "checks" | "head" | "mergeable">,
156
+ now = Date.now()
157
+ ): number | undefined {
158
+ const key = `${taskId}:${pr.head ?? ""}`;
159
+ for (const k of checkless.keys())
160
+ if (k.startsWith(`${taskId}:`) && k !== key) checkless.delete(k);
161
+ if (
162
+ pr.state !== "OPEN" ||
163
+ pr.checks !== "none" ||
164
+ pr.mergeable !== "MERGEABLE"
165
+ ) {
166
+ checkless.delete(key);
167
+ return undefined;
168
+ }
169
+ const since = checkless.get(key) ?? now;
170
+ checkless.set(key, since);
171
+ return now - since;
172
+ }
@@ -75,7 +75,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
75
75
  stack_status: "One stack's items, PRs and progress.",
76
76
  land: "Merge a whole stack bottom-up, each PR through the sign-off gates at its current head.",
77
77
  drop: "Reject a task: close its PR and remove its worktree. Logged with the reason.",
78
- stop: "Stop a session's agent, keeping its worktree and branch.",
78
+ stop: "Stop a session's agent mid-turn, even while it's thinking, keeping its conversation, worktree and branch; your next message runs as its next turn.",
79
79
  done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it's archived. Not a rejection.",
80
80
  add_project:
81
81
  "Make a git repository on this machine a project of this workspace, by its absolute path, or move the project already at that path into this workspace when it's in no workspace. Says its default branch, detected from git. Refuses a folder that isn't a git repository or that holds secrets, and a project another workspace has.",
@@ -0,0 +1,28 @@
1
+ import { endTmuxSession } from "../tmux/end";
2
+ import { hostLink } from "../hosts/remote-api";
3
+ import { stopChatTurn } from "../chat/stop";
4
+ import type { Session } from "../db";
5
+
6
+ // Stops a session's agent and keeps everything else, the same for every
7
+ // caller (the orchestrator's stop tool, `aos stop`): a chat's turn ends
8
+ // whatever it is doing, a terminal's tmux session ends. The worktree, branch
9
+ // and conversation stay, and the next message runs as the next turn. Throws
10
+ // when the chat can't be shown to have stopped.
11
+ export async function stopSession(
12
+ s: Pick<Session, "id" | "name" | "view" | "host_id" | "tmux_name">
13
+ ): Promise<string> {
14
+ if (s.view === "chat") {
15
+ if (hostLink(s.host_id))
16
+ throw new Error(
17
+ `Couldn't stop ${s.name}: it runs on another machine; stop it there.`
18
+ );
19
+ const out = await stopChatTurn(s.id);
20
+ if (!out.stopped)
21
+ throw new Error(`Couldn't stop ${s.name}: ${out.reason}.`);
22
+ return out.killed
23
+ ? `Stopped ${s.name}: its agent didn't answer the interrupt, so it was ended. Its conversation and worktree are kept; your next message runs as its next turn.`
24
+ : `Stopped ${s.name}'s turn. Your next message runs as its next turn.`;
25
+ }
26
+ await endTmuxSession(s.host_id, s.tmux_name);
27
+ return `Stopped ${s.name}: its agent is ended, its worktree and branch are kept.`;
28
+ }
@@ -28,6 +28,7 @@ export interface RowStatus {
28
28
  status?: string;
29
29
  need?: SessionNeed | null;
30
30
  unread?: boolean;
31
+ stalled?: boolean;
31
32
  asks?: number;
32
33
  // A program's own message (OSC 7501): untrusted, plain text only.
33
34
  detail?: string | null;
@@ -230,6 +231,7 @@ function sameStatus(a: RowStatus | undefined, b: RowStatus | undefined) {
230
231
  a.status === b.status &&
231
232
  (a.need ?? null) === (b.need ?? null) &&
232
233
  !!a.unread === !!b.unread &&
234
+ !!a.stalled === !!b.stalled &&
233
235
  a.asks === b.asks &&
234
236
  (a.detail ?? null) === (b.detail ?? null)
235
237
  );
@@ -11,7 +11,7 @@
11
11
  import { db, stackQueries as q, type Session, type StackItemRow } from "../db";
12
12
  import { getDefaultBranch } from "../git";
13
13
  import { sendMessage } from "../bus";
14
- import { chatStateNow, interruptChat } from "../chat/runner";
14
+ import { stopChatTurn } from "../chat/stop";
15
15
  import { findPRStrict, run } from "../tasks/gh";
16
16
  import type { TaskPR } from "../tasks/state";
17
17
  import { expandHome } from "../tasks/session";
@@ -64,7 +64,7 @@ export const stackNotice = (
64
64
  origin: { kind: "system", label, body } as const,
65
65
  });
66
66
 
67
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
67
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
68
68
  // The note on an item left alone because its agent was still working.
69
69
  export const AGENT_BUSY = "Waiting for its agent to stop";
70
70
  // How long a chat's turn may take to stop after it's interrupted.
@@ -79,17 +79,11 @@ export async function interruptAgent(
79
79
  wait = sleep
80
80
  ): Promise<boolean> {
81
81
  if (session.view === "chat") {
82
- const now = () =>
83
- chatStateNow(session.id).catch(() => "unreachable" as const);
84
- const busy = (s: Awaited<ReturnType<typeof now>>) =>
85
- s === "running" || s === "waiting" || s === "unreachable";
86
- if (!busy(await now())) return true;
87
- await interruptChat(session.id);
88
- for (let waited = 0; waited < CHAT_STOP_WAIT_MS; waited += 500) {
89
- await wait(500);
90
- if (!busy(await now())) return true;
91
- }
92
- return false;
82
+ const out = await stopChatTurn(session.id, {
83
+ graceMs: CHAT_STOP_WAIT_MS,
84
+ sleep: wait,
85
+ });
86
+ return out.stopped;
93
87
  }
94
88
  const sent = await run(
95
89
  "tmux",
@@ -21,7 +21,7 @@ import {
21
21
  } from "../providers/registry";
22
22
  import { getDb, type Session } from "../db";
23
23
  import { chatState } from "../chat/runner";
24
- import { chatActivityLine } from "../chat/activity";
24
+ import { chatActivityLine, chatStall } from "../chat/activity";
25
25
  import {
26
26
  chatNeed,
27
27
  isUnread,
@@ -60,6 +60,8 @@ export interface SessionStatusResponse {
60
60
  asks?: number;
61
61
  need?: SessionNeed | null;
62
62
  unread?: boolean;
63
+ // A chat turn silent past the stall threshold (lib/chat/stall).
64
+ stalled?: boolean;
63
65
  // A program's own message (OSC 7501): untrusted, plain text only.
64
66
  detail?: string | null;
65
67
  progress?: number | null;
@@ -464,6 +466,7 @@ async function collect(): Promise<StatusSnapshot> {
464
466
  agentType: session.agent_type,
465
467
  need: chatNeed(session, state, asks),
466
468
  unread: isUnread(session, state),
469
+ ...(chatStall(session.id) && { stalled: true }),
467
470
  ...(session.role === "orchestrator" && { asks }),
468
471
  };
469
472
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.483",
3
+ "version": "0.3.485",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"