@pinet/slack-bridge 0.2.4 → 0.2.7
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/README.md +26 -1
- package/dist/activity-log.d.ts +10 -3
- package/dist/agent-event-runtime.d.ts +0 -2
- package/dist/agent-event-runtime.js +0 -1
- package/dist/agent-prompt-guidance.d.ts +1 -6
- package/dist/agent-prompt-guidance.js +1 -6
- package/dist/broker/adapters/slack.d.ts +4 -1
- package/dist/broker/adapters/slack.js +16 -9
- package/dist/broker/client.d.ts +21 -4
- package/dist/broker/client.js +65 -25
- package/dist/broker/hibernation-activation-authority.d.ts +22 -0
- package/dist/broker/hibernation-activation-authority.js +51 -0
- package/dist/broker/hibernation-activation.d.ts +75 -0
- package/dist/broker/hibernation-activation.js +95 -0
- package/dist/broker/hibernation-command-router.d.ts +54 -0
- package/dist/broker/hibernation-command-router.js +124 -0
- package/dist/broker/hibernation-runtime-adapters.d.ts +79 -0
- package/dist/broker/hibernation-runtime-adapters.js +473 -0
- package/dist/broker/hibernation-runtime-helpers.d.ts +146 -0
- package/dist/broker/hibernation-runtime-helpers.js +243 -0
- package/dist/broker/index.d.ts +16 -1
- package/dist/broker/index.js +68 -10
- package/dist/broker/lock-conflict.d.ts +109 -0
- package/dist/broker/lock-conflict.js +409 -0
- package/dist/broker/socket-server.d.ts +25 -0
- package/dist/broker/socket-server.js +234 -4
- package/dist/broker-delivery.d.ts +1 -0
- package/dist/broker-delivery.js +10 -1
- package/dist/broker-runtime.d.ts +14 -2
- package/dist/broker-runtime.js +114 -3
- package/dist/canvases.d.ts +12 -2
- package/dist/canvases.js +1 -3
- package/dist/compaction-gate.d.ts +7 -0
- package/dist/compaction-gate.js +35 -0
- package/dist/deploy-manifest.d.ts +8 -2
- package/dist/deploy-manifest.js +9 -7
- package/dist/follower-runtime.d.ts +5 -2
- package/dist/follower-runtime.js +39 -5
- package/dist/git-metadata.d.ts +5 -9
- package/dist/git-metadata.js +23 -42
- package/dist/guardrails.js +5 -0
- package/dist/helpers.d.ts +77 -27
- package/dist/helpers.js +150 -39
- package/dist/hibernation-config.d.ts +13 -0
- package/dist/hibernation-config.js +14 -0
- package/dist/home-tab.d.ts +13 -2
- package/dist/imessage-tools.d.ts +4 -1
- package/dist/inbox-drain-runtime.d.ts +1 -0
- package/dist/inbox-drain-runtime.js +5 -1
- package/dist/index.js +340 -108
- package/dist/pinet-commands.d.ts +14 -1
- package/dist/pinet-commands.js +93 -22
- package/dist/pinet-mesh-ops.d.ts +6 -4
- package/dist/pinet-mesh-ops.js +8 -1
- package/dist/pinet-runtime-composition.d.ts +2 -1
- package/dist/pinet-runtime-composition.js +14 -3
- package/dist/pinet-session-formatting.d.ts +26 -2
- package/dist/pinet-session-formatting.js +2 -0
- package/dist/pinet-tools.d.ts +14 -0
- package/dist/pinet-tools.js +355 -50
- package/dist/prompts/broker/default.md +3 -3
- package/dist/prompts/broker/tmux.md +3 -3
- package/dist/runtime-agent-context.d.ts +3 -3
- package/dist/runtime-agent-context.js +12 -6
- package/dist/runtime-mode.js +4 -0
- package/dist/session-ui-runtime.js +3 -1
- package/dist/single-player-runtime.d.ts +1 -1
- package/dist/single-player-runtime.js +21 -5
- package/dist/slack-access.d.ts +10 -2
- package/dist/slack-access.js +64 -22
- package/dist/slack-block-kit.d.ts +44 -2
- package/dist/slack-block-kit.js +5 -5
- package/dist/slack-file-access.d.ts +12 -1
- package/dist/slack-file-access.js +4 -4
- package/dist/slack-message-context.d.ts +9 -2
- package/dist/slack-modals.d.ts +4 -1
- package/dist/slack-modals.js +5 -5
- package/dist/slack-pinet-runtime-adapter.d.ts +1 -0
- package/dist/slack-pinet-runtime-adapter.js +10 -0
- package/dist/slack-presence.d.ts +5 -4
- package/dist/slack-request-runtime.d.ts +3 -2
- package/dist/slack-scope-diagnostics.d.ts +27 -2
- package/dist/slack-socket-dedup.d.ts +8 -5
- package/dist/slack-tools.d.ts +3 -1
- package/dist/slack-tools.js +26 -16
- package/dist/slack-upload.d.ts +17 -1
- package/dist/subtree-broker-runtime.d.ts +83 -2
- package/dist/subtree-broker-runtime.js +774 -109
- package/dist/tool-output-limits.d.ts +11 -0
- package/dist/tool-output-limits.js +20 -0
- package/dist/tool-registration-runtime.d.ts +3 -0
- package/dist/tool-registration-runtime.js +27 -0
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -263,6 +263,7 @@ Main commands:
|
|
|
263
263
|
Coordinator commands:
|
|
264
264
|
|
|
265
265
|
- `/pinet start` or `/pinet broker` - become the broker
|
|
266
|
+
- `/pinet start replace` - take over a stale or stranded broker (graceful shutdown first, then a fenced SIGTERM fallback)
|
|
266
267
|
- `/pinet follow` - become a follower
|
|
267
268
|
- `/pinet unfollow` - disconnect from broker
|
|
268
269
|
- `/pinet reload <agent>` - ask another agent to reload
|
|
@@ -380,6 +381,30 @@ If Pinet does not respond:
|
|
|
380
381
|
4. Look in the log channel for errors
|
|
381
382
|
5. Try `/pinet status` to check if Pinet is running
|
|
382
383
|
|
|
384
|
+
### Stranded broker (lock held, controlling session lost)
|
|
385
|
+
|
|
386
|
+
If `/pinet start` reports that another broker is already running but you no
|
|
387
|
+
longer have that broker's Pi session (crash, laptop restart, stalled process):
|
|
388
|
+
|
|
389
|
+
1. Run `/pinet status` from any session — it reports machine-wide broker lock
|
|
390
|
+
ownership and socket health even while disconnected.
|
|
391
|
+
2. If the broker is healthy and you just want to participate, run
|
|
392
|
+
`/pinet follow`.
|
|
393
|
+
3. If the broker is stranded (or you need the broker in this session), run
|
|
394
|
+
`/pinet start replace`. It asks the current broker to shut down gracefully
|
|
395
|
+
over the socket, falls back to a verified SIGTERM against the recorded lock
|
|
396
|
+
owner, and never escalates to SIGKILL.
|
|
397
|
+
|
|
398
|
+
Two cases intentionally refuse automatic termination:
|
|
399
|
+
|
|
400
|
+
- **Legacy locks** (written by older builds, PID-only): there is no recorded
|
|
401
|
+
process start identity, so a SIGTERM could hit an unrelated process that
|
|
402
|
+
reused the PID. Inspect the process manually (`ps -p <pid>`), terminate it
|
|
403
|
+
yourself if it is truly the stranded broker, then run `/pinet start`.
|
|
404
|
+
- **Rejected shutdown**: a broker that responds but rejects the shutdown
|
|
405
|
+
request (usually a mesh secret mismatch) is alive, not stranded. Fix the
|
|
406
|
+
mesh secret configuration or stop that broker from its own session.
|
|
407
|
+
|
|
383
408
|
### Stalled agents
|
|
384
409
|
|
|
385
410
|
If work gets stuck:
|
|
@@ -471,6 +496,6 @@ Prevent all modifications:
|
|
|
471
496
|
|
|
472
497
|
## Support
|
|
473
498
|
|
|
474
|
-
- [GitHub repository](https://github.com/gugu91/
|
|
499
|
+
- [GitHub repository](https://github.com/gugu91/pinet)
|
|
475
500
|
- [Architecture documentation](../plans/)
|
|
476
501
|
- Check the log channel in Slack for runtime issues
|
package/dist/activity-log.d.ts
CHANGED
|
@@ -18,6 +18,13 @@ export interface ActivityLogEntry {
|
|
|
18
18
|
export interface LoggedActivityLogEntry extends ActivityLogEntry {
|
|
19
19
|
timestamp: string;
|
|
20
20
|
}
|
|
21
|
+
export type ActivityLogSlackBlock = Record<string, unknown>;
|
|
22
|
+
export interface ActivityLogPostMessageBody extends Record<string, unknown> {
|
|
23
|
+
channel: string;
|
|
24
|
+
text: string;
|
|
25
|
+
blocks: ActivityLogSlackBlock[];
|
|
26
|
+
thread_ts?: string;
|
|
27
|
+
}
|
|
21
28
|
export interface SlackActivityLoggerDeps {
|
|
22
29
|
getBotToken: () => string | undefined;
|
|
23
30
|
getLogChannel: () => string | undefined;
|
|
@@ -25,7 +32,7 @@ export interface SlackActivityLoggerDeps {
|
|
|
25
32
|
getAgentName: () => string;
|
|
26
33
|
getAgentEmoji: () => string;
|
|
27
34
|
resolveChannel: (nameOrId: string) => Promise<string>;
|
|
28
|
-
slack: (method: string, token: string, body?:
|
|
35
|
+
slack: (method: string, token: string, body?: ActivityLogPostMessageBody) => Promise<SlackResult>;
|
|
29
36
|
onError?: (error: unknown) => void;
|
|
30
37
|
now?: () => Date;
|
|
31
38
|
maxRecentEntries?: number;
|
|
@@ -34,10 +41,10 @@ export declare function normalizeActivityLogLevel(value: string | undefined): Ac
|
|
|
34
41
|
export declare function shouldLogActivity(configuredLevel: ActivityLogLevel, eventLevel: ActivityLogLevel): boolean;
|
|
35
42
|
export declare function redactSensitiveText(value: string): string;
|
|
36
43
|
export declare function buildActivityLogText(agentName: string, agentEmoji: string, entry: LoggedActivityLogEntry): string;
|
|
37
|
-
export declare function buildActivityLogBlocks(agentName: string, agentEmoji: string, entry: LoggedActivityLogEntry):
|
|
44
|
+
export declare function buildActivityLogBlocks(agentName: string, agentEmoji: string, entry: LoggedActivityLogEntry): ActivityLogSlackBlock[];
|
|
38
45
|
export declare function buildActivityLogThreadHeader(agentName: string, agentEmoji: string, dateKey: string): {
|
|
39
46
|
text: string;
|
|
40
|
-
blocks:
|
|
47
|
+
blocks: ActivityLogSlackBlock[];
|
|
41
48
|
};
|
|
42
49
|
export declare function formatRecentActivityLogEntries(entries: ReadonlyArray<LoggedActivityLogEntry>): string;
|
|
43
50
|
export declare class SlackActivityLogger {
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AgentCompletionRuntime } from "./agent-completion-runtime.js";
|
|
3
|
-
import type { AgentPromptGuidance } from "./agent-prompt-guidance.js";
|
|
4
3
|
import { type SlackToolPolicyRuntime, type SlackToolPolicyRuntimeDeps } from "./slack-tool-policy-runtime.js";
|
|
5
4
|
export interface AgentEventRuntimeDeps extends SlackToolPolicyRuntimeDeps {
|
|
6
|
-
beforeAgentStart: AgentPromptGuidance["beforeAgentStart"];
|
|
7
5
|
onCompletionAgentEnd: AgentCompletionRuntime["onAgentEnd"];
|
|
8
6
|
setDeliverTrackedSlackFollowUpMessage: (deliver: SlackToolPolicyRuntime["deliverTrackedSlackFollowUpMessage"]) => void;
|
|
9
7
|
}
|
|
@@ -18,7 +18,6 @@ export function createAgentEventRuntime(deps) {
|
|
|
18
18
|
pi.on("turn_end", slackToolPolicyRuntime.onTurnEnd);
|
|
19
19
|
pi.on("agent_end", slackToolPolicyRuntime.onAgentEnd);
|
|
20
20
|
pi.on("tool_call", slackToolPolicyRuntime.onToolCall);
|
|
21
|
-
pi.on("before_agent_start", deps.beforeAgentStart);
|
|
22
21
|
pi.on("agent_end", deps.onCompletionAgentEnd);
|
|
23
22
|
}
|
|
24
23
|
return {
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
import { type BrokerPromptLoadResult } from "./broker-prompt-loader.js";
|
|
2
|
-
export interface BeforeAgentStartEvent {
|
|
3
|
-
systemPrompt: string;
|
|
4
|
-
}
|
|
5
2
|
export interface AgentPromptGuidanceDeps {
|
|
6
3
|
getIdentityGuidelines: () => string[];
|
|
7
4
|
getAgentName: () => string;
|
|
@@ -15,8 +12,6 @@ export interface AgentPromptGuidanceDeps {
|
|
|
15
12
|
reportBrokerPromptDiagnostic?: (diagnostic: string) => void;
|
|
16
13
|
}
|
|
17
14
|
export interface AgentPromptGuidance {
|
|
18
|
-
|
|
19
|
-
systemPrompt: string;
|
|
20
|
-
}>;
|
|
15
|
+
buildPromptGuidelines: () => Promise<string[]>;
|
|
21
16
|
}
|
|
22
17
|
export declare function createAgentPromptGuidance(deps: AgentPromptGuidanceDeps): AgentPromptGuidance;
|
|
@@ -35,12 +35,7 @@ export function createAgentPromptGuidance(deps) {
|
|
|
35
35
|
}
|
|
36
36
|
return guidelines;
|
|
37
37
|
}
|
|
38
|
-
async function beforeAgentStart(event) {
|
|
39
|
-
return {
|
|
40
|
-
systemPrompt: event.systemPrompt + "\n\n" + (await buildPromptGuidelines()).join("\n"),
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
38
|
return {
|
|
44
|
-
|
|
39
|
+
buildPromptGuidelines,
|
|
45
40
|
};
|
|
46
41
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ParsedAppHomeOpened, type ParsedSlashCommand, type ParsedThreadStarted } from "../../slack-access.js";
|
|
1
|
+
import { type SlackSocketErrorSource, type ParsedAppHomeOpened, type ParsedSlashCommand, type ParsedThreadStarted } from "../../slack-access.js";
|
|
2
2
|
import { type SlackIngressGuardSettings } from "../../helpers.js";
|
|
3
3
|
import { type ReactionCommandSettings } from "../../reaction-triggers.js";
|
|
4
4
|
import type { AdapterCapabilityRequest, AdapterCapabilityResult, InboundMessage, OutboundMessage, MessageAdapter } from "./types.js";
|
|
@@ -41,6 +41,9 @@ export interface SlackAdapterConfig {
|
|
|
41
41
|
onAppHomeOpened?: (event: ParsedAppHomeOpened) => Promise<void> | void;
|
|
42
42
|
/** Best-effort callback for Slack slash commands handled by the broker process. */
|
|
43
43
|
onSlashCommand?: (event: ParsedSlashCommand) => Promise<string | null> | string | null;
|
|
44
|
+
onSocketOpen?: () => void;
|
|
45
|
+
onSocketReconnectScheduled?: () => void;
|
|
46
|
+
onSocketError?: (message: string, source: SlackSocketErrorSource) => void;
|
|
44
47
|
}
|
|
45
48
|
export declare const SLACK_THREAD_CACHE_MAX_SIZE = 5000;
|
|
46
49
|
export declare const SLACK_THREAD_CACHE_TTL_MS: number;
|
|
@@ -69,6 +69,8 @@ export class SlackAdapter {
|
|
|
69
69
|
appToken: this.config.appToken,
|
|
70
70
|
dedup: this.processedSocketDeliveries,
|
|
71
71
|
abortAndWait: () => this.slackRequests.abortAndWait(),
|
|
72
|
+
onOpen: () => this.config.onSocketOpen?.(),
|
|
73
|
+
onReconnectScheduled: () => this.config.onSocketReconnectScheduled?.(),
|
|
72
74
|
onThreadStarted: (event) => this.onThreadStarted(event),
|
|
73
75
|
onThreadContextChanged: (event) => this.onContextChanged(event),
|
|
74
76
|
onMessage: (event) => this.onMessage(event),
|
|
@@ -77,9 +79,9 @@ export class SlackAdapter {
|
|
|
77
79
|
onAppHomeOpened: (event) => this.onAppHomeOpened(event),
|
|
78
80
|
onInteractive: (event) => this.emitInteractiveInbound(event),
|
|
79
81
|
onSlashCommand: (event) => this.onSlashCommand(event),
|
|
80
|
-
onError: (error) => {
|
|
82
|
+
onError: (error, source) => {
|
|
81
83
|
if (!isAbortError(error)) {
|
|
82
|
-
|
|
84
|
+
this.config.onSocketError?.(errorMsg(error), source);
|
|
83
85
|
}
|
|
84
86
|
},
|
|
85
87
|
});
|
|
@@ -88,14 +90,19 @@ export class SlackAdapter {
|
|
|
88
90
|
}
|
|
89
91
|
async disconnect() {
|
|
90
92
|
this.shuttingDown = true;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
try {
|
|
94
|
+
await this.threadStatuses.clearAll();
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
const socketMode = this.socketMode;
|
|
98
|
+
this.socketMode = null;
|
|
99
|
+
if (socketMode) {
|
|
100
|
+
await socketMode.disconnect();
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
await this.slackRequests.abortAndWait();
|
|
104
|
+
}
|
|
97
105
|
}
|
|
98
|
-
await this.slackRequests.abortAndWait();
|
|
99
106
|
}
|
|
100
107
|
onInbound(handler) {
|
|
101
108
|
this.inboundHandler = handler;
|
package/dist/broker/client.d.ts
CHANGED
|
@@ -34,13 +34,23 @@ export interface ScheduledWakeupInfo {
|
|
|
34
34
|
export type { PortLeaseAcquireInput, PortLeaseInfo, PortLeaseListOptions, PortLeaseReleaseInput, PortLeaseRenewInput, PinetLaneInfo, PinetLaneListOptions, PinetLaneParticipantInfo, PinetLaneParticipantUpsertInput, PinetLaneUpsertInput, };
|
|
35
35
|
export declare const DEFAULT_SOCKET_PATH: string;
|
|
36
36
|
export declare const REQUEST_TIMEOUT_MS = 5000;
|
|
37
|
-
export declare const RECONNECT_DELAY_MS = 3000;
|
|
38
37
|
export declare const INITIAL_RECONNECT_DELAY_MS = 1000;
|
|
39
38
|
export declare const MAX_RECONNECT_DELAY_MS = 30000;
|
|
40
39
|
export declare const HEARTBEAT_INTERVAL_MS = 5000;
|
|
41
40
|
export declare const HEARTBEAT_METADATA_PROVIDER_TIMEOUT_MS = 2500;
|
|
42
|
-
/** Compute reconnect delay with exponential backoff and jitter. */
|
|
41
|
+
/** Compute reconnect delay with exponential backoff and jitter (±25%). */
|
|
43
42
|
export declare function computeReconnectDelay(attempt: number, random?: number): number;
|
|
43
|
+
/**
|
|
44
|
+
* A single-use, broker-issued wake fence presented by a runtime that the broker
|
|
45
|
+
* cold-launched to revive a hibernated identity. Consumed on the first
|
|
46
|
+
* successful register and never replayed on reconnect.
|
|
47
|
+
*/
|
|
48
|
+
interface WakeFence {
|
|
49
|
+
wakeLeaseId: string;
|
|
50
|
+
fenceToken: number;
|
|
51
|
+
runtimeGeneration: number;
|
|
52
|
+
reservationNonce: string;
|
|
53
|
+
}
|
|
44
54
|
export interface AgentBroadcastResult {
|
|
45
55
|
channel: string;
|
|
46
56
|
messageIds: number[];
|
|
@@ -59,11 +69,18 @@ export interface BrokerClientAuthOptions {
|
|
|
59
69
|
meshSecret?: string;
|
|
60
70
|
meshSecretPath?: string;
|
|
61
71
|
}
|
|
72
|
+
export interface BrokerClientTimingOptions {
|
|
73
|
+
/** @internal Allows tests to exercise reconnect behavior without waiting for production backoff. */
|
|
74
|
+
reconnectDelayMs?: (attempt: number) => number;
|
|
75
|
+
}
|
|
76
|
+
export type BrokerClientOptions = BrokerConnectOpts & BrokerClientAuthOptions & BrokerClientTimingOptions;
|
|
62
77
|
export declare class BrokerClient {
|
|
63
78
|
private readonly connectOpts;
|
|
64
79
|
private readonly meshSecret;
|
|
65
80
|
private readonly meshSecretPath;
|
|
81
|
+
private readonly reconnectDelayMs;
|
|
66
82
|
private socket;
|
|
83
|
+
private connectingSocket;
|
|
67
84
|
private connected;
|
|
68
85
|
private shuttingDown;
|
|
69
86
|
private reconnectTimer;
|
|
@@ -79,14 +96,14 @@ export declare class BrokerClient {
|
|
|
79
96
|
private nextId;
|
|
80
97
|
private readonly pending;
|
|
81
98
|
private buffer;
|
|
82
|
-
constructor(opts?: string |
|
|
99
|
+
constructor(opts?: string | BrokerClientOptions);
|
|
83
100
|
connect(): Promise<void>;
|
|
84
101
|
disconnect(): void;
|
|
85
102
|
disconnectGracefully(): Promise<void>;
|
|
86
103
|
isConnected(): boolean;
|
|
87
104
|
private resolveMeshSecret;
|
|
88
105
|
private authenticateIfNeeded;
|
|
89
|
-
register(name: string, emoji: string, metadata?: Record<string, unknown>, stableId?: string): Promise<{
|
|
106
|
+
register(name: string, emoji: string, metadata?: Record<string, unknown>, stableId?: string, wakeFence?: WakeFence): Promise<{
|
|
90
107
|
agentId: string;
|
|
91
108
|
name: string;
|
|
92
109
|
emoji: string;
|
package/dist/broker/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as net from "node:net";
|
|
2
|
+
import { computeBackoffDelay, withTimeout } from "@pinet/transport-core/async";
|
|
2
3
|
import { readMeshSecret } from "./auth.js";
|
|
3
4
|
import { DEFAULT_SOCKET_PATH as PINET_DEFAULT_SOCKET_PATH } from "./paths.js";
|
|
4
5
|
import { assertLoopbackTcpHost } from "./raw-tcp-loopback.js";
|
|
@@ -6,31 +7,17 @@ import { RPC_AGENT_NAME_CONFLICT, RPC_METHOD_NOT_FOUND } from "./types.js";
|
|
|
6
7
|
// ─── Constants (exported for testing) ────────────────────
|
|
7
8
|
export const DEFAULT_SOCKET_PATH = PINET_DEFAULT_SOCKET_PATH;
|
|
8
9
|
export const REQUEST_TIMEOUT_MS = 5000;
|
|
9
|
-
export const RECONNECT_DELAY_MS = 3000;
|
|
10
10
|
export const INITIAL_RECONNECT_DELAY_MS = 1000;
|
|
11
11
|
export const MAX_RECONNECT_DELAY_MS = 30000;
|
|
12
12
|
export const HEARTBEAT_INTERVAL_MS = 5000;
|
|
13
13
|
export const HEARTBEAT_METADATA_PROVIDER_TIMEOUT_MS = 2500;
|
|
14
|
-
|
|
15
|
-
return new Promise((resolve, reject) => {
|
|
16
|
-
const timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
17
|
-
timer.unref?.();
|
|
18
|
-
promise.then((value) => {
|
|
19
|
-
clearTimeout(timer);
|
|
20
|
-
resolve(value);
|
|
21
|
-
}, (error) => {
|
|
22
|
-
clearTimeout(timer);
|
|
23
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
/** Compute reconnect delay with exponential backoff and jitter. */
|
|
14
|
+
/** Compute reconnect delay with exponential backoff and jitter (±25%). */
|
|
28
15
|
export function computeReconnectDelay(attempt, random = Math.random()) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
16
|
+
return computeBackoffDelay(attempt, {
|
|
17
|
+
initialMs: INITIAL_RECONNECT_DELAY_MS,
|
|
18
|
+
maxMs: MAX_RECONNECT_DELAY_MS,
|
|
19
|
+
random,
|
|
20
|
+
});
|
|
34
21
|
}
|
|
35
22
|
function createRpcRequestError(method, error) {
|
|
36
23
|
const err = new Error(error.message);
|
|
@@ -78,7 +65,9 @@ export class BrokerClient {
|
|
|
78
65
|
connectOpts;
|
|
79
66
|
meshSecret;
|
|
80
67
|
meshSecretPath;
|
|
68
|
+
reconnectDelayMs;
|
|
81
69
|
socket = null;
|
|
70
|
+
connectingSocket = null;
|
|
82
71
|
connected = false;
|
|
83
72
|
shuttingDown = false;
|
|
84
73
|
reconnectTimer = null;
|
|
@@ -95,6 +84,10 @@ export class BrokerClient {
|
|
|
95
84
|
pending = new Map();
|
|
96
85
|
buffer = "";
|
|
97
86
|
constructor(opts) {
|
|
87
|
+
this.reconnectDelayMs =
|
|
88
|
+
typeof opts === "object" && opts !== null
|
|
89
|
+
? (opts.reconnectDelayMs ?? computeReconnectDelay)
|
|
90
|
+
: computeReconnectDelay;
|
|
98
91
|
if (opts === undefined) {
|
|
99
92
|
this.connectOpts = { path: DEFAULT_SOCKET_PATH };
|
|
100
93
|
this.meshSecret = null;
|
|
@@ -139,7 +132,10 @@ export class BrokerClient {
|
|
|
139
132
|
}
|
|
140
133
|
this.stopHeartbeat();
|
|
141
134
|
this.rejectAllPending(new Error("Client disconnected"));
|
|
135
|
+
const connectingSocket = this.connectingSocket;
|
|
136
|
+
this.connectingSocket = null;
|
|
142
137
|
try {
|
|
138
|
+
connectingSocket?.destroy();
|
|
143
139
|
this.socket?.destroy();
|
|
144
140
|
}
|
|
145
141
|
catch {
|
|
@@ -198,13 +194,14 @@ export class BrokerClient {
|
|
|
198
194
|
}
|
|
199
195
|
}
|
|
200
196
|
// ─── Registration ────────────────────────────────────
|
|
201
|
-
async register(name, emoji, metadata, stableId) {
|
|
197
|
+
async register(name, emoji, metadata, stableId, wakeFence) {
|
|
202
198
|
this.registrationSnapshot = {
|
|
203
199
|
name,
|
|
204
200
|
emoji,
|
|
205
201
|
...(metadata ? { metadata } : {}),
|
|
206
202
|
...(stableId ? { stableId } : {}),
|
|
207
203
|
...(name.trim().length === 0 ? { brokerAssignedIdentity: true } : {}),
|
|
204
|
+
...(wakeFence ? { wakeFence } : {}),
|
|
208
205
|
};
|
|
209
206
|
return this.performRegister(this.registrationSnapshot);
|
|
210
207
|
}
|
|
@@ -368,7 +365,12 @@ export class BrokerClient {
|
|
|
368
365
|
}
|
|
369
366
|
async searchAgentSessions(options = {}) {
|
|
370
367
|
try {
|
|
371
|
-
|
|
368
|
+
const sessions = (await this.request("agent.sessions.search", options));
|
|
369
|
+
return sessions.map((session) => ({
|
|
370
|
+
...session,
|
|
371
|
+
runtimeKind: session.runtimeKind ?? (session.tmuxSession ? "tmux" : null),
|
|
372
|
+
runtimeLocator: session.runtimeLocator ?? (session.runtimeKind === "herdr" ? null : session.tmuxSession),
|
|
373
|
+
}));
|
|
372
374
|
}
|
|
373
375
|
catch (err) {
|
|
374
376
|
if (isRpcMethodNotFoundError(err, "agent.sessions.search")) {
|
|
@@ -480,7 +482,7 @@ export class BrokerClient {
|
|
|
480
482
|
scheduleReconnect() {
|
|
481
483
|
if (this.shuttingDown || this.reconnectTimer)
|
|
482
484
|
return;
|
|
483
|
-
const delay =
|
|
485
|
+
const delay = this.reconnectDelayMs(this.reconnectAttempt);
|
|
484
486
|
this.reconnectAttempt++;
|
|
485
487
|
this.reconnectTimer = setTimeout(() => {
|
|
486
488
|
this.reconnectTimer = null;
|
|
@@ -491,7 +493,27 @@ export class BrokerClient {
|
|
|
491
493
|
connectSocket() {
|
|
492
494
|
return new Promise((resolve, reject) => {
|
|
493
495
|
const sock = net.createConnection(this.connectOpts);
|
|
496
|
+
this.connectingSocket = sock;
|
|
497
|
+
let settled = false;
|
|
498
|
+
const rejectConnect = (error) => {
|
|
499
|
+
if (settled)
|
|
500
|
+
return;
|
|
501
|
+
settled = true;
|
|
502
|
+
if (this.connectingSocket === sock) {
|
|
503
|
+
this.connectingSocket = null;
|
|
504
|
+
}
|
|
505
|
+
reject(error);
|
|
506
|
+
};
|
|
494
507
|
sock.on("connect", () => {
|
|
508
|
+
if (this.shuttingDown) {
|
|
509
|
+
rejectConnect(new Error("Client disconnected while connecting"));
|
|
510
|
+
sock.destroy();
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
settled = true;
|
|
514
|
+
if (this.connectingSocket === sock) {
|
|
515
|
+
this.connectingSocket = null;
|
|
516
|
+
}
|
|
495
517
|
this.socket = sock;
|
|
496
518
|
this.connected = true;
|
|
497
519
|
this.buffer = "";
|
|
@@ -501,6 +523,12 @@ export class BrokerClient {
|
|
|
501
523
|
this.onData(chunk.toString("utf-8"));
|
|
502
524
|
});
|
|
503
525
|
sock.on("close", () => {
|
|
526
|
+
if (this.connectingSocket === sock) {
|
|
527
|
+
this.connectingSocket = null;
|
|
528
|
+
}
|
|
529
|
+
if (!settled) {
|
|
530
|
+
rejectConnect(new Error("Socket closed before connection completed"));
|
|
531
|
+
}
|
|
504
532
|
const wasConnected = this.connected;
|
|
505
533
|
this.connected = false;
|
|
506
534
|
this.socket = null;
|
|
@@ -512,8 +540,8 @@ export class BrokerClient {
|
|
|
512
540
|
}
|
|
513
541
|
});
|
|
514
542
|
sock.on("error", (err) => {
|
|
515
|
-
if (!
|
|
516
|
-
|
|
543
|
+
if (!settled) {
|
|
544
|
+
rejectConnect(err);
|
|
517
545
|
}
|
|
518
546
|
// If already connected, the close event handles cleanup
|
|
519
547
|
});
|
|
@@ -526,14 +554,26 @@ export class BrokerClient {
|
|
|
526
554
|
pid: process.pid,
|
|
527
555
|
...(snapshot.metadata ? { metadata: snapshot.metadata } : {}),
|
|
528
556
|
...(snapshot.stableId ? { stableId: snapshot.stableId } : {}),
|
|
557
|
+
...(snapshot.wakeFence
|
|
558
|
+
? {
|
|
559
|
+
wakeLeaseId: snapshot.wakeFence.wakeLeaseId,
|
|
560
|
+
fenceToken: snapshot.wakeFence.fenceToken,
|
|
561
|
+
runtimeGeneration: snapshot.wakeFence.runtimeGeneration,
|
|
562
|
+
reservationNonce: snapshot.wakeFence.reservationNonce,
|
|
563
|
+
}
|
|
564
|
+
: {}),
|
|
529
565
|
}));
|
|
566
|
+
// Retain a reconnect snapshot but drop the single-use wake fence so a
|
|
567
|
+
// reconnect never replays a consumed generation reservation.
|
|
530
568
|
this.registrationSnapshot = snapshot.brokerAssignedIdentity
|
|
531
569
|
? {
|
|
532
570
|
...snapshot,
|
|
571
|
+
wakeFence: undefined,
|
|
533
572
|
...(result.metadata ? { metadata: result.metadata } : {}),
|
|
534
573
|
}
|
|
535
574
|
: {
|
|
536
575
|
...snapshot,
|
|
576
|
+
wakeFence: undefined,
|
|
537
577
|
name: result.name,
|
|
538
578
|
emoji: result.emoji,
|
|
539
579
|
...(result.metadata ? { metadata: result.metadata } : {}),
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture the activation authority from the process-launch environment ONCE and
|
|
3
|
+
* freeze it for the process lifetime. Idempotent: the first call wins, and any
|
|
4
|
+
* later environment or settings mutation cannot change the frozen value. Returns
|
|
5
|
+
* the frozen authority. Call this explicitly at broker start so the freeze point
|
|
6
|
+
* is deterministic; reads via {@link hibernationActivationAuthorized} also
|
|
7
|
+
* freeze lazily on first use.
|
|
8
|
+
*
|
|
9
|
+
* Accepts `1`, `true`, `yes`, or `on` (case-insensitive, trimmed) as authorized;
|
|
10
|
+
* everything else — including unset/empty — is unauthorized (the production
|
|
11
|
+
* default, a strict no-op).
|
|
12
|
+
*/
|
|
13
|
+
export declare function freezeHibernationActivationAuthority(env?: NodeJS.ProcessEnv): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The frozen live-runtime activation authority. Lazily freezes on first read
|
|
16
|
+
* from the process-launch environment when {@link freezeHibernationActivationAuthority}
|
|
17
|
+
* has not run yet, so the gate is always a process-lifetime constant. Config
|
|
18
|
+
* reloads and settings edits can never flip it.
|
|
19
|
+
*/
|
|
20
|
+
export declare function hibernationActivationAuthorized(): boolean;
|
|
21
|
+
/** Test-only: clear the frozen value so a test can re-capture a chosen env. */
|
|
22
|
+
export declare function __resetHibernationActivationAuthorityForTest(): void;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Durable, non-reloadable hibernation runtime-activation authority.
|
|
2
|
+
//
|
|
3
|
+
// The Phase B live process/tmux adapters must be activated ONLY by a durable
|
|
4
|
+
// authority captured at broker start and FROZEN for the entire process
|
|
5
|
+
// lifetime. This deliberately does NOT read agent-editable `SlackBridgeSettings`
|
|
6
|
+
// (which pi re-reads on config reload): there must be NO settings, reload, or
|
|
7
|
+
// other in-process path that can elevate a running broker into live-runtime
|
|
8
|
+
// activation. The single source of truth is an external, process-launch
|
|
9
|
+
// environment variable, captured exactly once.
|
|
10
|
+
//
|
|
11
|
+
// Security rationale: an attacker (or a well-meaning agent) who can edit the
|
|
12
|
+
// broker's settings file or trigger a settings reload must never be able to turn
|
|
13
|
+
// on the live runtime. Requiring an external launch-environment variable moves
|
|
14
|
+
// the activation decision to whoever started the broker process and freezes it
|
|
15
|
+
// for the lifetime of that process.
|
|
16
|
+
/** External process-launch env var that authorizes live-runtime activation. */
|
|
17
|
+
const ACTIVATION_ENV_VAR = "PINET_HIBERNATION_RUNTIME_ACTIVATION";
|
|
18
|
+
/** Captured once at broker start; never re-read afterwards. */
|
|
19
|
+
let frozen;
|
|
20
|
+
/**
|
|
21
|
+
* Capture the activation authority from the process-launch environment ONCE and
|
|
22
|
+
* freeze it for the process lifetime. Idempotent: the first call wins, and any
|
|
23
|
+
* later environment or settings mutation cannot change the frozen value. Returns
|
|
24
|
+
* the frozen authority. Call this explicitly at broker start so the freeze point
|
|
25
|
+
* is deterministic; reads via {@link hibernationActivationAuthorized} also
|
|
26
|
+
* freeze lazily on first use.
|
|
27
|
+
*
|
|
28
|
+
* Accepts `1`, `true`, `yes`, or `on` (case-insensitive, trimmed) as authorized;
|
|
29
|
+
* everything else — including unset/empty — is unauthorized (the production
|
|
30
|
+
* default, a strict no-op).
|
|
31
|
+
*/
|
|
32
|
+
export function freezeHibernationActivationAuthority(env = process.env) {
|
|
33
|
+
if (frozen === undefined) {
|
|
34
|
+
const raw = env[ACTIVATION_ENV_VAR]?.trim().toLowerCase();
|
|
35
|
+
frozen = raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
36
|
+
}
|
|
37
|
+
return frozen;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The frozen live-runtime activation authority. Lazily freezes on first read
|
|
41
|
+
* from the process-launch environment when {@link freezeHibernationActivationAuthority}
|
|
42
|
+
* has not run yet, so the gate is always a process-lifetime constant. Config
|
|
43
|
+
* reloads and settings edits can never flip it.
|
|
44
|
+
*/
|
|
45
|
+
export function hibernationActivationAuthorized() {
|
|
46
|
+
return freezeHibernationActivationAuthority();
|
|
47
|
+
}
|
|
48
|
+
/** Test-only: clear the frozen value so a test can re-capture a chosen env. */
|
|
49
|
+
export function __resetHibernationActivationAuthorityForTest() {
|
|
50
|
+
frozen = undefined;
|
|
51
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { HibernationOrchestrator, type HibernationOrchestratorConfig, type RuntimeLaunchContext, type StrandedWakeRecovery } from "@pinet/broker-core";
|
|
2
|
+
import type { AgentRuntimeSpec } from "@pinet/broker-core/types";
|
|
3
|
+
import type { BrokerDB } from "./schema.js";
|
|
4
|
+
import { resolveVcsIdentity } from "./hibernation-runtime-adapters.js";
|
|
5
|
+
import { type SpawnAuthoredRuntimeFacts } from "./hibernation-runtime-helpers.js";
|
|
6
|
+
/** Optional command runner override (git remote resolution); defaults to real `execFile`. */
|
|
7
|
+
type VcsRunner = Parameters<typeof resolveVcsIdentity>[1];
|
|
8
|
+
/**
|
|
9
|
+
* The DEFAULT-OFF live-runtime gate. Live process/tmux composition happens ONLY
|
|
10
|
+
* when the durable, process-lifetime activation authority — captured at broker
|
|
11
|
+
* start from the external launch environment, never agent-editable settings — is
|
|
12
|
+
* set. Config reloads and settings edits can NEVER flip it, so the production
|
|
13
|
+
* default (authority unset) keeps the broker on the `activation_pending` stub
|
|
14
|
+
* with zero live-runtime side effects. Operational permission (enabled / mode /
|
|
15
|
+
* repo allowlist) is a SEPARATE settings policy enforced by the command layer;
|
|
16
|
+
* this gate only decides whether the real machinery is wired in at all.
|
|
17
|
+
*/
|
|
18
|
+
export declare function hibernationRuntimeActive(): boolean;
|
|
19
|
+
export interface HibernationRuntimeDeps {
|
|
20
|
+
db: BrokerDB;
|
|
21
|
+
/** Broker owner id recorded as `brokerOwnerId`/`brokerInstanceId` on managed rows. */
|
|
22
|
+
brokerInstanceId: string;
|
|
23
|
+
/** slack-bridge extension entry a woken runtime loads (`pi -e <path>`). */
|
|
24
|
+
extensionEntryPath: string;
|
|
25
|
+
/** Base PINET_* env re-establishing the mesh connection for a woken worker. */
|
|
26
|
+
baseLaunchEnv: Record<string, string>;
|
|
27
|
+
/** Broker env var NAMES (never values) re-exported into a woken runtime when present. */
|
|
28
|
+
inheritedEnvKeys: string[];
|
|
29
|
+
config?: Partial<HibernationOrchestratorConfig>;
|
|
30
|
+
/**
|
|
31
|
+
* Confirm the woken runtime registered and its reserved generation was accepted.
|
|
32
|
+
* PRODUCTION leaves this undefined: the orchestrator's default polls the broker
|
|
33
|
+
* DB until the socket server's fenced-registration handler accepts the woken
|
|
34
|
+
* worker's generation. Injectable so an isolated E2E can key acceptance off the
|
|
35
|
+
* REAL respawned process coming back alive instead of a live socket handshake.
|
|
36
|
+
*/
|
|
37
|
+
awaitRuntimeRegistration?: (ctx: RuntimeLaunchContext) => Promise<boolean>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Seam 1 — compose the real orchestrator-backed executor.
|
|
41
|
+
*
|
|
42
|
+
* The process and tmux controllers are constructed INDEPENDENTLY: the immutable
|
|
43
|
+
* launch generation rides inside the attempt handle `respawnRuntime` returns, so
|
|
44
|
+
* attempt-scoped stop/liveness bind to the exact launched process with no shared
|
|
45
|
+
* registry. The returned {@link HibernationOrchestrator} structurally satisfies
|
|
46
|
+
* `HibernateCommandExecutor & WakeCommandExecutor`, so it drops directly into the
|
|
47
|
+
* command executor slot in place of the `activation_pending` stub — but only the
|
|
48
|
+
* caller's {@link hibernationRuntimeActive} gate decides when to use it.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createHibernationOrchestrator(deps: HibernationRuntimeDeps): HibernationOrchestrator;
|
|
51
|
+
/** Broker-authored spawn facts minus the VCS identity (derived here from the git remote). */
|
|
52
|
+
type SpawnRuntimeSpecFactsWithoutVcs<T extends SpawnAuthoredRuntimeFacts> = T extends SpawnAuthoredRuntimeFacts ? Omit<T, "vcsIdentity"> : never;
|
|
53
|
+
export type SpawnRuntimeSpecFacts = SpawnRuntimeSpecFactsWithoutVcs<SpawnAuthoredRuntimeFacts>;
|
|
54
|
+
/**
|
|
55
|
+
* Seam 2 — persist a durable, broker-authored runtime spec at worker spawn.
|
|
56
|
+
*
|
|
57
|
+
* The canonical `owner/repo` VCS identity (the ONLY value the repo allowlist
|
|
58
|
+
* authorizes against) is resolved HERE from the runtime's actual git `origin`
|
|
59
|
+
* remote — never from filesystem directory names. Fails closed: an unresolvable
|
|
60
|
+
* remote records `vcsIdentity: null` (the fail-closed gate then refuses), and a
|
|
61
|
+
* spec that could not be safely hibernated/woken (missing session/tmux/repo
|
|
62
|
+
* locators) is never written. Returns the persisted spec, or null when the facts
|
|
63
|
+
* do not compose a durable spec.
|
|
64
|
+
*/
|
|
65
|
+
export declare function persistSpawnedRuntimeSpec(db: BrokerDB, facts: SpawnRuntimeSpecFacts, runner?: VcsRunner): Promise<AgentRuntimeSpec | null>;
|
|
66
|
+
/**
|
|
67
|
+
* Seam 3 — reconcile crash-stranded wake state at broker startup.
|
|
68
|
+
*
|
|
69
|
+
* Must run BEFORE the broker socket accepts new registrations so a stranded
|
|
70
|
+
* `waking`/`dispatching` row is completed, quarantined, or requeued deterministically
|
|
71
|
+
* rather than racing an incoming (possibly duplicate) wake registration. This is a
|
|
72
|
+
* synchronous DB reconciliation; it launches nothing.
|
|
73
|
+
*/
|
|
74
|
+
export declare function recoverStrandedWakesBeforeRegistrations(orchestrator: HibernationOrchestrator): StrandedWakeRecovery[];
|
|
75
|
+
export {};
|