@prohost/cli 0.16.3 → 0.16.4

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,16 @@ builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.16.4
10
+
11
+ - **`409 conversation_busy` on claim** (ProhostAI serializes a conversation's turns): the run
12
+ stays queued instead of being skipped. Its slot goes to other work, and it is claimed again
13
+ when ProhostAI resends its frame (the earlier turn ended) or after a backoff of 30 s doubling to
14
+ at most 2 min — never in a tight loop. While it waits it does not hold its conversation's place
15
+ in the queue, so the earlier turn it waits for starts even if its frame came second; a resend
16
+ that arrives while its claim is still in flight is kept. Any other `409` is still another
17
+ computer's run. Works with servers that never send it.
18
+
9
19
  ## 0.16.3
10
20
 
11
21
  - **A dedicated service records its terminal switch in its home's code-host settings** as it
package/README.md CHANGED
@@ -97,7 +97,10 @@ program (no terminal) relies on `--expect-account`.
97
97
  2. The run waits its turn (below). When a slot is free and its AI login has
98
98
  room, the computer **claims** it (`POST /v1/computers/{id}/runs/{run_id}/claim`).
99
99
  Exactly one computer ever gets a claim; a `409` means another computer took
100
- it, and it is skipped.
100
+ it, and it is skipped. A `409 conversation_busy` means an earlier turn of the
101
+ same employee's conversation is still open: the run stays queued (its slot
102
+ goes to other work) and is claimed when ProhostAI sends its frame again, or
103
+ after a backoff of 30 s doubling to at most 2 min.
101
104
  3. The claim returns the run, a short-lived **run token** and the employee's
102
105
  **skills**. The skills are written into the employee's workspace —
103
106
  `.claude/skills/<key>/…` for Claude Code; `.codex/skills/<key>/…` plus a
@@ -61,6 +61,13 @@ export type ClaimResult = {
61
61
  | {
62
62
  kind: 'elsewhere';
63
63
  }
64
+ /**
65
+ * `409 conversation_busy`: an earlier turn of the same employee's conversation is still open.
66
+ * The run stays queued here; the server sends its frame again when that turn ends.
67
+ */
68
+ | {
69
+ kind: 'busy';
70
+ }
64
71
  /** Gone server-side (404 / 410): nothing to run. */
65
72
  | {
66
73
  kind: 'gone';
@@ -78,5 +85,4 @@ export type ClaimResult = {
78
85
  status: number;
79
86
  error?: string;
80
87
  };
81
- /** Claim a run for this computer. Exactly one computer ever gets a 200. */
82
88
  export declare function claimRun(options: ComputerApiOptions, computerId: string, runId: string): Promise<ClaimResult>;
package/dist/host/api.js CHANGED
@@ -94,6 +94,20 @@ function parseSkills(value) {
94
94
  });
95
95
  }
96
96
  /** Claim a run for this computer. Exactly one computer ever gets a 200. */
97
+ /** A refusal's code: `{detail: {error}}`, `{detail: "<code>"}` or `{error}`, matched exactly by the caller. */
98
+ function errorCode(body) {
99
+ if (!body || typeof body !== 'object' || Array.isArray(body))
100
+ return undefined;
101
+ const top = body;
102
+ const detail = top.detail;
103
+ if (typeof detail === 'string')
104
+ return detail;
105
+ if (detail && typeof detail === 'object' && !Array.isArray(detail)) {
106
+ const code = detail.error;
107
+ return typeof code === 'string' ? code : undefined;
108
+ }
109
+ return typeof top.error === 'string' ? top.error : undefined;
110
+ }
97
111
  export async function claimRun(options, computerId, runId) {
98
112
  const response = await callJson(options, claimPath(computerId, runId), {});
99
113
  if (response.status === 200 || response.status === 201) {
@@ -112,7 +126,7 @@ export async function claimRun(options, computerId, runId) {
112
126
  };
113
127
  }
114
128
  if (response.status === 409)
115
- return { kind: 'elsewhere' };
129
+ return errorCode(response.body) === 'conversation_busy' ? { kind: 'busy' } : { kind: 'elsewhere' };
116
130
  if (response.status === 404 || response.status === 410)
117
131
  return { kind: 'gone', status: response.status };
118
132
  if (response.status === 0 || response.status === 429 || response.status >= 500) {
@@ -67,8 +67,12 @@ export declare class HostScheduler<T extends QueuedRun = QueuedRun> {
67
67
  * run keeps its place and does not block the others.
68
68
  *
69
69
  * Taking it marks it running; call {@link finish} when it is done.
70
+ *
71
+ * A `parked` run (one the server said must wait for another turn of its conversation) neither
72
+ * starts nor holds its lane: the next run of the lane — possibly the very turn it waits for —
73
+ * is the lane's candidate instead. Runs of one lane still never run side by side.
70
74
  */
71
- take(config: SchedulerConfig, eligible?: (run: T) => boolean): T | undefined;
75
+ take(config: SchedulerConfig, eligible?: (run: T) => boolean, parked?: (run: T) => boolean): T | undefined;
72
76
  /** A started run is done. */
73
77
  finish(runId: string): void;
74
78
  /** A taken run could not start after all: back in the queue, in its old place. */
@@ -66,14 +66,20 @@ export class HostScheduler {
66
66
  * run keeps its place and does not block the others.
67
67
  *
68
68
  * Taking it marks it running; call {@link finish} when it is done.
69
+ *
70
+ * A `parked` run (one the server said must wait for another turn of its conversation) neither
71
+ * starts nor holds its lane: the next run of the lane — possibly the very turn it waits for —
72
+ * is the lane's candidate instead. Runs of one lane still never run side by side.
69
73
  */
70
- take(config, eligible = () => true) {
74
+ take(config, eligible = () => true, parked = () => false) {
71
75
  if (config.held)
72
76
  return undefined;
73
77
  const busyLanes = new Set([...this.running.values()].map((r) => r.lane));
74
78
  const seenLanes = new Set();
75
79
  const candidates = [];
76
80
  for (const run of this.queue) {
81
+ if (parked(run))
82
+ continue;
77
83
  // Only the oldest run of each lane is a candidate: later ones wait their turn.
78
84
  if (seenLanes.has(run.lane))
79
85
  continue;
@@ -112,6 +112,11 @@ export interface HostServiceResult {
112
112
  runsStarted: number;
113
113
  runsSkipped: number;
114
114
  }
115
+ /**
116
+ * How long a run whose conversation is busy waits before it is claimed again, unless the server
117
+ * sends its frame first (it does when the earlier turn ends): 30 s, doubling, at most 2 min.
118
+ */
119
+ export declare function busyBackoffMs(waits: number): number;
115
120
  /** The CLI's own entry file: when it changes on disk, an update was installed. */
116
121
  export declare function entrypointStamp(entry?: string): () => boolean;
117
122
  export declare function runHostService(options: HostServiceOptions): Promise<HostServiceResult>;
@@ -82,6 +82,13 @@ const ACK_RETRY = 503;
82
82
  const RECONNECT_MAX_DELAY_MS = 30_000;
83
83
  /** An employee id names a folder: nothing else gets in. */
84
84
  const SAFE_ID = /^[A-Za-z0-9-]{1,64}$/;
85
+ /**
86
+ * How long a run whose conversation is busy waits before it is claimed again, unless the server
87
+ * sends its frame first (it does when the earlier turn ends): 30 s, doubling, at most 2 min.
88
+ */
89
+ export function busyBackoffMs(waits) {
90
+ return Math.min(30_000 * 2 ** waits, 120_000);
91
+ }
85
92
  function timestamp() {
86
93
  return new Date().toISOString().slice(11, 19);
87
94
  }
@@ -260,6 +267,8 @@ async function serveHost(options) {
260
267
  }
261
268
  /** Runs being claimed right now, and the login each will run on. */
262
269
  const claiming = new Map();
270
+ /** Runs the server resent while their claim was in flight: a `conversation_busy` answer to that claim is already stale. */
271
+ const resentWhileClaiming = new Set();
263
272
  function runsOnLogin(accountKey) {
264
273
  let n = 0;
265
274
  for (const run of active.values())
@@ -489,7 +498,9 @@ async function serveHost(options) {
489
498
  return false;
490
499
  const pick = pool.pick(runtimeOf(run.agentId, run.data), conversationOf(run.lane), config.share_logins[runtimeOf(run.agentId, run.data)], runsOnLogin);
491
500
  return pick.kind === 'use' || pick.kind === 'move';
492
- });
501
+ },
502
+ // Waiting on an earlier turn of its conversation: that turn may be queued behind it.
503
+ (run) => Boolean(run.busyWaits) && run.notBefore > now());
493
504
  if (!next)
494
505
  break;
495
506
  background.push(startRun(next));
@@ -516,6 +527,19 @@ async function serveHost(options) {
516
527
  const claim = await claimRun(computerApi, computer.computer_id, queued.runId).finally(() => {
517
528
  claiming.delete(queued.runId);
518
529
  });
530
+ const resent = resentWhileClaiming.delete(queued.runId);
531
+ if (claim.kind === 'busy' && !draining) {
532
+ // Serialized server-side: an earlier turn of this conversation is still open. Not dropped,
533
+ // not failed: queued again (its slot is free for other work) until the server resends its
534
+ // frame, or its backoff ends. A resend that came while this claim was in flight already
535
+ // said the earlier turn ended: claimed again at once.
536
+ const waits = queued.busyWaits ?? 0;
537
+ scheduler.requeue({ ...queued, busyWaits: waits + 1, notBefore: resent ? 0 : now() + busyBackoffMs(waits) });
538
+ if (waits === 0)
539
+ log(`${timestamp()} ◦ run ${queued.runId.slice(0, 12)} waits — an earlier turn of its conversation is still open`);
540
+ pump();
541
+ return;
542
+ }
519
543
  if (claim.kind !== 'claimed') {
520
544
  if (claim.kind === 'retry' && queued.claimAttempts + 1 < MAX_CLAIM_ATTEMPTS && !draining) {
521
545
  scheduler.requeue({ ...queued, claimAttempts: queued.claimAttempts + 1, notBefore: now() + 2_000 * 2 ** queued.claimAttempts });
@@ -525,7 +549,7 @@ async function serveHost(options) {
525
549
  result.runsSkipped += 1;
526
550
  const why = claim.kind === 'elsewhere'
527
551
  ? 'another computer has it'
528
- : claim.kind === 'gone'
552
+ : claim.kind === 'gone' || claim.kind === 'busy'
529
553
  ? 'it is no longer waiting'
530
554
  : `the claim was refused (${claim.status}${'error' in claim && claim.error ? `: ${claim.error}` : ''})`;
531
555
  log(`${timestamp()} ◦ run ${queued.runId.slice(0, 12)} skipped — ${why}`);
@@ -1016,8 +1040,19 @@ async function serveHost(options) {
1016
1040
  return [ACK_IGNORED];
1017
1041
  if (typeof data.computer_id === 'string' && data.computer_id !== computer.computer_id)
1018
1042
  return [ACK_IGNORED];
1019
- if (scheduler.has(runId) || active.has(runId) || ledger.get(runId))
1043
+ if (scheduler.has(runId) || active.has(runId) || ledger.get(runId)) {
1044
+ // The server's resend of a run waiting on its busy conversation: the earlier turn ended, so
1045
+ // it is claimed now rather than at the end of its backoff.
1046
+ const waiting = scheduler.queued().find((run) => run.runId === runId);
1047
+ if (waiting?.busyWaits && waiting.notBefore > now()) {
1048
+ waiting.notBefore = 0;
1049
+ pump();
1050
+ }
1051
+ else if (claiming.has(runId)) {
1052
+ resentWhileClaiming.add(runId);
1053
+ }
1020
1054
  return [ACK_ACCEPTED];
1055
+ }
1021
1056
  if (draining)
1022
1057
  return [ACK_RETRY, 'draining'];
1023
1058
  if (!config.ai_employees_enabled)
package/dist/version.d.ts CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export declare const CLI_VERSION = "0.16.3";
10
+ export declare const CLI_VERSION = "0.16.4";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.16.3";
12
+ export declare const USER_AGENT = "prohost-cli/0.16.4";
package/dist/version.js CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export const CLI_VERSION = '0.16.3';
10
+ export const CLI_VERSION = '0.16.4';
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
12
  export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.16.3",
3
+ "version": "0.16.4",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",