@saadnvd1/agent-os 0.3.579 → 0.3.580

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.
@@ -0,0 +1,28 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { callingDevice } from "@/lib/security/device-me";
3
+ import { holdExisting } from "@/lib/tasks/held";
4
+ import { validateOrchestrated } from "@/lib/tasks/move-bundle";
5
+
6
+ // POST /api/tasks/:id/hold - the paired device whose orchestrator started a
7
+ // task here asks to hold it, as a move would have: for a queue-placed task
8
+ // started before holding existed. It never starts a task.
9
+ export async function POST(
10
+ request: NextRequest,
11
+ ctx: { params: Promise<{ id: string }> }
12
+ ) {
13
+ const id = (await ctx.params).id;
14
+ const device = callingDevice(request);
15
+ if (!device)
16
+ return NextResponse.json(
17
+ { error: "An orchestrator's task comes only from a paired machine" },
18
+ { status: 403 }
19
+ );
20
+ try {
21
+ const { orchestrated } = await request.json();
22
+ const held = holdExisting(id, device, validateOrchestrated(orchestrated));
23
+ return NextResponse.json({ held }, { status: held ? 200 : 404 });
24
+ } catch (error) {
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ return NextResponse.json({ error: message }, { status: 400 });
27
+ }
28
+ }
@@ -5,7 +5,12 @@ import { stampPriority, startOrQueue } from "@/lib/tasks/queue";
5
5
  import { isPriority } from "@/lib/tasks/schedule-types";
6
6
  import { TASK_CAPABILITIES } from "@/lib/tasks/remote";
7
7
  import { ensureProject, type ProjectRef } from "@/lib/tasks/project-ref";
8
- import { isSessionId, statusFor } from "@/lib/tasks/move-bundle";
8
+ import { callingDevice } from "@/lib/security/device-me";
9
+ import {
10
+ isSessionId,
11
+ statusFor,
12
+ validateOrchestrated,
13
+ } from "@/lib/tasks/move-bundle";
9
14
  import { startView } from "@/lib/sessions/launch";
10
15
  import { isValidAgentType } from "@/lib/providers";
11
16
  import { isAgentModelName } from "@/lib/providers/registry";
@@ -43,6 +48,7 @@ export async function POST(request: NextRequest) {
43
48
  plan_only,
44
49
  priority,
45
50
  machine,
51
+ orchestrated,
46
52
  } = await request.json();
47
53
  if (id !== undefined && !isSessionId(id)) throw new Error("Bad task id");
48
54
  const pid =
@@ -81,7 +87,26 @@ export async function POST(request: NextRequest) {
81
87
  view: startView(view),
82
88
  };
83
89
  if (id !== undefined) {
84
- const session = await createTask({ id, ...opts });
90
+ // An orchestrator's task is held for the paired device that sent it,
91
+ // as a moved one is (lib/tasks/held.ts).
92
+ const device = callingDevice(request);
93
+ let held;
94
+ if (orchestrated !== undefined) {
95
+ if (!device)
96
+ return NextResponse.json(
97
+ {
98
+ error: "An orchestrator's task comes only from a paired machine",
99
+ },
100
+ { status: 403 }
101
+ );
102
+ held = { device, orchestrated: validateOrchestrated(orchestrated) };
103
+ }
104
+ const session = await createTask({
105
+ id,
106
+ ...opts,
107
+ held,
108
+ startedByDevice: device ?? undefined,
109
+ });
85
110
  return NextResponse.json({ session }, { status: 201 });
86
111
  }
87
112
  const out = await startOrQueue(
package/lib/bus/index.ts CHANGED
@@ -14,9 +14,8 @@ import { previousNames } from "../session-names";
14
14
  import { deliverToPane, type Delivery } from "./delivery";
15
15
  import { resolveRef, wasNames, type Candidate } from "./resolve";
16
16
  import { tmuxPane } from "./tmux-pane";
17
- import { hostLink } from "../hosts/remote-api";
18
17
  import { pauseHoldOf, pausedNote } from "../tasks/pause-hold";
19
- import { relayToHeldTask } from "../tasks/held-remote";
18
+ import { isHeldRemote, relayToHeldTask } from "../tasks/held-remote";
20
19
  import {
21
20
  assertTakesMessages,
22
21
  findQueuedTarget,
@@ -210,7 +209,7 @@ export async function deliver(
210
209
  ): Promise<Delivery> {
211
210
  // A task this machine's orchestrator moved to a linked machine: that
212
211
  // machine's AgentOS delivers it, labelled as the orchestrator's (held.ts).
213
- if (to.task_prompt && to.started_by && hostLink(to.host_id))
212
+ if (isHeldRemote(to))
214
213
  return relayToHeldTask(to, line, from.name, from.messageId);
215
214
  // A linked machine's own session: an agent's message would reach it as
216
215
  // the user's (see runner.ts), so none is relayed yet.
@@ -2242,6 +2242,23 @@ export const migrations: Migration[] = [
2242
2242
  `);
2243
2243
  },
2244
2244
  },
2245
+ {
2246
+ key: "20261014T1235_placed_mirror_started_by",
2247
+ up: (db) => {
2248
+ // A task the queue started on a linked machine kept no record of the
2249
+ // orchestrator that queued it, so it couldn't be read, messaged or
2250
+ // stopped from there. Fills the mirror from the queue row; additive,
2251
+ // only where the mirror has none.
2252
+ db.exec(`
2253
+ UPDATE sessions SET
2254
+ started_by = (SELECT q.started_by FROM task_queue q WHERE q.id = sessions.id),
2255
+ task_since = COALESCE(task_since, created_at)
2256
+ WHERE started_by IS NULL AND task_prompt IS NOT NULL
2257
+ AND host_id IS NOT NULL AND host_id != 'local'
2258
+ AND (SELECT q.started_by FROM task_queue q WHERE q.id = sessions.id) IS NOT NULL
2259
+ `);
2260
+ },
2261
+ },
2245
2262
  {
2246
2263
  key: "20261014T1600_queued_task_messages",
2247
2264
  up: (db) => {
@@ -2261,6 +2278,25 @@ export const migrations: Migration[] = [
2261
2278
  );
2262
2279
  },
2263
2280
  },
2281
+ {
2282
+ key: "20261014T1800_task_starter_device",
2283
+ up: (db) => {
2284
+ // The paired device that started a task over the API: the only one
2285
+ // that may later hold it. Tasks already running have no record, so
2286
+ // they're marked unrecorded. Additive.
2287
+ const has = (
2288
+ db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
2289
+ ).some((c) => c.name === "started_by_device");
2290
+ if (!has)
2291
+ db.exec(`ALTER TABLE sessions ADD COLUMN started_by_device TEXT`);
2292
+ db.exec(`
2293
+ UPDATE sessions SET started_by_device = 'unrecorded'
2294
+ WHERE started_by_device IS NULL AND started_by IS NULL
2295
+ AND held_by_device IS NULL AND task_status = 'running'
2296
+ AND task_prompt IS NOT NULL AND (host_id IS NULL OR host_id = 'local')
2297
+ `);
2298
+ },
2299
+ },
2264
2300
  ];
2265
2301
 
2266
2302
  // `upTo`: stop after this legacy id or timestamp key, inclusive (tests that
package/lib/db/types.ts CHANGED
@@ -146,6 +146,8 @@ export interface Session {
146
146
  // The device and machine of the orchestrator that moved this task here.
147
147
  held_by_device?: string | null;
148
148
  held_from?: string | null;
149
+ // The paired device that started this task here over the API.
150
+ started_by_device?: string | null;
149
151
  held_for?: string | null;
150
152
  held_for_name?: string | null;
151
153
  // A held task's "plan written" line, for its orchestrator to read.
package/lib/done/index.ts CHANGED
@@ -31,7 +31,7 @@ import { releasePorts } from "../ports";
31
31
  import { dropSessionDatabase } from "../project-config/database";
32
32
  import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
33
33
  import { hostLink } from "../hosts/remote-api";
34
- import { doneHeldTask } from "../tasks/held-remote";
34
+ import { doneHeldTask, isHeldRemote } from "../tasks/held-remote";
35
35
  import { captureMergedTask } from "../session-logs";
36
36
  import { sessionEnded } from "../experiments/hooks";
37
37
  import { pushMerged } from "../notify/push/events";
@@ -175,7 +175,7 @@ export async function doneSession(
175
175
  await stopAgent(s);
176
176
  // The sign-off's cleanup has already settled it; this reads the result.
177
177
  worktree = await settleWorktree(s, { prHead: m.sha });
178
- } else if (s.started_by && s.task_prompt && hostLink(s.host_id)) {
178
+ } else if (isHeldRemote(s)) {
179
179
  // Moved to a linked machine by its orchestrator: that machine stops the
180
180
  // agent and removes the worktree, which is where they are.
181
181
  await doneHeldTask(s);
@@ -10,7 +10,7 @@ import { hostLink } from "../hosts/remote-api";
10
10
  import { peerPane } from "../hosts/peer-sessions";
11
11
  import { workspaceSessions } from "./facts";
12
12
  import { untrusted } from "./untrusted";
13
- import { heldChatTail } from "../tasks/held-remote";
13
+ import { heldChatTail, isHeldRemote } from "../tasks/held-remote";
14
14
  import {
15
15
  findExternalSession,
16
16
  knownExternalSession,
@@ -130,7 +130,7 @@ export async function readSession(
130
130
  const link = hostLink(s.host_id);
131
131
  if (s.view === "chat" && link) {
132
132
  // Its orchestrator's task is read through the machine that holds it.
133
- const text = s.started_by ? await heldChatTail(s, n) : null;
133
+ const text = isHeldRemote(s) ? await heldChatTail(s, n) : null;
134
134
  return text
135
135
  ? `${s.name} (chat on ${link.hostName}), last ${n} lines:\n${untrusted(s.name, text)}`
136
136
  : `${s.name} (chat on ${link.hostName}): its messages are on that machine; open it to read them.`;
@@ -9,7 +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
+ import { heldChatFacts, isHeldRemote } from "../tasks/held-remote";
13
13
  import { hostTasks } from "../tasks/remote";
14
14
  import { peerPane } from "../hosts/peer-sessions";
15
15
  import { untrusted } from "./untrusted";
@@ -88,7 +88,7 @@ async function peerChatWaiting(
88
88
  };
89
89
  // A task its orchestrator moved is asked about directly: the whole task
90
90
  // list is slow, and a miss there reads as unread.
91
- const facts = task.started_by ? await heldChatFacts(task) : null;
91
+ const facts = isHeldRemote(task) ? await heldChatFacts(task) : null;
92
92
  if (facts) {
93
93
  const { turn } = facts;
94
94
  return {
@@ -2,7 +2,7 @@ import { endTmuxSession } from "../tmux/end";
2
2
  import { hostLink } from "../hosts/remote-api";
3
3
  import { stopChatTurn } from "../chat/stop";
4
4
  import type { Session } from "../db";
5
- import { stopHeldTask } from "../tasks/held-remote";
5
+ import { isHeldRemote, stopHeldTask } from "../tasks/held-remote";
6
6
  import { releaseOnEnd } from "../simulator";
7
7
  import { forgetPlanWait } from "../models/plan-step";
8
8
 
@@ -21,12 +21,12 @@ export async function stopSession(
21
21
  | "tmux_name"
22
22
  | "started_by"
23
23
  | "task_prompt"
24
+ | "task_since"
24
25
  >
25
26
  ): Promise<string> {
26
27
  // A task its orchestrator moved to a linked machine stops through that
27
28
  // machine's AgentOS, chat or terminal.
28
- if (s.started_by && s.task_prompt && hostLink(s.host_id))
29
- return stopHeldTask(s);
29
+ if (isHeldRemote(s)) return stopHeldTask(s);
30
30
  if (s.view === "chat") {
31
31
  if (hostLink(s.host_id))
32
32
  throw new Error(
@@ -4,43 +4,152 @@
4
4
  * orchestrator polls and calls; the other machine never reaches back.
5
5
  */
6
6
 
7
- import type { Session } from "../db";
7
+ import { db, type Session } from "../db";
8
8
  import {
9
+ HostApiError,
9
10
  cleanRemoteBlock,
10
11
  cleanRemoteText,
11
12
  hostApi,
13
+ hostLink,
12
14
  requireHostLink,
13
15
  } from "../hosts/remote-api";
14
16
  import type { Delivery } from "../bus/delivery";
17
+ import { wakeLine } from "../bus/format";
18
+ import { orchestratedStart } from "./held";
15
19
 
16
20
  const path = (id: string, leaf: string) =>
17
21
  `/api/tasks/${encodeURIComponent(id)}/${leaf}`;
18
22
 
23
+ /**
24
+ * A task its orchestrator put on a linked machine, whether by moving it or
25
+ * by the queue placing it there: the one test every relayed call goes by.
26
+ * (held-replies.ts reads the same rule in SQL: started_by IS NOT NULL.)
27
+ */
28
+ export const isHeldRemote = (
29
+ s: Pick<Session, "started_by" | "task_prompt" | "host_id">
30
+ ): boolean => !!s.started_by && !!s.task_prompt && !!hostLink(s.host_id);
31
+
32
+ type Held = Pick<
33
+ Session,
34
+ "id" | "name" | "host_id" | "started_by" | "task_since" | "task_prompt"
35
+ >;
36
+
37
+ const ADOPT_RETRY_MS = 5 * 60 * 1000;
38
+ const STALE_MS = 24 * 60 * 60 * 1000;
39
+ const g = globalThis as unknown as { __agentosAdopted?: Map<string, number> };
40
+ const adopted = (g.__agentosAdopted ??= new Map());
41
+
42
+ /**
43
+ * A task the queue placed before machines held them is unheld there, so its
44
+ * calls are refused as "No such held task". Asks the machine to hold it for
45
+ * this one, then sends what was left undelivered. False when it can't.
46
+ */
47
+ async function adoptHeld(task: Held): Promise<boolean> {
48
+ const now = Date.now();
49
+ // Only failures are remembered, and only for the retry window.
50
+ for (const [id, at] of adopted)
51
+ if (now - at >= ADOPT_RETRY_MS) adopted.delete(id);
52
+ if (adopted.has(task.id)) return false;
53
+ adopted.set(task.id, now);
54
+ try {
55
+ const link = requireHostLink(task.host_id);
56
+ await hostApi(link, path(task.id, "hold"), {
57
+ body: {
58
+ orchestrated: orchestratedStart(
59
+ task.started_by!,
60
+ task.task_since ?? null
61
+ ),
62
+ },
63
+ timeout: 20000,
64
+ });
65
+ } catch {
66
+ return false;
67
+ }
68
+ adopted.delete(task.id);
69
+ await flushUndelivered(task);
70
+ return true;
71
+ }
72
+
73
+ /** The call, held first if that machine says it isn't, once. */
74
+ async function withHold<T>(task: Held, call: () => Promise<T>): Promise<T> {
75
+ try {
76
+ return await call();
77
+ } catch (error) {
78
+ if (!(error instanceof HostApiError) || error.status !== 404) throw error;
79
+ if (!(await adoptHeld(task))) throw error;
80
+ return call();
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Messages stored for the task while it couldn't be reached: sent now, each
86
+ * keyed by its stored id so one that did land isn't typed twice. Ones older
87
+ * than a day are marked read, not replayed into a later context.
88
+ */
89
+ export async function flushUndelivered(
90
+ task: Pick<
91
+ Session,
92
+ "id" | "host_id" | "started_by" | "task_since" | "task_prompt" | "name"
93
+ >
94
+ ): Promise<void> {
95
+ const rows = db
96
+ .prepare(
97
+ `SELECT id, from_id, from_name, body, created_at FROM bus_messages
98
+ WHERE to_id = ? AND delivered_at IS NULL AND read_at IS NULL
99
+ AND held_pause_id IS NULL ORDER BY id`
100
+ )
101
+ .all(task.id) as {
102
+ id: number;
103
+ from_id: string | null;
104
+ from_name: string;
105
+ body: string;
106
+ created_at: string;
107
+ }[];
108
+ for (const m of rows) {
109
+ const age = Date.now() - Date.parse(`${m.created_at.replace(" ", "T")}Z`);
110
+ if (age > STALE_MS) {
111
+ db.prepare(
112
+ `UPDATE bus_messages SET read_at = datetime('now') WHERE id = ?`
113
+ ).run(m.id);
114
+ continue;
115
+ }
116
+ const line = wakeLine({
117
+ fromName: m.from_name,
118
+ fromId: m.from_id,
119
+ body: m.body,
120
+ });
121
+ const res = await relayToHeldTask(task, line, m.from_name, m.id, false);
122
+ if (res.state === "failed") return;
123
+ db.prepare(
124
+ `UPDATE bus_messages SET delivered_at = datetime('now') WHERE id = ?`
125
+ ).run(m.id);
126
+ }
127
+ }
128
+
19
129
  /**
20
130
  * Delivered by that machine's AgentOS as a message from the orchestrator,
21
131
  * never as the user's own. Keyed by the stored message, so a retry that
22
132
  * already landed is not typed twice.
23
133
  */
24
134
  export async function relayToHeldTask(
25
- to: Session,
135
+ to: Held,
26
136
  line: string,
27
137
  fromName: string,
28
- messageId?: number
138
+ messageId?: number,
139
+ heal = true
29
140
  ): Promise<Delivery> {
30
141
  try {
31
142
  const link = requireHostLink(to.host_id);
32
- const { state } = await hostApi<{ state?: unknown }>(
33
- link,
34
- path(to.id, "message"),
35
- {
143
+ const send = () =>
144
+ hostApi<{ state?: unknown }>(link, path(to.id, "message"), {
36
145
  body: {
37
146
  id: messageId ? `bus-${messageId}` : undefined,
38
147
  line,
39
148
  from: fromName,
40
149
  },
41
150
  timeout: 20000,
42
- }
43
- );
151
+ });
152
+ const { state } = await (heal ? withHold(to, send) : send());
44
153
  return { state: state === "queued" ? "queued" : "delivered" };
45
154
  } catch (error) {
46
155
  const why = error instanceof Error ? error.message : String(error);
@@ -50,14 +159,16 @@ export async function relayToHeldTask(
50
159
 
51
160
  /** A held chat task's last lines of conversation, as that machine reads them. */
52
161
  export async function heldChatTail(
53
- task: Session,
162
+ task: Held,
54
163
  lines: number
55
164
  ): Promise<string | null> {
56
165
  const link = requireHostLink(task.host_id);
57
- const { text } = await hostApi<{ text?: unknown }>(
58
- link,
59
- `${path(task.id, "chat-tail")}?lines=${lines}`,
60
- { timeout: 10000 }
166
+ const { text } = await withHold(task, () =>
167
+ hostApi<{ text?: unknown }>(
168
+ link,
169
+ `${path(task.id, "chat-tail")}?lines=${lines}`,
170
+ { timeout: 10000 }
171
+ )
61
172
  ).catch(() => ({ text: undefined }));
62
173
  // Whole lines, as a local read has them (read.ts caps and trims them).
63
174
  return typeof text === "string" ? cleanRemoteBlock(text, 16000) : null;
@@ -69,13 +180,15 @@ export async function heldChatTail(
69
180
  * which callers treat as unread, never as clear.
70
181
  */
71
182
  export async function heldChatFacts(
72
- task: Pick<Session, "id" | "host_id">
183
+ task: Held
73
184
  ): Promise<{ blocked: string | null; turn: string | null } | null> {
74
185
  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 }
186
+ const res = await withHold(task, () =>
187
+ hostApi<{ blocked?: unknown; turn?: unknown }>(
188
+ link,
189
+ path(task.id, "chat-facts"),
190
+ { timeout: 10000 }
191
+ )
79
192
  ).catch(() => null);
80
193
  if (!res || (res.blocked !== null && typeof res.blocked !== "string"))
81
194
  return null;
@@ -87,23 +200,21 @@ export async function heldChatFacts(
87
200
  }
88
201
 
89
202
  /** Stops its agent there; its conversation and worktree are kept. */
90
- export async function stopHeldTask(
91
- task: Pick<Session, "id" | "name" | "host_id">
92
- ): Promise<string> {
203
+ export async function stopHeldTask(task: Held): Promise<string> {
93
204
  const link = requireHostLink(task.host_id);
94
- const { text } = await hostApi<{ text?: unknown }>(
95
- link,
96
- path(task.id, "stop"),
97
- {
205
+ const { text } = await withHold(task, () =>
206
+ hostApi<{ text?: unknown }>(link, path(task.id, "stop"), {
98
207
  body: {},
99
208
  timeout: 30000,
100
- }
209
+ })
101
210
  );
102
211
  return cleanRemoteText(text) || `Stopped ${task.name}.`;
103
212
  }
104
213
 
105
214
  /** Done there: its agent stops and its worktree goes. Never a merge. */
106
- export async function doneHeldTask(task: Session): Promise<void> {
215
+ export async function doneHeldTask(task: Held): Promise<void> {
107
216
  const link = requireHostLink(task.host_id);
108
- await hostApi(link, path(task.id, "done"), { body: {}, timeout: 120000 });
217
+ await withHold(task, () =>
218
+ hostApi(link, path(task.id, "done"), { body: {}, timeout: 120000 })
219
+ );
109
220
  }
package/lib/tasks/held.ts CHANGED
@@ -5,7 +5,9 @@
5
5
  * a green CI, past the gates its orchestrator judges it by.
6
6
  */
7
7
 
8
+ import os from "os";
8
9
  import { db, type Session } from "../db";
10
+ import type { OrchestratedBundle } from "./move-bundle";
9
11
 
10
12
  export function holderRefusal(
11
13
  session: Pick<Session, "held_by_device" | "held_from">,
@@ -26,3 +28,107 @@ export function heldRefusal(
26
28
  .get(id) as Pick<Session, "held_by_device" | "held_from"> | undefined;
27
29
  return session ? holderRefusal(session, device) : null;
28
30
  }
31
+
32
+ /** Holds a task here for the paired device that sent it, and the orchestrator there. */
33
+ export function holdFor(
34
+ id: string,
35
+ holder: string,
36
+ held: {
37
+ heldFrom: string;
38
+ orchestrator?: { id: string; name: string };
39
+ since?: string | null;
40
+ }
41
+ ): void {
42
+ db.prepare(
43
+ `UPDATE sessions SET held_by_device = ?, held_from = ?, held_for = ?, held_for_name = ?,
44
+ task_since = COALESCE(?, task_since) WHERE id = ?`
45
+ ).run(
46
+ holder,
47
+ held.heldFrom,
48
+ held.orchestrator?.id ?? null,
49
+ held.orchestrator?.name ?? null,
50
+ held.since ?? null,
51
+ id
52
+ );
53
+ }
54
+
55
+ /**
56
+ * What a linked machine needs to hold a task this machine's orchestrator
57
+ * starts there: the same description a move carries, minus work already done.
58
+ */
59
+ export function orchestratedStart(
60
+ startedBy: string,
61
+ since: string | null = new Date().toISOString()
62
+ ): OrchestratedBundle {
63
+ const o = db
64
+ .prepare(`SELECT name FROM sessions WHERE id = ?`)
65
+ .get(startedBy) as { name: string } | undefined;
66
+ return {
67
+ heldFrom: os.hostname(),
68
+ since,
69
+ modelProvider: null,
70
+ modelOverrides: null,
71
+ planPhase: null,
72
+ planFile: null,
73
+ orchestrator: o ? { id: startedBy, name: o.name } : undefined,
74
+ };
75
+ }
76
+
77
+ /** Stands in for a starter nobody recorded: tasks already running when starters began to be recorded. */
78
+ const UNRECORDED_STARTER = "unrecorded";
79
+
80
+ /** Records the paired device that started a task here, the only one that may later hold it. */
81
+ export function recordStarter(id: string, device: string): void {
82
+ db.prepare(`UPDATE sessions SET started_by_device = ? WHERE id = ?`).run(
83
+ device,
84
+ id
85
+ );
86
+ }
87
+
88
+ type Holdable = Pick<
89
+ Session,
90
+ | "held_by_device"
91
+ | "started_by"
92
+ | "started_by_device"
93
+ | "task_status"
94
+ | "host_id"
95
+ | "task_prompt"
96
+ >;
97
+
98
+ /**
99
+ * Whether `device` may hold this task: a running task on this machine that
100
+ * nobody here (an orchestrator or a person) owns, which that device started.
101
+ */
102
+ export function mayHold(s: Holdable, device: string): boolean {
103
+ if (!s.task_prompt || s.task_status !== "running") return false;
104
+ if (s.host_id && s.host_id !== "local") return false;
105
+ if (s.started_by || s.held_by_device) return false;
106
+ return (
107
+ s.started_by_device === device || s.started_by_device === UNRECORDED_STARTER
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Holds a task already running here for the device that started it, and the
113
+ * orchestrator there. False when there's no such running task; a task this
114
+ * device didn't start, one an orchestrator here owns, or one another device
115
+ * holds is refused. Holding the same task again is a no-op.
116
+ */
117
+ export function holdExisting(
118
+ id: string,
119
+ device: string,
120
+ held: Parameters<typeof holdFor>[2]
121
+ ): boolean {
122
+ const s = db
123
+ .prepare(
124
+ `SELECT held_by_device, started_by, started_by_device, task_status, host_id, task_prompt
125
+ FROM sessions WHERE id = ?`
126
+ )
127
+ .get(id) as Holdable | undefined;
128
+ if (!s?.task_prompt || s.task_status !== "running") return false;
129
+ if (s.held_by_device === device) return true;
130
+ if (!mayHold(s, device))
131
+ throw new Error("Only the machine that started this task can hold it");
132
+ holdFor(id, device, held);
133
+ return true;
134
+ }
@@ -28,6 +28,7 @@ import { expandHome } from "./session";
28
28
  import { ensureProject } from "./project-ref";
29
29
  import { checkoutBranchWorktree } from "./branch-worktree";
30
30
  import { rewritePaths, writeTranscript } from "./transcript";
31
+ import { holdFor } from "./held";
31
32
  import { stepProgress } from "./move-progress";
32
33
  import { dropChat, unpackChat } from "./move-chat";
33
34
  import { sendChat, stopChat } from "../chat/runner";
@@ -171,20 +172,12 @@ async function arrive(
171
172
  // Held for the orchestrator's device. The orchestrator's own state
172
173
  // (started_by, its gates) never comes from the bundle: it stays on its
173
174
  // machine, and a task coming back takes it from the mirror's record.
174
- if (holder)
175
+ if (holder) {
176
+ holdFor(id, holder, orch);
175
177
  db.prepare(
176
- `UPDATE sessions SET held_by_device = ?, held_from = ?,
177
- held_for = ?, held_for_name = ?,
178
- model_provider = ?, model_overrides = ? WHERE id = ?`
179
- ).run(
180
- holder,
181
- orch.heldFrom,
182
- orch.orchestrator?.id ?? null,
183
- orch.orchestrator?.name ?? null,
184
- orch.modelProvider,
185
- orch.modelOverrides,
186
- id
187
- );
178
+ `UPDATE sessions SET model_provider = ?, model_overrides = ? WHERE id = ?`
179
+ ).run(orch.modelProvider, orch.modelOverrides, id);
180
+ }
188
181
  }
189
182
  carryTaskState(bundle.moveId, id);
190
183
  })();
@@ -47,7 +47,8 @@ import {
47
47
  type TaskState,
48
48
  } from "./state";
49
49
  import { expandHome, prFor, storedPR, taskSessions } from "./session";
50
- import { InProgressError } from "./move-bundle";
50
+ import { InProgressError, type OrchestratedBundle } from "./move-bundle";
51
+ import { holdFor, mayHold, recordStarter } from "./held";
51
52
  import { nameFor } from "../session-titles";
52
53
  import { taskSetupOf, type TaskSetup } from "./setup";
53
54
  import { taskPlan } from "./plan-view";
@@ -160,10 +161,24 @@ export async function createTask(opts: {
160
161
  // How its agent runs: as a chat (the default, DEFAULT_START_VIEW) or in
161
162
  // a terminal.
162
163
  view?: StartView;
164
+ // Started by another machine's orchestrator, which this one holds it for
165
+ // (held.ts): that machine's paired device and its description of the task.
166
+ held?: { device: string; orchestrated: OrchestratedBundle };
167
+ // The paired device that sent this keyed start: the only one that may hold it later.
168
+ startedByDevice?: string;
163
169
  }): Promise<Session> {
164
170
  if (opts.id) {
165
171
  const existing = queries.getSession(db).get(opts.id) as Session | undefined;
166
- if (existing?.task_status) return existing;
172
+ if (existing?.task_status) {
173
+ // A retry of a start that wasn't held holds it now, for the device
174
+ // that started it and no other.
175
+ if (opts.held && existing.held_by_device !== opts.held.device) {
176
+ if (!mayHold(existing, opts.held.device))
177
+ throw new Error("That id is taken");
178
+ holdFor(existing.id, opts.held.device, opts.held.orchestrated);
179
+ }
180
+ return existing;
181
+ }
167
182
  if (existing) throw new Error("That id is taken");
168
183
  if (starting.has(opts.id))
169
184
  throw new InProgressError("That task is starting already");
@@ -234,6 +249,7 @@ async function startTask(
234
249
  model: typeof opts.model === "string" ? opts.model : undefined,
235
250
  baseBranch: opts.baseBranch,
236
251
  view,
252
+ startedBy: opts.startedBy,
237
253
  });
238
254
  opts.onCreated?.(session.id);
239
255
  return session;
@@ -321,6 +337,9 @@ async function startTask(
321
337
  opts.startedBy ?? null,
322
338
  id
323
339
  );
340
+ const starter = opts.held?.device ?? opts.startedByDevice;
341
+ if (starter) recordStarter(id, starter);
342
+ if (opts.held) holdFor(id, opts.held.device, opts.held.orchestrated);
324
343
  opts.onCreated?.(id);
325
344
  void naming.refine?.(id);
326
345
 
@@ -127,7 +127,9 @@ function validateChat(c: ChatBundle): ChatBundle {
127
127
 
128
128
  const PHASES = ["planning", "implementing"];
129
129
 
130
- function validateOrchestrated(o: OrchestratedBundle): OrchestratedBundle {
130
+ export function validateOrchestrated(
131
+ o: OrchestratedBundle
132
+ ): OrchestratedBundle {
131
133
  if (!o || typeof o !== "object") throw new Error("Bad orchestrated task");
132
134
  if (
133
135
  !isStrOrNull(o.since) ||
@@ -18,7 +18,7 @@ import { sessionWorkspace } from "../models/resolve";
18
18
  import { arrived as arrivedHere, importTask } from "./import";
19
19
  import {
20
20
  forgetHostTasks,
21
- hostTasks,
21
+ requireCapability,
22
22
  postIdempotent,
23
23
  unknownOutcome,
24
24
  } from "./remote";
@@ -88,25 +88,6 @@ export async function moveTask(id: string, toHostId: string): Promise<Session> {
88
88
  }
89
89
  }
90
90
 
91
- // A machine on an older AgentOS would take a chat task as a terminal one,
92
- // or an orchestrator's task as a plain one that anyone there could merge, or
93
- // refuse to hand one over.
94
- async function requireCapability(
95
- link: HostLink,
96
- capability: string,
97
- what: string
98
- ): Promise<void> {
99
- const { capabilities = [] } = await hostTasks(link, true).catch((err) => {
100
- throw new Error(
101
- `Can't ask ${link.hostName} whether it takes ${what}: ${(err as Error).message}`
102
- );
103
- });
104
- if (!capabilities.includes(capability))
105
- throw new Error(
106
- `${link.hostName}'s AgentOS can't move ${what} yet; update it first`
107
- );
108
- }
109
-
110
91
  const requireChatMoves = (link: HostLink) =>
111
92
  requireCapability(link, "chat-move", "chat tasks");
112
93
 
@@ -151,6 +151,8 @@ export interface PeerOption {
151
151
  name: string;
152
152
  view: MachineView;
153
153
  capabilities: string[];
154
+ // It can hold an orchestrator's task (false: an older AgentOS). Unset: yes.
155
+ holds?: boolean;
154
156
  // The project exists there (projectMachines said null).
155
157
  hasProject: boolean;
156
158
  loadPerCore: number;
@@ -74,7 +74,8 @@ import {
74
74
  viewOf,
75
75
  } from "../load/machines";
76
76
  import { peerReading, peerSource, refreshPeers } from "../hosts/peer-load";
77
- import { linkedHostIds } from "../hosts/remote-api";
77
+ import { hostLink, linkedHostIds } from "../hosts/remote-api";
78
+ import { hostTasks } from "./remote";
78
79
  import { projectMachines } from "../hosts/project-machines";
79
80
  import { claimResume, dueResumes } from "./pause";
80
81
 
@@ -829,6 +830,20 @@ async function peerOptions(
829
830
  const hostIds = [...linkedHostIds()];
830
831
  if (!hostIds.length || !rows.length) return out;
831
832
  await refreshPeers(hostIds, now);
833
+ // Whether each machine can hold an orchestrator's task (an older AgentOS
834
+ // can't, and would fail its start); unreadable counts as no.
835
+ const holds = new Map<string, boolean>();
836
+ await Promise.all(
837
+ hostIds.map(async (hostId) => {
838
+ const link = hostLink(hostId);
839
+ const caps = link
840
+ ? await hostTasks(link)
841
+ .then((t) => t.capabilities ?? [])
842
+ .catch(() => [])
843
+ : [];
844
+ holds.set(hostId, caps.includes("held-start"));
845
+ })
846
+ );
832
847
  const perProject = new Map<string, Record<string, string | null>>();
833
848
  for (const r of rows) {
834
849
  if (out.has(r.project_id)) continue;
@@ -850,6 +865,7 @@ async function peerOptions(
850
865
  name: src.label,
851
866
  view: viewOf(src, now),
852
867
  capabilities: src.capabilities,
868
+ holds: holds.get(hostId) ?? false,
853
869
  hasProject: can[hostId] === null,
854
870
  loadPerCore: reading ? reading.load1 / reading.cores : 99,
855
871
  };
@@ -925,7 +941,7 @@ export async function tickOnce(now = Date.now()): Promise<"stale" | void> {
925
941
  )) {
926
942
  const r = rows.find((x) => x.id === c.id)!;
927
943
  const options = (peers.get(r.project_id) ?? []).filter(
928
- (p) => !taken.has(p.hostId)
944
+ (p) => !taken.has(p.hostId) && (!r.started_by || p.holds !== false)
929
945
  );
930
946
  const spot = place(c, home, options, built.get(c.id)!.needs, now);
931
947
  if (!spot) continue;
@@ -15,6 +15,7 @@ import {
15
15
  } from "../hosts/remote-api";
16
16
  import { isRemoteHost } from "../hosts";
17
17
  import { projectRef } from "./project-ref";
18
+ import { orchestratedStart } from "./held";
18
19
  import type { TaskStatus } from "./state";
19
20
  import type { TaskView } from "./index";
20
21
 
@@ -33,6 +34,9 @@ export const TASK_CAPABILITIES = [
33
34
  // A held task's chat facts and its messages for its orchestrator.
34
35
  "held-chat-facts",
35
36
  "held-replies",
37
+ // A keyed start that carries an orchestrator's description holds the task
38
+ // for the device that sent it, as a move does (lib/tasks/held.ts).
39
+ "held-start",
36
40
  ] as const;
37
41
 
38
42
  export interface HostTasks {
@@ -48,7 +52,8 @@ export function isMirror(session: Session): boolean {
48
52
  export function mirrorTask(
49
53
  hostId: string,
50
54
  projectId: string | null,
51
- remote: Session
55
+ remote: Session,
56
+ startedBy?: string | null
52
57
  ): void {
53
58
  db.prepare(
54
59
  `INSERT INTO sessions (id, name, tmux_name, working_directory, model, group_path,
@@ -71,6 +76,35 @@ export function mirrorTask(
71
76
  // As that machine runs it: one that predates chat tasks says terminal.
72
77
  remote.view === "chat" ? "chat" : "terminal"
73
78
  );
79
+ // What carryTaskState gives a moved task's mirror: the orchestrator that
80
+ // holds it, and when it began (so its PR is judged from then).
81
+ if (startedBy)
82
+ db.prepare(
83
+ `UPDATE sessions SET started_by = COALESCE(started_by, ?),
84
+ task_since = COALESCE(task_since, created_at) WHERE id = ?`
85
+ ).run(startedBy, remote.id);
86
+ }
87
+
88
+ /**
89
+ * A machine on an older AgentOS would take a chat task as a terminal one,
90
+ * or an orchestrator's task as a plain one that anyone there could merge, or
91
+ * refuse to hand one over.
92
+ */
93
+ export async function requireCapability(
94
+ link: HostLink,
95
+ capability: string,
96
+ what: string,
97
+ verb = "move"
98
+ ): Promise<void> {
99
+ const { capabilities = [] } = await hostTasks(link, true).catch((err) => {
100
+ throw new Error(
101
+ `Can't ask ${link.hostName} whether it takes ${what}: ${(err as Error).message}`
102
+ );
103
+ });
104
+ if (!capabilities.includes(capability))
105
+ throw new Error(
106
+ `${link.hostName}'s AgentOS can't ${verb} ${what} yet; update it first`
107
+ );
74
108
  }
75
109
 
76
110
  export const unknownOutcome = (err: unknown) =>
@@ -101,16 +135,30 @@ export async function startRemoteTask(
101
135
  model?: string;
102
136
  baseBranch?: string;
103
137
  view?: "chat" | "terminal";
138
+ // The orchestrator that started it: that machine holds the task for it.
139
+ startedBy?: string | null;
104
140
  }
105
141
  ): Promise<Session> {
106
142
  const link = requireHostLink(hostId);
143
+ const { startedBy, ...rest } = opts;
144
+ if (startedBy)
145
+ await requireCapability(
146
+ link,
147
+ "held-start",
148
+ "an orchestrator's task",
149
+ "hold"
150
+ );
107
151
  const { session } = await postIdempotent<{ session: Session }>(
108
152
  link,
109
153
  "/api/tasks",
110
- { project: await projectRef(project), ...opts },
154
+ {
155
+ project: await projectRef(project),
156
+ ...rest,
157
+ orchestrated: startedBy ? orchestratedStart(startedBy) : undefined,
158
+ },
111
159
  180000
112
160
  );
113
- mirrorTask(hostId, project.id, session);
161
+ mirrorTask(hostId, project.id, session, startedBy);
114
162
  forgetHostTasks(hostId);
115
163
  return db
116
164
  .prepare(`SELECT * FROM sessions WHERE id = ?`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.579",
3
+ "version": "0.3.580",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"