@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 +19 -0
- package/app/api/bus/stop/route.ts +26 -0
- package/app/api/stall/route.ts +18 -0
- package/components/SessionList/RowParts.tsx +6 -0
- package/components/SessionList/SessionList.types.ts +1 -0
- package/components/Workspaces/StallForm.tsx +101 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/stall/index.ts +33 -0
- package/lib/chat/activity.ts +80 -6
- package/lib/chat/events.ts +1 -1
- package/lib/chat/registry.ts +7 -0
- package/lib/chat/runner.ts +2 -0
- package/lib/chat/socket.ts +2 -2
- package/lib/chat/stall-limits.ts +5 -0
- package/lib/chat/stall.ts +50 -0
- package/lib/chat/stop.ts +67 -0
- package/lib/chat/worker/host.ts +21 -3
- package/lib/chat/worker/protocol.ts +2 -1
- package/lib/orchestrator/act.ts +3 -9
- package/lib/orchestrator/armed.ts +1 -0
- package/lib/orchestrator/conditions.ts +18 -0
- package/lib/orchestrator/describe.ts +3 -1
- package/lib/orchestrator/external-gates.ts +2 -0
- package/lib/orchestrator/external-pr.ts +1 -1
- package/lib/orchestrator/facts.ts +25 -2
- package/lib/orchestrator/gates.ts +42 -9
- package/lib/orchestrator/repo-ci.ts +104 -0
- package/lib/orchestrator/signoff.ts +17 -7
- package/lib/orchestrator/task-state.ts +31 -0
- package/lib/orchestrator/tools.ts +1 -1
- package/lib/sessions/stop.ts +28 -0
- package/lib/sidebar/shelves.ts +2 -0
- package/lib/stacks/restack.ts +7 -13
- package/lib/status/collect.ts +4 -1
- package/package.json +1 -1
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
|
}
|
|
@@ -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's orchestrator is told
|
|
58
|
+
once. A long command that is still printing, or a background command the
|
|
59
|
+
agent is waiting on, isn'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
|
+
}
|
package/lib/chat/activity.ts
CHANGED
|
@@ -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)
|
|
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"
|
|
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
|
-
|
|
51
|
-
|
|
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);
|
package/lib/chat/events.ts
CHANGED
|
@@ -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 }
|
package/lib/chat/registry.ts
CHANGED
|
@@ -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).
|
package/lib/chat/runner.ts
CHANGED
|
@@ -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
|
}
|
package/lib/chat/socket.ts
CHANGED
|
@@ -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(
|
|
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,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
|
+
}
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -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
|
-
|
|
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 }
|
package/lib/orchestrator/act.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import { parseOverride, type ModelOverride } from "../models/resolve";
|
|
8
8
|
import { sendMessage } from "../bus";
|
|
9
|
-
import { chatState
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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);
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
7
|
-
*
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
}
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -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
|
);
|
package/lib/stacks/restack.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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",
|
package/lib/status/collect.ts
CHANGED
|
@@ -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
|
}
|