@haywoodmarx/agent-pager 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +4 -4
  2. package/dist/src/broker/address.d.ts +7 -0
  3. package/dist/src/broker/address.js +9 -0
  4. package/dist/src/broker/broker.d.ts +91 -23
  5. package/dist/src/broker/broker.js +505 -123
  6. package/dist/src/broker/operator-projection.js +3 -5
  7. package/dist/src/broker/protocol.d.ts +1 -1
  8. package/dist/src/broker/protocol.js +1 -1
  9. package/dist/src/broker/schema.d.ts +4 -0
  10. package/dist/src/broker/schema.js +9 -6
  11. package/dist/src/broker/snapshot.d.ts +4 -2
  12. package/dist/src/implementations/claude/gateway-cli.js +1 -2
  13. package/dist/src/implementations/claude/gateway.d.ts +11 -4
  14. package/dist/src/implementations/claude/gateway.js +44 -14
  15. package/dist/src/implementations/claude/instructions.js +1 -1
  16. package/dist/src/implementations/model.d.ts +6 -3
  17. package/dist/src/implementations/opencode/plugin.d.ts +7 -2
  18. package/dist/src/implementations/opencode/plugin.js +103 -7
  19. package/dist/src/implementations/pi/broker-client.d.ts +13 -4
  20. package/dist/src/implementations/pi/broker-client.js +71 -9
  21. package/dist/src/implementations/pi/extension.d.ts +7 -2
  22. package/dist/src/implementations/pi/extension.js +15 -3
  23. package/dist/src/index.d.ts +1 -1
  24. package/dist/src/index.js +1 -1
  25. package/dist/src/launch/broker-lock.d.ts +58 -4
  26. package/dist/src/launch/broker-lock.js +140 -7
  27. package/dist/src/launch/launch.d.ts +1 -1
  28. package/dist/src/launch/on-demand.d.ts +8 -8
  29. package/dist/src/launch/on-demand.js +54 -35
  30. package/dist/src/launch/runtime-composition.js +2 -0
  31. package/dist/src/launch/runtime.js +4 -14
  32. package/dist/src/launch/service.d.ts +1 -0
  33. package/dist/src/launch/service.js +23 -1
  34. package/dist/src/launch/workspace-branch.d.ts +3 -3
  35. package/dist/src/launch/workspace-branch.js +7 -15
  36. package/dist/src/launch/workspace-root.d.ts +13 -0
  37. package/dist/src/launch/workspace-root.js +27 -0
  38. package/dist/src/messaging/delivery-client.d.ts +17 -0
  39. package/dist/src/messaging/delivery-client.js +31 -1
  40. package/dist/src/messaging/identity-statement.d.ts +7 -0
  41. package/dist/src/messaging/identity-statement.js +8 -0
  42. package/dist/src/messaging/mcp.d.ts +8 -2
  43. package/dist/src/messaging/mcp.js +54 -1
  44. package/dist/src/messaging/notifications.d.ts +31 -3
  45. package/dist/src/messaging/notifications.js +25 -0
  46. package/dist/src/messaging/push-delivery.d.ts +2 -1
  47. package/dist/src/messaging/push-delivery.js +24 -7
  48. package/dist/src/messaging/tools.js +22 -2
  49. package/dist/src/messaging/wake.js +7 -0
  50. package/dist/src/operator/agent-projection.d.ts +8 -5
  51. package/dist/src/operator/agent-projection.js +29 -9
  52. package/dist/src/operator/protocol.d.ts +2 -2
  53. package/dist/src/operator/repository-system.d.ts +29 -0
  54. package/dist/src/operator/repository-system.js +39 -12
  55. package/dist/src/operator/repository.d.ts +28 -11
  56. package/dist/src/operator/repository.js +37 -4
  57. package/dist/src/shared/versions.d.ts +18 -0
  58. package/dist/src/shared/versions.js +26 -0
  59. package/dist/src/tui/agent-tree.js +2 -2
  60. package/dist/src/version.d.ts +7 -0
  61. package/dist/src/version.js +14 -6
  62. package/package.json +1 -1
  63. package/skills/pager/SKILL.md +35 -45
@@ -1,5 +1,5 @@
1
1
  import { ATTACH_HEADER, isAgentId } from "../../broker/protocol.js";
2
- import { adoptRotation, asRecord, BrokerDeliveryClient, exactRecord, registerAgent, rpcEnvelope, } from "../../messaging/delivery-client.js";
2
+ import { adoptRotation, asRecord, BrokerDeliveryClient, exactRecord, isExpiredRefusal, registerAgent, rpcEnvelope, } from "../../messaging/delivery-client.js";
3
3
  import { PAGER_VERSION } from "../../version.js";
4
4
  /**
5
5
  * Register a directly started Pi session through the unauthenticated broker boundary.
@@ -10,23 +10,37 @@ export async function registerPiAgent({ request, brokerUrl, workspacePath, sessi
10
10
  return registerAgent(request, brokerUrl, {
11
11
  workspacePath,
12
12
  implementationId: "pi",
13
- sessionName,
13
+ ...(sessionName ? { sessionName } : {}),
14
14
  ...(sessionKey ? { sessionKey } : {}),
15
15
  });
16
16
  }
17
- /** Authenticated messaging and delivery operations used by the Pi extension. */
17
+ /**
18
+ * Authenticated messaging and delivery operations used by the Pi extension.
19
+ *
20
+ * When the broker refuses the attachments this client holds as `expired` and a rejoin was supplied, a
21
+ * call registers the conversation again, attaches both channels with the reservations that return,
22
+ * and is retried once; calls refused together share one rejoin. Any other refusal reaches the caller
23
+ * unchanged.
24
+ */
18
25
  export class PiBrokerClient {
19
26
  #brokerUrl;
20
- #agentId;
21
27
  #request;
28
+ #sessionKey;
29
+ #rejoin;
30
+ #agentId;
22
31
  #messaging;
23
32
  #delivery;
24
- constructor(brokerUrl, agentId, request, messaging, delivery) {
33
+ #deliveryAddress;
34
+ #rejoining;
35
+ #attachments = 0;
36
+ constructor(brokerUrl, agentId, request, messaging, delivery, sessionKey, rejoin) {
25
37
  this.#brokerUrl = brokerUrl;
26
38
  this.#agentId = agentId;
27
39
  this.#request = request;
28
40
  this.#messaging = messaging;
29
41
  this.#delivery = delivery;
42
+ this.#sessionKey = sessionKey;
43
+ this.#rejoin = rejoin;
30
44
  }
31
45
  /**
32
46
  * Consume a Pi identity's messaging and delivery reservations.
@@ -34,9 +48,11 @@ export class PiBrokerClient {
34
48
  * @param identity - Reserved or directly registered broker identity.
35
49
  * @param request - HTTP operation used to reach the loopback broker.
36
50
  * @param sessionKey - Resumable conversation key, which the broker may resolve to another identity.
51
+ * @param rejoin - Registers this conversation again by its key and returns the attachments for the
52
+ * identity that results; absent when nothing identifies the conversation to register.
37
53
  * @returns - Connected client whose identity is the one the broker resolved, not the one requested.
38
54
  */
39
- static async connect(brokerUrl, identity, request, sessionKey) {
55
+ static async connect(brokerUrl, identity, request, sessionKey, rejoin) {
40
56
  const messaging = await initializeMessaging(request, brokerUrl, identity.messagingReservation, sessionKey);
41
57
  const delivery = await BrokerDeliveryClient.attach({
42
58
  brokerUrl,
@@ -45,7 +61,7 @@ export class PiBrokerClient {
45
61
  request,
46
62
  sessionKey,
47
63
  });
48
- return new PiBrokerClient(brokerUrl, messaging.agentId ?? delivery.agentId(), request, messaging, delivery);
64
+ return new PiBrokerClient(brokerUrl, messaging.agentId ?? delivery.agentId(), request, messaging, delivery, sessionKey, rejoin);
49
65
  }
50
66
  /**
51
67
  * Invoke one model-callable Pager MCP tool.
@@ -53,7 +69,10 @@ export class PiBrokerClient {
53
69
  * @param args - Validated Pi tool arguments.
54
70
  * @returns - Decoded broker tool result.
55
71
  */
56
- async callTool(name, args) {
72
+ callTool(name, args) {
73
+ return this.#recovering(() => this.#callMessagingTool(name, args));
74
+ }
75
+ async #callMessagingTool(name, args) {
57
76
  const response = await this.#request(this.#brokerUrl, {
58
77
  method: "POST",
59
78
  headers: messagingHeaders(this.#messaging),
@@ -86,11 +105,54 @@ export class PiBrokerClient {
86
105
  * @param address - Address text the session's inbox produced.
87
106
  */
88
107
  presentDeliveryAddress(address) {
108
+ this.#deliveryAddress = address;
89
109
  this.#delivery.presentDeliveryAddress(address);
90
110
  }
91
111
  /** Renew both independently fenced broker attachment leases. */
92
112
  async heartbeat() {
93
- await Promise.all([this.callTool("whoami", {}), this.#delivery.heartbeat()]);
113
+ await this.#recovering(async () => {
114
+ await Promise.all([this.#callMessagingTool("whoami", {}), this.#delivery.heartbeat()]);
115
+ });
116
+ }
117
+ async #recovering(call) {
118
+ await this.#rejoining?.catch(() => undefined);
119
+ const presentedAttachments = this.#attachments;
120
+ try {
121
+ return await call();
122
+ }
123
+ catch (error) {
124
+ const rejoin = this.#rejoin;
125
+ if (!rejoin || !isExpiredRefusal(error))
126
+ throw error;
127
+ if (this.#rejoining)
128
+ await this.#rejoining;
129
+ else if (this.#attachments === presentedAttachments)
130
+ await this.#startRejoin(rejoin);
131
+ return call();
132
+ }
133
+ }
134
+ #startRejoin(rejoin) {
135
+ this.#rejoining = this.#registerAgain(rejoin).finally(() => {
136
+ this.#rejoining = undefined;
137
+ });
138
+ return this.#rejoining;
139
+ }
140
+ async #registerAgain(rejoin) {
141
+ const identity = await rejoin();
142
+ const messaging = await initializeMessaging(this.#request, this.#brokerUrl, identity.messagingReservation, this.#sessionKey);
143
+ const delivery = await BrokerDeliveryClient.attach({
144
+ brokerUrl: this.#brokerUrl,
145
+ agentId: identity.agentId,
146
+ reservation: identity.deliveryReservation,
147
+ request: this.#request,
148
+ sessionKey: this.#sessionKey,
149
+ });
150
+ if (this.#deliveryAddress)
151
+ delivery.presentDeliveryAddress(this.#deliveryAddress);
152
+ this.#messaging = messaging;
153
+ this.#delivery = delivery;
154
+ this.#agentId = messaging.agentId ?? delivery.agentId();
155
+ this.#attachments += 1;
94
156
  }
95
157
  /** Tell the broker Pi is shutting down, ending both attachments so the session name is free at once. */
96
158
  async detach() {
@@ -34,8 +34,13 @@ export interface PiExtensionScheduler {
34
34
  export type PiExtensionDependencies = {
35
35
  /** Reads service configuration without opening the broker database. */
36
36
  readEnvironment(): RuntimeEnvironment;
37
- /** Starts or adopts the shared broker service. */
38
- ensureService(environment: RuntimeEnvironment): Promise<BrokerEndpoints>;
37
+ /**
38
+ * Starts or adopts the shared broker service.
39
+ * @param environment - Resolved configuration naming the database the service owns.
40
+ * @param report - Receives what a service this call started wrote while starting.
41
+ * @returns - The live service's endpoints.
42
+ */
43
+ ensureService(environment: RuntimeEnvironment, report: (message: string) => void): Promise<BrokerEndpoints>;
39
44
  /** Supplies launch identity and runtime selection values. */
40
45
  processEnv: NodeJS.ProcessEnv;
41
46
  /** Performs one broker HTTP request. */
@@ -87,7 +87,14 @@ export function createPiExtension(dependencies) {
87
87
  // A direct-mode registration displaced whatever the identity held, so its fresh reservations
88
88
  // are the only working ones.
89
89
  if (!runtime.client || runtime.connectionKey !== connection.key || connection.directCwd !== undefined) {
90
- runtime.client = await PiBrokerClient.connect(connection.brokerUrl, connection.identity, dependencies.fetch, connection.sessionKey);
90
+ runtime.client = await PiBrokerClient.connect(connection.brokerUrl, connection.identity, dependencies.fetch, connection.sessionKey, connection.sessionKey
91
+ ? () => registerPiAgent({
92
+ request: dependencies.fetch,
93
+ brokerUrl: connection.brokerUrl,
94
+ workspacePath: ctx.cwd,
95
+ sessionKey: connection.sessionKey,
96
+ })
97
+ : undefined);
91
98
  runtime.connectionKey = connection.key;
92
99
  runtime.directCwd = connection.directCwd;
93
100
  }
@@ -175,7 +182,12 @@ async function resolveConnection(dependencies, ctx) {
175
182
  directCwd: undefined,
176
183
  };
177
184
  }
178
- const endpoints = await dependencies.ensureService(dependencies.readEnvironment());
185
+ const endpoints = await dependencies.ensureService(dependencies.readEnvironment(), (message) => {
186
+ if (ctx.hasUI)
187
+ ctx.ui.notify(message.trim(), "info");
188
+ else
189
+ process.stderr.write(message.endsWith("\n") ? message : `${message}\n`);
190
+ });
179
191
  const identity = await registerPiAgent({
180
192
  request: dependencies.fetch,
181
193
  brokerUrl: endpoints.mcpUrl,
@@ -236,7 +248,7 @@ function hasLaunchIdentity(environment) {
236
248
  }
237
249
  const defaultDependencies = {
238
250
  readEnvironment: () => readEmbeddedRuntimeEnvironment(process.env, resolveBunExecutable),
239
- ensureService: (environment) => ensureBrokerService(environment),
251
+ ensureService: (environment, report) => ensureBrokerService(environment, { report }),
240
252
  processEnv: process.env,
241
253
  fetch: (url, init) => globalThis.fetch(url, init),
242
254
  runtimeSlot: systemPiRuntimeSlot(),
@@ -10,7 +10,7 @@ export { createImplementationRegistry } from "./launch/runtime-composition.js";
10
10
  export { PAGER_DEFAULTS, ConfigurationError, initializePagerStore, loadPagerConfiguration, resolvePagerStoreDirectory, resolveConfigurationSetting, type PagerConfig, type ConfigurationLoadOptions, type ConfigurationSettingPrecedence, type LoadedConfiguration, } from "./configuration.js";
11
11
  export { DirectTerminalProvider, ForegroundTerminalProvider, LaunchService, renderLaunchResult, type AttachState, type ForegroundProcessSpawner, type LaunchDiagnostic, type LaunchRequest, type LaunchResult, type ProcessSpawner, type ProcessState, type TerminalLaunch, type TerminalProvider, type TerminalProviderName, type TerminalSession, type TerminalTaskCompletion, } from "./launch/launch.js";
12
12
  export { BunRuntimeError, assertSupportedBunRuntime, probeBunVersion, resolveBunExecutable, satisfiesBunVersionFloor, BUN_PATH_ENVIRONMENT, MINIMUM_BUN_VERSION, RETIRED_NODE_PATH_ENVIRONMENT, type RuntimeProbe, } from "./launch/runtime.js";
13
- export { BrokerService, EndpointPortUnavailableError, systemWorkspaceBranch, type BrokerServiceOptions, type BrokerServiceStatus, } from "./launch/service.js";
13
+ export { BrokerService, EndpointPortUnavailableError, systemWorkspaceBranch, systemWorkspaceRoot, type BrokerServiceOptions, type BrokerServiceStatus, } from "./launch/service.js";
14
14
  export { systemBrokerServiceFilesystem, systemBrokerServiceScheduler } from "./launch/service-system.js";
15
15
  export { BrokerLockGuard, brokerLockPath } from "./launch/broker-lock.js";
16
16
  export { readRuntimeEnvironment, PAGER_AGENT_ID_ENVIRONMENT, PAGER_ATTACH_RESERVATION_ENVIRONMENT, PAGER_BROKER_URL_ENVIRONMENT, PAGER_MCP_URL_ENVIRONMENT, type RuntimeEnvironment, } from "./launch/environment.js";
package/dist/src/index.js CHANGED
@@ -9,7 +9,7 @@ export { createImplementationRegistry } from "./launch/runtime-composition.js";
9
9
  export { PAGER_DEFAULTS, ConfigurationError, initializePagerStore, loadPagerConfiguration, resolvePagerStoreDirectory, resolveConfigurationSetting, } from "./configuration.js";
10
10
  export { DirectTerminalProvider, ForegroundTerminalProvider, LaunchService, renderLaunchResult, } from "./launch/launch.js";
11
11
  export { BunRuntimeError, assertSupportedBunRuntime, probeBunVersion, resolveBunExecutable, satisfiesBunVersionFloor, BUN_PATH_ENVIRONMENT, MINIMUM_BUN_VERSION, RETIRED_NODE_PATH_ENVIRONMENT, } from "./launch/runtime.js";
12
- export { BrokerService, EndpointPortUnavailableError, systemWorkspaceBranch, } from "./launch/service.js";
12
+ export { BrokerService, EndpointPortUnavailableError, systemWorkspaceBranch, systemWorkspaceRoot, } from "./launch/service.js";
13
13
  export { systemBrokerServiceFilesystem, systemBrokerServiceScheduler } from "./launch/service-system.js";
14
14
  export { BrokerLockGuard, brokerLockPath } from "./launch/broker-lock.js";
15
15
  export { readRuntimeEnvironment, PAGER_AGENT_ID_ENVIRONMENT, PAGER_ATTACH_RESERVATION_ENVIRONMENT, PAGER_BROKER_URL_ENVIRONMENT, PAGER_MCP_URL_ENVIRONMENT, } from "./launch/environment.js";
@@ -1,7 +1,12 @@
1
1
  import { ProtocolError, type BrokerServiceIdentity } from "../broker/protocol.js";
2
2
  /** Identifies the ownership schema understood by this release. */
3
3
  export declare const SERVICE_PROTOCOL_VERSION = 1;
4
- /** Identifies the package build that owns production service state. */
4
+ /**
5
+ * Identifies the package build that owns production service state.
6
+ *
7
+ * A source checkout carries a `+checkout` suffix, so its service and a published release of the same
8
+ * version never adopt each other, and neither is taken for an older release the other may replace.
9
+ */
5
10
  export declare const SERVICE_BUILD_ID: string;
6
11
  /** Identifies the production runtime required to own SQLite. */
7
12
  export declare const SERVICE_RUNTIME = "bun";
@@ -83,6 +88,29 @@ export declare function brokerLockPath(databasePath: string): string;
83
88
  * @returns - Valid parsed lock data when the file is well-formed.
84
89
  */
85
90
  export declare function readBrokerLock(lockPath: string): BrokerLock | undefined;
91
+ /**
92
+ * Report whether one service build id names an older release of this package than another.
93
+ *
94
+ * Only a build id naming an exact `major.minor.patch` version compares, so a prerelease or a source
95
+ * checkout on either side is never called older.
96
+ * @param buildId - Build id read from a lock record, possibly written by a foreign build.
97
+ * @param currentBuildId - Build id of the caller.
98
+ * @returns - True only when both name releases of this package and `buildId`'s is lower.
99
+ */
100
+ export declare function isOlderServiceBuild(buildId: string, currentBuildId: string): boolean;
101
+ /**
102
+ * Report whether a live lock owner is one a starting service of this build may stop and replace.
103
+ *
104
+ * The lock must carry this build's lock format, runtime and wire protocol, as {@link readBrokerLock}
105
+ * requires, and an older release of this package must have written it, whatever its database schema.
106
+ * The platform must confirm that the process holding the lock's pid started when the lock records, so
107
+ * a reused pid, or one whose start time cannot be read, is never signalled.
108
+ * @param lock - Lock record, possibly written by a foreign build.
109
+ * @param ownerStartedAt - Start time the platform reports now for the lock's pid.
110
+ * @param currentBuildId - Build id of the service that would replace the owner.
111
+ * @returns - True only when every condition holds.
112
+ */
113
+ export declare function brokerLockIsReplaceable(lock: BrokerLock | undefined, ownerStartedAt: ProcessStartTime, currentBuildId?: string): boolean;
86
114
  /**
87
115
  * Name the package version of the build that wrote a lock record.
88
116
  * @param lock - Lock record, possibly written by a foreign build.
@@ -91,6 +119,13 @@ export declare function readBrokerLock(lockPath: string): BrokerLock | undefined
91
119
  export declare function brokerLockVersion(lock: Pick<BrokerLock, "buildId">): string;
92
120
  /** Bounds how many times acquisition re-attempts after losing a lock-file race. */
93
121
  export declare const BROKER_LOCK_ACQUISITION_ATTEMPTS = 5;
122
+ /**
123
+ * Bounds waiting for a signalled owner to exit. It must exceed `SHUTDOWN_GRACE_MS` in `cli.ts`, the
124
+ * time a stopping service allows its endpoints to close before it exits on its own.
125
+ */
126
+ export declare const BROKER_EXIT_TIMEOUT_MS = 10000;
127
+ /** Spaces the liveness polls made while a signalled owner shuts down. */
128
+ export declare const BROKER_EXIT_POLL_MS = 50;
94
129
  /** Reports the start time of a process, or that this platform cannot supply one. */
95
130
  export type ProcessStartTime = {
96
131
  known: true;
@@ -108,6 +143,13 @@ export type BrokerLockPorts = {
108
143
  newHolderToken(): string;
109
144
  /** Blocks the calling thread, because lock acquisition runs before the service has an event loop. */
110
145
  sleep(milliseconds: number): void;
146
+ /** Delivers a signal to an older-release owner being replaced. */
147
+ signal(pid: number, signal: NodeJS.Signals): void;
148
+ /**
149
+ * Tells the operator about an owner acquisition stopped on their behalf.
150
+ * @param message - One line naming the stopped process and the versions involved.
151
+ */
152
+ report(message: string): void;
111
153
  };
112
154
  /** Reports that the lock is held by a live service of the current build. */
113
155
  export declare class BrokerServiceAlreadyRunningError extends ProtocolError {
@@ -133,6 +175,14 @@ export declare class BrokerServiceLockOwnedError extends ProtocolError {
133
175
  /** Identifies the live process that owns the lock. */
134
176
  pid: number);
135
177
  }
178
+ /** Named failure raised when a signalled broker service is still running once the exit budget is spent. */
179
+ export declare class BrokerServiceExitTimeoutError extends ProtocolError {
180
+ /**
181
+ * Construct the failure reported when the owner ignores or outlasts its termination signal.
182
+ * @param pid - Process id of the owner that was signalled.
183
+ */
184
+ constructor(pid: number);
185
+ }
136
186
  /** Reports that acquisition kept losing the lock file to another writer. */
137
187
  export declare class BrokerLockContendedError extends ProtocolError {
138
188
  /** Names the contended lock file. */
@@ -227,20 +277,24 @@ export declare class BrokerLockGuard implements BrokerLockOwner {
227
277
  * Construct a guard over one lock file.
228
278
  * @param lockPath - Lock file adjacent to the database this process wants to own.
229
279
  * @param ports - Operating-system boundaries, replaced only by this module's own tests.
280
+ * @param buildId - Build id deciding which older-release owners this guard may replace.
230
281
  */
231
- constructor(lockPath: string, ports?: BrokerLockPorts);
282
+ constructor(lockPath: string, ports?: BrokerLockPorts, buildId?: string);
232
283
  /**
233
284
  * Names the lock file this guard claims.
234
285
  * @returns - Path the guard was constructed over.
235
286
  */
236
287
  get path(): string;
237
288
  /**
238
- * Take exclusive ownership of the database, reclaiming a lock whose owner is gone.
289
+ * Take exclusive ownership of the database, reclaiming a lock whose owner is gone and stopping
290
+ * one {@link brokerLockIsReplaceable} accepts.
239
291
  *
240
292
  * Terminates in every case: each attempt either wins the lock, throws because a live owner holds
241
293
  * it, or consumes one of a fixed number of attempts.
242
294
  * @throws {BrokerServiceAlreadyRunningError} - A live service of this build holds the lock.
243
- * @throws {BrokerServiceLockOwnedError} - A live service this build must not displace holds the lock.
295
+ * @throws {BrokerServiceLockOwnedError} - A live service this build must not displace holds the
296
+ * lock, or a replaceable one refused the signal.
297
+ * @throws {BrokerServiceExitTimeoutError} - A replaceable owner outlasted its termination signal.
244
298
  * @throws {BrokerLockContendedError} - Another writer kept recreating the lock.
245
299
  */
246
300
  acquire(): void;
@@ -4,12 +4,20 @@ import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync,
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { PROTOCOL_VERSION, ProtocolError, SCHEMA_MARKER } from "../broker/protocol.js";
6
6
  import { LOOPBACK_HOST } from "../configuration-defaults.js";
7
- import { PAGER_VERSION } from "../version.js";
7
+ import { compareNumericVersions, parseNumericVersion } from "../shared/versions.js";
8
+ import { PAGER_SOURCE_CHECKOUT, PAGER_VERSION } from "../version.js";
8
9
  import { satisfiesBunVersionFloor } from "./runtime.js";
9
10
  /** Identifies the ownership schema understood by this release. */
10
11
  export const SERVICE_PROTOCOL_VERSION = 1;
11
- /** Identifies the package build that owns production service state. */
12
- export const SERVICE_BUILD_ID = `@haywoodmarx/agent-pager@${PAGER_VERSION}`;
12
+ /** Names the package whose builds write service build ids. */
13
+ const SERVICE_PACKAGE = "@haywoodmarx/agent-pager";
14
+ /**
15
+ * Identifies the package build that owns production service state.
16
+ *
17
+ * A source checkout carries a `+checkout` suffix, so its service and a published release of the same
18
+ * version never adopt each other, and neither is taken for an older release the other may replace.
19
+ */
20
+ export const SERVICE_BUILD_ID = `${SERVICE_PACKAGE}@${PAGER_VERSION}${PAGER_SOURCE_CHECKOUT ? "+checkout" : ""}`;
13
21
  /** Identifies the production runtime required to own SQLite. */
14
22
  export const SERVICE_RUNTIME = "bun";
15
23
  /**
@@ -117,6 +125,43 @@ export function readBrokerLock(lockPath) {
117
125
  return undefined;
118
126
  }
119
127
  }
128
+ /**
129
+ * Report whether one service build id names an older release of this package than another.
130
+ *
131
+ * Only a build id naming an exact `major.minor.patch` version compares, so a prerelease or a source
132
+ * checkout on either side is never called older.
133
+ * @param buildId - Build id read from a lock record, possibly written by a foreign build.
134
+ * @param currentBuildId - Build id of the caller.
135
+ * @returns - True only when both name releases of this package and `buildId`'s is lower.
136
+ */
137
+ export function isOlderServiceBuild(buildId, currentBuildId) {
138
+ const owner = packageRelease(buildId);
139
+ const current = packageRelease(currentBuildId);
140
+ return owner !== undefined && current !== undefined && compareNumericVersions(owner, current) < 0;
141
+ }
142
+ function packageRelease(buildId) {
143
+ if (!buildId.startsWith(`${SERVICE_PACKAGE}@`))
144
+ return undefined;
145
+ return parseNumericVersion(brokerLockVersion({ buildId }), "exact");
146
+ }
147
+ /**
148
+ * Report whether a live lock owner is one a starting service of this build may stop and replace.
149
+ *
150
+ * The lock must carry this build's lock format, runtime and wire protocol, as {@link readBrokerLock}
151
+ * requires, and an older release of this package must have written it, whatever its database schema.
152
+ * The platform must confirm that the process holding the lock's pid started when the lock records, so
153
+ * a reused pid, or one whose start time cannot be read, is never signalled.
154
+ * @param lock - Lock record, possibly written by a foreign build.
155
+ * @param ownerStartedAt - Start time the platform reports now for the lock's pid.
156
+ * @param currentBuildId - Build id of the service that would replace the owner.
157
+ * @returns - True only when every condition holds.
158
+ */
159
+ export function brokerLockIsReplaceable(lock, ownerStartedAt, currentBuildId = SERVICE_BUILD_ID) {
160
+ return (lock !== undefined &&
161
+ isOlderServiceBuild(lock.buildId, currentBuildId) &&
162
+ lock.ownerStartedAt !== undefined &&
163
+ startTimeConfirms(lock.ownerStartedAt, ownerStartedAt));
164
+ }
120
165
  /**
121
166
  * Name the package version of the build that wrote a lock record.
122
167
  * @param lock - Lock record, possibly written by a foreign build.
@@ -196,6 +241,13 @@ export const BROKER_LOCK_ACQUISITION_ATTEMPTS = 5;
196
241
  const HOLDER_TOKEN_BYTES = 12;
197
242
  /** Delays one re-attempt after losing a lock-file race, in milliseconds. */
198
243
  const RECLAIM_RETRY_DELAY_MS = 20;
244
+ /**
245
+ * Bounds waiting for a signalled owner to exit. It must exceed `SHUTDOWN_GRACE_MS` in `cli.ts`, the
246
+ * time a stopping service allows its endpoints to close before it exits on its own.
247
+ */
248
+ export const BROKER_EXIT_TIMEOUT_MS = 10_000;
249
+ /** Spaces the liveness polls made while a signalled owner shuts down. */
250
+ export const BROKER_EXIT_POLL_MS = 50;
199
251
  /** Names the reading behind a Linux start time, which pairs boot time with ticks since boot. */
200
252
  const LINUX_START_TIME_TAG = "l1";
201
253
  /** Names the reading behind a macOS start time, which is an instant in milliseconds. */
@@ -230,6 +282,17 @@ export class BrokerServiceLockOwnedError extends ProtocolError {
230
282
  this.name = "BrokerServiceLockOwnedError";
231
283
  }
232
284
  }
285
+ /** Named failure raised when a signalled broker service is still running once the exit budget is spent. */
286
+ export class BrokerServiceExitTimeoutError extends ProtocolError {
287
+ /**
288
+ * Construct the failure reported when the owner ignores or outlasts its termination signal.
289
+ * @param pid - Process id of the owner that was signalled.
290
+ */
291
+ constructor(pid) {
292
+ super("validation_failed", `broker service pid ${String(pid)} was signalled to exit and is still running`);
293
+ this.name = "BrokerServiceExitTimeoutError";
294
+ }
295
+ }
233
296
  /** Reports that acquisition kept losing the lock file to another writer. */
234
297
  export class BrokerLockContendedError extends ProtocolError {
235
298
  lockPath;
@@ -356,6 +419,20 @@ export function startTimeStillMatches(recorded, actual) {
356
419
  }
357
420
  return false;
358
421
  }
422
+ /**
423
+ * Report whether a platform reading positively identifies the process a record describes.
424
+ *
425
+ * Unlike {@link startTimeStillMatches}, an unknown reading, or one measured another way, is not a
426
+ * match.
427
+ * @param recorded - Start time captured when the record was written.
428
+ * @param actual - Start time read from the platform now.
429
+ * @returns - True only for a known reading of the recorded kind that agrees with it.
430
+ */
431
+ function startTimeConfirms(recorded, actual) {
432
+ return (actual.known &&
433
+ startTimeReading(actual.value) === startTimeReading(recorded) &&
434
+ startTimeStillMatches(recorded, actual));
435
+ }
359
436
  function linuxStartTicks(value) {
360
437
  return value.split(":")[2];
361
438
  }
@@ -375,6 +452,12 @@ export function systemBrokerLockPorts() {
375
452
  sleep: (milliseconds) => {
376
453
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
377
454
  },
455
+ signal: (pid, signal) => {
456
+ process.kill(pid, signal);
457
+ },
458
+ report: (message) => {
459
+ process.stderr.write(`${message}\n`);
460
+ },
378
461
  };
379
462
  }
380
463
  /**
@@ -391,17 +474,20 @@ export class BrokerLockGuard {
391
474
  #lockPath;
392
475
  #reclaimPath;
393
476
  #ports;
477
+ #buildId;
394
478
  #holder;
395
479
  #startedAt = { known: false };
396
480
  /**
397
481
  * Construct a guard over one lock file.
398
482
  * @param lockPath - Lock file adjacent to the database this process wants to own.
399
483
  * @param ports - Operating-system boundaries, replaced only by this module's own tests.
484
+ * @param buildId - Build id deciding which older-release owners this guard may replace.
400
485
  */
401
- constructor(lockPath, ports = systemBrokerLockPorts()) {
486
+ constructor(lockPath, ports = systemBrokerLockPorts(), buildId = SERVICE_BUILD_ID) {
402
487
  this.#lockPath = lockPath;
403
488
  this.#reclaimPath = `${lockPath}.reclaim`;
404
489
  this.#ports = ports;
490
+ this.#buildId = buildId;
405
491
  }
406
492
  /**
407
493
  * Names the lock file this guard claims.
@@ -411,12 +497,15 @@ export class BrokerLockGuard {
411
497
  return this.#lockPath;
412
498
  }
413
499
  /**
414
- * Take exclusive ownership of the database, reclaiming a lock whose owner is gone.
500
+ * Take exclusive ownership of the database, reclaiming a lock whose owner is gone and stopping
501
+ * one {@link brokerLockIsReplaceable} accepts.
415
502
  *
416
503
  * Terminates in every case: each attempt either wins the lock, throws because a live owner holds
417
504
  * it, or consumes one of a fixed number of attempts.
418
505
  * @throws {BrokerServiceAlreadyRunningError} - A live service of this build holds the lock.
419
- * @throws {BrokerServiceLockOwnedError} - A live service this build must not displace holds the lock.
506
+ * @throws {BrokerServiceLockOwnedError} - A live service this build must not displace holds the
507
+ * lock, or a replaceable one refused the signal.
508
+ * @throws {BrokerServiceExitTimeoutError} - A replaceable owner outlasted its termination signal.
420
509
  * @throws {BrokerLockContendedError} - Another writer kept recreating the lock.
421
510
  */
422
511
  acquire() {
@@ -439,6 +528,10 @@ export class BrokerLockGuard {
439
528
  const observed = this.#observe();
440
529
  if (observed.state === "live")
441
530
  throw ownerError(observed.pid, observed.current);
531
+ if (observed.state === "replaceable") {
532
+ this.#replace(observed);
533
+ continue;
534
+ }
442
535
  if (observed.state === "absent")
443
536
  continue;
444
537
  if (observed.state === "unreadable") {
@@ -569,7 +662,43 @@ export class BrokerLockGuard {
569
662
  return { state: "unreadable" };
570
663
  if (!this.#ownerIsLive(pid, record))
571
664
  return { state: "stale" };
572
- return { state: "live", pid, current: brokerLockHasCurrentIdentity(readBrokerLock(this.#lockPath)) };
665
+ const lock = isBrokerLock(record) ? record : undefined;
666
+ if (brokerLockHasCurrentIdentity(lock))
667
+ return { state: "live", pid, current: true };
668
+ if (lock?.ownerStartedAt !== undefined &&
669
+ brokerLockIsReplaceable(lock, this.#ports.processStartedAt(pid), this.#buildId)) {
670
+ return { state: "replaceable", pid, version: brokerLockVersion(lock), startedAt: lock.ownerStartedAt };
671
+ }
672
+ // An owner that another service is replacing can exit after the liveness check above, leaving no
673
+ // start time to confirm. It is a departing owner, not one to refuse.
674
+ if (!this.#ports.processIsAlive(pid))
675
+ return { state: "stale" };
676
+ return { state: "live", pid, current: false };
677
+ }
678
+ /**
679
+ * Stop an older-release owner and wait for it to exit.
680
+ *
681
+ * Reports only when this guard's own signal was delivered.
682
+ * @param owner - The owner {@link brokerLockIsReplaceable} accepted, with its recorded start time.
683
+ * @param owner.pid - Process id to signal.
684
+ * @param owner.version - Version of the owner, named in the report.
685
+ * @param owner.startedAt - Start time the lock records, which a reused pid would not match.
686
+ */
687
+ #replace(owner) {
688
+ try {
689
+ this.#ports.signal(owner.pid, "SIGTERM");
690
+ }
691
+ catch {
692
+ if (!this.#ports.processIsAlive(owner.pid))
693
+ return;
694
+ throw new BrokerServiceLockOwnedError(owner.pid);
695
+ }
696
+ for (let waited = 0; this.#ownerIsLive(owner.pid, { ownerStartedAt: owner.startedAt }); waited += BROKER_EXIT_POLL_MS) {
697
+ if (waited >= BROKER_EXIT_TIMEOUT_MS)
698
+ throw new BrokerServiceExitTimeoutError(owner.pid);
699
+ this.#ports.sleep(BROKER_EXIT_POLL_MS);
700
+ }
701
+ this.#ports.report(`stopped broker service pid ${String(owner.pid)} running pager ${owner.version} to start pager ${PAGER_VERSION}`);
573
702
  }
574
703
  #ownerIsLive(pid, record) {
575
704
  if (!this.#ports.processIsAlive(pid))
@@ -586,6 +715,10 @@ export class BrokerLockGuard {
586
715
  const observed = this.#observe();
587
716
  if (observed.state === "live")
588
717
  throw ownerError(observed.pid, observed.current);
718
+ // An owner that appeared since the caller's observation is replaced by the caller's next
719
+ // attempt, outside this span, so the reclaim file is not held while it exits.
720
+ if (observed.state === "replaceable")
721
+ return "lock-taken";
589
722
  if (observed.state !== "absent")
590
723
  removeIfPresent(this.#lockPath);
591
724
  return this.#create(holder) ? "acquired" : "lock-taken";
@@ -32,7 +32,7 @@ export type LaunchRequest = {
32
32
  terminalProvider?: TerminalProviderName;
33
33
  /** Supplies the broker endpoint injected into implementation configuration. */
34
34
  brokerUrl: string;
35
- /** Supplies the workspace path associated with the launch. */
35
+ /** Supplies the directory the launched agent works in, which places it in that directory's workspace. */
36
36
  workspacePath: string;
37
37
  /** Requests reservationless workspace registration for an interactive wrapper session. */
38
38
  selfRegistration?: boolean;
@@ -77,6 +77,11 @@ export type OnDemandServiceDependencies = {
77
77
  processIsAlive(pid: number): boolean;
78
78
  /** Reports when a process started, so a recycled process id is not mistaken for the owner. */
79
79
  processStartedAt(pid: number): ProcessStartTime;
80
+ /**
81
+ * Names the build this caller starts, which decides which foreign owners this caller leaves for
82
+ * its service to replace.
83
+ */
84
+ buildId: string;
80
85
  /** Reports whether published endpoints answer. */
81
86
  endpointsReady(endpoints: BrokerEndpoints): Promise<BrokerProbeOutcome>;
82
87
  /** Delays one bounded discovery attempt. */
@@ -143,14 +148,6 @@ export declare class BrokerServiceUnreachableError extends ProtocolError {
143
148
  /** Records which kind of failure the last probe saw. */
144
149
  outcome: "unreachable" | "unhealthy");
145
150
  }
146
- /** Named failure raised when a signalled broker service is still running once the exit budget is spent. */
147
- export declare class BrokerServiceExitTimeoutError extends ProtocolError {
148
- /**
149
- * Construct the failure reported when the owner ignores or outlasts its termination signal.
150
- * @param pid - Process id of the owner that was signalled.
151
- */
152
- constructor(pid: number);
153
- }
154
151
  /** Named failure raised when an upgrade encounters a live pre-identity service. */
155
152
  export declare class LegacyBrokerServiceError extends ProtocolError {
156
153
  /**
@@ -170,6 +167,9 @@ export declare class LegacyBrokerServiceError extends ProtocolError {
170
167
  export declare function liveBrokerEndpoints(lock: BrokerLock | undefined, isAlive: (pid: number) => boolean, startedAt?: (pid: number) => ProcessStartTime): BrokerEndpoints | undefined;
171
168
  /**
172
169
  * Adopt a running broker service or start one detached and wait for its endpoints.
170
+ *
171
+ * Never signals a running service itself. A foreign owner that {@link brokerLockIsReplaceable}
172
+ * accepts is left for the started service to stop while it takes the lock; any other is refused.
173
173
  * @param environment - Resolved configuration shared by the caller and service process.
174
174
  * @param overrides - Optional process and timing seams for isolated verification.
175
175
  * @returns - Live loopback endpoints owned by the singleton service process.