@addai/node 0.28.0 → 0.29.0

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.
@@ -724,12 +724,17 @@ async function runDesktopCommand(cmd) {
724
724
  break;
725
725
  }
726
726
  case 'desktop_start': {
727
- // Someone pressed start, so this one fails loudly rather than
728
- // deferring quietly the way autostart does. They are owed an answer
729
- // they can act on: which limit, and where to change it.
727
+ // A full machine used to be a hard failure here, which left an entity
728
+ // with nothing to do but ask again and hope. It waits instead: the
729
+ // request keeps its place and the reconciler starts it when a slot
730
+ // frees. Said out loud, because a desktop that is "not starting yet"
731
+ // and one that is broken look identical from the outside.
730
732
  const room = await (0, manager_1.desktopStartAllowed)(await (0, manager_1.listDesktops)(), row.id);
731
- if (!room.allowed)
732
- throw new Error(room.reason);
733
+ if (!room.allowed) {
734
+ await (0, manager_1.setStatus)(row.id, { status: 'queued', status_message: room.reason });
735
+ onLog(`${room.reason}\nWaiting for a free slot — this desktop keeps its place in the queue.\n`);
736
+ break;
737
+ }
733
738
  await (0, manager_1.setStatus)(row.id, { status: 'starting' });
734
739
  await provider.start(row);
735
740
  await (0, manager_1.setStatus)(row.id, { status: 'running', status_message: null });
@@ -35,6 +35,10 @@ export declare function maxConcurrentDesktops(): number;
35
35
  * tracked in a counter. A counter would drift the moment somebody stopped a
36
36
  * container by hand, and drift upward means refusing to start anything. */
37
37
  export declare function runningDesktopCount(rows: DesktopRow[]): Promise<number>;
38
+ /** Oldest waiter first, everything else after. Without this the order is
39
+ * whatever the listing happened to return, which is how a desktop ends up
40
+ * waiting behind one that asked after it. */
41
+ export declare function queueOrder<T extends Pick<DesktopRow, 'status' | 'queued_at'>>(rows: T[]): T[];
38
42
  /** Whether one more may start. Returns the reason when it may not, so the
39
43
  * caller can say something better than "failed". */
40
44
  export declare function desktopStartAllowed(rows: DesktopRow[], excludeId?: string): Promise<{
@@ -43,6 +43,7 @@ exports.clampMaxDesktops = clampMaxDesktops;
43
43
  exports.setMaxConcurrentDesktops = setMaxConcurrentDesktops;
44
44
  exports.maxConcurrentDesktops = maxConcurrentDesktops;
45
45
  exports.runningDesktopCount = runningDesktopCount;
46
+ exports.queueOrder = queueOrder;
46
47
  exports.desktopStartAllowed = desktopStartAllowed;
47
48
  exports.startDesktopManager = startDesktopManager;
48
49
  exports.stopDesktopManager = stopDesktopManager;
@@ -68,6 +69,11 @@ function reconcileAction(row, actual) {
68
69
  || row.status === 'deleting' || row.status === 'pending'
69
70
  || row.status === 'failed')
70
71
  return 'none';
72
+ // Waiting for a slot. The tick retries it every pass; whether there is room
73
+ // yet is decided there, because it depends on every OTHER desktop, not this
74
+ // one. Somebody starting it by hand in the meantime just resolves the wait.
75
+ if (row.status === 'queued')
76
+ return actual?.running ? 'mark_running' : 'start';
71
77
  if (actual === null) {
72
78
  // The row says this exists and it does not. Someone pruned it by hand.
73
79
  return row.status === 'running' || row.status === 'stopped' ? 'mark_failed' : 'none';
@@ -176,6 +182,23 @@ async function runningDesktopCount(rows) {
176
182
  }
177
183
  return count;
178
184
  }
185
+ /** When a desktop asked for its slot, as a sortable number. A waiter whose
186
+ * stamp is missing or unreadable goes to the BACK, not the front: Date.parse
187
+ * returns NaN for those, every NaN comparison is false, and a comparator that
188
+ * returns NaN is treated as 0 — so the unstamped row would quietly keep
189
+ * whatever position the listing gave it and cut in front of real waiters. */
190
+ function waitingSince(row) {
191
+ if (row.status !== 'queued')
192
+ return Number.POSITIVE_INFINITY;
193
+ const at = Date.parse(row.queued_at ?? '');
194
+ return Number.isFinite(at) ? at : Number.POSITIVE_INFINITY;
195
+ }
196
+ /** Oldest waiter first, everything else after. Without this the order is
197
+ * whatever the listing happened to return, which is how a desktop ends up
198
+ * waiting behind one that asked after it. */
199
+ function queueOrder(rows) {
200
+ return [...rows].sort((a, b) => waitingSince(a) - waitingSince(b));
201
+ }
179
202
  /** Whether one more may start. Returns the reason when it may not, so the
180
203
  * caller can say something better than "failed". */
181
204
  async function desktopStartAllowed(rows, excludeId) {
@@ -199,7 +222,7 @@ async function tick() {
199
222
  if (!provider)
200
223
  return; // no engine: nothing to reconcile
201
224
  const rows = await listDesktops();
202
- for (const row of rows) {
225
+ for (const row of queueOrder(rows)) {
203
226
  const actual = await provider.inspect(row);
204
227
  const action = reconcileAction(row, actual);
205
228
  if (action === 'none')
@@ -212,7 +235,13 @@ async function tick() {
212
235
  // failing — nothing is wrong with the desktop, there is just no room.
213
236
  const room = await desktopStartAllowed(rows, row.id);
214
237
  if (!room.allowed) {
215
- await setStatus(row.id, { status: 'stopped', status_message: room.reason });
238
+ // Stay in the queue rather than dropping out of it. A desktop that
239
+ // fell back to 'stopped' here would need asking for all over again,
240
+ // which is exactly what waiting is supposed to save you.
241
+ // queued_at is stamped by the status RPC, which keeps the
242
+ // original on a re-queue — a desktop must not lose its place in
243
+ // the line every time the reconciler looks at it.
244
+ await setStatus(row.id, { status: 'queued', status_message: room.reason });
216
245
  continue;
217
246
  }
218
247
  await provider.start(row);
@@ -13,8 +13,11 @@ export interface DesktopRow {
13
13
  enabled_skills: string[];
14
14
  enabled_packages: string[];
15
15
  autostart: boolean;
16
- status: 'pending' | 'creating' | 'starting' | 'running' | 'stopped' | 'failed' | 'deleting';
16
+ status: 'pending' | 'creating' | 'starting' | 'running' | 'stopped' | 'failed' | 'deleting' | 'queued';
17
17
  status_message: string | null;
18
+ /** Set while this desktop is waiting for a slot on a full machine. The node
19
+ * serves its queue oldest-first, so this is the place in the line. */
20
+ queued_at?: string | null;
18
21
  vnc_port: number | null;
19
22
  vnc_password: string | null;
20
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addai/node",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Daemon that pairs a machine with your +Ai account and runs Claude / Codex / Kimi / Gemini agents on its behalf. Reachable via Supabase from Vault, Entity Studio, or any other +Ai surface.",
5
5
  "license": "MIT",
6
6
  "keywords": [