@saadnvd1/agent-os 0.3.597 → 0.3.600
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/SessionRow.tsx +2 -1
- 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/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/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:
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// A task that builds a waiting piece of a plan claims it, so the release
|
|
2
|
+
// sweep never starts that piece a second time.
|
|
3
|
+
|
|
4
|
+
import { db } from "../db";
|
|
5
|
+
import { getProject } from "../projects";
|
|
6
|
+
import type { FeaturePiece } from "./features";
|
|
7
|
+
|
|
8
|
+
export interface WaitingPiece extends FeaturePiece {
|
|
9
|
+
feature_name: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const COLUMNS = `fp.*, f.name AS feature_name`;
|
|
13
|
+
|
|
14
|
+
// Waiting pieces in `projectId` of the plans the caller belongs to: the plan
|
|
15
|
+
// it wrote, or the one whose piece it is building.
|
|
16
|
+
export function waitingPiecesFor(
|
|
17
|
+
callerId: string,
|
|
18
|
+
projectId: string
|
|
19
|
+
): WaitingPiece[] {
|
|
20
|
+
return db
|
|
21
|
+
.prepare(
|
|
22
|
+
`SELECT ${COLUMNS} FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id
|
|
23
|
+
WHERE f.status = 'building' AND fp.status = 'waiting' AND fp.project_id = ?
|
|
24
|
+
AND (f.plan_session_id = ?
|
|
25
|
+
OR EXISTS (SELECT 1 FROM feature_pieces o
|
|
26
|
+
WHERE o.feature_id = f.id AND o.session_id = ?))
|
|
27
|
+
ORDER BY f.created_at, fp.position`
|
|
28
|
+
)
|
|
29
|
+
.all(projectId, callerId, callerId) as WaitingPiece[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The orchestrator's view: waiting pieces of its workspace's plans.
|
|
33
|
+
export function workspacePieces(
|
|
34
|
+
workspaceId: string,
|
|
35
|
+
projectId: string
|
|
36
|
+
): WaitingPiece[] {
|
|
37
|
+
return db
|
|
38
|
+
.prepare(
|
|
39
|
+
`SELECT ${COLUMNS} FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id
|
|
40
|
+
WHERE f.status = 'building' AND fp.status = 'waiting' AND fp.project_id = ?
|
|
41
|
+
AND f.workspace_id = ?
|
|
42
|
+
ORDER BY f.created_at, fp.position`
|
|
43
|
+
)
|
|
44
|
+
.all(projectId, workspaceId) as WaitingPiece[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const listPieces = (waiting: WaitingPiece[]) =>
|
|
48
|
+
waiting.map((p) => `${p.position} ${p.name}`).join(", ");
|
|
49
|
+
|
|
50
|
+
// `name`, `position`, or `feature/name`; an error that lists what's waiting
|
|
51
|
+
// when nothing, or more than one, matches.
|
|
52
|
+
export function findPiece(waiting: WaitingPiece[], ref: string): WaitingPiece {
|
|
53
|
+
const want = ref.trim().toLowerCase();
|
|
54
|
+
const slash = want.indexOf("/");
|
|
55
|
+
const only = slash > 0 ? want.slice(0, slash) : null;
|
|
56
|
+
const piece = slash > 0 ? want.slice(slash + 1) : want;
|
|
57
|
+
const hits = waiting.filter(
|
|
58
|
+
(p) =>
|
|
59
|
+
(!only || p.feature_name.toLowerCase() === only) &&
|
|
60
|
+
(p.name.toLowerCase() === piece || String(p.position) === piece)
|
|
61
|
+
);
|
|
62
|
+
if (hits.length === 1) return hits[0];
|
|
63
|
+
const names = waiting
|
|
64
|
+
.map((p) =>
|
|
65
|
+
waiting.some((o) => o.feature_id !== p.feature_id)
|
|
66
|
+
? `${p.feature_name}/${p.name}`
|
|
67
|
+
: `${p.position} ${p.name}`
|
|
68
|
+
)
|
|
69
|
+
.join(", ");
|
|
70
|
+
if (hits.length > 1)
|
|
71
|
+
throw new Error(
|
|
72
|
+
`"${ref}" matches more than one waiting piece: ${names}. Name it as feature/piece.`
|
|
73
|
+
);
|
|
74
|
+
throw new Error(
|
|
75
|
+
waiting.length
|
|
76
|
+
? `No waiting piece "${ref}". Waiting pieces: ${names}.`
|
|
77
|
+
: `No waiting piece "${ref}": nothing in your plan is waiting in this project.`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function pieceRefusal(
|
|
82
|
+
waiting: WaitingPiece[],
|
|
83
|
+
projectId: string
|
|
84
|
+
): string {
|
|
85
|
+
const where = getProject(projectId)?.name ?? "this project";
|
|
86
|
+
return `Your plan "${waiting[0].feature_name}" has pieces waiting in ${where}: ${listPieces(waiting)}. Name the one this task builds with --piece <name>; AgentOS starts the others itself when they are due.`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function pieceStatus(id: number): string | undefined {
|
|
90
|
+
return (
|
|
91
|
+
db.prepare(`SELECT status FROM feature_pieces WHERE id = ?`).get(id) as
|
|
92
|
+
| { status: string }
|
|
93
|
+
| undefined
|
|
94
|
+
)?.status;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function claimable(id: number): void {
|
|
98
|
+
const status = pieceStatus(id);
|
|
99
|
+
if (status !== "waiting")
|
|
100
|
+
throw new Error(`That piece is ${status ?? "gone"}, not waiting`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Synchronous, so nothing runs between the check and the write.
|
|
104
|
+
export function claimPiece(id: number, sessionId: string): void {
|
|
105
|
+
const changed = db
|
|
106
|
+
.prepare(
|
|
107
|
+
`UPDATE feature_pieces SET status = 'building', session_id = ?, note = NULL
|
|
108
|
+
WHERE id = ? AND status = 'waiting'`
|
|
109
|
+
)
|
|
110
|
+
.run(sessionId, id).changes;
|
|
111
|
+
if (!changed)
|
|
112
|
+
throw new Error(`That piece is ${pieceStatus(id) ?? "gone"}, not waiting`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Gives a claim back when the session it was made for never came to be.
|
|
116
|
+
export function releaseClaim(id: number, sessionId: string): void {
|
|
117
|
+
db.prepare(
|
|
118
|
+
`UPDATE feature_pieces SET status = 'waiting', session_id = NULL
|
|
119
|
+
WHERE id = ? AND session_id = ? AND status = 'building'`
|
|
120
|
+
).run(id, sessionId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function pieceLabel(id: number): string {
|
|
124
|
+
const r = db
|
|
125
|
+
.prepare(
|
|
126
|
+
`SELECT fp.position, fp.name, f.name AS feature FROM feature_pieces fp
|
|
127
|
+
JOIN features f ON f.id = fp.feature_id WHERE fp.id = ?`
|
|
128
|
+
)
|
|
129
|
+
.get(id) as { position: number; name: string; feature: string } | undefined;
|
|
130
|
+
return r ? `piece ${r.position} ${r.name} of "${r.feature}"` : "a piece";
|
|
131
|
+
}
|
package/lib/plans/release.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { createTask } from "../tasks";
|
|
|
9
9
|
import { startOrQueue } from "../tasks/queue";
|
|
10
10
|
import { braked, BrakeRefused } from "../orchestrator/brakes";
|
|
11
11
|
import { queueEvent, queueEventOnce } from "../orchestrator/events";
|
|
12
|
-
import { amendBrief,
|
|
12
|
+
import { amendBrief, scopeChangesOf } from "../orchestrator/amendments";
|
|
13
13
|
import { usageRows } from "../session-logs/gather";
|
|
14
14
|
import { pieceBrief } from "./pieces";
|
|
15
15
|
import {
|
|
@@ -122,6 +122,9 @@ export const realDeps: ReleaseDeps = {
|
|
|
122
122
|
// A retry under the same id clears what a failed attempt left behind.
|
|
123
123
|
reclaim: true,
|
|
124
124
|
startedBy: plan?.started_by ?? null,
|
|
125
|
+
parentSessionId: feature.plan_session_id,
|
|
126
|
+
startedVia: "plan" as const,
|
|
127
|
+
startedByDevice: plan?.started_by_device ?? null,
|
|
125
128
|
originWorkspaceId: feature.workspace_id,
|
|
126
129
|
};
|
|
127
130
|
// A queued task keeps its queue id as its session id once it starts.
|
|
@@ -173,7 +176,7 @@ function creditParentMerge(feature: Feature, all: FeaturePiece[]): void {
|
|
|
173
176
|
)
|
|
174
177
|
)
|
|
175
178
|
return;
|
|
176
|
-
if (
|
|
179
|
+
if (scopeChangesOf(parent.id).length) return;
|
|
177
180
|
const covered = all.filter(
|
|
178
181
|
(p) => p.status === "waiting" && p.project_id === parent.project_id
|
|
179
182
|
);
|
package/lib/recipes/run.ts
CHANGED
|
@@ -152,6 +152,8 @@ export async function startRun(
|
|
|
152
152
|
access: "full",
|
|
153
153
|
model: typeof model === "string" ? model : null,
|
|
154
154
|
startedBy,
|
|
155
|
+
parentSessionId: startedBy,
|
|
156
|
+
startedVia: byOrchestrator ? "orchestrator" : "recipe",
|
|
155
157
|
}).then((l) => ({ ...l, id: l.session.id }))
|
|
156
158
|
);
|
|
157
159
|
settleStart(run.id, "running", { sessionId: session.id });
|
|
@@ -167,6 +169,8 @@ export async function startRun(
|
|
|
167
169
|
plan: false,
|
|
168
170
|
planReason: "recipe run",
|
|
169
171
|
startedBy,
|
|
172
|
+
parentSessionId: startedBy,
|
|
173
|
+
startedVia: byOrchestrator ? "orchestrator" : "recipe",
|
|
170
174
|
originWorkspaceId: byOrchestrator ? ws : undefined,
|
|
171
175
|
},
|
|
172
176
|
() =>
|
|
@@ -181,6 +185,8 @@ export async function startRun(
|
|
|
181
185
|
plan: false,
|
|
182
186
|
planReason: "recipe run",
|
|
183
187
|
startedBy,
|
|
188
|
+
parentSessionId: startedBy,
|
|
189
|
+
startedVia: byOrchestrator ? "orchestrator" : "recipe",
|
|
184
190
|
})
|
|
185
191
|
)
|
|
186
192
|
);
|
package/lib/schedules/start.ts
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// A fork continues its parent's conversation: it has a parent but wasn't
|
|
2
|
+
// started by anyone and isn't a task. A task or session started from another
|
|
3
|
+
// session also records that parent, and is not a fork.
|
|
4
|
+
export const isFork = (s: {
|
|
5
|
+
parent_session_id?: string | null;
|
|
6
|
+
started_by?: string | null;
|
|
7
|
+
started_via?: string | null;
|
|
8
|
+
task_prompt?: string | null;
|
|
9
|
+
}): boolean =>
|
|
10
|
+
!!s.parent_session_id && !s.started_by && !s.started_via && !s.task_prompt;
|
package/lib/sessions/launch.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* then (for a worktree) the setup in the background, then the first message.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { StartVia } from "../tasks/starter";
|
|
6
7
|
import fs from "fs";
|
|
7
8
|
import os from "os";
|
|
8
9
|
import path from "path";
|
|
@@ -74,6 +75,7 @@ export interface LaunchInput {
|
|
|
74
75
|
// The orchestrator session that starts it: it runs on the implementation
|
|
75
76
|
// role's model (or `model`), not the interactive one's.
|
|
76
77
|
startedBy?: string | null;
|
|
78
|
+
startedVia?: StartVia;
|
|
77
79
|
groupPath?: string;
|
|
78
80
|
systemPrompt?: string | null;
|
|
79
81
|
}
|
|
@@ -277,12 +279,14 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
277
279
|
runOn
|
|
278
280
|
);
|
|
279
281
|
db.prepare(
|
|
280
|
-
`UPDATE sessions SET view = ?, name_source = ?, setup_status = ?, started_by =
|
|
282
|
+
`UPDATE sessions SET view = ?, name_source = ?, setup_status = ?, started_by = ?,
|
|
283
|
+
started_via = ? WHERE id = ?`
|
|
281
284
|
).run(
|
|
282
285
|
view,
|
|
283
286
|
naming.source,
|
|
284
287
|
useWorktree ? "running" : null,
|
|
285
288
|
input.startedBy ?? null,
|
|
289
|
+
input.startedVia ?? (input.startedBy ? "orchestrator" : null),
|
|
286
290
|
id
|
|
287
291
|
);
|
|
288
292
|
if (interactive) setSessionModel(id, interactive);
|
package/lib/stacks/start.ts
CHANGED
package/lib/tasks/held-remote.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import type { Delivery } from "../bus/delivery";
|
|
17
17
|
import { wakeLine } from "../bus/format";
|
|
18
18
|
import { orchestratedStart } from "./held";
|
|
19
|
+
import { orchestratorOf } from "./starter";
|
|
19
20
|
|
|
20
21
|
const path = (id: string, leaf: string) =>
|
|
21
22
|
`/api/tasks/${encodeURIComponent(id)}/${leaf}`;
|
|
@@ -23,11 +24,12 @@ const path = (id: string, leaf: string) =>
|
|
|
23
24
|
/**
|
|
24
25
|
* A task its orchestrator put on a linked machine, whether by moving it or
|
|
25
26
|
* by the queue placing it there: the one test every relayed call goes by.
|
|
26
|
-
* (held-replies.ts reads the same rule in SQL: started_by
|
|
27
|
+
* (held-replies.ts reads the same rule in SQL: started_by is an orchestrator.)
|
|
27
28
|
*/
|
|
28
29
|
export const isHeldRemote = (
|
|
29
30
|
s: Pick<Session, "started_by" | "task_prompt" | "host_id">
|
|
30
|
-
): boolean =>
|
|
31
|
+
): boolean =>
|
|
32
|
+
!!s.task_prompt && !!hostLink(s.host_id) && !!orchestratorOf(s.started_by);
|
|
31
33
|
|
|
32
34
|
type Held = Pick<
|
|
33
35
|
Session,
|
|
@@ -27,18 +27,21 @@ interface Pulled {
|
|
|
27
27
|
body: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// Held means an orchestrator started it, as isHeldRemote reads it.
|
|
31
|
+
const HELD_BY = `started_by IN (SELECT id FROM sessions WHERE role = 'orchestrator')`;
|
|
32
|
+
|
|
30
33
|
const heldMirror = (hostId: string, id: string) =>
|
|
31
34
|
db
|
|
32
35
|
.prepare(
|
|
33
36
|
`SELECT * FROM sessions WHERE id = ? AND host_id = ?
|
|
34
|
-
AND
|
|
37
|
+
AND ${HELD_BY} AND task_status IN ('running', 'moving')`
|
|
35
38
|
)
|
|
36
39
|
.get(id, hostId) as Session | undefined;
|
|
37
40
|
|
|
38
41
|
export function hasHeldMirrors(hostId: string): boolean {
|
|
39
42
|
return !!db
|
|
40
43
|
.prepare(
|
|
41
|
-
`SELECT 1 FROM sessions WHERE host_id = ? AND
|
|
44
|
+
`SELECT 1 FROM sessions WHERE host_id = ? AND ${HELD_BY}
|
|
42
45
|
AND task_prompt IS NOT NULL AND task_status IN ('running', 'moving')`
|
|
43
46
|
)
|
|
44
47
|
.get(hostId);
|
package/lib/tasks/held.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* a green CI, past the gates its orchestrator judges it by.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { orchestratorOf } from "./starter";
|
|
8
9
|
import os from "os";
|
|
9
10
|
import { db, type Session } from "../db";
|
|
10
11
|
import type { OrchestratedBundle } from "./move-bundle";
|
|
@@ -102,7 +103,7 @@ type Holdable = Pick<
|
|
|
102
103
|
export function mayHold(s: Holdable, device: string): boolean {
|
|
103
104
|
if (!s.task_prompt || s.task_status !== "running") return false;
|
|
104
105
|
if (s.host_id && s.host_id !== "local") return false;
|
|
105
|
-
if (s.started_by || s.held_by_device) return false;
|
|
106
|
+
if (orchestratorOf(s.started_by) || s.held_by_device) return false;
|
|
106
107
|
return (
|
|
107
108
|
s.started_by_device === device || s.started_by_device === UNRECORDED_STARTER
|
|
108
109
|
);
|