@saadnvd1/agent-os 0.3.578 → 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.
@@ -31,6 +31,13 @@ import {
31
31
  import { queueEvent } from "../orchestrator/events";
32
32
  import { readUsage, windowRefusal } from "../orchestrator/usage";
33
33
  import { createTask } from "./index";
34
+ import {
35
+ discardQueuedHeld,
36
+ markQueuedHeldDelivered,
37
+ queuedHeldMessages,
38
+ queuedLabel,
39
+ queuedMessagesSection,
40
+ } from "./queue-held";
34
41
  import { expandHome } from "./session";
35
42
  import {
36
43
  DEFAULT_PRIORITY,
@@ -67,7 +74,8 @@ import {
67
74
  viewOf,
68
75
  } from "../load/machines";
69
76
  import { peerReading, peerSource, refreshPeers } from "../hosts/peer-load";
70
- import { linkedHostIds } from "../hosts/remote-api";
77
+ import { hostLink, linkedHostIds } from "../hosts/remote-api";
78
+ import { hostTasks } from "./remote";
71
79
  import { projectMachines } from "../hosts/project-machines";
72
80
  import { claimResume, dueResumes } from "./pause";
73
81
 
@@ -148,6 +156,8 @@ export interface QueuedTaskView {
148
156
  // Its place in the score order within its line, and how many are in it.
149
157
  rank: number;
150
158
  of: number;
159
+ // Messages held for it until it starts.
160
+ held: number;
151
161
  }
152
162
 
153
163
  export const START_ATTEMPTS = 3;
@@ -419,11 +429,7 @@ export async function startOrQueue<T>(
419
429
  }
420
430
  }
421
431
 
422
- function label(r: Pick<QueueRow, "name" | "prompt">): string {
423
- if (r.name) return r.name;
424
- const line = r.prompt.split("\n")[0].trim();
425
- return line.length > 60 ? `${line.slice(0, 57)}...` : line;
426
- }
432
+ const label = queuedLabel;
427
433
 
428
434
  function afterLabel(r: QueueRow): string | null {
429
435
  if (r.after_task) {
@@ -438,7 +444,9 @@ function afterLabel(r: QueueRow): string | null {
438
444
  export function listQueue(): QueuedTaskView[] {
439
445
  const rows = db
440
446
  .prepare(
441
- `SELECT q.*, p.name AS project_name, p.workspace_id AS workspace_id
447
+ `SELECT q.*, p.name AS project_name, p.workspace_id AS workspace_id,
448
+ (SELECT COUNT(*) FROM bus_messages b WHERE b.to_id = q.id
449
+ AND b.held_queued = 1 AND b.delivered_at IS NULL AND b.read_at IS NULL) AS held_count
442
450
  FROM task_queue q LEFT JOIN projects p ON p.id = q.project_id
443
451
  WHERE q.status IN ('queued', 'starting', 'failed')
444
452
  ORDER BY q.position`
@@ -446,6 +454,7 @@ export function listQueue(): QueuedTaskView[] {
446
454
  .all() as (QueueRow & {
447
455
  project_name: string | null;
448
456
  workspace_id: string | null;
457
+ held_count: number;
449
458
  })[];
450
459
  const places = new Map<string, number>();
451
460
  // The score order within each line, by priority and age alone: holds
@@ -489,6 +498,7 @@ export function listQueue(): QueuedTaskView[] {
489
498
  wait: r.wait_code,
490
499
  rank: rank?.rank ?? place,
491
500
  of: rank?.of ?? place,
501
+ held: r.held_count,
492
502
  };
493
503
  });
494
504
  }
@@ -635,10 +645,14 @@ async function startRow(
635
645
  const placed = opts.placement ?? null;
636
646
  if (!opts.force)
637
647
  recordAutoStart(placed?.hostId ?? LOCAL, !!opts.heavy, Date.now());
648
+ // A linked machine's start never sees this machine's held messages: they
649
+ // go with the brief it is sent.
650
+ const remote = isRemoteHost(placed?.hostId ?? r.host_id ?? project?.host_id);
651
+ const remoteHeld = remote ? queuedHeldMessages(r.id) : [];
638
652
  const task = {
639
653
  id: r.id,
640
654
  projectId: r.project_id,
641
- prompt: r.prompt,
655
+ prompt: r.prompt + queuedMessagesSection(remoteHeld),
642
656
  name: r.name ?? undefined,
643
657
  model: r.model_overrides
644
658
  ? (JSON.parse(r.model_overrides) as ModelOverride)
@@ -668,6 +682,7 @@ async function startRow(
668
682
  { spendApproval: false }
669
683
  )
670
684
  : await createTask(task);
685
+ if (remoteHeld.length) markQueuedHeldDelivered(r.id);
671
686
  patch(r.id, {
672
687
  status: "started",
673
688
  started_at: new Date().toISOString(),
@@ -815,6 +830,20 @@ async function peerOptions(
815
830
  const hostIds = [...linkedHostIds()];
816
831
  if (!hostIds.length || !rows.length) return out;
817
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
+ );
818
847
  const perProject = new Map<string, Record<string, string | null>>();
819
848
  for (const r of rows) {
820
849
  if (out.has(r.project_id)) continue;
@@ -836,6 +865,7 @@ async function peerOptions(
836
865
  name: src.label,
837
866
  view: viewOf(src, now),
838
867
  capabilities: src.capabilities,
868
+ holds: holds.get(hostId) ?? false,
839
869
  hasProject: can[hostId] === null,
840
870
  loadPerCore: reading ? reading.load1 / reading.cores : 99,
841
871
  };
@@ -911,7 +941,7 @@ export async function tickOnce(now = Date.now()): Promise<"stale" | void> {
911
941
  )) {
912
942
  const r = rows.find((x) => x.id === c.id)!;
913
943
  const options = (peers.get(r.project_id) ?? []).filter(
914
- (p) => !taken.has(p.hostId)
944
+ (p) => !taken.has(p.hostId) && (!r.started_by || p.holds !== false)
915
945
  );
916
946
  const spot = place(c, home, options, built.get(c.id)!.needs, now);
917
947
  if (!spot) continue;
@@ -1077,7 +1107,7 @@ export function findQueued(
1077
1107
  return hits[0] ?? null;
1078
1108
  }
1079
1109
 
1080
- export const queuedLabel = label;
1110
+ export { queuedLabel };
1081
1111
 
1082
1112
  // Changes the priority of a queued task or a running one (they share an
1083
1113
  // id). Age is kept. Throws when no such task is waiting or running.
@@ -1105,11 +1135,15 @@ export function stampPriority(id: string, priority: Priority | undefined) {
1105
1135
  }
1106
1136
 
1107
1137
  export function removeQueued(id: string): void {
1108
- const changed = db
1109
- .prepare(
1110
- `UPDATE task_queue SET status = 'removed' WHERE id = ? AND status IN ('queued', 'failed')`
1111
- )
1112
- .run(id).changes;
1138
+ const changed = db.transaction(() => {
1139
+ const n = db
1140
+ .prepare(
1141
+ `UPDATE task_queue SET status = 'removed' WHERE id = ? AND status IN ('queued', 'failed')`
1142
+ )
1143
+ .run(id).changes;
1144
+ if (n) discardQueuedHeld(id);
1145
+ return n;
1146
+ })();
1113
1147
  if (!changed) throw new Error("That task isn't waiting in the queue");
1114
1148
  tickQueueSoon();
1115
1149
  }
@@ -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 = ?`)
@@ -28,6 +28,11 @@ import { planPrompt, preparePlanDir } from "../models/plan-step";
28
28
 
29
29
  export { firstMessageId, launchPending };
30
30
  import { expandHome } from "./session";
31
+ import {
32
+ markQueuedHeldDelivered,
33
+ queuedHeldMessages,
34
+ queuedMessagesSection,
35
+ } from "./queue-held";
31
36
  import {
32
37
  enterStage,
33
38
  finishSetup,
@@ -138,7 +143,12 @@ export async function finishTaskStart(
138
143
  ms: setup.ms,
139
144
  error: "Not launched: the task was closed during setup",
140
145
  });
141
- const task = (session.task_prompt ?? "") + setupNote(setup);
146
+ // Messages sent while it waited in the queue follow the brief, in order.
147
+ const heldMessages = queuedHeldMessages(sessionId);
148
+ const task =
149
+ (session.task_prompt ?? "") +
150
+ queuedMessagesSection(heldMessages) +
151
+ setupNote(setup);
142
152
  const planning = session.plan_phase === "planning";
143
153
  if (planning) preparePlanDir(session.worktree_path);
144
154
  const prompt = planning ? planPrompt(task, !!session.plan_only) : task;
@@ -169,6 +179,7 @@ export async function finishTaskStart(
169
179
  );
170
180
  return done({ status: "failed", ms: setup.ms, error: message });
171
181
  }
182
+ if (heldMessages.length) markQueuedHeldDelivered(sessionId);
172
183
  return done(setup);
173
184
  }
174
185
 
@@ -237,6 +248,7 @@ export async function resumeTaskStarts(
237
248
  : fs.existsSync(promptFileFor(row.id)) && (await alive(row.tmux_name));
238
249
  if (launched) {
239
250
  recordSetup(db, row.id, { status: "ok", ms: null, error: null });
251
+ markQueuedHeldDelivered(row.id);
240
252
  if (queued)
241
253
  inBackground(`first message of task ${row.id}`, () =>
242
254
  sendQueuedNow(row.id, first)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.578",
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"