@staix/agent-hub 0.31.0 → 0.32.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.
- package/CHANGELOG.md +13 -0
- package/README.md +2 -2
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +1 -0
- package/docs/agent-notes/tests.md +2 -2
- package/docs/design/bus.md +41 -2
- package/docs/dogfood.md +1 -0
- package/docs/operations.md +54 -6
- package/docs/quickstart.md +6 -0
- package/docs/security.md +24 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +44 -1
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +55 -1
- package/docs/verification/2026-11-06-0.32.0.md +149 -0
- package/docs/verified.json +11 -11
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/cli/console-state.ts +1 -1
- package/src/cli/room.ts +20 -10
- package/src/hub/bus.ts +18 -0
- package/src/hub/daemon.ts +7 -5
- package/src/rooms/commands.ts +61 -16
- package/src/rooms/link.ts +136 -22
- package/src/rooms/liveness.ts +11 -0
- package/src/rooms/service.ts +142 -18
- package/src/rooms/status.ts +8 -1
- package/src/rooms/store.ts +10 -0
- package/src/ui/index.html +4 -2
package/docs/verified.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"documents": {
|
|
4
4
|
"README.md": {
|
|
5
|
-
"verifiedAgainst": "
|
|
5
|
+
"verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
|
|
6
6
|
"paths": [
|
|
7
7
|
"package.json",
|
|
8
8
|
"src/",
|
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
]
|
|
12
12
|
},
|
|
13
13
|
"docs/security.md": {
|
|
14
|
-
"verifiedAgainst": "
|
|
14
|
+
"verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
|
|
15
15
|
"paths": [
|
|
16
16
|
"src/",
|
|
17
17
|
"templates/"
|
|
18
18
|
]
|
|
19
19
|
},
|
|
20
20
|
"docs/operations.md": {
|
|
21
|
-
"verifiedAgainst": "
|
|
21
|
+
"verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
|
|
22
22
|
"paths": [
|
|
23
23
|
"package.json",
|
|
24
24
|
"src/",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
]
|
|
36
36
|
},
|
|
37
37
|
"docs/quickstart.md": {
|
|
38
|
-
"verifiedAgainst": "
|
|
38
|
+
"verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
|
|
39
39
|
"paths": [
|
|
40
40
|
"package.json",
|
|
41
41
|
"src/",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
]
|
|
44
44
|
},
|
|
45
45
|
"docs/agent-notes/adapters.md": {
|
|
46
|
-
"verifiedAgainst": "
|
|
46
|
+
"verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
|
|
47
47
|
"paths": [
|
|
48
48
|
"src/adapters/",
|
|
49
49
|
"src/pi/",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
]
|
|
94
94
|
},
|
|
95
95
|
"docs/agent-notes/budget.md": {
|
|
96
|
-
"verifiedAgainst": "
|
|
96
|
+
"verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
|
|
97
97
|
"paths": [
|
|
98
98
|
"src/hub/budget.ts",
|
|
99
99
|
"src/cli/statusline-tee.ts",
|
|
@@ -117,7 +117,7 @@
|
|
|
117
117
|
]
|
|
118
118
|
},
|
|
119
119
|
"docs/agent-notes/bus.md": {
|
|
120
|
-
"verifiedAgainst": "
|
|
120
|
+
"verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
|
|
121
121
|
"paths": [
|
|
122
122
|
"src/hub/bus.ts",
|
|
123
123
|
"src/hub/envelope.ts",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
]
|
|
133
133
|
},
|
|
134
134
|
"docs/agent-notes/daemon.md": {
|
|
135
|
-
"verifiedAgainst": "
|
|
135
|
+
"verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
|
|
136
136
|
"paths": [
|
|
137
137
|
"src/hub/daemon.ts",
|
|
138
138
|
"src/hub/control-client.ts",
|
|
@@ -243,7 +243,7 @@
|
|
|
243
243
|
]
|
|
244
244
|
},
|
|
245
245
|
"docs/agent-notes/tasks.md": {
|
|
246
|
-
"verifiedAgainst": "
|
|
246
|
+
"verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
|
|
247
247
|
"paths": [
|
|
248
248
|
"src/hub/tasks.ts",
|
|
249
249
|
"src/hub/board.ts",
|
|
@@ -291,7 +291,7 @@
|
|
|
291
291
|
]
|
|
292
292
|
},
|
|
293
293
|
"docs/agent-notes/tests.md": {
|
|
294
|
-
"verifiedAgainst": "
|
|
294
|
+
"verifiedAgainst": "4ad77748c96383bdef8e55a11ff53808a1359500",
|
|
295
295
|
"paths": [
|
|
296
296
|
"test/",
|
|
297
297
|
"scripts/check.sh",
|
|
@@ -320,7 +320,7 @@
|
|
|
320
320
|
]
|
|
321
321
|
},
|
|
322
322
|
"docs/agent-notes/README.md": {
|
|
323
|
-
"verifiedAgainst": "
|
|
323
|
+
"verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
|
|
324
324
|
"paths": [
|
|
325
325
|
"src/cli/console-state.ts",
|
|
326
326
|
"src/cli/attention.ts",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Young Joon Lee",
|
|
@@ -10248,7 +10248,7 @@ var package_default;
|
|
|
10248
10248
|
var init_package = __esm(() => {
|
|
10249
10249
|
package_default = {
|
|
10250
10250
|
name: "@staix/agent-hub",
|
|
10251
|
-
version: "0.
|
|
10251
|
+
version: "0.32.0",
|
|
10252
10252
|
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
|
|
10253
10253
|
license: "MIT",
|
|
10254
10254
|
type: "module",
|
package/src/cli/console-state.ts
CHANGED
|
@@ -1031,7 +1031,7 @@ function roomDetail(row: RoomStatusView): Record<string, unknown> {
|
|
|
1031
1031
|
return {
|
|
1032
1032
|
room: row.name, "room id": row.room, "link state": row.link ?? "unknown", owner: row.owner, "local member": row.member,
|
|
1033
1033
|
"member count": count(row.memberCount),
|
|
1034
|
-
"remote peers": row.remotePeers === null ? "unknown" : row.remotePeers.length ? row.remotePeers.map(peer => `${peer.id} (${peer.state})`) : "0",
|
|
1034
|
+
"remote peers": row.remotePeers === null ? "unknown" : row.remotePeers.length ? row.remotePeers.map(peer => `${peer.id} (${peer.presence ?? peer.state})${peer.liveness === "untracked" ? "; liveness not tracked (broker without ephemeral); upgrade the broker to track liveness" : ""}`) : "0",
|
|
1035
1035
|
"counter scope": `since daemon start: ${since}`,
|
|
1036
1036
|
"out handoff": count(relays?.outbound), "in admitted": count(relays?.inbound), withheld: count(relays?.withheld),
|
|
1037
1037
|
};
|
package/src/cli/room.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { decodeBase64url } from "../rooms/crypto.ts";
|
|
|
2
2
|
import { roomReceiptReasonText, type RoomReceiptReason } from "../rooms/application.ts";
|
|
3
3
|
import { frame, type Envelope } from "../hub/envelope.ts";
|
|
4
4
|
import { terminalText } from "./console-state.ts";
|
|
5
|
-
import type
|
|
5
|
+
import { ROOM_STORE_LIMITS, type RoomPersonInboxMeta, type RoomPersonInboxItem } from "../rooms/store.ts";
|
|
6
6
|
/** Membership mutations and inbox forms are person-only; main.ts refuses agent/live-test calls before importing this module. */
|
|
7
7
|
export const ROOM_USAGE = "usage: ahub room create <name> --broker <url> [--label <id>] | invite <name> [--accept <request>] | join <token> [--name <name>] [--label <id>] | join <name> --roster <signed-roster> | leave <name> | revoke <name> <fingerprint> | expose <name> [<peer>...] | resolve <name> --frame <digest> --action accept|discard (membership mutations and resolve require TTY y/N or --confirm <member-fingerprint-prefix>, 16..64 lowercase hex) | list [--json] | status [<name>] [--json] | inbox [<name>] [--json] | inbox show|done|discard <id>";
|
|
8
8
|
|
|
@@ -17,8 +17,9 @@ export type RoomRequest = (message: Record<string, unknown>) => Promise<any>;
|
|
|
17
17
|
export interface RoomConfirmationIO { isTTY(): boolean; answer(prompt: string): Promise<string> }
|
|
18
18
|
|
|
19
19
|
export class RoomCliError extends Error {
|
|
20
|
-
constructor(readonly code: "usage" | "refused" | "invalid_reply" | "inbox_list_limit" | "confirmation_required" | "not_confirmed" | "confirmation_mismatch") {
|
|
21
|
-
const message = { refused: "operation refused", invalid_reply: "invalid control reply", inbox_list_limit: "inbox list exceeds the limit; choose a room name", confirmation_required: "confirmation required; use a TTY or --confirm with the member fingerprint prefix", not_confirmed: "not confirmed; nothing executed", confirmation_mismatch: "confirmation does not match the member fingerprint; nothing executed"
|
|
20
|
+
constructor(readonly code: "usage" | "refused" | "invalid_reply" | "inbox_list_limit" | "confirmation_required" | "not_confirmed" | "confirmation_mismatch" | "epoch_rotated_hold_unavailable") {
|
|
21
|
+
const message = { refused: "operation refused", invalid_reply: "invalid control reply", inbox_list_limit: "inbox list exceeds the limit; choose a room name", confirmation_required: "confirmation required; use a TTY or --confirm with the member fingerprint prefix", not_confirmed: "not confirmed; nothing executed", confirmation_mismatch: "confirmation does not match the member fingerprint; nothing executed",
|
|
22
|
+
epoch_rotated_hold_unavailable: "epoch rotated but delivery journal holds are unavailable; inspect ahub room status and ahub queue show before another mutation" };
|
|
22
23
|
super(code === "usage" ? ROOM_USAGE : `room: ${message[code]}`);
|
|
23
24
|
this.name = "RoomCliError";
|
|
24
25
|
}
|
|
@@ -141,9 +142,9 @@ async function confirmMember(member: string, explicit: string | undefined, io: R
|
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
type DeliveryState = "pending" | "dispatching" | "accepted" | "completed" | "completed (admitted)" | "completed (inbox)" | "failed_safe" | "needs_review" | "disposed" | "disposed (accept, by person)" | "disposed (discard, by person)";
|
|
144
|
-
interface DeliveryRow { direction: "inbound" | "outbound"; id: string; application: string; peer: string; state: DeliveryState; disposition?: "accept" | "completed" | "retry" | "discard"; lateReceipt?: "completed (admitted)" | "completed (inbox)" | "failed_safe"; refusalReason?: RoomReceiptReason }
|
|
145
|
+
interface DeliveryRow { direction: "inbound" | "outbound"; id: string; application: string; peer: string; state: DeliveryState; disposition?: "accept" | "completed" | "retry" | "discard"; lateReceipt?: "completed (admitted)" | "completed (inbox)" | "failed_safe"; refusalReason?: RoomReceiptReason; holdReason?: "epoch rotated" }
|
|
145
146
|
interface DeliverySummary { direction: "inbound"; pending: number; needsReview: number; truncated: boolean }
|
|
146
|
-
interface RemotePeerRow { id: string; state: "idle" | "busy" | "paused" | "offline" }
|
|
147
|
+
interface RemotePeerRow { id: string; state: "idle" | "busy" | "paused" | "offline"; presence?: "idle" | "busy" | "paused" | "offline" | "unknown"; liveness?: "untracked" }
|
|
147
148
|
interface RoomRow { name: string; room: string; owner: string; member: string; epoch: string; state: string; deliveries?: (DeliveryRow | DeliverySummary)[]; remotePeers?: RemotePeerRow[] }
|
|
148
149
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
149
150
|
const REMOTE_ID = /^[a-z][a-z0-9-]{0,31}@[a-z][a-z0-9-]{0,31}$/;
|
|
@@ -170,10 +171,11 @@ function deliveryRows(value: unknown): (DeliveryRow | DeliverySummary)[] {
|
|
|
170
171
|
if (row.disposition !== undefined && (typeof row.disposition !== "string" || (row.direction === "inbound" ? !["accept", "discard"].includes(row.disposition) : !["completed", "retry", "discard"].includes(row.disposition)))) return fail("invalid_reply");
|
|
171
172
|
if (row.lateReceipt !== undefined && row.lateReceipt !== "completed (admitted)" && row.lateReceipt !== "completed (inbox)" && row.lateReceipt !== "failed_safe") return fail("invalid_reply");
|
|
172
173
|
if (row.refusalReason !== undefined && (typeof row.refusalReason !== "string" || !REFUSAL_REASONS.includes(row.refusalReason as RoomReceiptReason))) return fail("invalid_reply");
|
|
174
|
+
if (row.holdReason !== undefined && (row.holdReason !== "epoch rotated" || row.direction !== "outbound" || row.state !== "needs_review")) return fail("invalid_reply");
|
|
173
175
|
if (row.direction === "outbound" && row.state.startsWith("disposed")) return fail("invalid_reply");
|
|
174
176
|
const state = row.state === "completed" ? row.refusalReason === "inbox_admitted" ? "completed (inbox)" : "completed (admitted)" : row.state === "disposed" && row.disposition !== undefined ? `disposed (${row.disposition}, by person)` as DeliveryState : row.state as DeliveryState;
|
|
175
177
|
return { direction: row.direction, id: row.id, application: row.application, peer: row.peer, state,
|
|
176
|
-
...(row.disposition !== undefined ? { disposition: row.disposition as DeliveryRow["disposition"] } : {}), ...(row.lateReceipt !== undefined ? { lateReceipt: row.lateReceipt as DeliveryRow["lateReceipt"] } : {}), ...(row.refusalReason !== undefined ? { refusalReason: row.refusalReason as RoomReceiptReason } : {}) };
|
|
178
|
+
...(row.disposition !== undefined ? { disposition: row.disposition as DeliveryRow["disposition"] } : {}), ...(row.lateReceipt !== undefined ? { lateReceipt: row.lateReceipt as DeliveryRow["lateReceipt"] } : {}), ...(row.refusalReason !== undefined ? { refusalReason: row.refusalReason as RoomReceiptReason } : {}), ...(row.holdReason === "epoch rotated" ? { holdReason: "epoch rotated" } : {}) };
|
|
177
179
|
});
|
|
178
180
|
}
|
|
179
181
|
function remotePeerRows(value: unknown): RemotePeerRow[] {
|
|
@@ -181,7 +183,9 @@ function remotePeerRows(value: unknown): RemotePeerRow[] {
|
|
|
181
183
|
return value.map<RemotePeerRow>(item => {
|
|
182
184
|
const row = record(item);
|
|
183
185
|
if (typeof row.id !== "string" || !REMOTE_ID.test(row.id) || typeof row.state !== "string" || !["idle", "busy", "paused", "offline"].includes(row.state)) return fail("invalid_reply");
|
|
184
|
-
|
|
186
|
+
if (row.presence !== undefined && (typeof row.presence !== "string" || !["idle", "busy", "paused", "offline", "unknown"].includes(row.presence))) return fail("invalid_reply");
|
|
187
|
+
if (row.liveness !== undefined && row.liveness !== "untracked") return fail("invalid_reply");
|
|
188
|
+
return { id: row.id, state: row.state as RemotePeerRow["state"], ...(row.presence !== undefined ? { presence: row.presence as RemotePeerRow["presence"] } : {}), ...(row.liveness === "untracked" ? { liveness: "untracked" as const } : {}) };
|
|
185
189
|
});
|
|
186
190
|
}
|
|
187
191
|
function record(value: unknown): Record<string, unknown> {
|
|
@@ -190,7 +194,7 @@ function record(value: unknown): Record<string, unknown> {
|
|
|
190
194
|
}
|
|
191
195
|
function success(value: unknown): Record<string, unknown> {
|
|
192
196
|
const row = record(value);
|
|
193
|
-
if (row.ok !== true) fail("refused");
|
|
197
|
+
if (row.ok !== true) fail(row.code === "epoch_rotated_hold_unavailable" ? "epoch_rotated_hold_unavailable" : "refused");
|
|
194
198
|
return row;
|
|
195
199
|
}
|
|
196
200
|
function roomRows(value: unknown): RoomRow[] {
|
|
@@ -270,10 +274,10 @@ export async function runRoomCommand(args: readonly string[], request: RoomReque
|
|
|
270
274
|
console.log(`Rooms (${rows.length})`);
|
|
271
275
|
for (const row of rows) {
|
|
272
276
|
console.log(`${row.name} ${row.room} epoch ${row.epoch} ${row.state}\n owner ${row.owner}\n member ${row.member}`);
|
|
273
|
-
for (const peer of row.remotePeers ?? []) console.log(` peer ${peer.id}: ${peer.state}`);
|
|
277
|
+
for (const peer of row.remotePeers ?? []) console.log(` peer ${peer.id}: ${peer.presence ?? peer.state}${peer.liveness === "untracked" ? "; liveness not tracked (broker without ephemeral); upgrade the broker to track liveness" : ""}`);
|
|
274
278
|
for (const delivery of row.deliveries ?? []) {
|
|
275
279
|
if (!("id" in delivery)) console.log(` inbound: pending ${delivery.pending}, needs_review ${delivery.needsReview}${delivery.truncated ? "; truncated" : ""}`);
|
|
276
|
-
else console.log(` ${delivery.direction} ${delivery.id}: ${delivery.state}; application ${delivery.application}; peer ${delivery.peer}${delivery.disposition ? `; person disposition ${delivery.disposition}` : ""}${delivery.lateReceipt ? `; late receipt evidence ${delivery.lateReceipt}` : ""}${delivery.refusalReason ? `; refusal ${roomReceiptReasonText(delivery.refusalReason)}` : ""}`);
|
|
280
|
+
else console.log(` ${delivery.direction} ${delivery.id}: ${delivery.state}; application ${delivery.application}; peer ${delivery.peer}${delivery.disposition ? `; person disposition ${delivery.disposition}` : ""}${delivery.lateReceipt ? `; late receipt evidence ${delivery.lateReceipt}` : ""}${delivery.refusalReason ? `; refusal ${roomReceiptReasonText(delivery.refusalReason)}` : ""}${delivery.holdReason ? "; reason epoch rotated; inspect ahub queue show before resolving" : ""}`);
|
|
277
281
|
}
|
|
278
282
|
}
|
|
279
283
|
return;
|
|
@@ -283,6 +287,12 @@ export async function runRoomCommand(args: readonly string[], request: RoomReque
|
|
|
283
287
|
|| typeof preview.ticket !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(preview.ticket)) fail("invalid_reply");
|
|
284
288
|
console.log(`Owner fingerprint: ${preview.owner}`);
|
|
285
289
|
console.log(`Member fingerprint: ${preview.member}`);
|
|
290
|
+
if (preview.outstanding !== undefined) {
|
|
291
|
+
if (!Array.isArray(preview.outstanding) || preview.outstanding.length > ROOM_STORE_LIMITS.outboxPerRoom
|
|
292
|
+
|| preview.outstanding.some(value => typeof value !== "string" || !UUID.test(value)) || new Set(preview.outstanding).size !== preview.outstanding.length) fail("invalid_reply");
|
|
293
|
+
console.log(`Outstanding deliveries on the old epoch (${preview.outstanding.length}):`);
|
|
294
|
+
for (const id of preview.outstanding) console.log(` ${id}`);
|
|
295
|
+
}
|
|
286
296
|
const confirm = await confirmMember(preview.member, command.confirm, io);
|
|
287
297
|
const result = success(await request({ t: "room", op: "execute", ticket: preview.ticket, confirm }));
|
|
288
298
|
if (result.name !== undefined) {
|
package/src/hub/bus.ts
CHANGED
|
@@ -161,6 +161,7 @@ export const NOTES_KEPT = 10;
|
|
|
161
161
|
* it holds an `important` envelope, or `batchMax` envelopes, or its oldest one has waited `batchMs`.
|
|
162
162
|
*/
|
|
163
163
|
export const ROOM_PROOF_UNAVAILABLE_REASON = "room delivery proof unavailable; inspect ahub queue show";
|
|
164
|
+
export const ROOM_EPOCH_ROTATED_REASON = "room epoch rotated; inspect ahub queue show";
|
|
164
165
|
|
|
165
166
|
export class Bus {
|
|
166
167
|
readonly peers = new Map<PeerId, PeerAdapter>();
|
|
@@ -1367,6 +1368,23 @@ export class Bus {
|
|
|
1367
1368
|
return this.holdRemoteDeliveryMetadata(peerId, row, `room recipient proof no longer current after restart; inspect ahub queue show ${deliveryId}`);
|
|
1368
1369
|
}
|
|
1369
1370
|
|
|
1371
|
+
/** Person-authorized epoch change: hold metadata only, without cancellation, replay or completion. */
|
|
1372
|
+
holdRemoteEpochRotated(peerId: string, deliveryId: string, expectedRoom: string, expectedMember: string, currentGuard: () => boolean): boolean {
|
|
1373
|
+
if (!parseRemotePeerId(peerId) || typeof expectedRoom !== "string" || !expectedRoom || !remoteFingerprint(expectedMember)
|
|
1374
|
+
|| !this.journal || this.storageError || this.closed
|
|
1375
|
+
|| this.remoteQueueRooms.get(peerId) !== expectedRoom || this.remoteQueueMembers.get(peerId) !== expectedMember) return false;
|
|
1376
|
+
const row = this.journal.get(deliveryId), reason = `${ROOM_EPOCH_ROTATED_REASON} ${deliveryId}`;
|
|
1377
|
+
if (!row || row.peer !== peerId || row.originals.length !== 1 || row.originals[0]?.to?.includes(peerId) !== true
|
|
1378
|
+
|| this.journal.resolution(deliveryId)) return false;
|
|
1379
|
+
const active = this.activeDeliveries.get(deliveryId), transport = this.remoteTransport.get(deliveryId)?.peer;
|
|
1380
|
+
if (active !== undefined && active !== peerId || transport !== undefined && transport !== peerId) return false;
|
|
1381
|
+
if (!["dispatching", "accepted", "needs_review"].includes(row.state)) return false;
|
|
1382
|
+
if (row.state === "needs_review" && row.reason !== reason && row.reason !== `${ROOM_PROOF_UNAVAILABLE_REASON} ${deliveryId}`) return false;
|
|
1383
|
+
try { if (currentGuard() !== true) return false; } catch { return false; }
|
|
1384
|
+
if (row.state === "needs_review" && row.reason === reason) return true;
|
|
1385
|
+
return this.holdRemoteDeliveryMetadata(peerId, row, reason);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1370
1388
|
/** Resume the same delivery after the host revalidates its durable outbox proof. */
|
|
1371
1389
|
resumeRemotePending(source: PeerAdapter, deliveryId: string, state: "dispatching" | "accepted" | "needs_review", currentGuard: () => boolean): boolean {
|
|
1372
1390
|
const peer = source.id;
|
package/src/hub/daemon.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { readSdlcPr } from "./sdlc.ts";
|
|
|
4
4
|
import type { ActionMeta } from "@staix/agent-console/contract";
|
|
5
5
|
import { isReviewObserverExcluded, reviewCaptureExcluded } from "./memory-policy.ts";
|
|
6
6
|
import { RoomStoreError } from "../rooms/store.ts";
|
|
7
|
-
import { RoomCommands, type RoomPreview } from "../rooms/commands.ts";
|
|
7
|
+
import { RoomCommands, RoomRotationHoldError, type RoomPreview } from "../rooms/commands.ts";
|
|
8
8
|
import { queueBackpressurePolicy, type QueueBackpressurePolicy } from "./queue-backpressure.ts";
|
|
9
9
|
import { RoomChat, type RoomInvocationIngress } from "../rooms/service.ts";
|
|
10
10
|
import { RoomInvocations, roomInvocationHash } from "./room-invocations.ts";
|
|
@@ -10788,7 +10788,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
10788
10788
|
const personRoom = c.role === "console";
|
|
10789
10789
|
const roomCurrent = () => !stopping && !recoveryActive() && !bus.isRecoveryHeld
|
|
10790
10790
|
&& sock.data === client && sock.readyState === WebSocket.OPEN && personRoom;
|
|
10791
|
-
const roomFailure = () =>
|
|
10791
|
+
const roomFailure = (error?: unknown) => error instanceof RoomRotationHoldError
|
|
10792
|
+
? reply({ ok: false, code: "epoch_rotated_hold_unavailable", error: error.message })
|
|
10793
|
+
: reply({ ok: false, error: "room operation refused; check room state, authority and arguments" });
|
|
10792
10794
|
if (msg.op === "list" || msg.op === "status") {
|
|
10793
10795
|
if (msg.input !== undefined && (!msg.input || typeof msg.input !== "object" || Array.isArray(msg.input)
|
|
10794
10796
|
|| Object.keys(msg.input).some(key => key !== "name") || (msg.input.name !== undefined && typeof msg.input.name !== "string"))) return roomFailure();
|
|
@@ -10831,7 +10833,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
10831
10833
|
if (!roomCurrent()) return roomFailure();
|
|
10832
10834
|
const ticket = crypto.randomUUID();
|
|
10833
10835
|
roomTickets.set(sock, { id: ticket, expires: Date.now() + 30_000, preview });
|
|
10834
|
-
reply({ ok: true, owner: preview.owner, member: preview.member, ticket });
|
|
10836
|
+
reply({ ok: true, owner: preview.owner, member: preview.member, ticket, ...(preview.outstanding ? { outstanding: preview.outstanding } : {}) });
|
|
10835
10837
|
}, roomFailure);
|
|
10836
10838
|
return;
|
|
10837
10839
|
}
|
|
@@ -11312,7 +11314,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
11312
11314
|
async function stopOnce(): Promise<void> {
|
|
11313
11315
|
stopping = true;
|
|
11314
11316
|
controlWriteTelemetry?.stop();
|
|
11315
|
-
roomCommands.stop();
|
|
11317
|
+
const roomsStopped = roomCommands.stop().catch(() => { log("room shutdown failed; inspect ahub room status after restart"); });
|
|
11316
11318
|
tasks.stopQueuePressureRechecks();
|
|
11317
11319
|
const studiesStopped = tasks.stopStudies();
|
|
11318
11320
|
const reviewCommentsStopped = tasks.stopReviewComments();
|
|
@@ -11346,7 +11348,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
11346
11348
|
await Promise.race([Promise.all([schedulesPass?.catch(() => undefined), ...manualSchedulePasses, studiesStopped, reviewCommentsStopped]), new Promise<void>(resolve => { timer = setTimeout(resolve, 5_000); timer.unref?.(); })]);
|
|
11347
11349
|
clearTimeout(timer);
|
|
11348
11350
|
}
|
|
11349
|
-
await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped]);
|
|
11351
|
+
await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped, roomsStopped]);
|
|
11350
11352
|
await terminalLaunches.settled();
|
|
11351
11353
|
// A peer can still be inside memory recall or its native handshake when kill arrives.
|
|
11352
11354
|
// Drain those starts before taking the final owned-process snapshot.
|
package/src/rooms/commands.ts
CHANGED
|
@@ -3,14 +3,17 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { assertRoomCryptoReady, decodeBase64url, encodeBase64url, signBytes, verifyBytes } from "./crypto.ts";
|
|
5
5
|
import { acceptRoomEpoch, brokerUrl, canonicalBytes, createInvite, createJoin, createRoomEpoch, decodeInvite, decodeJoin, decodeRoster, roomMembers } from "./wire.ts";
|
|
6
|
-
import { RoomStore, type RoomState, type RoomPersonInboxMeta, type RoomPersonInboxItem } from "./store.ts";
|
|
6
|
+
import { RoomStore, type RoomState, type RoomPersonInboxMeta, type RoomPersonInboxItem, type RoomOutgoing } from "./store.ts";
|
|
7
7
|
import { RoomLink } from "./link.ts";
|
|
8
8
|
import { exposedPeers, roomName, RoomPolicy } from "./policy.ts";
|
|
9
9
|
import type { RoomChat } from "./service.ts";
|
|
10
10
|
import { projectRoomStatus, type RoomStatusView } from "./status.ts";
|
|
11
11
|
|
|
12
12
|
export interface RoomCommandResult { kind?: string; artifact?: string; name?: string; room?: string; owner?: string; member?: string }
|
|
13
|
-
export interface RoomPreview { owner: string; member: string; commit: () => Promise<RoomCommandResult> }
|
|
13
|
+
export interface RoomPreview { owner: string; member: string; outstanding?: readonly string[]; commit: () => Promise<RoomCommandResult> }
|
|
14
|
+
export class RoomRotationHoldError extends Error {
|
|
15
|
+
constructor() { super("room epoch rotated but delivery journal holds are unavailable; inspect ahub room status and ahub queue show before another mutation"); this.name = "RoomRotationHoldError"; }
|
|
16
|
+
}
|
|
14
17
|
const FP = /^[0-9a-f]{64}$/;
|
|
15
18
|
function refuse(code: string): never { throw new Error(`room: ${code}`); }
|
|
16
19
|
function fields(input: unknown, keys: string[], optional: string[] = []): Record<string, any> {
|
|
@@ -47,9 +50,10 @@ export class RoomCommands {
|
|
|
47
50
|
private pending?: Promise<RoomStore>;
|
|
48
51
|
private store?: RoomStore;
|
|
49
52
|
private stopped = false;
|
|
53
|
+
private stopWork?: Promise<void>;
|
|
50
54
|
private readonly links = new Map<string, RoomLink>();
|
|
51
55
|
private readonly policy: RoomPolicy;
|
|
52
|
-
constructor(private readonly options: { stateDir: string; cwd: string; clock?: () => number; presence?: () => readonly {id: string; state: string}[]; chat?: RoomChat }) { this.policy = new RoomPolicy(options.cwd); }
|
|
56
|
+
constructor(private readonly options: { stateDir: string; cwd: string; clock?: () => number; refreshMs?: number; presence?: () => readonly {id: string; state: string}[]; chat?: RoomChat }) { this.policy = new RoomPolicy(options.cwd); }
|
|
53
57
|
private async open(): Promise<RoomStore> {
|
|
54
58
|
if (this.stopped) refuse("stopped");
|
|
55
59
|
this.pending ??= RoomStore.open({ stateDir: join(this.options.stateDir, "rooms"), ...(this.options.clock ? { clock: this.options.clock } : {}) }).then(store => {
|
|
@@ -63,13 +67,19 @@ export class RoomCommands {
|
|
|
63
67
|
const store = await this.open();
|
|
64
68
|
if (!this.stopped) for (const state of store.list()) if (!state.left) this.link(state.name);
|
|
65
69
|
}
|
|
66
|
-
stop(): void {
|
|
70
|
+
stop(): Promise<void> {
|
|
71
|
+
if (this.stopWork) return this.stopWork;
|
|
72
|
+
this.stopped = true; clearTimeout(this.presenceTimer); this.options.chat?.stop();
|
|
73
|
+
const stops = [...this.links.values()].map(link => link.stop()); this.links.clear();
|
|
74
|
+
this.stopWork = Promise.allSettled([...(this.pending ? [this.pending] : []), ...stops]).then(() => { this.store?.close(); });
|
|
75
|
+
return this.stopWork;
|
|
76
|
+
}
|
|
67
77
|
private link(name: string): void {
|
|
68
78
|
if (this.stopped || !this.store || this.store.get(name)?.left) return;
|
|
69
79
|
let link = this.links.get(name);
|
|
70
80
|
if (!link) {
|
|
71
81
|
const store = this.store;
|
|
72
|
-
const created: RoomLink = new RoomLink({ store, name, application: this.options.chat?.application,
|
|
82
|
+
const created: RoomLink = new RoomLink({ store, name, application: this.options.chat?.application, refreshMs: this.options.refreshMs, now: this.options.clock,
|
|
73
83
|
onPresence: value => this.options.chat?.presence(store, name, created, value),
|
|
74
84
|
onState: state => this.options.chat?.linkState(store, name, created, state), presence: () => {
|
|
75
85
|
const allowed = new Set(this.policy.get(name));
|
|
@@ -78,7 +88,7 @@ export class RoomCommands {
|
|
|
78
88
|
this.links.set(name, created); link = created;
|
|
79
89
|
this.options.chat?.bind(store, name, created);
|
|
80
90
|
void created.start().catch(() => undefined);
|
|
81
|
-
} else { this.options.chat?.bind(this.store, name, link); link.refresh(); }
|
|
91
|
+
} else { this.options.chat?.bind(this.store, name, link); link.refresh(); void link.start().catch(() => undefined); }
|
|
82
92
|
}
|
|
83
93
|
private presenceTimer?: ReturnType<typeof setTimeout>;
|
|
84
94
|
changed(): void {
|
|
@@ -91,6 +101,26 @@ export class RoomCommands {
|
|
|
91
101
|
this.presenceTimer.unref?.();
|
|
92
102
|
}
|
|
93
103
|
private current(check: () => boolean): void { if (this.stopped || !check()) refuse("state_changed"); }
|
|
104
|
+
private async rotate(store: RoomStore, name: string, digest: string, expected: readonly RoomOutgoing[], check: () => boolean,
|
|
105
|
+
operation: (current: () => boolean) => Promise<RoomState>, after?: () => void): Promise<RoomState> {
|
|
106
|
+
const ids = expected.map(row => row.deliveryId).sort(), release = await this.links.get(name)?.quiesceApplications();
|
|
107
|
+
let next: RoomState | undefined, returned = false;
|
|
108
|
+
const current = () => !this.stopped && check() && store.get(name)?.current.digest === digest
|
|
109
|
+
&& JSON.stringify(store.rotationPending(name).map(row => row.deliveryId).sort()) === JSON.stringify(ids);
|
|
110
|
+
try {
|
|
111
|
+
if (!current()) refuse("state_changed");
|
|
112
|
+
next = await operation(current);
|
|
113
|
+
const held = next.current.digest === digest || this.options.chat?.epochRotated(store, name, digest, expected) !== false;
|
|
114
|
+
this.current(check); after?.();
|
|
115
|
+
if (!held) throw new RoomRotationHoldError();
|
|
116
|
+
returned = true; return next;
|
|
117
|
+
} finally {
|
|
118
|
+
release?.();
|
|
119
|
+
// A committed epoch still needs publication if its journal label or local
|
|
120
|
+
// policy update failed; its old sealed intents were already fenced atomically.
|
|
121
|
+
if (next && !returned && next.current.digest !== digest) this.link(name);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
94
124
|
private active(store: RoomStore, name: string): RoomState {
|
|
95
125
|
const state = store.get(name); if (!state || state.left) refuse("unavailable"); return state;
|
|
96
126
|
}
|
|
@@ -196,9 +226,12 @@ export class RoomCommands {
|
|
|
196
226
|
? { room: existing.current.room, owner, minEpoch: existing.current.epoch, digest: existing.current.digest }
|
|
197
227
|
: { room: invitation!.room, owner, minEpoch: "0" } });
|
|
198
228
|
this.current(check);
|
|
199
|
-
|
|
229
|
+
const outstanding = existing ? store.rotationPending(name) : [];
|
|
230
|
+
return { owner, member: own, ...(existing ? { outstanding: outstanding.map(row => row.deliveryId).sort() } : {}), commit: async () => {
|
|
200
231
|
this.current(check);
|
|
201
|
-
const state = await
|
|
232
|
+
const state = existing ? await this.rotate(store, name, existing.current.digest, outstanding, check, current =>
|
|
233
|
+
store.install({ name, broker, wire, expectedDigest: existing.current.digest, current, reactivate: true }))
|
|
234
|
+
: await store.install({ name, broker, wire, expectedDigest: null, current: check, reactivate: true });
|
|
202
235
|
return completed(state);
|
|
203
236
|
} };
|
|
204
237
|
}
|
|
@@ -235,7 +268,11 @@ export class RoomCommands {
|
|
|
235
268
|
const state = this.active(store, name), owner = this.owner(state), digest = state.current.digest;
|
|
236
269
|
if (action === "leave") return { owner, member: own, commit: async () => {
|
|
237
270
|
this.current(check); if (store.get(name)?.current.digest !== digest) refuse("state_changed");
|
|
238
|
-
|
|
271
|
+
await this.links.get(name)?.stop();
|
|
272
|
+
try {
|
|
273
|
+
this.current(check); if (store.get(name)?.current.digest !== digest) refuse("state_changed");
|
|
274
|
+
store.leave(name); this.links.delete(name); return { name, room: state.current.room, owner, member: own };
|
|
275
|
+
} catch (error) { this.link(name); throw error; }
|
|
239
276
|
} };
|
|
240
277
|
if (action === "invite") {
|
|
241
278
|
if (owner !== own) refuse("owner_only");
|
|
@@ -254,9 +291,12 @@ export class RoomCommands {
|
|
|
254
291
|
if (!sender || exposure.room !== state.current.room || exposure.owner !== owner || exposure.digest !== digest || !await verifyBytes({ fingerprint: sender.fingerprint, signingKey: sender.signingKey, agreementKey: sender.agreementKey }, exposureBytes(body), decodeBase64url(signature, 64))) refuse("untrusted");
|
|
255
292
|
}
|
|
256
293
|
this.current(check);
|
|
257
|
-
|
|
294
|
+
const outstanding = store.rotationPending(name);
|
|
295
|
+
return { owner, member, outstanding: outstanding.map(row => row.deliveryId).sort(), commit: async () => {
|
|
258
296
|
this.current(check);
|
|
259
|
-
const next =
|
|
297
|
+
const next = await this.rotate(store, name, digest, outstanding, check, current => exposure
|
|
298
|
+
? store.rotate(name, roomMembers(state.current).map(m => m.fingerprint === member ? { ...m, exposedPeers: exposedPeers(exposure.peers) } : m), digest, current)
|
|
299
|
+
: store.enroll(name, request, current));
|
|
260
300
|
this.current(check); completed(next);
|
|
261
301
|
return { name, owner, member, kind: "roster", artifact: encodeBase64url(next.wire) };
|
|
262
302
|
} };
|
|
@@ -264,19 +304,24 @@ export class RoomCommands {
|
|
|
264
304
|
if (action === "revoke") {
|
|
265
305
|
if (owner !== own) refuse("owner_only");
|
|
266
306
|
if (typeof row.member !== "string" || !FP.test(row.member) || row.member === own || !roomMembers(state.current).some(m => m.fingerprint === row.member)) refuse("invalid_member");
|
|
267
|
-
|
|
268
|
-
|
|
307
|
+
const outstanding = store.rotationPending(name);
|
|
308
|
+
return { owner, member: row.member, outstanding: outstanding.map(row => row.deliveryId).sort(), commit: async () => {
|
|
309
|
+
this.current(check); const next = await this.rotate(store, name, digest, outstanding, check, current =>
|
|
310
|
+
store.rotate(name, roomMembers(state.current).filter(m => m.fingerprint !== row.member), digest, current)); return completed(next);
|
|
269
311
|
} };
|
|
270
312
|
}
|
|
271
313
|
if (action === "expose") {
|
|
272
314
|
const peers = exposedPeers(row.peers ?? []);
|
|
273
315
|
this.policy.assertWritable(name, peers, check);
|
|
274
|
-
|
|
316
|
+
const outstanding = owner === own ? store.rotationPending(name) : [];
|
|
317
|
+
return { owner, member: own, ...(owner === own ? { outstanding: outstanding.map(row => row.deliveryId).sort() } : {}), commit: async () => {
|
|
275
318
|
this.current(check); this.policy.assertWritable(name, peers, check);
|
|
276
319
|
if (store.get(name)?.current.digest !== digest) refuse("state_changed");
|
|
277
320
|
if (owner === own) {
|
|
278
|
-
const next = await
|
|
279
|
-
|
|
321
|
+
const next = await this.rotate(store, name, digest, outstanding, check, current =>
|
|
322
|
+
store.rotate(name, roomMembers(state.current).map(m => m.fingerprint === own ? { ...m, exposedPeers: peers } : m), digest, current),
|
|
323
|
+
() => this.policy.set(name, peers, check));
|
|
324
|
+
return completed(next);
|
|
280
325
|
}
|
|
281
326
|
const body: Omit<ExposureRequest, "signature"> = { v: 1, type: "exposure", room: state.current.room, owner, member: own, digest, peers };
|
|
282
327
|
const request = canonicalBytes({ ...body, signature: encodeBase64url(await signBytes(store.identity, exposureBytes(body))) });
|