@saadnvd1/agent-os 0.3.517 → 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.
Files changed (57) hide show
  1. package/README.md +23 -3
  2. package/app/api/bus/peers/route.ts +8 -2
  3. package/app/api/tasks/[id]/arm/route.ts +5 -0
  4. package/app/api/tasks/[id]/chat-facts/route.ts +21 -0
  5. package/app/api/tasks/[id]/chat-tail/route.ts +15 -0
  6. package/app/api/tasks/[id]/done/route.ts +15 -0
  7. package/app/api/tasks/[id]/export/route.ts +6 -1
  8. package/app/api/tasks/[id]/merge/route.ts +9 -1
  9. package/app/api/tasks/[id]/message/route.ts +39 -0
  10. package/app/api/tasks/[id]/moved/route.ts +13 -1
  11. package/app/api/tasks/[id]/resume/route.ts +6 -1
  12. package/app/api/tasks/[id]/stop/route.ts +14 -0
  13. package/app/api/tasks/held-replies/route.ts +31 -0
  14. package/app/api/tasks/import/route.ts +11 -1
  15. package/lib/bus/held-reply.ts +89 -0
  16. package/lib/bus/index.ts +101 -12
  17. package/lib/db/migrations.ts +50 -0
  18. package/lib/db/types.ts +9 -0
  19. package/lib/done/bulk.ts +8 -0
  20. package/lib/done/index.ts +15 -0
  21. package/lib/done/worktree.ts +24 -10
  22. package/lib/hosts/remote-api.ts +12 -0
  23. package/lib/models/plan-step.ts +13 -3
  24. package/lib/orchestrator/act.ts +41 -1
  25. package/lib/orchestrator/armed.ts +6 -1
  26. package/lib/orchestrator/brakes.ts +9 -0
  27. package/lib/orchestrator/brief.ts +2 -1
  28. package/lib/orchestrator/facts.ts +72 -21
  29. package/lib/orchestrator/read.ts +14 -2
  30. package/lib/orchestrator/serve.ts +6 -0
  31. package/lib/orchestrator/shell-guard.ts +15 -0
  32. package/lib/orchestrator/task-state.ts +40 -15
  33. package/lib/orchestrator/tool-names.ts +1 -0
  34. package/lib/orchestrator/tool-schemas.ts +11 -0
  35. package/lib/orchestrator/tools.ts +1 -0
  36. package/lib/security/demo.ts +1 -0
  37. package/lib/sessions/stop.ts +15 -1
  38. package/lib/status-detector.ts +4 -0
  39. package/lib/tasks/branch-worktree.ts +16 -1
  40. package/lib/tasks/finish.ts +5 -1
  41. package/lib/tasks/held-remote.ts +109 -0
  42. package/lib/tasks/held-replies.ts +106 -0
  43. package/lib/tasks/held-routes.ts +33 -0
  44. package/lib/tasks/held.ts +28 -0
  45. package/lib/tasks/import.ts +65 -10
  46. package/lib/tasks/index.ts +11 -1
  47. package/lib/tasks/last-active.ts +25 -0
  48. package/lib/tasks/move-bundle.ts +72 -3
  49. package/lib/tasks/move-carry.ts +102 -0
  50. package/lib/tasks/move-flow.ts +55 -9
  51. package/lib/tasks/move-guard.ts +17 -13
  52. package/lib/tasks/move-recover.ts +2 -0
  53. package/lib/tasks/move.ts +49 -3
  54. package/lib/tasks/moved-away.ts +37 -0
  55. package/lib/tasks/remote.ts +6 -0
  56. package/lib/tasks/session.ts +1 -1
  57. package/package.json +1 -1
@@ -58,11 +58,12 @@ import { remoteTaskViews } from "./remote-views";
58
58
  import { chatStateNow } from "../chat/runner";
59
59
  import { saidSinceLastMessage } from "../chat/store";
60
60
 
61
+ import { lastActiveOf } from "./last-active";
61
62
  export * from "./state";
62
63
  export { codeReviewGate, codeReviewRefusal } from "./code-review";
63
64
  export { signOffTask, dropTask, signingOff, mergeSettled } from "./finish";
64
65
  export { prFor as taskPR } from "./session";
65
- export { moveTask } from "./move-flow";
66
+ export { assertMovable, moveTask } from "./move-flow";
66
67
 
67
68
  export interface TaskView {
68
69
  id: string;
@@ -106,6 +107,12 @@ export interface TaskView {
106
107
  quickOf?: string | null;
107
108
  // What it has cost so far in weighted tokens (lib/usage/weights).
108
109
  weighted?: number;
110
+ // Its last sign of life, in ms, for an orchestrator on another machine.
111
+ lastActiveAt?: number;
112
+ // Held for another machine's orchestrator (held.ts): which machine, and
113
+ // the "plan written" line it would have been told.
114
+ heldFrom?: string | null;
115
+ planNote?: string | null;
109
116
  }
110
117
 
111
118
  export async function createTask(opts: {
@@ -394,6 +401,9 @@ export async function taskView(session: Session): Promise<TaskView> {
394
401
  planning: session.plan_phase === "planning",
395
402
  plan: taskPlan(session),
396
403
  quickOf: session.quick_of ?? null,
404
+ lastActiveAt: session.held_by_device ? lastActiveOf(session) : undefined,
405
+ heldFrom: session.held_by_device ? (session.held_from ?? null) : null,
406
+ planNote: session.held_by_device ? (session.plan_note ?? null) : null,
397
407
  };
398
408
  }
399
409
 
@@ -0,0 +1,25 @@
1
+ // A session's last sign of life, in ms: its row, its tmux pane, its chat.
2
+
3
+ import { db, type Session } from "../db";
4
+ import { statusDetector } from "../status-detector";
5
+
6
+ export const sqliteMs = (t: string | null | undefined) =>
7
+ t ? Date.parse(`${t.replace(" ", "T")}Z`) || 0 : 0;
8
+
9
+ export function latestChatAt(sessionId: string): number {
10
+ const row = db
11
+ .prepare(
12
+ `SELECT data FROM chat_items WHERE session_id = ? ORDER BY seq DESC LIMIT 1`
13
+ )
14
+ .get(sessionId) as { data: string } | undefined;
15
+ return row
16
+ ? ((JSON.parse(row.data) as { createdAt?: number }).createdAt ?? 0)
17
+ : 0;
18
+ }
19
+
20
+ export const lastActiveOf = (s: Session): number =>
21
+ Math.max(
22
+ sqliteMs(s.updated_at),
23
+ statusDetector.getTimestamp(s.tmux_name) * 1000,
24
+ latestChatAt(s.id)
25
+ );
@@ -14,8 +14,10 @@ export const MAX_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
14
14
  // whole bundle stays under what a linked machine reads of an answer
15
15
  // (lib/hosts/remote-api), since a move back here reads it as one.
16
16
  export const MAX_CHAT_BYTES = 12 * 1024 * 1024;
17
+ // A task's plan file is a page or two; this is far past that.
18
+ export const MAX_PLAN_BYTES = 1024 * 1024;
17
19
  export const MAX_BUNDLE_BYTES =
18
- MAX_TRANSCRIPT_BYTES + MAX_CHAT_BYTES + 1024 * 1024;
20
+ MAX_TRANSCRIPT_BYTES + MAX_CHAT_BYTES + MAX_PLAN_BYTES + 1024 * 1024;
19
21
 
20
22
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
21
23
  export const isSessionId = (id: unknown): id is string =>
@@ -50,6 +52,28 @@ export interface TaskBundle {
50
52
  // A chat task's own state; absent for a terminal task, or from a machine
51
53
  // that predates chat moves.
52
54
  chat?: ChatBundle;
55
+ // What a task started by an orchestrator carries besides its work; absent
56
+ // for any other task, or from a machine that predates these moves.
57
+ orchestrated?: OrchestratedBundle;
58
+ }
59
+
60
+ export interface OrchestratedBundle {
61
+ // The machine whose orchestrator holds the task, for the other machine's
62
+ // screens and refusals.
63
+ heldFrom: string;
64
+ // When the task first started (an ISO time), so a PR opened before the
65
+ // move still counts as the task's.
66
+ since: string | null;
67
+ // The model's provider and a task's own models, as they were; an arriving
68
+ // machine without that provider refuses the task rather than change it.
69
+ modelProvider: string | null;
70
+ modelOverrides: string | null;
71
+ planPhase: "planning" | "implementing" | null;
72
+ // .agentos/plan.md, which is never in git.
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 };
53
77
  }
54
78
 
55
79
  export interface ChatBundle {
@@ -101,6 +125,47 @@ function validateChat(c: ChatBundle): ChatBundle {
101
125
  return { ...c, plan: !!c.plan };
102
126
  }
103
127
 
128
+ const PHASES = ["planning", "implementing"];
129
+
130
+ function validateOrchestrated(o: OrchestratedBundle): OrchestratedBundle {
131
+ if (!o || typeof o !== "object") throw new Error("Bad orchestrated task");
132
+ if (
133
+ !isStrOrNull(o.since) ||
134
+ !isStrOrNull(o.modelProvider) ||
135
+ !isStrOrNull(o.modelOverrides) ||
136
+ !isStrOrNull(o.planFile) ||
137
+ !(o.planPhase === null || PHASES.includes(o.planPhase as string))
138
+ )
139
+ throw new Error("Bad orchestrated task");
140
+ if (o.since !== null && Number.isNaN(Date.parse(o.since)))
141
+ throw new Error("Bad start time");
142
+ if ((o.planFile?.length ?? 0) > MAX_PLAN_BYTES)
143
+ throw new Error("The plan is too large to move");
144
+ return {
145
+ heldFrom: String(o.heldFrom || "another machine")
146
+ .replace(/[^\w.@ -]/g, "")
147
+ .slice(0, 100),
148
+ since: o.since,
149
+ modelProvider: o.modelProvider,
150
+ modelOverrides: o.modelOverrides,
151
+ planPhase: o.planPhase,
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,
166
+ };
167
+ }
168
+
104
169
  export function validateBundle(b: TaskBundle): TaskBundle {
105
170
  if (!b || typeof b !== "object") throw new Error("No task to import");
106
171
  if (!isSessionId(b.moveId)) throw new Error("Bad move id");
@@ -130,6 +195,9 @@ export function validateBundle(b: TaskBundle): TaskBundle {
130
195
  }
131
196
  return {
132
197
  ...b,
198
+ orchestrated: b.orchestrated
199
+ ? validateOrchestrated(b.orchestrated)
200
+ : undefined,
133
201
  chat: b.chat ? validateChat(b.chat) : undefined,
134
202
  name: b.name.slice(0, 200),
135
203
  from: String(b.from || "another machine")
@@ -140,10 +208,11 @@ export function validateBundle(b: TaskBundle): TaskBundle {
140
208
 
141
209
  export function arrivalNote(
142
210
  b: { from: string; branch: string },
143
- cwd: string
211
+ cwd: string,
212
+ here: string
144
213
  ): string {
145
214
  return [
146
- `This task just moved here from ${b.from}. Your worktree is now ${cwd}, on branch ${b.branch}.`,
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}.`,
147
216
  `Anything that was uncommitted came over as a commit starting "wip: moving to"; fold it into your own commits as you see fit.`,
148
217
  `Continue the task where you left off.`,
149
218
  ].join(" ");
@@ -0,0 +1,102 @@
1
+ /**
2
+ * An orchestrator's state about a task is keyed by the task's session id, and
3
+ * a move gives the task a new one on each machine. Carrying re-keys that state
4
+ * from the old id to the new, inside the transaction that makes the new row,
5
+ * so the gates, failure counts, asks, scope changes and events judge the task
6
+ * the same after a move. move-carry.test.ts fails on any table that keys by a
7
+ * session and is in neither list, so a new one gets decided.
8
+ */
9
+
10
+ import type Database from "better-sqlite3";
11
+ import { db } from "../db";
12
+
13
+ // table, column, and how the id sits in it: the whole value, `task:<id>`, or
14
+ // the tail of a `<condition>:<id>` key.
15
+ export const REKEYED = [
16
+ ["orchestrator_starts", "target", "id"],
17
+ ["orchestrator_checks", "session_id", "id"],
18
+ ["orchestrator_gate_failures", "session_id", "id"],
19
+ ["orchestrator_gate_failure_commits", "session_id", "id"],
20
+ ["orchestrator_asks", "subject", "task"],
21
+ ["brief_amendments", "session_id", "id"],
22
+ ["task_pr_wakes", "session_id", "id"],
23
+ ["orchestrator_events", "subject", "id"],
24
+ ["orchestrator_events", "key", "tail"],
25
+ ["check_runs", "session_id", "id"],
26
+ ["away_actions", "subject", "task"],
27
+ ["task_queue", "after_task", "id"],
28
+ ] as const;
29
+
30
+ // Session-keyed columns that stay on the old id, and why.
31
+ export const KEPT = [
32
+ ["orchestrator_event_log", "subject", "history is never rewritten"],
33
+ ["stack_items", "session_id", "stacked tasks don't move"],
34
+ [
35
+ "task_queue",
36
+ "after_any",
37
+ "a JSON list; a queued task waits on any of them",
38
+ ],
39
+ ] as const;
40
+
41
+ // Copied onto the new row where it has no value.
42
+ export const CARRIED_COLUMNS = [
43
+ "started_by",
44
+ "plan_decided",
45
+ "plan_reason",
46
+ "plan_phase",
47
+ "model_provider",
48
+ "model_overrides",
49
+ "merge_armed_at",
50
+ "merge_armed_seen",
51
+ "rebased_from",
52
+ "rebased_to",
53
+ "rebase_asked_head",
54
+ "pr_wake_since",
55
+ "pr_url",
56
+ "pr_number",
57
+ "pr_status",
58
+ ] as const;
59
+
60
+ /** The orchestration columns that make a session "an orchestrator's task". */
61
+ export const orchestrationOf = (session: Record<string, unknown>) =>
62
+ Object.fromEntries(
63
+ CARRIED_COLUMNS.map((c) => [c, session[c] ?? null] as const)
64
+ );
65
+
66
+ export function carryTaskState(
67
+ fromId: string,
68
+ toId: string,
69
+ handle: Database.Database = db
70
+ ): void {
71
+ if (fromId === toId) return;
72
+ for (const [table, col, shape] of REKEYED) {
73
+ if (shape === "id")
74
+ handle
75
+ .prepare(`UPDATE ${table} SET ${col} = ? WHERE ${col} = ?`)
76
+ .run(toId, fromId);
77
+ else if (shape === "task")
78
+ handle
79
+ .prepare(`UPDATE ${table} SET ${col} = ? WHERE ${col} = ?`)
80
+ .run(`task:${toId}`, `task:${fromId}`);
81
+ else
82
+ handle
83
+ .prepare(
84
+ `UPDATE OR IGNORE ${table}
85
+ SET ${col} = substr(${col}, 1, length(${col}) - ?) || ?
86
+ WHERE ${col} LIKE ?`
87
+ )
88
+ .run(fromId.length, toId, `%:${fromId}`);
89
+ }
90
+ const from = handle
91
+ .prepare(`SELECT * FROM sessions WHERE id = ?`)
92
+ .get(fromId) as Record<string, unknown> | undefined;
93
+ if (!from) return;
94
+ const sets = [...CARRIED_COLUMNS, "task_since"]
95
+ .map((c) => `${c} = COALESCE(${c}, @${c})`)
96
+ .join(", ");
97
+ handle.prepare(`UPDATE sessions SET ${sets} WHERE id = @id`).run({
98
+ ...orchestrationOf(from),
99
+ task_since: (from.task_since as string | null) ?? from.created_at ?? null,
100
+ id: toId,
101
+ });
102
+ }
@@ -10,8 +10,11 @@ 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, markMoved, resumeTask, type TaskBundle } from "./move";
14
- import { moveRefusal } from "./move-guard";
13
+ import { exportOrResume, resumeTask, type TaskBundle } from "./move";
14
+ import { isOrchestrated, moveRefusal } from "./move-guard";
15
+ import { holderRefusal } from "./held";
16
+ import { isPaused } from "../orchestrator/pause";
17
+ import { sessionWorkspace } from "../models/resolve";
15
18
  import { arrived as arrivedHere, importTask } from "./import";
16
19
  import {
17
20
  forgetHostTasks,
@@ -34,7 +37,8 @@ const LIVE = new Set(["running", "moving"]);
34
37
  const g = globalThis as unknown as { __agentosMovesInFlight?: Set<string> };
35
38
  const inFlight = (g.__agentosMovesInFlight ??= new Set());
36
39
 
37
- export async function moveTask(id: string, toHostId: string): Promise<Session> {
40
+ /** Why this task can't move to that machine now, as an error; or nothing. */
41
+ export function assertMovable(id: string, toHostId: string): Session {
38
42
  const session = getTaskSession(id);
39
43
  if (!LIVE.has(session.task_status ?? ""))
40
44
  throw new Error(`Task is already ${session.task_status}`);
@@ -47,9 +51,22 @@ export async function moveTask(id: string, toHostId: string): Promise<Session> {
47
51
  : "It's already on this machine"
48
52
  );
49
53
  }
54
+ // A person here can't move what another machine's orchestrator holds.
55
+ const held = holderRefusal(session, null);
56
+ if (held) throw new Error(held);
50
57
  const refusal = moveRefusal(session);
51
58
  if (refusal) throw new Error(refusal);
59
+ // Pause stops the orchestrator's new work, and a move is some.
60
+ const workspace = sessionWorkspace(session);
61
+ if (workspace && isOrchestrated(session) && isPaused(workspace))
62
+ throw new Error("The orchestrator is paused; moves wait for Resume");
52
63
  if (inFlight.has(id)) throw new Error("It's already moving");
64
+ return session;
65
+ }
66
+
67
+ export async function moveTask(id: string, toHostId: string): Promise<Session> {
68
+ const session = assertMovable(id, toHostId);
69
+ const toRemote = isRemoteHost(toHostId);
53
70
  inFlight.add(id);
54
71
  try {
55
72
  const moved = toRemote
@@ -66,19 +83,27 @@ export async function moveTask(id: string, toHostId: string): Promise<Session> {
66
83
  }
67
84
 
68
85
  // A machine on an older AgentOS would take a chat task as a terminal one,
69
- // or refuse to hand one over.
70
- async function requireChatMoves(link: HostLink): Promise<void> {
86
+ // or an orchestrator's task as a plain one that anyone there could merge, or
87
+ // refuse to hand one over.
88
+ async function requireCapability(
89
+ link: HostLink,
90
+ capability: string,
91
+ what: string
92
+ ): Promise<void> {
71
93
  const { capabilities = [] } = await hostTasks(link, true).catch((err) => {
72
94
  throw new Error(
73
- `Can't ask ${link.hostName} whether it takes chat tasks: ${(err as Error).message}`
95
+ `Can't ask ${link.hostName} whether it takes ${what}: ${(err as Error).message}`
74
96
  );
75
97
  });
76
- if (!capabilities.includes("chat-move"))
98
+ if (!capabilities.includes(capability))
77
99
  throw new Error(
78
- `${link.hostName}'s AgentOS can't move chat tasks yet; update it first`
100
+ `${link.hostName}'s AgentOS can't move ${what} yet; update it first`
79
101
  );
80
102
  }
81
103
 
104
+ const requireChatMoves = (link: HostLink) =>
105
+ requireCapability(link, "chat-move", "chat tasks");
106
+
82
107
  const unconfirmed = (where: string, err: unknown) =>
83
108
  new Error(
84
109
  `Couldn't confirm the move with ${where} (${(err as Error).message}). It's paused as "moving": press Move again to finish it.`
@@ -88,6 +113,8 @@ async function moveOut(session: Session, hostId: string): Promise<Session> {
88
113
  const link = requireHostLink(hostId);
89
114
  const chat = session.view === "chat";
90
115
  if (chat) await requireChatMoves(link);
116
+ if (isOrchestrated(session))
117
+ await requireCapability(link, "orchestrated-move", "orchestrator tasks");
91
118
  startProgress(
92
119
  session.id,
93
120
  link.hostName,
@@ -119,6 +146,13 @@ async function moveOut(session: Session, hostId: string): Promise<Session> {
119
146
  return settleMovedOut(session, hostId, link.hostName, there);
120
147
  }
121
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
+
122
156
  async function moveIn(mirror: Session): Promise<Session> {
123
157
  const link = requireHostLink(mirror.host_id);
124
158
  if (mirror.view === "chat") await requireChatMoves(link);
@@ -127,6 +161,12 @@ async function moveIn(mirror: Session): Promise<Session> {
127
161
  // A retry after it arrived here but that machine didn't hear: tidy up.
128
162
  let here = arrivedHere(mirror.id);
129
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);
130
170
  // That machine resumes the agent itself if its half fails there.
131
171
  stepProgress(mirror.id, "export");
132
172
  let bundle: TaskBundle;
@@ -138,6 +178,7 @@ async function moveIn(mirror: Session): Promise<Session> {
138
178
  300000
139
179
  ));
140
180
  } catch (err) {
181
+ mirrorRunning(mirror.id);
141
182
  if (unknownOutcome(err)) throw unconfirmed(link.hostName, err);
142
183
  throw err;
143
184
  }
@@ -149,6 +190,7 @@ async function moveIn(mirror: Session): Promise<Session> {
149
190
  `${(err as Error).message}; and ${link.hostName} didn't resume it: ${e.message}`
150
191
  );
151
192
  });
193
+ mirrorRunning(mirror.id);
152
194
  throw err;
153
195
  }
154
196
  }
@@ -163,7 +205,11 @@ async function moveIn(mirror: Session): Promise<Session> {
163
205
  `It's running here now, but ${link.hostName} didn't confirm (${(err as Error).message}). Press Move here again to tidy up.`
164
206
  );
165
207
  }
166
- markMoved(mirror.id, os.hostname());
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
+ );
167
213
  forgetHostTasks(link.hostId);
168
214
  return db
169
215
  .prepare(`SELECT * FROM sessions WHERE id = ?`)
@@ -1,13 +1,24 @@
1
1
  /**
2
2
  * Tasks that can't move: one waiting on a person (an escalated gate or an
3
- * open ask), on a LumifyHub card, in a stack, or started by an orchestrator
4
- * (its brakes, ceiling and Pause count only what runs here). Their guard state is keyed
5
- * by this session and wouldn't follow it, so the other machine could merge
6
- * what's waiting on them.
3
+ * open ask), on a LumifyHub card, or in a stack. A task an orchestrator
4
+ * started does move: its state is re-keyed to the new session (move-carry),
5
+ * and the machine it lands on holds it for that orchestrator's device
6
+ * (held.ts), so nothing there merges it.
7
7
  */
8
8
 
9
9
  import { db, type Session } from "../db";
10
10
  import { escalations } from "../orchestrator/gates";
11
+ import { taskSubject } from "../orchestrator/asks";
12
+
13
+ /** Started by an orchestrator, or held for one by the machine it left. */
14
+ export function isOrchestrated(session: Session): boolean {
15
+ if (session.started_by || session.held_by_device) return true;
16
+ return !!db
17
+ .prepare(
18
+ `SELECT 1 FROM orchestrator_starts WHERE kind != 'stack' AND target = ? LIMIT 1`
19
+ )
20
+ .get(session.id);
21
+ }
11
22
 
12
23
  export function moveRefusal(session: Session): string | null {
13
24
  if (session.quick_of)
@@ -16,9 +27,9 @@ export function moveRefusal(session: Session): string | null {
16
27
  return "It's waiting on you (an escalated gate), so it stays here";
17
28
  const ask = db
18
29
  .prepare(
19
- `SELECT 1 FROM orchestrator_asks WHERE subject = ? AND status = 'open' LIMIT 1`
30
+ `SELECT 1 FROM orchestrator_asks WHERE subject IN (?, ?) AND status = 'open' LIMIT 1`
20
31
  )
21
- .get(session.id);
32
+ .get(session.id, taskSubject(session.id));
22
33
  if (ask) return "It has an open ask waiting on you, so it stays here";
23
34
  const row = db
24
35
  .prepare(
@@ -31,12 +42,5 @@ export function moveRefusal(session: Session): string | null {
31
42
  | undefined;
32
43
  if (row?.lh_card_id) return "Card tasks run on this machine only for now";
33
44
  if (row?.stacked) return "Stacked tasks run on this machine only for now";
34
- const orchestrated = db
35
- .prepare(
36
- `SELECT 1 FROM orchestrator_starts WHERE kind != 'stack' AND target = ? LIMIT 1`
37
- )
38
- .get(session.id);
39
- if (orchestrated)
40
- return "An orchestrator's tasks run on this machine only for now";
41
45
  return null;
42
46
  }
@@ -12,6 +12,7 @@ import { hostApi, hostLink, type HostLink } from "../hosts/remote-api";
12
12
  import { getTaskSession } from "./session";
13
13
  import { markMoved, resumeTask } from "./move";
14
14
  import { forgetHostTasks, mirrorTask, unknownOutcome } from "./remote";
15
+ import { carryTaskState } from "./move-carry";
15
16
 
16
17
  /** POST, repeated when the outcome is unknown (the calls are idempotent). */
17
18
  export async function tell(
@@ -50,6 +51,7 @@ export function settleMovedOut(
50
51
  db.transaction(() => {
51
52
  markMoved(session.id, hostName);
52
53
  mirrorTask(hostId, session.project_id, there);
54
+ carryTaskState(session.id, there.id);
53
55
  })();
54
56
  forgetHostTasks(hostId);
55
57
  return db
package/lib/tasks/move.ts CHANGED
@@ -6,18 +6,26 @@
6
6
  * it off and a second move of it is refused.
7
7
  */
8
8
 
9
+ import fs from "fs";
9
10
  import os from "os";
11
+ import path from "path";
10
12
  import { db, type Session } from "../db";
11
13
  import { getProject } from "../projects";
12
14
  import { endTmuxSession } from "../tmux/end";
13
15
  import { launchClaude } from "../agents/launch";
14
16
  import { buildTaskBrief } from "./brief";
15
17
  import { git as runGit } from "../git-exec";
16
- import { getTaskSession } from "./session";
18
+ import { getTaskSession, isoUTC } from "./session";
17
19
  import { projectRef } from "./project-ref";
18
- import { moveRefusal } from "./move-guard";
19
- import { MAX_TRANSCRIPT_BYTES, type TaskBundle } from "./move-bundle";
20
+ import { isOrchestrated, moveRefusal } from "./move-guard";
21
+ import {
22
+ MAX_PLAN_BYTES,
23
+ MAX_TRANSCRIPT_BYTES,
24
+ type OrchestratedBundle,
25
+ type TaskBundle,
26
+ } from "./move-bundle";
20
27
  import { stepProgress } from "./move-progress";
28
+ import { PLAN_FILE } from "../models/plan-step";
21
29
  import { dropSessionDatabase } from "../project-config/database";
22
30
  import { releaseChat } from "../chat/runner";
23
31
  import { collectWhileMoving } from "../chat/hold";
@@ -98,6 +106,43 @@ function claimMoving(id: string, to: string): { fresh: boolean } {
98
106
  // Set by exportTask: did this call stop a running agent, or retry a move?
99
107
  const freshClaims = new Set<string>();
100
108
 
109
+ const readPlanFile = (cwd: string): string | null => {
110
+ try {
111
+ const text = fs.readFileSync(path.join(cwd, PLAN_FILE), "utf8");
112
+ return text.length > MAX_PLAN_BYTES ? null : text;
113
+ } catch {
114
+ return null;
115
+ }
116
+ };
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
+
132
+ // What an orchestrator's task carries besides its work (the orchestrator's
133
+ // own state is re-keyed here; see move-carry).
134
+ function orchestratedBundle(session: Session): OrchestratedBundle {
135
+ return {
136
+ heldFrom: session.held_from || os.hostname(),
137
+ since: isoUTC(session.task_since ?? session.created_at),
138
+ modelProvider: session.model_provider ?? null,
139
+ modelOverrides: session.model_overrides ?? null,
140
+ planPhase: session.plan_phase ?? null,
141
+ planFile: readPlanFile(session.working_directory),
142
+ orchestrator: orchestratorOf(session),
143
+ };
144
+ }
145
+
101
146
  /**
102
147
  * Stops the agent and pushes everything. On failure the task stays moving;
103
148
  * the caller resumes it (exportOrResume) when it knows nothing arrived.
@@ -163,6 +208,7 @@ export async function exportTask(id: string, to: string): Promise<TaskBundle> {
163
208
  ? { sessionId: claudeId, cwd, home: os.homedir(), transcript }
164
209
  : null,
165
210
  chat: chatBundle,
211
+ orchestrated: isOrchestrated(now) ? orchestratedBundle(now) : undefined,
166
212
  };
167
213
  } finally {
168
214
  packed();
@@ -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
+ }
@@ -27,6 +27,12 @@ export const TASK_CAPABILITIES = [
27
27
  "chat-turn",
28
28
  "keyed-start",
29
29
  "chat-move",
30
+ // Holds an orchestrator's task for the machine that sent it, and answers
31
+ // its messages, reads, stops and done (lib/tasks/held.ts).
32
+ "orchestrated-move",
33
+ // A held task's chat facts and its messages for its orchestrator.
34
+ "held-chat-facts",
35
+ "held-replies",
30
36
  ] as const;
31
37
 
32
38
  export interface HostTasks {
@@ -170,7 +170,7 @@ export async function prFor(
170
170
  const branch = renamed ?? session.branch_name;
171
171
  if (!branch) return null;
172
172
  // A PR older than the task is an earlier use of the branch's name.
173
- const since = isoUTC(session.created_at);
173
+ const since = isoUTC(session.task_since ?? session.created_at);
174
174
  let pr: TaskPR | null;
175
175
  if (renamed) {
176
176
  // Follow a rename only on gh's answer: a failure leaves the row put.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.517",
3
+ "version": "0.3.519",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"