@saadnvd1/agent-os 0.3.597 → 0.3.601
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/app/api/bus/spawn/route.ts +35 -16
- package/app/api/lumifyhub/cards/[cardId]/run/route.ts +3 -0
- package/app/api/tasks/route.ts +3 -0
- package/app/page.tsx +2 -1
- package/components/SessionList/LiveLine.tsx +33 -6
- package/components/SessionList/QueuedRow.tsx +45 -46
- package/components/SessionList/RowParts.tsx +1 -54
- package/components/SessionList/RowShell.tsx +13 -6
- package/components/SessionList/RowStatusPlacement.test.ts +114 -0
- package/components/SessionList/RowSubtitle.tsx +2 -3
- package/components/SessionList/SessionRow.tsx +14 -11
- package/lib/agents/spawn.ts +16 -4
- package/lib/chat/refresh.ts +2 -1
- package/lib/chat/runner.ts +29 -0
- package/lib/chat/store.ts +53 -0
- package/lib/db/migrations/20261014T2110_task_start_record.ts +21 -0
- package/lib/db/migrations/20261014T2200_end_orphaned_tasks.ts +23 -0
- package/lib/db/types.ts +2 -0
- package/lib/learning-cards/apply.ts +7 -1
- package/lib/models/plan-step.ts +25 -8
- package/lib/models/resolve.ts +1 -1
- package/lib/models/resume.ts +1 -1
- package/lib/models/roles.ts +0 -16
- package/lib/models/session-role.ts +19 -0
- package/lib/orchestrator/act.ts +22 -7
- package/lib/orchestrator/amendments.ts +10 -3
- package/lib/orchestrator/serve.ts +1 -0
- package/lib/orchestrator/testing.ts +8 -0
- package/lib/orchestrator/tool-schemas.ts +9 -0
- package/lib/orchestrator/tools.ts +1 -1
- package/lib/plans/claim.ts +131 -0
- package/lib/plans/release.ts +5 -2
- package/lib/recipes/run.ts +6 -0
- package/lib/schedules/start.ts +1 -0
- package/lib/sessions/fork.ts +10 -0
- package/lib/sessions/launch.ts +5 -1
- package/lib/sidebar/live-status.ts +33 -2
- package/lib/sidebar/row-badge.ts +26 -2
- package/lib/stacks/start.ts +1 -0
- package/lib/tasks/held-remote.ts +4 -2
- package/lib/tasks/held-replies.ts +5 -2
- package/lib/tasks/held.ts +2 -1
- package/lib/tasks/index.ts +85 -46
- package/lib/tasks/move-carry.ts +12 -1
- package/lib/tasks/move-guard.ts +2 -1
- package/lib/tasks/move.ts +7 -5
- package/lib/tasks/pause.ts +1 -1
- package/lib/tasks/queue.ts +54 -31
- package/lib/tasks/remote.ts +3 -1
- package/lib/tasks/starter.ts +124 -0
- package/lib/usage/calls.ts +2 -1
- package/lib/usage/turns.ts +2 -1
- package/package.json +1 -1
package/lib/agents/spawn.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
+
import { orchestratorOf, recordStart } from "../tasks/starter";
|
|
2
3
|
import os from "os";
|
|
3
4
|
import { db, queries, type Session } from "../db";
|
|
4
5
|
import { getProject, getAllProjects } from "../projects";
|
|
@@ -35,6 +36,7 @@ export async function spawnSession(opts: {
|
|
|
35
36
|
// The orchestrator session that starts it: the implementation role's
|
|
36
37
|
// model unless `model` is given.
|
|
37
38
|
startedBy?: string | null;
|
|
39
|
+
parentSessionId?: string | null;
|
|
38
40
|
}): Promise<Session> {
|
|
39
41
|
const prompt = opts.prompt.trim();
|
|
40
42
|
if (!prompt) throw new Error("Give the new session a prompt");
|
|
@@ -46,7 +48,7 @@ export async function spawnSession(opts: {
|
|
|
46
48
|
|
|
47
49
|
const id = randomUUID();
|
|
48
50
|
const tmuxName = `claude-${id}`;
|
|
49
|
-
const routed = opts.startedBy
|
|
51
|
+
const routed = orchestratorOf(opts.startedBy)
|
|
50
52
|
? resolveRole("implement", {
|
|
51
53
|
workspaceId: project.workspace_id,
|
|
52
54
|
override: parseOverride(opts.model),
|
|
@@ -76,9 +78,19 @@ export async function spawnSession(opts: {
|
|
|
76
78
|
project.id,
|
|
77
79
|
"local"
|
|
78
80
|
);
|
|
79
|
-
db.prepare(
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
db.prepare(`UPDATE sessions SET name_source = ? WHERE id = ?`).run(
|
|
82
|
+
naming.source,
|
|
83
|
+
id
|
|
84
|
+
);
|
|
85
|
+
recordStart(id, {
|
|
86
|
+
startedBy: opts.startedBy ?? null,
|
|
87
|
+
parentSessionId: opts.parentSessionId ?? opts.startedBy ?? null,
|
|
88
|
+
startedVia: orchestratorOf(opts.startedBy)
|
|
89
|
+
? "orchestrator"
|
|
90
|
+
: opts.startedBy
|
|
91
|
+
? "session"
|
|
92
|
+
: "person",
|
|
93
|
+
});
|
|
82
94
|
if (routed) setSessionModel(id, routed);
|
|
83
95
|
void naming.refine?.(id);
|
|
84
96
|
await launchClaude({ sessionId: id, tmuxName, cwd, model, prompt });
|
package/lib/chat/refresh.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
import { db } from "../db";
|
|
15
15
|
import { effectiveModels } from "../models/config";
|
|
16
|
-
import {
|
|
16
|
+
import { type ModelRole } from "../models/roles";
|
|
17
|
+
import { sessionRole } from "../models/session-role";
|
|
17
18
|
import { MAX_REFRESH_TOKENS, MIN_REFRESH_TOKENS } from "./refresh-limits";
|
|
18
19
|
|
|
19
20
|
export const ORCHESTRATOR_KEY = "orchestrator.refresh_tokens";
|
package/lib/chat/runner.ts
CHANGED
|
@@ -41,10 +41,13 @@ import {
|
|
|
41
41
|
setChatPlan,
|
|
42
42
|
} from "./settings";
|
|
43
43
|
import {
|
|
44
|
+
endOrphanedTasks,
|
|
45
|
+
endRunningTasks,
|
|
44
46
|
getItem,
|
|
45
47
|
hasItem,
|
|
46
48
|
itemsOfKind,
|
|
47
49
|
recentTasks,
|
|
50
|
+
sessionsWithRunningTasks,
|
|
48
51
|
saveItem,
|
|
49
52
|
settle,
|
|
50
53
|
} from "./store";
|
|
@@ -167,8 +170,26 @@ function planEnv(sessionId: string): PlanHandoffEnv {
|
|
|
167
170
|
};
|
|
168
171
|
}
|
|
169
172
|
|
|
173
|
+
// Tell watchers of tasks that just ended, and drop them from the live count.
|
|
174
|
+
function emitEnded(
|
|
175
|
+
sessionId: string,
|
|
176
|
+
live: Live | undefined,
|
|
177
|
+
ended: ReturnType<typeof endRunningTasks>
|
|
178
|
+
): void {
|
|
179
|
+
for (const item of ended) {
|
|
180
|
+
live?.activity.tasks.delete(item.taskId);
|
|
181
|
+
emit(sessionId, { type: "item", item });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
170
185
|
function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
|
|
171
186
|
track(live, e);
|
|
187
|
+
if (
|
|
188
|
+
e.type === "item" &&
|
|
189
|
+
e.item.kind === "tool" &&
|
|
190
|
+
(e.item.status === "stopped" || e.item.status === "error")
|
|
191
|
+
)
|
|
192
|
+
emitEnded(sessionId, live, endOrphanedTasks(sessionId));
|
|
172
193
|
if (e.type === "item" && e.item.kind === "task")
|
|
173
194
|
retireIfStale(sessionId, live);
|
|
174
195
|
if (e.type === "item") {
|
|
@@ -297,7 +318,11 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
|
|
|
297
318
|
);
|
|
298
319
|
},
|
|
299
320
|
};
|
|
321
|
+
// No worker running: its agent ended, and with it every job it ran.
|
|
322
|
+
const hadWorker = runningWorkers().includes(sessionId);
|
|
300
323
|
let { client, hello } = await connectWorker(sessionId, spawn, handlers);
|
|
324
|
+
if (!hadWorker) emitEnded(sessionId, undefined, endRunningTasks(sessionId));
|
|
325
|
+
emitEnded(sessionId, undefined, endOrphanedTasks(sessionId));
|
|
301
326
|
let activity = restoreActivity(sessionId, hello.state);
|
|
302
327
|
// An idle worker left over from before a redeploy: retire it, and start
|
|
303
328
|
// a fresh one only when a message needs it. One still running background
|
|
@@ -316,6 +341,7 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
|
|
|
316
341
|
{ code: RETIRED }
|
|
317
342
|
);
|
|
318
343
|
({ client, hello } = await connectWorker(sessionId, true, handlers));
|
|
344
|
+
emitEnded(sessionId, undefined, endRunningTasks(sessionId));
|
|
319
345
|
activity = restoreActivity(sessionId, hello.state);
|
|
320
346
|
}
|
|
321
347
|
live = {
|
|
@@ -965,6 +991,9 @@ export async function reattachChats(): Promise<void> {
|
|
|
965
991
|
})
|
|
966
992
|
)
|
|
967
993
|
);
|
|
994
|
+
// A task saved as running with no worker behind it has no job either.
|
|
995
|
+
for (const id of sessionsWithRunningTasks())
|
|
996
|
+
if (!live.has(id)) endRunningTasks(id);
|
|
968
997
|
// Turns cut off while no worker was left to finish them, then queues.
|
|
969
998
|
// One at a time, and only what was queued recently: a backlog from long
|
|
970
999
|
// ago isn't sent unasked by a restart (it stays on screen to send).
|
package/lib/chat/store.ts
CHANGED
|
@@ -235,6 +235,59 @@ export function runningItems<K extends "tool" | "task">(
|
|
|
235
235
|
).map((r) => JSON.parse(r.data));
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
const LOST = "Its agent ended before this finished";
|
|
239
|
+
|
|
240
|
+
function endTasks(
|
|
241
|
+
sessionId: string,
|
|
242
|
+
which: (task: Extract<ChatItem, { kind: "task" }>) => boolean
|
|
243
|
+
): Extract<ChatItem, { kind: "task" }>[] {
|
|
244
|
+
const ended: Extract<ChatItem, { kind: "task" }>[] = [];
|
|
245
|
+
for (const task of runningItems(sessionId, "task")) {
|
|
246
|
+
if (!which(task)) continue;
|
|
247
|
+
const item = {
|
|
248
|
+
...task,
|
|
249
|
+
status: "stopped" as const,
|
|
250
|
+
endedAt: Date.now(),
|
|
251
|
+
summary: task.summary ?? LOST,
|
|
252
|
+
};
|
|
253
|
+
saveItem(sessionId, item);
|
|
254
|
+
ended.push(item);
|
|
255
|
+
}
|
|
256
|
+
return ended;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Its agent is gone (a fresh worker, no worker at restart): nothing it ran
|
|
260
|
+
// in the background is still going. Returns what it ended.
|
|
261
|
+
export const endRunningTasks = (sessionId: string) =>
|
|
262
|
+
endTasks(sessionId, () => true);
|
|
263
|
+
|
|
264
|
+
// A task whose tool call was stopped or errored has no job behind it.
|
|
265
|
+
export function endOrphanedTasks(sessionId: string) {
|
|
266
|
+
const dead = new Set(
|
|
267
|
+
runningItems(sessionId, "task").flatMap((t) => {
|
|
268
|
+
const tool = t.toolUseId ? getItem(sessionId, t.toolUseId) : undefined;
|
|
269
|
+
return tool?.kind === "tool" &&
|
|
270
|
+
(tool.status === "stopped" || tool.status === "error")
|
|
271
|
+
? [t.id]
|
|
272
|
+
: [];
|
|
273
|
+
})
|
|
274
|
+
);
|
|
275
|
+
return endTasks(sessionId, (t) => dead.has(t.id));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Chats with a task saved as running.
|
|
279
|
+
export function sessionsWithRunningTasks(): string[] {
|
|
280
|
+
flushItems();
|
|
281
|
+
return (
|
|
282
|
+
db
|
|
283
|
+
.prepare(
|
|
284
|
+
`SELECT DISTINCT session_id FROM chat_items WHERE kind = 'task'
|
|
285
|
+
AND json_extract(data, '$.status') = 'running'`
|
|
286
|
+
)
|
|
287
|
+
.all() as { session_id: string }[]
|
|
288
|
+
).map((r) => r.session_id);
|
|
289
|
+
}
|
|
290
|
+
|
|
238
291
|
// Tool calls saved as still running.
|
|
239
292
|
export const runningTools = (sessionId: string) =>
|
|
240
293
|
runningItems(sessionId, "tool");
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261014T2110_task_start_record",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// Who started a task and how, on the session and its queue row; an
|
|
7
|
+
// approval kind on brief entries. Additive.
|
|
8
|
+
const add = (table: string, col: string, type: string) => {
|
|
9
|
+
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as {
|
|
10
|
+
name: string;
|
|
11
|
+
}[];
|
|
12
|
+
if (!columns.some((c) => c.name === col))
|
|
13
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`);
|
|
14
|
+
};
|
|
15
|
+
add("sessions", "started_via", "TEXT");
|
|
16
|
+
add("task_queue", "parent_session_id", "TEXT");
|
|
17
|
+
add("task_queue", "started_by_device", "TEXT");
|
|
18
|
+
add("task_queue", "started_via", "TEXT");
|
|
19
|
+
add("brief_amendments", "kind", "TEXT NOT NULL DEFAULT 'scope'");
|
|
20
|
+
},
|
|
21
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261014T2200_end_orphaned_tasks",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// A background task still "running" whose tool call was stopped or
|
|
7
|
+
// errored has no job behind it. Settles the rows already stuck.
|
|
8
|
+
db.exec(`
|
|
9
|
+
UPDATE chat_items
|
|
10
|
+
SET data = json_set(data, '$.status', 'stopped',
|
|
11
|
+
'$.endedAt', CAST(strftime('%s','now') AS INTEGER) * 1000,
|
|
12
|
+
'$.summary', COALESCE(json_extract(data, '$.summary'),
|
|
13
|
+
'Its agent ended before this finished'))
|
|
14
|
+
WHERE kind = 'task' AND json_extract(data, '$.status') = 'running'
|
|
15
|
+
AND EXISTS (
|
|
16
|
+
SELECT 1 FROM chat_items t
|
|
17
|
+
WHERE t.session_id = chat_items.session_id
|
|
18
|
+
AND t.item_id = json_extract(chat_items.data, '$.toolUseId')
|
|
19
|
+
AND t.kind = 'tool'
|
|
20
|
+
AND json_extract(t.data, '$.status') IN ('stopped', 'error'))
|
|
21
|
+
`);
|
|
22
|
+
},
|
|
23
|
+
} satisfies Migration;
|
package/lib/db/types.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface Session {
|
|
|
24
24
|
plan_only?: number | null;
|
|
25
25
|
// The orchestrator session that started this one; null for a person.
|
|
26
26
|
started_by?: string | null;
|
|
27
|
+
// How it started: session, orchestrator, person, plan, schedule, recipe, stack or remote.
|
|
28
|
+
started_via?: string | null;
|
|
27
29
|
// 1 planned, 0 no plan, null not stated; why, in the starter's words.
|
|
28
30
|
plan_decided?: number | null;
|
|
29
31
|
// JSON: the pieces Saad merged or dropped before approving the plan.
|
|
@@ -80,6 +80,7 @@ async function toClaudeMd(card: Card, p: LessonPayload, line: string) {
|
|
|
80
80
|
projectId: project.id,
|
|
81
81
|
prompt: `Add exactly this line under a "${HEADING}" heading in CLAUDE.md at the repository root, creating the file and heading if missing, and change nothing else: ${line}`,
|
|
82
82
|
startedBy: null,
|
|
83
|
+
startedVia: "person",
|
|
83
84
|
});
|
|
84
85
|
return task.id;
|
|
85
86
|
}
|
|
@@ -105,7 +106,12 @@ async function applyLesson(card: Card, p: LessonPayload, edits: Edits) {
|
|
|
105
106
|
if (!prompt || !p.projectId)
|
|
106
107
|
throw new CardRefused("There's no task to start");
|
|
107
108
|
return (
|
|
108
|
-
await createTask({
|
|
109
|
+
await createTask({
|
|
110
|
+
projectId: p.projectId,
|
|
111
|
+
prompt,
|
|
112
|
+
startedBy: null,
|
|
113
|
+
startedVia: "person",
|
|
114
|
+
})
|
|
109
115
|
).id;
|
|
110
116
|
}
|
|
111
117
|
case "rule":
|
package/lib/models/plan-step.ts
CHANGED
|
@@ -21,8 +21,9 @@ import { ANTHROPIC } from "./roles";
|
|
|
21
21
|
import { queueEvent } from "../orchestrator/events";
|
|
22
22
|
import { noApprovalsOn, recordAutoApproval } from "../approvals/no-approvals";
|
|
23
23
|
import { addNote } from "../orchestrator/notes";
|
|
24
|
-
import { amendBrief,
|
|
24
|
+
import { amendBrief, scopeChangesOf } from "../orchestrator/amendments";
|
|
25
25
|
import { PIECES_PROMPT, foldedBrief, pieceBrief } from "../plans/pieces";
|
|
26
|
+
import { orchestratorOf } from "../tasks/starter";
|
|
26
27
|
import {
|
|
27
28
|
createFeature,
|
|
28
29
|
ownPieceOf,
|
|
@@ -119,7 +120,7 @@ function piecesLine(r: NonNullable<ReturnType<typeof planFor>>): string {
|
|
|
119
120
|
|
|
120
121
|
// Tells the orchestrator that started a task its plan is written, once.
|
|
121
122
|
function announcePlan(s: Session, ref: string | null, retried = false): void {
|
|
122
|
-
if (!s.started_by && !s.held_by_device) return;
|
|
123
|
+
if (!orchestratorOf(s.started_by) && !s.held_by_device) return;
|
|
123
124
|
const workspaceId = sessionWorkspace(s);
|
|
124
125
|
// A task held for another machine's orchestrator can't reach it: the line
|
|
125
126
|
// waits on the task for that orchestrator to read (orchestrator/facts).
|
|
@@ -507,12 +508,21 @@ export async function recheckPlanning(
|
|
|
507
508
|
);
|
|
508
509
|
}
|
|
509
510
|
|
|
511
|
+
export type ApprovedBy = "saad" | "orchestrator" | "no-approvals";
|
|
512
|
+
|
|
513
|
+
const APPROVAL_TEXT: Record<ApprovedBy, string> = {
|
|
514
|
+
saad: "Saad approved the plan; implement it as planned.",
|
|
515
|
+
orchestrator: "The orchestrator approved the plan; implement it as planned.",
|
|
516
|
+
"no-approvals":
|
|
517
|
+
"The plan was approved automatically (no-approvals mode); implement it as planned.",
|
|
518
|
+
};
|
|
519
|
+
|
|
510
520
|
// approvePlan, sending through the chat runner (imported when needed: it
|
|
511
521
|
// imports this file).
|
|
512
522
|
export async function approvePlanNow(
|
|
513
523
|
sessionId: string,
|
|
514
524
|
message?: string,
|
|
515
|
-
by:
|
|
525
|
+
by: ApprovedBy = "saad"
|
|
516
526
|
) {
|
|
517
527
|
const { sendQueuedNow } = await import("../chat/runner");
|
|
518
528
|
return approvePlan(sessionId, message, (id, m) => sendQueuedNow(id, m), by);
|
|
@@ -526,7 +536,7 @@ export async function approvePlan(
|
|
|
526
536
|
sessionId: string,
|
|
527
537
|
message: string | undefined,
|
|
528
538
|
send: Send,
|
|
529
|
-
by:
|
|
539
|
+
by: ApprovedBy = "saad"
|
|
530
540
|
): Promise<{ ref: string; amendments: number }> {
|
|
531
541
|
const s = getSession(sessionId);
|
|
532
542
|
if (!s || s.plan_phase !== "awaiting" || s.task_status !== "running")
|
|
@@ -535,9 +545,16 @@ export async function approvePlan(
|
|
|
535
545
|
);
|
|
536
546
|
const text = message?.trim() || undefined;
|
|
537
547
|
const workspaceId = sessionWorkspace(s);
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
548
|
+
// Every approval is on the brief, so the review judges the PR as the
|
|
549
|
+
// implementation of the plan, not as plan-only work.
|
|
550
|
+
if (workspaceId) {
|
|
551
|
+
if (text) {
|
|
552
|
+
amendBrief(workspaceId, s.id, text);
|
|
553
|
+
addNote(
|
|
554
|
+
workspaceId,
|
|
555
|
+
`Plan approved with a change for ${s.name}: ${text}`
|
|
556
|
+
);
|
|
557
|
+
} else amendBrief(workspaceId, s.id, APPROVAL_TEXT[by], "approval");
|
|
541
558
|
}
|
|
542
559
|
const ref = await startImplementing(s, "awaiting", send, text);
|
|
543
560
|
if (!ref) throw new Error(`${s.name} isn't waiting for plan approval`);
|
|
@@ -549,5 +566,5 @@ export async function approvePlan(
|
|
|
549
566
|
subject: `plan for ${s.name}`,
|
|
550
567
|
detail: "already waiting when the mode was turned on",
|
|
551
568
|
});
|
|
552
|
-
return { ref, amendments:
|
|
569
|
+
return { ref, amendments: scopeChangesOf(s.id).length };
|
|
553
570
|
}
|
package/lib/models/resolve.ts
CHANGED
package/lib/models/resume.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
taskOverride,
|
|
19
19
|
type ModelOverride,
|
|
20
20
|
} from "./resolve";
|
|
21
|
-
import { sessionRole } from "./
|
|
21
|
+
import { sessionRole } from "./session-role";
|
|
22
22
|
|
|
23
23
|
export const RESUME_MESSAGE =
|
|
24
24
|
"You were paused. Carry on where you left off: check the worktree and the conversation above for where things stand, then finish the task.";
|
package/lib/models/roles.ts
CHANGED
|
@@ -85,19 +85,3 @@ export function parseModelRef(ref: string): ModelRef {
|
|
|
85
85
|
export const formatModelRef = (r: ModelRef) => `${r.provider}/${r.model}`;
|
|
86
86
|
|
|
87
87
|
export const REF_PATTERN = /^[a-z0-9][a-z0-9_-]*\/[\w.:@/-]+$/i;
|
|
88
|
-
|
|
89
|
-
// The role a session's next turn runs as.
|
|
90
|
-
export function sessionRole(session: {
|
|
91
|
-
role?: string | null;
|
|
92
|
-
task_status?: string | null;
|
|
93
|
-
plan_phase?: string | null;
|
|
94
|
-
started_by?: string | null;
|
|
95
|
-
}): ModelRole {
|
|
96
|
-
if (session.role === "orchestrator") return "orchestrator";
|
|
97
|
-
// Work an orchestrator started is implementation, not a person's session.
|
|
98
|
-
if (!session.task_status)
|
|
99
|
-
return session.started_by ? "implement" : "interactive";
|
|
100
|
-
return session.plan_phase === "planning" || session.plan_phase === "awaiting"
|
|
101
|
-
? "plan"
|
|
102
|
-
: "implement";
|
|
103
|
-
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { orchestratorOf } from "../tasks/starter";
|
|
2
|
+
import type { ModelRole } from "./roles";
|
|
3
|
+
|
|
4
|
+
// Server-only: it reads the database, so the client-safe roles module can't hold it.
|
|
5
|
+
// The role a session's next turn runs as.
|
|
6
|
+
export function sessionRole(session: {
|
|
7
|
+
role?: string | null;
|
|
8
|
+
task_status?: string | null;
|
|
9
|
+
plan_phase?: string | null;
|
|
10
|
+
started_by?: string | null;
|
|
11
|
+
}): ModelRole {
|
|
12
|
+
if (session.role === "orchestrator") return "orchestrator";
|
|
13
|
+
// Work an orchestrator started is implementation, not a person's session.
|
|
14
|
+
if (!session.task_status)
|
|
15
|
+
return orchestratorOf(session.started_by) ? "implement" : "interactive";
|
|
16
|
+
return session.plan_phase === "planning" || session.plan_phase === "awaiting"
|
|
17
|
+
? "plan"
|
|
18
|
+
: "implement";
|
|
19
|
+
}
|
package/lib/orchestrator/act.ts
CHANGED
|
@@ -35,10 +35,11 @@ import { treeOrder } from "../stacks/tree";
|
|
|
35
35
|
import type { StackItemView } from "../stacks/types";
|
|
36
36
|
import { doneSession } from "../done";
|
|
37
37
|
import { archiveChecked } from "../done/archive";
|
|
38
|
-
import { amendBrief,
|
|
38
|
+
import { amendBrief, scopeChangesOf } from "./amendments";
|
|
39
39
|
import { db, type Session } from "../db";
|
|
40
40
|
import { resurrectSession } from "../resurrect";
|
|
41
41
|
import { resolveEnded } from "../resurrect/find";
|
|
42
|
+
import { findPiece, pieceLabel, workspacePieces } from "../plans/claim";
|
|
42
43
|
import { approvePlanNow } from "../models/plan-step";
|
|
43
44
|
import { braked } from "./brakes";
|
|
44
45
|
import { getOrchestrator } from "./home";
|
|
@@ -96,7 +97,7 @@ export async function send(
|
|
|
96
97
|
amendBrief(workspaceId, to.id, message);
|
|
97
98
|
// In the decision log, so Saad sees every change of scope it records.
|
|
98
99
|
addNote(workspaceId, `Scope change for ${to.name}: ${message}`);
|
|
99
|
-
amended = ` Recorded as scope change ${
|
|
100
|
+
amended = ` Recorded as scope change ${scopeChangesOf(to.id).length} on its brief: reviews from now on judge against it (call review with fresh to judge the current head again).`;
|
|
100
101
|
}
|
|
101
102
|
const { delivery } = await sendMessage({
|
|
102
103
|
fromId: orchestratorId(workspaceId),
|
|
@@ -127,7 +128,7 @@ async function sendToQueued(
|
|
|
127
128
|
if (opts.scopeChange) {
|
|
128
129
|
amendBrief(workspaceId, queued.id, message);
|
|
129
130
|
addNote(workspaceId, `Scope change for ${queued.name}: ${message}`);
|
|
130
|
-
amended = ` Recorded as scope change ${
|
|
131
|
+
amended = ` Recorded as scope change ${scopeChangesOf(queued.id).length} on its brief: reviews judge against it once it has a PR.`;
|
|
131
132
|
}
|
|
132
133
|
const sent = await sendMessage({
|
|
133
134
|
fromId: orchestratorId(workspaceId),
|
|
@@ -161,6 +162,7 @@ export async function startTask(
|
|
|
161
162
|
machine?: "mac" | "any";
|
|
162
163
|
now?: boolean;
|
|
163
164
|
nowReason?: string;
|
|
165
|
+
piece?: string;
|
|
164
166
|
} = {}
|
|
165
167
|
): Promise<string> {
|
|
166
168
|
if (opts.planOnly && opts.plan === false)
|
|
@@ -170,6 +172,9 @@ export async function startTask(
|
|
|
170
172
|
const project = workspaceProject(workspaceId, projectRef);
|
|
171
173
|
const model = parseOverride(opts.model) ?? undefined;
|
|
172
174
|
const startedBy = orchestratorId(workspaceId);
|
|
175
|
+
const pieceId = opts.piece
|
|
176
|
+
? findPiece(workspacePieces(workspaceId, project.id), opts.piece).id
|
|
177
|
+
: undefined;
|
|
173
178
|
// Its brakes read this machine's usage, not the other machine's account.
|
|
174
179
|
if (isRemoteHost(project.host_id))
|
|
175
180
|
throw new Error(
|
|
@@ -190,6 +195,9 @@ export async function startTask(
|
|
|
190
195
|
planOnly: opts.planOnly,
|
|
191
196
|
planReason: opts.planReason,
|
|
192
197
|
startedBy,
|
|
198
|
+
parentSessionId: startedBy,
|
|
199
|
+
startedVia: "orchestrator",
|
|
200
|
+
pieceId,
|
|
193
201
|
baseBranch: opts.base || undefined,
|
|
194
202
|
after: opts.after,
|
|
195
203
|
originWorkspaceId: workspaceId,
|
|
@@ -213,13 +221,17 @@ export async function startTask(
|
|
|
213
221
|
planOnly: opts.planOnly,
|
|
214
222
|
planReason: opts.planReason,
|
|
215
223
|
startedBy,
|
|
224
|
+
parentSessionId: startedBy,
|
|
225
|
+
startedVia: "orchestrator",
|
|
226
|
+
pieceId,
|
|
216
227
|
}),
|
|
217
228
|
(s) => s.id
|
|
218
229
|
)
|
|
219
230
|
);
|
|
220
231
|
if ("queued" in out) {
|
|
221
232
|
const { queued } = out;
|
|
222
|
-
|
|
233
|
+
const builds = pieceId ? ` It builds ${pieceLabel(pieceId)}.` : "";
|
|
234
|
+
return `Queued (position ${queued.position}) task "${queued.name}" (id ${queued.id.slice(0, 8)}) in ${project.name}${queued.after ? `, after ${queued.after}` : ""}.${builds} It starts by itself; you'll get an event when it does.`;
|
|
223
235
|
}
|
|
224
236
|
const task = out.started;
|
|
225
237
|
if (opts.priority) stampPriority(task.id, opts.priority);
|
|
@@ -236,7 +248,7 @@ export async function startTask(
|
|
|
236
248
|
opts.plan && !opts.planOnly && task.plan_phase !== "planning"
|
|
237
249
|
? ` Plan step skipped: ${view === "terminal" ? "a terminal task has none" : "planning and implementation use the same model"}.`
|
|
238
250
|
: "";
|
|
239
|
-
return `Started task "${task.name}" (id ${task.id.slice(0, 8)}, ${view}) in ${project.name} on ${task.branch_name} (from ${task.base_branch}). It ends in a PR; you'll get an event when it opens.${skipped}${planOnlyNote}${past ? " Started past the running-task limit." : ""}`;
|
|
251
|
+
return `Started task "${task.name}" (id ${task.id.slice(0, 8)}, ${view}) in ${project.name} on ${task.branch_name} (from ${task.base_branch}). It ends in a PR; you'll get an event when it opens.${pieceId ? ` It builds ${pieceLabel(pieceId)}.` : ""}${skipped}${planOnlyNote}${past ? " Started past the running-task limit." : ""}`;
|
|
240
252
|
}
|
|
241
253
|
|
|
242
254
|
// Starts a queued task now, past the running-task limit and its `after`.
|
|
@@ -276,10 +288,10 @@ export async function approveTaskPlan(
|
|
|
276
288
|
task: Session,
|
|
277
289
|
message?: string
|
|
278
290
|
): Promise<string> {
|
|
279
|
-
const out = await approvePlanNow(task.id, message);
|
|
291
|
+
const out = await approvePlanNow(task.id, message, "orchestrator");
|
|
280
292
|
const amended = message
|
|
281
293
|
? ` Recorded as scope change ${out.amendments} on its brief: its review judges against it.`
|
|
282
|
-
: "";
|
|
294
|
+
: " Recorded the approval on its brief.";
|
|
283
295
|
return `Approved the plan of ${task.name}: implementing on ${out.ref}.${amended}`;
|
|
284
296
|
}
|
|
285
297
|
|
|
@@ -312,6 +324,8 @@ export async function startSession(
|
|
|
312
324
|
access: "full",
|
|
313
325
|
model,
|
|
314
326
|
startedBy,
|
|
327
|
+
parentSessionId: startedBy,
|
|
328
|
+
startedVia: "orchestrator",
|
|
315
329
|
})
|
|
316
330
|
).session
|
|
317
331
|
: spawnSession({
|
|
@@ -320,6 +334,7 @@ export async function startSession(
|
|
|
320
334
|
name: opts.name,
|
|
321
335
|
model,
|
|
322
336
|
startedBy,
|
|
337
|
+
parentSessionId: startedBy,
|
|
323
338
|
}),
|
|
324
339
|
(s) => s.id
|
|
325
340
|
);
|
|
@@ -10,19 +10,22 @@ export interface Amendment {
|
|
|
10
10
|
session_id: string;
|
|
11
11
|
workspace_id: string;
|
|
12
12
|
text: string;
|
|
13
|
+
// scope: a change to what the task builds; approval: a plan approved as written.
|
|
14
|
+
kind: "scope" | "approval";
|
|
13
15
|
created_at: string;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export function amendBrief(
|
|
17
19
|
workspaceId: string,
|
|
18
20
|
taskId: string,
|
|
19
|
-
text: string
|
|
21
|
+
text: string,
|
|
22
|
+
kind: Amendment["kind"] = "scope"
|
|
20
23
|
): Amendment {
|
|
21
24
|
const { lastInsertRowid } = db
|
|
22
25
|
.prepare(
|
|
23
|
-
`INSERT INTO brief_amendments (session_id, workspace_id, text) VALUES (?, ?, ?)`
|
|
26
|
+
`INSERT INTO brief_amendments (session_id, workspace_id, text, kind) VALUES (?, ?, ?, ?)`
|
|
24
27
|
)
|
|
25
|
-
.run(taskId, workspaceId, text.trim());
|
|
28
|
+
.run(taskId, workspaceId, text.trim(), kind);
|
|
26
29
|
return db
|
|
27
30
|
.prepare(`SELECT * FROM brief_amendments WHERE id = ?`)
|
|
28
31
|
.get(Number(lastInsertRowid)) as Amendment;
|
|
@@ -34,3 +37,7 @@ export function amendmentsOf(taskId: string): Amendment[] {
|
|
|
34
37
|
.prepare(`SELECT * FROM brief_amendments WHERE session_id = ? ORDER BY id`)
|
|
35
38
|
.all(taskId) as Amendment[];
|
|
36
39
|
}
|
|
40
|
+
|
|
41
|
+
// Only the changes of scope: an approval as written changes nothing.
|
|
42
|
+
export const scopeChangesOf = (taskId: string): Amendment[] =>
|
|
43
|
+
amendmentsOf(taskId).filter((a) => a.kind === "scope");
|
|
@@ -76,3 +76,11 @@ export function seedWorkspace(name = `ws-${randomUUID().slice(0, 6)}`) {
|
|
|
76
76
|
});
|
|
77
77
|
return { workspace, app, api, chat, task };
|
|
78
78
|
}
|
|
79
|
+
|
|
80
|
+
// An orchestrator session with this id, for rows that name it as started_by.
|
|
81
|
+
export function seedOrchestrator(id: string): void {
|
|
82
|
+
db.prepare(
|
|
83
|
+
`INSERT OR IGNORE INTO sessions (id, name, tmux_name, working_directory, role)
|
|
84
|
+
VALUES (?, ?, ?, '/tmp', 'orchestrator')`
|
|
85
|
+
).run(id, `orchestrator ${id}`, `claude-${id}`);
|
|
86
|
+
}
|
|
@@ -141,6 +141,15 @@ export const TOOL_SHAPES = {
|
|
|
141
141
|
.max(300)
|
|
142
142
|
.optional()
|
|
143
143
|
.describe("With now: why it can't wait for a slot; logged for Saad"),
|
|
144
|
+
piece: z
|
|
145
|
+
.string()
|
|
146
|
+
.trim()
|
|
147
|
+
.min(1)
|
|
148
|
+
.max(120)
|
|
149
|
+
.optional()
|
|
150
|
+
.describe(
|
|
151
|
+
"The waiting plan piece this task builds (its name or number, or feature/piece): it is claimed so AgentOS won't start it again"
|
|
152
|
+
),
|
|
144
153
|
model: modelOverride,
|
|
145
154
|
plan: z
|
|
146
155
|
.boolean()
|
|
@@ -62,7 +62,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
62
62
|
message_orchestrator:
|
|
63
63
|
"Message another workspace's orchestrator, by workspace name; never any other session there. It arrives as its next prompt, marked as from you and fenced as untrusted.",
|
|
64
64
|
start_task:
|
|
65
|
-
'Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). priority (urgent, blocking, normal, background) says where it stands in the queue; machine "mac" pins work that needs this Mac. A busy machine holds a start and the result says why. With after (a task\'s id or name, or "any"), or over the workspace\'s running task limit, it\'s queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason (plan_only true plans, then stops and waits for approve_plan instead of implementing): plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. now true (with now_reason) starts it at once past the running-task limit instead of queueing for a slot, logged; not with after. Refused while a brake holds.',
|
|
65
|
+
'Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). priority (urgent, blocking, normal, background) says where it stands in the queue; machine "mac" pins work that needs this Mac. A busy machine holds a start and the result says why. With after (a task\'s id or name, or "any"), or over the workspace\'s running task limit, it\'s queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason (plan_only true plans, then stops and waits for approve_plan instead of implementing): plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. now true (with now_reason) starts it at once past the running-task limit instead of queueing for a slot, logged; not with after. piece names the waiting piece of a plan this task builds, so it is claimed and not started a second time. Refused while a brake holds.',
|
|
66
66
|
set_task_model:
|
|
67
67
|
'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
|
|
68
68
|
start_session:
|