@moondeck/station 1.0.0 → 1.0.1

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.
@@ -0,0 +1,9 @@
1
+ -- First-turn dedup: accepted first-turn delivery ids, so a redelivery after a
2
+ -- cloud crash is acknowledged without queueing a second opening turn.
3
+ CREATE TABLE IF NOT EXISTS `host_first_turn_receipts` (
4
+ `task_id` text NOT NULL,
5
+ `first_turn_id` text NOT NULL,
6
+ `created_at` integer NOT NULL DEFAULT (unixepoch() * 1000),
7
+ PRIMARY KEY(`task_id`, `first_turn_id`),
8
+ CONSTRAINT `host_first_turn_receipts_identity` CHECK (length(`task_id`) > 0 AND length(`first_turn_id`) > 0)
9
+ );
@@ -218,6 +218,13 @@
218
218
  "when": 1793318400000,
219
219
  "tag": "0030_credential_cleanup_restops",
220
220
  "breakpoints": true
221
+ },
222
+ {
223
+ "idx": 31,
224
+ "version": "6",
225
+ "when": 1795910400000,
226
+ "tag": "0031_first_turn_receipts",
227
+ "breakpoints": true
221
228
  }
222
229
  ]
223
230
  }
package/db/schema.ts CHANGED
@@ -848,3 +848,24 @@ export const engineAccounts = sqliteTable("host_engine_accounts", {
848
848
 
849
849
  export type EngineAccountRow = typeof engineAccounts.$inferSelect;
850
850
  export type NewEngineAccountRow = typeof engineAccounts.$inferInsert;
851
+
852
+ // First-turn dedup: ids of first-turn deliveries this station has accepted, so
853
+ // a cloud that crashed before recording its own ack can redeliver without a
854
+ // second opening turn. Written only after the turn was accepted.
855
+ export const hostFirstTurnReceipts = sqliteTable(
856
+ "host_first_turn_receipts",
857
+ {
858
+ taskId: text("task_id").notNull(),
859
+ firstTurnId: text("first_turn_id").notNull(),
860
+ createdAt: integer("created_at", { mode: "number" })
861
+ .notNull()
862
+ .default(sql`(unixepoch() * 1000)`),
863
+ },
864
+ (t) => ({
865
+ pk: primaryKey({ columns: [t.taskId, t.firstTurnId] }),
866
+ identity: check(
867
+ "host_first_turn_receipts_identity",
868
+ sql`length(${t.taskId}) > 0 AND length(${t.firstTurnId}) > 0`,
869
+ ),
870
+ }),
871
+ );
@@ -1099,14 +1099,12 @@ async function detectPinnedRuntime(
1099
1099
  // id; a wiped or replaced toolchain state must fail closed exactly like a
1100
1100
  // replaced Docker daemon at the same socket.
1101
1101
  const probe = await probeAppleSystem(deps);
1102
- if (
1103
- probe === null ||
1104
- probe.endpoint !== host ||
1105
- pinnedDockerEngineId === null ||
1106
- probe.installationId !== pinnedDockerEngineId
1107
- ) {
1102
+ if (probe === null || pinnedDockerEngineId === null) {
1108
1103
  return pinnedRuntimeUnavailable(preference);
1109
1104
  }
1105
+ if (probe.endpoint !== host || probe.installationId !== pinnedDockerEngineId) {
1106
+ return pinnedRuntimeUnavailable(preference, "replaced");
1107
+ }
1110
1108
  applyPinnedAppleRuntimeEnvironment(pinnedAppleCliPath);
1111
1109
  return {
1112
1110
  status: "ready",
@@ -1117,13 +1115,12 @@ async function detectPinnedRuntime(
1117
1115
 
1118
1116
  applyPinnedDockerHost(host);
1119
1117
  const proof = await probeDocker({ host }, deps);
1120
- if (
1121
- proof === null ||
1122
- pinnedDockerEngineId === null ||
1123
- proof.engineId !== pinnedDockerEngineId
1124
- ) {
1118
+ if (proof === null || pinnedDockerEngineId === null) {
1125
1119
  return pinnedRuntimeUnavailable(preference);
1126
1120
  }
1121
+ if (proof.engineId !== pinnedDockerEngineId) {
1122
+ return pinnedRuntimeUnavailable(preference, "replaced");
1123
+ }
1127
1124
  return {
1128
1125
  status: "ready",
1129
1126
  preference: preference === "auto" ? "auto" : "docker",
@@ -1328,9 +1325,29 @@ function isLocalDockerEndpoint(host: string): boolean {
1328
1325
  return host.slice("unix://".length).startsWith("/");
1329
1326
  }
1330
1327
 
1328
+ /**
1329
+ * The pinned runtime can't be used. "unresponsive": it didn't answer. "replaced":
1330
+ * it answers, but as a different engine (Docker's daemon id, the Apple
1331
+ * system's installation id or state root) than the one this process pinned
1332
+ * at start, e.g. Docker or OrbStack was reset. That one used to read "not
1333
+ * responding" too, while `docker info` answered fine (the MacBook Air
1334
+ * station, 2026-11-03); a recheck can't adopt a new engine, only a restart.
1335
+ */
1331
1336
  function pinnedRuntimeUnavailable(
1332
1337
  preference: RuntimePreference,
1338
+ cause: "unresponsive" | "replaced" = "unresponsive",
1333
1339
  ): ContainerRuntimeState {
1340
+ if (cause === "replaced") {
1341
+ const what = pinnedRuntimeProvider === "apple-container" ? "Apple container runtime" : "Docker engine";
1342
+ return {
1343
+ status: "no-runtime",
1344
+ preference,
1345
+ message:
1346
+ `A different ${what} now answers than the one this Moondeck Station process started with ` +
1347
+ `(it was restarted, reset or replaced). Run \`${RUNTIME_RESTART_COMMAND}\` to use it.`,
1348
+ action: RUNTIME_RESTART_COMMAND,
1349
+ };
1350
+ }
1334
1351
  return {
1335
1352
  status: "no-runtime",
1336
1353
  preference,
package/lib/env-schema.ts CHANGED
@@ -16,6 +16,9 @@
16
16
  * first item belongs to the document header.
17
17
  * - `@name` is `@name=true`. Value decorators are single-use; function calls
18
18
  * (`@name(...)`) accumulate.
19
+ * - Calls and literals in decorators may continue on following `#` lines;
20
+ * quoted arguments stay on one line. Continuations are bounded and reject
21
+ * a new directive or block boundary before the structure closes.
19
22
  *
20
23
  * Fail closed (ADR-153): an @proxy Uai cannot fully interpret makes its key
21
24
  * `unsupported`, so the key is withheld; it never reads as no directive, which
@@ -61,6 +64,11 @@ type Failure = KnownProjectEnvSchemaUnavailableReason;
61
64
 
62
65
  /** A schema file larger than this is not read: no repository needs one. */
63
66
  export const MAX_ENV_SCHEMA_BYTES = 256 * 1024;
67
+ /** Bounds for one continued decorator comment: physical lines, and UTF-8
68
+ * bytes from its initial @ through the end of its closing line, after
69
+ * normalizing CRLF to LF as upstream does. */
70
+ export const MAX_ENV_SCHEMA_DECORATOR_LINES = 128;
71
+ export const MAX_ENV_SCHEMA_DECORATOR_BYTES = 16 * 1024;
64
72
  /** A minted placeholder fills at least this many characters at random. */
65
73
  export const PLACEHOLDER_RANDOM_CHARACTERS = 32;
66
74
 
@@ -102,11 +110,13 @@ const MAX_NESTING = 16;
102
110
 
103
111
  class SyntaxFailure extends Error {}
104
112
 
105
- /** One line's decorators, or a value's call, list or object. Throws
113
+ /** A comment's decorators, or a value's call, list or object. Throws
106
114
  * SyntaxFailure on anything it cannot read. */
107
115
  class DecoratorReader {
108
116
  private pos: number;
109
117
  private depth = 0;
118
+ private readonly start: number;
119
+ private decoratorLines = 1;
110
120
  /** A value's structure: inside it, line breaks and `#` comments are blanks. */
111
121
  private readonly multiline: boolean;
112
122
 
@@ -115,18 +125,19 @@ class DecoratorReader {
115
125
  options: { start?: number; value?: boolean } = {},
116
126
  ) {
117
127
  this.pos = options.start ?? 0;
128
+ this.start = this.pos;
118
129
  this.multiline = options.value === true;
119
130
  }
120
131
 
121
132
  /** Decorators up to a comment. */
122
- readAll(into: Decorator[]): void {
133
+ readAll(into: Decorator[]): number {
123
134
  for (;;) {
124
135
  this.skipSpaces();
125
- if (this.done() || this.peek() === "#") return;
136
+ if (this.done() || this.peek() === "#" || this.peek() === "\n" || this.atCrLf()) return this.pos;
126
137
  if (this.peek() !== "@") {
127
138
  // Stray text, as upstream reads it: skipped, and the decorators after
128
139
  // it still count. It may not hold an `@` (upstream refuses that).
129
- while (!this.done() && !isBlankChar(this.peek())) {
140
+ while (!this.done() && !isBlankChar(this.peek()) && this.peek() !== "\n" && !this.atCrLf()) {
130
141
  if (this.peek() === "@") throw new SyntaxFailure();
131
142
  this.pos += 1;
132
143
  }
@@ -148,7 +159,7 @@ class DecoratorReader {
148
159
  decorator.source = this.text.slice(start, this.pos);
149
160
  into.push(decorator);
150
161
  // Decorators end at whitespace or a comment; anything glued on is junk.
151
- if (!this.done() && !isBlankChar(this.peek()) && this.peek() !== "#") throw new SyntaxFailure();
162
+ if (!this.done() && !isBlankChar(this.peek()) && this.peek() !== "#" && this.peek() !== "\n" && !this.atCrLf()) throw new SyntaxFailure();
152
163
  }
153
164
  }
154
165
 
@@ -182,6 +193,31 @@ class DecoratorReader {
182
193
  this.pos += char === "\n" ? 1 : 2;
183
194
  } else if (this.multiline && this.depth > 0 && char === "#") {
184
195
  while (!this.done() && this.peek() !== "\n" && !this.atCrLf()) this.pos += 1;
196
+ } else if (!this.multiline && this.depth > 0) {
197
+ // env-spec's _decWs (packages/env-spec-parser/grammar.peggy):
198
+ // line breaks require a fresh #, and an
199
+ // interior # comment must end with such a continuation. #tag is
200
+ // an argument, not a comment. Never strip hashes inside values.
201
+ if (char === "#" && !/[A-Za-z0-9]/.test(this.text[this.pos + 1] ?? "")) {
202
+ while (!this.done() && this.peek() !== "\n" && !this.atCrLf()) this.pos += 1;
203
+ }
204
+ if (this.peek() !== "\n" && !this.atCrLf()) return;
205
+ this.pos += this.peek() === "\n" ? 1 : 2;
206
+ // Deliberately stricter than upstream: no indented continuation
207
+ // comments, consistent with our standalone comment rule.
208
+ if (this.peek() !== "#") throw new SyntaxFailure();
209
+ this.pos += 1;
210
+ this.skipBlanks();
211
+ // Deliberately stricter than upstream: a later directive or
212
+ // divider must not become an argument of an unfinished structure.
213
+ // Scoped plugin names (@varlock/...) remain valid arguments.
214
+ const rest = this.text.slice(this.pos);
215
+ if ((/^@[A-Za-z]/.test(rest) && !/^@[A-Za-z0-9_-]+\//.test(rest)) || /^[-=*#]{3}/.test(rest)) throw new SyntaxFailure();
216
+ this.decoratorLines += 1;
217
+ const newline = this.text.indexOf("\n", this.pos);
218
+ const lineEnd = newline === -1 ? this.text.length : newline - (this.text[newline - 1] === "\r" ? 1 : 0);
219
+ if (this.decoratorLines > MAX_ENV_SCHEMA_DECORATOR_LINES ||
220
+ Buffer.byteLength(this.text.slice(this.start, lineEnd).replaceAll("\r\n", "\n"), "utf8") > MAX_ENV_SCHEMA_DECORATOR_BYTES) throw new SyntaxFailure();
185
221
  } else {
186
222
  return;
187
223
  }
@@ -253,12 +289,11 @@ class DecoratorReader {
253
289
  for (;;) {
254
290
  if (this.done()) throw new SyntaxFailure();
255
291
  const char = this.peek();
256
- if (char === "\\" && this.pos + 1 < this.text.length) {
257
- const escaped = this.text[this.pos + 1]!;
258
- // Not even escaped: a string never spans lines. (A lone CR is text;
259
- // the LF of an escaped CRLF fails below, as any line break does.)
260
- if (escaped === "\n") throw new SyntaxFailure();
261
- out += escaped === quote || escaped === "\\" ? escaped : `\\${escaped}`;
292
+ // Upstream escapes only the wrapping quote. A backslash before any
293
+ // other character is literal, including another backslash: the next
294
+ // backslash may itself escape a quote and keep this string open.
295
+ if (char === "\\" && this.text[this.pos + 1] === quote) {
296
+ out += quote;
262
297
  this.pos += 2;
263
298
  continue;
264
299
  }
@@ -345,11 +380,14 @@ class DecoratorReader {
345
380
  }
346
381
 
347
382
  /** A decorator comment's decorators, or null if any of it is unreadable. */
348
- function readDecorators(content: string): Decorator[] | null {
383
+ function readDecorators(source: Source, index: number, content: string): { decorators: Decorator[]; lastLine: number } | null {
349
384
  const decorators: Decorator[] = [];
350
385
  try {
351
- new DecoratorReader(content).readAll(decorators);
352
- return decorators;
386
+ const start = source.lineStarts[index]! + source.lines[index]!.length - content.length;
387
+ const end = new DecoratorReader(source.text, { start }).readAll(decorators);
388
+ let lastLine = index;
389
+ while (lastLine + 1 < source.lines.length && source.lineStarts[lastLine + 1]! < end) lastLine += 1;
390
+ return { decorators, lastLine };
353
391
  } catch (error) {
354
392
  if (error instanceof SyntaxFailure) return null;
355
393
  throw error;
@@ -451,8 +489,8 @@ function endOfValue(
451
489
  let i = 0;
452
490
  while (i < text.length) {
453
491
  // An escaped delimiter (\" in "...", \""" in """...""") does not close.
454
- if (text[i] === "\\" && (fence.length === 1 || text.startsWith(fence, i + 1))) {
455
- i += 1 + (fence.length === 1 ? 1 : fence.length);
492
+ if (text[i] === "\\" && text.startsWith(fence, i + 1)) {
493
+ i += 1 + fence.length;
456
494
  continue;
457
495
  }
458
496
  if (lineIndex > index || fence.length === 1) {
@@ -507,13 +545,13 @@ function read(text: string): Reading | Failure {
507
545
  if (block.decorators.length > 0) detached.push(block);
508
546
  block = emptyBlock();
509
547
  };
510
- /** False when the decorators cannot be read: upstream refuses the file,
548
+ /** Null when the decorators cannot be read: upstream refuses the file,
511
549
  * and what they say about routing is unknown. */
512
- const addDecorators = (into: Block, content: string): boolean => {
513
- const decorators = readDecorators(content);
514
- if (decorators === null) return false;
515
- into.decorators.push(...decorators);
516
- return true;
550
+ const addDecorators = (into: Block, index: number, content: string): number | null => {
551
+ const result = readDecorators(source, index, content);
552
+ if (result === null) return null;
553
+ into.decorators.push(...result.decorators);
554
+ return result.lastLine;
517
555
  };
518
556
 
519
557
  for (let index = 0; index < lines.length; index += 1) {
@@ -526,7 +564,11 @@ function read(text: string): Reading | Failure {
526
564
  const content = raw.slice(1).replace(/^[ \t]+/, "");
527
565
  // A divider, as upstream has it: three of - = * #, in any mix.
528
566
  if (/^[-=*#]{3}/.test(content)) endBlock();
529
- else if (content.startsWith("@") && !addDecorators(block, content)) return "schema_malformed";
567
+ else if (content.startsWith("@")) {
568
+ const lastLine = addDecorators(block, index, content);
569
+ if (lastLine === null) return "schema_malformed";
570
+ index = lastLine;
571
+ }
530
572
  continue;
531
573
  }
532
574
  // Only an item may be indented, and only with spaces and tabs: upstream
@@ -549,7 +591,11 @@ function read(text: string): Reading | Failure {
549
591
  const comment = end.comment.replace(/^[ \t]+/, "");
550
592
  // Upstream refuses a divider after a value.
551
593
  if (/^[-=*#]{3}/.test(comment)) return "schema_malformed";
552
- if (comment.startsWith("@") && !addDecorators(attached, comment)) return "schema_malformed";
594
+ if (comment.startsWith("@")) {
595
+ const lastLine = addDecorators(attached, index, comment);
596
+ if (lastLine === null) return "schema_malformed";
597
+ index = lastLine;
598
+ }
553
599
  }
554
600
  items.push({ key: item[1]!, block: attached });
555
601
  }
@@ -0,0 +1,62 @@
1
+ import { and, eq } from "drizzle-orm";
2
+
3
+ import { getDb, schema } from "./db";
4
+
5
+ /** Has this station already accepted the first-turn delivery `firstTurnId`? */
6
+ export function hasFirstTurnReceipt(taskId: string, firstTurnId: string): boolean {
7
+ return (
8
+ getDb()
9
+ .select({ taskId: schema.hostFirstTurnReceipts.taskId })
10
+ .from(schema.hostFirstTurnReceipts)
11
+ .where(
12
+ and(
13
+ eq(schema.hostFirstTurnReceipts.taskId, taskId),
14
+ eq(schema.hostFirstTurnReceipts.firstTurnId, firstTurnId),
15
+ ),
16
+ )
17
+ .get() !== undefined
18
+ );
19
+ }
20
+
21
+ /** Remember an ACCEPTED first-turn delivery (call only after acceptance). */
22
+ export function recordFirstTurnReceipt(taskId: string, firstTurnId: string): void {
23
+ getDb()
24
+ .insert(schema.hostFirstTurnReceipts)
25
+ .values({ taskId, firstTurnId })
26
+ .onConflictDoNothing()
27
+ .run();
28
+ }
29
+
30
+ const inFlight = new Map<string, Promise<unknown>>();
31
+
32
+ /**
33
+ * Run `deliver` at most once per (task, firstTurnId): a repeat of an accepted
34
+ * id is acknowledged without delivering, and a repeat that arrives while the
35
+ * first is still being accepted waits for it, then re-checks. A failed
36
+ * delivery records nothing, so the cloud's retry delivers for real.
37
+ */
38
+ export async function deliverFirstTurnOnce<R extends { ok: boolean }>(
39
+ taskId: string,
40
+ firstTurnId: string,
41
+ deliver: () => Promise<R>,
42
+ ackedResult: () => R,
43
+ ): Promise<R> {
44
+ const key = `${taskId}\u0000${firstTurnId}`;
45
+ for (;;) {
46
+ const pending = inFlight.get(key);
47
+ if (!pending) break;
48
+ await pending.catch(() => undefined);
49
+ }
50
+ if (hasFirstTurnReceipt(taskId, firstTurnId)) return ackedResult();
51
+ const run = (async () => {
52
+ const result = await deliver();
53
+ if (result.ok) recordFirstTurnReceipt(taskId, firstTurnId);
54
+ return result;
55
+ })();
56
+ inFlight.set(key, run);
57
+ try {
58
+ return await run;
59
+ } finally {
60
+ inFlight.delete(key);
61
+ }
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moondeck/station",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Moondeck Station: runs Moondeck tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Moondeck <team@moondeck.io>",
@@ -0,0 +1,39 @@
1
+ /**
2
+ * What station.log says about a lost cloud connection, so its cause can be
3
+ * read off the log. The previous MacBook Air station showed 599 reconnects
4
+ * over about 18 hours as bare "disconnected; reconnecting in ~1s" lines:
5
+ * neither who closed the socket nor why was logged, and nothing showed that
6
+ * the Mac had been asleep (2026-11-03).
7
+ */
8
+
9
+ /** The close code and reason in words: who closed it, and why if known. */
10
+ export function describeClose(code: number, reason: string): string {
11
+ const why = reason.trim();
12
+ const known: Record<number, string> = {
13
+ 1000: "closed normally",
14
+ 1001: "the cloud went away (restart or deploy)",
15
+ 1006: "the connection dropped without a close frame (network, sleep or a proxy)",
16
+ 1011: "the cloud hit an internal error",
17
+ 1012: "the cloud is restarting",
18
+ 1013: "the cloud asked to try again later",
19
+ 4000: "closed by the cloud or this station",
20
+ 4001: "authentication refused",
21
+ };
22
+ const label = known[code] ?? "closed";
23
+ return `code ${code}, ${label}${why ? `: ${why}` : ""}`;
24
+ }
25
+
26
+ /** How far past its due time a periodic timer may fire before the gap counts
27
+ * as a suspension (system sleep, a stopped process) rather than load. */
28
+ export const SUSPEND_SLACK_MS = 20_000;
29
+
30
+ /**
31
+ * The time this process was suspended, when a timer due every `intervalMs`
32
+ * fires `now - previousTick` later; null when it fired on time. A sleeping
33
+ * Mac stops every timer; on wake the gap shows, the cloud has long dropped
34
+ * the silent socket (it closes one silent for 45 s), and a reconnect follows.
35
+ */
36
+ export function suspendedFor(previousTick: number, now: number, intervalMs: number): number | null {
37
+ const gap = now - previousTick;
38
+ return gap > intervalMs + SUSPEND_SLACK_MS ? gap : null;
39
+ }
package/src/cli.ts CHANGED
@@ -91,6 +91,7 @@ import { PINNED_RELEASE_MANIFEST_KEYS } from "../lib/release-trust";
91
91
  import { sidecarMode } from "../lib/sidecar";
92
92
  import { systemUserBusProbe } from "../lib/user-bus-env";
93
93
  import { LEGACY_HOST, STATION, STATION_DEFAULT_CLOUD_URL } from "./station-identity";
94
+ import { enrollmentNextSteps } from "./enrollment-next-steps";
94
95
  import {
95
96
  inspectLegacyHost,
96
97
  legacyHostRunningReason,
@@ -1729,10 +1730,11 @@ async function cmdSetup(rest: string[]): Promise<void> {
1729
1730
  console.log(green("✓ enrolled") + dim(` — config written to ${envLocalPath()}`));
1730
1731
  }
1731
1732
  printTelemetryNotice();
1732
- console.log("");
1733
- console.log("Start the host:");
1734
- console.log(cyan(" moondeck-station install && moondeck-station start") + dim(" (first time)"));
1735
- console.log(cyan(" moondeck-station restart") + dim(" (already installed)"));
1733
+ const steps = enrollmentNextSteps();
1734
+ if (steps.length > 0) console.log("");
1735
+ for (const step of steps) {
1736
+ console.log(step.command ? cyan(step.command) + dim(step.note ?? "") : step.text);
1737
+ }
1736
1738
  }
1737
1739
 
1738
1740
  // --- enroll (attach an org, ADR-098) -----------------------------------------
@@ -0,0 +1,24 @@
1
+ /**
2
+ * What `moondeck-station enroll` / `setup` tells the person to do once the
3
+ * machine is enrolled. Run by hand, the next step is theirs: install and
4
+ * start the service, or restart it. Run by the installer (which exports
5
+ * UAI_FROM_INSTALLER=1 for its enrollment call), the installer registers and
6
+ * starts the service itself right after, so telling the person to do it was
7
+ * a stale instruction (the owner's public-install test, 2026-11-03).
8
+ *
9
+ * An environment variable rather than a flag: the installer the cloud serves
10
+ * may run a station runtime older than this one, which ignores an unknown
11
+ * variable but would refuse an unknown flag.
12
+ */
13
+ import { STATION } from "./station-identity";
14
+
15
+ export const FROM_INSTALLER_ENV = "UAI_FROM_INSTALLER";
16
+
17
+ export function enrollmentNextSteps(env: NodeJS.ProcessEnv = process.env): Array<{ text: string; command?: string; note?: string }> {
18
+ if (env[FROM_INSTALLER_ENV] === "1") return [];
19
+ return [
20
+ { text: "Start the station:" },
21
+ { text: "", command: ` ${STATION.command} install && ${STATION.command} start`, note: " (first time)" },
22
+ { text: "", command: ` ${STATION.command} restart`, note: " (already installed)" },
23
+ ];
24
+ }
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  type LegacyStartPermit,
19
19
  } from "../lib/cloud-credential-host";
20
20
  import type { TaskAssignment } from "../lib/cloud-credential-leases";
21
+ import { deliverFirstTurnOnce } from "../lib/first-turn-receipts";
21
22
  import {
22
23
  StartInterruptedError,
23
24
  type CloudCredentialRuntime,
@@ -2039,7 +2040,7 @@ export const hostCommands: HostCommands = {
2039
2040
  });
2040
2041
  },
2041
2042
 
2042
- async channelDeliver(ctx, taskId, agentId, text, origin, assignment) {
2043
+ async channelDeliver(ctx, taskId, agentId, text, origin, assignment, firstTurnId) {
2043
2044
  logCommand(ctx, "channelDeliver", taskId, agentId);
2044
2045
  if (credentialsMode() === "pending") {
2045
2046
  return switchingToCloudCredentials();
@@ -2050,15 +2051,22 @@ export const hostCommands: HostCommands = {
2050
2051
  // An inbound turn is activity: the idle veto must cover the gap between
2051
2052
  // this delivery landing and the agent's first output event.
2052
2053
  getOrchestrator().markChannelActivity(taskId);
2053
- const result = await getOrchestrator().deliver(taskId, agentId, text, origin, assignment);
2054
- if (!result.ok) {
2055
- return {
2056
- ok: false,
2057
- code: mapDeliverError(result.error),
2058
- message: result.error,
2059
- };
2060
- }
2061
- return ok(undefined);
2054
+ const deliverOnce = async (): Promise<HostCommandResult<void>> => {
2055
+ const result = await getOrchestrator().deliver(taskId, agentId, text, origin, assignment);
2056
+ if (!result.ok) {
2057
+ return {
2058
+ ok: false,
2059
+ code: mapDeliverError(result.error),
2060
+ message: result.error,
2061
+ };
2062
+ }
2063
+ return ok(undefined);
2064
+ };
2065
+ // A first-turn id is accepted at most once: a cloud that crashed before
2066
+ // recording its ack redelivers the same id and is acknowledged here.
2067
+ return firstTurnId
2068
+ ? await deliverFirstTurnOnce(taskId, firstTurnId, deliverOnce, () => ok(undefined))
2069
+ : await deliverOnce();
2062
2070
  } catch (err) {
2063
2071
  return failFromUnknown(err);
2064
2072
  }
package/src/main.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { stationAgentCliAdvertisable } from "../lib/agent-cli";
3
+ import { describeClose, suspendedFor } from "./bridge-diagnostics";
3
4
  import { internalHostGatewayAdvertisable } from "../lib/custom-connection-launch";
4
5
  import "./load-env";
5
6
  import { discoverCustomConnection, saveCustomConnection, saveCustomConnectionManual } from "../lib/custom-connection-capture";
@@ -335,6 +336,7 @@ import {
335
336
  MCP_GATEWAY_HEALTH_PROTOCOL_FEATURE,
336
337
  SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
337
338
  ASSIGNMENT_EVIDENCE_PROTOCOL_FEATURE,
339
+ FIRST_TURN_DEDUP_PROTOCOL_FEATURE,
338
340
  LEAD_EVIDENCE_PROTOCOL_FEATURE,
339
341
  TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
340
342
  type CloudToHost,
@@ -1553,6 +1555,7 @@ function buildCapabilities(): HostCapabilities {
1553
1555
  protocolFeatures: [
1554
1556
  "host-workspace-file-read-v1",
1555
1557
  ASSIGNMENT_EVIDENCE_PROTOCOL_FEATURE,
1558
+ FIRST_TURN_DEDUP_PROTOCOL_FEATURE,
1556
1559
  LEAD_EVIDENCE_PROTOCOL_FEATURE,
1557
1560
  TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
1558
1561
  EVENT_REPLAY_PROTOCOL_FEATURE,
@@ -3096,8 +3099,18 @@ async function connect(): Promise<void> {
3096
3099
  }
3097
3100
  }, PING_INTERVAL_MS);
3098
3101
 
3102
+ let lastTick = Date.now();
3099
3103
  deadTimer = setInterval(() => {
3100
- if (Date.now() - lastTraffic > DEAD_AFTER_MS) {
3104
+ const now = Date.now();
3105
+ const suspended = suspendedFor(lastTick, now, 5_000);
3106
+ lastTick = now;
3107
+ if (suspended !== null) {
3108
+ console.warn(
3109
+ `[host-agent] this process was suspended for ~${Math.round(suspended / 1000)}s (system sleep?); ` +
3110
+ "the cloud drops a connection that is silent for 45s",
3111
+ );
3112
+ }
3113
+ if (now - lastTraffic > DEAD_AFTER_MS) {
3101
3114
  console.warn("[host-agent] bridge heartbeat timed out");
3102
3115
  socket.close(4000, "heartbeat timeout");
3103
3116
  }
@@ -3970,7 +3983,7 @@ async function connect(): Promise<void> {
3970
3983
  const delay = reconnectDelay();
3971
3984
  markReconnecting(text || `disconnected${ready ? "" : " before auth"}`);
3972
3985
  console.warn(
3973
- `[host-agent] disconnected${ready ? "" : " before auth"}; reconnecting in ${Math.round(delay)}ms`,
3986
+ `[host-agent] disconnected${ready ? "" : " before auth"} (${describeClose(code, text)}); reconnecting in ${Math.round(delay)}ms`,
3974
3987
  );
3975
3988
  addHostBreadcrumb("bridge", "disconnected", {
3976
3989
  code,
@@ -4639,8 +4652,9 @@ async function dispatchCommand(
4639
4652
  expectString(args, 0),
4640
4653
  expectString(args, 1),
4641
4654
  expectString(args, 2),
4642
- args[3] as DeliveryOrigin | undefined,
4643
- args[4] as AssignmentDelivery | undefined,
4655
+ typeof args[3] === "string" ? (args[3] as DeliveryOrigin) : undefined,
4656
+ (args[4] ?? undefined) as AssignmentDelivery | undefined,
4657
+ typeof args[5] === "string" && args[5] ? args[5] : undefined,
4644
4658
  );
4645
4659
  case "channelResolvePermission":
4646
4660
  return hostCommands.channelResolvePermission(
package/src/protocol.ts CHANGED
@@ -101,6 +101,8 @@ export type HostCommandResult<T> =
101
101
 
102
102
  /** Capability ids shared by host advertisement and fail-closed cloud gates. */
103
103
  export const ASSIGNMENT_EVIDENCE_PROTOCOL_FEATURE = "assignment-evidence-v1";
104
+ /** Hosts that accept at most one delivery per first-turn id (6th channelDeliver arg). */
105
+ export const FIRST_TURN_DEDUP_PROTOCOL_FEATURE = "first-turn-dedup-v1";
104
106
  export const LEAD_EVIDENCE_PROTOCOL_FEATURE = "lead-evidence-v1";
105
107
  export const TRANSCRIPT_TARGETS_PROTOCOL_FEATURE = "transcript-targets-v1";
106
108
  export const SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE =
@@ -305,7 +307,7 @@ export const INITIAL_PROJECT_ENV_POLICY_EPOCH = 1;
305
307
  * The wire guard accepts every historical positive epoch through this value,
306
308
  * but rejects a future epoch until this peer knows how to interpret it.
307
309
  */
308
- export const CURRENT_PROJECT_ENV_PARSER_EPOCH = 2;
310
+ export const CURRENT_PROJECT_ENV_PARSER_EPOCH = 3;
309
311
 
310
312
  export const PROJECT_ENV_UNSUPPORTED_REASON_CODES = [
311
313
  "malformed",
@@ -4810,6 +4812,9 @@ export interface HostCommands {
4810
4812
  * from an older cloud, which the host reads as "not a human turn". */
4811
4813
  origin?: DeliveryOrigin,
4812
4814
  assignment?: AssignmentDelivery,
4815
+ /** `first-turn-dedup-v1`: stable id of a first-turn delivery. A repeat of
4816
+ * an accepted id is acknowledged without queueing another turn. */
4817
+ firstTurnId?: string,
4813
4818
  ): Promise<HostCommandResult<void>>;
4814
4819
  channelResolvePermission(
4815
4820
  ctx: CommandContext,