@pinet/broker-core 0.2.2 → 0.2.6

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.
@@ -1,11 +1,33 @@
1
1
  import type { AgentInfo, BrokerMessage } from "./types.js";
2
+ type AgentMessageMetadataObject = Record<string, unknown>;
3
+ interface AgentCapabilities {
4
+ repo?: string;
5
+ role?: string;
6
+ tools?: string[];
7
+ tags?: string[];
8
+ }
9
+ export interface AgentMessageMetadata extends AgentMessageMetadataObject {
10
+ senderAgent?: string;
11
+ a2a?: boolean;
12
+ broadcast?: boolean;
13
+ broadcastChannel?: string;
14
+ trustedBrokerAgentId?: string;
15
+ emergency?: boolean;
16
+ targetScope?: string;
17
+ capabilities?: AgentCapabilities;
18
+ repo?: string;
19
+ role?: string;
20
+ broadcastChannels?: string[];
21
+ channels?: string[];
22
+ topics?: string[];
23
+ }
2
24
  export interface AgentMessageStorage {
3
25
  getAgents(): AgentInfo[];
4
26
  getThread(threadId: string): {
5
27
  threadId: string;
6
28
  } | null;
7
29
  createThread(threadId: string, source: string, channel: string, ownerAgent: string | null): void;
8
- insertMessage(threadId: string, source: string, direction: "inbound" | "outbound", sender: string, body: string, targetAgentIds: string[], metadata?: Record<string, unknown>): BrokerMessage;
30
+ insertMessage(threadId: string, source: string, direction: "inbound" | "outbound", sender: string, body: string, targetAgentIds: string[], metadata?: AgentMessageMetadata): BrokerMessage;
9
31
  }
10
32
  export interface AgentDispatchTarget {
11
33
  id: string;
@@ -16,7 +38,7 @@ export interface DirectAgentDispatchInput {
16
38
  senderAgentName: string;
17
39
  target: string;
18
40
  body: string;
19
- metadata?: Record<string, unknown>;
41
+ metadata?: AgentMessageMetadata;
20
42
  trustedBrokerAgentId?: string;
21
43
  }
22
44
  export interface BroadcastAgentDispatchInput {
@@ -24,7 +46,7 @@ export interface BroadcastAgentDispatchInput {
24
46
  senderAgentName: string;
25
47
  channel: string;
26
48
  body: string;
27
- metadata?: Record<string, unknown>;
49
+ metadata?: AgentMessageMetadata;
28
50
  }
29
51
  export interface DirectAgentDispatchResult {
30
52
  target: AgentDispatchTarget;
@@ -37,7 +59,7 @@ export interface BroadcastAgentDispatchResult {
37
59
  messageIds: number[];
38
60
  threadIds: string[];
39
61
  }
40
- export type AgentDispatchCallback = (target: AgentDispatchTarget, message: BrokerMessage, metadata: Record<string, unknown>) => void;
62
+ export type AgentDispatchCallback = (target: AgentDispatchTarget, message: BrokerMessage, metadata: AgentMessageMetadata) => void;
41
63
  export declare function isBroadcastChannelTarget(target: string): boolean;
42
64
  export declare function normalizeBroadcastChannel(channel: string): string | null;
43
65
  export declare function getAgentBroadcastChannels(agent: Pick<AgentInfo, "metadata">): string[];
@@ -46,3 +68,4 @@ export declare function resolveDirectAgentTarget(agents: AgentInfo[], target: st
46
68
  export declare function resolveBroadcastTargets(agents: AgentInfo[], senderAgentId: string, channel: string): AgentInfo[];
47
69
  export declare function dispatchDirectAgentMessage(storage: AgentMessageStorage, input: DirectAgentDispatchInput, onDispatch?: AgentDispatchCallback): DirectAgentDispatchResult;
48
70
  export declare function dispatchBroadcastAgentMessage(storage: AgentMessageStorage, input: BroadcastAgentDispatchInput, onDispatch?: AgentDispatchCallback): BroadcastAgentDispatchResult;
71
+ export {};
@@ -0,0 +1,123 @@
1
+ import type { AgentLifecycleState } from "./types.js";
2
+ /**
3
+ * Broker-managed hibernate/wake operator commands.
4
+ *
5
+ * These are the safe, default-off command primitives behind `pinet hibernate`
6
+ * and `pinet wake`. They add config-policy, repo-allowlist, and coarse
7
+ * lifecycle gating on top of the authoritative checkpoint/fence/eligibility
8
+ * enforcement in {@link HibernationOrchestrator}. Every result is sanitized:
9
+ * machine `reason` + static human `detail` only, never prompts, message bodies,
10
+ * argv, env values, or filesystem/socket paths.
11
+ */
12
+ export interface HibernationCommandPolicy {
13
+ /** Master switch. Defaults false; false still permits waking hibernated rows. */
14
+ enabled: boolean;
15
+ mode: "observe" | "manual" | "auto";
16
+ /** Positive allowlist of repo identifiers (slug or basename). Empty = none. */
17
+ allowedRepos: string[];
18
+ }
19
+ export interface HibernationCommandRefusal {
20
+ reason: string;
21
+ detail: string;
22
+ retryable: boolean;
23
+ }
24
+ /** Coarse command gate outcome, before any orchestrator execution. */
25
+ export type HibernationCommandGate = {
26
+ outcome: "proceed";
27
+ } | {
28
+ outcome: "noop";
29
+ reason: string;
30
+ detail: string;
31
+ } | {
32
+ outcome: "refused";
33
+ refusal: HibernationCommandRefusal;
34
+ };
35
+ export interface HibernationCommandResult {
36
+ command: "hibernate" | "wake";
37
+ agentId: string;
38
+ /** executed = state changed; noop = already in target state; refused = gated. */
39
+ outcome: "executed" | "noop" | "refused";
40
+ state: AgentLifecycleState | string;
41
+ reason: string;
42
+ detail: string;
43
+ runtimeGeneration?: number | null;
44
+ attempts?: number;
45
+ durationMs?: number;
46
+ retryable?: boolean;
47
+ }
48
+ export interface HibernateCommandGateInput {
49
+ state: AgentLifecycleState;
50
+ repoIdentifier: string | null;
51
+ policy: HibernationCommandPolicy;
52
+ }
53
+ /**
54
+ * Coarse policy/lifecycle gate for a hibernate command. Deep eligibility
55
+ * (policy=never, working, pending inbox, broker-managed metadata) is enforced
56
+ * authoritatively by the orchestrator's prepare/hibernate path.
57
+ */
58
+ export declare function evaluateHibernateCommandGate(input: HibernateCommandGateInput): HibernationCommandGate;
59
+ export interface WakeCommandGateInput {
60
+ state: AgentLifecycleState;
61
+ policy: HibernationCommandPolicy;
62
+ }
63
+ /**
64
+ * Coarse gate for a wake command. Waking a durable hibernation identity is a
65
+ * drain/recovery operation and is permitted even when `enabled=false`, per the
66
+ * safety model (disabling hibernation must never strand hibernated identities).
67
+ */
68
+ export declare function evaluateWakeCommandGate(input: WakeCommandGateInput): HibernationCommandGate;
69
+ export interface HibernateCommandExecutor {
70
+ prepareHibernation(agentId: string, opts?: {
71
+ reason?: string;
72
+ actor?: string;
73
+ correlationId?: string;
74
+ }): {
75
+ ready: boolean;
76
+ state: string;
77
+ reason: string;
78
+ };
79
+ hibernate(agentId: string, opts?: {
80
+ reason?: string;
81
+ actor?: string;
82
+ correlationId?: string;
83
+ }): Promise<{
84
+ ok: boolean;
85
+ state: string;
86
+ reason: string;
87
+ durationMs?: number;
88
+ }>;
89
+ }
90
+ export interface WakeCommandExecutor {
91
+ wake(agentId: string, opts?: {
92
+ reason?: string;
93
+ actor?: string;
94
+ correlationId?: string;
95
+ }): Promise<{
96
+ ok: boolean;
97
+ state: string;
98
+ reason: string;
99
+ runtimeGeneration?: number;
100
+ attempts?: number;
101
+ durationMs?: number;
102
+ }>;
103
+ }
104
+ /** Build a sanitized "unknown target" refusal for a command whose target didn't resolve. */
105
+ export declare function unknownHibernationTarget(command: "hibernate" | "wake", target: string): HibernationCommandResult;
106
+ export interface ExecuteHibernateCommandInput extends HibernateCommandGateInput {
107
+ executor: HibernateCommandExecutor;
108
+ agentId: string;
109
+ actor?: string;
110
+ reason?: string;
111
+ correlationId?: string;
112
+ }
113
+ export declare function executeHibernateCommand(input: ExecuteHibernateCommandInput): Promise<HibernationCommandResult>;
114
+ export interface ExecuteWakeCommandInput extends WakeCommandGateInput {
115
+ executor: WakeCommandExecutor;
116
+ agentId: string;
117
+ actor?: string;
118
+ reason?: string;
119
+ correlationId?: string;
120
+ }
121
+ export declare function executeWakeCommand(input: ExecuteWakeCommandInput): Promise<HibernationCommandResult>;
122
+ /** Render a sanitized, compact operator line for a command result. */
123
+ export declare function formatHibernationCommandResult(result: HibernationCommandResult): string;
@@ -0,0 +1,287 @@
1
+ import { fingerprintToken, sanitizeOperatorReason } from "./hibernation-status.js";
2
+ /**
3
+ * Reduce an executor/adapter-produced reason to an operator-safe string.
4
+ *
5
+ * Executor results (e.g. an aborted checkpoint) may fold runtime-authored text
6
+ * into their `reason`, which can carry a filesystem/socket path. Machine reason
7
+ * codes pass through unchanged; anything path-like is redacted. Falls back to a
8
+ * static code so the result always carries a non-empty reason.
9
+ */
10
+ function safeResultReason(reason) {
11
+ return sanitizeOperatorReason(reason) ?? "unspecified";
12
+ }
13
+ function refusal(reason, detail, retryable) {
14
+ return { outcome: "refused", refusal: { reason, detail, retryable } };
15
+ }
16
+ /**
17
+ * Coarse policy/lifecycle gate for a hibernate command. Deep eligibility
18
+ * (policy=never, working, pending inbox, broker-managed metadata) is enforced
19
+ * authoritatively by the orchestrator's prepare/hibernate path.
20
+ */
21
+ export function evaluateHibernateCommandGate(input) {
22
+ const { state, repoIdentifier, policy } = input;
23
+ if (!policy.enabled) {
24
+ return refusal("hibernation_disabled", "Hibernation is disabled (enabled=false). Waking already-hibernated identities is still permitted.", false);
25
+ }
26
+ if (policy.mode === "observe") {
27
+ return refusal("observe_only", "Hibernation is in observe-only mode; no state changes are performed.", false);
28
+ }
29
+ switch (state) {
30
+ case "hibernated":
31
+ case "hibernating":
32
+ return {
33
+ outcome: "noop",
34
+ reason: "already_hibernating",
35
+ detail: "Agent is already hibernated or hibernating.",
36
+ };
37
+ case "waking":
38
+ return refusal("wake_in_progress", "Agent is currently waking; retry once it settles.", true);
39
+ case "reap-candidate":
40
+ return refusal("quarantined", "Agent is quarantined as a reap-candidate and needs manual review, not hibernation.", false);
41
+ case "terminated":
42
+ return refusal("terminated", "Agent is terminated and cannot be hibernated.", false);
43
+ default:
44
+ break;
45
+ }
46
+ // Fail-closed allowlist with EXACT identity matching and NO basename collapse
47
+ // (C2): an allowlist entry must equal the broker-derived repo identifier
48
+ // exactly. A bare "pinet" entry therefore never admits a *different* root
49
+ // that merely shares a basename (e.g. "gugu91/pinet" or "evil/pinet"),
50
+ // and an "owner/repo" slug entry never admits a different owner. Blank
51
+ // identifiers/entries never match. Windows backslash separators are normalized
52
+ // to "/" on both sides so matching is OS-agnostic, and trailing slashes are
53
+ // stripped so "owner/repo/" and "owner/repo" compare equal.
54
+ const repo = (repoIdentifier ?? "").trim().replace(/\\/g, "/").replace(/\/+$/, "");
55
+ const repoAllowlisted = repo.length > 0 &&
56
+ policy.allowedRepos.some((raw) => {
57
+ const entry = raw.trim().replace(/\\/g, "/").replace(/\/+$/, "");
58
+ if (entry.length === 0)
59
+ return false;
60
+ return entry === repo;
61
+ });
62
+ if (!repoAllowlisted) {
63
+ return refusal("repo_not_allowlisted", "Agent's repository is not in the hibernation allowlist.", false);
64
+ }
65
+ return { outcome: "proceed" };
66
+ }
67
+ /**
68
+ * Coarse gate for a wake command. Waking a durable hibernation identity is a
69
+ * drain/recovery operation and is permitted even when `enabled=false`, per the
70
+ * safety model (disabling hibernation must never strand hibernated identities).
71
+ */
72
+ export function evaluateWakeCommandGate(input) {
73
+ switch (input.state) {
74
+ case "hibernated":
75
+ return { outcome: "proceed" };
76
+ case "waking":
77
+ // A wake is already in flight; there is nothing more for this command to
78
+ // do. Surfacing this as a noop (rather than proceeding to an executor
79
+ // that would refuse) keeps the operator signal accurate.
80
+ return {
81
+ outcome: "noop",
82
+ reason: "wake_in_progress",
83
+ detail: "Agent is already waking; the in-flight wake will deliver queued messages.",
84
+ };
85
+ case "live":
86
+ case "active":
87
+ case "grace":
88
+ case "idle":
89
+ return {
90
+ outcome: "noop",
91
+ reason: "already_awake",
92
+ detail: "Agent already has a live runtime; nothing to wake.",
93
+ };
94
+ case "hibernating":
95
+ return refusal("hibernate_in_progress", "Agent is mid-hibernation; retry once it reaches a hibernated state.", true);
96
+ case "reap-candidate":
97
+ return refusal("quarantined", "Agent is quarantined as a reap-candidate and needs manual review, not a wake.", false);
98
+ case "terminated":
99
+ return refusal("terminated", "Agent is terminated and cannot be woken.", false);
100
+ default:
101
+ return refusal("unknown_state", "Agent lifecycle state is not wakeable.", false);
102
+ }
103
+ }
104
+ /** Build a sanitized "unknown target" refusal for a command whose target didn't resolve. */
105
+ export function unknownHibernationTarget(command, target) {
106
+ // The target is arbitrary, unresolved operator input (it may be a paste, a
107
+ // path, or carry secret material). Never echo it — not even redacted. Emit a
108
+ // stable non-reversible fingerprint so an operator can still correlate repeated
109
+ // failures of the *same* input without any content reaching an operator surface.
110
+ const controlStripped = Array.from(target)
111
+ .map((ch) => {
112
+ const code = ch.codePointAt(0) ?? 0;
113
+ return code <= 0x1f || code === 0x7f ? " " : ch;
114
+ })
115
+ .join("")
116
+ .replace(/\s+/g, " ")
117
+ .trim();
118
+ const safeTarget = controlStripped.length > 0 ? `target:#${fingerprintToken(controlStripped)}` : "(unnamed)";
119
+ return {
120
+ command,
121
+ agentId: safeTarget,
122
+ outcome: "refused",
123
+ state: "unknown",
124
+ reason: "unknown_target",
125
+ detail: "No broker-managed agent matched the requested target.",
126
+ retryable: false,
127
+ };
128
+ }
129
+ export async function executeHibernateCommand(input) {
130
+ const gate = evaluateHibernateCommandGate(input);
131
+ if (gate.outcome === "refused") {
132
+ return {
133
+ command: "hibernate",
134
+ agentId: input.agentId,
135
+ outcome: "refused",
136
+ state: input.state,
137
+ reason: gate.refusal.reason,
138
+ detail: gate.refusal.detail,
139
+ retryable: gate.refusal.retryable,
140
+ };
141
+ }
142
+ if (gate.outcome === "noop") {
143
+ return {
144
+ command: "hibernate",
145
+ agentId: input.agentId,
146
+ outcome: "noop",
147
+ state: input.state,
148
+ reason: gate.reason,
149
+ detail: gate.detail,
150
+ };
151
+ }
152
+ const opts = { actor: input.actor, reason: input.reason, correlationId: input.correlationId };
153
+ const prep = input.executor.prepareHibernation(input.agentId, opts);
154
+ if (!prep.ready) {
155
+ return {
156
+ command: "hibernate",
157
+ agentId: input.agentId,
158
+ outcome: "refused",
159
+ state: prep.state,
160
+ reason: prep.reason,
161
+ detail: "Agent is not eligible for hibernation right now.",
162
+ retryable: prep.reason === "agent_working" || prep.reason === "pending_inbox",
163
+ };
164
+ }
165
+ const result = await input.executor.hibernate(input.agentId, opts);
166
+ return {
167
+ command: "hibernate",
168
+ agentId: input.agentId,
169
+ outcome: result.ok ? "executed" : "refused",
170
+ state: result.state,
171
+ reason: safeResultReason(result.reason),
172
+ detail: result.ok
173
+ ? "Agent runtime checkpointed and hibernated."
174
+ : result.state === "reap-candidate"
175
+ ? "Hibernation could not complete cleanly; agent quarantined as reap-candidate for manual review."
176
+ : "Hibernation aborted; the runtime was left running.",
177
+ durationMs: result.durationMs,
178
+ // Quarantine needs manual review, not a blind retry; an abort-to-active is
179
+ // safe to retry once the transient condition clears.
180
+ retryable: result.ok ? undefined : result.state !== "reap-candidate",
181
+ };
182
+ }
183
+ export async function executeWakeCommand(input) {
184
+ const gate = evaluateWakeCommandGate(input);
185
+ if (gate.outcome === "refused") {
186
+ return {
187
+ command: "wake",
188
+ agentId: input.agentId,
189
+ outcome: "refused",
190
+ state: input.state,
191
+ reason: gate.refusal.reason,
192
+ detail: gate.refusal.detail,
193
+ retryable: gate.refusal.retryable,
194
+ };
195
+ }
196
+ if (gate.outcome === "noop") {
197
+ return {
198
+ command: "wake",
199
+ agentId: input.agentId,
200
+ outcome: "noop",
201
+ state: input.state,
202
+ reason: gate.reason,
203
+ detail: gate.detail,
204
+ };
205
+ }
206
+ const result = await input.executor.wake(input.agentId, {
207
+ actor: input.actor,
208
+ reason: input.reason,
209
+ correlationId: input.correlationId,
210
+ });
211
+ if (!result.ok && result.reason === "wake_in_progress") {
212
+ // Another lease owner is already waking this agent. That is the single
213
+ // winner and it will deliver queued work — this is a benign no-op, not a
214
+ // retryable failure (mirrors an already-`waking` target at the gate).
215
+ return {
216
+ command: "wake",
217
+ agentId: input.agentId,
218
+ outcome: "noop",
219
+ state: result.state,
220
+ reason: result.reason,
221
+ detail: "A wake is already in progress for this agent; the in-flight wake will deliver queued work.",
222
+ };
223
+ }
224
+ if (!result.ok && result.reason === "wake_lease_contended") {
225
+ // A non-wake lifecycle lease (e.g. a lingering hibernate around a crash) is
226
+ // transiently holding the agent. Unlike an in-flight wake, nothing will
227
+ // drain the inbox, so this is a distinct *retryable* refusal — the queued
228
+ // trigger is preserved (requeued) rather than consumed as a no-op.
229
+ return {
230
+ command: "wake",
231
+ agentId: input.agentId,
232
+ outcome: "refused",
233
+ state: result.state,
234
+ reason: result.reason,
235
+ detail: "A non-wake lifecycle operation is transiently holding this agent; no wake is in flight. The trigger was requeued — retry shortly.",
236
+ retryable: true,
237
+ };
238
+ }
239
+ // Classify the failure by REASON, not just state. Some non-quarantined
240
+ // failures are terminal: a bare retry cannot change the outcome because the
241
+ // target is not a wakeable hibernated identity, or its durable launch manifest
242
+ // is gone. Marking those retryable would send an operator into a futile retry
243
+ // loop instead of the corrective action (investigate / re-spawn the worker).
244
+ const quarantined = result.state === "reap-candidate";
245
+ const notHibernated = result.reason.startsWith("not_hibernated");
246
+ const terminalReason = quarantined ||
247
+ notHibernated ||
248
+ result.reason === "unknown_agent" ||
249
+ result.reason === "missing_runtime_spec";
250
+ const failDetail = quarantined
251
+ ? "Wake failed and the agent was quarantined as reap-candidate for manual review."
252
+ : result.reason === "missing_runtime_spec"
253
+ ? "Wake failed: no durable runtime manifest exists for this identity, so it cannot be relaunched. Re-spawn the worker instead of retrying."
254
+ : result.reason === "unknown_agent"
255
+ ? "No such agent is known to the broker; there is nothing to wake."
256
+ : notHibernated
257
+ ? `Agent is not in a wakeable hibernated state (${result.state}); nothing to wake.`
258
+ : "Wake failed; agent left in a safe state — a retry may succeed.";
259
+ return {
260
+ command: "wake",
261
+ agentId: input.agentId,
262
+ outcome: result.ok ? "executed" : "refused",
263
+ state: result.state,
264
+ reason: safeResultReason(result.reason),
265
+ detail: result.ok ? "Agent runtime woken; queued messages will drain in order." : failDetail,
266
+ runtimeGeneration: result.ok ? (result.runtimeGeneration ?? null) : null,
267
+ attempts: result.attempts,
268
+ durationMs: result.durationMs,
269
+ // Quarantine and terminal-reason failures need operator action, not a retry;
270
+ // only genuinely transient safe-state failures are retryable.
271
+ retryable: result.ok ? undefined : !terminalReason,
272
+ };
273
+ }
274
+ /** Render a sanitized, compact operator line for a command result. */
275
+ export function formatHibernationCommandResult(result) {
276
+ const marker = result.outcome === "executed" ? "\u2713" : result.outcome === "noop" ? "\u2014" : "\u2717";
277
+ const parts = [
278
+ `${marker} ${result.command} ${result.agentId}: ${result.outcome} (${result.reason})`,
279
+ ];
280
+ parts.push(` ${result.detail}`);
281
+ parts.push(` state=${result.state}`);
282
+ if (result.runtimeGeneration != null)
283
+ parts.push(` runtime_generation=${result.runtimeGeneration}`);
284
+ if (result.attempts != null)
285
+ parts.push(` attempts=${result.attempts}`);
286
+ return parts.join("\n");
287
+ }