pi-daddy 0.17.0 → 0.18.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 +154 -80
- package/README.md +54 -25
- package/dist/approval-prompt.d.ts +3 -1
- package/dist/approval-prompt.d.ts.map +1 -1
- package/dist/approval-prompt.js +1 -1
- package/dist/approval-prompt.js.map +1 -1
- package/dist/approval-store.d.ts.map +1 -1
- package/dist/approval-store.js +4 -1
- package/dist/approval-store.js.map +1 -1
- package/dist/approval.d.ts +20 -2
- package/dist/approval.d.ts.map +1 -1
- package/dist/approval.js +26 -6
- package/dist/approval.js.map +1 -1
- package/dist/chain.d.ts +6 -1
- package/dist/chain.d.ts.map +1 -1
- package/dist/chain.js +1 -1
- package/dist/chain.js.map +1 -1
- package/dist/check-runner.d.ts +61 -0
- package/dist/check-runner.d.ts.map +1 -0
- package/dist/check-runner.js +237 -0
- package/dist/check-runner.js.map +1 -0
- package/dist/correlation.d.ts +90 -0
- package/dist/correlation.d.ts.map +1 -0
- package/dist/correlation.js +183 -0
- package/dist/correlation.js.map +1 -0
- package/dist/delegate-types.d.ts +140 -0
- package/dist/delegate-types.d.ts.map +1 -0
- package/dist/delegate-types.js +8 -0
- package/dist/delegate-types.js.map +1 -0
- package/dist/delegate.d.ts +4 -128
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +68 -72
- package/dist/delegate.js.map +1 -1
- package/dist/delegation-approval.d.ts +36 -0
- package/dist/delegation-approval.d.ts.map +1 -0
- package/dist/delegation-approval.js +67 -0
- package/dist/delegation-approval.js.map +1 -0
- package/dist/git-identity.d.ts +13 -0
- package/dist/git-identity.d.ts.map +1 -0
- package/dist/git-identity.js +44 -0
- package/dist/git-identity.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lease-record.d.ts +78 -0
- package/dist/lease-record.d.ts.map +1 -0
- package/dist/lease-record.js +52 -0
- package/dist/lease-record.js.map +1 -0
- package/dist/ledger-events.d.ts +87 -0
- package/dist/ledger-events.d.ts.map +1 -0
- package/dist/ledger-events.js +45 -0
- package/dist/ledger-events.js.map +1 -0
- package/dist/ledger-report.d.ts +19 -12
- package/dist/ledger-report.d.ts.map +1 -1
- package/dist/ledger-report.js +81 -1
- package/dist/ledger-report.js.map +1 -1
- package/dist/ledger.d.ts +44 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +20 -2
- package/dist/ledger.js.map +1 -1
- package/dist/refusals.d.ts +16 -0
- package/dist/refusals.d.ts.map +1 -0
- package/dist/refusals.js +51 -0
- package/dist/refusals.js.map +1 -0
- package/dist/run-child.d.ts +4 -0
- package/dist/run-child.d.ts.map +1 -1
- package/dist/run-child.js +21 -1
- package/dist/run-child.js.map +1 -1
- package/dist/run-herdr.d.ts +7 -0
- package/dist/run-herdr.d.ts.map +1 -1
- package/dist/run-herdr.js +41 -12
- package/dist/run-herdr.js.map +1 -1
- package/dist/workspace-lease.d.ts +28 -0
- package/dist/workspace-lease.d.ts.map +1 -0
- package/dist/workspace-lease.js +276 -0
- package/dist/workspace-lease.js.map +1 -0
- package/dist/workspace.d.ts +32 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +79 -0
- package/dist/workspace.js.map +1 -0
- package/extensions/approval-banking.ts +66 -0
- package/extensions/approvals.ts +95 -7
- package/extensions/chain-approval-facts.ts +51 -0
- package/extensions/chain-ledger.ts +48 -0
- package/extensions/delegate-chain.ts +115 -81
- package/extensions/delegation.ts +81 -23
- package/extensions/execute-child.ts +289 -0
- package/extensions/fanout-outcome.ts +97 -0
- package/extensions/run-delegation.ts +130 -120
- package/extensions/session.ts +4 -0
- package/extensions/workspace-runtime.ts +165 -0
- package/package.json +17 -1
- package/src/approval-prompt.ts +4 -2
- package/src/approval-store.ts +4 -1
- package/src/approval.ts +47 -14
- package/src/chain.ts +3 -1
- package/src/check-runner.ts +341 -0
- package/src/correlation.ts +260 -0
- package/src/delegate-types.ts +144 -0
- package/src/delegate.ts +80 -179
- package/src/delegation-approval.ts +99 -0
- package/src/git-identity.ts +52 -0
- package/src/index.ts +50 -0
- package/src/lease-record.ts +119 -0
- package/src/ledger-events.ts +138 -0
- package/src/ledger-report.ts +90 -2
- package/src/ledger.ts +68 -3
- package/src/refusals.ts +66 -0
- package/src/run-child.ts +23 -1
- package/src/run-herdr.ts +41 -11
- package/src/workspace-lease.ts +303 -0
- package/src/workspace.ts +135 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lease's RECORD: what a workspace lease writes down about who holds it and how they let it go.
|
|
3
|
+
*
|
|
4
|
+
* Split from `./workspace-lease.ts` to stay under the 400-line module ceiling, along the seam this
|
|
5
|
+
* review round kept finding defects on — absent vs unreadable metadata, a swallowed handover write, a
|
|
6
|
+
* successor's token — all of which are about what the record SAYS rather than about the kernel lock.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { ValidatedWorkspace, WorkspaceAccess } from "./workspace.ts";
|
|
12
|
+
|
|
13
|
+
export interface LeaseMetadata {
|
|
14
|
+
version: 1;
|
|
15
|
+
state: "active" | "released";
|
|
16
|
+
token: string;
|
|
17
|
+
owner_id: string;
|
|
18
|
+
workspace_id: string;
|
|
19
|
+
root: string;
|
|
20
|
+
pid: number;
|
|
21
|
+
acquired_at: string;
|
|
22
|
+
released_at?: string;
|
|
23
|
+
release_reason?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WorkspaceLease {
|
|
27
|
+
workspace: ValidatedWorkspace;
|
|
28
|
+
access: WorkspaceAccess;
|
|
29
|
+
ownerId: string;
|
|
30
|
+
/**
|
|
31
|
+
* `true` only when the kernel lock was free but metadata said the prior owner never released.
|
|
32
|
+
* `"unknown"` when the prior owner's record could not be read at all — that is not evidence of a
|
|
33
|
+
* clean handover and must not be recorded as one (R-100).
|
|
34
|
+
*/
|
|
35
|
+
recovered: boolean | "unknown";
|
|
36
|
+
/** Attach the governed resource so parent death cleans it before the kernel lock is released. */
|
|
37
|
+
attachProcess(pid: number): void;
|
|
38
|
+
attachHerdrTab(tabId: string): void;
|
|
39
|
+
/** Resolves only when the kernel-lock helper exits before explicit release. */
|
|
40
|
+
lost: Promise<Error>;
|
|
41
|
+
/**
|
|
42
|
+
* NEVER throws. A cleanup function that can throw destroys its caller's return value — a completed
|
|
43
|
+
* child's entire output was discarded this way (R-99) — so the outcome is a VALUE the caller records:
|
|
44
|
+
* `"released"` the kernel lock was handed back and the handover recorded;
|
|
45
|
+
* `"released-unrecorded"` the lock was handed back but the record failed, so the NEXT owner will
|
|
46
|
+
* report a recovery that did not happen unless this is ledgered;
|
|
47
|
+
* `"lost"` the helper was already gone, so this owner never released anything.
|
|
48
|
+
*/
|
|
49
|
+
release(reason?: string): Promise<LeaseReleaseOutcome>;
|
|
50
|
+
/**
|
|
51
|
+
* Record that this lease is being kept deliberately, WITHOUT releasing the kernel lock.
|
|
52
|
+
*
|
|
53
|
+
* Needed because a retained lease never calls `release()`, so the metadata stays `state: "active"` and
|
|
54
|
+
* whoever acquires next reports `recovered: true` — blaming a crash on a known-good path.
|
|
55
|
+
*/
|
|
56
|
+
markRetained(reason?: string): Promise<void>;
|
|
57
|
+
/** Reads the give-up marker the helper leaves when it could not close a herdr writer tab. */
|
|
58
|
+
readCloseFailure(): Promise<{ reason: string; herdr_tab?: string } | null>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What a release actually did. Five members rather than three, because the first version conflated facts
|
|
63
|
+
* that call for different responses — and one of them is an alarm:
|
|
64
|
+
* `released` the lock went back and THIS owner wrote its own handover;
|
|
65
|
+
* `released-unrecorded` the lock went back and the record does not say so, so the next owner will report
|
|
66
|
+
* a recovery that never happened unless the caller ledgers this. **The alarm.**
|
|
67
|
+
* `released-superseded` the lock went back and a successor already owns the metadata, so we correctly
|
|
68
|
+
* declined to overwrite it. Healthy, and previously indistinguishable from the
|
|
69
|
+
* alarm above;
|
|
70
|
+
* `not-held` a read lease, which never took a kernel lock — recording it as `released`
|
|
71
|
+
* overstated how many handovers the kernel performed, which is R-105 on the
|
|
72
|
+
* release side of the same defect;
|
|
73
|
+
* `lost` the helper was already gone; this owner released nothing.
|
|
74
|
+
*/
|
|
75
|
+
export type LeaseReleaseOutcome =
|
|
76
|
+
| "released"
|
|
77
|
+
| "released-unrecorded"
|
|
78
|
+
| "released-superseded"
|
|
79
|
+
| "not-held"
|
|
80
|
+
| "lost";
|
|
81
|
+
|
|
82
|
+
export function leasePaths(leaseDir: string, root: string) {
|
|
83
|
+
// Canonical root, never caller-chosen workspace ID: aliases for one worktree must contend.
|
|
84
|
+
const key = createHash("sha256").update(root, "utf8").digest("hex");
|
|
85
|
+
return {
|
|
86
|
+
lock: join(leaseDir, `${key}.lock`),
|
|
87
|
+
metadata: join(leaseDir, `${key}.json`),
|
|
88
|
+
marker: join(leaseDir, `${key}.close-failed.json`),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function atomicMetadata(path: string, value: LeaseMetadata): Promise<void> {
|
|
93
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
94
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
95
|
+
await rename(temp, path);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `null` means no prior owner was recorded. `"malformed"` means a record exists and cannot be read,
|
|
100
|
+
* which is strictly LESS evidence than a readable "active" one and must never read as a clean handover
|
|
101
|
+
* (R-100). Absent and unreadable are different facts; conflating them silently erases a real recovery.
|
|
102
|
+
*/
|
|
103
|
+
export async function readMetadata(path: string): Promise<LeaseMetadata | null | "malformed"> {
|
|
104
|
+
let raw: string;
|
|
105
|
+
try {
|
|
106
|
+
raw = await readFile(path, "utf8");
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return (error as NodeJS.ErrnoException)?.code === "ENOENT" ? null : "malformed";
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const value = JSON.parse(raw) as Partial<LeaseMetadata>;
|
|
112
|
+
if (value?.version !== 1) return "malformed";
|
|
113
|
+
if (value.state !== "active" && value.state !== "released") return "malformed";
|
|
114
|
+
if (typeof value.token !== "string" || !value.token) return "malformed";
|
|
115
|
+
return value as LeaseMetadata;
|
|
116
|
+
} catch {
|
|
117
|
+
return "malformed";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The v2 runtime-event half of the ledger: workspace leases, child lifecycle and check receipts, with
|
|
3
|
+
* their builders. Split out of `./ledger.ts` only to stay under the 400-line module ceiling this
|
|
4
|
+
* project enforces mechanically; `./ledger.ts` re-exports everything here, so "the ledger module"
|
|
5
|
+
* remains one import.
|
|
6
|
+
*/
|
|
7
|
+
import { LEDGER_VERSION, type LedgerEventBase, type GrantRecord } from "./ledger.ts";
|
|
8
|
+
import type { ExecutorKind } from "./executor.ts";
|
|
9
|
+
import type { CorrelationMetadata } from "./correlation.ts";
|
|
10
|
+
import type { StructuredRefusal } from "./refusals.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `released` is a handover this owner performed. FOUR members were added by the 0.18.0 review pass, and
|
|
14
|
+
* they were not all previously recorded the same way — `uncontended` was recorded as an *acquisition*, and
|
|
15
|
+
* `lost` was recorded as nothing at all, because the old `release()` threw before the append ran:
|
|
16
|
+
* `lost` the kernel lock went away under a live governed writer — NOT the same fact as a user
|
|
17
|
+
* cancelling, which is what it used to be indistinguishable from (R-103);
|
|
18
|
+
* `retained` the lease was deliberately kept because a herdr writer tab would not close, so the
|
|
19
|
+
* successor's `recovered: true` would otherwise blame a healthy path (R-104);
|
|
20
|
+
* `uncontended` a read lease took no kernel lock at all, so counting it as an acquisition
|
|
21
|
+
* overstated how many exclusions the kernel actually performed (R-105).
|
|
22
|
+
*/
|
|
23
|
+
export type WorkspaceLeaseOutcome =
|
|
24
|
+
| "acquired"
|
|
25
|
+
| "uncontended"
|
|
26
|
+
| "refused"
|
|
27
|
+
| "released"
|
|
28
|
+
| "released-unrecorded"
|
|
29
|
+
| "lost"
|
|
30
|
+
| "retained"
|
|
31
|
+
| "timeout"
|
|
32
|
+
| "recovered";
|
|
33
|
+
|
|
34
|
+
export interface WorkspaceLeaseEvent extends LedgerEventBase {
|
|
35
|
+
ledgerVersion: typeof LEDGER_VERSION;
|
|
36
|
+
event: "workspace_lease";
|
|
37
|
+
childId: string;
|
|
38
|
+
workspaceId: string;
|
|
39
|
+
root: string;
|
|
40
|
+
access: "read" | "write";
|
|
41
|
+
outcome: WorkspaceLeaseOutcome;
|
|
42
|
+
/** `"unknown"` when the prior owner's record was unreadable — not evidence of a clean handover. */
|
|
43
|
+
recovered?: boolean | "unknown";
|
|
44
|
+
releaseReason?: string;
|
|
45
|
+
refusal?: StructuredRefusal;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ChildLifecycleEvent extends LedgerEventBase {
|
|
49
|
+
ledgerVersion: typeof LEDGER_VERSION;
|
|
50
|
+
event: "child_lifecycle";
|
|
51
|
+
childId: string;
|
|
52
|
+
state: "starting" | "completed" | "failed";
|
|
53
|
+
executor: ExecutorKind;
|
|
54
|
+
exitCode?: number | null;
|
|
55
|
+
signal?: NodeJS.Signals | null;
|
|
56
|
+
timedOut?: boolean;
|
|
57
|
+
aborted?: boolean;
|
|
58
|
+
truncated?: boolean;
|
|
59
|
+
reason?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CheckReceiptLedgerEvent extends LedgerEventBase {
|
|
63
|
+
ledgerVersion: typeof LEDGER_VERSION;
|
|
64
|
+
event: "check_receipt";
|
|
65
|
+
childId: string;
|
|
66
|
+
receiptId: string;
|
|
67
|
+
workspaceId: string;
|
|
68
|
+
checkId: string;
|
|
69
|
+
treeSha: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type RuntimeLedgerEvent =
|
|
73
|
+
| (GrantRecord & { ledgerVersion: typeof LEDGER_VERSION; event: "capability_decision" })
|
|
74
|
+
| WorkspaceLeaseEvent
|
|
75
|
+
| ChildLifecycleEvent
|
|
76
|
+
| CheckReceiptLedgerEvent;
|
|
77
|
+
|
|
78
|
+
export function buildWorkspaceLeaseEvent(args: {
|
|
79
|
+
childId: string;
|
|
80
|
+
workspaceId: string;
|
|
81
|
+
root: string;
|
|
82
|
+
access: "read" | "write";
|
|
83
|
+
outcome: WorkspaceLeaseOutcome;
|
|
84
|
+
recovered?: boolean | "unknown";
|
|
85
|
+
releaseReason?: string;
|
|
86
|
+
refusal?: StructuredRefusal;
|
|
87
|
+
correlation?: CorrelationMetadata;
|
|
88
|
+
now: Date;
|
|
89
|
+
}): WorkspaceLeaseEvent {
|
|
90
|
+
return {
|
|
91
|
+
ledgerVersion: LEDGER_VERSION,
|
|
92
|
+
event: "workspace_lease",
|
|
93
|
+
ts: args.now.toISOString(),
|
|
94
|
+
childId: args.childId,
|
|
95
|
+
workspaceId: args.workspaceId,
|
|
96
|
+
root: args.root,
|
|
97
|
+
access: args.access,
|
|
98
|
+
outcome: args.outcome,
|
|
99
|
+
// `!== undefined`, not truthiness. `recovered: false` — a POSITIVE assertion that the predecessor
|
|
100
|
+
// handed over cleanly — was dropped while the truthy `"unknown"` survived, so absence meant both
|
|
101
|
+
// "clean" and "never supplied". R-100's whole argument is that absence of evidence is not evidence,
|
|
102
|
+
// and this was the one line discarding the evidence.
|
|
103
|
+
...(args.recovered !== undefined ? { recovered: args.recovered } : {}),
|
|
104
|
+
...(args.releaseReason ? { releaseReason: args.releaseReason } : {}),
|
|
105
|
+
...(args.refusal ? { refusal: structuredClone(args.refusal) } : {}),
|
|
106
|
+
...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function buildChildLifecycleEvent(args: {
|
|
111
|
+
childId: string;
|
|
112
|
+
state: "starting" | "completed" | "failed";
|
|
113
|
+
executor: ExecutorKind;
|
|
114
|
+
exitCode?: number | null;
|
|
115
|
+
signal?: NodeJS.Signals | null;
|
|
116
|
+
timedOut?: boolean;
|
|
117
|
+
aborted?: boolean;
|
|
118
|
+
truncated?: boolean;
|
|
119
|
+
reason?: string;
|
|
120
|
+
correlation?: CorrelationMetadata;
|
|
121
|
+
now: Date;
|
|
122
|
+
}): ChildLifecycleEvent {
|
|
123
|
+
return {
|
|
124
|
+
ledgerVersion: LEDGER_VERSION,
|
|
125
|
+
event: "child_lifecycle",
|
|
126
|
+
ts: args.now.toISOString(),
|
|
127
|
+
childId: args.childId,
|
|
128
|
+
state: args.state,
|
|
129
|
+
executor: args.executor,
|
|
130
|
+
...(args.exitCode !== undefined ? { exitCode: args.exitCode } : {}),
|
|
131
|
+
...(args.signal !== undefined ? { signal: args.signal } : {}),
|
|
132
|
+
...(args.timedOut ? { timedOut: true } : {}),
|
|
133
|
+
...(args.aborted ? { aborted: true } : {}),
|
|
134
|
+
...(args.truncated ? { truncated: true } : {}),
|
|
135
|
+
...(args.reason ? { reason: args.reason } : {}),
|
|
136
|
+
...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/ledger-report.ts
CHANGED
|
@@ -17,12 +17,20 @@ import { DELEGATE_SUBJECT } from "./approval.ts";
|
|
|
17
17
|
import type { ApprovalSource } from "./approval.ts";
|
|
18
18
|
import type { GrantRecord } from "./ledger.ts";
|
|
19
19
|
import { isEscalationAttempt } from "./ledger.ts";
|
|
20
|
+
import type { WorkspaceLeaseOutcome } from "./ledger-events.ts";
|
|
20
21
|
|
|
21
22
|
export interface LedgerReport {
|
|
22
23
|
/** False when the file is absent — a configuration state, not damage. */
|
|
23
24
|
exists: boolean;
|
|
24
|
-
/**
|
|
25
|
+
/** Capability-decision records (legacy or v2). */
|
|
25
26
|
records: number;
|
|
27
|
+
/** Every parsed ledger event, including lifecycle and lease events. */
|
|
28
|
+
events: number;
|
|
29
|
+
workspaceLeases: {
|
|
30
|
+
acquired: number; uncontended: number; refused: number; released: number;
|
|
31
|
+
releasedUnrecorded: number; lost: number; retained: number; timeout: number; recovered: number;
|
|
32
|
+
};
|
|
33
|
+
lifecycle: { starting: number; completed: number; failed: number };
|
|
26
34
|
/** Lines that did not, with 1-based line numbers so the report is actionable. */
|
|
27
35
|
corrupt: Array<{ line: number; text: string }>;
|
|
28
36
|
/** Records where an agent asked for more than it held — ADR-0008's designated signal. */
|
|
@@ -116,6 +124,43 @@ export interface LedgerReport {
|
|
|
116
124
|
* Deliberately reports rather than repairs. A corrupt line is evidence; rewriting the file to make it parse
|
|
117
125
|
* would destroy the one artifact an investigation has.
|
|
118
126
|
*/
|
|
127
|
+
// Keyed by the UNION, not by `string`. Typed loosely, adding a `WorkspaceLeaseOutcome` member without a
|
|
128
|
+
// counter compiled fine and then made `verifyLedger` file the package's own valid event as CORRUPTION —
|
|
129
|
+
// the worst available outcome for an integrity signal, since real damage becomes noise.
|
|
130
|
+
const LEASE_OUTCOME_COUNTERS: Record<WorkspaceLeaseOutcome, keyof LedgerReport["workspaceLeases"]> = {
|
|
131
|
+
acquired: "acquired",
|
|
132
|
+
uncontended: "uncontended",
|
|
133
|
+
refused: "refused",
|
|
134
|
+
released: "released",
|
|
135
|
+
"released-unrecorded": "releasedUnrecorded",
|
|
136
|
+
lost: "lost",
|
|
137
|
+
retained: "retained",
|
|
138
|
+
timeout: "timeout",
|
|
139
|
+
recovered: "recovered",
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function validV2Base(event: Record<string, unknown>): boolean {
|
|
143
|
+
return event.ledgerVersion === 2 && typeof event.ts === "string";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function requireV2(event: Record<string, unknown>, fields: string[]): void {
|
|
147
|
+
if (!validV2Base(event) || fields.some((field) => typeof event[field] !== "string" || event[field] === "")) {
|
|
148
|
+
throw new Error("invalid versioned ledger event");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function requireCapabilityDecision(event: Record<string, unknown>): void {
|
|
153
|
+
requireV2(event, ["parentId", "childId", "executor", "taskDigest"]);
|
|
154
|
+
for (const field of ["requested", "parentGrant", "effective", "denied", "clipped", "gatedBlocked"]) {
|
|
155
|
+
if (!Array.isArray(event[field]) || !(event[field] as unknown[]).every((value) => typeof value === "string")) {
|
|
156
|
+
throw new Error("invalid capability decision arrays");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (!Number.isInteger(event.depth) || typeof event.blocked !== "boolean" || !/^[a-f0-9]{64}$/i.test(String(event.taskDigest))) {
|
|
160
|
+
throw new Error("invalid capability decision identity");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
119
164
|
export async function verifyLedger(path: string): Promise<LedgerReport> {
|
|
120
165
|
let text: string;
|
|
121
166
|
try {
|
|
@@ -125,6 +170,9 @@ export async function verifyLedger(path: string): Promise<LedgerReport> {
|
|
|
125
170
|
return {
|
|
126
171
|
exists: false,
|
|
127
172
|
records: 0,
|
|
173
|
+
events: 0,
|
|
174
|
+
workspaceLeases: { acquired: 0, uncontended: 0, refused: 0, released: 0, releasedUnrecorded: 0, lost: 0, retained: 0, timeout: 0, recovered: 0 },
|
|
175
|
+
lifecycle: { starting: 0, completed: 0, failed: 0 },
|
|
128
176
|
corrupt: [],
|
|
129
177
|
escalationAttempts: 0,
|
|
130
178
|
executors: { herdr: 0, process: 0, unknown: 0 },
|
|
@@ -146,6 +194,9 @@ export async function verifyLedger(path: string): Promise<LedgerReport> {
|
|
|
146
194
|
// Keyed by name+digest: one name with two entries is the signal that the definition changed mid-ledger.
|
|
147
195
|
const digests = new Map<string, { name: string; source: string; sha256: string; spawns: number }>();
|
|
148
196
|
let records = 0;
|
|
197
|
+
let events = 0;
|
|
198
|
+
const workspaceLeases = { acquired: 0, uncontended: 0, refused: 0, released: 0, releasedUnrecorded: 0, lost: 0, retained: 0, timeout: 0, recovered: 0 };
|
|
199
|
+
const lifecycle = { starting: 0, completed: 0, failed: 0 };
|
|
149
200
|
let escalationAttempts = 0;
|
|
150
201
|
const executors = { herdr: 0, process: 0, unknown: 0 };
|
|
151
202
|
const bySource: Record<ApprovalSource, number> = { prompt: 0, session: 0, persisted: 0, inherited: 0 };
|
|
@@ -165,9 +216,43 @@ export async function verifyLedger(path: string): Promise<LedgerReport> {
|
|
|
165
216
|
// A trailing newline yields one empty final element, which is normal rather than damage.
|
|
166
217
|
if (raw.trim().length === 0) return;
|
|
167
218
|
try {
|
|
168
|
-
const
|
|
219
|
+
const event = JSON.parse(raw) as Record<string, unknown>;
|
|
220
|
+
if (event.ledgerVersion !== undefined && event.event === undefined) {
|
|
221
|
+
throw new Error("versioned ledger line has no event discriminator");
|
|
222
|
+
}
|
|
223
|
+
if (event.event === "workspace_lease") {
|
|
224
|
+
requireV2(event, ["childId", "workspaceId", "root", "access"]);
|
|
225
|
+
const outcome = typeof event.outcome === "string" ? event.outcome : "";
|
|
226
|
+
// Explicit map, not a name match: the ledger's outcome vocabulary and these counter names are
|
|
227
|
+
// allowed to differ, and a valid event must never be counted as corruption because a counter
|
|
228
|
+
// happens to be spelled differently.
|
|
229
|
+
const counter = Object.hasOwn(LEASE_OUTCOME_COUNTERS, outcome)
|
|
230
|
+
? LEASE_OUTCOME_COUNTERS[outcome as WorkspaceLeaseOutcome]
|
|
231
|
+
: undefined;
|
|
232
|
+
if (!counter) throw new Error("invalid workspace lease event");
|
|
233
|
+
workspaceLeases[counter] += 1;
|
|
234
|
+
events += 1;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (event.event === "child_lifecycle") {
|
|
238
|
+
requireV2(event, ["childId", "executor"]);
|
|
239
|
+
const state = typeof event.state === "string" ? event.state : "";
|
|
240
|
+
if (!Object.hasOwn(lifecycle, state)) throw new Error("invalid child lifecycle event");
|
|
241
|
+
lifecycle[state as keyof typeof lifecycle] += 1;
|
|
242
|
+
events += 1;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (event.event === "check_receipt") {
|
|
246
|
+
requireV2(event, ["childId", "receiptId", "workspaceId", "checkId", "treeSha"]);
|
|
247
|
+
events += 1;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (event.event !== undefined && event.event !== "capability_decision") throw new Error("unknown ledger event");
|
|
251
|
+
const parsed = event as unknown as GrantRecord;
|
|
252
|
+
if (event.event === "capability_decision") requireCapabilityDecision(event);
|
|
169
253
|
if (!Array.isArray(parsed.denied)) throw new Error("not a grant record");
|
|
170
254
|
records += 1;
|
|
255
|
+
events += 1;
|
|
171
256
|
if (isEscalationAttempt(parsed)) escalationAttempts += 1;
|
|
172
257
|
const executor = (parsed as { executor?: unknown }).executor;
|
|
173
258
|
if (executor === "herdr") executors.herdr += 1;
|
|
@@ -227,6 +312,9 @@ export async function verifyLedger(path: string): Promise<LedgerReport> {
|
|
|
227
312
|
return {
|
|
228
313
|
exists: true,
|
|
229
314
|
records,
|
|
315
|
+
events,
|
|
316
|
+
workspaceLeases,
|
|
317
|
+
lifecycle,
|
|
230
318
|
corrupt,
|
|
231
319
|
escalationAttempts,
|
|
232
320
|
executors,
|
package/src/ledger.ts
CHANGED
|
@@ -28,9 +28,29 @@ import type { DefinitionDigest } from "./definitions.ts";
|
|
|
28
28
|
import { DELEGATE_SUBJECT } from "./approval.ts";
|
|
29
29
|
import type { ApprovalScope, ApprovalSource } from "./approval.ts";
|
|
30
30
|
import type { PromptOutcomeKind } from "./approval-prompt.ts";
|
|
31
|
+
import type { CorrelationMetadata } from "./correlation.ts";
|
|
32
|
+
import type { StructuredRefusal } from "./refusals.ts";
|
|
33
|
+
// Type-only, so the cycle with ./ledger-events.ts is erased at runtime.
|
|
34
|
+
import type { RuntimeLedgerEvent } from "./ledger-events.ts";
|
|
31
35
|
|
|
32
|
-
export
|
|
36
|
+
export const LEDGER_VERSION = 2 as const;
|
|
37
|
+
|
|
38
|
+
export interface LedgerEventBase {
|
|
39
|
+
/** Optional only on the legacy-compatible `GrantRecord` public type; every v2 event builder writes it. */
|
|
40
|
+
ledgerVersion?: typeof LEDGER_VERSION;
|
|
41
|
+
event?: "capability_decision" | "workspace_lease" | "child_lifecycle" | "check_receipt";
|
|
33
42
|
ts: string;
|
|
43
|
+
childId?: string;
|
|
44
|
+
correlation?: CorrelationMetadata;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface GrantRecord extends LedgerEventBase {
|
|
48
|
+
/**
|
|
49
|
+
* Present only when a trusted `taskDigest` was supplied — that is what makes a line v2. `buildRecord`
|
|
50
|
+
* does NOT always emit it, and the comment here used to say it did while the emission site 200 lines
|
|
51
|
+
* down said the opposite. Two comments asserting contradictory facts about one field is R-28's shape.
|
|
52
|
+
*/
|
|
53
|
+
event?: "capability_decision";
|
|
34
54
|
parentId: string;
|
|
35
55
|
childId: string;
|
|
36
56
|
depth: number;
|
|
@@ -77,6 +97,10 @@ export interface GrantRecord {
|
|
|
77
97
|
* one capability while claiming to describe the set was not merely a reporting defect.
|
|
78
98
|
*/
|
|
79
99
|
approvalScopes?: Record<Capability, ApprovalScope>;
|
|
100
|
+
/** Persisted approval expiry, by capability. Session-scoped approvals have no wall-clock expiry. */
|
|
101
|
+
approvalExpiresAt?: Record<Capability, string>;
|
|
102
|
+
/** Count bounds where one exists. A consumed `once` approval records `{max:1, remaining:0}`. */
|
|
103
|
+
approvalUses?: Record<Capability, { max: number; remaining: number }>;
|
|
80
104
|
/** Present only when the source was a live prompt, and only when one scope covers the whole set. */
|
|
81
105
|
approvalScope?: ApprovalScope;
|
|
82
106
|
/** A human was asked and declined. Distinct from `denied`, which is an escalation attempt. */
|
|
@@ -134,6 +158,10 @@ export interface GrantRecord {
|
|
|
134
158
|
* `definitionDigest` names its instructions, and neither says where the TASK came from.
|
|
135
159
|
*/
|
|
136
160
|
taskFrom?: string;
|
|
161
|
+
/** Trusted SHA-256 of the exact task; task text remains forbidden (ADR-0034). */
|
|
162
|
+
taskDigest?: string;
|
|
163
|
+
/** Stable machine-readable refusal accompanying `reason`. */
|
|
164
|
+
refusal?: StructuredRefusal;
|
|
137
165
|
}
|
|
138
166
|
|
|
139
167
|
export interface LedgerOptions {
|
|
@@ -146,6 +174,11 @@ export interface LedgerOptions {
|
|
|
146
174
|
* closed. Set false only where the ledger is advisory.
|
|
147
175
|
*/
|
|
148
176
|
strict?: boolean;
|
|
177
|
+
/**
|
|
178
|
+
* Called when a NON-strict append failed. Required in spirit rather than in types: a caller that opts
|
|
179
|
+
* out of failing closed still has to say something, or the ledger silently develops holes.
|
|
180
|
+
*/
|
|
181
|
+
onFailure?: (error: unknown) => void;
|
|
149
182
|
}
|
|
150
183
|
|
|
151
184
|
export function buildRecord(args: {
|
|
@@ -161,6 +194,8 @@ export function buildRecord(args: {
|
|
|
161
194
|
approved?: Capability[];
|
|
162
195
|
approvalSources?: Record<Capability, ApprovalSource>;
|
|
163
196
|
approvalScopes?: Record<Capability, ApprovalScope>;
|
|
197
|
+
approvalExpiresAt?: Record<Capability, string>;
|
|
198
|
+
approvalUses?: Record<Capability, { max: number; remaining: number }>;
|
|
164
199
|
humanDenied?: boolean;
|
|
165
200
|
gateOutcome?: PromptOutcomeKind;
|
|
166
201
|
definitionDigest?: DefinitionDigest;
|
|
@@ -168,6 +203,9 @@ export function buildRecord(args: {
|
|
|
168
203
|
executor: ExecutorKind;
|
|
169
204
|
/** The child whose output composed this task (ADR-0033). Absent for anything but a chain step. */
|
|
170
205
|
taskFrom?: string;
|
|
206
|
+
taskDigest?: string;
|
|
207
|
+
correlation?: CorrelationMetadata;
|
|
208
|
+
refusal?: StructuredRefusal;
|
|
171
209
|
now: Date;
|
|
172
210
|
}): GrantRecord {
|
|
173
211
|
// R-46: the scalar is a SUMMARY, emitted only when it cannot mislead. `buildRecord` derives it rather
|
|
@@ -177,6 +215,9 @@ export function buildRecord(args: {
|
|
|
177
215
|
const scopes = args.approvalScopes ?? {};
|
|
178
216
|
const distinctScopes = [...new Set(Object.values(scopes))];
|
|
179
217
|
return {
|
|
218
|
+
// A trusted task digest is what distinguishes a v2 capability event from the legacy construction API.
|
|
219
|
+
// Production delegation always supplies it; old TypeScript callers remain able to append legacy lines.
|
|
220
|
+
...(args.taskDigest ? { ledgerVersion: LEDGER_VERSION, event: "capability_decision" as const } : {}),
|
|
180
221
|
ts: args.now.toISOString(),
|
|
181
222
|
parentId: args.parentId,
|
|
182
223
|
childId: args.childId,
|
|
@@ -184,6 +225,9 @@ export function buildRecord(args: {
|
|
|
184
225
|
agentType: args.agentType,
|
|
185
226
|
executor: args.executor,
|
|
186
227
|
...(args.taskFrom ? { taskFrom: args.taskFrom } : {}),
|
|
228
|
+
...(args.taskDigest ? { taskDigest: args.taskDigest } : {}),
|
|
229
|
+
...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
|
|
230
|
+
...(args.refusal ? { refusal: structuredClone(args.refusal) } : {}),
|
|
187
231
|
requested: args.requested,
|
|
188
232
|
parentGrant: args.parentGrant,
|
|
189
233
|
effective: args.result.effective,
|
|
@@ -197,6 +241,10 @@ export function buildRecord(args: {
|
|
|
197
241
|
...(Object.keys(sources).length > 0 ? { approvalSources: sources } : {}),
|
|
198
242
|
...(distinctScopes.length === 1 ? { approvalScope: distinctScopes[0] } : {}),
|
|
199
243
|
...(Object.keys(scopes).length > 0 ? { approvalScopes: scopes } : {}),
|
|
244
|
+
...(args.approvalExpiresAt && Object.keys(args.approvalExpiresAt).length > 0
|
|
245
|
+
? { approvalExpiresAt: args.approvalExpiresAt }
|
|
246
|
+
: {}),
|
|
247
|
+
...(args.approvalUses && Object.keys(args.approvalUses).length > 0 ? { approvalUses: args.approvalUses } : {}),
|
|
200
248
|
...(args.humanDenied ? { humanDenied: true } : {}),
|
|
201
249
|
// Written whenever a gate was reached and not satisfied by a yes. `granted` is omitted deliberately —
|
|
202
250
|
// an approved spawn already says so through `approvalSources`, and a field that appears on every record
|
|
@@ -228,8 +276,8 @@ export { verifyLedger, type LedgerReport } from "./ledger-report.ts";
|
|
|
228
276
|
const withLedgerLock = <T>(path: string, write: () => Promise<T>): Promise<T> =>
|
|
229
277
|
withFileLock(path, "grant ledger", write);
|
|
230
278
|
|
|
231
|
-
export async function
|
|
232
|
-
const line = `${JSON.stringify(
|
|
279
|
+
export async function appendLedgerEvent(options: LedgerOptions, event: RuntimeLedgerEvent | GrantRecord): Promise<void> {
|
|
280
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
233
281
|
try {
|
|
234
282
|
await mkdir(dirname(options.path), { recursive: true });
|
|
235
283
|
// O_APPEND alone is not enough once several processes write to one ledger — see `withLedgerLock`.
|
|
@@ -238,10 +286,27 @@ export async function appendRecord(options: LedgerOptions, record: GrantRecord):
|
|
|
238
286
|
if (options.strict ?? true) {
|
|
239
287
|
throw new Error(`grant ledger write failed (failing closed): ${String(error)}`);
|
|
240
288
|
}
|
|
289
|
+
// A non-strict append is a deliberate choice not to fail closed. It is NOT a choice to be silent:
|
|
290
|
+
// a silent safe-mode is as confusing as a silent unsafe one, so the caller is always told.
|
|
291
|
+
options.onFailure?.(error);
|
|
241
292
|
}
|
|
242
293
|
}
|
|
243
294
|
|
|
295
|
+
export async function appendRecord(options: LedgerOptions, record: GrantRecord): Promise<void> {
|
|
296
|
+
return appendLedgerEvent(options, record);
|
|
297
|
+
}
|
|
298
|
+
|
|
244
299
|
/** True when this record shows an agent asking for more than it holds. */
|
|
245
300
|
export function isEscalationAttempt(record: GrantRecord): boolean {
|
|
246
301
|
return record.denied.length > 0;
|
|
247
302
|
}
|
|
303
|
+
|
|
304
|
+
export {
|
|
305
|
+
buildChildLifecycleEvent,
|
|
306
|
+
buildWorkspaceLeaseEvent,
|
|
307
|
+
type ChildLifecycleEvent,
|
|
308
|
+
type CheckReceiptLedgerEvent,
|
|
309
|
+
type RuntimeLedgerEvent,
|
|
310
|
+
type WorkspaceLeaseEvent,
|
|
311
|
+
type WorkspaceLeaseOutcome,
|
|
312
|
+
} from "./ledger-events.ts";
|
package/src/refusals.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const REFUSAL_CODES = [
|
|
2
|
+
"CAPABILITY_ESCALATION",
|
|
3
|
+
"DEFINITION_NOT_AUTHORIZED",
|
|
4
|
+
"UNDECLARED_TOOLS",
|
|
5
|
+
"UNKNOWN_TOOL",
|
|
6
|
+
"GATED_UNAPPROVED",
|
|
7
|
+
"APPROVAL_EXPIRED",
|
|
8
|
+
"APPROVAL_SCOPE_MISMATCH",
|
|
9
|
+
"APPROVAL_FLOW_FAILED",
|
|
10
|
+
"DEPTH_EXCEEDED",
|
|
11
|
+
"FANOUT_EXCEEDED",
|
|
12
|
+
"EXECUTOR_UNAVAILABLE",
|
|
13
|
+
// Execution-phase outcomes. Without these, an external controller could distinguish a policy refusal
|
|
14
|
+
// from an internal error but not a lost writer lease from a user pressing stop (R-103).
|
|
15
|
+
"CHILD_TIMED_OUT",
|
|
16
|
+
"CHILD_CANCELLED",
|
|
17
|
+
"CHILD_EXIT_NONZERO",
|
|
18
|
+
// Planner refusals that previously carried a message and no code at all — including the narrowing
|
|
19
|
+
// violation, which is the hardest rule this package enforces (R-109).
|
|
20
|
+
"TASK_MISSING",
|
|
21
|
+
"UNKNOWN_DEFINITION",
|
|
22
|
+
"CEILING_PATTERNS_UNRESOLVED",
|
|
23
|
+
"NARROWING_VIOLATED",
|
|
24
|
+
"DEFINITION_UNREADABLE",
|
|
25
|
+
"CORRELATION_TOO_LARGE",
|
|
26
|
+
"CORRELATION_INVALID",
|
|
27
|
+
"LEDGER_WRITE_FAILED",
|
|
28
|
+
"FANOUT_FAILED",
|
|
29
|
+
"WORKSPACE_NOT_REGISTERED",
|
|
30
|
+
"WORKSPACE_WRITE_CONFLICT",
|
|
31
|
+
"WORKSPACE_LEASE_STALE",
|
|
32
|
+
"CHECK_NOT_CONFIGURED",
|
|
33
|
+
"CHECK_CONFIGURATION_INVALID",
|
|
34
|
+
"CHECK_IDENTITY_UNAVAILABLE",
|
|
35
|
+
"CHECK_IDENTITY_MISMATCH",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
export type RefusalCode = (typeof REFUSAL_CODES)[number];
|
|
39
|
+
|
|
40
|
+
export interface StructuredRefusal {
|
|
41
|
+
code: RefusalCode;
|
|
42
|
+
/** Existing actionable diagnostic. Codes accompany rather than replace it. */
|
|
43
|
+
message: string;
|
|
44
|
+
details?: Record<string, string | number | boolean | null>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function refusal(
|
|
48
|
+
code: RefusalCode,
|
|
49
|
+
message: string,
|
|
50
|
+
details?: StructuredRefusal["details"],
|
|
51
|
+
): StructuredRefusal {
|
|
52
|
+
return { code, message, ...(details && Object.keys(details).length > 0 ? { details: { ...details } } : {}) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Error shape for API callers. pi-facing tools still throw, as pi requires, while retaining a stable code. */
|
|
56
|
+
export class GovernanceRefusal extends Error {
|
|
57
|
+
readonly code: RefusalCode;
|
|
58
|
+
readonly details?: StructuredRefusal["details"];
|
|
59
|
+
|
|
60
|
+
constructor(value: StructuredRefusal) {
|
|
61
|
+
super(value.message);
|
|
62
|
+
this.name = "GovernanceRefusal";
|
|
63
|
+
this.code = value.code;
|
|
64
|
+
this.details = value.details;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/run-child.ts
CHANGED
|
@@ -81,6 +81,8 @@ export interface ChildRunRequest {
|
|
|
81
81
|
* governed child mid-task.
|
|
82
82
|
*/
|
|
83
83
|
onOutput?: (chunk: string) => void;
|
|
84
|
+
/** Security hook called immediately after spawn, before output handling (for lease attachment). */
|
|
85
|
+
onSpawn?: (pid: number) => void;
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
export interface ChildRunResult {
|
|
@@ -90,6 +92,8 @@ export interface ChildRunResult {
|
|
|
90
92
|
truncated: boolean;
|
|
91
93
|
timedOut: boolean;
|
|
92
94
|
aborted: boolean;
|
|
95
|
+
/** Signal that ended the process, separate from the nullable numeric exit code. */
|
|
96
|
+
signal?: NodeJS.Signals | null;
|
|
93
97
|
/** Set when the process could not be started at all. */
|
|
94
98
|
spawnError?: string;
|
|
95
99
|
}
|
|
@@ -125,6 +129,17 @@ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
|
|
|
125
129
|
settle({ code: null, text: "", truncated: false, timedOut: false, aborted: false, spawnError: String(error) });
|
|
126
130
|
return;
|
|
127
131
|
}
|
|
132
|
+
try {
|
|
133
|
+
if (child.pid !== undefined) request.onSpawn?.(child.pid);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const failed = { code: null, text: "", truncated: false, timedOut: false, aborted: false, spawnError: String(error) };
|
|
136
|
+
child.stdout?.destroy();
|
|
137
|
+
child.stderr?.destroy();
|
|
138
|
+
child.once("error", () => settle(failed));
|
|
139
|
+
child.once("close", () => settle(failed));
|
|
140
|
+
child.kill("SIGKILL");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
128
143
|
|
|
129
144
|
// One decoder for BOTH streams is deliberate: they are already interleaved into one `text`, and two
|
|
130
145
|
// decoders would each hold their own partial character, so a split byte could surface out of order.
|
|
@@ -227,12 +242,19 @@ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
|
|
|
227
242
|
done = true;
|
|
228
243
|
clearTimers();
|
|
229
244
|
request.signal?.removeEventListener("abort", onAbort);
|
|
245
|
+
child.stdout?.destroy();
|
|
246
|
+
child.stderr?.destroy();
|
|
230
247
|
settle(result);
|
|
231
248
|
};
|
|
232
249
|
|
|
233
250
|
child.on("error", (error) =>
|
|
234
251
|
finish({ code: null, text, truncated, timedOut, aborted, spawnError: String(error) }),
|
|
235
252
|
);
|
|
236
|
-
|
|
253
|
+
// `close` waits for every inherited pipe, including one retained by a detached grandchild. Once the
|
|
254
|
+
// governed PID exits, allow a short drain and settle anyway so timeout/output bounds remain bounds.
|
|
255
|
+
child.on("exit", (code, signal) => {
|
|
256
|
+
timers.push(setTimeout(() => finish({ code, signal, text, truncated, timedOut, aborted }), 100));
|
|
257
|
+
});
|
|
258
|
+
child.on("close", (code, signal) => finish({ code, signal, text, truncated, timedOut, aborted }));
|
|
237
259
|
});
|
|
238
260
|
}
|