@saadnvd1/agent-os 0.3.631 → 0.3.639

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 (177) hide show
  1. package/app/api/sessions/[id]/pr/route.ts +3 -3
  2. package/app/api/sessions/[id]/setup/route.ts +3 -3
  3. package/app/api/tasks/[id]/approve-plan/route.ts +5 -1
  4. package/app/api/tasks/[id]/arm/route.ts +6 -5
  5. package/app/api/tasks/[id]/chat-facts/route.ts +2 -2
  6. package/app/api/tasks/[id]/chat-tail/route.ts +2 -2
  7. package/app/api/tasks/[id]/checks/route.ts +1 -1
  8. package/app/api/tasks/[id]/diff/route.ts +1 -1
  9. package/app/api/tasks/[id]/done/route.ts +2 -2
  10. package/app/api/tasks/[id]/drop/route.ts +6 -2
  11. package/app/api/tasks/[id]/export/route.ts +3 -4
  12. package/app/api/tasks/[id]/merge/route.ts +3 -3
  13. package/app/api/tasks/[id]/message/route.ts +2 -2
  14. package/app/api/tasks/[id]/model/route.ts +5 -1
  15. package/app/api/tasks/[id]/move/route.ts +5 -1
  16. package/app/api/tasks/[id]/moved/route.ts +6 -8
  17. package/app/api/tasks/[id]/plan/route.ts +35 -0
  18. package/app/api/tasks/[id]/plan-edits/route.ts +6 -5
  19. package/app/api/tasks/[id]/priority/route.ts +4 -1
  20. package/app/api/tasks/[id]/resume/route.ts +3 -4
  21. package/app/api/tasks/[id]/stop/route.ts +2 -2
  22. package/app/api/tasks/route.ts +5 -2
  23. package/app/globals.css +28 -0
  24. package/components/Chat/ChatPanel.tsx +2 -11
  25. package/components/Chat/EventTurns.tsx +1 -7
  26. package/components/Chat/Markdown.tsx +1 -0
  27. package/components/Chat/Messages.tsx +1 -1
  28. package/lib/approvals/enable.ts +9 -2
  29. package/lib/away/report.ts +4 -4
  30. package/lib/away/run.ts +2 -2
  31. package/lib/bookmarks/store.ts +3 -3
  32. package/lib/bus/held-reply.ts +2 -2
  33. package/lib/bus/index.ts +3 -3
  34. package/lib/chat/compact-context.ts +1 -1
  35. package/lib/chat/events.ts +4 -0
  36. package/lib/chat/group.ts +3 -1
  37. package/lib/chat/hold.ts +3 -3
  38. package/lib/chat/reading-order.ts +50 -0
  39. package/lib/chat/refresh.ts +1 -1
  40. package/lib/chat/registry.ts +3 -3
  41. package/lib/chat/runner.ts +1 -1
  42. package/lib/chat/turns.ts +22 -3
  43. package/lib/chat/worker/host.ts +3 -0
  44. package/lib/chat/worker/main.ts +1 -1
  45. package/lib/db/index.ts +5 -1
  46. package/lib/db/lumifyhub.ts +8 -9
  47. package/lib/db/machine-scope.ts +12 -0
  48. package/lib/db/migrations/20261015T1600_plan_waits_for_caller.ts +19 -0
  49. package/lib/db/migrations/20261015T1800_split_sessions_tables.ts +172 -0
  50. package/lib/db/migrations/README.md +7 -0
  51. package/lib/db/queries.ts +16 -31
  52. package/lib/db/session-parts/columns.ts +186 -0
  53. package/lib/db/session-parts/fill.ts +33 -0
  54. package/lib/db/session-parts/index.ts +9 -0
  55. package/lib/db/session-parts/pauses.ts +11 -0
  56. package/lib/db/session-parts/placement.ts +11 -0
  57. package/lib/db/session-parts/plans.ts +31 -0
  58. package/lib/db/session-parts/prs.ts +31 -0
  59. package/lib/db/session-parts/tasks.ts +27 -0
  60. package/lib/db/session-parts/view.ts +43 -0
  61. package/lib/db/session-parts/write.ts +69 -0
  62. package/lib/db/types.ts +6 -1
  63. package/lib/done/archive.ts +2 -2
  64. package/lib/done/auto-archive.ts +1 -1
  65. package/lib/done/bulk.ts +1 -1
  66. package/lib/done/index.ts +8 -14
  67. package/lib/env-setup.ts +17 -4
  68. package/lib/health/evidence.ts +1 -1
  69. package/lib/health/facts.ts +1 -1
  70. package/lib/health/pieces.ts +1 -1
  71. package/lib/hosts/peer-actions.ts +2 -2
  72. package/lib/hosts/peer-mirror.ts +2 -2
  73. package/lib/hosts/peer-sync.ts +69 -32
  74. package/lib/learning-cards/input.ts +1 -1
  75. package/lib/learning-cards/store.ts +1 -1
  76. package/lib/models/plan-step.ts +83 -46
  77. package/lib/models/resume.ts +5 -7
  78. package/lib/notify/index.ts +1 -1
  79. package/lib/notify/push/events.ts +1 -1
  80. package/lib/notify/push/index.ts +1 -1
  81. package/lib/orchestrator/act.ts +1 -1
  82. package/lib/orchestrator/armed.ts +17 -15
  83. package/lib/orchestrator/ask-approvals.ts +1 -1
  84. package/lib/orchestrator/ask-heads.ts +1 -1
  85. package/lib/orchestrator/ask-settle.ts +1 -1
  86. package/lib/orchestrator/brakes.ts +1 -1
  87. package/lib/orchestrator/digest.ts +2 -2
  88. package/lib/orchestrator/external-pr.ts +2 -2
  89. package/lib/orchestrator/facts.ts +4 -28
  90. package/lib/orchestrator/held-asks.ts +3 -3
  91. package/lib/orchestrator/home.ts +2 -2
  92. package/lib/orchestrator/overview.ts +1 -1
  93. package/lib/orchestrator/pr-wake.ts +3 -5
  94. package/lib/orchestrator/questions.ts +1 -1
  95. package/lib/orchestrator/quick-store.ts +1 -1
  96. package/lib/orchestrator/quick.ts +10 -7
  97. package/lib/orchestrator/read.ts +1 -1
  98. package/lib/orchestrator/rebase.ts +8 -11
  99. package/lib/orchestrator/reconcile.ts +2 -2
  100. package/lib/orchestrator/signoff.ts +3 -1
  101. package/lib/orchestrator/targets.ts +1 -1
  102. package/lib/orchestrator/task-state.ts +4 -2
  103. package/lib/plans/features.ts +1 -1
  104. package/lib/plans/release.ts +1 -1
  105. package/lib/ports.ts +1 -1
  106. package/lib/resurrect/find.ts +1 -1
  107. package/lib/resurrect/index.ts +24 -18
  108. package/lib/schedules/store.ts +1 -1
  109. package/lib/session-audit/sources.ts +3 -3
  110. package/lib/session-logs/index.ts +1 -1
  111. package/lib/session-titles.ts +1 -1
  112. package/lib/sessions/setup-progress.ts +1 -1
  113. package/lib/sessions/stop.ts +1 -1
  114. package/lib/sessions/worktree-setup.ts +2 -1
  115. package/lib/simulator/index.ts +1 -1
  116. package/lib/simulator/task-devices.ts +1 -1
  117. package/lib/stacks/board.ts +1 -1
  118. package/lib/stacks/land.ts +1 -1
  119. package/lib/stacks/restack.ts +1 -1
  120. package/lib/stacks/start.ts +2 -2
  121. package/lib/stacks/tick.ts +1 -1
  122. package/lib/stacks/view.ts +1 -1
  123. package/lib/status/incremental.ts +1 -1
  124. package/lib/tasks/contract/actions.ts +35 -0
  125. package/lib/tasks/contract/authorize.ts +82 -0
  126. package/lib/tasks/contract/client.ts +296 -0
  127. package/lib/tasks/{held-remote.ts → contract/held-calls.ts} +35 -66
  128. package/lib/tasks/contract/index.ts +28 -0
  129. package/lib/tasks/contract/plan-path.ts +7 -0
  130. package/lib/tasks/contract/plan.ts +274 -0
  131. package/lib/tasks/contract/routes.ts +26 -0
  132. package/lib/tasks/contract/start.ts +122 -0
  133. package/lib/tasks/contract/sync.ts +126 -0
  134. package/lib/tasks/contract/types.ts +41 -0
  135. package/lib/tasks/{remote-views.ts → contract/views.ts} +11 -18
  136. package/lib/tasks/finish.ts +10 -9
  137. package/lib/tasks/held-replies.ts +7 -13
  138. package/lib/tasks/held.ts +36 -17
  139. package/lib/tasks/hold-checks.ts +134 -0
  140. package/lib/tasks/holds.ts +281 -0
  141. package/lib/tasks/import.ts +26 -14
  142. package/lib/tasks/index.ts +56 -22
  143. package/lib/tasks/launch-gate.ts +1 -1
  144. package/lib/tasks/move-bundle.ts +17 -2
  145. package/lib/tasks/move-carry.ts +15 -7
  146. package/lib/tasks/move-flow.ts +29 -36
  147. package/lib/tasks/move-guard.ts +10 -2
  148. package/lib/tasks/move-recover.ts +6 -25
  149. package/lib/tasks/move.ts +28 -27
  150. package/lib/tasks/moved-away.ts +1 -1
  151. package/lib/tasks/pause-hold.ts +13 -5
  152. package/lib/tasks/pause.ts +21 -54
  153. package/lib/tasks/placement.ts +16 -16
  154. package/lib/tasks/plan-view.ts +11 -3
  155. package/lib/tasks/queue-candidates.ts +151 -0
  156. package/lib/tasks/queue-placement.ts +155 -0
  157. package/lib/tasks/queue-rows.ts +236 -0
  158. package/lib/tasks/queue-start.ts +213 -0
  159. package/lib/tasks/queue-tick.ts +148 -0
  160. package/lib/tasks/queue-types.ts +141 -0
  161. package/lib/tasks/queue-view.ts +112 -0
  162. package/lib/tasks/queue.ts +67 -1102
  163. package/lib/tasks/schedule.ts +44 -147
  164. package/lib/tasks/session.ts +7 -4
  165. package/lib/tasks/start.ts +4 -4
  166. package/lib/tasks/starter.ts +8 -11
  167. package/lib/usage/calls.ts +1 -1
  168. package/lib/usage/compare.ts +1 -1
  169. package/lib/usage/quality.ts +1 -1
  170. package/lib/usage/turns.ts +1 -1
  171. package/lib/voice/vocab.ts +1 -1
  172. package/package.json +1 -1
  173. package/scripts/backfill-session-logs.ts +1 -1
  174. package/scripts/check +55 -7
  175. package/lib/tasks/auto-hold.ts +0 -17
  176. package/lib/tasks/held-routes.ts +0 -33
  177. package/lib/tasks/remote.ts +0 -238
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The one loop that brings this machine's mirrors in line with what their
3
+ * machines say. Each tick reads every machine's task list once and applies it
4
+ * to each mirror: finished and moved, a plan written, a plan note to tell the
5
+ * orchestrator about, an approved plan coming home. Nothing else polls a
6
+ * machine's tasks to write a mirror.
7
+ */
8
+
9
+ import { db, type Session } from "../../db";
10
+ import { isRemoteHost } from "../../hosts";
11
+ import { hostLink, cleanRemoteText } from "../../hosts/remote-api";
12
+ import { sessionWorkspace } from "../../models/resolve";
13
+ import { queueEvent } from "../../orchestrator/events";
14
+ import { orchestratorOf } from "../starter";
15
+ import type { TaskStatus } from "../state";
16
+ import type { TaskView } from "../index";
17
+ import * as client from "./client";
18
+ import {
19
+ finishArrival,
20
+ fire,
21
+ handingOff,
22
+ handoffHome,
23
+ planWritten,
24
+ } from "./plan";
25
+ import { setMirrorStatus } from "./start";
26
+ import { setPlan } from "../../db/session-parts";
27
+
28
+ const FINISHED: Record<string, TaskStatus> = {
29
+ merged: "merged",
30
+ dropped: "dropped",
31
+ done: "done",
32
+ };
33
+
34
+ const getSession = (id: string) =>
35
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
36
+ | Session
37
+ | undefined;
38
+
39
+ // The plan-written line a held task stored on its machine (plan-step.ts),
40
+ // once per distinct plan: the mirror keeps the last one it told about.
41
+ function announcePlanNote(s: Session, view: TaskView): void {
42
+ const workspaceId = sessionWorkspace(s);
43
+ if (!workspaceId || !orchestratorOf(s.started_by)) return;
44
+ const note =
45
+ typeof view.planNote === "string"
46
+ ? cleanRemoteText(view.planNote).slice(0, 2000)
47
+ : "";
48
+ if (!note || note === s.plan_note) return;
49
+ setPlan(db, s.id, { plan_note: note });
50
+ queueEvent(workspaceId, `plan-done:${s.id}`, s.id, note);
51
+ }
52
+
53
+ async function applyView(
54
+ s: Session,
55
+ view: TaskView | undefined,
56
+ gone: Set<string>
57
+ ): Promise<void> {
58
+ if (!view) {
59
+ if (gone.has(s.id)) setMirrorStatus(s.id, "moved");
60
+ return;
61
+ }
62
+ if (FINISHED[view.state]) {
63
+ setMirrorStatus(s.id, FINISHED[view.state]);
64
+ return;
65
+ }
66
+ if (!s.plan_waits_for_caller) return announcePlanNote(s, view);
67
+ if (s.plan_phase === "planning") await planWritten(s, view);
68
+ else if (s.plan_phase === "handoff") fire(s.id, () => handoffHome(s.id));
69
+ }
70
+
71
+ /** Every task this machine mirrors from a linked machine, that machine's word applied. */
72
+ export async function syncMirrors(): Promise<void> {
73
+ const rows = (
74
+ db
75
+ .prepare(
76
+ `SELECT * FROM session_rows
77
+ WHERE task_prompt IS NOT NULL AND task_status = 'running'
78
+ AND archived_at IS NULL AND peer_mirror IS NOT 1
79
+ AND host_id IS NOT NULL`
80
+ )
81
+ .all() as Session[]
82
+ ).filter((s) => isRemoteHost(s.host_id));
83
+ const byHost = new Map<string, Session[]>();
84
+ for (const s of rows)
85
+ byHost.set(s.host_id, [...(byHost.get(s.host_id) ?? []), s]);
86
+ for (const [hostId, mirrors] of byHost) {
87
+ const link = hostLink(hostId);
88
+ if (!link) continue;
89
+ let listing: Awaited<ReturnType<typeof client.list>>;
90
+ try {
91
+ listing = await client.list(link);
92
+ } catch {
93
+ continue;
94
+ }
95
+ const live = new Map(
96
+ (Array.isArray(listing.tasks) ? listing.tasks : []).map((t) => [t?.id, t])
97
+ );
98
+ const gone = new Set((listing.moved ?? []).map((m) => m.id));
99
+ for (const s of mirrors) {
100
+ try {
101
+ await applyView(s, live.get(s.id), gone);
102
+ } catch (e) {
103
+ console.error(`Mirror of ${s.id} on ${hostId} failed:`, e);
104
+ }
105
+ }
106
+ }
107
+ // A task that arrived but never began implementing (a restart between).
108
+ const arrived = db
109
+ .prepare(
110
+ `SELECT id, plan_handoff_message FROM session_rows
111
+ WHERE plan_phase = 'awaiting' AND plan_handoff_message IS NOT NULL
112
+ AND task_status = 'running' AND archived_at IS NULL AND peer_mirror IS NOT 1`
113
+ )
114
+ .all() as { id: string; plan_handoff_message: string }[];
115
+ for (const a of arrived) {
116
+ if (isRemoteHost(getSession(a.id)?.host_id)) continue;
117
+ fire(a.id, async () => {
118
+ handingOff.add(a.id);
119
+ try {
120
+ await finishArrival(a.id, a.plan_handoff_message);
121
+ } finally {
122
+ handingOff.delete(a.id);
123
+ }
124
+ });
125
+ }
126
+ }
@@ -0,0 +1,41 @@
1
+ import type { TaskView } from "../index";
2
+
3
+ /**
4
+ * What this machine's task API does, for another machine deciding whether
5
+ * it can trust it with a pinned merge or a move; keyed-start: a session
6
+ * start takes a key, so it's safe to retry (lib/sessions/launch.ts).
7
+ */
8
+ export const TASK_CAPABILITIES = [
9
+ "pinned-merge",
10
+ "move",
11
+ "chat-turn",
12
+ "keyed-start",
13
+ "chat-move",
14
+ // Holds an orchestrator's task for the machine that sent it, and answers
15
+ // its messages, reads, stops and done (contract/authorize.ts).
16
+ "orchestrated-move",
17
+ // A held task's chat facts and its messages for its orchestrator.
18
+ "held-chat-facts",
19
+ "held-replies",
20
+ // A keyed start that carries an orchestrator's description holds the task
21
+ // for the device that sent it, as a move does (lib/tasks/held.ts).
22
+ "held-start",
23
+ // A plan placed here by another machine stops at the plan for that machine
24
+ // to decide what follows (contract/plan.ts).
25
+ "remote-plan",
26
+ // Every task route a peer calls checks the holder or starter the same way.
27
+ // compat: the shims marked "task-contract-1" in contract/ go once every
28
+ // linked machine reports this.
29
+ "task-contract-1",
30
+ ] as const;
31
+
32
+ export interface HostTasks {
33
+ tasks: TaskView[];
34
+ moved?: { id: string; movedTo: string | null }[];
35
+ capabilities?: string[];
36
+ }
37
+
38
+ export interface ChatFacts {
39
+ blocked: string | null;
40
+ turn: string | null;
41
+ }
@@ -1,21 +1,15 @@
1
1
  /**
2
2
  * How tasks on other machines look here: each mirror's view as its machine
3
- * reports it. A mirror follows the machine's word: finished when it says
4
- * finished, moved when it says moved, never because it went missing once.
3
+ * reports it. Reading only: the sync loop (sync.ts) is what records that a
4
+ * mirror finished or moved, never because it went missing once.
5
5
  */
6
6
 
7
- import type { Session } from "../db";
8
- import { requireHostLink, type HostLink } from "../hosts/remote-api";
9
- import { deriveTaskState, type TaskStatus } from "./state";
10
- import { hostTasks, setMirrorStatus } from "./remote";
11
- import type { TaskView } from "./index";
12
- import { viewOf } from "./move-targets";
13
-
14
- const FINISHED: Record<string, TaskStatus> = {
15
- merged: "merged",
16
- dropped: "dropped",
17
- done: "done",
18
- };
7
+ import type { Session } from "../../db";
8
+ import { requireHostLink, type HostLink } from "../../hosts/remote-api";
9
+ import { deriveTaskState } from "../state";
10
+ import type { TaskView } from "../index";
11
+ import { viewOf } from "../move-targets";
12
+ import * as client from "./client";
19
13
 
20
14
  function offlineView(
21
15
  session: Session,
@@ -57,13 +51,13 @@ async function viewsOn(hostId: string, rows: Session[]): Promise<TaskView[]> {
57
51
  return rows.map((s) => offlineView(s, "", (err as Error).message));
58
52
  }
59
53
  try {
60
- const { tasks, moved = [] } = await hostTasks(link);
54
+ const { tasks, moved = [] } = await client.list(link);
61
55
  const live = new Map(tasks.map((t) => [t.id, t]));
62
56
  const gone = new Set(moved.map((m) => m.id));
63
57
  return rows.flatMap((s): TaskView[] => {
64
58
  const t = live.get(s.id);
65
59
  if (!t) {
66
- if (gone.has(s.id)) return (setMirrorStatus(s.id, "moved"), []);
60
+ if (gone.has(s.id)) return [];
67
61
  return [
68
62
  offlineView(
69
63
  s,
@@ -72,7 +66,6 @@ async function viewsOn(hostId: string, rows: Session[]): Promise<TaskView[]> {
72
66
  ),
73
67
  ];
74
68
  }
75
- if (FINISHED[t.state]) setMirrorStatus(s.id, FINISHED[t.state]);
76
69
  return [
77
70
  {
78
71
  ...t,
@@ -92,7 +85,7 @@ async function viewsOn(hostId: string, rows: Session[]): Promise<TaskView[]> {
92
85
  }
93
86
  }
94
87
 
95
- export async function remoteTaskViews(mirrors: Session[]): Promise<TaskView[]> {
88
+ export async function mirrorViews(mirrors: Session[]): Promise<TaskView[]> {
96
89
  const byHost = new Map<string, Session[]>();
97
90
  for (const s of mirrors)
98
91
  byHost.set(s.host_id, [...(byHost.get(s.host_id) ?? []), s]);
@@ -1,6 +1,7 @@
1
1
  import { db, type Session } from "../db";
2
2
  import { endPauseOnFinish } from "./pause-hold";
3
- import { isMirror, remoteTaskAction } from "./remote";
3
+ import { dropThere, mergeThere } from "./contract/actions";
4
+ import { isMirror } from "./contract/start";
4
5
  import { deleteWorktree } from "../worktrees";
5
6
  import { settleWorktree, type MergedAs } from "../done/worktree";
6
7
  import {
@@ -37,6 +38,7 @@ import { captureMergedTask } from "../session-logs";
37
38
  import { sessionEnded } from "../experiments/hooks";
38
39
  import { snapshotWorktree } from "../resurrect/snapshot";
39
40
  import { keepSessionFiles } from "../session-artifacts/collect";
41
+ import { setPr, setTask } from "../db/session-parts";
40
42
 
41
43
  // Never deleted, whatever a setting says.
42
44
  export const PROTECTED_BRANCHES = new Set(["main", "master", "HEAD"]);
@@ -141,7 +143,7 @@ export async function signOffTask(
141
143
  // Its own machine checks the gates and merges.
142
144
  // That machine's settings chose the method; null if it didn't say.
143
145
  if (isMirror(session))
144
- return remoteTaskAction(session, "merge", opts.head, session.base_branch);
146
+ return mergeThere(session, opts.head, session.base_branch);
145
147
  const repo = projectPathFor(session);
146
148
  if (!repo) throw new Error("Task has no project");
147
149
  const refusal = signOffRefusal(id);
@@ -178,9 +180,10 @@ export async function signOffTask(
178
180
  method: policy.method,
179
181
  head: opts.head,
180
182
  });
181
- db.prepare(
182
- `UPDATE sessions SET task_status = 'merged', pr_status = 'merged' WHERE id = ?`
183
- ).run(id);
183
+ db.transaction(() => {
184
+ setTask(db, id, { task_status: "merged" });
185
+ setPr(db, id, { pr_status: "merged" });
186
+ })();
184
187
  endPauseOnFinish(id);
185
188
  db.prepare(
186
189
  `UPDATE stack_items SET status = 'merged', error = NULL WHERE session_id = ?`
@@ -225,7 +228,7 @@ export async function dropTask(id: string): Promise<void> {
225
228
  if (session.task_status !== "running")
226
229
  throw new Error(`Task is already ${session.task_status}`);
227
230
  if (isMirror(session)) {
228
- await remoteTaskAction(session, "drop");
231
+ await dropThere(session);
229
232
  return;
230
233
  }
231
234
  const repo = projectPathFor(session);
@@ -247,9 +250,7 @@ export async function dropTask(id: string): Promise<void> {
247
250
  if (pr?.state === "OPEN") {
248
251
  await run("gh", ["pr", "close", String(pr.number)], repo).catch(() => {});
249
252
  }
250
- db.prepare(`UPDATE sessions SET task_status = 'dropped' WHERE id = ?`).run(
251
- id
252
- );
253
+ setTask(db, id, { task_status: "dropped" });
253
254
  endPauseOnFinish(id);
254
255
  db.prepare(
255
256
  `UPDATE stack_items SET status = 'dropped' WHERE session_id = ?`
@@ -8,7 +8,8 @@
8
8
 
9
9
  import { db, type Session } from "../db";
10
10
  import { sendMessage } from "../bus";
11
- import { hostApi, type HostLink } from "../hosts/remote-api";
11
+ import type { HostLink } from "../hosts/remote-api";
12
+ import * as client from "./contract/client";
12
13
 
13
14
  const PULL_EVERY_MS = 5000;
14
15
  const MISSING_FOR_MS = 10 * 60 * 1000;
@@ -27,13 +28,13 @@ interface Pulled {
27
28
  body: string;
28
29
  }
29
30
 
30
- // Held means an orchestrator started it, as isHeldRemote reads it.
31
+ // Held means an orchestrator started it, as isHeldRemote reads it (contract/held-calls.ts).
31
32
  const HELD_BY = `started_by IN (SELECT id FROM sessions WHERE role = 'orchestrator')`;
32
33
 
33
34
  const heldMirror = (hostId: string, id: string) =>
34
35
  db
35
36
  .prepare(
36
- `SELECT * FROM sessions WHERE id = ? AND host_id = ?
37
+ `SELECT * FROM session_rows WHERE id = ? AND host_id = ?
37
38
  AND ${HELD_BY} AND task_status IN ('running', 'moving')`
38
39
  )
39
40
  .get(id, hostId) as Session | undefined;
@@ -41,7 +42,7 @@ const heldMirror = (hostId: string, id: string) =>
41
42
  export function hasHeldMirrors(hostId: string): boolean {
42
43
  return !!db
43
44
  .prepare(
44
- `SELECT 1 FROM sessions WHERE host_id = ? AND ${HELD_BY}
45
+ `SELECT 1 FROM session_rows WHERE host_id = ? AND ${HELD_BY}
45
46
  AND task_prompt IS NOT NULL AND task_status IN ('running', 'moving')`
46
47
  )
47
48
  .get(hostId);
@@ -58,11 +59,7 @@ export async function pullHeldReplies(link: HostLink): Promise<number> {
58
59
  try {
59
60
  let messages: Pulled[];
60
61
  try {
61
- const res = await hostApi<{ messages?: unknown }>(
62
- link,
63
- "/api/tasks/held-replies",
64
- { timeout: 8000 }
65
- );
62
+ const res = await client.replies(link);
66
63
  messages = (Array.isArray(res.messages) ? res.messages : []).filter(
67
64
  (m): m is Pulled =>
68
65
  !!m &&
@@ -98,10 +95,7 @@ export async function pullHeldReplies(link: HostLink): Promise<number> {
98
95
  }
99
96
  }
100
97
  if (handled.length)
101
- await hostApi(link, "/api/tasks/held-replies", {
102
- body: { ids: handled },
103
- timeout: 8000,
104
- }).catch(() => undefined);
98
+ await client.ackReplies(link, handled).catch(() => undefined);
105
99
  return delivered;
106
100
  } finally {
107
101
  busy.delete(hostId);
package/lib/tasks/held.ts CHANGED
@@ -8,7 +8,9 @@
8
8
  import { orchestratorOf } from "./starter";
9
9
  import os from "os";
10
10
  import { db, type Session } from "../db";
11
+ import { isRemoteHost } from "../hosts";
11
12
  import type { OrchestratedBundle } from "./move-bundle";
13
+ import { setPlacement, setTask } from "../db/session-parts";
12
14
 
13
15
  export function holderRefusal(
14
16
  session: Pick<Session, "held_by_device" | "held_from">,
@@ -25,7 +27,7 @@ export function heldRefusal(
25
27
  device: string | null | undefined
26
28
  ): string | null {
27
29
  const session = db
28
- .prepare(`SELECT held_by_device, held_from FROM sessions WHERE id = ?`)
30
+ .prepare(`SELECT held_by_device, held_from FROM session_rows WHERE id = ?`)
29
31
  .get(id) as Pick<Session, "held_by_device" | "held_from"> | undefined;
30
32
  return session ? holderRefusal(session, device) : null;
31
33
  }
@@ -40,17 +42,15 @@ export function holdFor(
40
42
  since?: string | null;
41
43
  }
42
44
  ): void {
43
- db.prepare(
44
- `UPDATE sessions SET held_by_device = ?, held_from = ?, held_for = ?, held_for_name = ?,
45
- task_since = COALESCE(?, task_since) WHERE id = ?`
46
- ).run(
47
- holder,
48
- held.heldFrom,
49
- held.orchestrator?.id ?? null,
50
- held.orchestrator?.name ?? null,
51
- held.since ?? null,
52
- id
53
- );
45
+ db.transaction(() => {
46
+ setPlacement(db, id, {
47
+ held_by_device: holder,
48
+ held_from: held.heldFrom,
49
+ held_for: held.orchestrator?.id ?? null,
50
+ held_for_name: held.orchestrator?.name ?? null,
51
+ });
52
+ setTask(db, id, { task_since: held.since ?? undefined });
53
+ })();
54
54
  }
55
55
 
56
56
  /**
@@ -80,10 +80,7 @@ const UNRECORDED_STARTER = "unrecorded";
80
80
 
81
81
  /** Records the paired device that started a task here, the only one that may later hold it. */
82
82
  export function recordStarter(id: string, device: string): void {
83
- db.prepare(`UPDATE sessions SET started_by_device = ? WHERE id = ?`).run(
84
- device,
85
- id
86
- );
83
+ setPlacement(db, id, { started_by_device: device });
87
84
  }
88
85
 
89
86
  type Holdable = Pick<
@@ -123,7 +120,7 @@ export function holdExisting(
123
120
  const s = db
124
121
  .prepare(
125
122
  `SELECT held_by_device, started_by, started_by_device, task_status, host_id, task_prompt
126
- FROM sessions WHERE id = ?`
123
+ FROM session_rows WHERE id = ?`
127
124
  )
128
125
  .get(id) as Holdable | undefined;
129
126
  if (!s?.task_prompt || s.task_status !== "running") return false;
@@ -133,3 +130,25 @@ export function holdExisting(
133
130
  holdFor(id, device, held);
134
131
  return true;
135
132
  }
133
+
134
+ /**
135
+ * A plan another machine placed here is read and approved by that machine
136
+ * only: it decides where the task is built. Null for any other task.
137
+ */
138
+ export function planWaitRefusal(
139
+ id: string,
140
+ device: string | null | undefined
141
+ ): string | null {
142
+ const s = db
143
+ .prepare(
144
+ `SELECT plan_waits_for_caller, started_by_device, host_id FROM session_rows WHERE id = ?`
145
+ )
146
+ .get(id) as
147
+ | Pick<Session, "plan_waits_for_caller" | "started_by_device" | "host_id">
148
+ | undefined;
149
+ if (!s?.plan_waits_for_caller) return null;
150
+ // The placing machine's own mirror of the plan: its people decide it.
151
+ if (isRemoteHost(s.host_id)) return null;
152
+ if (device && device === s.started_by_device) return null;
153
+ return "The machine that placed this plan decides it; approve it from there";
154
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The holds that read the database or this machine, each returning `Hold`s
3
+ * (holds.ts) so every caller that asks "may this start now" gets the same
4
+ * answer and the same words: the orchestrator's Pause and the usage window,
5
+ * `after`, and this machine's load for a start here.
6
+ */
7
+
8
+ import type { Session } from "../db";
9
+ import { getProject } from "../projects";
10
+ import { isRemoteHost } from "../hosts";
11
+ import { isPaused } from "../orchestrator/pause";
12
+ import { brakesEnabled } from "../orchestrator/brakes";
13
+ import { readUsage, windowRefusal } from "../orchestrator/usage";
14
+ import { localView, machineCapabilities } from "../load/machines";
15
+ import { DEFAULT_PRIORITY, type Priority } from "./schedule-types";
16
+ import { machineHolds, holdReason, type Hold } from "./holds";
17
+ import { heavyNeeds, meetsNeeds, needsOf, type Need } from "./placement";
18
+ import { expandHome } from "./session";
19
+ import { afterLabel, hasFinished } from "./queue-rows";
20
+ import type { QueueRow } from "./queue-types";
21
+ import type { PauseBy } from "./schedule-types";
22
+
23
+ // Why an automatic start has to wait: the orchestrator's Pause, then the
24
+ // usage window. A start now by a person isn't held, and a plan-only start
25
+ // is held by Pause alone.
26
+ export function autoHolds(
27
+ workspaceId: string | null,
28
+ planOnly = false
29
+ ): Hold[] {
30
+ const out: Hold[] = [];
31
+ if (workspaceId && isPaused(workspaceId))
32
+ out.push({
33
+ code: "orch_paused",
34
+ text: "Waiting: the orchestrator is paused",
35
+ });
36
+ if (planOnly) return out;
37
+ const window = brakesEnabled() ? windowRefusal(readUsage()) : null;
38
+ if (window) out.push({ code: "window", text: `Waiting: ${window}` });
39
+ return out;
40
+ }
41
+
42
+ export const autoHold = (
43
+ workspaceId: string | null,
44
+ planOnly = false
45
+ ): Hold | null => autoHolds(workspaceId, planOnly)[0] ?? null;
46
+
47
+ // `after X`: the task it waits on hasn't finished.
48
+ export function afterHold(r: QueueRow): Hold | null {
49
+ if (!r.after_task || hasFinished(r.after_task)) return null;
50
+ return {
51
+ code: "after",
52
+ text: `After ${afterLabel({ ...r, after_any: null }) ?? "its task"}`,
53
+ };
54
+ }
55
+
56
+ // What this machine would say to a start of a task with these needs here
57
+ // and now: empty means it may start.
58
+ export function localHolds(
59
+ ask: { needs: Need[]; priority?: Priority },
60
+ now = Date.now()
61
+ ): Hold[] {
62
+ return machineHolds(
63
+ {
64
+ heavy: heavyNeeds(ask.needs),
65
+ needsUnmet: !meetsNeeds(ask.needs, machineCapabilities()),
66
+ remote: false,
67
+ priority: ask.priority ?? DEFAULT_PRIORITY,
68
+ },
69
+ localView(now),
70
+ now
71
+ );
72
+ }
73
+
74
+ export const requestNeeds = (
75
+ projectId: string,
76
+ workingDirectory: string,
77
+ prompt: string,
78
+ setting: Need[] | null
79
+ ): Need[] =>
80
+ setting ??
81
+ needsOf({
82
+ projectId,
83
+ projectPath: expandHome(workingDirectory),
84
+ prompt,
85
+ }).needs;
86
+
87
+ // Whether the task needs something this machine lacks (a Mac).
88
+ export function needsUnmetHere(
89
+ projectId: string,
90
+ prompt: string,
91
+ setting: Need[] | null
92
+ ): boolean {
93
+ const project = getProject(projectId);
94
+ if (!project || isRemoteHost(project.host_id)) return false;
95
+ return !meetsNeeds(
96
+ requestNeeds(projectId, project.working_directory, prompt, setting),
97
+ machineCapabilities()
98
+ );
99
+ }
100
+
101
+ // Whether this machine's load rules out starting a task for this project now.
102
+ export function loadHeldStart(
103
+ projectId: string,
104
+ prompt: string,
105
+ setting: Need[] | null
106
+ ): boolean {
107
+ const project = getProject(projectId);
108
+ if (!project || isRemoteHost(project.host_id)) return false;
109
+ const head = localHolds({
110
+ needs: requestNeeds(projectId, project.working_directory, prompt, setting),
111
+ })[0];
112
+ return head?.code === "load_red" || head?.code === "heavy";
113
+ }
114
+
115
+ // Why this machine's load rules out starting an agent now, or null. Only a
116
+ // red (or still settling) machine holds a resume; amber doesn't.
117
+ export function loadHoldFor(s: Session): string | null {
118
+ const project = s.project_id ? getProject(s.project_id) : null;
119
+ if (!project || isRemoteHost(s.host_id ?? project.host_id)) return null;
120
+ const hold = localHolds({ needs: [] })[0];
121
+ return hold?.code === "load_red" ? holdReason(hold) : null;
122
+ }
123
+
124
+ // Why a resume this actor asked for must wait: this machine's load, and for
125
+ // the orchestrator also what holds its own starts (it is paused, or the
126
+ // usage window). A person's own resume skips the second.
127
+ export function resumeHoldFor(s: Session, actor: PauseBy): string | null {
128
+ const load = loadHoldFor(s);
129
+ if (load) return load;
130
+ if (actor !== "orchestrator") return null;
131
+ const ws = s.project_id ? getProject(s.project_id)?.workspace_id : null;
132
+ const hold = autoHold(ws ?? null);
133
+ return hold ? holdReason(hold) : null;
134
+ }