pi-daddy 0.18.1 → 0.20.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 +201 -0
- package/README.md +136 -23
- package/contracts/ledger/v2/README.md +59 -0
- package/contracts/ledger/v2/fixtures/capability-decision.json +95 -0
- package/contracts/ledger/v2/fixtures/check-receipt.json +40 -0
- package/contracts/ledger/v2/fixtures/child-lifecycle.json +42 -0
- package/contracts/ledger/v2/fixtures/workspace-lease.json +41 -0
- package/contracts/ledger/v2/ledger-event.schema.json +633 -0
- package/contracts/ledger/v3/README.md +36 -0
- package/contracts/ledger/v3/fixtures/capability-decision.json +98 -0
- package/contracts/ledger/v3/fixtures/check-receipt.json +42 -0
- package/contracts/ledger/v3/fixtures/child-lifecycle.json +45 -0
- package/contracts/ledger/v3/fixtures/workflow-fact.json +41 -0
- package/contracts/ledger/v3/fixtures/workspace-lease.json +43 -0
- package/contracts/ledger/v3/ledger-event.schema.json +930 -0
- package/dist/approval-prompt.d.ts +2 -1
- package/dist/approval-prompt.d.ts.map +1 -1
- package/dist/approval-prompt.js +9 -0
- package/dist/approval-prompt.js.map +1 -1
- package/dist/approval.d.ts +4 -2
- package/dist/approval.d.ts.map +1 -1
- package/dist/approval.js +4 -0
- package/dist/approval.js.map +1 -1
- package/dist/capabilities.d.ts +90 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +114 -3
- package/dist/capabilities.js.map +1 -1
- package/dist/catalog.d.ts +15 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +54 -3
- package/dist/catalog.js.map +1 -1
- package/dist/check-runner.d.ts.map +1 -1
- package/dist/check-runner.js +19 -7
- package/dist/check-runner.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +21 -1
- package/dist/cli.js.map +1 -1
- package/dist/correlation.d.ts.map +1 -1
- package/dist/correlation.js +10 -0
- package/dist/correlation.js.map +1 -1
- package/dist/dashboard-cli.d.ts +18 -0
- package/dist/dashboard-cli.d.ts.map +1 -0
- package/dist/dashboard-cli.js +154 -0
- package/dist/dashboard-cli.js.map +1 -0
- package/dist/dashboard-handshake.d.ts +37 -0
- package/dist/dashboard-handshake.d.ts.map +1 -0
- package/dist/dashboard-handshake.js +127 -0
- package/dist/dashboard-handshake.js.map +1 -0
- package/dist/dashboard-herdr.d.ts +54 -0
- package/dist/dashboard-herdr.d.ts.map +1 -0
- package/dist/dashboard-herdr.js +286 -0
- package/dist/dashboard-herdr.js.map +1 -0
- package/dist/dashboard-projection.d.ts +73 -0
- package/dist/dashboard-projection.d.ts.map +1 -0
- package/dist/dashboard-projection.js +294 -0
- package/dist/dashboard-projection.js.map +1 -0
- package/dist/dashboard-render.d.ts +10 -0
- package/dist/dashboard-render.d.ts.map +1 -0
- package/dist/dashboard-render.js +208 -0
- package/dist/dashboard-render.js.map +1 -0
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +7 -1
- package/dist/definitions.js.map +1 -1
- package/dist/delegate-types.d.ts +6 -2
- package/dist/delegate-types.d.ts.map +1 -1
- package/dist/delegate-types.js.map +1 -1
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +34 -4
- package/dist/delegate.js.map +1 -1
- package/dist/delegation-approval.d.ts.map +1 -1
- package/dist/delegation-approval.js +37 -12
- package/dist/delegation-approval.js.map +1 -1
- package/dist/execution-id.d.ts +6 -0
- package/dist/execution-id.d.ts.map +1 -0
- package/dist/execution-id.js +13 -0
- package/dist/execution-id.js.map +1 -0
- package/dist/executor.d.ts +2 -1
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +1 -0
- package/dist/executor.js.map +1 -1
- package/dist/grant-env.d.ts +2 -0
- package/dist/grant-env.d.ts.map +1 -1
- package/dist/grant-env.js +26 -3
- package/dist/grant-env.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +13 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +35 -2
- package/dist/init.js.map +1 -1
- package/dist/lease-helper.d.ts +58 -0
- package/dist/lease-helper.d.ts.map +1 -0
- package/dist/lease-helper.js +94 -0
- package/dist/lease-helper.js.map +1 -0
- package/dist/lease-record.d.ts +15 -3
- package/dist/lease-record.d.ts.map +1 -1
- package/dist/lease-record.js.map +1 -1
- package/dist/ledger-events.d.ts +59 -15
- package/dist/ledger-events.d.ts.map +1 -1
- package/dist/ledger-events.js +67 -5
- package/dist/ledger-events.js.map +1 -1
- package/dist/ledger-identifiers.d.ts +7 -0
- package/dist/ledger-identifiers.d.ts.map +1 -0
- package/dist/ledger-identifiers.js +20 -0
- package/dist/ledger-identifiers.js.map +1 -0
- package/dist/ledger-report.d.ts +5 -2
- package/dist/ledger-report.d.ts.map +1 -1
- package/dist/ledger-report.js +38 -14
- package/dist/ledger-report.js.map +1 -1
- package/dist/ledger-v3-validation.d.ts +11 -0
- package/dist/ledger-v3-validation.d.ts.map +1 -0
- package/dist/ledger-v3-validation.js +265 -0
- package/dist/ledger-v3-validation.js.map +1 -0
- package/dist/ledger.d.ts +27 -9
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +38 -6
- package/dist/ledger.js.map +1 -1
- package/dist/propagation.d.ts +19 -1
- package/dist/propagation.d.ts.map +1 -1
- package/dist/propagation.js +23 -1
- package/dist/propagation.js.map +1 -1
- package/dist/refusals.d.ts +1 -1
- package/dist/refusals.d.ts.map +1 -1
- package/dist/refusals.js +1 -0
- package/dist/refusals.js.map +1 -1
- package/dist/resolve.d.ts +10 -0
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +27 -3
- package/dist/resolve.js.map +1 -1
- package/dist/routing-authority.d.ts +71 -0
- package/dist/routing-authority.d.ts.map +1 -0
- package/dist/routing-authority.js +100 -0
- package/dist/routing-authority.js.map +1 -0
- package/dist/run-child.d.ts +3 -1
- package/dist/run-child.d.ts.map +1 -1
- package/dist/run-child.js +30 -2
- package/dist/run-child.js.map +1 -1
- package/dist/run-herdr.d.ts +2 -0
- package/dist/run-herdr.d.ts.map +1 -1
- package/dist/run-herdr.js +8 -0
- package/dist/run-herdr.js.map +1 -1
- package/dist/skill-packages.d.ts +11 -5
- package/dist/skill-packages.d.ts.map +1 -1
- package/dist/skill-packages.js +20 -11
- package/dist/skill-packages.js.map +1 -1
- package/dist/workflow-fact-id.d.ts +4 -0
- package/dist/workflow-fact-id.d.ts.map +1 -0
- package/dist/workflow-fact-id.js +14 -0
- package/dist/workflow-fact-id.js.map +1 -0
- package/dist/workflow-facts.d.ts +34 -0
- package/dist/workflow-facts.d.ts.map +1 -0
- package/dist/workflow-facts.js +43 -0
- package/dist/workflow-facts.js.map +1 -0
- package/dist/workspace-lease.d.ts +15 -3
- package/dist/workspace-lease.d.ts.map +1 -1
- package/dist/workspace-lease.js +81 -24
- package/dist/workspace-lease.js.map +1 -1
- package/dist/workspace.d.ts +25 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +142 -5
- package/dist/workspace.js.map +1 -1
- package/extensions/chain-ledger.ts +4 -0
- package/extensions/chain-plan.ts +98 -0
- package/extensions/delegate-chain.ts +63 -122
- package/extensions/delegation-ledger.ts +60 -0
- package/extensions/delegation.ts +20 -14
- package/extensions/execute-child.ts +85 -21
- package/extensions/execution-occurrence.ts +20 -0
- package/extensions/grants-command.ts +33 -4
- package/extensions/grants.ts +50 -1
- package/extensions/init-command.ts +33 -2
- package/extensions/run-delegation.ts +36 -56
- package/extensions/session-report.ts +13 -20
- package/extensions/session.ts +16 -3
- package/extensions/workspace-runtime.ts +44 -4
- package/herdr-plugin/herdr-plugin.toml +18 -0
- package/package.json +22 -5
- package/src/approval-prompt.ts +2 -1
- package/src/approval.ts +4 -2
- package/src/capabilities.ts +120 -4
- package/src/catalog.ts +62 -4
- package/src/check-runner.ts +24 -8
- package/src/cli.ts +24 -1
- package/src/correlation.ts +11 -0
- package/src/dashboard-cli.ts +171 -0
- package/src/dashboard-handshake.ts +185 -0
- package/src/dashboard-herdr.ts +355 -0
- package/src/dashboard-projection.ts +390 -0
- package/src/dashboard-render.ts +254 -0
- package/src/definitions.ts +7 -1
- package/src/delegate-types.ts +6 -2
- package/src/delegate.ts +43 -4
- package/src/delegation-approval.ts +37 -12
- package/src/execution-id.ts +18 -0
- package/src/executor.ts +2 -1
- package/src/grant-env.ts +39 -7
- package/src/index.ts +26 -0
- package/src/init.ts +40 -2
- package/src/lease-helper.ts +97 -0
- package/src/lease-record.ts +15 -3
- package/src/ledger-events.ts +116 -28
- package/src/ledger-identifiers.ts +21 -0
- package/src/ledger-report.ts +40 -19
- package/src/ledger-v3-validation.ts +266 -0
- package/src/ledger.ts +75 -12
- package/src/propagation.ts +24 -1
- package/src/refusals.ts +1 -0
- package/src/resolve.ts +29 -3
- package/src/routing-authority.ts +121 -0
- package/src/run-child.ts +35 -3
- package/src/run-herdr.ts +9 -0
- package/src/skill-packages.ts +20 -13
- package/src/workflow-fact-id.ts +16 -0
- package/src/workflow-facts.ts +65 -0
- package/src/workspace-lease.ts +84 -24
- package/src/workspace.ts +179 -6
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-0035's three routing guards, and why each one is shaped the way it is.
|
|
3
|
+
*
|
|
4
|
+
* Lifted out of `delegate.ts` when it crossed the 400-line ceiling `test/file-size.test.ts` enforces — the
|
|
5
|
+
* same move `grants.ts` made at 398 and `delegate.ts` itself made at 413. It is a real seam rather than a
|
|
6
|
+
* line-count dodge: all three answer one question, *may this caller route this child to this workspace, and
|
|
7
|
+
* is the id even usable as a capability?*, and none of them looks at anything else in a delegation. The
|
|
8
|
+
* alternative was trimming the rationale below to fit, which is how a codebase loses the reasons for its
|
|
9
|
+
* guards.
|
|
10
|
+
*
|
|
11
|
+
* Each returns a refusal DESCRIPTOR rather than a `Delegation`, so the planner keeps sole ownership of
|
|
12
|
+
* assembling records. `denied` is present exactly when the attempt should count as an escalation.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mayRouteToWorkspace, isSafeWorkspaceId, workspaceCapability } from "./capabilities.ts";
|
|
16
|
+
import { WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
|
|
17
|
+
import { WILDCARD } from "./pi-tools.ts";
|
|
18
|
+
import type { RefusalCode } from "./refusals.ts";
|
|
19
|
+
|
|
20
|
+
export interface RoutingRefusal {
|
|
21
|
+
code: RefusalCode;
|
|
22
|
+
reason: string;
|
|
23
|
+
/**
|
|
24
|
+
* The capability to record in `denied`, when this refusal IS an escalation attempt.
|
|
25
|
+
*
|
|
26
|
+
* Absent for a malformed id, which is a bad request rather than a bid for authority — and seeding `denied`
|
|
27
|
+
* with an id that re-splits into several capabilities would make the one signal `isEscalationAttempt` reads
|
|
28
|
+
* unparseable. Present for an unauthorised route, which is exactly a bid for authority.
|
|
29
|
+
*/
|
|
30
|
+
denied?: Capability[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* May this caller route a child to `boundWorkspaceId`, and is that id usable as a capability id at all?
|
|
35
|
+
*
|
|
36
|
+
* Checked before anything is said about the target, for the reason `maySpawnDefinition` is: it is a
|
|
37
|
+
* governance question about the SESSION. Before ADR-0035 nothing checked it — the registry inherited into
|
|
38
|
+
* every governed child and a child routed to `staging` could route its grandchild to `prod` (R-131, measured
|
|
39
|
+
* in `docs/probes/g36-workspace-attenuation`).
|
|
40
|
+
*
|
|
41
|
+
* **Well-formedness first, because `workspace_id` is a model-facing tool parameter** and the next step turns
|
|
42
|
+
* it into a capability id. `workspace_id: "prod,tool:bash"` produced a `WORKSPACE_NOT_AUTHORIZED` whose
|
|
43
|
+
* `denied` array held `workspace:prod,tool:bash`: no authority minted, since the refusal is terminal, but
|
|
44
|
+
* `denied` is the channel `isEscalationAttempt` and `/grants ledger` count and an id that re-splits makes it
|
|
45
|
+
* unreadable. 0.18.1 is what happens when a comma goes unremarked.
|
|
46
|
+
*
|
|
47
|
+
* **`!== undefined`, not truthiness.** `workspace_id: ""` is falsy, so it skipped BOTH checks and failed
|
|
48
|
+
* closed much later at `resolveWorkspace` with `denied: []` — an unauthorised routing attempt invisible to
|
|
49
|
+
* every audit query. An empty id is a malformed id, not an absent one.
|
|
50
|
+
*/
|
|
51
|
+
export function checkRoutingAuthority(
|
|
52
|
+
boundWorkspaceId: string | undefined,
|
|
53
|
+
ownGrant: readonly Capability[],
|
|
54
|
+
): RoutingRefusal | null {
|
|
55
|
+
if (boundWorkspaceId === undefined) return null;
|
|
56
|
+
const authorising = workspaceCapability(boundWorkspaceId);
|
|
57
|
+
if (!isSafeWorkspaceId(boundWorkspaceId)) {
|
|
58
|
+
return {
|
|
59
|
+
code: "GRANT_ID_MALFORMED",
|
|
60
|
+
reason:
|
|
61
|
+
`workspace id ${JSON.stringify(boundWorkspaceId)} is not usable as a capability id — it would ` +
|
|
62
|
+
`become ${JSON.stringify(authorising)}, and an id must match [A-Za-z0-9][A-Za-z0-9._/-]*. Slashes ` +
|
|
63
|
+
`and dots are fine; spaces, quotes, commas, wildcards, shell metacharacters and non-ASCII are not.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (mayRouteToWorkspace(ownGrant, boundWorkspaceId)) return null;
|
|
67
|
+
// Filtered by the grammar too: an id that cannot pass `isSafeWorkspaceId` is refused on every
|
|
68
|
+
// attempt, so listing it here points the model at a destination it can never reach.
|
|
69
|
+
const held = ownGrant
|
|
70
|
+
.filter((c) => c.startsWith("workspace:") && isSafeWorkspaceId(c.slice("workspace:".length)))
|
|
71
|
+
.sort();
|
|
72
|
+
return {
|
|
73
|
+
code: "WORKSPACE_NOT_AUTHORIZED",
|
|
74
|
+
// Recorded as a denial rather than a bare refusal, exactly as DEFINITION_NOT_AUTHORIZED is: asking to
|
|
75
|
+
// route somewhere this session was not granted IS an attempt to exceed the grant.
|
|
76
|
+
denied: [authorising],
|
|
77
|
+
reason:
|
|
78
|
+
`cannot route a child to workspace "${boundWorkspaceId}" — this session does not hold ${authorising}. ` +
|
|
79
|
+
(held.length > 0
|
|
80
|
+
? `It may route to: ${held.join(", ")}.`
|
|
81
|
+
: `It may route to no workspace at all; add ${authorising} to its grant to allow this one.`),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* May `workspace:*` be handed to a child? No — and only its HOLDER gets told so in those words.
|
|
87
|
+
*
|
|
88
|
+
* `workspace:*` is held and never inherited, so a child that "was granted" it would receive an env without
|
|
89
|
+
* it, and the ledger would record an authority the child does not have — the mirror image of R-131 and just
|
|
90
|
+
* as unreadable. `childEnv` strips it from a session's OWN grant because a root legitimately holds it; asking
|
|
91
|
+
* to hand it to a child is a different act and the honest answer is no. `NARROWING_VIOLATED` is the existing
|
|
92
|
+
* code for "this grant would not actually narrow", which is precisely what routing authority over every
|
|
93
|
+
* registered root does.
|
|
94
|
+
*
|
|
95
|
+
* **`tool:*` does NOT reach the same outcome, and an earlier comment claimed it did.** It said
|
|
96
|
+
* `assertNarrowing` produces the equivalent refusal; it does not. `UNIVERSAL_CAPABILITIES` is
|
|
97
|
+
* `["ext:pi-fabric/fabric_exec", "tool:fabric_exec"]`, so `result.universal` is empty for `tool:*` and
|
|
98
|
+
* nothing throws: requesting it for a child is allowed, recorded as granted, and silently stripped from the
|
|
99
|
+
* child env by `inheritableGrant` (R-135). The asymmetry is deliberate — an ungoverned session's own grant IS
|
|
100
|
+
* `tool:*`, so refusing to spawn from one would break governance-is-opt-in, while `workspace:*` only ever
|
|
101
|
+
* appears in `requested` because somebody asked — but it is an asymmetry, not one rule twice.
|
|
102
|
+
*
|
|
103
|
+
* **Only for a holder.** A caller without it is attempting an escalation, and this refusal would tell it the
|
|
104
|
+
* wildcard "is held, never inherited" — false about a session holding nothing of the sort — while leaving
|
|
105
|
+
* `denied` empty, so nothing counted a model probing `tools: ["workspace:*"]`. Returning `null` lets
|
|
106
|
+
* `resolve()` deny it as uncovered, which is what `agent:*` and `tool:*` already do.
|
|
107
|
+
*/
|
|
108
|
+
export function checkWorkspaceWildcardRequest(
|
|
109
|
+
requested: readonly Capability[],
|
|
110
|
+
ownGrant: readonly Capability[],
|
|
111
|
+
): RoutingRefusal | null {
|
|
112
|
+
if (!requested.includes(WORKSPACE_WILDCARD)) return null;
|
|
113
|
+
if (!ownGrant.includes(WORKSPACE_WILDCARD) && !ownGrant.includes(WILDCARD)) return null;
|
|
114
|
+
return {
|
|
115
|
+
code: "NARROWING_VIOLATED",
|
|
116
|
+
reason:
|
|
117
|
+
`cannot grant ${WORKSPACE_WILDCARD} to a child — it is held, never inherited, because a descendant ` +
|
|
118
|
+
`holding it could route anywhere the registry lists and routing would stop attenuating below here. ` +
|
|
119
|
+
`Name the workspaces this child may route to instead (workspace:<id>, one per id).`,
|
|
120
|
+
};
|
|
121
|
+
}
|
package/src/run-child.ts
CHANGED
|
@@ -13,6 +13,15 @@ import { spawn } from "node:child_process";
|
|
|
13
13
|
import { StringDecoder } from "node:string_decoder";
|
|
14
14
|
import { parseBound } from "./propagation.ts";
|
|
15
15
|
|
|
16
|
+
interface RunChildTestControl { hardDeadlineAtAfterSpawn(): number }
|
|
17
|
+
const TEST_CONTROL = Symbol.for("pi-daddy.internal.run-child-test-control");
|
|
18
|
+
|
|
19
|
+
function currentRunChildTestControl(): RunChildTestControl | undefined {
|
|
20
|
+
if (process.env.NODE_TEST_CONTEXT !== "child-v8") return undefined;
|
|
21
|
+
const storage = (globalThis as Record<symbol, { getStore(): RunChildTestControl | undefined }>)[TEST_CONTROL];
|
|
22
|
+
return storage?.getStore();
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
/**
|
|
17
26
|
* The longest prefix of `text` that fits in `budget` BYTES, never splitting a character.
|
|
18
27
|
*
|
|
@@ -64,9 +73,11 @@ export interface ChildRunRequest {
|
|
|
64
73
|
signal?: AbortSignal;
|
|
65
74
|
/** Hard cap on captured output. Beyond it the child is killed and the result flagged. */
|
|
66
75
|
maxOutputBytes?: number;
|
|
67
|
-
/**
|
|
76
|
+
/** Soft wall-clock cap. On expiry: SIGTERM, then SIGKILL after `killGraceMs`. */
|
|
68
77
|
timeoutMs?: number;
|
|
69
78
|
killGraceMs?: number;
|
|
79
|
+
/** Absolute epoch-millisecond hard cap: SIGKILL fires here even when the SIGTERM grace has not elapsed. */
|
|
80
|
+
hardDeadlineAt?: number;
|
|
70
81
|
/**
|
|
71
82
|
* Called with each chunk as it arrives, for the parent's progress display (ADR-0032).
|
|
72
83
|
*
|
|
@@ -106,9 +117,11 @@ export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
|
106
117
|
export const DEFAULT_KILL_GRACE_MS = 5000;
|
|
107
118
|
|
|
108
119
|
export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
|
|
120
|
+
const testControl = currentRunChildTestControl();
|
|
109
121
|
const maxOutputBytes = request.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
110
122
|
const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
111
123
|
const killGraceMs = request.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
|
124
|
+
const hardDeadlineAt = request.hardDeadlineAt;
|
|
112
125
|
|
|
113
126
|
// A-R3: checked BEFORE spawning. `AbortSignal` does not replay, so a listener attached after an
|
|
114
127
|
// `await` cannot observe an abort that already happened — and the child would then run to completion
|
|
@@ -160,6 +173,16 @@ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
|
|
|
160
173
|
timers.push(setTimeout(() => child.kill("SIGKILL"), killGraceMs));
|
|
161
174
|
};
|
|
162
175
|
|
|
176
|
+
// A timer phase may run before libuv delivers an exit that the OS already observed, especially after the
|
|
177
|
+
// controller event loop was blocked across the deadline. Give pending child-process events one turn to
|
|
178
|
+
// publish exitCode/signalCode before deciding that a governed PID is still live.
|
|
179
|
+
const controlIfRunning = (control: () => void) => {
|
|
180
|
+
setImmediate(() => {
|
|
181
|
+
if (done || child.exitCode !== null || child.signalCode !== null) return;
|
|
182
|
+
control();
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
|
|
163
186
|
/** How much of `text` the progress display has already been shown. */
|
|
164
187
|
let emittedUpTo = 0;
|
|
165
188
|
|
|
@@ -225,11 +248,20 @@ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
|
|
|
225
248
|
child.stderr?.on("data", capture);
|
|
226
249
|
|
|
227
250
|
timers.push(
|
|
228
|
-
setTimeout(() => {
|
|
251
|
+
setTimeout(() => controlIfRunning(() => {
|
|
229
252
|
timedOut = true;
|
|
230
253
|
stop();
|
|
231
|
-
}, timeoutMs),
|
|
254
|
+
}), timeoutMs),
|
|
232
255
|
);
|
|
256
|
+
const activeHardDeadlineAt = testControl?.hardDeadlineAtAfterSpawn() ?? hardDeadlineAt;
|
|
257
|
+
if (activeHardDeadlineAt !== undefined) {
|
|
258
|
+
// Independent of the soft timer and measured from the recorded epoch, not from `spawn`: neither a
|
|
259
|
+
// delayed soft callback nor spawn/setup time may start a fresh grace period beyond the lifecycle bound.
|
|
260
|
+
timers.push(setTimeout(() => controlIfRunning(() => {
|
|
261
|
+
timedOut = true;
|
|
262
|
+
child.kill("SIGKILL");
|
|
263
|
+
}), Math.max(0, activeHardDeadlineAt - Date.now())));
|
|
264
|
+
}
|
|
233
265
|
|
|
234
266
|
const onAbort = () => {
|
|
235
267
|
aborted = true;
|
package/src/run-herdr.ts
CHANGED
|
@@ -98,6 +98,8 @@ export interface HerdrRunRequest {
|
|
|
98
98
|
* name a human can switch to **while the child is alive**, which is the whole point of the pane surviving.
|
|
99
99
|
*/
|
|
100
100
|
onPane?: (paneId: string, agentName: string) => void;
|
|
101
|
+
/** Called only after agent start and prompt both succeed; pane creation alone is still `starting`. */
|
|
102
|
+
onRunning?: (paneId: string, agentName: string) => void;
|
|
101
103
|
/** Security hook for attaching a writer lease to the tab. Unlike display callbacks, errors fail the run. */
|
|
102
104
|
onTab?: (tabId: string) => void;
|
|
103
105
|
/** Close even a settled pane before releasing a writer lease; no post-lease prompt may remain live. */
|
|
@@ -300,6 +302,13 @@ export async function runHerdrPane(request: HerdrRunRequest): Promise<ChildRunRe
|
|
|
300
302
|
|
|
301
303
|
const prompted = parseReply(await exec(["agent", "prompt", agentName, request.prompt]));
|
|
302
304
|
if (prompted.error) return { ...empty, spawnError: `herdr agent prompt failed: ${prompted.error}` };
|
|
305
|
+
if (request.onRunning) {
|
|
306
|
+
try {
|
|
307
|
+
request.onRunning(paneId, agentName);
|
|
308
|
+
} catch {
|
|
309
|
+
/* display/observation only */
|
|
310
|
+
}
|
|
311
|
+
}
|
|
303
312
|
|
|
304
313
|
const settled = await waitForSettled(exec, { ...request, name: agentName }, before, deadline, maxOutputBytes);
|
|
305
314
|
if (settled.aborted || settled.timedOut) {
|
package/src/skill-packages.ts
CHANGED
|
@@ -30,7 +30,8 @@ import { homedir } from "node:os";
|
|
|
30
30
|
import { join, resolve, sep } from "node:path";
|
|
31
31
|
import { ceilingForDefinition, parseSkillDefinition, type SkillDefinition } from "./definitions.ts";
|
|
32
32
|
import { WILDCARD } from "./pi-tools.ts";
|
|
33
|
-
import { AGENT_WILDCARD, type Capability } from "./resolve.ts";
|
|
33
|
+
import { AGENT_WILDCARD, WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
|
|
34
|
+
import { isSafeCapability } from "./capabilities.ts";
|
|
34
35
|
|
|
35
36
|
export interface DiscoveredSkill {
|
|
36
37
|
definition: SkillDefinition;
|
|
@@ -107,21 +108,27 @@ export function isSafeName(name: string): boolean {
|
|
|
107
108
|
* grants), which is right for the enforcement path — the catalog refuses what it does not know — and is
|
|
108
109
|
* exactly why the check has to be here, at the boundary that *generates* rather than the one that enforces.
|
|
109
110
|
*
|
|
110
|
-
* The grammar is the one `docs/SPEC.md` documents: `tool:<name>`, `skill:<name>`, `agent:<name>`,
|
|
111
|
-
* `ext:<pkg>/<tool>` where `<pkg>` may be npm-scoped. No wildcards — those are refused
|
|
112
|
-
* loudly, because "you tried to grant yourself everything" is a different fact from "that is
|
|
111
|
+
* The grammar is the one `docs/SPEC.md` documents: `tool:<name>`, `skill:<name>`, `agent:<name>`,
|
|
112
|
+
* `workspace:<id>`, and `ext:<pkg>/<tool>` where `<pkg>` may be npm-scoped. No wildcards — those are refused
|
|
113
|
+
* separately and loudly, because "you tried to grant yourself everything" is a different fact from "that is
|
|
114
|
+
* not a name".
|
|
115
|
+
*
|
|
116
|
+
* `workspace:` was absent until 2026-08-21, so the boundary that GENERATES grants could not emit the one
|
|
117
|
+
* capability ADR-0035's breaking change made mandatory: a package needing to route was reported as declaring
|
|
118
|
+
* something that "is not a name". A namespace has to be added here as well as to the enforcing path, and
|
|
119
|
+
* that is the whole lesson of the review this came out of.
|
|
113
120
|
*/
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
new RegExp(`^(tool|skill|agent):${segment}$`).test(id) ||
|
|
118
|
-
new RegExp(`^ext:(@${segment}/)?${segment}/${segment}$`).test(id)
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
+
// Imported as well as re-exported: a bare `export … from` does not bind the name in this module's scope, and
|
|
122
|
+
// `refusalFor` below calls it. Re-exported because this has been the import site since 0.13.0.
|
|
123
|
+
export { isSafeCapability };
|
|
121
124
|
|
|
122
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* The ids that confer authority over a whole namespace. A package declaring one is claiming it, not
|
|
127
|
+
* describing a need — so it is reported as a *wildcard claim* rather than as a malformed name, which is a
|
|
128
|
+
* different sentence to show an operator. `workspace:*` belongs here for the same reason `agent:*` does.
|
|
129
|
+
*/
|
|
123
130
|
function wildcardsIn(capabilities: Capability[]): Capability[] {
|
|
124
|
-
return capabilities.filter((c) => c === WILDCARD || c === AGENT_WILDCARD);
|
|
131
|
+
return capabilities.filter((c) => c === WILDCARD || c === AGENT_WILDCARD || c === WORKSPACE_WILDCARD);
|
|
125
132
|
}
|
|
126
133
|
|
|
127
134
|
/**
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const WORKFLOW_FACT_ID_RE = /^fact:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
2
|
+
const WORKFLOW_IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
|
3
|
+
|
|
4
|
+
export function isWorkflowFactId(value: unknown): value is string {
|
|
5
|
+
return typeof value === "string" && WORKFLOW_FACT_ID_RE.test(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function isWorkflowIdentifier(value: unknown): value is string {
|
|
9
|
+
return typeof value === "string" && WORKFLOW_IDENTIFIER_RE.test(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function workflowFactStateMatches(provenance: unknown, state: unknown): boolean {
|
|
13
|
+
return (provenance === "planned" && state === "pending") ||
|
|
14
|
+
(provenance === "observed" && state === "observed") ||
|
|
15
|
+
(provenance === "controller_validated" && ["started", "completed", "blocked"].includes(String(state)));
|
|
16
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { LEDGER_VERSION } from "./ledger.ts";
|
|
2
|
+
import { normaliseCorrelation, type CorrelationMetadata } from "./correlation.ts";
|
|
3
|
+
import { isWorkflowFactId, isWorkflowIdentifier, workflowFactStateMatches } from "./workflow-fact-id.ts";
|
|
4
|
+
import { assertLedgerV3Wire } from "./ledger-v3-validation.ts";
|
|
5
|
+
|
|
6
|
+
export const WORKFLOW_FACT_PROVENANCE = ["planned", "observed", "controller_validated"] as const;
|
|
7
|
+
export type WorkflowFactProvenance = typeof WORKFLOW_FACT_PROVENANCE[number];
|
|
8
|
+
export const WORKFLOW_FACT_KINDS = ["workflow_phase", "inline_skill", "transition"] as const;
|
|
9
|
+
export type WorkflowFactKind = typeof WORKFLOW_FACT_KINDS[number];
|
|
10
|
+
export const WORKFLOW_FACT_STATES = ["pending", "observed", "started", "completed", "blocked"] as const;
|
|
11
|
+
export type WorkflowFactState = typeof WORKFLOW_FACT_STATES[number];
|
|
12
|
+
|
|
13
|
+
export interface WorkflowFactEvent {
|
|
14
|
+
ledgerVersion: typeof LEDGER_VERSION;
|
|
15
|
+
event: "workflow_fact";
|
|
16
|
+
ts: string;
|
|
17
|
+
factId: string;
|
|
18
|
+
/** Package/controller identifier, not free-form display text. */
|
|
19
|
+
source: string;
|
|
20
|
+
provenance: WorkflowFactProvenance;
|
|
21
|
+
kind: WorkflowFactKind;
|
|
22
|
+
/** Phase, skill, or transition identifier. Never task text. */
|
|
23
|
+
subject: string;
|
|
24
|
+
state: WorkflowFactState;
|
|
25
|
+
/** run_id is required by the builder; other values remain non-authoritative labels. */
|
|
26
|
+
correlation: CorrelationMetadata;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildWorkflowFactEvent(args: {
|
|
30
|
+
factId: string;
|
|
31
|
+
source: string;
|
|
32
|
+
provenance: WorkflowFactProvenance;
|
|
33
|
+
kind: WorkflowFactKind;
|
|
34
|
+
subject: string;
|
|
35
|
+
state: WorkflowFactState;
|
|
36
|
+
correlation: CorrelationMetadata;
|
|
37
|
+
now: Date;
|
|
38
|
+
}): WorkflowFactEvent {
|
|
39
|
+
if (!isWorkflowFactId(args.factId)) throw new TypeError("factId must be a pi-daddy workflow fact id");
|
|
40
|
+
if (!isWorkflowIdentifier(args.source)) throw new TypeError("workflow fact source must be an identifier");
|
|
41
|
+
if (!WORKFLOW_FACT_PROVENANCE.includes(args.provenance)) throw new TypeError("workflow fact provenance is not recognised");
|
|
42
|
+
if (!WORKFLOW_FACT_KINDS.includes(args.kind)) throw new TypeError("workflow fact kind is not recognised");
|
|
43
|
+
if (!isWorkflowIdentifier(args.subject)) throw new TypeError("workflow fact subject must be an identifier, not task text");
|
|
44
|
+
if (!WORKFLOW_FACT_STATES.includes(args.state)) throw new TypeError("workflow fact state is not recognised");
|
|
45
|
+
const correlation = normaliseCorrelation(args.correlation);
|
|
46
|
+
if (!correlation?.run_id) throw new TypeError("workflow facts require correlation.run_id");
|
|
47
|
+
if (!workflowFactStateMatches(args.provenance, args.state)) {
|
|
48
|
+
const expected = args.provenance === "planned" ? "pending" : args.provenance === "observed"
|
|
49
|
+
? "observed" : "started, completed, or blocked";
|
|
50
|
+
throw new TypeError(`${args.provenance.replaceAll("_", "-")} workflow facts must use ${expected}`);
|
|
51
|
+
}
|
|
52
|
+
const event: WorkflowFactEvent = {
|
|
53
|
+
ledgerVersion: LEDGER_VERSION,
|
|
54
|
+
event: "workflow_fact",
|
|
55
|
+
ts: args.now.toISOString(),
|
|
56
|
+
factId: args.factId,
|
|
57
|
+
source: args.source,
|
|
58
|
+
provenance: args.provenance,
|
|
59
|
+
kind: args.kind,
|
|
60
|
+
subject: args.subject,
|
|
61
|
+
state: args.state,
|
|
62
|
+
correlation,
|
|
63
|
+
};
|
|
64
|
+
return assertLedgerV3Wire(event);
|
|
65
|
+
}
|
package/src/workspace-lease.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { homedir } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { GovernanceRefusal, refusal } from "./refusals.ts";
|
|
16
16
|
import type { ValidatedWorkspace, WorkspaceAccess } from "./workspace.ts";
|
|
17
|
+
import { assertCloseBounds, HELPER_SOURCE, LEASE_READY, unrefStream } from "./lease-helper.ts";
|
|
17
18
|
|
|
18
19
|
export const ENV_WORKSPACE_LEASE_DIR = "PI_GRANTS_WORKSPACE_LEASE_DIR";
|
|
19
20
|
|
|
@@ -22,28 +23,17 @@ export function defaultWorkspaceLeaseDir(env: NodeJS.ProcessEnv = process.env):
|
|
|
22
23
|
return env[ENV_WORKSPACE_LEASE_DIR] ?? join(agentDir, "pi-daddy", "workspace-leases");
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
const LEASE_READY = "PI_DADDY_LEASE_READY";
|
|
26
|
-
// The lock holder also owns crash cleanup for the governed child. If the parent dies, stdin closes;
|
|
27
|
-
// the helper signals the attached process, or closes the herdr tab, before releasing flock. A raw
|
|
28
|
-
// descendant deliberately detached by bash remains ADR-0012's OS-containment boundary, not a lease
|
|
29
|
-
// guarantee. The process branch SIGTERMs, escalates to SIGKILL at +500ms, and releases at +750ms
|
|
30
|
-
// WITHOUT confirming death (R-101). The herdr branch retries `tab close` a BOUNDED number of times
|
|
31
|
-
// and then releases anyway, leaving a marker file: an unreleasable lock strands a worktree forever
|
|
32
|
-
// with no in-product recovery, which is strictly worse than a recorded failure to close (R-102).
|
|
33
|
-
const HELPER_SOURCE = `
|
|
34
|
-
import { execFile } from "node:child_process";
|
|
35
|
-
import { writeFileSync } from "node:fs";
|
|
36
|
-
let clean=false, target=null, buffered="";
|
|
37
|
-
process.stdout.write(${JSON.stringify(`${LEASE_READY}:`)}+process.pid+"\\n");
|
|
38
|
-
process.stdin.setEncoding("utf8");
|
|
39
|
-
process.stdin.on("data",chunk=>{buffered+=chunk;for(;;){const i=buffered.indexOf("\\n");if(i<0)break;const line=buffered.slice(0,i);buffered=buffered.slice(i+1);try{const value=JSON.parse(line);if(value.release)clean=true;else if(value.process_pid)target={process_pid:value.process_pid};else if(value.herdr_tab)target={herdr_tab:value.herdr_tab};}catch{}}});
|
|
40
|
-
process.stdin.on("end",()=>{if(clean||!target)return process.exit(0);if(target.process_pid){try{process.kill(target.process_pid,"SIGTERM")}catch{return process.exit(0)}setTimeout(()=>{try{process.kill(target.process_pid,"SIGKILL")}catch{}},500);return setTimeout(()=>process.exit(0),750);}let left=Number(process.env.PI_DADDY_LEASE_CLOSE_ATTEMPTS||10);const giveUp=()=>{try{if(process.env.PI_DADDY_LEASE_MARKER)writeFileSync(process.env.PI_DADDY_LEASE_MARKER,JSON.stringify({reason:"herdr-close-failed",herdr_tab:target.herdr_tab})+"\\n")}catch{}process.exit(0)};const close=()=>execFile("herdr",["tab","close",target.herdr_tab],error=>{if(!error)return process.exit(0);if(--left<=0)return giveUp();setTimeout(close,1000)});close();});
|
|
41
|
-
process.stdin.resume();`;
|
|
42
26
|
|
|
43
27
|
/**
|
|
44
|
-
* The ledger outcome for a release.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
28
|
+
* The ledger outcome for a release. Exported because call sites had their own copies and the check runner's
|
|
29
|
+
* asserted `released` unconditionally — reproducing in the evidence path the exact defect this union was
|
|
30
|
+
* added to expose (R-100/R-103).
|
|
31
|
+
*
|
|
32
|
+
* **It is NOT the only definition, and saying so here was wrong.** `extensions/workspace-runtime.ts` keeps a
|
|
33
|
+
* private copy with no `not-held` arm, and it is the one the whole delegation path uses — so releasing a
|
|
34
|
+
* *read* lease on a delegated child is still recorded `released`, a handover the kernel never performed.
|
|
35
|
+
* Measured; open as R-141; the sentence claiming a single definition survived two review passes because
|
|
36
|
+
* `test/workspace.test.ts` pins the `not-held` arm on THIS function, which no delegation path calls.
|
|
47
37
|
*/
|
|
48
38
|
export function leaseReleaseLedgerOutcome(
|
|
49
39
|
outcome: LeaseReleaseOutcome | "retained",
|
|
@@ -78,9 +68,17 @@ export async function acquireWorkspaceLease(input: {
|
|
|
78
68
|
signal?: AbortSignal;
|
|
79
69
|
flockCommand?: string;
|
|
80
70
|
acquisitionTimeoutMs?: number;
|
|
71
|
+
/**
|
|
72
|
+
* Bound on how long ONE `herdr tab close` attempt may take before it counts as failed (R-146). A hung
|
|
73
|
+
* herdr is not a failed herdr: without this the attempt never returns and the retry bound below is
|
|
74
|
+
* unreachable.
|
|
75
|
+
*/
|
|
76
|
+
herdrCloseTimeoutMs?: number;
|
|
81
77
|
/** Bound on the helper's `herdr tab close` retries before it releases the lock anyway (R-102). */
|
|
82
78
|
herdrCloseAttempts?: number;
|
|
83
79
|
}): Promise<WorkspaceLease> {
|
|
80
|
+
// Before the read-lease return, so both paths are validated (R-152).
|
|
81
|
+
assertCloseBounds(input);
|
|
84
82
|
if (input.access === "read") {
|
|
85
83
|
return {
|
|
86
84
|
workspace: input.workspace,
|
|
@@ -89,7 +87,9 @@ export async function acquireWorkspaceLease(input: {
|
|
|
89
87
|
recovered: false,
|
|
90
88
|
attachProcess: () => {},
|
|
91
89
|
attachHerdrTab: () => {},
|
|
92
|
-
|
|
90
|
+
// A read lease took no kernel lock, so there is nothing to keep: `not-held` is the same answer
|
|
91
|
+
// `release()` gives, and it is the truth rather than a retention that never happened (R-152).
|
|
92
|
+
markRetained: async () => "not-held" as const,
|
|
93
93
|
readCloseFailure: async () => null,
|
|
94
94
|
lost: new Promise(() => {}),
|
|
95
95
|
// No kernel lock was ever taken, so there is no handover to claim (R-105, release side).
|
|
@@ -108,7 +108,12 @@ export async function acquireWorkspaceLease(input: {
|
|
|
108
108
|
process.execPath, "--input-type=module", "-e", HELPER_SOURCE,
|
|
109
109
|
], {
|
|
110
110
|
stdio: ["pipe", "pipe", "pipe"],
|
|
111
|
-
env: {
|
|
111
|
+
env: {
|
|
112
|
+
...process.env,
|
|
113
|
+
PI_DADDY_LEASE_MARKER: paths.marker,
|
|
114
|
+
PI_DADDY_LEASE_CLOSE_ATTEMPTS: String(input.herdrCloseAttempts ?? 10),
|
|
115
|
+
PI_DADDY_LEASE_CLOSE_TIMEOUT_MS: String(input.herdrCloseTimeoutMs ?? 15_000),
|
|
116
|
+
},
|
|
112
117
|
// Own process group, for two reasons. It lets teardown kill `flock` AND the helper holding the lock
|
|
113
118
|
// file descriptor as one unit even before the helper has reported its pid (R-99), and it stops a
|
|
114
119
|
// stray group signal — a terminal Ctrl-C, a closed window — from releasing a live writer's lock as
|
|
@@ -240,12 +245,67 @@ export async function acquireWorkspaceLease(input: {
|
|
|
240
245
|
async markRetained(reason = "retained") {
|
|
241
246
|
// Deliberately does NOT touch the kernel lock or the helper: the pane may still be live. It only
|
|
242
247
|
// stops the record from looking like a crash.
|
|
248
|
+
//
|
|
249
|
+
// **Both guards below exist because the CALLER ledgers whatever this returns (R-152).**
|
|
250
|
+
//
|
|
251
|
+
// Already settled: `release()` checked `settled` and this did not, so a completed handover could be
|
|
252
|
+
// rewritten into `retained:…` and the memoized answer flipped with it — the mirror of the defect R-146
|
|
253
|
+
// fixed, reachable by the `try { … } finally { await lease.release() }` shape this API invites.
|
|
254
|
+
if (settled) return settled;
|
|
255
|
+
// Already dead: the helper is gone and the kernel lock with it, so the fact is `lost`. Recording a
|
|
256
|
+
// retention here tells an operator a pane may still be live and tells the next owner nothing crashed —
|
|
257
|
+
// precisely the two facts R-103's outcome union was added to keep apart.
|
|
258
|
+
if (holder.exitCode !== null || holder.signalCode !== null) return (settled = "lost");
|
|
243
259
|
const current = await readMetadata(paths.metadata);
|
|
260
|
+
let recorded = false;
|
|
244
261
|
if (current !== "malformed" && current?.token === token) {
|
|
245
|
-
await atomicMetadata(paths.metadata, {
|
|
262
|
+
recorded = await atomicMetadata(paths.metadata, {
|
|
246
263
|
...metadata, state: "released", released_at: new Date().toISOString(), release_reason: `retained:${reason}`,
|
|
247
|
-
}).
|
|
264
|
+
}).then(() => true, () => false);
|
|
248
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* **It must, however, let THIS process exit (R-146).** Leaving the helper alone is the decision;
|
|
268
|
+
* leaving the parent's three pipes and the child handle referenced was an accident, and it meant a
|
|
269
|
+
* retained lease wedged its own process forever — measured `exit=124` against `exit=0` for the same
|
|
270
|
+
* sequence ending in `release()`.
|
|
271
|
+
*
|
|
272
|
+
* **Which hosts it actually wedged, corrected after review.** `process.exit()` runs `exit` handlers and
|
|
273
|
+
* ignores pending handles, so this only bites a host that lets the loop drain: pi's **print** mode sets
|
|
274
|
+
* `process.exitCode` and returns (`dist/main.js`), and a library consumer — an ADR-0034 external
|
|
275
|
+
* controller calling this package directly — does the same. pi's interactive and rpc modes call
|
|
276
|
+
* `process.exit()` explicitly (`dist/modes/interactive/interactive-mode.js:3148`), so there the process
|
|
277
|
+
* still leaves and `pane-reaper`'s `process.once("exit")` sweep still runs. An earlier version of this
|
|
278
|
+
* comment claimed the sweep was lost generally and cited `src/cli.ts`, which has one subcommand
|
|
279
|
+
* (`init`) and can neither hold a lease nor open a pane.
|
|
280
|
+
*
|
|
281
|
+
* Unref rather than close, because the lock must outlive this call: when the parent does exit, the
|
|
282
|
+
* helper sees EOF and runs the path it was written for — bounded `herdr tab close` attempts, a marker
|
|
283
|
+
* file, then release anyway (R-102: an unreleasable lock strands a worktree with no in-product
|
|
284
|
+
* recovery, which is strictly worse than a recorded failure to close). Narrowed to this path on
|
|
285
|
+
* purpose: at spawn it would remove the accidental guarantee that a process cannot exit while a lease
|
|
286
|
+
* is still ACTIVE, which is a different decision and not this fix.
|
|
287
|
+
*/
|
|
288
|
+
/**
|
|
289
|
+
* **Terminal (R-146).** `releasing` stops a late `holder.close` being reported as a lost lease, and
|
|
290
|
+
* `settled` stops a later `release()` running the clean handshake — which it did: measured, it returned
|
|
291
|
+
* `released`, overwrote `retained:herdr-close-failed` with `completed`, and sent `{release:true}` so the
|
|
292
|
+
* helper exited `clean` and never attempted the close. The pane retention exists to protect was
|
|
293
|
+
* abandoned silently, and the ledger asserted a clean handover for a lease kept *because* a pane would
|
|
294
|
+
* not close. No in-tree caller does this — but `WorkspaceLease` is a public export and
|
|
295
|
+
* `try { … } finally { await lease.release() }` is the obvious shape for the external controllers this
|
|
296
|
+
* whole fix is for.
|
|
297
|
+
*/
|
|
298
|
+
releasing = true;
|
|
299
|
+
// **Settle only if the record was actually written.** An unwritable lease directory used to leave the
|
|
300
|
+
// record `state: "active"` while the ledger said `retained`, so the next owner reported a phantom
|
|
301
|
+
// `recovered: true` — and making retention terminal removed the later `release()` that would still have
|
|
302
|
+
// written the handover. Unsettled, that repair is available again; the residue is stated in R-152
|
|
303
|
+
// rather than implied, because nothing here can write a record to a directory that refuses writes.
|
|
304
|
+
if (recorded) settled = "retained";
|
|
305
|
+
holder.unref();
|
|
306
|
+
unrefStream(holder.stdout);
|
|
307
|
+
unrefStream(holder.stderr);
|
|
308
|
+
return "retained";
|
|
249
309
|
},
|
|
250
310
|
async readCloseFailure() {
|
|
251
311
|
try {
|