@saadnvd1/agent-os 0.3.597 → 0.3.601

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 (53) hide show
  1. package/app/api/bus/spawn/route.ts +35 -16
  2. package/app/api/lumifyhub/cards/[cardId]/run/route.ts +3 -0
  3. package/app/api/tasks/route.ts +3 -0
  4. package/app/page.tsx +2 -1
  5. package/components/SessionList/LiveLine.tsx +33 -6
  6. package/components/SessionList/QueuedRow.tsx +45 -46
  7. package/components/SessionList/RowParts.tsx +1 -54
  8. package/components/SessionList/RowShell.tsx +13 -6
  9. package/components/SessionList/RowStatusPlacement.test.ts +114 -0
  10. package/components/SessionList/RowSubtitle.tsx +2 -3
  11. package/components/SessionList/SessionRow.tsx +14 -11
  12. package/lib/agents/spawn.ts +16 -4
  13. package/lib/chat/refresh.ts +2 -1
  14. package/lib/chat/runner.ts +29 -0
  15. package/lib/chat/store.ts +53 -0
  16. package/lib/db/migrations/20261014T2110_task_start_record.ts +21 -0
  17. package/lib/db/migrations/20261014T2200_end_orphaned_tasks.ts +23 -0
  18. package/lib/db/types.ts +2 -0
  19. package/lib/learning-cards/apply.ts +7 -1
  20. package/lib/models/plan-step.ts +25 -8
  21. package/lib/models/resolve.ts +1 -1
  22. package/lib/models/resume.ts +1 -1
  23. package/lib/models/roles.ts +0 -16
  24. package/lib/models/session-role.ts +19 -0
  25. package/lib/orchestrator/act.ts +22 -7
  26. package/lib/orchestrator/amendments.ts +10 -3
  27. package/lib/orchestrator/serve.ts +1 -0
  28. package/lib/orchestrator/testing.ts +8 -0
  29. package/lib/orchestrator/tool-schemas.ts +9 -0
  30. package/lib/orchestrator/tools.ts +1 -1
  31. package/lib/plans/claim.ts +131 -0
  32. package/lib/plans/release.ts +5 -2
  33. package/lib/recipes/run.ts +6 -0
  34. package/lib/schedules/start.ts +1 -0
  35. package/lib/sessions/fork.ts +10 -0
  36. package/lib/sessions/launch.ts +5 -1
  37. package/lib/sidebar/live-status.ts +33 -2
  38. package/lib/sidebar/row-badge.ts +26 -2
  39. package/lib/stacks/start.ts +1 -0
  40. package/lib/tasks/held-remote.ts +4 -2
  41. package/lib/tasks/held-replies.ts +5 -2
  42. package/lib/tasks/held.ts +2 -1
  43. package/lib/tasks/index.ts +85 -46
  44. package/lib/tasks/move-carry.ts +12 -1
  45. package/lib/tasks/move-guard.ts +2 -1
  46. package/lib/tasks/move.ts +7 -5
  47. package/lib/tasks/pause.ts +1 -1
  48. package/lib/tasks/queue.ts +54 -31
  49. package/lib/tasks/remote.ts +3 -1
  50. package/lib/tasks/starter.ts +124 -0
  51. package/lib/usage/calls.ts +2 -1
  52. package/lib/usage/turns.ts +2 -1
  53. package/package.json +1 -1
@@ -0,0 +1,131 @@
1
+ // A task that builds a waiting piece of a plan claims it, so the release
2
+ // sweep never starts that piece a second time.
3
+
4
+ import { db } from "../db";
5
+ import { getProject } from "../projects";
6
+ import type { FeaturePiece } from "./features";
7
+
8
+ export interface WaitingPiece extends FeaturePiece {
9
+ feature_name: string;
10
+ }
11
+
12
+ const COLUMNS = `fp.*, f.name AS feature_name`;
13
+
14
+ // Waiting pieces in `projectId` of the plans the caller belongs to: the plan
15
+ // it wrote, or the one whose piece it is building.
16
+ export function waitingPiecesFor(
17
+ callerId: string,
18
+ projectId: string
19
+ ): WaitingPiece[] {
20
+ return db
21
+ .prepare(
22
+ `SELECT ${COLUMNS} FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id
23
+ WHERE f.status = 'building' AND fp.status = 'waiting' AND fp.project_id = ?
24
+ AND (f.plan_session_id = ?
25
+ OR EXISTS (SELECT 1 FROM feature_pieces o
26
+ WHERE o.feature_id = f.id AND o.session_id = ?))
27
+ ORDER BY f.created_at, fp.position`
28
+ )
29
+ .all(projectId, callerId, callerId) as WaitingPiece[];
30
+ }
31
+
32
+ // The orchestrator's view: waiting pieces of its workspace's plans.
33
+ export function workspacePieces(
34
+ workspaceId: string,
35
+ projectId: string
36
+ ): WaitingPiece[] {
37
+ return db
38
+ .prepare(
39
+ `SELECT ${COLUMNS} FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id
40
+ WHERE f.status = 'building' AND fp.status = 'waiting' AND fp.project_id = ?
41
+ AND f.workspace_id = ?
42
+ ORDER BY f.created_at, fp.position`
43
+ )
44
+ .all(projectId, workspaceId) as WaitingPiece[];
45
+ }
46
+
47
+ export const listPieces = (waiting: WaitingPiece[]) =>
48
+ waiting.map((p) => `${p.position} ${p.name}`).join(", ");
49
+
50
+ // `name`, `position`, or `feature/name`; an error that lists what's waiting
51
+ // when nothing, or more than one, matches.
52
+ export function findPiece(waiting: WaitingPiece[], ref: string): WaitingPiece {
53
+ const want = ref.trim().toLowerCase();
54
+ const slash = want.indexOf("/");
55
+ const only = slash > 0 ? want.slice(0, slash) : null;
56
+ const piece = slash > 0 ? want.slice(slash + 1) : want;
57
+ const hits = waiting.filter(
58
+ (p) =>
59
+ (!only || p.feature_name.toLowerCase() === only) &&
60
+ (p.name.toLowerCase() === piece || String(p.position) === piece)
61
+ );
62
+ if (hits.length === 1) return hits[0];
63
+ const names = waiting
64
+ .map((p) =>
65
+ waiting.some((o) => o.feature_id !== p.feature_id)
66
+ ? `${p.feature_name}/${p.name}`
67
+ : `${p.position} ${p.name}`
68
+ )
69
+ .join(", ");
70
+ if (hits.length > 1)
71
+ throw new Error(
72
+ `"${ref}" matches more than one waiting piece: ${names}. Name it as feature/piece.`
73
+ );
74
+ throw new Error(
75
+ waiting.length
76
+ ? `No waiting piece "${ref}". Waiting pieces: ${names}.`
77
+ : `No waiting piece "${ref}": nothing in your plan is waiting in this project.`
78
+ );
79
+ }
80
+
81
+ export function pieceRefusal(
82
+ waiting: WaitingPiece[],
83
+ projectId: string
84
+ ): string {
85
+ const where = getProject(projectId)?.name ?? "this project";
86
+ return `Your plan "${waiting[0].feature_name}" has pieces waiting in ${where}: ${listPieces(waiting)}. Name the one this task builds with --piece <name>; AgentOS starts the others itself when they are due.`;
87
+ }
88
+
89
+ export function pieceStatus(id: number): string | undefined {
90
+ return (
91
+ db.prepare(`SELECT status FROM feature_pieces WHERE id = ?`).get(id) as
92
+ | { status: string }
93
+ | undefined
94
+ )?.status;
95
+ }
96
+
97
+ export function claimable(id: number): void {
98
+ const status = pieceStatus(id);
99
+ if (status !== "waiting")
100
+ throw new Error(`That piece is ${status ?? "gone"}, not waiting`);
101
+ }
102
+
103
+ // Synchronous, so nothing runs between the check and the write.
104
+ export function claimPiece(id: number, sessionId: string): void {
105
+ const changed = db
106
+ .prepare(
107
+ `UPDATE feature_pieces SET status = 'building', session_id = ?, note = NULL
108
+ WHERE id = ? AND status = 'waiting'`
109
+ )
110
+ .run(sessionId, id).changes;
111
+ if (!changed)
112
+ throw new Error(`That piece is ${pieceStatus(id) ?? "gone"}, not waiting`);
113
+ }
114
+
115
+ // Gives a claim back when the session it was made for never came to be.
116
+ export function releaseClaim(id: number, sessionId: string): void {
117
+ db.prepare(
118
+ `UPDATE feature_pieces SET status = 'waiting', session_id = NULL
119
+ WHERE id = ? AND session_id = ? AND status = 'building'`
120
+ ).run(id, sessionId);
121
+ }
122
+
123
+ export function pieceLabel(id: number): string {
124
+ const r = db
125
+ .prepare(
126
+ `SELECT fp.position, fp.name, f.name AS feature FROM feature_pieces fp
127
+ JOIN features f ON f.id = fp.feature_id WHERE fp.id = ?`
128
+ )
129
+ .get(id) as { position: number; name: string; feature: string } | undefined;
130
+ return r ? `piece ${r.position} ${r.name} of "${r.feature}"` : "a piece";
131
+ }
@@ -9,7 +9,7 @@ import { createTask } from "../tasks";
9
9
  import { startOrQueue } from "../tasks/queue";
10
10
  import { braked, BrakeRefused } from "../orchestrator/brakes";
11
11
  import { queueEvent, queueEventOnce } from "../orchestrator/events";
12
- import { amendBrief, amendmentsOf } from "../orchestrator/amendments";
12
+ import { amendBrief, scopeChangesOf } from "../orchestrator/amendments";
13
13
  import { usageRows } from "../session-logs/gather";
14
14
  import { pieceBrief } from "./pieces";
15
15
  import {
@@ -122,6 +122,9 @@ export const realDeps: ReleaseDeps = {
122
122
  // A retry under the same id clears what a failed attempt left behind.
123
123
  reclaim: true,
124
124
  startedBy: plan?.started_by ?? null,
125
+ parentSessionId: feature.plan_session_id,
126
+ startedVia: "plan" as const,
127
+ startedByDevice: plan?.started_by_device ?? null,
125
128
  originWorkspaceId: feature.workspace_id,
126
129
  };
127
130
  // A queued task keeps its queue id as its session id once it starts.
@@ -173,7 +176,7 @@ function creditParentMerge(feature: Feature, all: FeaturePiece[]): void {
173
176
  )
174
177
  )
175
178
  return;
176
- if (amendmentsOf(parent.id).length) return;
179
+ if (scopeChangesOf(parent.id).length) return;
177
180
  const covered = all.filter(
178
181
  (p) => p.status === "waiting" && p.project_id === parent.project_id
179
182
  );
@@ -152,6 +152,8 @@ export async function startRun(
152
152
  access: "full",
153
153
  model: typeof model === "string" ? model : null,
154
154
  startedBy,
155
+ parentSessionId: startedBy,
156
+ startedVia: byOrchestrator ? "orchestrator" : "recipe",
155
157
  }).then((l) => ({ ...l, id: l.session.id }))
156
158
  );
157
159
  settleStart(run.id, "running", { sessionId: session.id });
@@ -167,6 +169,8 @@ export async function startRun(
167
169
  plan: false,
168
170
  planReason: "recipe run",
169
171
  startedBy,
172
+ parentSessionId: startedBy,
173
+ startedVia: byOrchestrator ? "orchestrator" : "recipe",
170
174
  originWorkspaceId: byOrchestrator ? ws : undefined,
171
175
  },
172
176
  () =>
@@ -181,6 +185,8 @@ export async function startRun(
181
185
  plan: false,
182
186
  planReason: "recipe run",
183
187
  startedBy,
188
+ parentSessionId: startedBy,
189
+ startedVia: byOrchestrator ? "orchestrator" : "recipe",
184
190
  })
185
191
  )
186
192
  );
@@ -37,6 +37,7 @@ async function startTask(
37
37
  projectId: projectOf(schedule).id,
38
38
  prompt: schedule.prompt,
39
39
  name: schedule.name,
40
+ startedVia: "schedule",
40
41
  onCreated: onSession,
41
42
  });
42
43
  return session.id;
@@ -0,0 +1,10 @@
1
+ // A fork continues its parent's conversation: it has a parent but wasn't
2
+ // started by anyone and isn't a task. A task or session started from another
3
+ // session also records that parent, and is not a fork.
4
+ export const isFork = (s: {
5
+ parent_session_id?: string | null;
6
+ started_by?: string | null;
7
+ started_via?: string | null;
8
+ task_prompt?: string | null;
9
+ }): boolean =>
10
+ !!s.parent_session_id && !s.started_by && !s.started_via && !s.task_prompt;
@@ -3,6 +3,7 @@
3
3
  * then (for a worktree) the setup in the background, then the first message.
4
4
  */
5
5
 
6
+ import type { StartVia } from "../tasks/starter";
6
7
  import fs from "fs";
7
8
  import os from "os";
8
9
  import path from "path";
@@ -74,6 +75,7 @@ export interface LaunchInput {
74
75
  // The orchestrator session that starts it: it runs on the implementation
75
76
  // role's model (or `model`), not the interactive one's.
76
77
  startedBy?: string | null;
78
+ startedVia?: StartVia;
77
79
  groupPath?: string;
78
80
  systemPrompt?: string | null;
79
81
  }
@@ -277,12 +279,14 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
277
279
  runOn
278
280
  );
279
281
  db.prepare(
280
- `UPDATE sessions SET view = ?, name_source = ?, setup_status = ?, started_by = ? WHERE id = ?`
282
+ `UPDATE sessions SET view = ?, name_source = ?, setup_status = ?, started_by = ?,
283
+ started_via = ? WHERE id = ?`
281
284
  ).run(
282
285
  view,
283
286
  naming.source,
284
287
  useWorktree ? "running" : null,
285
288
  input.startedBy ?? null,
289
+ input.startedVia ?? (input.startedBy ? "orchestrator" : null),
286
290
  id
287
291
  );
288
292
  if (interactive) setSessionModel(id, interactive);
@@ -4,16 +4,37 @@ import {
4
4
  type PrStage,
5
5
  type PrStageName,
6
6
  } from "../tasks/pr-stage";
7
+ import type { StatusBadge } from "./row-badge";
7
8
 
8
- export type LiveTone = "blue" | "amber" | "red" | "green" | "purple" | "muted";
9
+ export type LiveTone =
10
+ | "blue"
11
+ | "amber"
12
+ | "red"
13
+ | "green"
14
+ | "purple"
15
+ | "primary"
16
+ | "muted";
9
17
 
10
18
  export interface LivePart {
11
19
  text: string;
12
20
  tone: LiveTone;
13
21
  // Still moving on its own: the UI shows a spinner.
14
22
  busy?: boolean;
23
+ // A status badge (need, stalled, plan step): drawn as a pill, with an icon
24
+ // for the plan kinds.
25
+ badge?: StatusBadge["kind"];
26
+ tip?: string;
15
27
  }
16
28
 
29
+ export const BADGE_TONE: Record<StatusBadge["kind"], LiveTone> = {
30
+ need: "amber",
31
+ failed: "red",
32
+ stalled: "amber",
33
+ starting: "muted",
34
+ planning: "blue",
35
+ "plan-ready": "primary",
36
+ };
37
+
17
38
  export const PR_STAGE_TONE: Record<PrStageName, LiveTone> = {
18
39
  merged: "purple",
19
40
  conflicts: "amber",
@@ -28,14 +49,24 @@ export const PR_STAGE_TONE: Record<PrStageName, LiveTone> = {
28
49
  };
29
50
 
30
51
  // What the row's second line says about the session right now, in order:
31
- // "Compacting", then "PR CI". Empty when there is nothing to say.
52
+ // "Compacting", then a status badge (Needs you, Stalled, Planning, Plan
53
+ // ready), then "PR CI". Empty when there is nothing to say.
32
54
  export function liveParts(input: {
33
55
  compacting?: boolean;
34
56
  prStage?: PrStage;
57
+ badge?: StatusBadge | null;
35
58
  }): LivePart[] {
36
59
  const parts: LivePart[] = [];
37
60
  if (input.compacting)
38
61
  parts.push({ text: "Compacting", tone: "blue", busy: true });
62
+ const b = input.badge;
63
+ if (b)
64
+ parts.push({
65
+ text: b.label,
66
+ tone: BADGE_TONE[b.kind],
67
+ badge: b.kind,
68
+ tip: b.tip,
69
+ });
39
70
  const s = input.prStage;
40
71
  if (s) {
41
72
  const word = PR_STAGE_SHORT[s.stage];
@@ -1,7 +1,7 @@
1
- // The plan badge a row shows on its right: writing its plan, or a plan
1
+ // The plan badge a row shows: writing its plan, or a plan
2
2
  // waiting for approval. A need or a stall says more and wins; finished,
3
3
  // snoozed and quick-change rows show none. Client-safe.
4
- import type { SidebarRow } from "./shelves";
4
+ import { NEED_LABEL, type SidebarRow } from "./shelves";
5
5
 
6
6
  export type RowKindBadge = {
7
7
  kind: "planning" | "plan-ready";
@@ -38,3 +38,27 @@ export function rowKindBadge(
38
38
  };
39
39
  return null;
40
40
  }
41
+
42
+ // The one status badge a row's second line carries: what it needs from you,
43
+ // stalled, or its plan step. They are mutually exclusive by precedence. The
44
+ // queued rows' Failed/Starting use the same shape.
45
+ export type StatusBadge = {
46
+ kind: "need" | "failed" | "stalled" | "starting" | RowKindBadge["kind"];
47
+ label: string;
48
+ tip?: string;
49
+ };
50
+
51
+ export function rowStatusBadge(
52
+ row: Parameters<typeof rowKindBadge>[0] &
53
+ Pick<SidebarRow, "need"> & { status?: SidebarRow["status"] }
54
+ ): StatusBadge | null {
55
+ if (row.need) {
56
+ const asks = row.status?.asks ?? 0;
57
+ return {
58
+ kind: row.need === "failed" ? "failed" : "need",
59
+ label: `${NEED_LABEL[row.need]}${asks > 1 ? ` ${asks}` : ""}`,
60
+ };
61
+ }
62
+ if (row.status?.stalled) return { kind: "stalled", label: "Stalled" };
63
+ return rowKindBadge(row);
64
+ }
@@ -119,6 +119,7 @@ export async function startItem(
119
119
  prompt: promptFromCard(card),
120
120
  name: card.title,
121
121
  cardId: card.id,
122
+ startedVia: "stack",
122
123
  base,
123
124
  onCreated: (sessionId) =>
124
125
  q.updateItem(db, item.id, {
@@ -16,6 +16,7 @@ import {
16
16
  import type { Delivery } from "../bus/delivery";
17
17
  import { wakeLine } from "../bus/format";
18
18
  import { orchestratedStart } from "./held";
19
+ import { orchestratorOf } from "./starter";
19
20
 
20
21
  const path = (id: string, leaf: string) =>
21
22
  `/api/tasks/${encodeURIComponent(id)}/${leaf}`;
@@ -23,11 +24,12 @@ const path = (id: string, leaf: string) =>
23
24
  /**
24
25
  * A task its orchestrator put on a linked machine, whether by moving it or
25
26
  * 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
+ * (held-replies.ts reads the same rule in SQL: started_by is an orchestrator.)
27
28
  */
28
29
  export const isHeldRemote = (
29
30
  s: Pick<Session, "started_by" | "task_prompt" | "host_id">
30
- ): boolean => !!s.started_by && !!s.task_prompt && !!hostLink(s.host_id);
31
+ ): boolean =>
32
+ !!s.task_prompt && !!hostLink(s.host_id) && !!orchestratorOf(s.started_by);
31
33
 
32
34
  type Held = Pick<
33
35
  Session,
@@ -27,18 +27,21 @@ interface Pulled {
27
27
  body: string;
28
28
  }
29
29
 
30
+ // Held means an orchestrator started it, as isHeldRemote reads it.
31
+ const HELD_BY = `started_by IN (SELECT id FROM sessions WHERE role = 'orchestrator')`;
32
+
30
33
  const heldMirror = (hostId: string, id: string) =>
31
34
  db
32
35
  .prepare(
33
36
  `SELECT * FROM sessions WHERE id = ? AND host_id = ?
34
- AND started_by IS NOT NULL AND task_status IN ('running', 'moving')`
37
+ AND ${HELD_BY} AND task_status IN ('running', 'moving')`
35
38
  )
36
39
  .get(id, hostId) as Session | undefined;
37
40
 
38
41
  export function hasHeldMirrors(hostId: string): boolean {
39
42
  return !!db
40
43
  .prepare(
41
- `SELECT 1 FROM sessions WHERE host_id = ? AND started_by IS NOT NULL
44
+ `SELECT 1 FROM sessions WHERE host_id = ? AND ${HELD_BY}
42
45
  AND task_prompt IS NOT NULL AND task_status IN ('running', 'moving')`
43
46
  )
44
47
  .get(hostId);
package/lib/tasks/held.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * a green CI, past the gates its orchestrator judges it by.
6
6
  */
7
7
 
8
+ import { orchestratorOf } from "./starter";
8
9
  import os from "os";
9
10
  import { db, type Session } from "../db";
10
11
  import type { OrchestratedBundle } from "./move-bundle";
@@ -102,7 +103,7 @@ type Holdable = Pick<
102
103
  export function mayHold(s: Holdable, device: string): boolean {
103
104
  if (!s.task_prompt || s.task_status !== "running") return false;
104
105
  if (s.host_id && s.host_id !== "local") return false;
105
- if (s.started_by || s.held_by_device) return false;
106
+ if (orchestratorOf(s.started_by) || s.held_by_device) return false;
106
107
  return (
107
108
  s.started_by_device === device || s.started_by_device === UNRECORDED_STARTER
108
109
  );
@@ -48,7 +48,9 @@ import {
48
48
  } from "./state";
49
49
  import { expandHome, prFor, storedPR, taskSessions } from "./session";
50
50
  import { InProgressError, type OrchestratedBundle } from "./move-bundle";
51
- import { holdFor, mayHold, recordStarter } from "./held";
51
+ import { holdFor, mayHold } from "./held";
52
+ import { orchestratorOf, recordStart, type StartVia } from "./starter";
53
+ import { claimable, claimPiece, releaseClaim } from "../plans/claim";
52
54
  import { nameFor } from "../session-titles";
53
55
  import { taskSetupOf, type TaskSetup } from "./setup";
54
56
  import { taskPlan } from "./plan-view";
@@ -136,8 +138,13 @@ export async function createTask(opts: {
136
138
  planReason?: string | null;
137
139
  // Stop after the plan and wait for approval (implies plan).
138
140
  planOnly?: boolean;
139
- // The orchestrator session that started it (null: a person).
141
+ // The session that started it (null: a person), the session it came from,
142
+ // and how: every start says (tasks/starter.ts).
140
143
  startedBy?: string | null;
144
+ parentSessionId?: string | null;
145
+ startedVia: StartVia;
146
+ // The waiting plan piece it builds: claimed for this task.
147
+ pieceId?: number;
141
148
  // Started from this LumifyHub card, which the task then moves.
142
149
  cardId?: string;
143
150
  // Stacked: cut from another task's pushed branch, at this exact commit.
@@ -165,7 +172,7 @@ export async function createTask(opts: {
165
172
  // (held.ts): that machine's paired device and its description of the task.
166
173
  held?: { device: string; orchestrated: OrchestratedBundle };
167
174
  // The paired device that sent this keyed start: the only one that may hold it later.
168
- startedByDevice?: string;
175
+ startedByDevice?: string | null;
169
176
  }): Promise<Session> {
170
177
  if (opts.id) {
171
178
  const existing = queries.getSession(db).get(opts.id) as Session | undefined;
@@ -222,6 +229,7 @@ async function startTask(
222
229
  if (!prompt) throw new Error("Describe the task");
223
230
  const project = getProject(opts.projectId);
224
231
  if (!project || project.is_uncategorized) throw new Error("Pick a project");
232
+ if (opts.pieceId) claimable(opts.pieceId);
225
233
  const hostId = opts.hostId ?? project.host_id;
226
234
  const view = opts.view ?? DEFAULT_START_VIEW;
227
235
  const agent = opts.agent ?? "claude";
@@ -251,6 +259,16 @@ async function startTask(
251
259
  view,
252
260
  startedBy: opts.startedBy,
253
261
  });
262
+ // The mirror's started_by stays orchestrator-only: a held-remote task is
263
+ // relayed, stopped and answered through it. A plain calling task is its
264
+ // parent and started_via only.
265
+ recordStart(session.id, {
266
+ startedBy: orchestratorOf(opts.startedBy),
267
+ parentSessionId: opts.parentSessionId ?? null,
268
+ startedVia: opts.startedVia,
269
+ startedByDevice: opts.held?.device ?? opts.startedByDevice,
270
+ });
271
+ if (opts.pieceId) claimPiece(opts.pieceId, session.id);
254
272
  opts.onCreated?.(session.id);
255
273
  return session;
256
274
  }
@@ -295,50 +313,71 @@ async function startTask(
295
313
  const model = routing.first.model;
296
314
  const tmuxName = `${provider.id}-${id}`;
297
315
 
298
- queries
299
- .createSession(db)
300
- .run(
301
- id,
302
- naming.name,
303
- tmuxName,
304
- wt.worktreePath,
305
- null,
306
- model,
307
- null,
308
- "sessions",
309
- agent,
310
- 1,
311
- project.id,
312
- "local"
316
+ // Claimed right before the row exists, with nothing awaited between, so the
317
+ // release sweep can't start the same piece.
318
+ if (opts.pieceId) {
319
+ try {
320
+ claimPiece(opts.pieceId, id);
321
+ } catch (error) {
322
+ await discardLeftoverStart(projectPath, feature).catch(() => {});
323
+ throw error;
324
+ }
325
+ }
326
+ try {
327
+ queries
328
+ .createSession(db)
329
+ .run(
330
+ id,
331
+ naming.name,
332
+ tmuxName,
333
+ wt.worktreePath,
334
+ null,
335
+ model,
336
+ null,
337
+ "sessions",
338
+ agent,
339
+ 1,
340
+ project.id,
341
+ "local"
342
+ );
343
+ queries
344
+ .updateSessionWorktree(db)
345
+ .run(wt.worktreePath, wt.branchName, baseBranch, null, id);
346
+ // Everything the launch needs is on the row, so a restart can resume it.
347
+ db.prepare(
348
+ `UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
349
+ model_provider = ?, model_overrides = ?, plan_phase = ?, plan_only = ?,
350
+ plan_decided = ?, plan_reason = ? WHERE id = ?`
351
+ ).run(
352
+ prompt,
353
+ buildTaskBrief({
354
+ branch: wt.branchName,
355
+ baseBranch,
356
+ stack: opts.base?.stack,
357
+ }),
358
+ naming.source,
359
+ view,
360
+ routing.first.provider.id === ANTHROPIC
361
+ ? null
362
+ : routing.first.provider.id,
363
+ routing.override ? JSON.stringify(routing.override) : null,
364
+ routing.plan ? "planning" : null,
365
+ routing.planOnly ? 1 : null,
366
+ routing.planOnly ? 1 : opts.plan === undefined ? null : opts.plan ? 1 : 0,
367
+ opts.planReason?.trim() || null,
368
+ id
313
369
  );
314
- queries
315
- .updateSessionWorktree(db)
316
- .run(wt.worktreePath, wt.branchName, baseBranch, null, id);
317
- // Everything the launch needs is on the row, so a restart can resume it.
318
- db.prepare(
319
- `UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
320
- model_provider = ?, model_overrides = ?, plan_phase = ?, plan_only = ?,
321
- plan_decided = ?, plan_reason = ?, started_by = ? WHERE id = ?`
322
- ).run(
323
- prompt,
324
- buildTaskBrief({
325
- branch: wt.branchName,
326
- baseBranch,
327
- stack: opts.base?.stack,
328
- }),
329
- naming.source,
330
- view,
331
- routing.first.provider.id === ANTHROPIC ? null : routing.first.provider.id,
332
- routing.override ? JSON.stringify(routing.override) : null,
333
- routing.plan ? "planning" : null,
334
- routing.planOnly ? 1 : null,
335
- routing.planOnly ? 1 : opts.plan === undefined ? null : opts.plan ? 1 : 0,
336
- opts.planReason?.trim() || null,
337
- opts.startedBy ?? null,
338
- id
339
- );
340
- const starter = opts.held?.device ?? opts.startedByDevice;
341
- if (starter) recordStarter(id, starter);
370
+ recordStart(id, {
371
+ startedBy: opts.startedBy ?? null,
372
+ parentSessionId: opts.parentSessionId ?? null,
373
+ startedVia: opts.startedVia,
374
+ startedByDevice: opts.held?.device ?? opts.startedByDevice,
375
+ });
376
+ } catch (error) {
377
+ // The claim was made for a session that never existed.
378
+ if (opts.pieceId) releaseClaim(opts.pieceId, id);
379
+ throw error;
380
+ }
342
381
  if (opts.held) holdFor(id, opts.held.device, opts.held.orchestrated);
343
382
  opts.onCreated?.(id);
344
383
  void naming.refine?.(id);
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type Database from "better-sqlite3";
11
11
  import { db } from "../db";
12
+ import { orchestratorOf } from "./starter";
12
13
 
13
14
  // table, column, and how the id sits in it: the whole value, `task:<id>`, or
14
15
  // the tail of a `<condition>:<id>` key.
@@ -59,9 +60,19 @@ export const CARRIED_COLUMNS = [
59
60
  ] as const;
60
61
 
61
62
  /** The orchestration columns that make a session "an orchestrator's task". */
63
+ // started_by carries as the orchestrator only: a task a plain task started is
64
+ // not held, and a mirror reading it as held would relay to the wrong session.
62
65
  export const orchestrationOf = (session: Record<string, unknown>) =>
63
66
  Object.fromEntries(
64
- CARRIED_COLUMNS.map((c) => [c, session[c] ?? null] as const)
67
+ CARRIED_COLUMNS.map(
68
+ (c) =>
69
+ [
70
+ c,
71
+ c === "started_by"
72
+ ? orchestratorOf(session.started_by as string | null)
73
+ : (session[c] ?? null),
74
+ ] as const
75
+ )
65
76
  );
66
77
 
67
78
  export function carryTaskState(
@@ -10,10 +10,11 @@ import { db, type Session } from "../db";
10
10
  import { escalations } from "../orchestrator/gates";
11
11
  import { taskSubject } from "../orchestrator/asks";
12
12
  import { usesGitCrypt } from "../worktree-add";
13
+ import { orchestratorOf } from "./starter";
13
14
 
14
15
  /** Started by an orchestrator, or held for one by the machine it left. */
15
16
  export function isOrchestrated(session: Session): boolean {
16
- if (session.started_by || session.held_by_device) return true;
17
+ if (orchestratorOf(session.started_by) || session.held_by_device) return true;
17
18
  return !!db
18
19
  .prepare(
19
20
  `SELECT 1 FROM orchestrator_starts WHERE kind != 'stack' AND target = ? LIMIT 1`
package/lib/tasks/move.ts CHANGED
@@ -30,6 +30,7 @@ import { dropSessionDatabase } from "../project-config/database";
30
30
  import { releaseChat } from "../chat/runner";
31
31
  import { collectWhileMoving } from "../chat/hold";
32
32
  import { packChat, stopChatForMove } from "./move-chat";
33
+ import { orchestratorOf as starterOrchestrator } from "./starter";
33
34
  import {
34
35
  isClaudeSessionId,
35
36
  latestSessionId,
@@ -122,11 +123,12 @@ function orchestratorOf(
122
123
  ): { id: string; name: string } | undefined {
123
124
  if (session.held_for && session.held_for_name)
124
125
  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;
126
+ const by = starterOrchestrator(session.started_by);
127
+ if (!by) return undefined;
128
+ const o = db.prepare(`SELECT name FROM sessions WHERE id = ?`).get(by) as
129
+ | { name: string }
130
+ | undefined;
131
+ return o ? { id: by, name: o.name } : undefined;
130
132
  }
131
133
 
132
134
  // What an orchestrator's task carries besides its work (the orchestrator's
@@ -28,7 +28,7 @@ import { launchPending } from "./launch-gate";
28
28
  import { killTmux } from "./start";
29
29
  import { launchClaude } from "../agents/launch";
30
30
  import { resolveRole, sessionWorkspace, taskOverride } from "../models/resolve";
31
- import { sessionRole } from "../models/roles";
31
+ import { sessionRole } from "../models/session-role";
32
32
  import { holdingEscalations } from "../orchestrator/gates";
33
33
  import { openBySubject, workSubject } from "../orchestrator/asks";
34
34
  import { queueEventOnce } from "../orchestrator/events";