@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.
- package/db/migrations/0031_first_turn_receipts.sql +9 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +21 -0
- package/lib/container-runtime.ts +28 -11
- package/lib/env-schema.ts +70 -24
- package/lib/first-turn-receipts.ts +62 -0
- package/package.json +1 -1
- package/src/bridge-diagnostics.ts +39 -0
- package/src/cli.ts +6 -4
- package/src/enrollment-next-steps.ts +24 -0
- package/src/index.ts +18 -10
- package/src/main.ts +18 -4
- package/src/protocol.ts +6 -1
|
@@ -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
|
+
);
|
package/lib/container-runtime.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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[]):
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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] === "\\" &&
|
|
455
|
-
i += 1 +
|
|
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
|
-
/**
|
|
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):
|
|
513
|
-
const
|
|
514
|
-
if (
|
|
515
|
-
into.decorators.push(...decorators);
|
|
516
|
-
return
|
|
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("@")
|
|
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("@")
|
|
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
|
@@ -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
|
-
|
|
1733
|
-
console.log("
|
|
1734
|
-
|
|
1735
|
-
|
|
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
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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,
|