@haywoodmarx/agent-pager 0.1.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/src/broker/address.d.ts +7 -0
- package/dist/src/broker/address.js +9 -0
- package/dist/src/broker/broker.d.ts +112 -24
- package/dist/src/broker/broker.js +604 -132
- package/dist/src/broker/operator-projection.d.ts +22 -0
- package/dist/src/broker/operator-projection.js +43 -5
- package/dist/src/broker/protocol.d.ts +1 -1
- package/dist/src/broker/protocol.js +1 -1
- package/dist/src/broker/schema.d.ts +4 -0
- package/dist/src/broker/schema.js +20 -8
- package/dist/src/broker/snapshot.d.ts +4 -2
- package/dist/src/implementations/claude/gateway-cli.js +1 -2
- package/dist/src/implementations/claude/gateway.d.ts +11 -4
- package/dist/src/implementations/claude/gateway.js +44 -14
- package/dist/src/implementations/claude/instructions.js +1 -1
- package/dist/src/implementations/model.d.ts +6 -3
- package/dist/src/implementations/opencode/plugin.d.ts +7 -2
- package/dist/src/implementations/opencode/plugin.js +103 -7
- package/dist/src/implementations/pi/broker-client.d.ts +13 -4
- package/dist/src/implementations/pi/broker-client.js +71 -9
- package/dist/src/implementations/pi/extension.d.ts +7 -2
- package/dist/src/implementations/pi/extension.js +15 -3
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +2 -2
- package/dist/src/launch/broker-lock.d.ts +58 -4
- package/dist/src/launch/broker-lock.js +140 -7
- package/dist/src/launch/launch.d.ts +1 -1
- package/dist/src/launch/on-demand.d.ts +8 -8
- package/dist/src/launch/on-demand.js +54 -35
- package/dist/src/launch/runtime-composition.js +2 -0
- package/dist/src/launch/runtime.js +4 -14
- package/dist/src/launch/service.d.ts +1 -0
- package/dist/src/launch/service.js +23 -1
- package/dist/src/launch/workspace-branch.d.ts +3 -3
- package/dist/src/launch/workspace-branch.js +7 -15
- package/dist/src/launch/workspace-root.d.ts +13 -0
- package/dist/src/launch/workspace-root.js +27 -0
- package/dist/src/messaging/delivery-client.d.ts +17 -0
- package/dist/src/messaging/delivery-client.js +31 -1
- package/dist/src/messaging/identity-statement.d.ts +7 -0
- package/dist/src/messaging/identity-statement.js +8 -0
- package/dist/src/messaging/mcp.d.ts +8 -2
- package/dist/src/messaging/mcp.js +54 -1
- package/dist/src/messaging/notifications.d.ts +31 -3
- package/dist/src/messaging/notifications.js +25 -0
- package/dist/src/messaging/push-delivery.d.ts +2 -1
- package/dist/src/messaging/push-delivery.js +24 -7
- package/dist/src/messaging/tools.js +22 -2
- package/dist/src/messaging/wake.js +7 -0
- package/dist/src/operator/agent-projection.d.ts +8 -5
- package/dist/src/operator/agent-projection.js +29 -9
- package/dist/src/operator/commands.d.ts +2 -0
- package/dist/src/operator/commands.js +57 -21
- package/dist/src/operator/protocol.d.ts +34 -2
- package/dist/src/operator/protocol.js +9 -0
- package/dist/src/operator/repository-system.d.ts +29 -0
- package/dist/src/operator/repository-system.js +39 -12
- package/dist/src/operator/repository.d.ts +28 -11
- package/dist/src/operator/repository.js +37 -4
- package/dist/src/operator/transport.js +10 -5
- package/dist/src/operator/updates.js +7 -1
- package/dist/src/operator/validation.js +35 -1
- package/dist/src/shared/versions.d.ts +18 -0
- package/dist/src/shared/versions.js +26 -0
- package/dist/src/tui/agent-tree.d.ts +8 -5
- package/dist/src/tui/agent-tree.js +195 -22
- package/dist/src/tui/app.js +6 -2
- package/dist/src/tui/message-views.js +4 -3
- package/dist/src/tui/model.d.ts +25 -1
- package/dist/src/tui/model.js +33 -9
- package/dist/src/tui/presentation.d.ts +11 -2
- package/dist/src/tui/presentation.js +26 -4
- package/dist/src/version.d.ts +7 -0
- package/dist/src/version.js +14 -6
- package/package.json +1 -1
- package/skills/pager/SKILL.md +35 -45
|
@@ -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 {
|
|
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
|
-
/**
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { ProtocolError } from "../broker/protocol.js";
|
|
7
7
|
import { resolveBunExecutable } from "./runtime.js";
|
|
8
|
-
import { brokerLockHasCurrentIdentity, brokerLockIsLive, brokerLockPath, isCurrentBrokerServiceIdentity, processIsAlive, processStartedAt, readBrokerLock, startTimeStillMatches, readBrokerOwner,
|
|
8
|
+
import { BROKER_EXIT_POLL_MS, BROKER_EXIT_TIMEOUT_MS, BrokerServiceExitTimeoutError, brokerLockHasCurrentIdentity, brokerLockIsLive, brokerLockIsReplaceable, brokerLockPath, isCurrentBrokerServiceIdentity, processIsAlive, processStartedAt, readBrokerLock, startTimeStillMatches, readBrokerOwner, SERVICE_BUILD_ID, } from "./broker-lock.js";
|
|
9
9
|
/** Bounds one loopback endpoint probe, which either answers promptly or is treated as unreachable. */
|
|
10
10
|
export const BROKER_PROBE_TIMEOUT_MS = 2_000;
|
|
11
11
|
/**
|
|
@@ -99,17 +99,6 @@ export class BrokerServiceUnreachableError extends ProtocolError {
|
|
|
99
99
|
this.name = "BrokerServiceUnreachableError";
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
-
/** Named failure raised when a signalled broker service is still running once the exit budget is spent. */
|
|
103
|
-
export class BrokerServiceExitTimeoutError extends ProtocolError {
|
|
104
|
-
/**
|
|
105
|
-
* Construct the failure reported when the owner ignores or outlasts its termination signal.
|
|
106
|
-
* @param pid - Process id of the owner that was signalled.
|
|
107
|
-
*/
|
|
108
|
-
constructor(pid) {
|
|
109
|
-
super("validation_failed", `broker service pid ${String(pid)} was signalled to exit and is still running`);
|
|
110
|
-
this.name = "BrokerServiceExitTimeoutError";
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
102
|
/** Named failure raised when an upgrade encounters a live pre-identity service. */
|
|
114
103
|
export class LegacyBrokerServiceError extends ProtocolError {
|
|
115
104
|
/**
|
|
@@ -143,6 +132,9 @@ export function liveBrokerEndpoints(lock, isAlive, startedAt) {
|
|
|
143
132
|
}
|
|
144
133
|
/**
|
|
145
134
|
* Adopt a running broker service or start one detached and wait for its endpoints.
|
|
135
|
+
*
|
|
136
|
+
* Never signals a running service itself. A foreign owner that {@link brokerLockIsReplaceable}
|
|
137
|
+
* accepts is left for the started service to stop while it takes the lock; any other is refused.
|
|
146
138
|
* @param environment - Resolved configuration shared by the caller and service process.
|
|
147
139
|
* @param overrides - Optional process and timing seams for isolated verification.
|
|
148
140
|
* @returns - Live loopback endpoints owned by the singleton service process.
|
|
@@ -150,11 +142,10 @@ export function liveBrokerEndpoints(lock, isAlive, startedAt) {
|
|
|
150
142
|
export async function ensureBrokerService(environment, overrides = {}) {
|
|
151
143
|
const dependencies = { ...defaultDependencies(), ...overrides };
|
|
152
144
|
const lockPath = brokerLockPath(environment.dbPath);
|
|
153
|
-
const ownerPid = readBrokerOwnerPid(lockPath);
|
|
154
145
|
const candidate = readBrokerLock(lockPath);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
146
|
+
const refused = refusedForeignOwner(lockPath, readBrokerOwner(lockPath), candidate, dependencies);
|
|
147
|
+
if (refused !== undefined)
|
|
148
|
+
throw new LegacyBrokerServiceError(refused);
|
|
158
149
|
const existing = liveBrokerEndpoints(candidate, dependencies.processIsAlive, dependencies.processStartedAt);
|
|
159
150
|
if (existing) {
|
|
160
151
|
// The lock already proved this is the current build, so a probe that misses is a slow or
|
|
@@ -189,11 +180,12 @@ export async function ensureBrokerService(environment, overrides = {}) {
|
|
|
189
180
|
let unanswered;
|
|
190
181
|
try {
|
|
191
182
|
while (dependencies.now() <= deadline) {
|
|
192
|
-
const
|
|
183
|
+
const loopOwner = readBrokerOwner(lockPath);
|
|
184
|
+
const loopOwnerPid = loopOwner?.pid;
|
|
193
185
|
const loopCandidate = readBrokerLock(lockPath);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
186
|
+
const loopRefused = refusedForeignOwner(lockPath, loopOwner, loopCandidate, dependencies);
|
|
187
|
+
if (loopRefused !== undefined)
|
|
188
|
+
throw new LegacyBrokerServiceError(loopRefused);
|
|
197
189
|
const endpoints = liveBrokerEndpoints(loopCandidate, dependencies.processIsAlive, dependencies.processStartedAt);
|
|
198
190
|
unanswered = undefined;
|
|
199
191
|
if (endpoints) {
|
|
@@ -243,13 +235,6 @@ export async function ensureBrokerService(environment, overrides = {}) {
|
|
|
243
235
|
child.releaseOutput();
|
|
244
236
|
}
|
|
245
237
|
}
|
|
246
|
-
/**
|
|
247
|
-
* Bounds waiting for a signalled owner to exit. It must exceed `SHUTDOWN_GRACE_MS` in `cli.ts`, the
|
|
248
|
-
* time a stopping service allows its endpoints to close before it exits on its own.
|
|
249
|
-
*/
|
|
250
|
-
const BROKER_EXIT_TIMEOUT_MS = 10_000;
|
|
251
|
-
/** Spaces the liveness polls made while a signalled owner shuts down. */
|
|
252
|
-
const BROKER_EXIT_POLL_MS = 50;
|
|
253
238
|
/**
|
|
254
239
|
* Stop the broker service that owns the configured database, whichever build started it.
|
|
255
240
|
*
|
|
@@ -261,13 +246,10 @@ const BROKER_EXIT_POLL_MS = 50;
|
|
|
261
246
|
*/
|
|
262
247
|
export async function stopBrokerService(environment, overrides = {}) {
|
|
263
248
|
const dependencies = {
|
|
264
|
-
|
|
265
|
-
processStartedAt,
|
|
249
|
+
...systemProcessDependencies(),
|
|
266
250
|
signal: (pid, signal) => {
|
|
267
251
|
process.kill(pid, signal);
|
|
268
252
|
},
|
|
269
|
-
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
270
|
-
now: Date.now,
|
|
271
253
|
...overrides,
|
|
272
254
|
};
|
|
273
255
|
const owner = readBrokerOwner(brokerLockPath(environment.dbPath));
|
|
@@ -376,8 +358,21 @@ function spawnCapturingStderr(command, args, environment) {
|
|
|
376
358
|
},
|
|
377
359
|
};
|
|
378
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* Build the operating-system process and timing seams shared by starting and stopping a service.
|
|
363
|
+
* @returns - Seams backed by the process table, timers and the wall clock.
|
|
364
|
+
*/
|
|
365
|
+
function systemProcessDependencies() {
|
|
366
|
+
return {
|
|
367
|
+
processIsAlive,
|
|
368
|
+
processStartedAt,
|
|
369
|
+
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
370
|
+
now: Date.now,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
379
373
|
function defaultDependencies() {
|
|
380
374
|
return {
|
|
375
|
+
...systemProcessDependencies(),
|
|
381
376
|
spawnService: spawnCapturingStderr,
|
|
382
377
|
report: (message) => {
|
|
383
378
|
process.stderr.write(message.endsWith("\n") ? message : `${message}\n`);
|
|
@@ -386,8 +381,7 @@ function defaultDependencies() {
|
|
|
386
381
|
command: resolveBunExecutable(environment.processEnv),
|
|
387
382
|
args: [fileURLToPath(new URL("./cli.js", import.meta.url)), "serve"],
|
|
388
383
|
}),
|
|
389
|
-
|
|
390
|
-
processStartedAt,
|
|
384
|
+
buildId: SERVICE_BUILD_ID,
|
|
391
385
|
endpointsReady: async ({ mcpUrl, snapshotUrl }) => {
|
|
392
386
|
let health;
|
|
393
387
|
let snapshot;
|
|
@@ -421,8 +415,6 @@ function defaultDependencies() {
|
|
|
421
415
|
return "unhealthy";
|
|
422
416
|
}
|
|
423
417
|
},
|
|
424
|
-
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
425
|
-
now: Date.now,
|
|
426
418
|
};
|
|
427
419
|
}
|
|
428
420
|
function adoptionRetryDelayMs(probes) {
|
|
@@ -433,3 +425,30 @@ function ownerStartTimeMatches(lock, startedAt) {
|
|
|
433
425
|
return true;
|
|
434
426
|
return startTimeStillMatches(lock.ownerStartedAt, startedAt(lock.pid));
|
|
435
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* Name the live foreign owner this caller must refuse.
|
|
430
|
+
*
|
|
431
|
+
* An owner counts as live only when its process started at the time its lock records.
|
|
432
|
+
* @param lockPath - Lock file both records were read from, read again before refusing.
|
|
433
|
+
* @param owner - Owner read from any structurally valid lock, current or legacy.
|
|
434
|
+
* @param lock - The same file read as a lock record this build accepts, when it is one.
|
|
435
|
+
* @param dependencies - Process seams reading liveness and start times.
|
|
436
|
+
* @returns - The owner's pid when it must be refused, or undefined when it is absent, stale, current,
|
|
437
|
+
* or replaceable.
|
|
438
|
+
*/
|
|
439
|
+
function refusedForeignOwner(lockPath, owner, lock, dependencies) {
|
|
440
|
+
if (owner === undefined || !dependencies.processIsAlive(owner.pid))
|
|
441
|
+
return undefined;
|
|
442
|
+
const startedAt = dependencies.processStartedAt(owner.pid);
|
|
443
|
+
if (owner.startedAt !== undefined && !startTimeStillMatches(owner.startedAt, startedAt))
|
|
444
|
+
return undefined;
|
|
445
|
+
if (brokerLockHasCurrentIdentity(lock))
|
|
446
|
+
return undefined;
|
|
447
|
+
if (lock?.pid === owner.pid && brokerLockIsReplaceable(lock, startedAt, dependencies.buildId))
|
|
448
|
+
return undefined;
|
|
449
|
+
// An owner that another starting service is replacing can exit, or remove its lock, between the
|
|
450
|
+
// reads above, leaving no start time or record to judge. It is a departing owner, not a foreign one.
|
|
451
|
+
if (!dependencies.processIsAlive(owner.pid) || readBrokerOwner(lockPath)?.pid !== owner.pid)
|
|
452
|
+
return undefined;
|
|
453
|
+
return owner.pid;
|
|
454
|
+
}
|
|
@@ -15,6 +15,7 @@ import { discoverImplementationRegistry, implementationId, } from "../implementa
|
|
|
15
15
|
import { DirectTerminalProvider, ForegroundTerminalProvider, LaunchService, } from "./launch.js";
|
|
16
16
|
import { spawnForegroundProcess } from "./terminal.js";
|
|
17
17
|
import { systemWorkspaceBranch } from "./workspace-branch.js";
|
|
18
|
+
import { systemWorkspaceRoot } from "./workspace-root.js";
|
|
18
19
|
import { initializePagerStore } from "../configuration.js";
|
|
19
20
|
import { isPlainObject } from "../shared/guards.js";
|
|
20
21
|
export { readRuntimeEnvironment } from "./environment.js";
|
|
@@ -33,6 +34,7 @@ export function createBroker(environment) {
|
|
|
33
34
|
return new Broker(environment.dbPath, {
|
|
34
35
|
now: Date.now,
|
|
35
36
|
workspaceBranch: systemWorkspaceBranch,
|
|
37
|
+
workspaceRoot: systemWorkspaceRoot,
|
|
36
38
|
report: (message) => {
|
|
37
39
|
process.stderr.write(`${message}\n`);
|
|
38
40
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { accessSync, constants, realpathSync } from "node:fs";
|
|
3
3
|
import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { compareNumericVersions, parseNumericVersion } from "../shared/versions.js";
|
|
4
5
|
import { readRuntimeEnvironment } from "./environment.js";
|
|
5
6
|
/** Bounds one probe of an external runtime executable. */
|
|
6
7
|
const RUNTIME_PROBE_TIMEOUT_MS = 10_000;
|
|
@@ -32,22 +33,11 @@ export class BunRuntimeError extends Error {
|
|
|
32
33
|
* @returns - True when the version is at or above the supported floor.
|
|
33
34
|
*/
|
|
34
35
|
export function satisfiesBunVersionFloor(version) {
|
|
35
|
-
const candidate =
|
|
36
|
-
const minimum =
|
|
36
|
+
const candidate = parseNumericVersion(version, "prefix");
|
|
37
|
+
const minimum = parseNumericVersion(MINIMUM_BUN_VERSION, "prefix");
|
|
37
38
|
if (!candidate || !minimum)
|
|
38
39
|
return false;
|
|
39
|
-
|
|
40
|
-
const difference = (candidate[index] ?? 0) - (minimum[index] ?? 0);
|
|
41
|
-
if (difference !== 0)
|
|
42
|
-
return difference > 0;
|
|
43
|
-
}
|
|
44
|
-
return true;
|
|
45
|
-
}
|
|
46
|
-
function numericVersion(version) {
|
|
47
|
-
const match = /^(\d+)\.(\d+)\.(\d+)/u.exec(version);
|
|
48
|
-
if (!match)
|
|
49
|
-
return undefined;
|
|
50
|
-
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
40
|
+
return compareNumericVersions(candidate, minimum) >= 0;
|
|
51
41
|
}
|
|
52
42
|
/**
|
|
53
43
|
* Fail fast when the current process is not a supported Bun runtime.
|
|
@@ -10,6 +10,7 @@ import { type OperatorTransportSchedulerPort } from "../operator/transport.js";
|
|
|
10
10
|
import { type OperatorUpdateSourcePort } from "../operator/updates.js";
|
|
11
11
|
import { type BrokerLockOwner } from "./broker-lock.js";
|
|
12
12
|
export { systemWorkspaceBranch } from "./workspace-branch.js";
|
|
13
|
+
export { systemWorkspaceRoot } from "./workspace-root.js";
|
|
13
14
|
/** Schedules the service's timers, none of which may keep the process alive. */
|
|
14
15
|
export interface BrokerServiceSchedulerPort extends OperatorTransportSchedulerPort {
|
|
15
16
|
/**
|
|
@@ -9,13 +9,16 @@ import { startMcpServer } from "../messaging/mcp.js";
|
|
|
9
9
|
import { NotificationHub, NotificationScheduler } from "../messaging/notifications.js";
|
|
10
10
|
import { PushDeliveryService } from "../messaging/push-delivery.js";
|
|
11
11
|
import { OperatorCommandService } from "../operator/commands.js";
|
|
12
|
+
import { rememberRepositoryInspections } from "../operator/repository.js";
|
|
12
13
|
import { createSystemRepositoryProjectionDependencies } from "../operator/repository-system.js";
|
|
13
14
|
import { issueOperatorClientId, startOperatorTransport, } from "../operator/transport.js";
|
|
14
15
|
import { SqliteOperatorEventStore } from "../operator/update-store.js";
|
|
15
16
|
import { OperatorUpdateService } from "../operator/updates.js";
|
|
16
17
|
import { currentBrokerServiceIdentity } from "./broker-lock.js";
|
|
17
18
|
import { systemWorkspaceBranch } from "./workspace-branch.js";
|
|
19
|
+
import { systemWorkspaceRoot } from "./workspace-root.js";
|
|
18
20
|
export { systemWorkspaceBranch } from "./workspace-branch.js";
|
|
21
|
+
export { systemWorkspaceRoot } from "./workspace-root.js";
|
|
19
22
|
/** Tables whose rows can move the moment a sweep next has work to do. */
|
|
20
23
|
const SWEEP_DEADLINE_TABLES = ["agents", "sessions", "messages", "wakes"];
|
|
21
24
|
/** Sets how often the watchdog checks that the database this service serves still exists. */
|
|
@@ -26,6 +29,10 @@ const OPERATOR_HEARTBEAT_INTERVAL_MS = 5_000;
|
|
|
26
29
|
const OPERATOR_LIVENESS_TIMEOUT_MS = 15_000;
|
|
27
30
|
/** Sets the entropy of an operator control id, in bytes. */
|
|
28
31
|
const CONTROL_ID_BYTES = 12;
|
|
32
|
+
/** Bounds how long one directory's Git inspection stands in for a fresh one in operator projections. */
|
|
33
|
+
const REPOSITORY_INSPECTION_TTL_MS = 30_000;
|
|
34
|
+
/** Bounds each Git command the operator projection runs; one that runs longer is treated as having failed. */
|
|
35
|
+
const REPOSITORY_DISCOVERY_TIMEOUT_MS = 2_000;
|
|
29
36
|
/** Delays the retry after a sweep failed, so a persistent failure cannot spin on a due deadline. */
|
|
30
37
|
const SWEEP_FAILURE_RETRY_MS = 1_000;
|
|
31
38
|
/** Reports that a configured loopback endpoint could not take its port. */
|
|
@@ -137,6 +144,7 @@ export class BrokerService {
|
|
|
137
144
|
this.#broker = this.#options.openBroker(this.#databasePath, {
|
|
138
145
|
now,
|
|
139
146
|
workspaceBranch: systemWorkspaceBranch,
|
|
147
|
+
workspaceRoot: systemWorkspaceRoot,
|
|
140
148
|
report: this.#report,
|
|
141
149
|
...(config
|
|
142
150
|
? {
|
|
@@ -194,6 +202,13 @@ export class BrokerService {
|
|
|
194
202
|
forgetDeliveryAddress: (agentId) => {
|
|
195
203
|
pushDelivery.forget(agentId);
|
|
196
204
|
},
|
|
205
|
+
notifySessionClaimed: (claim) => {
|
|
206
|
+
notifications.publishSessionClaimed(claim.agentId, {
|
|
207
|
+
claimedAddress: claim.address,
|
|
208
|
+
claimantAddress: claim.retiredAddress,
|
|
209
|
+
claimantId: claim.retiredId,
|
|
210
|
+
});
|
|
211
|
+
},
|
|
197
212
|
},
|
|
198
213
|
host: bindHost,
|
|
199
214
|
healthIdentity: currentBrokerServiceIdentity(),
|
|
@@ -423,12 +438,19 @@ export class BrokerService {
|
|
|
423
438
|
const descriptors = new Map();
|
|
424
439
|
for (const descriptor of this.#implementations.descriptors())
|
|
425
440
|
descriptors.set(descriptor.id, descriptor);
|
|
441
|
+
const repositoryDiscovery = createSystemRepositoryProjectionDependencies({
|
|
442
|
+
gitTimeoutMs: REPOSITORY_DISCOVERY_TIMEOUT_MS,
|
|
443
|
+
});
|
|
426
444
|
const operatorUpdates = new OperatorUpdateService({
|
|
427
445
|
source: broker,
|
|
428
446
|
store,
|
|
429
447
|
now: this.#now,
|
|
430
448
|
projection: {
|
|
431
|
-
|
|
449
|
+
fileSystem: repositoryDiscovery.fileSystem,
|
|
450
|
+
git: rememberRepositoryInspections(repositoryDiscovery.git, {
|
|
451
|
+
now: this.#now,
|
|
452
|
+
ttlMs: REPOSITORY_INSPECTION_TTL_MS,
|
|
453
|
+
}),
|
|
432
454
|
implementations: {
|
|
433
455
|
find: (implementationId) => {
|
|
434
456
|
const descriptor = descriptors.get(implementationId);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Report the branch checked out at
|
|
2
|
+
* Report the branch checked out at an agent's working directory.
|
|
3
3
|
*
|
|
4
4
|
* A standalone module so a caller composing only the broker itself avoids pulling in the whole
|
|
5
5
|
* `BrokerService` module graph for one `git` lookup.
|
|
6
|
-
* @param path - Absolute
|
|
6
|
+
* @param path - Absolute working directory to inspect.
|
|
7
7
|
* @returns - Current branch name, or null when the path is not a repository, is detached, or the
|
|
8
|
-
* lookup did not answer within {@link WORKSPACE_BRANCH_TIMEOUT_MS}.
|
|
8
|
+
* lookup failed or did not answer within {@link WORKSPACE_BRANCH_TIMEOUT_MS}.
|
|
9
9
|
*/
|
|
10
10
|
export declare function systemWorkspaceBranch(path: string): string | null;
|
|
@@ -1,27 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
/**
|
|
3
|
-
* Bounds the branch lookup, which runs a subprocess against a path this process does not control.
|
|
4
|
-
*
|
|
5
|
-
* The branch is a label on a workspace row; nothing waits on it, so a slow answer is discarded
|
|
6
|
-
* rather than waited for.
|
|
7
|
-
*/
|
|
1
|
+
import { runGit } from "../operator/repository-system.js";
|
|
2
|
+
/** Bounds the branch lookup; a lookup that runs longer is treated as having failed. */
|
|
8
3
|
const WORKSPACE_BRANCH_TIMEOUT_MS = 2_000;
|
|
9
4
|
/**
|
|
10
|
-
* Report the branch checked out at
|
|
5
|
+
* Report the branch checked out at an agent's working directory.
|
|
11
6
|
*
|
|
12
7
|
* A standalone module so a caller composing only the broker itself avoids pulling in the whole
|
|
13
8
|
* `BrokerService` module graph for one `git` lookup.
|
|
14
|
-
* @param path - Absolute
|
|
9
|
+
* @param path - Absolute working directory to inspect.
|
|
15
10
|
* @returns - Current branch name, or null when the path is not a repository, is detached, or the
|
|
16
|
-
* lookup did not answer within {@link WORKSPACE_BRANCH_TIMEOUT_MS}.
|
|
11
|
+
* lookup failed or did not answer within {@link WORKSPACE_BRANCH_TIMEOUT_MS}.
|
|
17
12
|
*/
|
|
18
13
|
export function systemWorkspaceBranch(path) {
|
|
19
14
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
23
|
-
timeout: WORKSPACE_BRANCH_TIMEOUT_MS,
|
|
24
|
-
}).trim() || null);
|
|
15
|
+
const result = runGit("git", ["-C", path, "branch", "--show-current"], WORKSPACE_BRANCH_TIMEOUT_MS);
|
|
16
|
+
return result.kind === "ok" ? result.stdout.trim() || null : null;
|
|
25
17
|
}
|
|
26
18
|
catch {
|
|
27
19
|
return null;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report the directory that names the workspace a working directory belongs to.
|
|
3
|
+
*
|
|
4
|
+
* Inside a Git repository that is one directory for every worktree of the repository, so the main
|
|
5
|
+
* worktree, every linked worktree, and every subdirectory of any of them share one workspace: the
|
|
6
|
+
* directory holding the repository's `.git` or `.bare` directory.
|
|
7
|
+
* @param path - Absolute working directory to inspect.
|
|
8
|
+
* @returns - Canonical directory naming the repository containing the path, or the path itself when
|
|
9
|
+
* it is not inside a repository, its Git directory is not named `.git` or `.bare`, or it does not exist.
|
|
10
|
+
* @throws {RepositoryDiscoveryError} When Git cannot answer: it is missing, failed, or did not answer
|
|
11
|
+
* within {@link WORKSPACE_ROOT_TIMEOUT_MS}.
|
|
12
|
+
*/
|
|
13
|
+
export declare function systemWorkspaceRoot(path: string): string;
|