@saadnvd1/agent-os 0.3.624 → 0.3.627

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.
@@ -533,6 +533,26 @@ export class ChatHost {
533
533
  return true;
534
534
  }
535
535
 
536
+ // A blocking step moved to the background frees the agent: the turn goes
537
+ // on, and what's queued goes in now, in order, as a message sent mid-turn
538
+ // does, rather than wait for the turn to end. Only the turn it was asked
539
+ // in: one that ended meanwhile has sent its queue by the usual rules.
540
+ private sendQueuedMidTurn(turn: number): void {
541
+ if (
542
+ !this.busy() ||
543
+ this.turnsStarted !== turn ||
544
+ this.closed ||
545
+ this.handingOver() ||
546
+ this.compacting ||
547
+ this.compactingNow
548
+ )
549
+ return;
550
+ for (let next; (next = claimNext(this.session.id)); ) {
551
+ this.emit({ type: "queue" });
552
+ this.sendUser(next);
553
+ }
554
+ }
555
+
536
556
  private sendUser(
537
557
  m: {
538
558
  id: string;
@@ -677,13 +697,20 @@ export class ChatHost {
677
697
  this.sendUser(cmd, true);
678
698
  return;
679
699
  }
700
+ // What the reader queued goes first: an event or agent message that
701
+ // reaches an idle worker (a fresh one after a restart) waits behind
702
+ // it rather than start a turn their next message would stop.
703
+ const backlog = listQueue(this.session.id).length > 0;
704
+ if (!this.busy() && backlog && !this.closed) {
705
+ enqueue(this.session.id, cmd);
706
+ this.emit({ type: "queue" });
707
+ this.sendQueued();
708
+ return;
709
+ }
680
710
  if (this.compactFirst(cmd)) return;
681
711
  // A compaction run for an earlier message takes nothing in: what's
682
712
  // sent meanwhile goes behind that message.
683
- if (
684
- (cmd.queue || this.compacting) &&
685
- (this.busy() || listQueue(this.session.id).length)
686
- ) {
713
+ if ((cmd.queue || this.compacting) && (this.busy() || backlog)) {
687
714
  // Behind the running turn, and behind anything queued before it.
688
715
  enqueue(this.session.id, cmd);
689
716
  this.emit({ type: "queue" });
@@ -762,7 +789,9 @@ export class ChatHost {
762
789
  case "background_task":
763
790
  try {
764
791
  if (!this.conversation.backgroundTask) throw new Error("unsupported");
792
+ const turn = this.turnsStarted;
765
793
  const ok = await this.conversation.backgroundTask(cmd.toolUseId);
794
+ if (ok) this.sendQueuedMidTurn(turn);
766
795
  this.emit({ type: "background_result", reqId: cmd.reqId, ok });
767
796
  } catch (error) {
768
797
  this.emit({
@@ -2,7 +2,8 @@
2
2
  * The orchestrator chat's tools, as an in-process MCP server in its chat
3
3
  * worker. Each one asks the AgentOS server, which holds the live state
4
4
  * (chat activity, status, tasks) and enforces the workspace scope, brakes
5
- * and gates, and returns its text as is.
5
+ * and gates, and returns its text as is. While the server restarts a call
6
+ * waits for it (httpToolCaller).
6
7
  */
7
8
 
8
9
  import { createHash } from "crypto";
@@ -17,27 +18,90 @@ import { TOOL_SHAPES, TOOLS, type ToolName } from "./tool-schemas";
17
18
 
18
19
  type CallToolResult = Awaited<ReturnType<Parameters<typeof tool>[3]>>;
19
20
 
20
- export type ToolCaller = (tool: ToolName, args: object) => Promise<string>;
21
+ export type ToolCaller = (
22
+ tool: ToolName,
23
+ args: object,
24
+ signal?: AbortSignal
25
+ ) => Promise<string>;
26
+
27
+ // How long a call waits for a restarting server before it gives up. A
28
+ // redeploy's server is back in well under this.
29
+ export const RESTART_WAIT_MS = 120_000;
30
+ const RETRY_FIRST_MS = 250;
31
+ const RETRY_MAX_MS = 2000;
32
+
33
+ const refused = (error: unknown) =>
34
+ (error as { cause?: { code?: string } } | null)?.cause?.code ===
35
+ "ECONNREFUSED";
36
+
37
+ const sleep = (ms: number, signal?: AbortSignal) =>
38
+ new Promise<void>((resolve, reject) => {
39
+ if (signal?.aborted) return reject(signal.reason);
40
+ const timer = setTimeout(() => {
41
+ signal?.removeEventListener("abort", onAbort);
42
+ resolve();
43
+ }, ms);
44
+ const onAbort = () => {
45
+ clearTimeout(timer);
46
+ reject(signal?.reason);
47
+ };
48
+ signal?.addEventListener("abort", onAbort, { once: true });
49
+ });
21
50
 
22
51
  export const TOKEN_HEADER = "x-agentos-orchestrator";
23
52
 
53
+ // A server that is down refused the connection and never saw the request,
54
+ // so the call waits for it and goes again. One the restart cut off after it
55
+ // was sent isn't repeated (start_task, send and the rest aren't safe to):
56
+ // the agent is told to check first.
24
57
  export function httpToolCaller(
25
58
  baseUrl: string,
26
59
  workspaceId: string,
27
- token: string
60
+ token: string,
61
+ waitMs = RESTART_WAIT_MS
28
62
  ): ToolCaller {
29
- return async (name, args) => {
30
- const res = await fetch(
31
- `${baseUrl}/api/workspaces/${encodeURIComponent(workspaceId)}/orchestrator/tools`,
32
- {
33
- method: "POST",
34
- headers: { "Content-Type": "application/json", [TOKEN_HEADER]: token },
35
- body: JSON.stringify({ tool: name, args }),
63
+ const url = `${baseUrl}/api/workspaces/${encodeURIComponent(workspaceId)}/orchestrator/tools`;
64
+ return async (name, args, signal) => {
65
+ const deadline = Date.now() + waitMs;
66
+ let delay = RETRY_FIRST_MS;
67
+ let waiting = false;
68
+ for (;;) {
69
+ let res: Response;
70
+ try {
71
+ res = await fetch(url, {
72
+ method: "POST",
73
+ headers: {
74
+ "Content-Type": "application/json",
75
+ [TOKEN_HEADER]: token,
76
+ },
77
+ body: JSON.stringify({ tool: name, args }),
78
+ signal,
79
+ });
80
+ } catch (error) {
81
+ if (signal?.aborted) throw error;
82
+ if (!refused(error))
83
+ throw new Error(
84
+ `AgentOS restarted while ${name} ran, so it may or may not have taken effect. Check (sessions, read) before doing it again.`
85
+ );
86
+ if (Date.now() + delay > deadline)
87
+ throw new Error(
88
+ `AgentOS is restarting and didn't come back in time; try ${name} again shortly.`
89
+ );
90
+ if (!waiting)
91
+ console.error(`${name}: AgentOS is down; waiting for it to return`);
92
+ waiting = true;
93
+ await sleep(delay, signal);
94
+ delay = Math.min(delay * 2, RETRY_MAX_MS);
95
+ continue;
36
96
  }
37
- );
38
- const text = await res.text();
39
- if (!res.ok) throw new Error(text || `AgentOS answered ${res.status}`);
40
- return text;
97
+ const text = await res.text().catch(() => {
98
+ throw new Error(
99
+ `AgentOS restarted while ${name} ran, so it may or may not have taken effect. Check (sessions, read) before doing it again.`
100
+ );
101
+ });
102
+ if (!res.ok) throw new Error(text || `AgentOS answered ${res.status}`);
103
+ return text;
104
+ }
41
105
  };
42
106
  }
43
107
 
@@ -138,8 +202,12 @@ export function orchestratorTools(
138
202
  name: ORCHESTRATOR_SERVER,
139
203
  version: "1.0.0",
140
204
  tools: TOOLS.map((name) =>
141
- tool(name, DESCRIPTIONS[name], TOOL_SHAPES[name], (args: object) =>
142
- answer(call(name, args))
205
+ tool(
206
+ name,
207
+ DESCRIPTIONS[name],
208
+ TOOL_SHAPES[name],
209
+ (args: object, extra: unknown) =>
210
+ answer(call(name, args, (extra as { signal?: AbortSignal })?.signal))
143
211
  )
144
212
  ),
145
213
  });
@@ -5,7 +5,8 @@
5
5
  * every 15s while anything in the workspace is working, every 60s when not.
6
6
  */
7
7
 
8
- import { chatState, sendChat } from "../chat/runner";
8
+ import { chatState, chatStateNow, sendChat } from "../chat/runner";
9
+ import type { ChatState } from "../chat/events";
9
10
  import { getWorkspace } from "../workspaces";
10
11
  import { conditionsFor, recentlyMessaged, stackFacts } from "./conditions";
11
12
  import { deliverEvents } from "./deliver";
@@ -113,6 +114,18 @@ export function nextDiffAt(
113
114
  return Math.min(now + (working ? DIFF_BUSY_MS : DIFF_IDLE_MS), ...settles);
114
115
  }
115
116
 
117
+ // Right after a restart nothing is attached yet, though the orchestrator's
118
+ // worker may still be mid-turn: ask the worker, and count one that can't
119
+ // be reached as busy for this tick.
120
+ export async function deliveryTurn(
121
+ orchestratorId: string
122
+ ): Promise<ChatState | null> {
123
+ return (
124
+ chatState(orchestratorId) ??
125
+ (await chatStateNow(orchestratorId).catch(() => "running" as const))
126
+ );
127
+ }
128
+
116
129
  async function tick(): Promise<void> {
117
130
  if (busy) return;
118
131
  busy = true;
@@ -136,7 +149,7 @@ async function tick(): Promise<void> {
136
149
  await deliverEvents({
137
150
  workspaceId,
138
151
  orchestratorId: orch.id,
139
- turn: chatState(orch.id),
152
+ turn: await deliveryTurn(orch.id),
140
153
  send: sendChat,
141
154
  });
142
155
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.624",
3
+ "version": "0.3.627",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env bash
2
2
  # Run every minute by serviceman on Saad's Mac: when origin/main moves,
3
3
  # redeploy the live instance and publish the same commit to npm. Runs in the
4
- # background. Chat turns live in workers and outlive the restart; reviews
5
- # and task setups don't, so the restart waits up to 10 minutes for them.
4
+ # background. Chat turns (tasks' and orchestrators') live in workers and
5
+ # outlive the restart, and an orchestrator's tool calls wait for the server to
6
+ # return. Reviews and task setups are server jobs that run again after a
7
+ # restart, so it waits up to 10 minutes for them rather than pay twice.
6
8
  # sm add agentos-autodeploy "scripts/autodeploy" --cron "* * * * *"
7
9
  # Restarts are spaced AGENTOS_MIN_GAP seconds apart (an hour; 0 turns it
8
10
  # off): a head that lands inside the gap waits and is deployed, whatever it