@saadnvd1/agent-os 0.3.518 → 0.3.519
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 +4 -1
- package/app/api/bus/peers/route.ts +8 -2
- 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/lib/bus/held-reply.ts +89 -0
- package/lib/bus/index.ts +86 -10
- package/lib/db/migrations.ts +25 -0
- package/lib/db/types.ts +2 -0
- package/lib/done/worktree.ts +24 -10
- package/lib/hosts/remote-api.ts +12 -0
- package/lib/orchestrator/task-state.ts +40 -15
- 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/move-bundle.ts +19 -2
- package/lib/tasks/move-flow.ts +21 -2
- package/lib/tasks/move.ts +15 -0
- package/lib/tasks/moved-away.ts +37 -0
- package/lib/tasks/remote.ts +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1587,7 +1587,10 @@ Once linked:
|
|
|
1587
1587
|
judging the PR at its head, reads BLOCKED lines and open approvals from the
|
|
1588
1588
|
task's chat there, and gets PR, CI, idle and plan events by polling that
|
|
1589
1589
|
machine. Its messages arrive marked as from the orchestrator, never as
|
|
1590
|
-
yours.
|
|
1590
|
+
yours. The task can `aos send` its orchestrator back: the machine it runs
|
|
1591
|
+
on only stores the message, and the orchestrator's machine pulls it and
|
|
1592
|
+
delivers it as that task's own. Moving it back stops its agent there and
|
|
1593
|
+
removes its worktree when nothing in it would be lost. Guards (write guard, shell deny list, attribution settings) run in
|
|
1591
1594
|
the task's worker on either machine, and the task can't merge or push to
|
|
1592
1595
|
the default branch. Moving counts toward the brakes and Pause refuses the
|
|
1593
1596
|
tool.
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import { listPeers, resolveTarget } from "@/lib/bus";
|
|
2
|
+
import { listPeers, resolveHeldOrchestrator, resolveTarget } from "@/lib/bus";
|
|
3
3
|
|
|
4
4
|
// ?ref=<name>: just the session that name reaches, the way aos send would.
|
|
5
5
|
export async function GET(request: NextRequest) {
|
|
6
6
|
const ref = request.nextUrl.searchParams.get("ref");
|
|
7
|
-
|
|
7
|
+
// from: the asking session, which a held task needs for its orchestrator.
|
|
8
|
+
const from = request.nextUrl.searchParams.get("from") ?? "";
|
|
9
|
+
if (ref === null)
|
|
10
|
+
return NextResponse.json({ peers: await listPeers(from || undefined) });
|
|
11
|
+
const held = from ? resolveHeldOrchestrator(from, ref) : null;
|
|
12
|
+
if (held)
|
|
13
|
+
return NextResponse.json({ peer: { id: held.id, name: held.name } });
|
|
8
14
|
try {
|
|
9
15
|
const { session, note } = resolveTarget(ref);
|
|
10
16
|
return NextResponse.json({
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { blockedReason } from "@/lib/tasks/state";
|
|
3
|
+
import { saidSinceLastMessage } from "@/lib/chat/store";
|
|
4
|
+
import { chatStateNow } from "@/lib/chat/runner";
|
|
5
|
+
import { heldRoute } from "@/lib/tasks/held-routes";
|
|
6
|
+
|
|
7
|
+
// GET /api/tasks/:id/chat-facts - what its chat says about being blocked
|
|
8
|
+
// and whether a question is open, for the orchestrator that moved it here.
|
|
9
|
+
// Cheap (no PR lookups), unlike the whole task list.
|
|
10
|
+
export async function GET(
|
|
11
|
+
request: NextRequest,
|
|
12
|
+
ctx: { params: Promise<{ id: string }> }
|
|
13
|
+
) {
|
|
14
|
+
return heldRoute(request, ctx, async (task) => {
|
|
15
|
+
if (task.view !== "chat") return { blocked: null, turn: null };
|
|
16
|
+
return {
|
|
17
|
+
blocked: blockedReason(saidSinceLastMessage(task.id)),
|
|
18
|
+
turn: await chatStateNow(task.id).catch(() => "unknown"),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { markMoved } from "@/lib/tasks/move";
|
|
3
3
|
import { callingDevice } from "@/lib/security/device-me";
|
|
4
4
|
import { heldRefusal } from "@/lib/tasks/held";
|
|
5
|
+
import { cleanUpMovedAway } from "@/lib/tasks/moved-away";
|
|
6
|
+
import { runInBackground } from "@/lib/async-operations";
|
|
5
7
|
|
|
6
8
|
// POST /api/tasks/:id/moved - the task now runs on the machine named `to`
|
|
7
9
|
export async function POST(
|
|
@@ -13,5 +15,10 @@ export async function POST(
|
|
|
13
15
|
if (held) return NextResponse.json({ error: held }, { status: 403 });
|
|
14
16
|
const { to } = await request.json().catch(() => ({}));
|
|
15
17
|
markMoved(id, String(to || "another machine").slice(0, 100));
|
|
18
|
+
// A task its orchestrator took back: its agent and worktree here go.
|
|
19
|
+
if (held === null)
|
|
20
|
+
runInBackground(async () => {
|
|
21
|
+
await cleanUpMovedAway(id);
|
|
22
|
+
}, `moved-away-${id}`);
|
|
16
23
|
return NextResponse.json({ success: true });
|
|
17
24
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { callingDevice } from "@/lib/security/device-me";
|
|
3
|
+
import { ackReplies, pendingReplies } from "@/lib/bus/held-reply";
|
|
4
|
+
|
|
5
|
+
// GET /api/tasks/held-replies - what the tasks this device moved here have
|
|
6
|
+
// said to their orchestrator, for it to deliver there. Only that device's.
|
|
7
|
+
export async function GET(request: NextRequest) {
|
|
8
|
+
const device = callingDevice(request);
|
|
9
|
+
if (!device)
|
|
10
|
+
return NextResponse.json(
|
|
11
|
+
{ error: "Needs a paired device" },
|
|
12
|
+
{ status: 403 }
|
|
13
|
+
);
|
|
14
|
+
return NextResponse.json({ messages: pendingReplies(device) });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// POST { ids } - those are delivered; they are not offered again.
|
|
18
|
+
export async function POST(request: NextRequest) {
|
|
19
|
+
const device = callingDevice(request);
|
|
20
|
+
if (!device)
|
|
21
|
+
return NextResponse.json(
|
|
22
|
+
{ error: "Needs a paired device" },
|
|
23
|
+
{ status: 403 }
|
|
24
|
+
);
|
|
25
|
+
const { ids } = await request.json().catch(() => ({}));
|
|
26
|
+
const list = (Array.isArray(ids) ? ids : []).filter((n): n is number =>
|
|
27
|
+
Number.isSafeInteger(n)
|
|
28
|
+
);
|
|
29
|
+
ackReplies(device, list.slice(0, 200));
|
|
30
|
+
return NextResponse.json({ success: true });
|
|
31
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A task its orchestrator moved here has no orchestrator on this machine to
|
|
3
|
+
* message, and this machine never reaches back to the one that holds it. Its
|
|
4
|
+
* message to the orchestrator is only stored here (relay_state 'pending');
|
|
5
|
+
* the holder pulls it (lib/tasks/held-replies.ts) and delivers it there as
|
|
6
|
+
* that task's own. This machine never delivers or attributes it, and the
|
|
7
|
+
* holder, not the sender, says who receives it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { db, type Session } from "../db";
|
|
11
|
+
import { resolveRef } from "./resolve";
|
|
12
|
+
|
|
13
|
+
export interface HeldOrchestrator {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
heldFrom: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The orchestrator a running held task messages, or null. */
|
|
20
|
+
export function heldOrchestrator(
|
|
21
|
+
s: Session | undefined
|
|
22
|
+
): HeldOrchestrator | null {
|
|
23
|
+
if (
|
|
24
|
+
!s ||
|
|
25
|
+
!s.held_by_device ||
|
|
26
|
+
!s.held_for ||
|
|
27
|
+
!s.held_for_name ||
|
|
28
|
+
s.task_status !== "running"
|
|
29
|
+
)
|
|
30
|
+
return null;
|
|
31
|
+
return {
|
|
32
|
+
id: s.held_for,
|
|
33
|
+
name: s.held_for_name,
|
|
34
|
+
heldFrom: s.held_from || "another machine",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The orchestrator `ref` names for this held sender: its name, id prefix or "orchestrator". */
|
|
39
|
+
export function heldTarget(
|
|
40
|
+
from: Session | undefined,
|
|
41
|
+
ref: string
|
|
42
|
+
): HeldOrchestrator | null {
|
|
43
|
+
const o = from && heldOrchestrator(from);
|
|
44
|
+
if (!o) return null;
|
|
45
|
+
if (ref.trim().toLowerCase() === "orchestrator") return o;
|
|
46
|
+
const r = resolveRef(ref, [
|
|
47
|
+
{
|
|
48
|
+
id: o.id,
|
|
49
|
+
name: o.name,
|
|
50
|
+
projectName: null,
|
|
51
|
+
tmuxName: "",
|
|
52
|
+
previousNames: [],
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
return r.ok ? o : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PendingReply {
|
|
59
|
+
id: number;
|
|
60
|
+
fromId: string;
|
|
61
|
+
body: string;
|
|
62
|
+
createdAt: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What the calling device's held tasks have said to their orchestrators. */
|
|
66
|
+
export function pendingReplies(device: string, limit = 50): PendingReply[] {
|
|
67
|
+
return (
|
|
68
|
+
db
|
|
69
|
+
.prepare(
|
|
70
|
+
`SELECT m.id, m.from_id AS fromId, m.body, m.created_at AS createdAt
|
|
71
|
+
FROM bus_messages m JOIN sessions s ON s.id = m.from_id
|
|
72
|
+
WHERE m.relay_state = 'pending' AND s.held_by_device = ?
|
|
73
|
+
ORDER BY m.id LIMIT ?`
|
|
74
|
+
)
|
|
75
|
+
.all(device, limit) as PendingReply[]
|
|
76
|
+
).map((r) => ({ ...r, id: Number(r.id) }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Marks them pulled; only the device's own are touched. */
|
|
80
|
+
export function ackReplies(device: string, ids: number[]): void {
|
|
81
|
+
const mark = db.prepare(
|
|
82
|
+
`UPDATE bus_messages SET relay_state = 'relayed', delivered_at = datetime('now')
|
|
83
|
+
WHERE id = ? AND relay_state = 'pending'
|
|
84
|
+
AND from_id IN (SELECT id FROM sessions WHERE held_by_device = ?)`
|
|
85
|
+
);
|
|
86
|
+
db.transaction(() => {
|
|
87
|
+
for (const id of ids) mark.run(id, device);
|
|
88
|
+
})();
|
|
89
|
+
}
|
package/lib/bus/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { resolveRef, wasNames, type Candidate } from "./resolve";
|
|
|
16
16
|
import { tmuxPane } from "./tmux-pane";
|
|
17
17
|
import { hostLink } from "../hosts/remote-api";
|
|
18
18
|
import { relayToHeldTask } from "../tasks/held-remote";
|
|
19
|
+
import { heldOrchestrator, heldTarget } from "./held-reply";
|
|
19
20
|
import { HUMAN, overRateLimit, wakeLine, type BusMessageView } from "./format";
|
|
20
21
|
|
|
21
22
|
export * from "./format";
|
|
@@ -54,11 +55,21 @@ function candidates(sessions: Session[]): Candidate[] {
|
|
|
54
55
|
}));
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
const sessionById = (id: string | null | undefined) =>
|
|
59
|
+
id
|
|
60
|
+
? (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
|
|
61
|
+
| Session
|
|
62
|
+
| undefined)
|
|
63
|
+
: undefined;
|
|
64
|
+
|
|
65
|
+
// `callerId`: the asking session. A task its orchestrator moved here also
|
|
66
|
+
// sees that orchestrator, which it can message though it runs elsewhere.
|
|
67
|
+
export async function listPeers(callerId?: string): Promise<Peer[]> {
|
|
58
68
|
await statusDetector.refreshCache();
|
|
59
69
|
const sessions = liveSessions();
|
|
60
70
|
const named = candidates(sessions);
|
|
61
|
-
|
|
71
|
+
const orchestrator = heldOrchestrator(sessionById(callerId));
|
|
72
|
+
const listed = await Promise.all(
|
|
62
73
|
sessions.map(async (s, i) => {
|
|
63
74
|
const status = statusDetector.sessionExists(s.tmux_name)
|
|
64
75
|
? await statusDetector.getStatus(s.tmux_name)
|
|
@@ -76,6 +87,25 @@ export async function listPeers(): Promise<Peer[]> {
|
|
|
76
87
|
};
|
|
77
88
|
})
|
|
78
89
|
);
|
|
90
|
+
if (!orchestrator) return listed;
|
|
91
|
+
return [
|
|
92
|
+
...listed,
|
|
93
|
+
{
|
|
94
|
+
id: orchestrator.id,
|
|
95
|
+
name: orchestrator.name,
|
|
96
|
+
was: [],
|
|
97
|
+
projectName: null,
|
|
98
|
+
tmuxName: "",
|
|
99
|
+
hostId: "remote",
|
|
100
|
+
running: true,
|
|
101
|
+
doing: `your orchestrator, on ${orchestrator.heldFrom}`,
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The orchestrator a held caller's ref names, if it names it.
|
|
107
|
+
export function resolveHeldOrchestrator(callerId: string, ref: string) {
|
|
108
|
+
return heldTarget(sessionById(callerId), ref);
|
|
79
109
|
}
|
|
80
110
|
|
|
81
111
|
// A live session by name, old name, project/name, id or id prefix. The note
|
|
@@ -208,16 +238,18 @@ export async function sendMessage(opts: {
|
|
|
208
238
|
// It's never left unread for `aos inbox` to show in another form; a
|
|
209
239
|
// failed one is the sender's to retry.
|
|
210
240
|
line?: string;
|
|
241
|
+
// A message pulled from a linked machine, by its key there: one already
|
|
242
|
+
// stored is not stored or delivered again.
|
|
243
|
+
relayKey?: string;
|
|
211
244
|
}): Promise<{ message: BusMessageView; delivery: Delivery; note?: string }> {
|
|
212
245
|
const body = opts.body.trim();
|
|
213
246
|
if (!body) throw new Error("Message is empty");
|
|
214
|
-
const
|
|
215
|
-
const from = opts.fromId
|
|
216
|
-
? (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(opts.fromId) as
|
|
217
|
-
| Session
|
|
218
|
-
| undefined)
|
|
219
|
-
: undefined;
|
|
247
|
+
const from = sessionById(opts.fromId);
|
|
220
248
|
if (opts.fromId && !from) throw new Error("Unknown sender session");
|
|
249
|
+
// A held task's message to its orchestrator is kept for the holder to pull.
|
|
250
|
+
const held = heldTarget(from, opts.to);
|
|
251
|
+
if (from && held) return storeForHolder(from, held, body);
|
|
252
|
+
const { session: to, note } = resolveTarget(opts.to);
|
|
221
253
|
if (from && from.id === to.id)
|
|
222
254
|
throw new Error("A session can't message itself");
|
|
223
255
|
if (from && overRateLimit(pairTimestamps(from.id, to.id))) {
|
|
@@ -225,13 +257,32 @@ export async function sendMessage(opts: {
|
|
|
225
257
|
`Too many messages between ${from.name} and ${to.name}; wait a few minutes`
|
|
226
258
|
);
|
|
227
259
|
}
|
|
260
|
+
if (opts.relayKey) {
|
|
261
|
+
const seen = db
|
|
262
|
+
.prepare(`SELECT id FROM bus_messages WHERE relay_key = ?`)
|
|
263
|
+
.get(opts.relayKey) as { id: number } | undefined;
|
|
264
|
+
if (seen)
|
|
265
|
+
return {
|
|
266
|
+
message: toView(
|
|
267
|
+
db.prepare(`${SELECT} WHERE m.id = ?`).get(seen.id) as Row
|
|
268
|
+
),
|
|
269
|
+
delivery: { state: "delivered" },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
228
272
|
|
|
229
273
|
const fromName = from?.name ?? opts.fromLabel ?? HUMAN;
|
|
230
274
|
const { lastInsertRowid } = db
|
|
231
275
|
.prepare(
|
|
232
|
-
`INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body) VALUES (?, ?, ?, ?, ?)`
|
|
276
|
+
`INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body, relay_key) VALUES (?, ?, ?, ?, ?, ?)`
|
|
233
277
|
)
|
|
234
|
-
.run(
|
|
278
|
+
.run(
|
|
279
|
+
from?.id ?? null,
|
|
280
|
+
fromName,
|
|
281
|
+
to.id,
|
|
282
|
+
to.name,
|
|
283
|
+
body,
|
|
284
|
+
opts.relayKey ?? null
|
|
285
|
+
);
|
|
235
286
|
const id = Number(lastInsertRowid);
|
|
236
287
|
|
|
237
288
|
await statusDetector.refreshCache();
|
|
@@ -260,6 +311,31 @@ export async function sendMessage(opts: {
|
|
|
260
311
|
return { message, delivery, note };
|
|
261
312
|
}
|
|
262
313
|
|
|
314
|
+
function storeForHolder(
|
|
315
|
+
from: Session,
|
|
316
|
+
to: { id: string; name: string; heldFrom: string },
|
|
317
|
+
body: string
|
|
318
|
+
) {
|
|
319
|
+
if (overRateLimit(pairTimestamps(from.id, to.id)))
|
|
320
|
+
throw new Error(
|
|
321
|
+
`Too many messages between ${from.name} and ${to.name}; wait a few minutes`
|
|
322
|
+
);
|
|
323
|
+
const { lastInsertRowid } = db
|
|
324
|
+
.prepare(
|
|
325
|
+
`INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body, relay_state)
|
|
326
|
+
VALUES (?, ?, ?, ?, ?, 'pending')`
|
|
327
|
+
)
|
|
328
|
+
.run(from.id, from.name, to.id, to.name, body);
|
|
329
|
+
const message = toView(
|
|
330
|
+
db.prepare(`${SELECT} WHERE m.id = ?`).get(Number(lastInsertRowid)) as Row
|
|
331
|
+
);
|
|
332
|
+
return {
|
|
333
|
+
message,
|
|
334
|
+
delivery: { state: "queued" as const },
|
|
335
|
+
note: `${to.name} runs on ${to.heldFrom}: it gets this when that machine next checks in, within a few seconds`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
263
339
|
// Unread messages for a session, marked read as they're returned.
|
|
264
340
|
export function readInbox(sessionId: string): BusMessageView[] {
|
|
265
341
|
const rows = db
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1714,6 +1714,31 @@ const migrations: Migration[] = [
|
|
|
1714
1714
|
add(col);
|
|
1715
1715
|
},
|
|
1716
1716
|
},
|
|
1717
|
+
{
|
|
1718
|
+
id: 74,
|
|
1719
|
+
name: "held_task_replies",
|
|
1720
|
+
up: (db) => {
|
|
1721
|
+
// held_for / held_for_name: the orchestrator (id, name there) a held
|
|
1722
|
+
// task messages. relay_state: a held task's message to it, waiting for
|
|
1723
|
+
// the holder to pull it (pending) or pulled (relayed). relay_key: a
|
|
1724
|
+
// pulled message's key on the orchestrator's machine, so a repeat pull
|
|
1725
|
+
// is not delivered twice.
|
|
1726
|
+
const has = (table: string, col: string) =>
|
|
1727
|
+
(
|
|
1728
|
+
db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
|
|
1729
|
+
).some((c) => c.name === col);
|
|
1730
|
+
for (const col of ["held_for", "held_for_name"])
|
|
1731
|
+
if (!has("sessions", col))
|
|
1732
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN ${col} TEXT`);
|
|
1733
|
+
for (const col of ["relay_state", "relay_key"])
|
|
1734
|
+
if (!has("bus_messages", col))
|
|
1735
|
+
db.exec(`ALTER TABLE bus_messages ADD COLUMN ${col} TEXT`);
|
|
1736
|
+
db.exec(
|
|
1737
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_bus_relay_key ON bus_messages(relay_key) WHERE relay_key IS NOT NULL;
|
|
1738
|
+
CREATE INDEX IF NOT EXISTS idx_bus_relay_pending ON bus_messages(relay_state) WHERE relay_state = 'pending'`
|
|
1739
|
+
);
|
|
1740
|
+
},
|
|
1741
|
+
},
|
|
1717
1742
|
];
|
|
1718
1743
|
|
|
1719
1744
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/db/types.ts
CHANGED
|
@@ -124,6 +124,8 @@ export interface Session {
|
|
|
124
124
|
// The device and machine of the orchestrator that moved this task here.
|
|
125
125
|
held_by_device?: string | null;
|
|
126
126
|
held_from?: string | null;
|
|
127
|
+
held_for?: string | null;
|
|
128
|
+
held_for_name?: string | null;
|
|
127
129
|
// A held task's "plan written" line, for its orchestrator to read.
|
|
128
130
|
plan_note?: string | null;
|
|
129
131
|
// Orchestration fields
|
package/lib/done/worktree.ts
CHANGED
|
@@ -114,14 +114,15 @@ const gone = (merged: MergedAs | null, still = false) => ({
|
|
|
114
114
|
export async function worktreeFate(
|
|
115
115
|
session: Judged,
|
|
116
116
|
merged: MergedAs | null,
|
|
117
|
-
waitMs = 60_000
|
|
117
|
+
waitMs = 60_000,
|
|
118
|
+
movedAway = false
|
|
118
119
|
): Promise<FatePreview> {
|
|
119
120
|
const path = session.worktree_path;
|
|
120
121
|
if (!path) return { action: "none" };
|
|
121
122
|
for (;;) {
|
|
122
123
|
if (!(await removalSettled(path, waitMs))) return gone(merged, true);
|
|
123
124
|
if (!fs.existsSync(path)) return gone(merged);
|
|
124
|
-
const fate = await judge(session, merged);
|
|
125
|
+
const fate = await judge(session, merged, movedAway);
|
|
125
126
|
// A removal that started while it read: read what it leaves.
|
|
126
127
|
if (!removalRunning(path) && fs.existsSync(path)) return fate;
|
|
127
128
|
}
|
|
@@ -133,8 +134,17 @@ async function commitsKeep(
|
|
|
133
134
|
path: string,
|
|
134
135
|
repo: string,
|
|
135
136
|
session: Judged,
|
|
136
|
-
merged: MergedAs | null
|
|
137
|
+
merged: MergedAs | null,
|
|
138
|
+
movedAway = false
|
|
137
139
|
): Promise<string | null> {
|
|
140
|
+
// The task went on elsewhere: only commits no remote has would be lost.
|
|
141
|
+
if (movedAway) {
|
|
142
|
+
const stray = await strayCommits(path, {}).catch(() => null);
|
|
143
|
+
if (stray === null) return "its commits can't be checked";
|
|
144
|
+
return stray > 0
|
|
145
|
+
? `${stray} commit${stray === 1 ? " is" : "s are"} on it that no remote has`
|
|
146
|
+
: null;
|
|
147
|
+
}
|
|
138
148
|
if (merged) {
|
|
139
149
|
const stray = await strayCommits(path, merged).catch(() => null);
|
|
140
150
|
if (stray === null) return "its commits can't be checked";
|
|
@@ -163,7 +173,8 @@ async function commitsKeep(
|
|
|
163
173
|
// kept one names every reason, the commits as well as the changes.
|
|
164
174
|
async function judge(
|
|
165
175
|
session: Judged,
|
|
166
|
-
merged: MergedAs | null
|
|
176
|
+
merged: MergedAs | null,
|
|
177
|
+
movedAway = false
|
|
167
178
|
): Promise<FatePreview> {
|
|
168
179
|
const path = session.worktree_path!;
|
|
169
180
|
const keep = (why: string) => ({ action: "kept" as const, why, path });
|
|
@@ -183,14 +194,16 @@ async function judge(
|
|
|
183
194
|
unsaved.length
|
|
184
195
|
? `it has uncommitted changes (${describeChanges(unsaved)})`
|
|
185
196
|
: null,
|
|
186
|
-
await commitsKeep(path, repo, session, merged),
|
|
197
|
+
await commitsKeep(path, repo, session, merged, movedAway),
|
|
187
198
|
].filter((w) => w !== null);
|
|
188
199
|
if (why.length) return keep(why.join(", and "));
|
|
189
200
|
return {
|
|
190
201
|
action: "remove",
|
|
191
|
-
why:
|
|
192
|
-
? "its
|
|
193
|
-
:
|
|
202
|
+
why: movedAway
|
|
203
|
+
? "its work moved to another machine"
|
|
204
|
+
: merged
|
|
205
|
+
? "its branch is merged"
|
|
206
|
+
: "its branch has no commits of its own",
|
|
194
207
|
repo,
|
|
195
208
|
};
|
|
196
209
|
}
|
|
@@ -199,9 +212,10 @@ async function judge(
|
|
|
199
212
|
// and says so only once it's really gone.
|
|
200
213
|
export async function settleWorktree(
|
|
201
214
|
session: Judged,
|
|
202
|
-
merged: MergedAs | null
|
|
215
|
+
merged: MergedAs | null,
|
|
216
|
+
movedAway = false
|
|
203
217
|
): Promise<WorktreeFate> {
|
|
204
|
-
const fate = await worktreeFate(session, merged);
|
|
218
|
+
const fate = await worktreeFate(session, merged, undefined, movedAway);
|
|
205
219
|
if (fate.action !== "remove") return fate;
|
|
206
220
|
const path = session.worktree_path!;
|
|
207
221
|
await deleteWorktree(path, fate.repo, true).catch(() => {});
|
package/lib/hosts/remote-api.ts
CHANGED
|
@@ -70,6 +70,18 @@ export const cleanRemoteText = (s: unknown): string =>
|
|
|
70
70
|
.trim()
|
|
71
71
|
.slice(0, 300);
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Another machine's multi-line text (a chat's tail): control characters
|
|
75
|
+
* other than newline and tab become spaces, and the end is kept within `cap`.
|
|
76
|
+
*/
|
|
77
|
+
export const cleanRemoteBlock = (s: unknown, cap: number): string => {
|
|
78
|
+
const text = String(s ?? "")
|
|
79
|
+
.replace(/\r\n?/g, "\n")
|
|
80
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, " ")
|
|
81
|
+
.replace(/\s+$/, "");
|
|
82
|
+
return text.length > cap ? `…${text.slice(text.length - cap + 1)}` : text;
|
|
83
|
+
};
|
|
84
|
+
|
|
73
85
|
/**
|
|
74
86
|
* Why a call failed. `refused`: that AgentOS answered and said no, so nothing
|
|
75
87
|
* happened there. Otherwise the outcome is unknown (unreachable, timed out,
|
|
@@ -9,6 +9,7 @@ import { blockedReason } from "../tasks/state";
|
|
|
9
9
|
import { checkWaitingPatterns, statusDetector } from "../status-detector";
|
|
10
10
|
import { getCheck, putCheck } from "./checks";
|
|
11
11
|
import { cleanRemoteText, hostLink, type HostLink } from "../hosts/remote-api";
|
|
12
|
+
import { heldChatFacts } from "../tasks/held-remote";
|
|
12
13
|
import { hostTasks } from "../tasks/remote";
|
|
13
14
|
import { peerPane } from "../hosts/peer-sessions";
|
|
14
15
|
import { untrusted } from "./untrusted";
|
|
@@ -24,21 +25,7 @@ export async function waitingState(
|
|
|
24
25
|
// its own reading of the task is asked for, fresh.
|
|
25
26
|
const link = hostLink(task.host_id);
|
|
26
27
|
if (link && task.view === "chat") return peerChatWaiting(link, task);
|
|
27
|
-
if (task.view === "chat")
|
|
28
|
-
const reason = blockedReason(saidSinceLastMessage(task.id));
|
|
29
|
-
// Asked of a worker still running from before a restart, too; one
|
|
30
|
-
// that can't be reached can't be cleared.
|
|
31
|
-
const state = await chatStateNow(task.id).catch(() => "unknown");
|
|
32
|
-
return {
|
|
33
|
-
blocked: reason === null ? null : untrusted(task.name, reason),
|
|
34
|
-
waitingOn:
|
|
35
|
-
state === "waiting"
|
|
36
|
-
? "an approval or question in its chat"
|
|
37
|
-
: state === "unknown"
|
|
38
|
-
? "its chat worker couldn't be reached, so an open approval or question can't be ruled out"
|
|
39
|
-
: null,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
28
|
+
if (task.view === "chat") return localChatWaiting(task);
|
|
42
29
|
// A linked machine reads its own screens: asked, and an answer it can't
|
|
43
30
|
// give is a screen that can't be cleared.
|
|
44
31
|
let tail: string;
|
|
@@ -71,6 +58,26 @@ export async function waitingState(
|
|
|
71
58
|
};
|
|
72
59
|
}
|
|
73
60
|
|
|
61
|
+
// A chat task whose conversation is in this machine's store (or, for a held
|
|
62
|
+
// task, the one the holder asks about).
|
|
63
|
+
export async function localChatWaiting(
|
|
64
|
+
task: Pick<Session, "id" | "name">
|
|
65
|
+
): Promise<{ blocked: string | null; waitingOn: string | null }> {
|
|
66
|
+
const reason = blockedReason(saidSinceLastMessage(task.id));
|
|
67
|
+
// Asked of a worker still running from before a restart, too; one
|
|
68
|
+
// that can't be reached can't be cleared.
|
|
69
|
+
const state = await chatStateNow(task.id).catch(() => "unknown");
|
|
70
|
+
return {
|
|
71
|
+
blocked: reason === null ? null : untrusted(task.name, reason),
|
|
72
|
+
waitingOn:
|
|
73
|
+
state === "waiting"
|
|
74
|
+
? "an approval or question in its chat"
|
|
75
|
+
: state === "unknown"
|
|
76
|
+
? "its chat worker couldn't be reached, so an open approval or question can't be ruled out"
|
|
77
|
+
: null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
74
81
|
async function peerChatWaiting(
|
|
75
82
|
link: HostLink,
|
|
76
83
|
task: Session
|
|
@@ -79,6 +86,24 @@ async function peerChatWaiting(
|
|
|
79
86
|
blocked: `its chat on ${link.hostName} couldn't be read, so a BLOCKED: line or open question can't be ruled out`,
|
|
80
87
|
waitingOn: null,
|
|
81
88
|
};
|
|
89
|
+
// A task its orchestrator moved is asked about directly: the whole task
|
|
90
|
+
// list is slow, and a miss there reads as unread.
|
|
91
|
+
const facts = task.started_by ? await heldChatFacts(task) : null;
|
|
92
|
+
if (facts) {
|
|
93
|
+
const { turn } = facts;
|
|
94
|
+
return {
|
|
95
|
+
blocked: facts.blocked ? untrusted(task.name, facts.blocked) : null,
|
|
96
|
+
waitingOn:
|
|
97
|
+
turn === "waiting"
|
|
98
|
+
? `an approval or question in its chat on ${link.hostName}`
|
|
99
|
+
: turn === null ||
|
|
100
|
+
turn === "running" ||
|
|
101
|
+
turn === "idle" ||
|
|
102
|
+
turn === "error"
|
|
103
|
+
? null
|
|
104
|
+
: `its chat on ${link.hostName} couldn't say whether a question is open`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
82
107
|
const view = await hostTasks(link, true)
|
|
83
108
|
.then(({ tasks, capabilities }) =>
|
|
84
109
|
// One that can't say its chat's turn can't rule out an open question.
|
package/lib/security/demo.ts
CHANGED
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/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.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 {
|
|
@@ -125,6 +139,7 @@ function orchestratedBundle(session: Session): OrchestratedBundle {
|
|
|
125
139
|
modelOverrides: session.model_overrides ?? null,
|
|
126
140
|
planPhase: session.plan_phase ?? null,
|
|
127
141
|
planFile: readPlanFile(session.working_directory),
|
|
142
|
+
orchestrator: orchestratorOf(session),
|
|
128
143
|
};
|
|
129
144
|
}
|
|
130
145
|
|
|
@@ -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/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 {
|