@huynle/hub 0.8.0 → 0.8.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/CHANGELOG.md +213 -1
- package/README.md +84 -15
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/cli/dist/hub-cli.js +649 -242
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
- package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
- package/packages/cli/src/cli.ts +3 -3
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +2 -2
- package/packages/cli/src/mcp.ts +7 -3
- package/packages/cli/src/messaging.ts +4 -2
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +20 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/teams-mcp.ts +59 -2
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +10 -2
- package/packages/node/src/opencode-warm.ts +27 -8
- package/packages/node/src/run.ts +3 -2
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +7 -1
- package/packages/protocol/src/launch.ts +14 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +22 -2
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/acting.ts +3 -0
- package/src/central/agent/audit.ts +1 -1
- package/src/central/agent/decisions.ts +58 -2
- package/src/central/agent/delivery.ts +101 -27
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events.ts +7 -1
- package/src/central/agent/launch-engine.ts +173 -30
- package/src/central/agent/launch-routes.ts +20 -9
- package/src/central/agent/launch-spec.ts +26 -2
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +8 -3
- package/src/central/agent/messages.ts +3 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +3 -0
- package/src/central/agent/routes.ts +4 -2
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/artifacts/jobs-routes.ts +4 -1
- package/src/central/concierge/board-routes.ts +13 -2
- package/src/central/concierge/inbound.ts +4 -7
- package/src/central/concierge/prompt.ts +4 -3
- package/src/central/concierge/proposals.ts +8 -2
- package/src/central/concierge/routes.ts +62 -2
- package/src/central/concierge/session-routes.ts +62 -6
- package/src/central/concierge/supervisor.ts +34 -7
- package/src/central/concierge/watchlist-routes.ts +4 -2
- package/src/central/config.ts +21 -5
- package/src/central/http.ts +38 -15
- package/src/central/main.ts +1 -1
- package/src/central/registry.ts +26 -1
- package/src/central/teams/intake.ts +20 -6
- package/src/central/teams/staffing.ts +22 -0
- package/src/central/workspaces/sidechats-routes.ts +1 -1
- package/src/index.ts +39 -18
- package/src/migrate.ts +109 -3
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/hub-tool.ts +24 -10
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +18 -6
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/session-context.ts +107 -27
- package/src/spoke/spoke.ts +13 -5
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
- package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DAr4IHM8.js} +1 -1
- package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-DWZj595u.js} +1 -1
- package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-JUb6OeI3.js} +1 -1
- package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +4 -4
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
- package/web/dist/assets/Library-CtxRqTjo.js +0 -2
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
- package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
- package/web/dist/assets/index-DaH5GUdV.js +0 -31
- package/web/dist/assets/index-WWuKfWxL.css +0 -1
- package/web/dist/assets/live-YyTjE47g.js +0 -5
- package/web/dist/assets/ui-C9Igxzjm.js +0 -1
|
@@ -7,13 +7,16 @@
|
|
|
7
7
|
// {feature: "deliver"} and nothing is stored. No fallback through steer/turn.
|
|
8
8
|
// - Modes: plain msg → delivery "queue" (after the running turn), ask/reply/decline/urgent → "steer" (also the
|
|
9
9
|
// first msg after a primer to an idle session: the two then share one model call, quirk hzmcqwpj); wake
|
|
10
|
-
// unless wake:false, a subagent recipient, the hop cap (alerted once when crossed) or a spent wake budget
|
|
10
|
+
// unless wake:false, a subagent recipient, the hop cap (alerted once when crossed) or a spent wake budget; Sent
|
|
11
|
+
// says whether it woke (woke) and, when not, why (wakeSkipped: wake_false | subagent | hop_cap | budget).
|
|
11
12
|
// - Roles: one holder, the idle one (by real evidence) else the most recently active, else (all offline) the
|
|
12
13
|
// one seen last; the message records the holder generation. sendAll: every holder, a message each. A session
|
|
13
14
|
// never messages itself: it is left out of a role it holds, and an address naming only it is invalid.
|
|
14
15
|
// - Pending: no deliver-capable connection reports the recipient (offline) or every one failed (including an
|
|
15
16
|
// OpenCode spoke refusing a routine wake:false msg while the recipient's turn runs, RF-05) → kept for
|
|
16
|
-
// PENDING_MS in the StateStore and delivered oldest first on the user's next sessions frame (onSessions).
|
|
17
|
+
// PENDING_MS in the StateStore and delivered oldest first on the user's next sessions frame (onSessions). One
|
|
18
|
+
// still pending after PENDING_MS expires (the loop's tick, or a flush that finds it): delivery "expired", and its
|
|
19
|
+
// sender is told (a session by a quiet hub notice, the user by an alert, everyone through the journal). In a
|
|
17
20
|
// cluster (deps.locks) each (user, session) flush holds a lease (deliver:<user>:<session>) and re-reads the
|
|
18
21
|
// entry under it, so a spoke reconnecting to another pod meanwhile never gets a message twice. A flush stops at a
|
|
19
22
|
// session's first failed delivery (its later messages wait, in order, for the next frame: one round trip per busy
|
|
@@ -37,8 +40,12 @@
|
|
|
37
40
|
// for the user (their stored-only inbox; nothing goes to a spoke); a msg alerts the user ("user"), an ask is a
|
|
38
41
|
// Decision's carrier (./decisions pushes it). The user cannot message @me; `@me.project` is malformed.
|
|
39
42
|
// - Loop protection (./message-limits): pair/user rate limits before storing, hop per message, human turns
|
|
40
|
-
// (console, voice, typed in the harness) reset a session's chain (humanTurn).
|
|
43
|
+
// (console, voice, typed in the harness) reset a session's chain (humanTurn). Apps keep no depth (S1): a new
|
|
44
|
+
// msg/ask from an app is hop 1 plus its relay depth (what OTHER sessions sent it in the last 5 minutes, so a
|
|
45
|
+
// relay loop through an app still reaches the hop cap), an app's reply or decline counts up from the message it
|
|
46
|
+
// answers, and nothing sent to an app raises a persistent depth.
|
|
41
47
|
import type { DeliverRequest, DeliverResult, DeliveryMode, MessageKind as WireKind } from "../../protocol"
|
|
48
|
+
import type { WakeSkip } from "../../../packages/sdk/src/types.ts"
|
|
42
49
|
import { isDeliverResult } from "../../protocol"
|
|
43
50
|
import { randomBytes } from "node:crypto"
|
|
44
51
|
import type { Clock, Counters, Locks, StateStore, Timers } from "../backend/types"
|
|
@@ -80,8 +87,8 @@ export type DeliverSend = (connID: string, req: DeliverRequest) => Promise<Deliv
|
|
|
80
87
|
* ask stayed unanswered by an idle recipient after every re-wake.
|
|
81
88
|
*/
|
|
82
89
|
export interface DeliveryAlert {
|
|
83
|
-
/** user: a session or app sent the user (@me) a message. */
|
|
84
|
-
readonly kind: "hopCap" | "unanswered" | "user"
|
|
90
|
+
/** user: a session or app sent the user (@me) a message; expired: the user's message stayed pending for 24 h. */
|
|
91
|
+
readonly kind: "hopCap" | "unanswered" | "user" | "expired"
|
|
85
92
|
/** The recipient session (user: the sending session, "" for an app). */
|
|
86
93
|
readonly sessionID: string
|
|
87
94
|
readonly messageID: string
|
|
@@ -113,8 +120,12 @@ export interface DeliveryDeps {
|
|
|
113
120
|
/** Labels of a session sender (default: its @name from the directory). */
|
|
114
121
|
readonly describe?: (userID: string, sessionID: string) => PartyInfo
|
|
115
122
|
readonly alert?: (userID: string, alert: DeliveryAlert) => void
|
|
123
|
+
/** A pending message expired undelivered (its sender was told): the hub journals the change. Never throws through. */
|
|
124
|
+
readonly expired?: (userID: string, message: Message) => void
|
|
116
125
|
/** Limiter caps (default PAIR_LIMIT, USER_LIMIT, WAKE_BUDGET). */
|
|
117
126
|
readonly limits?: Partial<MessageLimits>
|
|
127
|
+
/** How long what an app received from a session raises its new messages (message-limits RELAY_WINDOW_MS). */
|
|
128
|
+
readonly relayWindowMs?: number
|
|
118
129
|
/** The users the re-wake loop looks at (every user of the hub; default: the users seen by this process since start). */
|
|
119
130
|
readonly users?: () => Iterable<string> | Promise<Iterable<string>>
|
|
120
131
|
/** Cluster mode: a pending flush holds a lease per (user, session), so two pods never deliver the same message. */
|
|
@@ -136,6 +147,11 @@ export interface SendOptions {
|
|
|
136
147
|
readonly urgent?: boolean
|
|
137
148
|
/** Asks only. */
|
|
138
149
|
readonly expiresInMs?: number
|
|
150
|
+
/**
|
|
151
|
+
* Hub-internal, never from a request body: the message continues a chain at least this deep (its hop is the
|
|
152
|
+
* larger of its own and this). Team intake passes the submitting session's depth + 1.
|
|
153
|
+
*/
|
|
154
|
+
readonly chainHop?: number
|
|
139
155
|
}
|
|
140
156
|
|
|
141
157
|
export interface Sent {
|
|
@@ -144,6 +160,8 @@ export interface Sent {
|
|
|
144
160
|
readonly delivery?: DeliveryState
|
|
145
161
|
/** Whether it was sent to wake its recipient (false: wake:false, a subagent, the hop cap or a spent wake budget). */
|
|
146
162
|
readonly woke?: boolean
|
|
163
|
+
/** Why it was not (only when woke is false). */
|
|
164
|
+
readonly wakeSkipped?: WakeSkip
|
|
147
165
|
}
|
|
148
166
|
|
|
149
167
|
/** A reply or decline as sent, with the message it answered. */
|
|
@@ -247,7 +265,7 @@ export class Delivery {
|
|
|
247
265
|
this.newID = deps.newID ?? newMessageID
|
|
248
266
|
this.owner = deps.owner ?? `delivery-${process.pid}-${randomBytes(4).toString("hex")}`
|
|
249
267
|
this.messages = new Messages({ state: deps.state, now, newID: this.newID })
|
|
250
|
-
this.hops = new HopTracker({ state: deps.state, now })
|
|
268
|
+
this.hops = new HopTracker({ state: deps.state, now, ...(deps.relayWindowMs === undefined ? {} : { relayWindowMs: deps.relayWindowMs }) })
|
|
251
269
|
this.limiter = new MessageLimiter({ counters: deps.counters, now, ...(deps.limits ? { limits: deps.limits } : {}) })
|
|
252
270
|
// An explicit (re)name primes again, so the session learns its new address.
|
|
253
271
|
deps.directory.onNamed?.((userID, sessionID) => void this.prime(userID, sessionID, { again: true }))
|
|
@@ -367,6 +385,11 @@ export class Delivery {
|
|
|
367
385
|
return
|
|
368
386
|
}
|
|
369
387
|
for (const userID of users) {
|
|
388
|
+
try {
|
|
389
|
+
await this.expirePending(userID)
|
|
390
|
+
} catch (cause) {
|
|
391
|
+
console.error(`[hub] pending expiry failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
392
|
+
}
|
|
370
393
|
try {
|
|
371
394
|
await this.rewake(userID)
|
|
372
395
|
} catch (cause) {
|
|
@@ -404,7 +427,7 @@ export class Delivery {
|
|
|
404
427
|
const from = this.party(userID, actor)
|
|
405
428
|
const recipient: Party = { kind: "user" }
|
|
406
429
|
await this.limiter.admit(userID, from, recipient)
|
|
407
|
-
const hop = await this.hops.next(userID, from)
|
|
430
|
+
const hop = await this.hops.next(userID, from, { to: recipient })
|
|
408
431
|
const message = await this.messages.send(userID, {
|
|
409
432
|
from,
|
|
410
433
|
to: USER_ADDRESS,
|
|
@@ -428,7 +451,7 @@ export class Delivery {
|
|
|
428
451
|
await this.deps.inbound?.admit(userID, from, sessionID, kind)
|
|
429
452
|
this.gate(userID, sessionID)
|
|
430
453
|
await this.limiter.admit(userID, from, recipient)
|
|
431
|
-
const hop = await this.hops.next(userID, from)
|
|
454
|
+
const hop = Math.max(await this.hops.next(userID, from, { to: recipient }), options.chainHop ?? 0)
|
|
432
455
|
const generation = resolved.address.kind === "role" && !("team" in resolved) ? roleGeneration(entry, resolved.address) : undefined
|
|
433
456
|
const message = await this.messages.send(userID, {
|
|
434
457
|
from,
|
|
@@ -440,15 +463,15 @@ export class Delivery {
|
|
|
440
463
|
...(generation === undefined ? {} : { generation }),
|
|
441
464
|
...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
|
|
442
465
|
})
|
|
443
|
-
await this.hops.received(userID, recipient, hop)
|
|
466
|
+
await this.hops.received(userID, recipient, hop, from)
|
|
444
467
|
this.users.add(userID)
|
|
445
|
-
const
|
|
446
|
-
const
|
|
447
|
-
if (kind === "ask" &&
|
|
468
|
+
const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : entry.parentID !== undefined ? "subagent" : undefined
|
|
469
|
+
const skipped = await this.wakeFor(userID, message, unwanted)
|
|
470
|
+
if (kind === "ask" && unwanted === undefined && !overHopCap(hop)) {
|
|
448
471
|
const rewake: Rewake = { messageID: message.id, sessionID, reminders: 0, nextAt: message.at + backoff(0) }
|
|
449
472
|
await this.deps.state.put(userID, REWAKE, message.id, rewake)
|
|
450
473
|
}
|
|
451
|
-
return this.deliver(userID, message, sessionID,
|
|
474
|
+
return this.deliver(userID, message, sessionID, skipped, options.urgent === true)
|
|
452
475
|
}
|
|
453
476
|
|
|
454
477
|
private schedule(): void {
|
|
@@ -555,33 +578,38 @@ export class Delivery {
|
|
|
555
578
|
}
|
|
556
579
|
if (to && original) await this.deps.teams?.answer(userID, from, original, to, kind, this.limiter)
|
|
557
580
|
if (to) await this.limiter.admit(userID, from, to)
|
|
558
|
-
const hop = await this.hops.next(userID, from)
|
|
581
|
+
const hop = await this.hops.next(userID, from, original ? { answering: original, ...(to ? { to } : {}) } : {})
|
|
559
582
|
const generation = original && actor.kind === "session" ? this.holderGeneration(userID, actor.sessionID, original) : undefined
|
|
560
583
|
const answered = await store({ from, replyTo, hop, ...(generation === undefined ? {} : { generation }) })
|
|
561
584
|
const { message } = answered
|
|
562
|
-
await this.hops.received(userID, message.recipient, hop)
|
|
585
|
+
await this.hops.received(userID, message.recipient, hop, from)
|
|
563
586
|
this.users.add(userID)
|
|
564
587
|
if (message.recipient.kind !== "session") return answered
|
|
565
588
|
const sessionID = message.recipient.sessionID
|
|
566
|
-
const
|
|
567
|
-
|
|
589
|
+
const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : this.isSubagent(userID, sessionID) ? "subagent" : undefined
|
|
590
|
+
const skipped = await this.wakeFor(userID, message, unwanted)
|
|
591
|
+
return { ...(await this.deliver(userID, message, sessionID, skipped, true)), original: answered.original }
|
|
568
592
|
}
|
|
569
593
|
|
|
570
|
-
/**
|
|
571
|
-
|
|
572
|
-
|
|
594
|
+
/**
|
|
595
|
+
* Why the message may not start a turn (undefined: it may): `unwanted` (wake:false or a subagent recipient), over
|
|
596
|
+
* the hop cap (alerting once at the cap), or no wake budget left.
|
|
597
|
+
*/
|
|
598
|
+
private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined): Promise<WakeSkip | undefined> {
|
|
599
|
+
if (unwanted) return unwanted
|
|
600
|
+
if (message.recipient.kind !== "session") return "wake_false"
|
|
573
601
|
if (overHopCap(message.hop)) {
|
|
574
602
|
if (message.hop === HOP_CAP + 1) {
|
|
575
603
|
this.deps.alert?.(userID, {
|
|
576
604
|
kind: "hopCap",
|
|
577
605
|
sessionID: message.recipient.sessionID,
|
|
578
606
|
messageID: message.id,
|
|
579
|
-
text: `
|
|
607
|
+
text: `Agent messages went ${HOP_CAP} hops without you (latest: ${partyLabel(message.from)} → ${partyLabel(message.recipient)}); further messages in this chain no longer wake anyone`,
|
|
580
608
|
})
|
|
581
609
|
}
|
|
582
|
-
return
|
|
610
|
+
return "hop_cap"
|
|
583
611
|
}
|
|
584
|
-
return this.limiter.wake(userID, message.recipient)
|
|
612
|
+
return (await this.limiter.wake(userID, message.recipient)) ? undefined : "budget"
|
|
585
613
|
}
|
|
586
614
|
|
|
587
615
|
/** The replier's generation of the role the original reached (undefined: not a role, or not held now). */
|
|
@@ -619,16 +647,18 @@ export class Delivery {
|
|
|
619
647
|
}
|
|
620
648
|
|
|
621
649
|
/** Delivers now, or queues the message as pending when no spoke takes it. */
|
|
622
|
-
private async deliver(userID: string, message: Message, sessionID: string,
|
|
650
|
+
private async deliver(userID: string, message: Message, sessionID: string, skipped: WakeSkip | undefined, urgent: boolean): Promise<Sent> {
|
|
651
|
+
const wake = skipped === undefined
|
|
652
|
+
const woke = wake ? { woke: true } : { woke: false, wakeSkipped: skipped }
|
|
623
653
|
// Messages still pending for this session go first (e.g. a quiet one a busy spoke refused, RF-05): the turn this
|
|
624
654
|
// one starts then carries them, instead of them arriving inside it.
|
|
625
655
|
if (wake && (await this.deps.state.list<Pending>(userID, PENDING)).some((p) => p.value.sessionID === sessionID)) await this.flush(userID, sessionID)
|
|
626
656
|
const delivered = await this.attempt(userID, message, sessionID, wake, urgent)
|
|
627
|
-
if (delivered) return { ...delivered, woke
|
|
657
|
+
if (delivered) return { ...delivered, ...woke }
|
|
628
658
|
const at = this.now()
|
|
629
659
|
const pending: Pending = { messageID: message.id, sessionID, wake, urgent, at, expiresAt: at + PENDING_MS }
|
|
630
660
|
await this.deps.state.put(userID, PENDING, message.id, pending)
|
|
631
|
-
return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending", woke
|
|
661
|
+
return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending", ...woke }
|
|
632
662
|
}
|
|
633
663
|
|
|
634
664
|
/** Tries each deliver-capable connection in turn (same message id: the harness dedupes it). */
|
|
@@ -671,7 +701,11 @@ export class Delivery {
|
|
|
671
701
|
this.flushStats.skipped++
|
|
672
702
|
continue
|
|
673
703
|
}
|
|
674
|
-
|
|
704
|
+
if (value.expiresAt <= this.now()) {
|
|
705
|
+
await this.expireEntry(userID, key, value, version)
|
|
706
|
+
continue
|
|
707
|
+
}
|
|
708
|
+
const message = await this.messages.get(userID, value.messageID)
|
|
675
709
|
// Gone, or an ask that closed (expired) while it waited: nothing to deliver.
|
|
676
710
|
if (!message || (message.kind === "ask" && message.state !== "open")) {
|
|
677
711
|
await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })
|
|
@@ -695,6 +729,46 @@ export class Delivery {
|
|
|
695
729
|
}
|
|
696
730
|
}
|
|
697
731
|
|
|
732
|
+
/** The loop's sweep: the user's messages pending past PENDING_MS expire (their senders are told). */
|
|
733
|
+
private async expirePending(userID: string): Promise<void> {
|
|
734
|
+
const t = this.now()
|
|
735
|
+
for (const { key, value, version } of await this.deps.state.list<Pending>(userID, PENDING)) {
|
|
736
|
+
if (value.expiresAt <= t) await this.expireEntry(userID, key, value, version)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* A pending message that waited PENDING_MS: removed (once: version-checked), marked "expired", and its sender
|
|
742
|
+
* told: a session by a quiet notice (wake:false, never the message text), the user by an alert; the change is
|
|
743
|
+
* journaled (deps.expired) for every sender, an app included. An ask that already closed is dropped silently.
|
|
744
|
+
*/
|
|
745
|
+
private async expireEntry(userID: string, key: string, value: Pending, version: number): Promise<void> {
|
|
746
|
+
if (!(await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })).ok) return
|
|
747
|
+
const current = await this.messages.get(userID, value.messageID)
|
|
748
|
+
if (!current || (current.kind === "ask" && current.state !== "open" && current.state !== "expired")) return
|
|
749
|
+
const message = (await this.messages.setDelivery(userID, current.id, "expired")) ?? current
|
|
750
|
+
const what = `${message.kind} ${message.id} to ${message.to}`
|
|
751
|
+
try {
|
|
752
|
+
this.deps.expired?.(userID, message)
|
|
753
|
+
} catch (cause) {
|
|
754
|
+
console.error(`[hub] pending expiry journal failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
755
|
+
}
|
|
756
|
+
const { from } = message
|
|
757
|
+
if (from.kind === "user") {
|
|
758
|
+
this.deps.alert?.(userID, { kind: "expired", sessionID: value.sessionID, messageID: message.id, text: `Your ${message.kind} to ${message.to} was not delivered: its session stayed offline or busy for 24 h` })
|
|
759
|
+
} else if (from.kind === "session") {
|
|
760
|
+
const id = this.newID()
|
|
761
|
+
await this.request(userID, from.sessionID, {
|
|
762
|
+
op: "deliver",
|
|
763
|
+
sessionID: from.sessionID,
|
|
764
|
+
messageID: id,
|
|
765
|
+
text: `[hub] notice · ${what} was not delivered: its session stayed offline or busy for 24 h · ${id}`,
|
|
766
|
+
wake: false,
|
|
767
|
+
meta: { kind: "msg", from: "hub", to: wireAddress(from), delivery: "queue" },
|
|
768
|
+
})
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
698
772
|
/** Takes the (user, session) flush lease once per flush (always true without locks); false: another process flushes it. */
|
|
699
773
|
private async lease(userID: string, sessionID: string, leases: Map<string, boolean>): Promise<boolean> {
|
|
700
774
|
const { locks } = this.deps
|
|
@@ -35,6 +35,8 @@ export const AGENT_ERROR_STATUS = {
|
|
|
35
35
|
internal: 500,
|
|
36
36
|
/** The machine is offline (actions only; messages queue instead). */
|
|
37
37
|
offline: 503,
|
|
38
|
+
/** (FX-07) The hub's state did not answer in time (`retryAfter`, seconds): refused, try again. */
|
|
39
|
+
busy: 503,
|
|
38
40
|
} as const satisfies Record<string, number>
|
|
39
41
|
|
|
40
42
|
export type AgentErrorCode = keyof typeof AGENT_ERROR_STATUS
|
|
@@ -25,7 +25,7 @@ import type { BlockerRecommendation } from "../../status"
|
|
|
25
25
|
import type { Escalation } from "./acting"
|
|
26
26
|
import type { AgentActor, UserActor } from "./actor"
|
|
27
27
|
import type { AgentAudit } from "./audit"
|
|
28
|
-
import { type ActOutcome, type Decision, type DecisionActor, type Decisions, WHY_MAX } from "./decisions"
|
|
28
|
+
import { type ActOutcome, type Decision, type DecisionActor, type Decisions, decisionRequest, WHY_MAX } from "./decisions"
|
|
29
29
|
import { type Party, partyFromActor, partyKey, partyLabel } from "./envelope"
|
|
30
30
|
import { AgentError } from "./errors"
|
|
31
31
|
import type { Message } from "./messages"
|
|
@@ -168,7 +168,15 @@ export class Escalations {
|
|
|
168
168
|
about: [escalation.sessionID],
|
|
169
169
|
},
|
|
170
170
|
{
|
|
171
|
-
action: {
|
|
171
|
+
action: {
|
|
172
|
+
kind: "permission",
|
|
173
|
+
requestID: escalation.requestID,
|
|
174
|
+
rootID: escalation.rootID,
|
|
175
|
+
sessionID: escalation.sessionID,
|
|
176
|
+
always: escalation.always,
|
|
177
|
+
...(escalation.request ? { request: decisionRequest(escalation.request) } : {}),
|
|
178
|
+
...(escalation.evaluation.reason ? { reason: escalation.evaluation.reason.slice(0, WHY_MAX) } : {}),
|
|
179
|
+
},
|
|
172
180
|
quiet: pushes > 1,
|
|
173
181
|
},
|
|
174
182
|
)
|
|
@@ -193,7 +193,13 @@ export function launchEvent(launch: Launch): EventInput {
|
|
|
193
193
|
type: "launch.changed",
|
|
194
194
|
family: "launch",
|
|
195
195
|
subject: { launchID: launch.id, ...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}) },
|
|
196
|
-
data: {
|
|
196
|
+
data: {
|
|
197
|
+
state: launch.state,
|
|
198
|
+
...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}),
|
|
199
|
+
...(launch.name !== undefined ? { name: launch.name } : {}),
|
|
200
|
+
// FX-02: a queued launch's wait kind (never its text); the engine journals a change of kind only.
|
|
201
|
+
...(launch.state === "queued" && launch.waiting ? { wait: launch.waiting.wait } : {}),
|
|
202
|
+
},
|
|
197
203
|
...(launch.by !== undefined ? { audience: { by: launch.by } } : {}),
|
|
198
204
|
}
|
|
199
205
|
}
|