@saadnvd1/agent-os 0.3.581 → 0.3.584
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/components/Chat/BackgroundTasks.tsx +58 -12
- package/components/views/DesktopAppActions.tsx +4 -1
- package/data/chat/useChat.ts +6 -3
- package/lib/chat/background.ts +54 -0
- package/lib/chat/events.ts +6 -1
- package/lib/chat/runner.ts +2 -1
- package/lib/chat/socket.ts +4 -1
- package/lib/chat/task-output.ts +8 -3
- package/lib/needs-you.ts +15 -1
- package/lib/recipes/settle.ts +28 -15
- package/lib/sidebar/shelves.ts +11 -0
- package/lib/tasks/queue.ts +21 -10
- package/lib/tasks/schedule.ts +9 -5
- package/package.json +1 -1
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
X,
|
|
14
14
|
} from "lucide-react";
|
|
15
15
|
import type { ChatItem } from "@/lib/chat/events";
|
|
16
|
-
import {
|
|
16
|
+
import { outputLines, taskLife, type TaskOutput } from "@/lib/chat/background";
|
|
17
17
|
import { formatElapsed } from "@/lib/chat/elapsed";
|
|
18
18
|
import { cn } from "@/lib/utils";
|
|
19
19
|
import { useNow } from "./useNow";
|
|
@@ -59,7 +59,7 @@ function TaskRow({
|
|
|
59
59
|
}: {
|
|
60
60
|
task: Task;
|
|
61
61
|
now: number;
|
|
62
|
-
output:
|
|
62
|
+
output: TaskOutput | undefined;
|
|
63
63
|
// Follows its output for the line under its name.
|
|
64
64
|
tail: boolean;
|
|
65
65
|
// Brought here from the call it came from.
|
|
@@ -77,7 +77,7 @@ function TaskRow({
|
|
|
77
77
|
useEffect(() => {
|
|
78
78
|
const el = outputRef.current;
|
|
79
79
|
if (el) el.scrollTop = el.scrollHeight;
|
|
80
|
-
}, [output]);
|
|
80
|
+
}, [output?.text]);
|
|
81
81
|
|
|
82
82
|
useEffect(() => {
|
|
83
83
|
if (!highlight) return;
|
|
@@ -95,8 +95,12 @@ function TaskRow({
|
|
|
95
95
|
}, [open, tail, running, taskId, onLoadOutput]);
|
|
96
96
|
|
|
97
97
|
const took = formatElapsed((task.endedAt ?? now) - task.createdAt);
|
|
98
|
-
const
|
|
99
|
-
|
|
98
|
+
const life = taskLife(task, output, now);
|
|
99
|
+
const text = output?.text;
|
|
100
|
+
const lines = outputLines(text);
|
|
101
|
+
const idle = formatElapsed(
|
|
102
|
+
Math.max(0, now - (output?.updatedAt ?? task.createdAt))
|
|
103
|
+
);
|
|
100
104
|
return (
|
|
101
105
|
<div
|
|
102
106
|
ref={rowRef}
|
|
@@ -137,10 +141,52 @@ function TaskRow({
|
|
|
137
141
|
</button>
|
|
138
142
|
)}
|
|
139
143
|
</div>
|
|
140
|
-
{
|
|
141
|
-
<
|
|
142
|
-
{
|
|
143
|
-
|
|
144
|
+
{!open && (
|
|
145
|
+
<div className="space-y-0.5 pr-2 pb-1.5 pl-9">
|
|
146
|
+
{life.state === "exited" && (
|
|
147
|
+
<p
|
|
148
|
+
className={cn(
|
|
149
|
+
"font-mono text-[11px]",
|
|
150
|
+
life.failed ? "text-destructive" : "text-muted-foreground"
|
|
151
|
+
)}
|
|
152
|
+
>
|
|
153
|
+
{life.code === null ? task.status : `exited ${life.code}`}
|
|
154
|
+
</p>
|
|
155
|
+
)}
|
|
156
|
+
{lines.length > 0 ? (
|
|
157
|
+
<pre className="text-muted-foreground max-h-[4.5rem] overflow-hidden font-mono text-[11px] leading-[1.15rem]">
|
|
158
|
+
{lines.map((l, i) => (
|
|
159
|
+
<span key={i} className="block truncate">
|
|
160
|
+
{l}
|
|
161
|
+
</span>
|
|
162
|
+
))}
|
|
163
|
+
</pre>
|
|
164
|
+
) : (
|
|
165
|
+
running && (
|
|
166
|
+
<p className="text-muted-foreground truncate font-mono text-[11px]">
|
|
167
|
+
{task.taskType && /monitor|watch/.test(task.taskType)
|
|
168
|
+
? "watching"
|
|
169
|
+
: "running"}
|
|
170
|
+
: {task.summary || task.description}
|
|
171
|
+
</p>
|
|
172
|
+
)
|
|
173
|
+
)}
|
|
174
|
+
{running && (
|
|
175
|
+
<p
|
|
176
|
+
className={cn(
|
|
177
|
+
"text-[11px] tabular-nums",
|
|
178
|
+
life.state === "quiet"
|
|
179
|
+
? "text-amber-600 dark:text-amber-400"
|
|
180
|
+
: "text-muted-foreground"
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
183
|
+
{life.state === "quiet" ? "Quiet · " : ""}
|
|
184
|
+
{output?.updatedAt
|
|
185
|
+
? `last output ${idle} ago`
|
|
186
|
+
: `no output in ${idle}`}
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
144
190
|
)}
|
|
145
191
|
{open && (
|
|
146
192
|
<div className="space-y-1 pb-2 pl-7">
|
|
@@ -149,12 +195,12 @@ function TaskRow({
|
|
|
149
195
|
)}
|
|
150
196
|
{output === undefined ? (
|
|
151
197
|
<p className="text-muted-foreground text-xs">Loading output…</p>
|
|
152
|
-
) :
|
|
198
|
+
) : text ? (
|
|
153
199
|
<pre
|
|
154
200
|
ref={outputRef}
|
|
155
201
|
className="bg-foreground/[0.04] max-h-56 overflow-auto rounded-lg px-2 py-1.5 font-mono text-[11px] whitespace-pre-wrap"
|
|
156
202
|
>
|
|
157
|
-
{
|
|
203
|
+
{text}
|
|
158
204
|
</pre>
|
|
159
205
|
) : (
|
|
160
206
|
<p className="text-muted-foreground text-xs">
|
|
@@ -179,7 +225,7 @@ export function BackgroundTasks({
|
|
|
179
225
|
items: ChatItem[];
|
|
180
226
|
// A task to open the tray on, from the call that became it.
|
|
181
227
|
reveal?: { taskId: string; at: number } | null;
|
|
182
|
-
outputs: Record<string,
|
|
228
|
+
outputs: Record<string, TaskOutput>;
|
|
183
229
|
onStop: (taskId: string) => void;
|
|
184
230
|
onLoadOutput: (taskId: string) => void;
|
|
185
231
|
}) {
|
|
@@ -14,6 +14,8 @@ import { NotificationsMenu } from "@/components/NotificationsMenu";
|
|
|
14
14
|
import type { ViewProps } from "./types";
|
|
15
15
|
import { newDraft } from "@/stores/drafts";
|
|
16
16
|
import { paletteActions } from "@/stores/palette";
|
|
17
|
+
import { useClock } from "@/hooks/useMinuteTick";
|
|
18
|
+
import { inBell } from "@/lib/sidebar/shelves";
|
|
17
19
|
import { DRAFT_KEYS } from "@/lib/drafts";
|
|
18
20
|
|
|
19
21
|
// Labels collapse to icons when the pane bar carrying these is narrow
|
|
@@ -70,6 +72,7 @@ export function DesktopAppActions({
|
|
|
70
72
|
notificationSettings,
|
|
71
73
|
attachToSession,
|
|
72
74
|
}: AppActionsProps) {
|
|
75
|
+
const now = useClock();
|
|
73
76
|
return (
|
|
74
77
|
<div
|
|
75
78
|
className="flex shrink-0 items-center gap-0.5"
|
|
@@ -95,7 +98,7 @@ export function DesktopAppActions({
|
|
|
95
98
|
<NotificationsMenu
|
|
96
99
|
settings={notificationSettings}
|
|
97
100
|
waitingSessions={sessions
|
|
98
|
-
.filter((s) => sessionStatuses[s.id]
|
|
101
|
+
.filter((s) => inBell(s, sessionStatuses[s.id], now))
|
|
99
102
|
.map((s) => ({ id: s.id, name: s.name }))}
|
|
100
103
|
onSelectSession={(id) => {
|
|
101
104
|
const session = sessions.find((s) => s.id === id);
|
package/data/chat/useChat.ts
CHANGED
|
@@ -22,7 +22,7 @@ import type {
|
|
|
22
22
|
} from "@/lib/chat/events";
|
|
23
23
|
import type { SearchResult } from "@/lib/chat/find";
|
|
24
24
|
import { addBody, touchBody } from "@/lib/chat/bodyCache";
|
|
25
|
-
import { backgroundFailure } from "@/lib/chat/background";
|
|
25
|
+
import { backgroundFailure, type TaskOutput } from "@/lib/chat/background";
|
|
26
26
|
|
|
27
27
|
// The index of the item with `id`, searched from the end: what changes is
|
|
28
28
|
// almost always the latest.
|
|
@@ -164,7 +164,7 @@ export function useChat(
|
|
|
164
164
|
const searchRequests = useRef(
|
|
165
165
|
new Map<string, (result: SearchResult) => void>()
|
|
166
166
|
);
|
|
167
|
-
const [taskOutputs, setTaskOutputs] = useState<Record<string,
|
|
167
|
+
const [taskOutputs, setTaskOutputs] = useState<Record<string, TaskOutput>>(
|
|
168
168
|
{}
|
|
169
169
|
);
|
|
170
170
|
const [canBackground, setCanBackground] = useState(false);
|
|
@@ -278,7 +278,10 @@ export function useChat(
|
|
|
278
278
|
);
|
|
279
279
|
if (message) toast(message, { duration: 2500 });
|
|
280
280
|
} else if (m.type === "task_output") {
|
|
281
|
-
setTaskOutputs((prev) => ({
|
|
281
|
+
setTaskOutputs((prev) => ({
|
|
282
|
+
...prev,
|
|
283
|
+
[m.taskId]: { text: m.text, updatedAt: m.updatedAt },
|
|
284
|
+
}));
|
|
282
285
|
} else if (m.type === "undo_preview") {
|
|
283
286
|
setUndoPreview({ from: m.from, preview: m.preview });
|
|
284
287
|
} else if (m.type === "undone") {
|
package/lib/chat/background.ts
CHANGED
|
@@ -75,3 +75,57 @@ export function outputTail(text: string | null | undefined): string | null {
|
|
|
75
75
|
}
|
|
76
76
|
return null;
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
// A running task that has printed nothing for this long is marked quiet.
|
|
80
|
+
export const QUIET_AFTER_MS = 5 * 60 * 1000;
|
|
81
|
+
const TAIL_LINES = 4;
|
|
82
|
+
|
|
83
|
+
// What the server reads of a task's output file: the end of it, and when it
|
|
84
|
+
// last changed.
|
|
85
|
+
export interface TaskOutput {
|
|
86
|
+
text: string | null;
|
|
87
|
+
updatedAt: number | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The last few lines that say anything, oldest first.
|
|
91
|
+
export function outputLines(
|
|
92
|
+
text: string | null | undefined,
|
|
93
|
+
max = TAIL_LINES
|
|
94
|
+
): string[] {
|
|
95
|
+
const lines = (text?.split("\n") ?? []).map((l) => l.trimEnd());
|
|
96
|
+
return lines.filter((l) => l.trim()).slice(-max);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// "... failed with exit code 65" from the agent's note on a finished task.
|
|
100
|
+
export function exitCode(summary: string | undefined): number | null {
|
|
101
|
+
const m =
|
|
102
|
+
summary &&
|
|
103
|
+
/exit(?:ed)?(?: with)?(?: status| code)?\s*:?\s*(-?\d+)/i.exec(summary);
|
|
104
|
+
return m ? Number(m[1]) : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type TaskLife =
|
|
108
|
+
| { state: "active" }
|
|
109
|
+
| { state: "quiet"; idleMs: number }
|
|
110
|
+
| { state: "exited"; code: number | null; failed: boolean };
|
|
111
|
+
|
|
112
|
+
// Whether a task is progressing, has gone quiet, or has ended. Idle time
|
|
113
|
+
// counts from its last output, or from its start when it has printed nothing.
|
|
114
|
+
export function taskLife(
|
|
115
|
+
task: Pick<Task, "status" | "createdAt" | "summary">,
|
|
116
|
+
output: TaskOutput | null | undefined,
|
|
117
|
+
now: number
|
|
118
|
+
): TaskLife {
|
|
119
|
+
if (task.status !== "running") {
|
|
120
|
+
const code = exitCode(task.summary);
|
|
121
|
+
return {
|
|
122
|
+
state: "exited",
|
|
123
|
+
code: code ?? (task.status === "completed" ? 0 : null),
|
|
124
|
+
failed: task.status === "failed" || (code !== null && code !== 0),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const idleMs = Math.max(0, now - (output?.updatedAt ?? task.createdAt));
|
|
128
|
+
return idleMs >= QUIET_AFTER_MS
|
|
129
|
+
? { state: "quiet", idleMs }
|
|
130
|
+
: { state: "active" };
|
|
131
|
+
}
|
package/lib/chat/events.ts
CHANGED
|
@@ -369,7 +369,12 @@ export type ChatServerMessage =
|
|
|
369
369
|
}
|
|
370
370
|
| { type: "context"; context: ChatContext | null }
|
|
371
371
|
| { type: "undo_preview"; from: string; preview: UndoPreview }
|
|
372
|
-
| {
|
|
372
|
+
| {
|
|
373
|
+
type: "task_output";
|
|
374
|
+
taskId: string;
|
|
375
|
+
text: string | null;
|
|
376
|
+
updatedAt: number | null;
|
|
377
|
+
}
|
|
373
378
|
| {
|
|
374
379
|
type: "background_result";
|
|
375
380
|
toolUseId?: string;
|
package/lib/chat/runner.ts
CHANGED
|
@@ -63,6 +63,7 @@ import { holdsQueue, settingUp } from "../sessions/setup-progress";
|
|
|
63
63
|
import { firstMessageId, launchPending } from "../tasks/launch-gate";
|
|
64
64
|
import type { ChatItem, FileSuggestion } from "./events";
|
|
65
65
|
import { taskOutputTail } from "./task-output";
|
|
66
|
+
import type { TaskOutput } from "./background";
|
|
66
67
|
import { cutOffSessions, cutOffTurn, RESUME_NOTE } from "./interrupted";
|
|
67
68
|
import { queueEvent } from "../orchestrator/events";
|
|
68
69
|
import { restoreActivity, track } from "./activity";
|
|
@@ -881,7 +882,7 @@ export async function backgroundChatTask(
|
|
|
881
882
|
export function chatTaskOutput(
|
|
882
883
|
sessionId: string,
|
|
883
884
|
taskId: string
|
|
884
|
-
):
|
|
885
|
+
): TaskOutput | null {
|
|
885
886
|
const task = itemsOfKind(sessionId, "task").find((i) => i.taskId === taskId);
|
|
886
887
|
if (task?.kind !== "task") return null;
|
|
887
888
|
return taskOutputTail(taskId, task.outputFile);
|
package/lib/chat/socket.ts
CHANGED
|
@@ -196,7 +196,10 @@ function onChatMessage(
|
|
|
196
196
|
reply({
|
|
197
197
|
type: "task_output",
|
|
198
198
|
taskId: msg.taskId,
|
|
199
|
-
|
|
199
|
+
...(chatTaskOutput(sessionId, msg.taskId) ?? {
|
|
200
|
+
text: null,
|
|
201
|
+
updatedAt: null,
|
|
202
|
+
}),
|
|
200
203
|
});
|
|
201
204
|
else if (msg.type === "history" && Number.isFinite(msg.before))
|
|
202
205
|
reply({
|
package/lib/chat/task-output.ts
CHANGED
|
@@ -2,6 +2,8 @@ import fs from "fs";
|
|
|
2
2
|
import os from "os";
|
|
3
3
|
import path from "path";
|
|
4
4
|
|
|
5
|
+
import type { TaskOutput } from "./background";
|
|
6
|
+
|
|
5
7
|
const TAIL_BYTES = 6000;
|
|
6
8
|
const found = new Map<string, string>();
|
|
7
9
|
|
|
@@ -48,19 +50,22 @@ function walk(dir: string, name: string, depth: number): string | null {
|
|
|
48
50
|
export function taskOutputTail(
|
|
49
51
|
taskId: string,
|
|
50
52
|
knownFile?: string
|
|
51
|
-
):
|
|
53
|
+
): TaskOutput | null {
|
|
52
54
|
const file =
|
|
53
55
|
knownFile && fs.existsSync(knownFile) ? knownFile : findOutputFile(taskId);
|
|
54
56
|
if (!file) return null;
|
|
55
57
|
try {
|
|
56
|
-
const { size } = fs.statSync(file);
|
|
58
|
+
const { size, mtimeMs } = fs.statSync(file);
|
|
57
59
|
const fd = fs.openSync(file, "r");
|
|
58
60
|
try {
|
|
59
61
|
const start = Math.max(0, size - TAIL_BYTES);
|
|
60
62
|
const buf = Buffer.alloc(size - start);
|
|
61
63
|
fs.readSync(fd, buf, 0, buf.length, start);
|
|
62
64
|
const text = buf.toString("utf8");
|
|
63
|
-
return
|
|
65
|
+
return {
|
|
66
|
+
text: start > 0 ? text.slice(text.indexOf("\n") + 1) : text,
|
|
67
|
+
updatedAt: mtimeMs,
|
|
68
|
+
};
|
|
64
69
|
} finally {
|
|
65
70
|
fs.closeSync(fd);
|
|
66
71
|
}
|
package/lib/needs-you.ts
CHANGED
|
@@ -33,7 +33,12 @@ function latestChatItem(sessionId: string): ChatItem | null {
|
|
|
33
33
|
// is routine for it.
|
|
34
34
|
export function needsYou(
|
|
35
35
|
session: Pick<Session, "id" | "view" | "updated_at" | "last_seen_at"> &
|
|
36
|
-
Partial<
|
|
36
|
+
Partial<
|
|
37
|
+
Pick<
|
|
38
|
+
Session,
|
|
39
|
+
"role" | "workspace_id" | "task_status" | "settled_at" | "archived_at"
|
|
40
|
+
>
|
|
41
|
+
>,
|
|
37
42
|
chatState: ChatState | null
|
|
38
43
|
): boolean {
|
|
39
44
|
const seen = sqliteMs(session.last_seen_at);
|
|
@@ -43,7 +48,16 @@ export function needsYou(
|
|
|
43
48
|
if (chatState === "waiting") return true;
|
|
44
49
|
if (session.role === "orchestrator")
|
|
45
50
|
return openAskCount(session.workspace_id) > 0;
|
|
51
|
+
if (session.archived_at) return false;
|
|
46
52
|
const latest = latestChatItem(session.id);
|
|
53
|
+
// Put on the Done shelf after its last turn: that turn is history, not
|
|
54
|
+
// news. (settled_at has second precision.)
|
|
55
|
+
if (
|
|
56
|
+
latest &&
|
|
57
|
+
session.settled_at &&
|
|
58
|
+
latest.createdAt <= sqliteMs(session.settled_at) + 1000
|
|
59
|
+
)
|
|
60
|
+
return false;
|
|
47
61
|
return latest?.kind === "turn_end" && latest.createdAt > seen;
|
|
48
62
|
}
|
|
49
63
|
// A terminal session's updated_at moves when it starts waiting.
|
package/lib/recipes/settle.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { db, queries, type Session } from "../db";
|
|
|
7
7
|
import { finalMessage, usageRows } from "../session-logs/gather";
|
|
8
8
|
import { chatStateNow } from "../chat/runner";
|
|
9
9
|
import { doneSession } from "../done";
|
|
10
|
+
import { notifyStatusChanged } from "../status/hub";
|
|
10
11
|
import { blockedReason } from "../tasks/state";
|
|
11
12
|
import { runClaude, type ClaudeRunner } from "../orchestrator/claude-cli";
|
|
12
13
|
import { releasePieces } from "../plans/release";
|
|
@@ -406,23 +407,35 @@ async function settleOne(
|
|
|
406
407
|
} else idleSince.delete(run.id);
|
|
407
408
|
}
|
|
408
409
|
|
|
409
|
-
// After a run settles: a
|
|
410
|
-
//
|
|
411
|
-
//
|
|
410
|
+
// After a run settles: a session run that reported its result (ok or failed)
|
|
411
|
+
// is marked Done so the sidebar and the bell don't pile up; a task run is left
|
|
412
|
+
// to its gates. A run that ended without a result line, was blocked or timed
|
|
413
|
+
// out may be a real question, so it stays open. The settle event already told
|
|
414
|
+
// the orchestrator.
|
|
415
|
+
export function finishesSession(
|
|
416
|
+
row: { status: RunStatus; summary_source: string | null },
|
|
417
|
+
recipe: Pick<Recipe, "kind" | "auto_done">
|
|
418
|
+
): boolean {
|
|
419
|
+
if (recipe.kind !== "session" || !recipe.auto_done) return false;
|
|
420
|
+
if (row.status === "succeeded") return true;
|
|
421
|
+
return row.status === "failed" && row.summary_source === "line";
|
|
422
|
+
}
|
|
423
|
+
|
|
412
424
|
async function afterSettle(run: Live): Promise<void> {
|
|
413
425
|
const row = db
|
|
414
|
-
.prepare(`SELECT status FROM recipe_runs WHERE id = ?`)
|
|
415
|
-
.get(run.id) as
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
)
|
|
425
|
-
|
|
426
|
+
.prepare(`SELECT status, summary_source FROM recipe_runs WHERE id = ?`)
|
|
427
|
+
.get(run.id) as
|
|
428
|
+
| { status: RunStatus; summary_source: string | null }
|
|
429
|
+
| undefined;
|
|
430
|
+
if (!row || !run.session_id || !finishesSession(row, run.recipe)) return;
|
|
431
|
+
try {
|
|
432
|
+
await doneSession(run.session_id, { by: "direct", noMerge: true });
|
|
433
|
+
notifyStatusChanged();
|
|
434
|
+
} catch (error) {
|
|
435
|
+
console.warn(
|
|
436
|
+
`[recipes] couldn't mark run #${run.seq} (${run.session_id}) done:`,
|
|
437
|
+
error instanceof Error ? error.message : error
|
|
438
|
+
);
|
|
426
439
|
}
|
|
427
440
|
}
|
|
428
441
|
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -198,6 +198,17 @@ export function settledHow(
|
|
|
198
198
|
return now - active >= SETTLE_AFTER_MS ? "auto" : null;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
// Whether the bell lists a session: waiting on you, and not put away. Like
|
|
202
|
+
// the shelves, a live need wins over Done.
|
|
203
|
+
export function inBell(
|
|
204
|
+
session: Parameters<typeof settledHow>[0] & Pick<Session, "archived_at">,
|
|
205
|
+
status: RowStatus | undefined,
|
|
206
|
+
now: number
|
|
207
|
+
): boolean {
|
|
208
|
+
if (status?.status !== "waiting" || session.archived_at) return false;
|
|
209
|
+
return !!status.need || !settledHow(session, now);
|
|
210
|
+
}
|
|
211
|
+
|
|
201
212
|
// Woken by its timer and not opened since.
|
|
202
213
|
function wokeUnread(
|
|
203
214
|
session: Parameters<typeof activeMs>[0] & Pick<Session, "last_seen_at">,
|
package/lib/tasks/queue.ts
CHANGED
|
@@ -189,7 +189,10 @@ const workspaceOf = (projectId: string) =>
|
|
|
189
189
|
// gives its slot back until it resumes. A quick change
|
|
190
190
|
// runs no agent, so it holds none. Without a
|
|
191
191
|
// workspace, the project is the scope.
|
|
192
|
-
function runningIn(
|
|
192
|
+
function runningIn(
|
|
193
|
+
scope: { workspaceId: string | null; projectId: string },
|
|
194
|
+
forSlots = false
|
|
195
|
+
) {
|
|
193
196
|
const where = scope.workspaceId
|
|
194
197
|
? `p.workspace_id = @ws`
|
|
195
198
|
: `p.id = @project AND p.workspace_id IS NULL`;
|
|
@@ -199,6 +202,7 @@ function runningIn(scope: { workspaceId: string | null; projectId: string }) {
|
|
|
199
202
|
`SELECT s.id FROM sessions s JOIN projects p ON p.id = s.project_id
|
|
200
203
|
WHERE ${where} AND s.archived_at IS NULL AND s.quick_of IS NULL
|
|
201
204
|
AND (s.pause_state IS NULL OR s.pause_state != 'paused')
|
|
205
|
+
${forSlots ? `AND NOT (COALESCE(s.plan_only, 0) = 1 AND COALESCE(s.plan_phase, '') != 'implementing')` : ""}
|
|
202
206
|
AND s.task_status IN (${IN_FLIGHT.map((s) => `'${s}'`).join(", ")})`
|
|
203
207
|
)
|
|
204
208
|
.all({ ws: scope.workspaceId, project: scope.projectId }) as {
|
|
@@ -214,7 +218,8 @@ function startingIn(workspaceId: string): number {
|
|
|
214
218
|
db
|
|
215
219
|
.prepare(
|
|
216
220
|
`SELECT COUNT(*) AS n FROM task_queue q JOIN projects p ON p.id = q.project_id
|
|
217
|
-
WHERE q.status = 'starting' AND p.workspace_id =
|
|
221
|
+
WHERE q.status = 'starting' AND p.workspace_id = ?
|
|
222
|
+
AND q.plan_only IS NULL`
|
|
218
223
|
)
|
|
219
224
|
.get(workspaceId) as { n: number }
|
|
220
225
|
).n
|
|
@@ -315,7 +320,8 @@ const waitingIn = (workspaceId: string) =>
|
|
|
315
320
|
.prepare(
|
|
316
321
|
`SELECT COUNT(*) AS n FROM task_queue q JOIN projects p ON p.id = q.project_id
|
|
317
322
|
WHERE q.status = 'queued' AND p.workspace_id = ?
|
|
318
|
-
AND q.after_task IS NULL AND q.after_any IS NULL
|
|
323
|
+
AND q.after_task IS NULL AND q.after_any IS NULL
|
|
324
|
+
AND q.plan_only IS NULL`
|
|
319
325
|
)
|
|
320
326
|
.get(workspaceId) as { n: number }
|
|
321
327
|
).n;
|
|
@@ -344,9 +350,10 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
344
350
|
} else if (after)
|
|
345
351
|
afterTask = resolveAfter(after, req.originWorkspaceId ?? workspaceId).id;
|
|
346
352
|
const full =
|
|
353
|
+
!req.planOnly &&
|
|
347
354
|
limit !== null &&
|
|
348
355
|
!!workspaceId &&
|
|
349
|
-
(runningIn({ workspaceId, projectId: project.id }).length +
|
|
356
|
+
(runningIn({ workspaceId, projectId: project.id }, true).length +
|
|
350
357
|
startingIn(workspaceId) >=
|
|
351
358
|
limit ||
|
|
352
359
|
waitingIn(workspaceId) > 0);
|
|
@@ -360,6 +367,7 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
360
367
|
const loaded =
|
|
361
368
|
!waits &&
|
|
362
369
|
!full &&
|
|
370
|
+
!req.planOnly &&
|
|
363
371
|
!!req.originWorkspaceId &&
|
|
364
372
|
loadHeldStart(project.id, req.prompt, setting);
|
|
365
373
|
if (!waits && !full && !loaded) return null;
|
|
@@ -523,9 +531,10 @@ export const queuedView = (id: string) =>
|
|
|
523
531
|
|
|
524
532
|
// Why an automatic start has to wait, or null. A start now by a person
|
|
525
533
|
// isn't held.
|
|
526
|
-
function autoHold(workspaceId: string | null): Hold | null {
|
|
534
|
+
function autoHold(workspaceId: string | null, planOnly = false): Hold | null {
|
|
527
535
|
if (workspaceId && isPaused(workspaceId))
|
|
528
536
|
return { code: "orch_paused", text: "Waiting: the orchestrator is paused" };
|
|
537
|
+
if (planOnly) return null;
|
|
529
538
|
const window = brakesEnabled() ? windowRefusal(readUsage()) : null;
|
|
530
539
|
return window ? { code: "window", text: `Waiting: ${window}` } : null;
|
|
531
540
|
}
|
|
@@ -609,7 +618,7 @@ async function startRow(
|
|
|
609
618
|
return "failed";
|
|
610
619
|
}
|
|
611
620
|
if (!opts.force) {
|
|
612
|
-
const held = autoHold(workspaceId);
|
|
621
|
+
const held = autoHold(workspaceId, !!r.plan_only);
|
|
613
622
|
if (held) {
|
|
614
623
|
if (r.note !== held.text)
|
|
615
624
|
patch(r.id, { note: held.text, wait_code: held.code });
|
|
@@ -621,7 +630,8 @@ async function startRow(
|
|
|
621
630
|
if (
|
|
622
631
|
workspaceId &&
|
|
623
632
|
limit !== null &&
|
|
624
|
-
|
|
633
|
+
!r.plan_only &&
|
|
634
|
+
runningIn({ workspaceId, projectId: r.project_id }, true).length +
|
|
625
635
|
startingIn(workspaceId) >=
|
|
626
636
|
limit
|
|
627
637
|
)
|
|
@@ -643,7 +653,7 @@ async function startRow(
|
|
|
643
653
|
}
|
|
644
654
|
if (!claimed) return "held";
|
|
645
655
|
const placed = opts.placement ?? null;
|
|
646
|
-
if (!opts.force)
|
|
656
|
+
if (!opts.force && !r.plan_only)
|
|
647
657
|
recordAutoStart(placed?.hostId ?? LOCAL, !!opts.heavy, Date.now());
|
|
648
658
|
// A linked machine's start never sees this machine's held messages: they
|
|
649
659
|
// go with the brief it is sent.
|
|
@@ -747,7 +757,7 @@ function groupFor(
|
|
|
747
757
|
const scope = { workspaceId, projectId };
|
|
748
758
|
return {
|
|
749
759
|
inFlight:
|
|
750
|
-
runningIn(scope).length +
|
|
760
|
+
runningIn(scope, true).length +
|
|
751
761
|
(workspaceId ? (reserved.get(workspaceId) ?? 0) : 0) +
|
|
752
762
|
startingRows,
|
|
753
763
|
limit: limitOf(workspaceId) ?? Number.POSITIVE_INFINITY,
|
|
@@ -814,8 +824,9 @@ function candidateOf(
|
|
|
814
824
|
heavy: heavyNeeds(needs),
|
|
815
825
|
needsUnmet: !remote && !meetsNeeds(needs, machineCapabilities()),
|
|
816
826
|
remote,
|
|
827
|
+
planOnly: !!r.plan_only,
|
|
817
828
|
before,
|
|
818
|
-
auto: autoHold(r.workspace_id),
|
|
829
|
+
auto: autoHold(r.workspace_id, !!r.plan_only),
|
|
819
830
|
any,
|
|
820
831
|
},
|
|
821
832
|
};
|
package/lib/tasks/schedule.ts
CHANGED
|
@@ -82,6 +82,9 @@ export interface Candidate {
|
|
|
82
82
|
needsUnmet: boolean;
|
|
83
83
|
// Starts on another machine: this one's load doesn't matter to it.
|
|
84
84
|
remote: boolean;
|
|
85
|
+
// A plan-only task before approval: it reads code and writes one file, so
|
|
86
|
+
// load, spacing and the slot limit don't apply. Pause and `after` still do.
|
|
87
|
+
planOnly?: boolean;
|
|
85
88
|
// `after X` hold, from the caller (it needs the database).
|
|
86
89
|
before: Hold | null;
|
|
87
90
|
// Orchestrator Pause and the usage window, which hold after `after`.
|
|
@@ -127,11 +130,12 @@ const seconds = (ms: number) => Math.max(1, Math.ceil(ms / 1000));
|
|
|
127
130
|
|
|
128
131
|
// The machine holds, in order. Null: it may start here now.
|
|
129
132
|
export function machineHold(
|
|
130
|
-
c: Pick<Candidate, "heavy" | "needsUnmet" | "remote" | "priority"
|
|
133
|
+
c: Pick<Candidate, "heavy" | "needsUnmet" | "remote" | "priority"> &
|
|
134
|
+
Partial<Pick<Candidate, "planOnly">>,
|
|
131
135
|
m: MachineView,
|
|
132
136
|
now: number
|
|
133
137
|
): Hold | null {
|
|
134
|
-
if (c.remote) return null;
|
|
138
|
+
if (c.remote || c.planOnly) return null;
|
|
135
139
|
if (c.needsUnmet) return { code: "needs", text: `Waiting: needs a Mac` };
|
|
136
140
|
if (!m.enabled) return null;
|
|
137
141
|
if (m.reading)
|
|
@@ -226,7 +230,7 @@ export function planStarts(
|
|
|
226
230
|
priority: c.priority,
|
|
227
231
|
});
|
|
228
232
|
if (hold) return void plan.explain.set(c.id, view(hold));
|
|
229
|
-
if (inFlight >= group.limit)
|
|
233
|
+
if (!c.planOnly && inFlight >= group.limit)
|
|
230
234
|
return void plan.explain.set(
|
|
231
235
|
c.id,
|
|
232
236
|
view({
|
|
@@ -240,8 +244,8 @@ export function planStarts(
|
|
|
240
244
|
if (free) used.add(free);
|
|
241
245
|
plan.consume.set(c.id, free);
|
|
242
246
|
}
|
|
243
|
-
inFlight += 1;
|
|
244
|
-
if (!c.remote) {
|
|
247
|
+
if (!c.planOnly) inFlight += 1;
|
|
248
|
+
if (!c.remote && !c.planOnly) {
|
|
245
249
|
m.lastAutoStartAt = now;
|
|
246
250
|
if (c.heavy) {
|
|
247
251
|
m.lastHeavyStartAt = now;
|