@saadnvd1/agent-os 0.3.517 → 0.3.518

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 (46) hide show
  1. package/README.md +20 -3
  2. package/app/api/tasks/[id]/arm/route.ts +5 -0
  3. package/app/api/tasks/[id]/chat-tail/route.ts +15 -0
  4. package/app/api/tasks/[id]/done/route.ts +15 -0
  5. package/app/api/tasks/[id]/export/route.ts +6 -1
  6. package/app/api/tasks/[id]/merge/route.ts +9 -1
  7. package/app/api/tasks/[id]/message/route.ts +39 -0
  8. package/app/api/tasks/[id]/moved/route.ts +6 -1
  9. package/app/api/tasks/[id]/resume/route.ts +6 -1
  10. package/app/api/tasks/[id]/stop/route.ts +14 -0
  11. package/app/api/tasks/import/route.ts +11 -1
  12. package/lib/bus/index.ts +15 -2
  13. package/lib/db/migrations.ts +25 -0
  14. package/lib/db/types.ts +7 -0
  15. package/lib/done/bulk.ts +8 -0
  16. package/lib/done/index.ts +15 -0
  17. package/lib/models/plan-step.ts +13 -3
  18. package/lib/orchestrator/act.ts +41 -1
  19. package/lib/orchestrator/armed.ts +6 -1
  20. package/lib/orchestrator/brakes.ts +9 -0
  21. package/lib/orchestrator/brief.ts +2 -1
  22. package/lib/orchestrator/facts.ts +72 -21
  23. package/lib/orchestrator/read.ts +14 -2
  24. package/lib/orchestrator/serve.ts +6 -0
  25. package/lib/orchestrator/shell-guard.ts +15 -0
  26. package/lib/orchestrator/tool-names.ts +1 -0
  27. package/lib/orchestrator/tool-schemas.ts +11 -0
  28. package/lib/orchestrator/tools.ts +1 -0
  29. package/lib/sessions/stop.ts +15 -1
  30. package/lib/tasks/branch-worktree.ts +16 -1
  31. package/lib/tasks/finish.ts +5 -1
  32. package/lib/tasks/held-remote.ts +80 -0
  33. package/lib/tasks/held-routes.ts +33 -0
  34. package/lib/tasks/held.ts +28 -0
  35. package/lib/tasks/import.ts +60 -7
  36. package/lib/tasks/index.ts +11 -1
  37. package/lib/tasks/last-active.ts +25 -0
  38. package/lib/tasks/move-bundle.ts +53 -1
  39. package/lib/tasks/move-carry.ts +102 -0
  40. package/lib/tasks/move-flow.ts +34 -7
  41. package/lib/tasks/move-guard.ts +17 -13
  42. package/lib/tasks/move-recover.ts +2 -0
  43. package/lib/tasks/move.ts +34 -3
  44. package/lib/tasks/remote.ts +3 -0
  45. package/lib/tasks/session.ts +1 -1
  46. package/package.json +1 -1
package/README.md CHANGED
@@ -1201,7 +1201,7 @@ it may do on its own, and the lines that always come back to you as asks.
1201
1201
  - **Tools:** `sessions` (every session's status, activity, task, PR, CI and
1202
1202
  stack position), `read` (the end of a terminal or chat) and `cards` (the
1203
1203
  linked boards' cards), plus acting tools: `send`, `start_task`,
1204
- `start_session`, `stack`, `stack_status`, `land`, `drop`, `stop`, `done`,
1204
+ `start_session`, `stack`, `stack_status`, `land`, `drop`, `stop`, `done`, `move`,
1205
1205
  `add_project` (a git repository on the machine becomes a project of the
1206
1206
  workspace, or the project already at that path moves in from no workspace;
1207
1207
  folders that hold secrets and projects of another workspace are refused),
@@ -1222,7 +1222,8 @@ it may do on its own, and the lines that always come back to you as asks.
1222
1222
  naming the tool to use instead: merging, closing or retargeting a PR
1223
1223
  (`gh pr merge`, `gh pr close`, `gh pr edit --base`, and `gh api` or `curl`
1224
1224
  calls that merge a pull request or PATCH or PUT one; use `sign_off`, `arm`,
1225
- `land` or `drop`), `git push` (`quick_pr` pushes), writing in a project
1225
+ `land` or `drop`), `aos move`, `aos done` and `aos stop` (use `move`, `done` and
1226
+ `stop`, which are scoped to the workspace and held by Pause), `git push` (`quick_pr` pushes), writing in a project
1226
1227
  repository or task worktree other than its own open quick changes (git
1227
1228
  commit, reset, checkout, rebase, merge, stash, clean or rm there, and `rm`,
1228
1229
  `mv`, `cp`, `tee` or a redirect into one), writing AgentOS's state (`sqlite3`
@@ -1574,6 +1575,22 @@ Once linked:
1574
1575
  press Move again to finish it, or **Resume here**, which first asks the
1575
1576
  other machine whether it arrived. Tasks waiting on you (an escalated gate or
1576
1577
  an open ask), card tasks and stacked tasks don't move yet.
1578
+ - **An orchestrator's task moves too**, by the orchestrator's `move` tool (`task`,
1579
+ `machine`; `here` brings it back) or the Move button. Its state is re-keyed to
1580
+ the task's new session id in the same step it lands: brief and scope changes,
1581
+ plan file and phase, gate checks and failure counts, asks, wakes, queue
1582
+ order, arm, model and provider. Both machines need an AgentOS that can do
1583
+ it, and the arriving one must have the task's provider. The machine it lands
1584
+ on holds it for the orchestrator's machine alone: only that device can
1585
+ merge, arm, message, stop, done or read it, and the arriving machine's own
1586
+ orchestrator, armed merges and bulk done skip it. The orchestrator keeps
1587
+ judging the PR at its head, reads BLOCKED lines and open approvals from the
1588
+ task's chat there, and gets PR, CI, idle and plan events by polling that
1589
+ machine. Its messages arrive marked as from the orchestrator, never as
1590
+ yours. Guards (write guard, shell deny list, attribution settings) run in
1591
+ the task's worker on either machine, and the task can't merge or push to
1592
+ the default branch. Moving counts toward the brakes and Pause refuses the
1593
+ tool.
1577
1594
  - **A chat task moves the same way**, between turns: a running turn is let
1578
1595
  finish (up to 3 minutes, else the move is refused and it carries on here),
1579
1596
  and one waiting on your answer isn't moved. What's sent to it while its
@@ -1586,7 +1603,7 @@ Once linked:
1586
1603
  Works for any project. Requests to the other machine carry its device token,
1587
1604
  so it can sit behind a proxy that never trusts loopback. Linking needs this
1588
1605
  machine or the tailnet, and only pairs with the ssh target's own host name.
1589
- The orchestrator still starts tasks on its own machine only.
1606
+ The orchestrator starts tasks on its own machine and moves them afterwards.
1590
1607
 
1591
1608
  ## Security
1592
1609
 
@@ -1,6 +1,8 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { db, type Session } from "@/lib/db";
3
3
  import { armTask, setArmed } from "@/lib/orchestrator/armed";
4
+ import { callingDevice } from "@/lib/security/device-me";
5
+ import { heldRefusal } from "@/lib/tasks/held";
4
6
 
5
7
  // { armed: true } merges the task's PR by itself once every gate passes;
6
8
  // false takes that back.
@@ -8,6 +10,9 @@ export async function POST(
8
10
  request: NextRequest,
9
11
  { params }: { params: Promise<{ id: string }> }
10
12
  ) {
13
+ const id = (await params).id;
14
+ const held = heldRefusal(id, callingDevice(request));
15
+ if (held) return NextResponse.json({ error: held }, { status: 403 });
11
16
  const { armed } = await request.json().catch(() => ({}));
12
17
  if (typeof armed !== "boolean")
13
18
  return NextResponse.json(
@@ -0,0 +1,15 @@
1
+ import { NextRequest } from "next/server";
2
+ import { chatTailText } from "@/lib/orchestrator/read";
3
+ import { heldRoute } from "@/lib/tasks/held-routes";
4
+
5
+ // GET /api/tasks/:id/chat-tail?lines= - the last lines of its conversation,
6
+ // for the orchestrator that moved it here to read.
7
+ export async function GET(
8
+ request: NextRequest,
9
+ ctx: { params: Promise<{ id: string }> }
10
+ ) {
11
+ const lines = Number(request.nextUrl.searchParams.get("lines")) || 60;
12
+ return heldRoute(request, ctx, async (task) => ({
13
+ text: task.view === "chat" ? chatTailText(task.id, lines) : "",
14
+ }));
15
+ }
@@ -0,0 +1,15 @@
1
+ import { NextRequest } from "next/server";
2
+ import { doneSession } from "@/lib/done";
3
+ import { heldRoute } from "@/lib/tasks/held-routes";
4
+
5
+ // POST /api/tasks/:id/done - its orchestrator finishes it: the agent stops
6
+ // and the worktree goes. Never a merge (noMerge): an open PR is refused
7
+ // here, since only its orchestrator's gates merge it.
8
+ export async function POST(
9
+ request: NextRequest,
10
+ ctx: { params: Promise<{ id: string }> }
11
+ ) {
12
+ return heldRoute(request, ctx, async (task) => ({
13
+ outcome: await doneSession(task.id, { by: "direct", noMerge: true }),
14
+ }));
15
+ }
@@ -1,5 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { exportOrResume } from "@/lib/tasks/move";
3
+ import { callingDevice } from "@/lib/security/device-me";
4
+ import { heldRefusal } from "@/lib/tasks/held";
3
5
 
4
6
  // POST /api/tasks/:id/export - stop the agent, push, hand over the task.
5
7
  // The caller confirms with /moved, or restarts it here with /resume; if it
@@ -8,10 +10,13 @@ export async function POST(
8
10
  request: NextRequest,
9
11
  { params }: { params: Promise<{ id: string }> }
10
12
  ) {
13
+ const id = (await params).id;
14
+ const held = heldRefusal(id, callingDevice(request));
15
+ if (held) return NextResponse.json({ error: held }, { status: 403 });
11
16
  try {
12
17
  const { to } = await request.json().catch(() => ({}));
13
18
  const bundle = await exportOrResume(
14
- (await params).id,
19
+ id,
15
20
  String(to || "another machine").slice(0, 100)
16
21
  );
17
22
  return NextResponse.json({ bundle });
@@ -1,5 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { signOffTask } from "@/lib/tasks";
3
+ import { callingDevice } from "@/lib/security/device-me";
4
+ import { heldRefusal } from "@/lib/tasks/held";
3
5
 
4
6
  // head: merge only if the PR is still at this commit; the answer names the
5
7
  // commit it was pinned to, so another machine forwarding a merge can check.
@@ -7,6 +9,9 @@ export async function POST(
7
9
  request: NextRequest,
8
10
  { params }: { params: Promise<{ id: string }> }
9
11
  ) {
12
+ const id = (await params).id;
13
+ const held = heldRefusal(id, callingDevice(request));
14
+ if (held) return NextResponse.json({ error: held }, { status: 403 });
10
15
  const { head } = await request.json().catch(() => ({}));
11
16
  if (
12
17
  head !== undefined &&
@@ -14,7 +19,10 @@ export async function POST(
14
19
  )
15
20
  return NextResponse.json({ error: "Bad head commit" }, { status: 400 });
16
21
  try {
17
- const method = await signOffTask((await params).id, { head });
22
+ const method = await signOffTask(id, {
23
+ head,
24
+ device: callingDevice(request),
25
+ });
18
26
  return NextResponse.json({ success: true, head: head ?? null, method });
19
27
  } catch (error) {
20
28
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,39 @@
1
+ import { NextRequest } from "next/server";
2
+ import { statusDetector } from "@/lib/status-detector";
3
+ import { deliver } from "@/lib/bus";
4
+ import { sendChatConfirmed } from "@/lib/chat/runner";
5
+ import { cleanRemoteText } from "@/lib/hosts/remote-api";
6
+ import { heldRoute } from "@/lib/tasks/held-routes";
7
+
8
+ const MAX_LINE = 40_000;
9
+
10
+ // POST /api/tasks/:id/message {id?, line, from} - the orchestrator that moved
11
+ // this task here speaks to it. It arrives as that orchestrator's message,
12
+ // never as the user's, and a repeat of the same id is not delivered twice.
13
+ export async function POST(
14
+ request: NextRequest,
15
+ ctx: { params: Promise<{ id: string }> }
16
+ ) {
17
+ const body = await request.json().catch(() => ({}));
18
+ return heldRoute(request, ctx, async (task) => {
19
+ const line = typeof body.line === "string" ? body.line.trim() : "";
20
+ if (!line || line.length > MAX_LINE) throw new Error("Bad message");
21
+ const id = typeof body.id === "string" ? body.id.slice(0, 100) : undefined;
22
+ const from = cleanRemoteText(body.from).slice(0, 100) || "the orchestrator";
23
+ const label = `${from} on ${task.held_from ?? "another machine"}`;
24
+ if (task.view === "chat") {
25
+ const state = await sendChatConfirmed(task.id, {
26
+ id,
27
+ text: line,
28
+ from: label,
29
+ origin: { kind: "peer", label },
30
+ compact: "auto",
31
+ });
32
+ return { state };
33
+ }
34
+ await statusDetector.refreshCache();
35
+ const delivery = await deliver(task, line, { name: label });
36
+ if (delivery.state === "failed") throw new Error(delivery.why);
37
+ return { state: delivery.state };
38
+ });
39
+ }
@@ -1,12 +1,17 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { markMoved } from "@/lib/tasks/move";
3
+ import { callingDevice } from "@/lib/security/device-me";
4
+ import { heldRefusal } from "@/lib/tasks/held";
3
5
 
4
6
  // POST /api/tasks/:id/moved - the task now runs on the machine named `to`
5
7
  export async function POST(
6
8
  request: NextRequest,
7
9
  { params }: { params: Promise<{ id: string }> }
8
10
  ) {
11
+ const id = (await params).id;
12
+ const held = heldRefusal(id, callingDevice(request));
13
+ if (held) return NextResponse.json({ error: held }, { status: 403 });
9
14
  const { to } = await request.json().catch(() => ({}));
10
- markMoved((await params).id, String(to || "another machine").slice(0, 100));
15
+ markMoved(id, String(to || "another machine").slice(0, 100));
11
16
  return NextResponse.json({ success: true });
12
17
  }
@@ -1,5 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { resumeHere } from "@/lib/tasks/move-recover";
3
+ import { callingDevice } from "@/lib/security/device-me";
4
+ import { heldRefusal } from "@/lib/tasks/held";
3
5
 
4
6
  // POST /api/tasks/:id/resume {force?} - start its agent again here, on its
5
7
  // conversation. A task left moving asks its target first unless forced.
@@ -7,9 +9,12 @@ export async function POST(
7
9
  request: NextRequest,
8
10
  { params }: { params: Promise<{ id: string }> }
9
11
  ) {
12
+ const id = (await params).id;
13
+ const held = heldRefusal(id, callingDevice(request));
14
+ if (held) return NextResponse.json({ error: held }, { status: 403 });
10
15
  const { force } = await request.json().catch(() => ({}));
11
16
  try {
12
- const result = await resumeHere((await params).id, force === true);
17
+ const result = await resumeHere(id, force === true);
13
18
  return NextResponse.json({ success: true, ...result });
14
19
  } catch (error) {
15
20
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,14 @@
1
+ import { NextRequest } from "next/server";
2
+ import { stopSession } from "@/lib/sessions/stop";
3
+ import { heldRoute } from "@/lib/tasks/held-routes";
4
+
5
+ // POST /api/tasks/:id/stop - its orchestrator stops the agent; the
6
+ // conversation and worktree are kept.
7
+ export async function POST(
8
+ request: NextRequest,
9
+ ctx: { params: Promise<{ id: string }> }
10
+ ) {
11
+ return heldRoute(request, ctx, async (task) => ({
12
+ text: await stopSession(task),
13
+ }));
14
+ }
@@ -1,4 +1,5 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { callingDevice } from "@/lib/security/device-me";
2
3
  import { importTask } from "@/lib/tasks/import";
3
4
  import { MAX_BUNDLE_BYTES, statusFor } from "@/lib/tasks/move-bundle";
4
5
 
@@ -11,7 +12,16 @@ export async function POST(request: NextRequest) {
11
12
  { status: 413 }
12
13
  );
13
14
  try {
14
- const session = await importTask(await request.json());
15
+ const bundle = await request.json();
16
+ // An orchestrator's task is held for the device that sent it, which
17
+ // only a paired device is.
18
+ const holder = callingDevice(request);
19
+ if (bundle?.orchestrated && !holder)
20
+ return NextResponse.json(
21
+ { error: "An orchestrator's task comes only from a paired machine" },
22
+ { status: 403 }
23
+ );
24
+ const session = await importTask(bundle, { holder });
15
25
  return NextResponse.json({ session }, { status: 201 });
16
26
  } catch (error) {
17
27
  const message = error instanceof Error ? error.message : String(error);
package/lib/bus/index.ts CHANGED
@@ -14,6 +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
+ import { relayToHeldTask } from "../tasks/held-remote";
17
19
  import { HUMAN, overRateLimit, wakeLine, type BusMessageView } from "./format";
18
20
 
19
21
  export * from "./format";
@@ -142,11 +144,21 @@ function pairTimestamps(a: string | null, b: string): number[] {
142
144
 
143
145
  // Hands the message to the recipient: a chat session's worker, or typed
144
146
  // into a terminal's pane and checked that it went in.
145
- async function deliver(
147
+ export async function deliver(
146
148
  to: Session,
147
149
  line: string,
148
- from: { name: string; peer?: PeerMessage; origin?: ChatOrigin }
150
+ from: {
151
+ name: string;
152
+ peer?: PeerMessage;
153
+ origin?: ChatOrigin;
154
+ // The stored message's id, which a relayed one is keyed by.
155
+ messageId?: number;
156
+ }
149
157
  ): Promise<Delivery> {
158
+ // A task this machine's orchestrator moved to a linked machine: that
159
+ // machine's AgentOS delivers it, labelled as the orchestrator's (held.ts).
160
+ if (to.task_prompt && to.started_by && hostLink(to.host_id))
161
+ return relayToHeldTask(to, line, from.name, from.messageId);
150
162
  // A linked machine's own session: an agent's message would reach it as
151
163
  // the user's (see runner.ts), so none is relayed yet.
152
164
  if (to.peer_mirror && !to.task_prompt)
@@ -228,6 +240,7 @@ export async function sendMessage(opts: {
228
240
  opts.line ?? wakeLine({ fromName, fromId: from?.id ?? null, body }),
229
241
  {
230
242
  name: fromName,
243
+ messageId: id,
231
244
  peer: from ? { sessionId: from.id, body } : undefined,
232
245
  origin: from
233
246
  ? { kind: "peer", label: from.name, sessionId: from.id }
@@ -1689,6 +1689,31 @@ const migrations: Migration[] = [
1689
1689
  `);
1690
1690
  },
1691
1691
  },
1692
+ {
1693
+ id: 73,
1694
+ name: "orchestrated_task_moves",
1695
+ up: (db) => {
1696
+ // task_since: when the task first started, kept across moves (null:
1697
+ // created_at). held_by_device / held_from: the device and machine of
1698
+ // the orchestrator that moved this task here; only that device may
1699
+ // merge, arm, message or move it. plan_note: the "plan written" line
1700
+ // of a held task, for its orchestrator to read.
1701
+ const add = (col: string) => {
1702
+ const columns = db.prepare(`PRAGMA table_info(sessions)`).all() as {
1703
+ name: string;
1704
+ }[];
1705
+ if (!columns.some((c) => c.name === col))
1706
+ db.exec(`ALTER TABLE sessions ADD COLUMN ${col} TEXT`);
1707
+ };
1708
+ for (const col of [
1709
+ "task_since",
1710
+ "held_by_device",
1711
+ "held_from",
1712
+ "plan_note",
1713
+ ])
1714
+ add(col);
1715
+ },
1716
+ },
1692
1717
  ];
1693
1718
 
1694
1719
  // `upTo`: stop after this id (tests that start from an older database).
package/lib/db/types.ts CHANGED
@@ -119,6 +119,13 @@ export interface Session {
119
119
  rebase_asked_head?: string | null;
120
120
  // When its PR's reviews and comments started being watched (pr-wake.ts).
121
121
  pr_wake_since?: string | null;
122
+ // When the task first started, kept across moves (null: created_at).
123
+ task_since?: string | null;
124
+ // The device and machine of the orchestrator that moved this task here.
125
+ held_by_device?: string | null;
126
+ held_from?: string | null;
127
+ // A held task's "plan written" line, for its orchestrator to read.
128
+ plan_note?: string | null;
122
129
  // Orchestration fields
123
130
  conductor_session_id: string | null;
124
131
  worker_task: string | null;
package/lib/done/bulk.ts CHANGED
@@ -54,6 +54,8 @@ function scopeSessions(scope: DoneScope): Session[] {
54
54
  AND role IS NULL AND archived_at IS NULL
55
55
  -- A linked machine's own sessions are cleaned up there.
56
56
  AND peer_mirror = 0
57
+ -- A task another machine's orchestrator moved here is its to finish.
58
+ AND held_by_device IS NULL
57
59
  AND (task_status IS NULL OR task_status = 'running')
58
60
  ORDER BY created_at`
59
61
  )
@@ -75,6 +77,12 @@ export async function idleSessions(
75
77
 
76
78
  // The caller's workspace when its project has one, else its project.
77
79
  export function scopeOf(session: Session): DoneScope {
80
+ // An orchestrator acts through its tools, which Pause holds and which are
81
+ // logged; aos would reach the same sessions past both.
82
+ if (session.role === "orchestrator")
83
+ throw new Error(
84
+ `${session.name} is an orchestrator: it moves, finishes and stops sessions with its move, done and stop tools, not aos`
85
+ );
78
86
  const project = session.project_id ? getProject(session.project_id) : null;
79
87
  if (!project || project.is_uncategorized)
80
88
  throw new Error(`${session.name} isn't in a project`);
package/lib/done/index.ts CHANGED
@@ -25,6 +25,8 @@ import { sessionMergePolicy } from "../tasks/finish";
25
25
  import { releasePorts } from "../ports";
26
26
  import { dropSessionDatabase } from "../project-config/database";
27
27
  import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
28
+ import { hostLink } from "../hosts/remote-api";
29
+ import { doneHeldTask } from "../tasks/held-remote";
28
30
  import { captureMergedTask } from "../session-logs";
29
31
  import { pushMerged } from "../notify/push/events";
30
32
 
@@ -161,6 +163,19 @@ export async function doneSession(
161
163
  await stopAgent(s);
162
164
  // The sign-off's cleanup has already settled it; this reads the result.
163
165
  worktree = await settleWorktree(s, { prHead: m.sha });
166
+ } else if (s.started_by && s.task_prompt && hostLink(s.host_id)) {
167
+ // Moved to a linked machine by its orchestrator: that machine stops the
168
+ // agent and removes the worktree, which is where they are.
169
+ await doneHeldTask(s);
170
+ if (plan.mark) mark(s, plan.mark, plan.pr);
171
+ else
172
+ db.prepare(
173
+ `UPDATE sessions SET task_status = 'done' WHERE id = ? AND task_status = 'running'`
174
+ ).run(s.id);
175
+ worktree = {
176
+ action: "removed",
177
+ why: `on ${hostLink(s.host_id)?.hostName ?? "its machine"}`,
178
+ };
164
179
  } else {
165
180
  if (plan.mark) mark(s, plan.mark, plan.pr);
166
181
  if (
@@ -73,9 +73,12 @@ export function planSummary(text: string, max = 5): string[] {
73
73
 
74
74
  // Tells the orchestrator that started a task its plan is written, once.
75
75
  function announcePlan(s: Session, ref: string): void {
76
- if (!s.started_by) return;
76
+ if (!s.started_by && !s.held_by_device) return;
77
77
  const workspaceId = sessionWorkspace(s);
78
- if (!workspaceId || !s.worktree_path) return;
78
+ // A task held for another machine's orchestrator can't reach it: the line
79
+ // waits on the task for that orchestrator to read (orchestrator/facts).
80
+ if (!s.held_by_device && !workspaceId) return;
81
+ if (!s.worktree_path) return;
79
82
  const file = path.join(s.worktree_path, PLAN_FILE);
80
83
  let head = `Task "${s.name}": no plan file was written; implementing on ${ref} anyway.`;
81
84
  try {
@@ -85,7 +88,14 @@ function announcePlan(s: Session, ref: string): void {
85
88
  } catch {
86
89
  // No plan file: the line says so.
87
90
  }
88
- queueEvent(workspaceId, `plan-done:${s.id}`, s.id, head);
91
+ if (s.held_by_device) {
92
+ db.prepare(`UPDATE sessions SET plan_note = ? WHERE id = ?`).run(
93
+ head,
94
+ s.id
95
+ );
96
+ return;
97
+ }
98
+ queueEvent(workspaceId!, `plan-done:${s.id}`, s.id, head);
89
99
  }
90
100
 
91
101
  export const implementMessageId = (sessionId: string) =>
@@ -19,7 +19,9 @@ import {
19
19
  launchSession,
20
20
  type StartView,
21
21
  } from "../sessions/launch";
22
- import { createTask, dropTask } from "../tasks";
22
+ import { assertMovable, createTask, dropTask, moveTask } from "../tasks";
23
+ import { getHost, hostIdNamed } from "../hosts";
24
+ import { queueEvent } from "./events";
23
25
  import {
24
26
  findQueued,
25
27
  queuedLabel,
@@ -280,3 +282,41 @@ export async function done(workspaceId: string, ref: string): Promise<string> {
280
282
  export async function stop(workspaceId: string, ref: string): Promise<string> {
281
283
  return stopSession(findWorkspaceSession(workspaceId, ref));
282
284
  }
285
+
286
+ // Carries a task to a linked machine, or back ("here"): heavy test or build
287
+ // work can run on the box. The move takes minutes, so it runs in the
288
+ // background; the orchestrator is told when the task lands or doesn't.
289
+ // Everything about the task (gates, events, read/send/stop/drop) is the same
290
+ // wherever it runs; the machine it lands on holds it for this one.
291
+ export async function moveTaskTo(
292
+ workspaceId: string,
293
+ ref: string,
294
+ machine: string
295
+ ): Promise<string> {
296
+ const task = workspaceTask(workspaceId, ref);
297
+ const hostId = hostIdNamed(machine);
298
+ assertMovable(task.id, hostId);
299
+ const where = hostId === "local" ? "this machine" : machineName(hostId);
300
+ void moveTask(task.id, hostId).then(
301
+ (moved) => {
302
+ addNote(workspaceId, `Moved task ${task.name} to ${where}.`);
303
+ queueEvent(
304
+ workspaceId,
305
+ `move:${moved.id}:${Date.now()}`,
306
+ moved.id,
307
+ `Task ${task.name} now runs on ${where}.`
308
+ );
309
+ },
310
+ (error) =>
311
+ queueEvent(
312
+ workspaceId,
313
+ `move-failed:${task.id}:${Date.now()}`,
314
+ task.id,
315
+ `Moving task ${task.name} to ${where} failed: ${error instanceof Error ? error.message : String(error)}. It carries on where it was unless it says it's paused as "moving".`
316
+ )
317
+ );
318
+ return `Moving ${task.name} to ${where}. It takes a few minutes; an event tells you when it runs there.`;
319
+ }
320
+
321
+ const machineName = (hostId: string) =>
322
+ getHost(hostId)?.name ?? "the other machine";
@@ -58,7 +58,8 @@ function armedTasks(workspaceId: string): Session[] {
58
58
  .prepare(
59
59
  `SELECT s.* FROM sessions s JOIN projects p ON p.id = s.project_id
60
60
  WHERE p.workspace_id = ? AND s.merge_armed_at IS NOT NULL
61
- AND s.task_status = 'running' AND s.peer_mirror = 0`
61
+ AND s.task_status = 'running' AND s.peer_mirror = 0
62
+ AND s.held_by_device IS NULL`
62
63
  )
63
64
  .all(workspaceId) as Session[];
64
65
  }
@@ -100,6 +101,10 @@ export function armTask(task: Session): void {
100
101
  );
101
102
  if (task.peer_mirror)
102
103
  throw new Error(`${task.name} runs on a linked machine; arm it there`);
104
+ if (task.held_by_device)
105
+ throw new Error(
106
+ `${task.name} is held for ${task.held_from ?? "another machine"}'s orchestrator; it arms it there`
107
+ );
103
108
  if (!armingWorkspace(task))
104
109
  throw new Error(
105
110
  `${task.name}'s project isn't in a workspace with an orchestrator, which an armed merge reports to`
@@ -74,6 +74,15 @@ export async function runningCount(
74
74
  "running";
75
75
  if (busy) running.add(s.id);
76
76
  }
77
+ // A task mid-move is out of the list above, but it runs again within
78
+ // minutes, here or there: it holds its slot.
79
+ const moving = db
80
+ .prepare(
81
+ `SELECT s.id FROM sessions s JOIN projects p ON p.id = s.project_id
82
+ WHERE p.workspace_id = ? AND s.task_status = 'moving' AND s.peer_mirror = 0`
83
+ )
84
+ .all(workspaceId) as { id: string }[];
85
+ for (const { id } of moving) running.add(id);
77
86
  for (const { target } of startsSince(workspaceId, now - WARMUP_MS))
78
87
  if (target) running.add(target);
79
88
  return running.size;
@@ -109,7 +109,8 @@ Acting:
109
109
  - \`${TOOL_NAMES.quick_pr}\` (change, title, body): commit everything in a quick change, push its branch (never the default branch) and open its PR, or push to the PR it has; its review starts at once. Then it's gated like any task: CI, that review (also its code-review gate), scope, then sign_off or arm. It's in \`sessions\` as your quick change; drop it to throw it away, and it's cleaned up like a task when it merges or is dropped.
110
110
  - \`${TOOL_NAMES.stack}\` (target, plan_only?): run a board's open cards as stacked tasks; plan_only shows the plan without starting.
111
111
  - \`${TOOL_NAMES.land}\` (id): merge a stack bottom-up through the gates.
112
- - \`${TOOL_NAMES.drop}\` (task, reason): close a task's PR and remove its worktree.
112
+ - \`${TOOL_NAMES.move}\` (task, machine): carry a running task to a linked machine (heavy test or build work), or back with machine "here". It keeps its branch, conversation and plan, and everything you do with a task works the same there: its events come to you, read, send, stop, done and drop reach it, and the gates judge its PR at its head. The machine it lands on holds it for you and can't merge it. It runs in the background; an event says when it lands. Refused while paused, and for a task waiting on Saad.
113
+ - \`${TOOL_NAMES.drop}\` (task, reason): close a task's PR and remove its worktree (on whichever machine holds it).
113
114
  - \`${TOOL_NAMES.stop}\` (session): stop a session's agent, keeping its work.
114
115
  - \`${TOOL_NAMES.done}\` (session): finish a session whose work is complete. A task with an open PR merges through the same gates as sign_off first (refused naming the gate otherwise); then the agent stops, the worktree goes only if nothing in it would be lost, and the session is archived out of view. Not a rejection: use drop for that.
115
116
  - \`${TOOL_NAMES.add_project}\` (path, name?): make a git repository on this machine a project of this workspace, or move the project already at that path in when it's in no workspace. Use it rather than sending Saad steps. It refuses a folder that isn't a git repository or holds secrets, and a project another workspace has: moving that is Saad's call, so ask_saad.