@huynle/hub 0.8.0 → 0.8.2
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 +326 -1
- package/README.md +85 -16
- 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 +723 -281
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
- package/packages/cli/src/artifacts-mcp.ts +8 -7
- 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 +9 -4
- 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/team-intake-mcp.ts +8 -7
- package/packages/cli/src/teams-mcp.ts +61 -3
- package/packages/cli/src/tool-args.ts +6 -0
- package/packages/cli/src/watchlist.ts +5 -4
- 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/concierge.ts +4 -1
- package/packages/protocol/src/embedded-media.ts +59 -0
- package/packages/protocol/src/index.ts +90 -7
- package/packages/protocol/src/launch.ts +14 -0
- package/packages/protocol/src/wire.ts +1 -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/client.ts +3 -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/packages/spoke-sdk/src/connection.ts +13 -1
- package/src/central/agent/acting-routes.ts +3 -2
- package/src/central/agent/acting.ts +70 -12
- package/src/central/agent/audit.ts +1 -1
- package/src/central/agent/decisions.ts +58 -2
- package/src/central/agent/delivery.ts +111 -28
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/escalation.ts +39 -10
- 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 +60 -17
- 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 +11 -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/context.ts +3 -1
- package/src/central/concierge/inbound.ts +4 -7
- package/src/central/concierge/prompt.ts +10 -6
- 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 +87 -35
- package/src/central/main.ts +1 -1
- package/src/central/registry.ts +49 -4
- package/src/central/revert-windows.ts +66 -0
- package/src/central/spoke-ws.ts +6 -3
- package/src/central/teams/intake.ts +20 -6
- package/src/central/teams/staffing.ts +24 -2
- package/src/central/workspaces/sidechats-routes.ts +1 -1
- package/src/index.ts +63 -30
- package/src/migrate.ts +109 -3
- package/src/spoke/deliver.ts +6 -0
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +94 -9
- package/src/spoke/hub-tool.ts +26 -12
- 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 +120 -0
- package/src/spoke/profile-apply.ts +24 -14
- package/src/spoke/session-context.ts +183 -33
- package/src/spoke/spoke.ts +129 -41
- package/src/spoke/timeline.ts +30 -3
- package/src/spoke/tracker.ts +17 -1
- package/src/status.ts +2 -1
- package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
- package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
- package/web/dist/assets/Library-BXbJvTUS.js +2 -0
- package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
- package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
- package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-g4V9mfSr.js +34 -0
- package/web/dist/assets/live-OgQWPrSY.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-DjEipyto.js} +1 -1
- package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
- package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
- package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.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
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
// {verdict, reason, tier, grants}. The `escalate` hook (P3-T8 turns it into a Decision) gets the escalation
|
|
17
17
|
// first; a request without parsed details, or truncated, is always needs_user (tap).
|
|
18
18
|
// · deny, answer, prompt and stop need only the grant's verb (a reject is always safe).
|
|
19
|
+
// · (R-03) the CURRENT Concierge session's approve without any usable grant is needs_user too (a Decision at the hub's tier;
|
|
20
|
+
// the hub's never rules still forbid; self-tree still applies), never a bare refusal; other agents stay forbidden.
|
|
21
|
+
// A Concierge escalation recommends approve only when it passed `recommend: true`.
|
|
19
22
|
// - Every outcome after the address resolves is in the agent audit: decision (allow, needs_user, forbidden,
|
|
20
23
|
// paused, rate_limited) and outcome (ok, gone, offline, unsupported, escalated, refused, failed).
|
|
21
24
|
// - Races: the request answered elsewhere first is gone (410).
|
|
@@ -83,8 +86,12 @@ export interface Escalation {
|
|
|
83
86
|
readonly request?: PermissionRequest
|
|
84
87
|
readonly always: boolean
|
|
85
88
|
readonly evaluation: Evaluation
|
|
86
|
-
/** The agent's reason (`--why`): its recommendation to approve. */
|
|
89
|
+
/** The agent's reason (`--why`): its recommendation to approve (a Concierge's: only with `recommend`). */
|
|
87
90
|
readonly why?: string
|
|
91
|
+
/** (R-03) The actor is a Concierge session: its escalation recommends approve only with `recommend`. */
|
|
92
|
+
readonly concierge?: true
|
|
93
|
+
/** (R-03) The Concierge itself advises approving (absent: the user only asked it to try). */
|
|
94
|
+
readonly recommend?: true
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
/** The escalation hook: answers the Decision it opened (or nothing); a throw only loses the Decision. */
|
|
@@ -111,11 +118,18 @@ export interface ActingDeps {
|
|
|
111
118
|
* "always", even inside its grant: needs_user, a tap Decision. A failed read counts as yes (fail closed).
|
|
112
119
|
*/
|
|
113
120
|
readonly conciergeActor?: (userID: string, sessionID: string) => Promise<boolean>
|
|
121
|
+
/**
|
|
122
|
+
* (R-03 verify) Whether the acting session is the user's CURRENT Concierge session (never a retired one). Only it
|
|
123
|
+
* may escalate an approve without a grant; a failed read counts as no (fail closed: refused).
|
|
124
|
+
*/
|
|
125
|
+
readonly currentConcierge?: (userID: string, sessionID: string) => Promise<boolean>
|
|
114
126
|
}
|
|
115
127
|
|
|
116
128
|
/** (CON-13 decision) Why the Concierge escalates a covered request. */
|
|
117
129
|
export const CONCIERGE_TAP_REASON = "Shell, outside folders and tools always need the user's tap"
|
|
118
130
|
export const CONCIERGE_ALWAYS_REASON = "The Concierge never answers “always”: the user decides"
|
|
131
|
+
/** (R-03) Why the Concierge escalates an approval it holds no grant for. */
|
|
132
|
+
export const CONCIERGE_NO_GRANT_REASON = "No grant lets it approve here: the user decides"
|
|
119
133
|
|
|
120
134
|
/** Who acts, for which user. */
|
|
121
135
|
export interface Caller {
|
|
@@ -127,6 +141,8 @@ export interface ApproveInput {
|
|
|
127
141
|
readonly requestID: string
|
|
128
142
|
readonly always?: boolean
|
|
129
143
|
readonly why?: string
|
|
144
|
+
/** (R-03) The Concierge advises approving: its escalation recommends approve (other agents' why always does). */
|
|
145
|
+
readonly recommend?: boolean
|
|
130
146
|
}
|
|
131
147
|
|
|
132
148
|
export interface Approved {
|
|
@@ -282,6 +298,17 @@ function neverForms(request: PermissionRequest): PermissionRequest[] {
|
|
|
282
298
|
|
|
283
299
|
const patterns = (texts: readonly string[]): Pattern[] => texts.flatMap((t) => parsePattern(t) ?? [])
|
|
284
300
|
|
|
301
|
+
/** Forbidden when a never rule (the hub's, or a usable grant's) matches the request as asked or normalised (saves too, for always). */
|
|
302
|
+
function checkNever(request: PermissionRequest, rules: ReadonlyArray<{ grant: Grant; never: Pattern[] }>, hubNever: readonly Pattern[], always: boolean): void {
|
|
303
|
+
const sets = rules.length ? rules.map((r) => ({ id: r.grant.id, never: r.never })) : [{ id: undefined, never: [] as Pattern[] }]
|
|
304
|
+
for (const form of neverForms(request)) {
|
|
305
|
+
for (const { id, never } of sets) {
|
|
306
|
+
const verdict = evaluatePermission(form, { grant: { allow: [], never }, hubNever, always, allowAlways: true })
|
|
307
|
+
if (verdict.verdict === "never") throw forbidden(`never: ${verdict.by === "hub" ? "the hub's" : `grant ${id}'s`} rule ${formatPattern(verdict.pattern)} matches ${verdict.subject}`)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
285
312
|
const invalid = (message: string) => new AgentError("invalid", message)
|
|
286
313
|
const forbidden = (message: string) => new AgentError("forbidden", message)
|
|
287
314
|
const offline = () => new AgentError("offline", "No spoke reports this session now")
|
|
@@ -303,6 +330,8 @@ export class Acting {
|
|
|
303
330
|
const concierge = await this.isConcierge(caller)
|
|
304
331
|
return this.act(caller, "approve", target, { requestID: input.requestID }, input.why, {
|
|
305
332
|
selfTree: true,
|
|
333
|
+
// (R-03) The Concierge without a grant escalates (needs_user, a Decision) instead of being refused.
|
|
334
|
+
...(concierge ? { withoutGrant: () => this.isCurrentConcierge(caller) } : {}),
|
|
306
335
|
choose: (usable) => this.choosePermission(usable, origin.request, always, concierge),
|
|
307
336
|
escalation: (evaluation) => ({
|
|
308
337
|
verb: "approve",
|
|
@@ -314,6 +343,8 @@ export class Acting {
|
|
|
314
343
|
always,
|
|
315
344
|
evaluation,
|
|
316
345
|
...(input.why ? { why: input.why } : {}),
|
|
346
|
+
...(concierge ? { concierge: true as const } : {}),
|
|
347
|
+
...(concierge && input.recommend === true ? { recommend: true as const } : {}),
|
|
317
348
|
}),
|
|
318
349
|
run: async (grant) => {
|
|
319
350
|
const answered = await this.deps.actions.answerPermission({ rootID: target.rootID, requestID: input.requestID, decision })
|
|
@@ -519,9 +550,20 @@ export class Acting {
|
|
|
519
550
|
if (target.parentID) throw invalid("Prompts go to root sessions, not subagent sessions")
|
|
520
551
|
if (actor.kind === "session" && actor.sessionID === target.sessionID) throw invalid("A session never prompts itself")
|
|
521
552
|
}
|
|
553
|
+
// A prompt commits a staged revert (OpenCode, quirk 9budy9na): only the console's Send (or Restore) ends one.
|
|
554
|
+
// Checked after the grant (an agent without one learns nothing about the session) and audited like any refusal.
|
|
555
|
+
const notStaged = () => {
|
|
556
|
+
if ((this.deps.registry.session(target.sessionID) as { revert?: unknown } | undefined)?.revert) {
|
|
557
|
+
throw new AgentError("conflict", "A revert is staged in this session: a prompt would make it permanent", { hint: "Restore it or send from the session's console first" })
|
|
558
|
+
}
|
|
559
|
+
}
|
|
522
560
|
return this.act(caller, "prompt", target, {}, input.why, {
|
|
561
|
+
check: notStaged,
|
|
523
562
|
run: async (grant) => {
|
|
524
|
-
if (!grant)
|
|
563
|
+
if (!grant) {
|
|
564
|
+
notStaged()
|
|
565
|
+
return this.userPrompt(target.sessionID, text, images, input.intent)
|
|
566
|
+
}
|
|
525
567
|
return this.agentPrompt(caller, addr, target.sessionID, text, grant, input.delivery)
|
|
526
568
|
},
|
|
527
569
|
})
|
|
@@ -584,6 +626,8 @@ export class Acting {
|
|
|
584
626
|
plan: {
|
|
585
627
|
/** A session never acts in its own tree (permissions and forms). */
|
|
586
628
|
readonly selfTree?: boolean
|
|
629
|
+
/** (R-03) No usable grant is not forbidden: `choose` gets none and throws NeedsUser (or forbidden). */
|
|
630
|
+
readonly withoutGrant?: () => Promise<boolean>
|
|
587
631
|
/** The grant to act under (default: the newest usable); throws NeedsUser or forbidden. */
|
|
588
632
|
readonly choose?: (usable: Grant[]) => Grant
|
|
589
633
|
readonly escalation?: (evaluation: Evaluation) => Omit<Escalation, "userID" | "actor">
|
|
@@ -610,7 +654,7 @@ export class Acting {
|
|
|
610
654
|
acting.ensureActing(userID)
|
|
611
655
|
await this.guard(userID, target)
|
|
612
656
|
const usable = await grants.usable(actor, verb, { directory: target.directory })
|
|
613
|
-
if (!usable.length) throw forbidden(`No grant lets this ${actor.kind} ${verb} in this session`)
|
|
657
|
+
if (!usable.length && !(plan.choose && plan.withoutGrant && (await plan.withoutGrant()))) throw forbidden(`No grant lets this ${actor.kind} ${verb} in this session`)
|
|
614
658
|
if (plan.selfTree && actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) {
|
|
615
659
|
throw forbidden("A session never answers requests in its own session tree")
|
|
616
660
|
}
|
|
@@ -670,24 +714,32 @@ export class Acting {
|
|
|
670
714
|
}
|
|
671
715
|
}
|
|
672
716
|
|
|
717
|
+
/** (R-03 verify) The actor is the user's current Concierge session; a failed read (or no reader) is no. */
|
|
718
|
+
private async isCurrentConcierge(caller: Caller): Promise<boolean> {
|
|
719
|
+
const { actor } = caller
|
|
720
|
+
if (actor.kind !== "session" || !this.deps.currentConcierge) return false
|
|
721
|
+
try {
|
|
722
|
+
return await this.deps.currentConcierge(caller.userID, actor.sessionID)
|
|
723
|
+
} catch {
|
|
724
|
+
return false
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
673
728
|
/**
|
|
674
729
|
* The grant that covers the request, after every never rule; NeedsUser when none does. (CON-13 decision)
|
|
675
730
|
* `concierge`: a Concierge actor's "always", or a covered request of the tap risk tier, is NeedsUser at tap tier.
|
|
731
|
+
* (R-03 verify) Every never rule (the hub's and each usable grant's, also with no grant) is checked first, before
|
|
732
|
+
* any NeedsUser: a forbidden request never becomes a Decision, "always" or not.
|
|
676
733
|
*/
|
|
677
734
|
private choosePermission(usable: Grant[], request: PermissionRequest | undefined, always: boolean, concierge = false): Grant {
|
|
678
735
|
const hubNever = this.deps.acting.hubNever()
|
|
679
736
|
const ids = usable.map((g) => g.id)
|
|
737
|
+
const rules = usable.map((g) => ({ grant: g, allow: patterns(g.permissions.allow), never: patterns(g.permissions.never) }))
|
|
738
|
+
if (request) checkNever(request, rules, hubNever, always)
|
|
680
739
|
if (concierge && always) throw new NeedsUser({ verdict: "outside", reason: CONCIERGE_ALWAYS_REASON, tier: "tap", grants: ids })
|
|
681
740
|
if (!request) throw new NeedsUser({ verdict: "outside", reason: "The request's details are unknown", tier: "tap", grants: ids })
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
for (const { grant, allow, never } of rules) {
|
|
685
|
-
const verdict = evaluatePermission(form, { grant: { allow, never }, hubNever, always, allowAlways: grant.allowAlways })
|
|
686
|
-
if (verdict.verdict === "never") {
|
|
687
|
-
throw forbidden(`never: ${verdict.by === "hub" ? "the hub's" : `grant ${grant.id}'s`} rule ${formatPattern(verdict.pattern)} matches ${verdict.subject}`)
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
}
|
|
741
|
+
// (R-03) The current Concierge holds no grant: anything the never rules let through is the user's, at the hub's tier.
|
|
742
|
+
if (!usable.length) throw new NeedsUser({ verdict: "outside", reason: CONCIERGE_NO_GRANT_REASON, tier: request.truncated ? "tap" : riskTier(request, hubNever), grants: [] })
|
|
691
743
|
const allNever = [...hubNever, ...rules.flatMap((r) => r.never)]
|
|
692
744
|
if (request.truncated) throw new NeedsUser({ verdict: "outside", reason: "The request was too large to check in full", tier: "tap", grants: ids })
|
|
693
745
|
let reason: string | undefined
|
|
@@ -790,6 +842,12 @@ export function auditOf(cause: unknown): [AuditDecision, AuditOutcome] {
|
|
|
790
842
|
return ["forbidden", "refused"]
|
|
791
843
|
case "rate_limited":
|
|
792
844
|
return ["rate_limited", "refused"]
|
|
845
|
+
// Allowed, but the session's state refuses it now (a staged revert).
|
|
846
|
+
case "conflict":
|
|
847
|
+
return ["allow", "refused"]
|
|
848
|
+
// (FX-07) The hub's state did not answer in time: refused, and Activity says the hub was busy.
|
|
849
|
+
case "busy":
|
|
850
|
+
return ["busy", "refused"]
|
|
793
851
|
case "needs_user":
|
|
794
852
|
return ["needs_user", "refused"]
|
|
795
853
|
case "gone":
|
|
@@ -29,7 +29,7 @@ export const AUDIT_PAGE_DEFAULT = 100
|
|
|
29
29
|
export const AUDIT_PAGE_MAX = 500
|
|
30
30
|
|
|
31
31
|
/** The policy's decision: allowed, or why not. */
|
|
32
|
-
export type AuditDecision = "allow" | "needs_user" | "forbidden" | "paused" | "rate_limited"
|
|
32
|
+
export type AuditDecision = "allow" | "needs_user" | "forbidden" | "paused" | "rate_limited" | "busy"
|
|
33
33
|
/** What happened: done, failed, raced (gone), the spoke could not (offline, unsupported), escalated or refused. */
|
|
34
34
|
export type AuditOutcome = "ok" | "failed" | "gone" | "offline" | "unsupported" | "escalated" | "refused"
|
|
35
35
|
|
|
@@ -106,6 +106,8 @@ export interface ActAction {
|
|
|
106
106
|
readonly target: string
|
|
107
107
|
/** The verb's validated input (requestID, formID, answer, text). */
|
|
108
108
|
readonly input: Readonly<Record<string, unknown>>
|
|
109
|
+
/** (FX-05) deny: the permission it would deny, for the console's card. */
|
|
110
|
+
readonly request?: DecisionRequest
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
/** An escalated permission request: the answer replies to it as the user. */
|
|
@@ -118,6 +120,56 @@ export interface PermissionAction {
|
|
|
118
120
|
readonly sessionID: string
|
|
119
121
|
/** The agent asked to approve it always. */
|
|
120
122
|
readonly always: boolean
|
|
123
|
+
/** (FX-05) What it asks, for the console's card (absent when the hub did not know the request's details). */
|
|
124
|
+
readonly request?: DecisionRequest
|
|
125
|
+
/** (FX-05) Why the hub sent it to the user (the evaluation's reason). */
|
|
126
|
+
readonly reason?: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* (FX-05) A permission request as a Decision shows it (bounded): its kind, what it applies to and the patterns an
|
|
131
|
+
* "always" would save. What was not stored is recorded, so a shortened command never reads as complete: the totals
|
|
132
|
+
* when items were dropped, each cut item with its full length, and `truncated` when the hub received the request
|
|
133
|
+
* already shortened (src/status.ts permissionRequest). The console renders it as text; the Concierge's Decision reads
|
|
134
|
+
* leave it out (concierge/board-routes.ts conciergeDecisionView).
|
|
135
|
+
*/
|
|
136
|
+
export interface DecisionRequest {
|
|
137
|
+
readonly action: string
|
|
138
|
+
readonly resources: readonly string[]
|
|
139
|
+
readonly save?: readonly string[]
|
|
140
|
+
/** How many resources the request had, when more than were stored. */
|
|
141
|
+
readonly resourcesTotal?: number
|
|
142
|
+
/** How many save patterns it had, when more than were stored. */
|
|
143
|
+
readonly savesTotal?: number
|
|
144
|
+
/** Stored items cut at DECISION_REQUEST_TEXT characters, with their full length. */
|
|
145
|
+
readonly cut?: readonly { readonly field: "resources" | "save"; readonly index: number; readonly length: number }[]
|
|
146
|
+
/** The hub received the request already shortened. */
|
|
147
|
+
readonly truncated?: true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Resources (and save patterns) a Decision keeps of a request, and the characters of each. */
|
|
151
|
+
export const DECISION_REQUEST_ITEMS = 20
|
|
152
|
+
export const DECISION_REQUEST_TEXT = 2_000
|
|
153
|
+
|
|
154
|
+
/** A permission request, bounded for a Decision, with what was not stored. */
|
|
155
|
+
export function decisionRequest(request: { readonly action: string; readonly resources: readonly string[]; readonly save?: readonly string[]; readonly truncated?: true }): DecisionRequest {
|
|
156
|
+
const cut: { field: "resources" | "save"; index: number; length: number }[] = []
|
|
157
|
+
const bound = (field: "resources" | "save", list: readonly string[]) =>
|
|
158
|
+
list.slice(0, DECISION_REQUEST_ITEMS).map((r, index) => {
|
|
159
|
+
if (r.length > DECISION_REQUEST_TEXT) cut.push({ field, index, length: r.length })
|
|
160
|
+
return r.slice(0, DECISION_REQUEST_TEXT)
|
|
161
|
+
})
|
|
162
|
+
const resources = bound("resources", request.resources)
|
|
163
|
+
const save = request.save?.length ? bound("save", request.save) : undefined
|
|
164
|
+
return {
|
|
165
|
+
action: request.action.slice(0, DECISION_REQUEST_TEXT),
|
|
166
|
+
resources,
|
|
167
|
+
...(save ? { save } : {}),
|
|
168
|
+
...(request.resources.length > resources.length ? { resourcesTotal: request.resources.length } : {}),
|
|
169
|
+
...(save && request.save!.length > save.length ? { savesTotal: request.save!.length } : {}),
|
|
170
|
+
...(cut.length ? { cut } : {}),
|
|
171
|
+
...(request.truncated ? { truncated: true as const } : {}),
|
|
172
|
+
}
|
|
121
173
|
}
|
|
122
174
|
|
|
123
175
|
/** A grant an agent requested (grant-requests.ts): approving creates it as the user. */
|
|
@@ -457,11 +509,15 @@ export class Decisions {
|
|
|
457
509
|
|
|
458
510
|
// ---- internals ----------------------------------------------------------------------------------------
|
|
459
511
|
|
|
460
|
-
/**
|
|
512
|
+
/**
|
|
513
|
+
* Whether the session is one of the user's Concierge sessions; a failed read is no label (never fails the ask).
|
|
514
|
+
* (FX-07) A read that timed out (`busy`) refuses the ask instead: the label could not be checked (fail closed).
|
|
515
|
+
*/
|
|
461
516
|
private async isConcierge(userID: string, sessionID: string): Promise<boolean> {
|
|
462
517
|
try {
|
|
463
518
|
return (await this.deps.concierge?.(userID, sessionID)) === true
|
|
464
|
-
} catch {
|
|
519
|
+
} catch (cause) {
|
|
520
|
+
if (cause instanceof AgentError && cause.code === "busy") throw cause
|
|
465
521
|
return false
|
|
466
522
|
}
|
|
467
523
|
}
|
|
@@ -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"
|
|
@@ -61,6 +68,8 @@ export interface DeliveryRegistry {
|
|
|
61
68
|
idle(sessionID: string): boolean
|
|
62
69
|
/** Higher: more recent activity. */
|
|
63
70
|
recency(sessionID: string): number
|
|
71
|
+
/** A revert is staged in the session (Registry.revertStaged): nothing is sent to it (absent: never). */
|
|
72
|
+
revertStaged?(sessionID: string): boolean
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
/** Address resolution (Directory.resolve) and the primer flag (without primed/markPrimed: no primer). */
|
|
@@ -80,8 +89,8 @@ export type DeliverSend = (connID: string, req: DeliverRequest) => Promise<Deliv
|
|
|
80
89
|
* ask stayed unanswered by an idle recipient after every re-wake.
|
|
81
90
|
*/
|
|
82
91
|
export interface DeliveryAlert {
|
|
83
|
-
/** user: a session or app sent the user (@me) a message. */
|
|
84
|
-
readonly kind: "hopCap" | "unanswered" | "user"
|
|
92
|
+
/** user: a session or app sent the user (@me) a message; expired: the user's message stayed pending for 24 h. */
|
|
93
|
+
readonly kind: "hopCap" | "unanswered" | "user" | "expired"
|
|
85
94
|
/** The recipient session (user: the sending session, "" for an app). */
|
|
86
95
|
readonly sessionID: string
|
|
87
96
|
readonly messageID: string
|
|
@@ -113,8 +122,12 @@ export interface DeliveryDeps {
|
|
|
113
122
|
/** Labels of a session sender (default: its @name from the directory). */
|
|
114
123
|
readonly describe?: (userID: string, sessionID: string) => PartyInfo
|
|
115
124
|
readonly alert?: (userID: string, alert: DeliveryAlert) => void
|
|
125
|
+
/** A pending message expired undelivered (its sender was told): the hub journals the change. Never throws through. */
|
|
126
|
+
readonly expired?: (userID: string, message: Message) => void
|
|
116
127
|
/** Limiter caps (default PAIR_LIMIT, USER_LIMIT, WAKE_BUDGET). */
|
|
117
128
|
readonly limits?: Partial<MessageLimits>
|
|
129
|
+
/** How long what an app received from a session raises its new messages (message-limits RELAY_WINDOW_MS). */
|
|
130
|
+
readonly relayWindowMs?: number
|
|
118
131
|
/** The users the re-wake loop looks at (every user of the hub; default: the users seen by this process since start). */
|
|
119
132
|
readonly users?: () => Iterable<string> | Promise<Iterable<string>>
|
|
120
133
|
/** Cluster mode: a pending flush holds a lease per (user, session), so two pods never deliver the same message. */
|
|
@@ -136,6 +149,11 @@ export interface SendOptions {
|
|
|
136
149
|
readonly urgent?: boolean
|
|
137
150
|
/** Asks only. */
|
|
138
151
|
readonly expiresInMs?: number
|
|
152
|
+
/**
|
|
153
|
+
* Hub-internal, never from a request body: the message continues a chain at least this deep (its hop is the
|
|
154
|
+
* larger of its own and this). Team intake passes the submitting session's depth + 1.
|
|
155
|
+
*/
|
|
156
|
+
readonly chainHop?: number
|
|
139
157
|
}
|
|
140
158
|
|
|
141
159
|
export interface Sent {
|
|
@@ -144,6 +162,8 @@ export interface Sent {
|
|
|
144
162
|
readonly delivery?: DeliveryState
|
|
145
163
|
/** Whether it was sent to wake its recipient (false: wake:false, a subagent, the hop cap or a spent wake budget). */
|
|
146
164
|
readonly woke?: boolean
|
|
165
|
+
/** Why it was not (only when woke is false). */
|
|
166
|
+
readonly wakeSkipped?: WakeSkip
|
|
147
167
|
}
|
|
148
168
|
|
|
149
169
|
/** A reply or decline as sent, with the message it answered. */
|
|
@@ -247,7 +267,7 @@ export class Delivery {
|
|
|
247
267
|
this.newID = deps.newID ?? newMessageID
|
|
248
268
|
this.owner = deps.owner ?? `delivery-${process.pid}-${randomBytes(4).toString("hex")}`
|
|
249
269
|
this.messages = new Messages({ state: deps.state, now, newID: this.newID })
|
|
250
|
-
this.hops = new HopTracker({ state: deps.state, now })
|
|
270
|
+
this.hops = new HopTracker({ state: deps.state, now, ...(deps.relayWindowMs === undefined ? {} : { relayWindowMs: deps.relayWindowMs }) })
|
|
251
271
|
this.limiter = new MessageLimiter({ counters: deps.counters, now, ...(deps.limits ? { limits: deps.limits } : {}) })
|
|
252
272
|
// An explicit (re)name primes again, so the session learns its new address.
|
|
253
273
|
deps.directory.onNamed?.((userID, sessionID) => void this.prime(userID, sessionID, { again: true }))
|
|
@@ -367,6 +387,11 @@ export class Delivery {
|
|
|
367
387
|
return
|
|
368
388
|
}
|
|
369
389
|
for (const userID of users) {
|
|
390
|
+
try {
|
|
391
|
+
await this.expirePending(userID)
|
|
392
|
+
} catch (cause) {
|
|
393
|
+
console.error(`[hub] pending expiry failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
394
|
+
}
|
|
370
395
|
try {
|
|
371
396
|
await this.rewake(userID)
|
|
372
397
|
} catch (cause) {
|
|
@@ -404,7 +429,7 @@ export class Delivery {
|
|
|
404
429
|
const from = this.party(userID, actor)
|
|
405
430
|
const recipient: Party = { kind: "user" }
|
|
406
431
|
await this.limiter.admit(userID, from, recipient)
|
|
407
|
-
const hop = await this.hops.next(userID, from)
|
|
432
|
+
const hop = await this.hops.next(userID, from, { to: recipient })
|
|
408
433
|
const message = await this.messages.send(userID, {
|
|
409
434
|
from,
|
|
410
435
|
to: USER_ADDRESS,
|
|
@@ -428,7 +453,7 @@ export class Delivery {
|
|
|
428
453
|
await this.deps.inbound?.admit(userID, from, sessionID, kind)
|
|
429
454
|
this.gate(userID, sessionID)
|
|
430
455
|
await this.limiter.admit(userID, from, recipient)
|
|
431
|
-
const hop = await this.hops.next(userID, from)
|
|
456
|
+
const hop = Math.max(await this.hops.next(userID, from, { to: recipient }), options.chainHop ?? 0)
|
|
432
457
|
const generation = resolved.address.kind === "role" && !("team" in resolved) ? roleGeneration(entry, resolved.address) : undefined
|
|
433
458
|
const message = await this.messages.send(userID, {
|
|
434
459
|
from,
|
|
@@ -440,15 +465,15 @@ export class Delivery {
|
|
|
440
465
|
...(generation === undefined ? {} : { generation }),
|
|
441
466
|
...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
|
|
442
467
|
})
|
|
443
|
-
await this.hops.received(userID, recipient, hop)
|
|
468
|
+
await this.hops.received(userID, recipient, hop, from)
|
|
444
469
|
this.users.add(userID)
|
|
445
|
-
const
|
|
446
|
-
const
|
|
447
|
-
if (kind === "ask" &&
|
|
470
|
+
const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : entry.parentID !== undefined ? "subagent" : undefined
|
|
471
|
+
const skipped = await this.wakeFor(userID, message, unwanted)
|
|
472
|
+
if (kind === "ask" && unwanted === undefined && !overHopCap(hop)) {
|
|
448
473
|
const rewake: Rewake = { messageID: message.id, sessionID, reminders: 0, nextAt: message.at + backoff(0) }
|
|
449
474
|
await this.deps.state.put(userID, REWAKE, message.id, rewake)
|
|
450
475
|
}
|
|
451
|
-
return this.deliver(userID, message, sessionID,
|
|
476
|
+
return this.deliver(userID, message, sessionID, skipped, options.urgent === true)
|
|
452
477
|
}
|
|
453
478
|
|
|
454
479
|
private schedule(): void {
|
|
@@ -555,33 +580,38 @@ export class Delivery {
|
|
|
555
580
|
}
|
|
556
581
|
if (to && original) await this.deps.teams?.answer(userID, from, original, to, kind, this.limiter)
|
|
557
582
|
if (to) await this.limiter.admit(userID, from, to)
|
|
558
|
-
const hop = await this.hops.next(userID, from)
|
|
583
|
+
const hop = await this.hops.next(userID, from, original ? { answering: original, ...(to ? { to } : {}) } : {})
|
|
559
584
|
const generation = original && actor.kind === "session" ? this.holderGeneration(userID, actor.sessionID, original) : undefined
|
|
560
585
|
const answered = await store({ from, replyTo, hop, ...(generation === undefined ? {} : { generation }) })
|
|
561
586
|
const { message } = answered
|
|
562
|
-
await this.hops.received(userID, message.recipient, hop)
|
|
587
|
+
await this.hops.received(userID, message.recipient, hop, from)
|
|
563
588
|
this.users.add(userID)
|
|
564
589
|
if (message.recipient.kind !== "session") return answered
|
|
565
590
|
const sessionID = message.recipient.sessionID
|
|
566
|
-
const
|
|
567
|
-
|
|
591
|
+
const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : this.isSubagent(userID, sessionID) ? "subagent" : undefined
|
|
592
|
+
const skipped = await this.wakeFor(userID, message, unwanted)
|
|
593
|
+
return { ...(await this.deliver(userID, message, sessionID, skipped, true)), original: answered.original }
|
|
568
594
|
}
|
|
569
595
|
|
|
570
|
-
/**
|
|
571
|
-
|
|
572
|
-
|
|
596
|
+
/**
|
|
597
|
+
* Why the message may not start a turn (undefined: it may): `unwanted` (wake:false or a subagent recipient), over
|
|
598
|
+
* the hop cap (alerting once at the cap), or no wake budget left.
|
|
599
|
+
*/
|
|
600
|
+
private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined): Promise<WakeSkip | undefined> {
|
|
601
|
+
if (unwanted) return unwanted
|
|
602
|
+
if (message.recipient.kind !== "session") return "wake_false"
|
|
573
603
|
if (overHopCap(message.hop)) {
|
|
574
604
|
if (message.hop === HOP_CAP + 1) {
|
|
575
605
|
this.deps.alert?.(userID, {
|
|
576
606
|
kind: "hopCap",
|
|
577
607
|
sessionID: message.recipient.sessionID,
|
|
578
608
|
messageID: message.id,
|
|
579
|
-
text: `
|
|
609
|
+
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
610
|
})
|
|
581
611
|
}
|
|
582
|
-
return
|
|
612
|
+
return "hop_cap"
|
|
583
613
|
}
|
|
584
|
-
return this.limiter.wake(userID, message.recipient)
|
|
614
|
+
return (await this.limiter.wake(userID, message.recipient)) ? undefined : "budget"
|
|
585
615
|
}
|
|
586
616
|
|
|
587
617
|
/** The replier's generation of the role the original reached (undefined: not a role, or not held now). */
|
|
@@ -619,16 +649,18 @@ export class Delivery {
|
|
|
619
649
|
}
|
|
620
650
|
|
|
621
651
|
/** Delivers now, or queues the message as pending when no spoke takes it. */
|
|
622
|
-
private async deliver(userID: string, message: Message, sessionID: string,
|
|
652
|
+
private async deliver(userID: string, message: Message, sessionID: string, skipped: WakeSkip | undefined, urgent: boolean): Promise<Sent> {
|
|
653
|
+
const wake = skipped === undefined
|
|
654
|
+
const woke = wake ? { woke: true } : { woke: false, wakeSkipped: skipped }
|
|
623
655
|
// Messages still pending for this session go first (e.g. a quiet one a busy spoke refused, RF-05): the turn this
|
|
624
656
|
// one starts then carries them, instead of them arriving inside it.
|
|
625
657
|
if (wake && (await this.deps.state.list<Pending>(userID, PENDING)).some((p) => p.value.sessionID === sessionID)) await this.flush(userID, sessionID)
|
|
626
658
|
const delivered = await this.attempt(userID, message, sessionID, wake, urgent)
|
|
627
|
-
if (delivered) return { ...delivered, woke
|
|
659
|
+
if (delivered) return { ...delivered, ...woke }
|
|
628
660
|
const at = this.now()
|
|
629
661
|
const pending: Pending = { messageID: message.id, sessionID, wake, urgent, at, expiresAt: at + PENDING_MS }
|
|
630
662
|
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
|
|
663
|
+
return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending", ...woke }
|
|
632
664
|
}
|
|
633
665
|
|
|
634
666
|
/** Tries each deliver-capable connection in turn (same message id: the harness dedupes it). */
|
|
@@ -643,8 +675,13 @@ export class Delivery {
|
|
|
643
675
|
return { message: updated, delivery: result.delivered }
|
|
644
676
|
}
|
|
645
677
|
|
|
646
|
-
/**
|
|
678
|
+
/**
|
|
679
|
+
* Sends the request to each of the user's deliver-capable connections for the session until one takes it. Not at
|
|
680
|
+
* all while a revert is staged there: admitting anything commits it (decision in docs/session-view.md), so the
|
|
681
|
+
* message stays pending and goes in on the sessions frame that shows the revert restored or committed.
|
|
682
|
+
*/
|
|
647
683
|
private async request(userID: string, sessionID: string, req: DeliverRequest): Promise<DeliverResult | undefined> {
|
|
684
|
+
if (this.deps.registry.revertStaged?.(sessionID)) return undefined
|
|
648
685
|
for (const connID of this.targets(userID, sessionID)) {
|
|
649
686
|
let result: unknown
|
|
650
687
|
try {
|
|
@@ -671,7 +708,11 @@ export class Delivery {
|
|
|
671
708
|
this.flushStats.skipped++
|
|
672
709
|
continue
|
|
673
710
|
}
|
|
674
|
-
|
|
711
|
+
if (value.expiresAt <= this.now()) {
|
|
712
|
+
await this.expireEntry(userID, key, value, version)
|
|
713
|
+
continue
|
|
714
|
+
}
|
|
715
|
+
const message = await this.messages.get(userID, value.messageID)
|
|
675
716
|
// Gone, or an ask that closed (expired) while it waited: nothing to deliver.
|
|
676
717
|
if (!message || (message.kind === "ask" && message.state !== "open")) {
|
|
677
718
|
await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })
|
|
@@ -695,6 +736,48 @@ export class Delivery {
|
|
|
695
736
|
}
|
|
696
737
|
}
|
|
697
738
|
|
|
739
|
+
/** The loop's sweep: the user's messages pending past PENDING_MS expire (their senders are told). */
|
|
740
|
+
private async expirePending(userID: string): Promise<void> {
|
|
741
|
+
const t = this.now()
|
|
742
|
+
for (const { key, value, version } of await this.deps.state.list<Pending>(userID, PENDING)) {
|
|
743
|
+
if (value.expiresAt <= t) await this.expireEntry(userID, key, value, version)
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* A pending message that waited PENDING_MS: removed (once: version-checked), marked "expired", and its sender
|
|
749
|
+
* told: a session by a quiet notice (wake:false, never the message text), the user by an alert; the change is
|
|
750
|
+
* journaled (deps.expired) for every sender, an app included. An ask that already closed is dropped silently.
|
|
751
|
+
*/
|
|
752
|
+
private async expireEntry(userID: string, key: string, value: Pending, version: number): Promise<void> {
|
|
753
|
+
if (!(await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })).ok) return
|
|
754
|
+
const current = await this.messages.get(userID, value.messageID)
|
|
755
|
+
if (!current || (current.kind === "ask" && current.state !== "open" && current.state !== "expired")) return
|
|
756
|
+
const message = (await this.messages.setDelivery(userID, current.id, "expired")) ?? current
|
|
757
|
+
const what = `${message.kind} ${message.id} to ${message.to}`
|
|
758
|
+
// Why it waited: a staged revert holds every delivery (request()), else the session was offline or busy.
|
|
759
|
+
const why = this.deps.registry.revertStaged?.(value.sessionID) ? "a revert stayed staged in its session for 24 h" : "its session stayed offline or busy for 24 h"
|
|
760
|
+
try {
|
|
761
|
+
this.deps.expired?.(userID, message)
|
|
762
|
+
} catch (cause) {
|
|
763
|
+
console.error(`[hub] pending expiry journal failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
764
|
+
}
|
|
765
|
+
const { from } = message
|
|
766
|
+
if (from.kind === "user") {
|
|
767
|
+
this.deps.alert?.(userID, { kind: "expired", sessionID: value.sessionID, messageID: message.id, text: `Your ${message.kind} to ${message.to} was not delivered: ${why}` })
|
|
768
|
+
} else if (from.kind === "session") {
|
|
769
|
+
const id = this.newID()
|
|
770
|
+
await this.request(userID, from.sessionID, {
|
|
771
|
+
op: "deliver",
|
|
772
|
+
sessionID: from.sessionID,
|
|
773
|
+
messageID: id,
|
|
774
|
+
text: `[hub] notice · ${what} was not delivered: ${why} · ${id}`,
|
|
775
|
+
wake: false,
|
|
776
|
+
meta: { kind: "msg", from: "hub", to: wireAddress(from), delivery: "queue" },
|
|
777
|
+
})
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
698
781
|
/** Takes the (user, session) flush lease once per flush (always true without locks); false: another process flushes it. */
|
|
699
782
|
private async lease(userID: string, sessionID: string, leases: Map<string, boolean>): Promise<boolean> {
|
|
700
783
|
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
|