@saadnvd1/agent-os 0.3.518 → 0.3.520
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 -1
- package/app/api/bus/peers/route.ts +8 -2
- package/app/api/bus/spawn/route.ts +4 -0
- package/app/api/tasks/[id]/approve-plan/route.ts +22 -0
- package/app/api/tasks/[id]/chat-facts/route.ts +21 -0
- package/app/api/tasks/[id]/moved/route.ts +7 -0
- package/app/api/tasks/held-replies/route.ts +31 -0
- package/app/api/tasks/route.ts +2 -0
- package/components/Tasks/TaskRow.tsx +46 -1
- package/components/Tasks/task-state-labels.ts +1 -0
- package/data/tasks/index.ts +12 -0
- package/lib/bus/held-reply.ts +89 -0
- package/lib/bus/index.ts +86 -10
- package/lib/db/migrations.ts +40 -0
- package/lib/db/types.ts +5 -1
- package/lib/done/worktree.ts +24 -10
- package/lib/hosts/remote-api.ts +12 -0
- package/lib/lumifyhub/lists.ts +1 -0
- package/lib/models/plan-step.ts +116 -32
- package/lib/models/roles.ts +3 -1
- package/lib/orchestrator/act.ts +24 -2
- package/lib/orchestrator/brief.ts +3 -1
- package/lib/orchestrator/conditions.ts +1 -0
- package/lib/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +11 -2
- package/lib/orchestrator/serve.ts +8 -0
- package/lib/orchestrator/task-state.ts +40 -15
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +18 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/security/demo.ts +1 -0
- package/lib/status-detector.ts +4 -0
- package/lib/tasks/held-remote.ts +31 -2
- package/lib/tasks/held-replies.ts +106 -0
- package/lib/tasks/import.ts +5 -3
- package/lib/tasks/index.ts +28 -7
- package/lib/tasks/move-bundle.ts +19 -2
- package/lib/tasks/move-flow.ts +21 -2
- package/lib/tasks/move-guard.ts +2 -0
- package/lib/tasks/move.ts +20 -1
- package/lib/tasks/moved-away.ts +37 -0
- package/lib/tasks/plan-view.ts +6 -1
- package/lib/tasks/queue.ts +6 -2
- package/lib/tasks/remote.ts +3 -0
- package/lib/tasks/start.ts +1 -1
- package/lib/tasks/state.ts +7 -1
- package/package.json +1 -1
package/lib/status-detector.ts
CHANGED
|
@@ -537,6 +537,10 @@ class SessionStatusDetector {
|
|
|
537
537
|
if (this.peersAsked.has(hostId)) return;
|
|
538
538
|
this.peersAsked.add(hostId);
|
|
539
539
|
this.hostListedAt.set(hostId, Date.now());
|
|
540
|
+
// What its held tasks have said to their orchestrators here.
|
|
541
|
+
void import("./tasks/held-replies")
|
|
542
|
+
.then((m) => m.pullHeldReplies(link))
|
|
543
|
+
.catch(() => undefined);
|
|
540
544
|
void peerTmuxSessions(link)
|
|
541
545
|
.then(
|
|
542
546
|
(list) => {
|
package/lib/tasks/held-remote.ts
CHANGED
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Session } from "../db";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
cleanRemoteBlock,
|
|
10
|
+
cleanRemoteText,
|
|
11
|
+
hostApi,
|
|
12
|
+
requireHostLink,
|
|
13
|
+
} from "../hosts/remote-api";
|
|
9
14
|
import type { Delivery } from "../bus/delivery";
|
|
10
15
|
|
|
11
16
|
const path = (id: string, leaf: string) =>
|
|
@@ -54,7 +59,31 @@ export async function heldChatTail(
|
|
|
54
59
|
`${path(task.id, "chat-tail")}?lines=${lines}`,
|
|
55
60
|
{ timeout: 10000 }
|
|
56
61
|
).catch(() => ({ text: undefined }));
|
|
57
|
-
|
|
62
|
+
// Whole lines, as a local read has them (read.ts caps and trims them).
|
|
63
|
+
return typeof text === "string" ? cleanRemoteBlock(text, 16000) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What its chat says about being blocked or asking, as that machine reads it
|
|
68
|
+
* (the same reading a local chat task gets). Null when it can't be asked,
|
|
69
|
+
* which callers treat as unread, never as clear.
|
|
70
|
+
*/
|
|
71
|
+
export async function heldChatFacts(
|
|
72
|
+
task: Pick<Session, "id" | "host_id">
|
|
73
|
+
): Promise<{ blocked: string | null; turn: string | null } | null> {
|
|
74
|
+
const link = requireHostLink(task.host_id);
|
|
75
|
+
const res = await hostApi<{ blocked?: unknown; turn?: unknown }>(
|
|
76
|
+
link,
|
|
77
|
+
path(task.id, "chat-facts"),
|
|
78
|
+
{ timeout: 10000 }
|
|
79
|
+
).catch(() => null);
|
|
80
|
+
if (!res || (res.blocked !== null && typeof res.blocked !== "string"))
|
|
81
|
+
return null;
|
|
82
|
+
if (res.turn !== null && typeof res.turn !== "string") return null;
|
|
83
|
+
return {
|
|
84
|
+
blocked: res.blocked ? cleanRemoteBlock(res.blocked, 2000) : null,
|
|
85
|
+
turn: res.turn,
|
|
86
|
+
};
|
|
58
87
|
}
|
|
59
88
|
|
|
60
89
|
/** Stops its agent there; its conversation and worktree are kept. */
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the tasks this machine's orchestrators moved to a linked machine say
|
|
3
|
+
* to those orchestrators. That machine only stores the messages (bus/held-reply);
|
|
4
|
+
* this machine, which polls, pulls them and delivers each as that task's own
|
|
5
|
+
* message to the orchestrator that started it. Who receives it is this
|
|
6
|
+
* machine's own record (started_by), never anything the other machine names.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { db, type Session } from "../db";
|
|
10
|
+
import { sendMessage } from "../bus";
|
|
11
|
+
import { hostApi, type HostLink } from "../hosts/remote-api";
|
|
12
|
+
|
|
13
|
+
const PULL_EVERY_MS = 5000;
|
|
14
|
+
const MISSING_FOR_MS = 10 * 60 * 1000;
|
|
15
|
+
const MAX_BODY = 8000;
|
|
16
|
+
|
|
17
|
+
const g = globalThis as unknown as {
|
|
18
|
+
__agentosRepliesAt?: Map<string, number>;
|
|
19
|
+
__agentosRepliesBusy?: Set<string>;
|
|
20
|
+
};
|
|
21
|
+
const lastAsked = (g.__agentosRepliesAt ??= new Map());
|
|
22
|
+
const busy = (g.__agentosRepliesBusy ??= new Set());
|
|
23
|
+
|
|
24
|
+
interface Pulled {
|
|
25
|
+
id: number;
|
|
26
|
+
fromId: string;
|
|
27
|
+
body: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const heldMirror = (hostId: string, id: string) =>
|
|
31
|
+
db
|
|
32
|
+
.prepare(
|
|
33
|
+
`SELECT * FROM sessions WHERE id = ? AND host_id = ?
|
|
34
|
+
AND started_by IS NOT NULL AND task_status IN ('running', 'moving')`
|
|
35
|
+
)
|
|
36
|
+
.get(id, hostId) as Session | undefined;
|
|
37
|
+
|
|
38
|
+
export function hasHeldMirrors(hostId: string): boolean {
|
|
39
|
+
return !!db
|
|
40
|
+
.prepare(
|
|
41
|
+
`SELECT 1 FROM sessions WHERE host_id = ? AND started_by IS NOT NULL
|
|
42
|
+
AND task_prompt IS NOT NULL AND task_status IN ('running', 'moving')`
|
|
43
|
+
)
|
|
44
|
+
.get(hostId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Delivers what the link's machine holds for this machine's orchestrators. */
|
|
48
|
+
export async function pullHeldReplies(link: HostLink): Promise<number> {
|
|
49
|
+
const hostId = link.hostId;
|
|
50
|
+
if (busy.has(hostId) || !hasHeldMirrors(hostId)) return 0;
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
if (now - (lastAsked.get(hostId) ?? 0) < PULL_EVERY_MS) return 0;
|
|
53
|
+
lastAsked.set(hostId, now);
|
|
54
|
+
busy.add(hostId);
|
|
55
|
+
try {
|
|
56
|
+
let messages: Pulled[];
|
|
57
|
+
try {
|
|
58
|
+
const res = await hostApi<{ messages?: unknown }>(
|
|
59
|
+
link,
|
|
60
|
+
"/api/tasks/held-replies",
|
|
61
|
+
{ timeout: 8000 }
|
|
62
|
+
);
|
|
63
|
+
messages = (Array.isArray(res.messages) ? res.messages : []).filter(
|
|
64
|
+
(m): m is Pulled =>
|
|
65
|
+
!!m &&
|
|
66
|
+
Number.isSafeInteger(m.id) &&
|
|
67
|
+
typeof m.fromId === "string" &&
|
|
68
|
+
typeof m.body === "string"
|
|
69
|
+
);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
// A machine that predates replies: leave it alone for a while.
|
|
72
|
+
if ((err as { status?: number }).status === 404)
|
|
73
|
+
lastAsked.set(hostId, now + MISSING_FOR_MS);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
const handled: number[] = [];
|
|
77
|
+
let delivered = 0;
|
|
78
|
+
for (const m of messages) {
|
|
79
|
+
const mirror = heldMirror(hostId, m.fromId);
|
|
80
|
+
if (!mirror) {
|
|
81
|
+
handled.push(m.id); // nothing here to deliver it from
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
await sendMessage({
|
|
86
|
+
fromId: mirror.id,
|
|
87
|
+
to: mirror.started_by!,
|
|
88
|
+
body: m.body.slice(0, MAX_BODY),
|
|
89
|
+
relayKey: `${hostId}:${m.id}`,
|
|
90
|
+
});
|
|
91
|
+
handled.push(m.id);
|
|
92
|
+
delivered++;
|
|
93
|
+
} catch {
|
|
94
|
+
// Gone, or rate limited: offered again at the next pull.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (handled.length)
|
|
98
|
+
await hostApi(link, "/api/tasks/held-replies", {
|
|
99
|
+
body: { ids: handled },
|
|
100
|
+
timeout: 8000,
|
|
101
|
+
}).catch(() => undefined);
|
|
102
|
+
return delivered;
|
|
103
|
+
} finally {
|
|
104
|
+
busy.delete(hostId);
|
|
105
|
+
}
|
|
106
|
+
}
|
package/lib/tasks/import.ts
CHANGED
|
@@ -174,10 +174,13 @@ async function arrive(
|
|
|
174
174
|
if (holder)
|
|
175
175
|
db.prepare(
|
|
176
176
|
`UPDATE sessions SET held_by_device = ?, held_from = ?,
|
|
177
|
+
held_for = ?, held_for_name = ?,
|
|
177
178
|
model_provider = ?, model_overrides = ? WHERE id = ?`
|
|
178
179
|
).run(
|
|
179
180
|
holder,
|
|
180
181
|
orch.heldFrom,
|
|
182
|
+
orch.orchestrator?.id ?? null,
|
|
183
|
+
orch.orchestrator?.name ?? null,
|
|
181
184
|
orch.modelProvider,
|
|
182
185
|
orch.modelOverrides,
|
|
183
186
|
id
|
|
@@ -203,9 +206,8 @@ async function arrive(
|
|
|
203
206
|
});
|
|
204
207
|
}, `setup-moved-${bundle.branch}`);
|
|
205
208
|
}
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
: `${bundle.prompt}\n\n${arrivalNote(bundle, worktreePath)}`;
|
|
209
|
+
const note = arrivalNote(bundle, worktreePath, os.hostname());
|
|
210
|
+
const prompt = claude ? note : `${bundle.prompt}\n\n${note}`;
|
|
209
211
|
if (bundle.chat) {
|
|
210
212
|
// Its worker starts on the carried conversation, takes the note
|
|
211
213
|
// first, then what was queued.
|
package/lib/tasks/index.ts
CHANGED
|
@@ -100,6 +100,8 @@ export interface TaskView {
|
|
|
100
100
|
// plan step (lib/models).
|
|
101
101
|
model?: string;
|
|
102
102
|
planning?: boolean;
|
|
103
|
+
// A plan-only task's plan is written and waits for approval.
|
|
104
|
+
planReady?: boolean;
|
|
103
105
|
// What its starter decided about a plan step, and why; null if unstated.
|
|
104
106
|
// Its plan: ready to read or approve, with a title and capped text.
|
|
105
107
|
plan?: TaskPlan | null;
|
|
@@ -128,6 +130,8 @@ export async function createTask(opts: {
|
|
|
128
130
|
plan?: boolean;
|
|
129
131
|
// Why the starter chose to plan or not: shown on the task's card.
|
|
130
132
|
planReason?: string | null;
|
|
133
|
+
// Stop after the plan and wait for approval (implies plan).
|
|
134
|
+
planOnly?: boolean;
|
|
131
135
|
// The orchestrator session that started it (null: a person).
|
|
132
136
|
startedBy?: string | null;
|
|
133
137
|
// Started from this LumifyHub card, which the task then moves.
|
|
@@ -198,6 +202,8 @@ async function startTask(
|
|
|
198
202
|
const hostId = opts.hostId ?? project.host_id;
|
|
199
203
|
const view = opts.view ?? DEFAULT_START_VIEW;
|
|
200
204
|
if (isRemoteHost(hostId)) {
|
|
205
|
+
if (opts.planOnly)
|
|
206
|
+
throw new Error("A plan-only task runs on this machine for now");
|
|
201
207
|
if (opts.base || opts.cardId)
|
|
202
208
|
throw new Error(
|
|
203
209
|
"Stacked and card tasks run on this machine only for now"
|
|
@@ -216,11 +222,18 @@ async function startTask(
|
|
|
216
222
|
|
|
217
223
|
if (opts.baseBranch !== undefined && !isBranchName(opts.baseBranch))
|
|
218
224
|
throw new Error(`"${opts.baseBranch}" isn't a branch name`);
|
|
225
|
+
if (opts.planOnly && opts.plan === false)
|
|
226
|
+
throw new Error(
|
|
227
|
+
"planOnly implies plan; it can't be combined with plan false"
|
|
228
|
+
);
|
|
229
|
+
if (opts.planOnly && view !== "chat")
|
|
230
|
+
throw new Error("A plan-only task needs a chat, not a terminal");
|
|
219
231
|
const routing = taskRouting(
|
|
220
232
|
project.workspace_id,
|
|
221
233
|
view,
|
|
222
234
|
opts.model,
|
|
223
|
-
opts.plan
|
|
235
|
+
opts.plan,
|
|
236
|
+
opts.planOnly
|
|
224
237
|
);
|
|
225
238
|
const projectPath = expandHome(project.working_directory);
|
|
226
239
|
const naming = await nameFor(prompt, projectPath, opts.name);
|
|
@@ -266,7 +279,7 @@ async function startTask(
|
|
|
266
279
|
// Everything the launch needs is on the row, so a restart can resume it.
|
|
267
280
|
db.prepare(
|
|
268
281
|
`UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
|
|
269
|
-
model_provider = ?, model_overrides = ?, plan_phase = ?,
|
|
282
|
+
model_provider = ?, model_overrides = ?, plan_phase = ?, plan_only = ?,
|
|
270
283
|
plan_decided = ?, plan_reason = ?, started_by = ? WHERE id = ?`
|
|
271
284
|
).run(
|
|
272
285
|
prompt,
|
|
@@ -280,7 +293,8 @@ async function startTask(
|
|
|
280
293
|
routing.first.provider.id === ANTHROPIC ? null : routing.first.provider.id,
|
|
281
294
|
routing.override ? JSON.stringify(routing.override) : null,
|
|
282
295
|
routing.plan ? "planning" : null,
|
|
283
|
-
|
|
296
|
+
routing.planOnly ? 1 : null,
|
|
297
|
+
routing.planOnly ? 1 : opts.plan === undefined ? null : opts.plan ? 1 : 0,
|
|
284
298
|
opts.planReason?.trim() || null,
|
|
285
299
|
opts.startedBy ?? null,
|
|
286
300
|
id
|
|
@@ -356,8 +370,10 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
356
370
|
sessionStatus !== undefined
|
|
357
371
|
? await shellOnly(session.tmux_name)
|
|
358
372
|
: false;
|
|
373
|
+
const planReady = live && session.plan_phase === "awaiting";
|
|
374
|
+
// A plan waiting for approval isn't a question the agent asked.
|
|
359
375
|
const blocked =
|
|
360
|
-
live && sessionStatus === "waiting"
|
|
376
|
+
live && sessionStatus === "waiting" && !planReady
|
|
361
377
|
? blockedReason(
|
|
362
378
|
chat
|
|
363
379
|
? saidSinceLastMessage(session.id)
|
|
@@ -373,6 +389,7 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
373
389
|
sessionStatus: agentGone ? "dead" : sessionStatus,
|
|
374
390
|
pr,
|
|
375
391
|
blocked: blocked !== null,
|
|
392
|
+
planReady,
|
|
376
393
|
settingUp: setup?.status === "running" || setup?.status === "held",
|
|
377
394
|
});
|
|
378
395
|
syncTaskCardInBackground(session, state, pr);
|
|
@@ -399,6 +416,7 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
399
416
|
armed: !!session.merge_armed_at,
|
|
400
417
|
model: sessionModelRef(session),
|
|
401
418
|
planning: session.plan_phase === "planning",
|
|
419
|
+
planReady,
|
|
402
420
|
plan: taskPlan(session),
|
|
403
421
|
quickOf: session.quick_of ?? null,
|
|
404
422
|
lastActiveAt: session.held_by_device ? lastActiveOf(session) : undefined,
|
|
@@ -443,20 +461,23 @@ export function taskRouting(
|
|
|
443
461
|
workspaceId: string | null,
|
|
444
462
|
view: StartView,
|
|
445
463
|
model: unknown,
|
|
446
|
-
plan?: boolean
|
|
464
|
+
plan?: boolean,
|
|
465
|
+
planOnly?: boolean
|
|
447
466
|
) {
|
|
448
467
|
const override = parseOverride(model);
|
|
449
468
|
const models = effectiveModels();
|
|
450
469
|
const opts = { workspaceId, override };
|
|
451
470
|
const implement = resolveRole("implement", opts, models);
|
|
452
471
|
const planning = resolveRole("plan", opts, models);
|
|
472
|
+
// Plan only plans even on one model: the stop is the point, not a switch.
|
|
453
473
|
const planStep =
|
|
454
474
|
view === "chat" &&
|
|
455
|
-
(
|
|
456
|
-
|
|
475
|
+
(!!planOnly ||
|
|
476
|
+
((plan ?? models.planStep) && planning.ref !== implement.ref));
|
|
457
477
|
return {
|
|
458
478
|
override,
|
|
459
479
|
plan: planStep,
|
|
480
|
+
planOnly: planStep && !!planOnly,
|
|
460
481
|
first: planStep ? planning : implement,
|
|
461
482
|
};
|
|
462
483
|
}
|
package/lib/tasks/move-bundle.ts
CHANGED
|
@@ -71,6 +71,9 @@ export interface OrchestratedBundle {
|
|
|
71
71
|
planPhase: "planning" | "implementing" | null;
|
|
72
72
|
// .agentos/plan.md, which is never in git.
|
|
73
73
|
planFile: string | null;
|
|
74
|
+
// The orchestrator that holds the task (its id and name on the machine
|
|
75
|
+
// that runs it), so the task can message it from where it runs.
|
|
76
|
+
orchestrator?: { id: string; name: string };
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
export interface ChatBundle {
|
|
@@ -147,6 +150,19 @@ function validateOrchestrated(o: OrchestratedBundle): OrchestratedBundle {
|
|
|
147
150
|
modelOverrides: o.modelOverrides,
|
|
148
151
|
planPhase: o.planPhase,
|
|
149
152
|
planFile: o.planFile,
|
|
153
|
+
orchestrator:
|
|
154
|
+
o.orchestrator &&
|
|
155
|
+
isSessionId(o.orchestrator.id) &&
|
|
156
|
+
typeof o.orchestrator.name === "string" &&
|
|
157
|
+
o.orchestrator.name.trim()
|
|
158
|
+
? {
|
|
159
|
+
id: o.orchestrator.id,
|
|
160
|
+
name: o.orchestrator.name
|
|
161
|
+
.replace(/[\u0000-\u001f]/g, " ")
|
|
162
|
+
.trim()
|
|
163
|
+
.slice(0, 200),
|
|
164
|
+
}
|
|
165
|
+
: undefined,
|
|
150
166
|
};
|
|
151
167
|
}
|
|
152
168
|
|
|
@@ -192,10 +208,11 @@ export function validateBundle(b: TaskBundle): TaskBundle {
|
|
|
192
208
|
|
|
193
209
|
export function arrivalNote(
|
|
194
210
|
b: { from: string; branch: string },
|
|
195
|
-
cwd: string
|
|
211
|
+
cwd: string,
|
|
212
|
+
here: string
|
|
196
213
|
): string {
|
|
197
214
|
return [
|
|
198
|
-
`This task just moved
|
|
215
|
+
`This task just moved from ${b.from} to ${here}. It now runs on ${here}: every command you run executes there, not on ${b.from}, so name ${here} when you report where something ran. Your worktree is now ${cwd}, on branch ${b.branch}.`,
|
|
199
216
|
`Anything that was uncommitted came over as a commit starting "wip: moving to"; fold it into your own commits as you see fit.`,
|
|
200
217
|
`Continue the task where you left off.`,
|
|
201
218
|
].join(" ");
|
package/lib/tasks/move-flow.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { db, type Session } from "../db";
|
|
|
10
10
|
import { isRemoteHost } from "../hosts";
|
|
11
11
|
import { requireHostLink, type HostLink } from "../hosts/remote-api";
|
|
12
12
|
import { getTaskSession } from "./session";
|
|
13
|
-
import { exportOrResume,
|
|
13
|
+
import { exportOrResume, resumeTask, type TaskBundle } from "./move";
|
|
14
14
|
import { isOrchestrated, moveRefusal } from "./move-guard";
|
|
15
15
|
import { holderRefusal } from "./held";
|
|
16
16
|
import { isPaused } from "../orchestrator/pause";
|
|
@@ -146,6 +146,13 @@ async function moveOut(session: Session, hostId: string): Promise<Session> {
|
|
|
146
146
|
return settleMovedOut(session, hostId, link.hostName, there);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
function mirrorRunning(id: string): void {
|
|
150
|
+
db.prepare(
|
|
151
|
+
`UPDATE sessions SET task_status = 'running', moved_to = NULL
|
|
152
|
+
WHERE id = ? AND task_status = 'moving'`
|
|
153
|
+
).run(id);
|
|
154
|
+
}
|
|
155
|
+
|
|
149
156
|
async function moveIn(mirror: Session): Promise<Session> {
|
|
150
157
|
const link = requireHostLink(mirror.host_id);
|
|
151
158
|
if (mirror.view === "chat") await requireChatMoves(link);
|
|
@@ -154,6 +161,12 @@ async function moveIn(mirror: Session): Promise<Session> {
|
|
|
154
161
|
// A retry after it arrived here but that machine didn't hear: tidy up.
|
|
155
162
|
let here = arrivedHere(mirror.id);
|
|
156
163
|
if (!here) {
|
|
164
|
+
// Moving, so nothing reads the mirror as a task to judge, or sign off,
|
|
165
|
+
// while it leaves (a refusal below puts it back).
|
|
166
|
+
db.prepare(
|
|
167
|
+
`UPDATE sessions SET task_status = 'moving', moved_to = 'this machine'
|
|
168
|
+
WHERE id = ? AND task_status = 'running'`
|
|
169
|
+
).run(mirror.id);
|
|
157
170
|
// That machine resumes the agent itself if its half fails there.
|
|
158
171
|
stepProgress(mirror.id, "export");
|
|
159
172
|
let bundle: TaskBundle;
|
|
@@ -165,6 +178,7 @@ async function moveIn(mirror: Session): Promise<Session> {
|
|
|
165
178
|
300000
|
|
166
179
|
));
|
|
167
180
|
} catch (err) {
|
|
181
|
+
mirrorRunning(mirror.id);
|
|
168
182
|
if (unknownOutcome(err)) throw unconfirmed(link.hostName, err);
|
|
169
183
|
throw err;
|
|
170
184
|
}
|
|
@@ -176,6 +190,7 @@ async function moveIn(mirror: Session): Promise<Session> {
|
|
|
176
190
|
`${(err as Error).message}; and ${link.hostName} didn't resume it: ${e.message}`
|
|
177
191
|
);
|
|
178
192
|
});
|
|
193
|
+
mirrorRunning(mirror.id);
|
|
179
194
|
throw err;
|
|
180
195
|
}
|
|
181
196
|
}
|
|
@@ -190,7 +205,11 @@ async function moveIn(mirror: Session): Promise<Session> {
|
|
|
190
205
|
`It's running here now, but ${link.hostName} didn't confirm (${(err as Error).message}). Press Move here again to tidy up.`
|
|
191
206
|
);
|
|
192
207
|
}
|
|
193
|
-
|
|
208
|
+
// The mirror was only how this machine showed it: the task is the new row.
|
|
209
|
+
db.prepare(`DELETE FROM sessions WHERE id = ? AND host_id = ?`).run(
|
|
210
|
+
mirror.id,
|
|
211
|
+
mirror.host_id
|
|
212
|
+
);
|
|
194
213
|
forgetHostTasks(link.hostId);
|
|
195
214
|
return db
|
|
196
215
|
.prepare(`SELECT * FROM sessions WHERE id = ?`)
|
package/lib/tasks/move-guard.ts
CHANGED
|
@@ -25,6 +25,8 @@ export function moveRefusal(session: Session): string | null {
|
|
|
25
25
|
return "It's a quick change its orchestrator makes on this machine, so it stays here";
|
|
26
26
|
if (escalations(session.id).length)
|
|
27
27
|
return "It's waiting on you (an escalated gate), so it stays here";
|
|
28
|
+
if (session.plan_only && session.plan_phase !== "implementing")
|
|
29
|
+
return "It's a plan-only task that hasn't been approved, so it stays here";
|
|
28
30
|
const ask = db
|
|
29
31
|
.prepare(
|
|
30
32
|
`SELECT 1 FROM orchestrator_asks WHERE subject IN (?, ?) AND status = 'open' LIMIT 1`
|
package/lib/tasks/move.ts
CHANGED
|
@@ -115,6 +115,20 @@ const readPlanFile = (cwd: string): string | null => {
|
|
|
115
115
|
}
|
|
116
116
|
};
|
|
117
117
|
|
|
118
|
+
// The orchestrator that holds the task: its own, or the one on the machine
|
|
119
|
+
// that moved it here.
|
|
120
|
+
function orchestratorOf(
|
|
121
|
+
session: Session
|
|
122
|
+
): { id: string; name: string } | undefined {
|
|
123
|
+
if (session.held_for && session.held_for_name)
|
|
124
|
+
return { id: session.held_for, name: session.held_for_name };
|
|
125
|
+
if (!session.started_by) return undefined;
|
|
126
|
+
const o = db
|
|
127
|
+
.prepare(`SELECT name FROM sessions WHERE id = ?`)
|
|
128
|
+
.get(session.started_by) as { name: string } | undefined;
|
|
129
|
+
return o ? { id: session.started_by, name: o.name } : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
118
132
|
// What an orchestrator's task carries besides its work (the orchestrator's
|
|
119
133
|
// own state is re-keyed here; see move-carry).
|
|
120
134
|
function orchestratedBundle(session: Session): OrchestratedBundle {
|
|
@@ -123,8 +137,13 @@ function orchestratedBundle(session: Session): OrchestratedBundle {
|
|
|
123
137
|
since: isoUTC(session.task_since ?? session.created_at),
|
|
124
138
|
modelProvider: session.model_provider ?? null,
|
|
125
139
|
modelOverrides: session.model_overrides ?? null,
|
|
126
|
-
|
|
140
|
+
// moveRefusal keeps an unapproved plan-only task here.
|
|
141
|
+
planPhase: (session.plan_phase ?? null) as
|
|
142
|
+
| "planning"
|
|
143
|
+
| "implementing"
|
|
144
|
+
| null,
|
|
127
145
|
planFile: readPlanFile(session.working_directory),
|
|
146
|
+
orchestrator: orchestratorOf(session),
|
|
128
147
|
};
|
|
129
148
|
}
|
|
130
149
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a machine tidies up after a task it held went back to the machine
|
|
3
|
+
* that sent it: its agent and chat worker stop, its ports are released, and
|
|
4
|
+
* its worktree goes when nothing in it would be lost (no uncommitted change,
|
|
5
|
+
* no commit a remote lacks). The row stays, archived, as history.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db, type Session } from "../db";
|
|
9
|
+
import { endSessionAgent } from "../sessions/end";
|
|
10
|
+
import { releasePorts } from "../ports";
|
|
11
|
+
import { settleWorktree, type WorktreeFate } from "../done/worktree";
|
|
12
|
+
import { archiveSession } from "../done/archive";
|
|
13
|
+
|
|
14
|
+
export async function cleanUpMovedAway(id: string): Promise<WorktreeFate> {
|
|
15
|
+
const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
|
|
16
|
+
| Session
|
|
17
|
+
| undefined;
|
|
18
|
+
if (!s || s.task_status !== "moved" || !s.held_by_device)
|
|
19
|
+
return { action: "none" };
|
|
20
|
+
await endSessionAgent(s).catch((err) =>
|
|
21
|
+
console.error(`[moved-away] ${id}: agent not stopped:`, err)
|
|
22
|
+
);
|
|
23
|
+
releasePorts(id);
|
|
24
|
+
const fate = await settleWorktree(s, null, true).catch(
|
|
25
|
+
(err): WorktreeFate => ({
|
|
26
|
+
action: "kept",
|
|
27
|
+
why: `it couldn't be checked: ${err instanceof Error ? err.message : err}`,
|
|
28
|
+
path: s.worktree_path ?? "",
|
|
29
|
+
})
|
|
30
|
+
);
|
|
31
|
+
archiveSession(id);
|
|
32
|
+
if (fate.action === "kept")
|
|
33
|
+
console.warn(
|
|
34
|
+
`[moved-away] ${id}: worktree kept at ${fate.path}: ${fate.why}`
|
|
35
|
+
);
|
|
36
|
+
return fate;
|
|
37
|
+
}
|
package/lib/tasks/plan-view.ts
CHANGED
|
@@ -102,7 +102,12 @@ export function taskPlan(session: Session): TaskPlan | null {
|
|
|
102
102
|
return {
|
|
103
103
|
...base,
|
|
104
104
|
planned: true,
|
|
105
|
-
status:
|
|
105
|
+
status:
|
|
106
|
+
phase === "planning"
|
|
107
|
+
? "writing"
|
|
108
|
+
: phase === "awaiting"
|
|
109
|
+
? "ready"
|
|
110
|
+
: "approved",
|
|
106
111
|
source: "step",
|
|
107
112
|
title: planTitle(body),
|
|
108
113
|
path: text !== null ? PLAN_FILE : null,
|
package/lib/tasks/queue.ts
CHANGED
|
@@ -48,6 +48,7 @@ export interface QueueRow {
|
|
|
48
48
|
// orchestrator session that started it.
|
|
49
49
|
plan: number | null;
|
|
50
50
|
plan_reason: string | null;
|
|
51
|
+
plan_only: number | null;
|
|
51
52
|
started_by: string | null;
|
|
52
53
|
view: "chat" | "terminal" | null;
|
|
53
54
|
base_branch: string | null;
|
|
@@ -222,6 +223,7 @@ export interface QueueRequest {
|
|
|
222
223
|
model?: ModelOverride;
|
|
223
224
|
plan?: boolean;
|
|
224
225
|
planReason?: string | null;
|
|
226
|
+
planOnly?: boolean;
|
|
225
227
|
startedBy?: string | null;
|
|
226
228
|
view?: "chat" | "terminal";
|
|
227
229
|
baseBranch?: string;
|
|
@@ -277,9 +279,9 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
277
279
|
if (!req.prompt.trim()) throw new Error("Describe the task");
|
|
278
280
|
const id = randomUUID();
|
|
279
281
|
db.prepare(
|
|
280
|
-
`INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, started_by, view, base_branch,
|
|
282
|
+
`INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, plan_only, started_by, view, base_branch,
|
|
281
283
|
host_id, after_task, after_any, origin_workspace_id, position)
|
|
282
|
-
VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @started_by, @view, @base_branch,
|
|
284
|
+
VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @plan_only, @started_by, @view, @base_branch,
|
|
283
285
|
@host_id, @after_task, @after_any, @origin,
|
|
284
286
|
(SELECT COALESCE(MAX(position), 0) + 1 FROM task_queue))`
|
|
285
287
|
).run({
|
|
@@ -294,6 +296,7 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
294
296
|
: null,
|
|
295
297
|
plan: req.plan === undefined ? null : req.plan ? 1 : 0,
|
|
296
298
|
plan_reason: req.planReason?.trim() || null,
|
|
299
|
+
plan_only: req.planOnly ? 1 : null,
|
|
297
300
|
started_by: req.startedBy ?? null,
|
|
298
301
|
view: req.view ?? null,
|
|
299
302
|
base_branch: req.baseBranch || null,
|
|
@@ -472,6 +475,7 @@ async function startRow(
|
|
|
472
475
|
: (r.model ?? undefined),
|
|
473
476
|
plan: r.plan == null ? undefined : !!r.plan,
|
|
474
477
|
planReason: r.plan_reason,
|
|
478
|
+
planOnly: !!r.plan_only,
|
|
475
479
|
startedBy: r.started_by,
|
|
476
480
|
view: r.view ?? undefined,
|
|
477
481
|
baseBranch: r.base_branch ?? undefined,
|
package/lib/tasks/remote.ts
CHANGED
|
@@ -30,6 +30,9 @@ export const TASK_CAPABILITIES = [
|
|
|
30
30
|
// Holds an orchestrator's task for the machine that sent it, and answers
|
|
31
31
|
// its messages, reads, stops and done (lib/tasks/held.ts).
|
|
32
32
|
"orchestrated-move",
|
|
33
|
+
// A held task's chat facts and its messages for its orchestrator.
|
|
34
|
+
"held-chat-facts",
|
|
35
|
+
"held-replies",
|
|
33
36
|
] as const;
|
|
34
37
|
|
|
35
38
|
export interface HostTasks {
|
package/lib/tasks/start.ts
CHANGED
|
@@ -141,7 +141,7 @@ export async function finishTaskStart(
|
|
|
141
141
|
const task = (session.task_prompt ?? "") + setupNote(setup);
|
|
142
142
|
const planning = session.plan_phase === "planning";
|
|
143
143
|
if (planning) preparePlanDir(session.worktree_path);
|
|
144
|
-
const prompt = planning ? planPrompt(task) : task;
|
|
144
|
+
const prompt = planning ? planPrompt(task, !!session.plan_only) : task;
|
|
145
145
|
try {
|
|
146
146
|
if (session.view === "chat") await launchChat(sessionId, prompt);
|
|
147
147
|
else {
|
package/lib/tasks/state.ts
CHANGED
|
@@ -41,6 +41,8 @@ export type TaskState =
|
|
|
41
41
|
| "blocked"
|
|
42
42
|
| "review"
|
|
43
43
|
| "checks-failing"
|
|
44
|
+
// A plan-only task's plan is written; it waits for approval.
|
|
45
|
+
| "plan-ready"
|
|
44
46
|
| "exited"
|
|
45
47
|
| "merged"
|
|
46
48
|
| "dropped"
|
|
@@ -56,11 +58,14 @@ export interface TaskStateInput {
|
|
|
56
58
|
blocked: boolean;
|
|
57
59
|
// Its worktree is still being set up; the agent hasn't started yet.
|
|
58
60
|
settingUp?: boolean;
|
|
61
|
+
// plan_phase "awaiting": no agent runs, and none is needed.
|
|
62
|
+
planReady?: boolean;
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
// Ordered by what needs the human first.
|
|
62
66
|
export function deriveTaskState(input: TaskStateInput): TaskState {
|
|
63
|
-
const { taskStatus, sessionStatus, pr, blocked, settingUp } =
|
|
67
|
+
const { taskStatus, sessionStatus, pr, blocked, settingUp, planReady } =
|
|
68
|
+
input;
|
|
64
69
|
if (taskStatus === "merged" || pr?.state === "MERGED") return "merged";
|
|
65
70
|
if (taskStatus === "dropped") return "dropped";
|
|
66
71
|
if (taskStatus === "done" || taskStatus === "moved") return "done";
|
|
@@ -70,6 +75,7 @@ export function deriveTaskState(input: TaskStateInput): TaskState {
|
|
|
70
75
|
if (pr?.state === "OPEN") {
|
|
71
76
|
return pr.checks === "fail" ? "checks-failing" : "review";
|
|
72
77
|
}
|
|
78
|
+
if (planReady && sessionStatus !== "running") return "plan-ready";
|
|
73
79
|
if (!sessionStatus || sessionStatus === "dead") return "exited";
|
|
74
80
|
if (sessionStatus === "running") return "working";
|
|
75
81
|
return "needs-input";
|