@gotgenes/pi-permission-system 26.1.0 → 26.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.
- package/CHANGELOG.md +17 -0
- package/docs/configuration.md +14 -14
- package/docs/subagent-integration.md +22 -3
- package/package.json +1 -1
- package/src/authority/approval-escalator.ts +17 -15
- package/src/authority/authorizer.ts +4 -4
- package/src/authority/forwarding-liveness.ts +450 -0
- package/src/authority/forwarding-manager.ts +21 -0
- package/src/authority/permission-forwarding.ts +9 -1
- package/src/authority/serving-registry.ts +33 -0
- package/src/index.ts +24 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [26.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.1.0...pi-permission-system-v26.2.0) (2026-08-17)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** publish a serving heartbeat while draining the inbox ([2055e35](https://github.com/gotgenes/pi-packages/commit/2055e353e147af6738ddc23c2c755f56f8032f8b)), closes [#721](https://github.com/gotgenes/pi-packages/issues/721)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* **pi-permission-system:** fail fast when an out-of-process parent is not serving ([e190af9](https://github.com/gotgenes/pi-packages/commit/e190af9f06c49375432f19bb228e068ade01c912)), closes [#721](https://github.com/gotgenes/pi-packages/issues/721)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Documentation
|
|
22
|
+
|
|
23
|
+
* **pi-permission-system:** document out-of-process forwarding liveness ([b5a50d5](https://github.com/gotgenes/pi-packages/commit/b5a50d535c97ca0ec388c5f482afb395e15289fa)), closes [#721](https://github.com/gotgenes/pi-packages/issues/721)
|
|
24
|
+
|
|
8
25
|
## [26.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.0.0...pi-permission-system-v26.1.0) (2026-08-17)
|
|
9
26
|
|
|
10
27
|
|
package/docs/configuration.md
CHANGED
|
@@ -97,20 +97,20 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
|
|
|
97
97
|
|
|
98
98
|
## Runtime Knobs
|
|
99
99
|
|
|
100
|
-
| Key | Default | Description
|
|
101
|
-
| --------------------------- | -------- |
|
|
102
|
-
| `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl`
|
|
103
|
-
| `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings unredacted — see [Log file sensitivity](#log-file-sensitivity)
|
|
104
|
-
| `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled
|
|
105
|
-
| `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press.
|
|
106
|
-
| `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose
|
|
107
|
-
| `promptMaxRows` | `24` | Max rows a permission prompt renders before eliding its evidence. The request's own facts are never elided by this budget; `Ctrl+O` expands the prompt to the complete request.
|
|
108
|
-
| `promptFieldMaxWidth` | `400` | Max characters of any one field shown in a permission prompt. This is what bounds a single long field (a here-string command, say) that would otherwise fill the prompt through wrapping.
|
|
109
|
-
| `reviewLogFieldMaxWidth` | `1000` | Max characters of any one value written to the review log. A longer value is stored shortened, marked with an ellipsis. Raise it to keep longer values; this is a length bound, not redaction.
|
|
110
|
-
| `toolInputPreviewMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning.
|
|
111
|
-
| `toolTextSummaryMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning.
|
|
112
|
-
| `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`).
|
|
113
|
-
| `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)).
|
|
100
|
+
| Key | Default | Description |
|
|
101
|
+
| --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
102
|
+
| `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
|
|
103
|
+
| `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings unredacted — see [Log file sensitivity](#log-file-sensitivity) |
|
|
104
|
+
| `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
|
|
105
|
+
| `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
|
|
106
|
+
| `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose parent is not draining its inbox gives up in ~2 s regardless, whether that parent runs in this process or its own. |
|
|
107
|
+
| `promptMaxRows` | `24` | Max rows a permission prompt renders before eliding its evidence. The request's own facts are never elided by this budget; `Ctrl+O` expands the prompt to the complete request. |
|
|
108
|
+
| `promptFieldMaxWidth` | `400` | Max characters of any one field shown in a permission prompt. This is what bounds a single long field (a here-string command, say) that would otherwise fill the prompt through wrapping. |
|
|
109
|
+
| `reviewLogFieldMaxWidth` | `1000` | Max characters of any one value written to the review log. A longer value is stored shortened, marked with an ellipsis. Raise it to keep longer values; this is a length bound, not redaction. |
|
|
110
|
+
| `toolInputPreviewMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
|
|
111
|
+
| `toolTextSummaryMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
|
|
112
|
+
| `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`). |
|
|
113
|
+
| `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). |
|
|
114
114
|
|
|
115
115
|
Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
|
|
116
116
|
No debug output is printed to the terminal.
|
|
@@ -35,7 +35,20 @@ For in-process child sessions, detection and forwarding use the event-driven reg
|
|
|
35
35
|
### When nobody answers
|
|
36
36
|
|
|
37
37
|
A forwarded request is only useful if some session is draining the inbox it was written into.
|
|
38
|
-
The polling session publishes the session id it polls, and
|
|
38
|
+
The polling session publishes the session id it polls, and a child checks that its target is published before committing to a long wait.
|
|
39
|
+
|
|
40
|
+
The announcement goes out on two channels, because a child cannot always reach the same one.
|
|
41
|
+
A child running inside its parent's process reads a process-global registry.
|
|
42
|
+
A child running as a separate `pi` process (the `PI_SUBAGENT_PARENT_SESSION` path) shares no memory with its parent, so it reads a heartbeat record the serving session refreshes under `<agent dir>/sessions/permission-forwarding/serving/`, holding the served session id, the serving process id, and the time it was last refreshed.
|
|
43
|
+
|
|
44
|
+
For an out-of-process target, four things count as "not draining":
|
|
45
|
+
|
|
46
|
+
| What the child finds | What it means |
|
|
47
|
+
| ------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
48
|
+
| No record | The parent exited, never served, or runs a version that does not publish one |
|
|
49
|
+
| A record naming a process that is gone | The parent was killed rather than shut down |
|
|
50
|
+
| A record nobody has refreshed for several seconds | The parent's process survives but has stopped polling |
|
|
51
|
+
| A record for a different session id | The child is forwarding somewhere nobody is listening |
|
|
39
52
|
|
|
40
53
|
If the target is not draining its inbox, the child gives up after a two-second grace window rather than waiting out `forwardingTimeoutMs`, and the tool is blocked with:
|
|
41
54
|
|
|
@@ -47,6 +60,7 @@ permission requests.
|
|
|
47
60
|
|
|
48
61
|
The grace window exists so a request that arrives while the parent is switching sessions is not abandoned in the gap.
|
|
49
62
|
A target that *is* draining its inbox is waited on for the full `forwardingTimeoutMs`, however long the human takes to decide.
|
|
63
|
+
That includes a parent whose human is still deliberating at an earlier forwarded prompt: it keeps refreshing its heartbeat throughout, so a second child does not read it as gone.
|
|
50
64
|
|
|
51
65
|
Every other way the forwarding path can give up — an unresolvable parent session, forwarding directories that cannot be created, a request that cannot be written, an unreadable response, and the timeout itself — is reported the same way: as approval being unavailable, with a reason naming the specific failure.
|
|
52
66
|
None of them is reported as a user denial, because no user was ever asked.
|
|
@@ -68,8 +82,13 @@ The serving node records its decider on the response — a rule of its own (with
|
|
|
68
82
|
That is the difference between a human approving a subagent's request and the parent's policy approving it on their behalf — two outcomes that were previously indistinguishable in the log.
|
|
69
83
|
An older parent that sends no decider yields `"decision": null`: the hop is still recorded, and the answer is still honored.
|
|
70
84
|
|
|
71
|
-
|
|
72
|
-
|
|
85
|
+
### Upgrading
|
|
86
|
+
|
|
87
|
+
Upgrade the parent before relying on the out-of-process signal — in practice, restart the interactive session after upgrading the package.
|
|
88
|
+
|
|
89
|
+
A parent session still running a version that predates the heartbeat publishes none, and a child on a version that expects one reads that absence as "not draining" and gives up in about two seconds.
|
|
90
|
+
That only happens in the window where an upgrade lands while a parent session is already running, and it resolves as soon as that session restarts.
|
|
91
|
+
Nothing needs to be edited, and in-process children are unaffected: parent and child there are the same running copy by construction.
|
|
73
92
|
|
|
74
93
|
---
|
|
75
94
|
|
package/package.json
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
sleep,
|
|
20
20
|
writeJsonFileAtomic,
|
|
21
21
|
} from "#src/authority/forwarding-io";
|
|
22
|
+
import type { TargetServingLookup } from "#src/authority/forwarding-liveness";
|
|
22
23
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
23
24
|
import {
|
|
24
25
|
type ForwardedAccessFacts,
|
|
@@ -33,7 +34,6 @@ import {
|
|
|
33
34
|
resolvePermissionForwardingTarget,
|
|
34
35
|
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
|
35
36
|
} from "#src/authority/permission-forwarding";
|
|
36
|
-
import type { ServingLookup } from "#src/authority/serving-registry";
|
|
37
37
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
38
38
|
import { createPermissionRequestId } from "#src/permission-request-id";
|
|
39
39
|
import { buildUiPrompt } from "#src/permission-ui-prompt";
|
|
@@ -97,8 +97,8 @@ export interface ParentAuthorizerDeps {
|
|
|
97
97
|
forwardingDir: string;
|
|
98
98
|
/** In-process subagent session registry for forwarding target resolution. */
|
|
99
99
|
registry?: SubagentSessionRegistry;
|
|
100
|
-
/** Whether the resolved target is draining its inbox
|
|
101
|
-
serving:
|
|
100
|
+
/** Whether the resolved target is draining its inbox, on whichever channel can say. */
|
|
101
|
+
serving: TargetServingLookup;
|
|
102
102
|
/** How long to wait for the target's answer, read live so config edits apply. */
|
|
103
103
|
getTimeoutMs: () => number;
|
|
104
104
|
logger: DebugReviewLogger;
|
|
@@ -181,7 +181,7 @@ function forwardableRequestId(requesterRequestId: string): string {
|
|
|
181
181
|
export class ParentAuthorizer implements TerminalAuthorizer {
|
|
182
182
|
private readonly forwardingDir: string;
|
|
183
183
|
private readonly registry: SubagentSessionRegistry | undefined;
|
|
184
|
-
private readonly serving:
|
|
184
|
+
private readonly serving: TargetServingLookup;
|
|
185
185
|
private readonly getTimeoutMs: () => number;
|
|
186
186
|
private readonly logger: DebugReviewLogger;
|
|
187
187
|
|
|
@@ -387,11 +387,17 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
387
387
|
unservedSince !== null &&
|
|
388
388
|
Date.now() - unservedSince >= PERMISSION_FORWARDING_SERVING_GRACE_MS
|
|
389
389
|
) {
|
|
390
|
+
const observation = this.serving.describe(target);
|
|
390
391
|
this.logger.review("forwarded_permission.no_serving_session", {
|
|
391
392
|
requestId,
|
|
392
393
|
requesterSessionId: request.requesterSessionId,
|
|
393
394
|
targetSessionId,
|
|
394
|
-
|
|
395
|
+
// Which channel answered, and what it saw: the difference between a
|
|
396
|
+
// parent that exited, one that was killed, and one polling under a
|
|
397
|
+
// different session id is the whole diagnosis of a stalled forward.
|
|
398
|
+
servingChannel: observation.channel,
|
|
399
|
+
servingState: observation.state,
|
|
400
|
+
servingSessionIds: observation.servingIds,
|
|
395
401
|
});
|
|
396
402
|
this.discardRequest(location, requestPath);
|
|
397
403
|
return abandon(
|
|
@@ -421,21 +427,17 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
421
427
|
/**
|
|
422
428
|
* Track how long the target has looked unserved, or `null` while it looks fine.
|
|
423
429
|
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
430
|
+
* Which channel can answer for this target is the judge's decision, not this
|
|
431
|
+
* one's: a target it cannot judge answers `null`, which resets the window
|
|
432
|
+
* exactly as "serving" does, so an unjudgeable target waits out the timeout.
|
|
427
433
|
*/
|
|
428
434
|
private checkServingLiveness(
|
|
429
435
|
target: PermissionForwardingTarget,
|
|
430
436
|
unservedSince: number | null,
|
|
431
437
|
): number | null {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
if (this.serving.isServing(target.sessionId)) {
|
|
436
|
-
return null;
|
|
437
|
-
}
|
|
438
|
-
return unservedSince ?? Date.now();
|
|
438
|
+
return this.serving.isServing(target) === false
|
|
439
|
+
? (unservedSince ?? Date.now())
|
|
440
|
+
: null;
|
|
439
441
|
}
|
|
440
442
|
|
|
441
443
|
/**
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TargetServingLookup } from "#src/authority/forwarding-liveness";
|
|
2
3
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
3
4
|
import type {
|
|
4
5
|
PromptPreferences,
|
|
5
6
|
requestPermissionDecision,
|
|
6
7
|
} from "#src/authority/permission-prompt-component";
|
|
7
|
-
import type { ServingLookup } from "#src/authority/serving-registry";
|
|
8
8
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
9
9
|
import type { PermissionEventBus } from "#src/permission-events";
|
|
10
10
|
import type { AuthorizerLog, PermissionQuery } from "#src/service";
|
|
@@ -108,8 +108,8 @@ export interface AuthorizerSelectionDeps {
|
|
|
108
108
|
forwardingDir: string;
|
|
109
109
|
/** In-process subagent session registry for forwarding target resolution. */
|
|
110
110
|
registry?: SubagentSessionRegistry;
|
|
111
|
-
/**
|
|
112
|
-
|
|
111
|
+
/** Whether a forwarding target is draining its inbox, on whichever channel can say. */
|
|
112
|
+
serving: TargetServingLookup;
|
|
113
113
|
/** The forwarding timeout, read live so a config edit applies to the next ask. */
|
|
114
114
|
getForwardingTimeoutMs: () => number;
|
|
115
115
|
logger: DebugReviewLogger;
|
|
@@ -145,7 +145,7 @@ export function selectAuthorizer(
|
|
|
145
145
|
terminal: new ParentAuthorizer(ctx, {
|
|
146
146
|
forwardingDir: deps.forwardingDir,
|
|
147
147
|
registry: deps.registry,
|
|
148
|
-
serving: deps.
|
|
148
|
+
serving: deps.serving,
|
|
149
149
|
getTimeoutMs: deps.getForwardingTimeoutMs,
|
|
150
150
|
logger: deps.logger,
|
|
151
151
|
}),
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forwarding-liveness.ts — Is anyone draining a forwarded-permission inbox?
|
|
3
|
+
*
|
|
4
|
+
* The in-process answer already exists: a serving session marks itself in the
|
|
5
|
+
* process-global `ServingSessionRegistry`, and an in-process child abandons a
|
|
6
|
+
* target that has looked unmarked for the grace window instead of waiting out
|
|
7
|
+
* the full forwarding timeout (#719).
|
|
8
|
+
*
|
|
9
|
+
* A child spawned as a separate `pi` process shares no `globalThis` with its
|
|
10
|
+
* parent, so that mark is invisible to it and it keeps waiting the full ten
|
|
11
|
+
* minutes — every `ask` forwarded to a session that has already exited costs
|
|
12
|
+
* the whole timeout and ends in a denial nobody made (#735 scenario 1).
|
|
13
|
+
*
|
|
14
|
+
* The filesystem is the only channel those two processes share, so the serving
|
|
15
|
+
* session publishes a heartbeat there: one record per serving session,
|
|
16
|
+
* refreshed while it polls and withdrawn when it stops.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import {
|
|
22
|
+
ensureDirectoryExists,
|
|
23
|
+
isErrnoCode,
|
|
24
|
+
logPermissionForwardingError,
|
|
25
|
+
safeDeleteFile,
|
|
26
|
+
writeJsonFileAtomic,
|
|
27
|
+
} from "#src/authority/forwarding-io";
|
|
28
|
+
import type { PermissionForwardingTarget } from "#src/authority/permission-forwarding";
|
|
29
|
+
import {
|
|
30
|
+
encodeSessionIdForPath,
|
|
31
|
+
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
|
32
|
+
} from "#src/authority/permission-forwarding";
|
|
33
|
+
import type {
|
|
34
|
+
ServingAnnouncer,
|
|
35
|
+
ServingLookup,
|
|
36
|
+
} from "#src/authority/serving-registry";
|
|
37
|
+
import type { DebugReviewLogger } from "#src/session-logger";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How often a serving session rewrites its heartbeat — four poll ticks.
|
|
41
|
+
*
|
|
42
|
+
* Longer than the poll interval so `ForwardingManager` can announce on every
|
|
43
|
+
* tick without four filesystem writes a second, and short enough that a record
|
|
44
|
+
* deleted underneath its owner reappears well inside the grace window a
|
|
45
|
+
* forwarding child waits out before abandoning.
|
|
46
|
+
*/
|
|
47
|
+
export const SERVING_HEARTBEAT_REFRESH_MS =
|
|
48
|
+
4 * PERMISSION_FORWARDING_POLL_INTERVAL_MS;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How long a heartbeat may go unrefreshed before its writer is presumed gone —
|
|
52
|
+
* five refreshes.
|
|
53
|
+
*
|
|
54
|
+
* Generous because a delayed Node timer is not a dead session, and because the
|
|
55
|
+
* case this threshold exists for (a process that is alive but no longer
|
|
56
|
+
* polling) is the rare one: an exited session withdraws its record and a killed
|
|
57
|
+
* one is caught by the recorded pid, neither of which waits for staleness.
|
|
58
|
+
*/
|
|
59
|
+
export const SERVING_HEARTBEAT_STALE_MS = 5 * SERVING_HEARTBEAT_REFRESH_MS;
|
|
60
|
+
|
|
61
|
+
/** What a serving session publishes while it drains its forwarded-permission inbox. */
|
|
62
|
+
export interface ServingHeartbeat {
|
|
63
|
+
sessionId: string;
|
|
64
|
+
/** The serving process, so a killed session is detectable without waiting out staleness. */
|
|
65
|
+
pid: number;
|
|
66
|
+
updatedAt: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How a session's heartbeat reads right now.
|
|
71
|
+
*
|
|
72
|
+
* Only `"alive"` means someone is draining the inbox. The other three are the
|
|
73
|
+
* ways a target can be unserved, kept apart because they are the diagnosis a
|
|
74
|
+
* stalled forward needs: `"absent"` is a session that exited (or never served,
|
|
75
|
+
* or runs a version that does not publish), `"dead_pid"` one that was killed,
|
|
76
|
+
* and `"stale"` one whose process survives but stopped polling.
|
|
77
|
+
*/
|
|
78
|
+
export type HeartbeatState = "alive" | "absent" | "stale" | "dead_pid";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read side of the heartbeat channel, consumed by a forwarding child.
|
|
82
|
+
*
|
|
83
|
+
* Separate from the announce seam because the two have no caller in common: a
|
|
84
|
+
* serving session only publishes, and a forwarding child only reads (ISP).
|
|
85
|
+
*/
|
|
86
|
+
export interface HeartbeatReader {
|
|
87
|
+
read(sessionId: string): HeartbeatState;
|
|
88
|
+
/** Every session whose record reads as alive, for the abandonment diagnostic. */
|
|
89
|
+
servingIds(): readonly string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Query-side seam: is the session a forwarding target names being drained?
|
|
94
|
+
*
|
|
95
|
+
* Keyed on the target rather than a session id because the answer depends on
|
|
96
|
+
* how the target was resolved. An in-process child and its parent share a
|
|
97
|
+
* `globalThis`, so the registry answers for them; an out-of-process pair shares
|
|
98
|
+
* only the filesystem; and a session that owns the inbox it is forwarding to is
|
|
99
|
+
* not a case either channel describes.
|
|
100
|
+
*
|
|
101
|
+
* Consolidating that into one collaborator is what keeps `ParentAuthorizer`
|
|
102
|
+
* from holding two lookups and re-deciding which one applies — the decision has
|
|
103
|
+
* one home, and a third channel would not reach the poll loop.
|
|
104
|
+
*/
|
|
105
|
+
export interface TargetServingLookup {
|
|
106
|
+
/** `true` serving, `false` not serving, `null` when the target carries no signal. */
|
|
107
|
+
isServing(target: PermissionForwardingTarget): boolean | null;
|
|
108
|
+
/** What the judge observed, for the review entry a child writes when it gives up. */
|
|
109
|
+
describe(target: PermissionForwardingTarget): ServingObservation;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What answered a liveness question, and what it saw. */
|
|
113
|
+
export interface ServingObservation {
|
|
114
|
+
channel: "registry" | "heartbeat" | "none";
|
|
115
|
+
/** The heartbeat state behind a `"heartbeat"` answer; `null` on the other channels. */
|
|
116
|
+
state: HeartbeatState | null;
|
|
117
|
+
servingIds: readonly string[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Constructor config for {@link ForwardingLivenessJudge}. */
|
|
121
|
+
export interface ForwardingLivenessJudgeDeps {
|
|
122
|
+
/** Answers for a target the requester shares a process with. */
|
|
123
|
+
registry: ServingLookup;
|
|
124
|
+
/** Answers for a target in another process. */
|
|
125
|
+
heartbeats: HeartbeatReader;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Routes a liveness question to the channel that can answer it.
|
|
130
|
+
*
|
|
131
|
+
* The routing key is `PermissionForwardingTarget.source`, which the resolver
|
|
132
|
+
* already produces — so "in-process" is decided once, where the target is
|
|
133
|
+
* found, rather than re-derived here (#719).
|
|
134
|
+
*/
|
|
135
|
+
export class ForwardingLivenessJudge implements TargetServingLookup {
|
|
136
|
+
constructor(private readonly deps: ForwardingLivenessJudgeDeps) {}
|
|
137
|
+
|
|
138
|
+
isServing(target: PermissionForwardingTarget): boolean | null {
|
|
139
|
+
switch (target.source) {
|
|
140
|
+
case "registry":
|
|
141
|
+
return this.deps.registry.isServing(target.sessionId);
|
|
142
|
+
case "env":
|
|
143
|
+
return this.deps.heartbeats.read(target.sessionId) === "alive";
|
|
144
|
+
case "self":
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe(target: PermissionForwardingTarget): ServingObservation {
|
|
150
|
+
switch (target.source) {
|
|
151
|
+
case "registry":
|
|
152
|
+
return {
|
|
153
|
+
channel: "registry",
|
|
154
|
+
state: null,
|
|
155
|
+
servingIds: this.deps.registry.servingIds(),
|
|
156
|
+
};
|
|
157
|
+
case "env":
|
|
158
|
+
return {
|
|
159
|
+
channel: "heartbeat",
|
|
160
|
+
state: this.deps.heartbeats.read(target.sessionId),
|
|
161
|
+
servingIds: this.deps.heartbeats.servingIds(),
|
|
162
|
+
};
|
|
163
|
+
case "self":
|
|
164
|
+
return { channel: "none", state: null, servingIds: [] };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const SERVING_HEARTBEAT_DIRECTORY_NAME = "serving";
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Where serving heartbeats live: beside the `sessions/` tree, never inside it.
|
|
173
|
+
*
|
|
174
|
+
* A heartbeat under `sessions/<id>/` would make that session root permanently
|
|
175
|
+
* non-empty, entangling liveness with the request/response cleanup whose
|
|
176
|
+
* removal ordering already produced an ENOENT write loop (#398). Kept disjoint,
|
|
177
|
+
* that logic stays untouched and "who is serving" is a single directory read.
|
|
178
|
+
*/
|
|
179
|
+
export function servingHeartbeatDir(forwardingDir: string): string {
|
|
180
|
+
return join(forwardingDir, SERVING_HEARTBEAT_DIRECTORY_NAME);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The heartbeat record for `sessionId`, under {@link servingHeartbeatDir}. */
|
|
184
|
+
export function servingHeartbeatPath(
|
|
185
|
+
forwardingDir: string,
|
|
186
|
+
sessionId: string,
|
|
187
|
+
): string {
|
|
188
|
+
return join(
|
|
189
|
+
servingHeartbeatDir(forwardingDir),
|
|
190
|
+
`${encodeSessionIdForPath(sessionId)}.json`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Constructor config for {@link ServingHeartbeatStore}. */
|
|
195
|
+
export interface ServingHeartbeatStoreDeps {
|
|
196
|
+
forwardingDir: string;
|
|
197
|
+
logger: DebugReviewLogger;
|
|
198
|
+
/** Injected so the refresh throttle and staleness are testable without sleeping. */
|
|
199
|
+
now?: () => number;
|
|
200
|
+
/** The process to record. Injected so a test can publish a pid it controls. */
|
|
201
|
+
pid?: number;
|
|
202
|
+
/** Injected so a test can decide which pids are running. */
|
|
203
|
+
isProcessAlive?: (pid: number) => boolean;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Publishes this session's serving heartbeat to the filesystem.
|
|
208
|
+
*
|
|
209
|
+
* Satisfies the same {@link ServingAnnouncer} seam as `ServingSessionRegistry`,
|
|
210
|
+
* so `ForwardingManager` announces to both channels through one collaborator
|
|
211
|
+
* and neither knows the other exists.
|
|
212
|
+
*
|
|
213
|
+
* `markServing` is idempotent by that seam's contract and internally throttled,
|
|
214
|
+
* so the caller may announce on every poll tick. Nothing here throws: it runs
|
|
215
|
+
* from a timer, and a filesystem failure must degrade to the pre-existing
|
|
216
|
+
* timeout rather than break the poll loop.
|
|
217
|
+
*/
|
|
218
|
+
export class ServingHeartbeatStore
|
|
219
|
+
implements ServingAnnouncer, HeartbeatReader
|
|
220
|
+
{
|
|
221
|
+
private readonly forwardingDir: string;
|
|
222
|
+
private readonly logger: DebugReviewLogger;
|
|
223
|
+
private readonly now: () => number;
|
|
224
|
+
private readonly pid: number;
|
|
225
|
+
private readonly isProcessAlive: (pid: number) => boolean;
|
|
226
|
+
private published: { sessionId: string; at: number } | null = null;
|
|
227
|
+
private hasSweptDeadRecords = false;
|
|
228
|
+
|
|
229
|
+
constructor(deps: ServingHeartbeatStoreDeps) {
|
|
230
|
+
this.forwardingDir = deps.forwardingDir;
|
|
231
|
+
this.logger = deps.logger;
|
|
232
|
+
this.now = deps.now ?? Date.now;
|
|
233
|
+
this.pid = deps.pid ?? process.pid;
|
|
234
|
+
this.isProcessAlive = deps.isProcessAlive ?? isRunningProcess;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Publish (or refresh) `sessionId`'s heartbeat. Throttled; never throws. */
|
|
238
|
+
markServing(sessionId: string): void {
|
|
239
|
+
const at = this.now();
|
|
240
|
+
if (this.isThrottled(sessionId, at)) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const directory = servingHeartbeatDir(this.forwardingDir);
|
|
245
|
+
if (
|
|
246
|
+
!ensureDirectoryExists(
|
|
247
|
+
this.logger,
|
|
248
|
+
directory,
|
|
249
|
+
"permission forwarding serving heartbeat",
|
|
250
|
+
)
|
|
251
|
+
) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.sweepDeadRecordsOnce();
|
|
255
|
+
|
|
256
|
+
const heartbeat: ServingHeartbeat = {
|
|
257
|
+
sessionId,
|
|
258
|
+
pid: this.pid,
|
|
259
|
+
updatedAt: at,
|
|
260
|
+
};
|
|
261
|
+
try {
|
|
262
|
+
writeJsonFileAtomic(
|
|
263
|
+
this.logger,
|
|
264
|
+
servingHeartbeatPath(this.forwardingDir, sessionId),
|
|
265
|
+
heartbeat,
|
|
266
|
+
);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
logPermissionForwardingError(
|
|
269
|
+
this.logger,
|
|
270
|
+
`Failed to publish the serving heartbeat for session '${sessionId}'`,
|
|
271
|
+
error,
|
|
272
|
+
);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.published = { sessionId, at };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Withdraw `sessionId`'s heartbeat, leaving the directory for its siblings. */
|
|
279
|
+
clearServing(sessionId: string): void {
|
|
280
|
+
if (this.published?.sessionId === sessionId) {
|
|
281
|
+
this.published = null;
|
|
282
|
+
}
|
|
283
|
+
safeDeleteFile(
|
|
284
|
+
this.logger,
|
|
285
|
+
servingHeartbeatPath(this.forwardingDir, sessionId),
|
|
286
|
+
"permission forwarding serving heartbeat",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** How `sessionId`'s heartbeat reads right now. */
|
|
291
|
+
read(sessionId: string): HeartbeatState {
|
|
292
|
+
const record = this.readRecord(
|
|
293
|
+
servingHeartbeatPath(this.forwardingDir, sessionId),
|
|
294
|
+
);
|
|
295
|
+
return record === null ? "absent" : this.classify(record);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Every session whose record reads as alive. */
|
|
299
|
+
servingIds(): readonly string[] {
|
|
300
|
+
const ids: string[] = [];
|
|
301
|
+
for (const { record } of this.listRecords()) {
|
|
302
|
+
if (record !== null && this.classify(record) === "alive") {
|
|
303
|
+
ids.push(record.sessionId);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return ids;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Private methods ────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Delete the records of processes that are provably gone, once per session.
|
|
313
|
+
*
|
|
314
|
+
* Without this the directory grows one record per session that was killed
|
|
315
|
+
* rather than shut down, forever. Bounded to a single directory read at the
|
|
316
|
+
* first announcement, and safe under pid reuse: a wrongly swept owner
|
|
317
|
+
* republishes within the refresh window, which is shorter than the grace a
|
|
318
|
+
* forwarding child waits out.
|
|
319
|
+
*
|
|
320
|
+
* Only a dead pid is proof. A record that is merely stale belongs to a
|
|
321
|
+
* process that still exists, and the reader already reports it as stale
|
|
322
|
+
* without anyone having to remove it.
|
|
323
|
+
*/
|
|
324
|
+
private sweepDeadRecordsOnce(): void {
|
|
325
|
+
if (this.hasSweptDeadRecords) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
this.hasSweptDeadRecords = true;
|
|
329
|
+
for (const { path, record } of this.listRecords()) {
|
|
330
|
+
if (record !== null && this.isProcessAlive(record.pid)) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
safeDeleteFile(
|
|
334
|
+
this.logger,
|
|
335
|
+
path,
|
|
336
|
+
"abandoned permission forwarding serving heartbeat",
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Every published record, paired with its path; unusable ones read as `null`. */
|
|
342
|
+
private listRecords(): {
|
|
343
|
+
path: string;
|
|
344
|
+
record: ServingHeartbeat | null;
|
|
345
|
+
}[] {
|
|
346
|
+
const directory = servingHeartbeatDir(this.forwardingDir);
|
|
347
|
+
let names: string[];
|
|
348
|
+
try {
|
|
349
|
+
names = readdirSync(directory);
|
|
350
|
+
} catch {
|
|
351
|
+
return [];
|
|
352
|
+
}
|
|
353
|
+
return names
|
|
354
|
+
.filter((name) => name.endsWith(".json"))
|
|
355
|
+
.map((name) => {
|
|
356
|
+
const path = join(directory, name);
|
|
357
|
+
return { path, record: this.readRecord(path) };
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Read a record, or `null` when it is missing or unusable.
|
|
363
|
+
*
|
|
364
|
+
* Silent by design: a forwarding child calls this on every poll tick, so a
|
|
365
|
+
* warning per unreadable read would flood the review log at four lines a
|
|
366
|
+
* second. The unusability is already reported once, as the `absent` state on
|
|
367
|
+
* the abandonment entry.
|
|
368
|
+
*/
|
|
369
|
+
private readRecord(path: string): ServingHeartbeat | null {
|
|
370
|
+
try {
|
|
371
|
+
return asServingHeartbeat(JSON.parse(readFileSync(path, "utf-8")));
|
|
372
|
+
} catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Which of the four states a well-formed record is in. */
|
|
378
|
+
private classify(record: ServingHeartbeat): HeartbeatState {
|
|
379
|
+
if (!this.isProcessAlive(record.pid)) {
|
|
380
|
+
return "dead_pid";
|
|
381
|
+
}
|
|
382
|
+
return this.now() - record.updatedAt >= SERVING_HEARTBEAT_STALE_MS
|
|
383
|
+
? "stale"
|
|
384
|
+
: "alive";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Whether the record on disk is recent enough to leave alone.
|
|
389
|
+
*
|
|
390
|
+
* Time alone, with no existence probe: an existence check would cost a
|
|
391
|
+
* syscall on every poll tick to save at most one refresh window, and a record
|
|
392
|
+
* removed underneath its owner reappears inside the grace window anyway.
|
|
393
|
+
*/
|
|
394
|
+
private isThrottled(sessionId: string, at: number): boolean {
|
|
395
|
+
return (
|
|
396
|
+
this.published !== null &&
|
|
397
|
+
this.published.sessionId === sessionId &&
|
|
398
|
+
at - this.published.at < SERVING_HEARTBEAT_REFRESH_MS
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── Module-private helpers ────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Narrow a parsed record, or `undefined`.
|
|
407
|
+
*
|
|
408
|
+
* `pid` must be a positive integer specifically: `process.kill(0, 0)` addresses
|
|
409
|
+
* the caller's own process group and `kill(-n)` a foreign one, so a malformed
|
|
410
|
+
* record must be rejected before it can reach the liveness probe.
|
|
411
|
+
*/
|
|
412
|
+
function asServingHeartbeat(value: unknown): ServingHeartbeat | null {
|
|
413
|
+
if (typeof value !== "object" || value === null) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
const candidate = value as Partial<ServingHeartbeat>;
|
|
417
|
+
if (
|
|
418
|
+
typeof candidate.sessionId !== "string" ||
|
|
419
|
+
candidate.sessionId.length === 0 ||
|
|
420
|
+
typeof candidate.pid !== "number" ||
|
|
421
|
+
!Number.isInteger(candidate.pid) ||
|
|
422
|
+
candidate.pid <= 0 ||
|
|
423
|
+
typeof candidate.updatedAt !== "number" ||
|
|
424
|
+
!Number.isFinite(candidate.updatedAt)
|
|
425
|
+
) {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
sessionId: candidate.sessionId,
|
|
430
|
+
pid: candidate.pid,
|
|
431
|
+
updatedAt: candidate.updatedAt,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Whether `pid` names a running process.
|
|
437
|
+
*
|
|
438
|
+
* Signal `0` performs the permission and existence checks without delivering
|
|
439
|
+
* anything. `EPERM` means the process exists under another user — reported as
|
|
440
|
+
* alive, the direction that falls back to the timeout rather than abandoning a
|
|
441
|
+
* request someone may still answer.
|
|
442
|
+
*/
|
|
443
|
+
function isRunningProcess(pid: number): boolean {
|
|
444
|
+
try {
|
|
445
|
+
process.kill(pid, 0);
|
|
446
|
+
return true;
|
|
447
|
+
} catch (error) {
|
|
448
|
+
return isErrnoCode(error, "EPERM");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
@@ -65,6 +65,12 @@ export class ForwardingManager {
|
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
67
|
this.timer = setInterval(() => {
|
|
68
|
+
// Ahead of the processing guard: a session whose human is deliberating at
|
|
69
|
+
// a forwarded dialog holds `processInbox` open for as long as they take,
|
|
70
|
+
// and it is serving throughout. Refreshing behind the guard would let its
|
|
71
|
+
// announcement decay exactly when it is most demonstrably alive, and
|
|
72
|
+
// every other forwarding child would give up on it.
|
|
73
|
+
this.refreshServing();
|
|
68
74
|
if (!this.context || this.processing) {
|
|
69
75
|
return;
|
|
70
76
|
}
|
|
@@ -107,6 +113,21 @@ export class ForwardingManager {
|
|
|
107
113
|
});
|
|
108
114
|
}
|
|
109
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Re-announce the served session, keeping a decayable channel current.
|
|
118
|
+
*
|
|
119
|
+
* Separate from {@link announceServing} because that one detects a change to
|
|
120
|
+
* write its log line, and this one deliberately writes none — four review
|
|
121
|
+
* entries a second would drown the log the announcement exists to make
|
|
122
|
+
* readable.
|
|
123
|
+
*/
|
|
124
|
+
private refreshServing(): void {
|
|
125
|
+
if (this.servingSessionId === null) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.deps.serving.markServing(this.servingSessionId);
|
|
129
|
+
}
|
|
130
|
+
|
|
110
131
|
/** Withdraw the published session, if any. */
|
|
111
132
|
private withdrawServing(): void {
|
|
112
133
|
const sessionId = this.servingSessionId;
|
|
@@ -201,7 +201,15 @@ export function normalizePermissionForwardingSessionId(
|
|
|
201
201
|
return trimmed;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
|
|
204
|
+
/**
|
|
205
|
+
* Make a session id safe to name a path segment.
|
|
206
|
+
*
|
|
207
|
+
* Exported because the forwarding tree has two layouts keyed by session id —
|
|
208
|
+
* `sessions/<id>/` and the serving-heartbeat records beside it — and a second
|
|
209
|
+
* encoding would be a silent way for the two to disagree about which file
|
|
210
|
+
* belongs to which session.
|
|
211
|
+
*/
|
|
212
|
+
export function encodeSessionIdForPath(sessionId: string): string {
|
|
205
213
|
return encodeURIComponent(sessionId);
|
|
206
214
|
}
|
|
207
215
|
|
|
@@ -35,10 +35,43 @@ export const SERVING_SESSION_REGISTRY_KEY = Symbol.for(
|
|
|
35
35
|
* neither reads the store nor gains a query it has no business making (ISP).
|
|
36
36
|
*/
|
|
37
37
|
export interface ServingAnnouncer {
|
|
38
|
+
/**
|
|
39
|
+
* Record that `sessionId` is polling its inbox.
|
|
40
|
+
*
|
|
41
|
+
* Idempotent, and called on every poll tick rather than once per session: an
|
|
42
|
+
* announcement that can decay (the filesystem heartbeat) has to be kept
|
|
43
|
+
* current, and one that cannot (this registry) costs a set insertion to say
|
|
44
|
+
* so again.
|
|
45
|
+
*/
|
|
38
46
|
markServing(sessionId: string): void;
|
|
39
47
|
clearServing(sessionId: string): void;
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Fan an announcement out to every channel a serving session publishes on.
|
|
52
|
+
*
|
|
53
|
+
* A session announces to the process-global registry (for its in-process
|
|
54
|
+
* children) and to the filesystem (for children in other processes). Composing
|
|
55
|
+
* them keeps `ForwardingManager` holding one collaborator, so adding or
|
|
56
|
+
* removing a channel never reaches the poll loop.
|
|
57
|
+
*/
|
|
58
|
+
export function composeServingAnnouncers(
|
|
59
|
+
...announcers: readonly ServingAnnouncer[]
|
|
60
|
+
): ServingAnnouncer {
|
|
61
|
+
return {
|
|
62
|
+
markServing(sessionId: string): void {
|
|
63
|
+
for (const announcer of announcers) {
|
|
64
|
+
announcer.markServing(sessionId);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
clearServing(sessionId: string): void {
|
|
68
|
+
for (const announcer of announcers) {
|
|
69
|
+
announcer.clearServing(sessionId);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
42
75
|
/**
|
|
43
76
|
* Query-side seam: a forwarding child asks whether its target is draining.
|
|
44
77
|
*
|
package/src/index.ts
CHANGED
|
@@ -8,11 +8,18 @@ import {
|
|
|
8
8
|
ForwardedRequestServer,
|
|
9
9
|
type ServingPolicy,
|
|
10
10
|
} from "./authority/forwarded-request-server";
|
|
11
|
+
import {
|
|
12
|
+
ForwardingLivenessJudge,
|
|
13
|
+
ServingHeartbeatStore,
|
|
14
|
+
} from "./authority/forwarding-liveness";
|
|
11
15
|
import { ForwardingManager } from "./authority/forwarding-manager";
|
|
12
16
|
import { PERMISSION_FORWARDING_TIMEOUT_MS } from "./authority/permission-forwarding";
|
|
13
17
|
import { requestPermissionDecision } from "./authority/permission-prompt-component";
|
|
14
18
|
import { PermissionPrompter } from "./authority/permission-prompter";
|
|
15
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
composeServingAnnouncers,
|
|
21
|
+
getServingSessionRegistry,
|
|
22
|
+
} from "./authority/serving-registry";
|
|
16
23
|
import { SubagentDetection } from "./authority/subagent-detection";
|
|
17
24
|
import { subscribeSubagentLifecycle } from "./authority/subagent-lifecycle-events";
|
|
18
25
|
import { getSubagentSessionRegistry } from "./authority/subagent-registry";
|
|
@@ -111,6 +118,20 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
111
118
|
|
|
112
119
|
const prompter = new PermissionPrompter({ logger });
|
|
113
120
|
|
|
121
|
+
// The filesystem half of the serving announcement. `servingRegistry` reaches
|
|
122
|
+
// an in-process child through `globalThis`; a child in its own process shares
|
|
123
|
+
// nothing but this directory, so the served session publishes a heartbeat
|
|
124
|
+
// there too (#721).
|
|
125
|
+
const servingHeartbeats = new ServingHeartbeatStore({
|
|
126
|
+
forwardingDir: paths.forwardingDir,
|
|
127
|
+
logger,
|
|
128
|
+
});
|
|
129
|
+
// The read side of both channels, routed by how the target was resolved.
|
|
130
|
+
const servingLiveness = new ForwardingLivenessJudge({
|
|
131
|
+
registry: servingRegistry,
|
|
132
|
+
heartbeats: servingHeartbeats,
|
|
133
|
+
});
|
|
134
|
+
|
|
114
135
|
const authorizerSelection = new AuthorizerSelection({
|
|
115
136
|
detection: subagentDetection,
|
|
116
137
|
events: pi.events,
|
|
@@ -121,7 +142,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
121
142
|
requestPermissionDecision,
|
|
122
143
|
forwardingDir: paths.forwardingDir,
|
|
123
144
|
registry: subagentRegistry,
|
|
124
|
-
|
|
145
|
+
serving: servingLiveness,
|
|
125
146
|
getForwardingTimeoutMs: () =>
|
|
126
147
|
configStore.current().forwardingTimeoutMs ??
|
|
127
148
|
PERMISSION_FORWARDING_TIMEOUT_MS,
|
|
@@ -177,7 +198,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
177
198
|
new ForwardingManager({
|
|
178
199
|
detection: subagentDetection,
|
|
179
200
|
forwarder: requestServer,
|
|
180
|
-
serving: servingRegistry,
|
|
201
|
+
serving: composeServingAnnouncers(servingRegistry, servingHeartbeats),
|
|
181
202
|
logger,
|
|
182
203
|
}),
|
|
183
204
|
permissionManager,
|