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
package/src/delegate.ts
CHANGED
|
@@ -6,13 +6,22 @@
|
|
|
6
6
|
import { planSpawn } from "./spawn.ts";
|
|
7
7
|
import { ceilingForDefinition, digestDefinition, type DefinitionDigest, type SkillDefinition } from "./definitions.ts";
|
|
8
8
|
import { assertNarrowing, type Capability, type ResolveResult } from "./resolve.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { checkRoutingAuthority, checkWorkspaceWildcardRequest } from "./routing-authority.ts";
|
|
10
|
+
import {
|
|
11
|
+
DELEGATE_CAPABILITY,
|
|
12
|
+
agentCapability,
|
|
13
|
+
maySpawnDefinition,
|
|
14
|
+
normaliseCapability,
|
|
15
|
+
} from "./capabilities.ts";
|
|
10
16
|
|
|
11
17
|
// Re-exported so the split stays internal: `delegate.ts` has been the import site for these since 0.6.0 and
|
|
12
18
|
// four modules plus the test suite name it. Moving the definitions without moving the door would be churn
|
|
13
19
|
// charged to every caller for a line count they did not cause.
|
|
14
20
|
export { DELEGATE_CAPABILITY, agentCapability, maySpawnDefinition, normaliseCapability } from "./capabilities.ts";
|
|
15
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
ENV_APPROVED, ENV_DEPTH, ENV_EXECUTION_ID, ENV_FANOUT, ENV_GATED, ENV_GRANT, ENV_LEDGER, ENV_MAX_DEPTH,
|
|
23
|
+
ENV_PARENT_ID, inheritableGrant,
|
|
24
|
+
} from "./propagation.ts";
|
|
16
25
|
import { inheritApprovals, type InheritableApproval } from "./approval.ts";
|
|
17
26
|
import { suggestForUnknown, unknownCapabilities, type Catalog } from "./catalog.ts";
|
|
18
27
|
import { GovernanceRefusal, refusal, type RefusalCode, type StructuredRefusal } from "./refusals.ts";
|
|
@@ -72,6 +81,17 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
|
|
|
72
81
|
}
|
|
73
82
|
if (!request.task?.trim()) return denied({ ...empty, reason: "a delegation needs a task" }, "TASK_MISSING");
|
|
74
83
|
|
|
84
|
+
// ADR-0035's routing guards live in `routing-authority.ts` with their rationale; both are checked here,
|
|
85
|
+
// before anything is said about the target, because they are governance questions about the SESSION.
|
|
86
|
+
const routing = checkRoutingAuthority(request.boundWorkspaceId, ctx.ownGrant);
|
|
87
|
+
if (routing) {
|
|
88
|
+
return denied({
|
|
89
|
+
...empty,
|
|
90
|
+
...(routing.denied ? { requested: routing.denied, result: { ...empty.result, denied: routing.denied } } : {}),
|
|
91
|
+
reason: routing.reason,
|
|
92
|
+
}, routing.code);
|
|
93
|
+
}
|
|
94
|
+
|
|
75
95
|
// ADR-0016. A named definition replaces the model's tool list with an operator-authored ceiling.
|
|
76
96
|
let requested: Capability[];
|
|
77
97
|
let systemPrompt: string | undefined;
|
|
@@ -181,6 +201,13 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
|
|
|
181
201
|
}
|
|
182
202
|
}
|
|
183
203
|
|
|
204
|
+
// See `checkWorkspaceWildcardRequest`: refused for a HOLDER (the ledger must not record authority the
|
|
205
|
+
// child will not receive), and left to `resolve()` for anyone else, so the probe lands in `denied`.
|
|
206
|
+
const wildcardRequest = checkWorkspaceWildcardRequest(requested, ctx.ownGrant);
|
|
207
|
+
if (wildcardRequest) {
|
|
208
|
+
return denied({ ...empty, requested, reason: wildcardRequest.reason }, wildcardRequest.code);
|
|
209
|
+
}
|
|
210
|
+
|
|
184
211
|
const { result, approvalBinding, bindingMismatch } = resolveDelegationApproval({
|
|
185
212
|
task: request.task,
|
|
186
213
|
agent: request.agent,
|
|
@@ -266,8 +293,14 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
|
|
|
266
293
|
args.splice(args.length - 1, 0, "-e", ctx.extensionPath);
|
|
267
294
|
}
|
|
268
295
|
|
|
296
|
+
// `inheritableGrant`, not `result.effective` directly: this is the path a DELEGATED child's grant
|
|
297
|
+
// actually travels, and the "held but never inherited" rule for `tool:*` and `workspace:*` was enforced
|
|
298
|
+
// only in `childEnv`. A parent holding `workspace:*` could request it for its child and this line handed
|
|
299
|
+
// it over, so the rule ADR-0035 advertises held by accident — masked downstream rather than enforced
|
|
300
|
+
// here. One spelling of the rule, called from both paths.
|
|
301
|
+
const inheritable = inheritableGrant(result.effective);
|
|
269
302
|
const env: Record<string, string> = {
|
|
270
|
-
[ENV_GRANT]: (assertCapabilitiesArePropagatable(
|
|
303
|
+
[ENV_GRANT]: (assertCapabilitiesArePropagatable(inheritable), inheritable.join(",")),
|
|
271
304
|
[ENV_DEPTH]: String(childDepth),
|
|
272
305
|
[ENV_MAX_DEPTH]: String(ctx.maxDepth),
|
|
273
306
|
};
|
|
@@ -276,12 +309,17 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
|
|
|
276
309
|
// process boundaries with no shared state.
|
|
277
310
|
if (ctx.fanoutBudget !== undefined) env[ENV_FANOUT] = String(ctx.fanoutBudget);
|
|
278
311
|
if (ctx.childSpawnId) env[ENV_PARENT_ID] = ctx.childSpawnId;
|
|
312
|
+
if (ctx.childExecutionId) env[ENV_EXECUTION_ID] = ctx.childExecutionId;
|
|
279
313
|
if (ctx.gated.length > 0) env[ENV_GATED] = ctx.gated.join(",");
|
|
280
314
|
// Approvals ride down with the grant, but only ever for what this child actually received — so
|
|
281
315
|
// `approved ⊆ grant` holds at every level (ADR-0010). Written even when empty, so this object states
|
|
282
316
|
// the child's approval set outright rather than leaving it to whatever the caller merges over; see
|
|
283
317
|
// `mergeChildEnv`, which is what actually stops the parent's value leaking through.
|
|
284
|
-
|
|
318
|
+
// Clamped to what the child actually INHERITS, not to what it was granted. The two differ only for a
|
|
319
|
+
// non-inheritable wildcard, and passing down an approval for a capability the child does not hold would
|
|
320
|
+
// leave banked authority with nothing to spend it on — `childEnv` clamps to `inheritable` for the same
|
|
321
|
+
// reason on the other path.
|
|
322
|
+
env[ENV_APPROVED] = inheritApprovals(ctx.approved ?? [], inheritable).join(",");
|
|
285
323
|
if (ctx.ledgerPath) env[ENV_LEDGER] = ctx.ledgerPath;
|
|
286
324
|
|
|
287
325
|
return {
|
|
@@ -293,6 +331,7 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
|
|
|
293
331
|
childDepth,
|
|
294
332
|
requested,
|
|
295
333
|
childId: ctx.childSpawnId,
|
|
334
|
+
executionId: ctx.childExecutionId,
|
|
296
335
|
taskDigest,
|
|
297
336
|
...(correlation ? { correlation } : {}),
|
|
298
337
|
...(approvalBinding ? { approvalBinding } : {}),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DELEGATE_SUBJECT, type InheritableApproval } from "./approval.ts";
|
|
2
|
-
import { agentCapability } from "./capabilities.ts";
|
|
2
|
+
import { agentCapability, workspaceCapability } from "./capabilities.ts";
|
|
3
3
|
import {
|
|
4
4
|
approvalBindingsEqual,
|
|
5
5
|
buildApprovalBinding,
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type CorrelationMetadata,
|
|
8
8
|
} from "./correlation.ts";
|
|
9
9
|
import type { DefinitionDigest, SkillDefinition } from "./definitions.ts";
|
|
10
|
-
import { AGENT_WILDCARD, resolve, type Capability, type ResolveResult } from "./resolve.ts";
|
|
10
|
+
import { AGENT_WILDCARD, WORKSPACE_WILDCARD, resolve, type Capability, type ResolveResult } from "./resolve.ts";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Resolve the approval half of one delegation after its requested capability set is known.
|
|
@@ -44,15 +44,32 @@ export function resolveDelegationApproval(input: {
|
|
|
44
44
|
approved: [],
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Authorities the PARENT is spending on this one delegation, gated as such.
|
|
49
|
+
*
|
|
50
|
+
* ADR-0024 established the shape for `agent:<name>`: the id is gated as the parent's authority to run
|
|
51
|
+
* that definition *now*, and never joins requested/effective, because that would hand the child authority
|
|
52
|
+
* to recursively spawn itself. ADR-0035 added a second member of the category — `workspace:<id>`, the
|
|
53
|
+
* authority to route this child somewhere — and shipped without it, so `PI_GRANTS_GATED=workspace:prod`
|
|
54
|
+
* was accepted, recorded, and silently inert: no human was ever asked. The ADR claimed the opposite in
|
|
55
|
+
* three places.
|
|
56
|
+
*
|
|
57
|
+
* A LIST rather than two variables on purpose. This is the third namespace whose authorising id is gated
|
|
58
|
+
* per-delegation rather than granted downward, and the first two were written as one special case each;
|
|
59
|
+
* a fourth should extend an array, not add a third `if` and a third field to thread through.
|
|
60
|
+
*/
|
|
61
|
+
const authorisingCapabilities: Capability[] = [];
|
|
62
|
+
const gateAuthority = (authorising: Capability, wildcard: Capability) => {
|
|
63
|
+
if (input.gated.includes(authorising) || input.gated.includes(wildcard)) {
|
|
64
|
+
authorisingCapabilities.push(authorising);
|
|
54
65
|
unapproved.gatedBlocked = [...unapproved.gatedBlocked, authorising];
|
|
55
66
|
}
|
|
67
|
+
};
|
|
68
|
+
if (input.spawned) gateAuthority(agentCapability(input.spawned.name), AGENT_WILDCARD);
|
|
69
|
+
// Trusted: `boundWorkspaceId` is set only from a routing spec resolved against the operator registry,
|
|
70
|
+
// never from a model-supplied `correlation` claim (R-110).
|
|
71
|
+
if (input.boundWorkspaceId) {
|
|
72
|
+
gateAuthority(workspaceCapability(input.boundWorkspaceId), WORKSPACE_WILDCARD);
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
const potential = resolve({
|
|
@@ -92,8 +109,16 @@ export function resolveDelegationApproval(input: {
|
|
|
92
109
|
gated: input.gated,
|
|
93
110
|
approved: approvedCapabilities,
|
|
94
111
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
112
|
+
// Filtered against what `resolve()` already listed, not just against approvals. In the ORDINARY chained
|
|
113
|
+
// configuration — route the child to `prod` AND grant it `workspace:prod` so it can route onward — the
|
|
114
|
+
// authorising id and the requested id are spelled identically, so appending unconditionally produced
|
|
115
|
+
// `gatedBlocked: ["workspace:prod","workspace:prod"]`, which reached the refusal text a model reads
|
|
116
|
+
// (*"workspace:prod, workspace:prod requires explicit approval"*) and the append-only ledger, whose schema
|
|
117
|
+
// has no `uniqueItems`. The old `agent:`-only code had the same shape and never hit it, because a
|
|
118
|
+
// self-recursive `agent:X` ceiling is not a thing anybody writes.
|
|
119
|
+
const stillGated = authorisingCapabilities.filter(
|
|
120
|
+
(c) => !approvedCapabilities.includes(c) && !result.gatedBlocked.includes(c),
|
|
121
|
+
);
|
|
122
|
+
if (stillGated.length > 0) result.gatedBlocked = [...result.gatedBlocked, ...stillGated];
|
|
98
123
|
return { result, ...(approvalBinding ? { approvalBinding } : {}), bindingMismatch };
|
|
99
124
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** Unique identity for one governed execution occurrence. Logical child ids remain readable positions. */
|
|
4
|
+
export type ExecutionId = `exec:${string}`;
|
|
5
|
+
|
|
6
|
+
const EXECUTION_ID_RE = /^exec:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
7
|
+
|
|
8
|
+
export function isExecutionId(value: unknown): value is ExecutionId {
|
|
9
|
+
return typeof value === "string" && EXECUTION_ID_RE.test(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function newExecutionId(): ExecutionId {
|
|
13
|
+
return `exec:${randomUUID()}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function assertExecutionId(value: unknown, field = "executionId"): asserts value is ExecutionId {
|
|
17
|
+
if (!isExecutionId(value)) throw new TypeError(`${field} must be a pi-daddy execution id`);
|
|
18
|
+
}
|
package/src/executor.ts
CHANGED
|
@@ -18,7 +18,8 @@ import type { HerdrProbe } from "./herdr-cli.ts";
|
|
|
18
18
|
|
|
19
19
|
export const ENV_HERDR = "PI_GRANTS_HERDR";
|
|
20
20
|
|
|
21
|
-
export
|
|
21
|
+
export const EXECUTOR_KINDS = ["process", "herdr"] as const;
|
|
22
|
+
export type ExecutorKind = typeof EXECUTOR_KINDS[number];
|
|
22
23
|
|
|
23
24
|
export interface ExecutorChoice {
|
|
24
25
|
kind: ExecutorKind;
|
package/src/grant-env.ts
CHANGED
|
@@ -42,6 +42,12 @@ export const WITHHELD_BY_DEFAULT: readonly Capability[] = [
|
|
|
42
42
|
|
|
43
43
|
/** Would `init` put this capability in the live grant? */
|
|
44
44
|
export function isLiveByDefault(capability: Capability): boolean {
|
|
45
|
+
// No `workspace:<id>` is ever live by default (ADR-0035), and this is the same rule the list above states
|
|
46
|
+
// rather than a new one: authority "does not become live because a package asked for it". A registry id is
|
|
47
|
+
// a *choice of where a child runs*, which is the operator's to make and cannot be inferred from a
|
|
48
|
+
// declaration — ADR-0028's whole position. Enumerated ids are unbounded, so this is a namespace test and
|
|
49
|
+
// not a list membership.
|
|
50
|
+
if (capability.startsWith("workspace:")) return false;
|
|
45
51
|
return !WITHHELD_BY_DEFAULT.includes(capability);
|
|
46
52
|
}
|
|
47
53
|
|
|
@@ -87,6 +93,8 @@ export interface GrantEnvInput {
|
|
|
87
93
|
withheld: Map<Capability, string[]>;
|
|
88
94
|
/** Definitions whose `agent:` id is withheld because they need a withheld capability. */
|
|
89
95
|
withheldDefinitions: string[];
|
|
96
|
+
/** `workspace:<id>` ids this project could route to (ADR-0035). Rendered commented, never granted. */
|
|
97
|
+
routableWorkspaces?: Capability[];
|
|
90
98
|
/** `agent:<name>` ids a ceiling names that `init` did not write here. Reported, never granted. */
|
|
91
99
|
crossReferences: { from: string; capability: Capability }[];
|
|
92
100
|
cautions: string[];
|
|
@@ -140,13 +148,37 @@ export function renderGrantEnv(input: GrantEnvInput): string {
|
|
|
140
148
|
for (const [capability, needed] of [...input.withheld].sort()) {
|
|
141
149
|
lines.push(`# ${capability.padEnd(width)} (${needed.join(", ")})`);
|
|
142
150
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
151
|
+
lines.push("#");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// OUTSIDE the block above, and that is the fix. This is the file's only statement that a withheld
|
|
155
|
+
// definition's `agent:` id must be granted too — and it used to render only when `withheld` was non-empty,
|
|
156
|
+
// so a package whose sole withheld capability is a ROUTING id (the common read-only routing case) lost it
|
|
157
|
+
// entirely once `workspace:` ids stopped going into that map. The reason line above still said "see below"
|
|
158
|
+
// and pointed at nothing.
|
|
159
|
+
if (input.withheldDefinitions.length > 0) {
|
|
160
|
+
lines.push(
|
|
161
|
+
`# …and then: ${input.withheldDefinitions.map((n) => `agent:${n}`).join(",")}`,
|
|
162
|
+
"# Their `agent:` ids are withheld too: a definition that cannot receive what it declares would",
|
|
163
|
+
"# be authorised to run and then refused, which is a worse answer than not being authorised.",
|
|
164
|
+
"#",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ADR-0035. Routing became a capability in 0.19.0, which made every existing grant that routes start
|
|
169
|
+
// refusing — so the migration has to be visible from the file the operator already opens. Listed and NOT
|
|
170
|
+
// granted: which worktree a child starts in is the operator's decision and cannot be read off a
|
|
171
|
+
// declaration (ADR-0028), and granting one because a package named it is the "does not become live because
|
|
172
|
+
// a package asked for it" rule that `WITHHELD_BY_DEFAULT` above states.
|
|
173
|
+
if (input.routableWorkspaces && input.routableWorkspaces.length > 0) {
|
|
174
|
+
lines.push(
|
|
175
|
+
"# ROUTABLE WORKSPACES — routing a child to a registered worktree needs the id in PI_GRANTS_GRANT",
|
|
176
|
+
"# (ADR-0035, 0.19.0). Without it a delegation naming one is refused WORKSPACE_NOT_AUTHORIZED. Add the",
|
|
177
|
+
"# ones this project's children may start in; a child can only pass on ids it holds itself, so this is",
|
|
178
|
+
"# also the list of what any DESCENDANT could reach. Not granted for you: `workspace:*` exists but is",
|
|
179
|
+
"# held and never inherited, which makes it the wrong answer for anything but a single-worktree setup.",
|
|
180
|
+
);
|
|
181
|
+
for (const capability of input.routableWorkspaces) lines.push(`# ${capability}`);
|
|
150
182
|
lines.push("#");
|
|
151
183
|
}
|
|
152
184
|
|
package/src/index.ts
CHANGED
|
@@ -11,9 +11,14 @@ export {
|
|
|
11
11
|
export {
|
|
12
12
|
appendLedgerEvent,
|
|
13
13
|
appendRecord,
|
|
14
|
+
buildCheckReceiptLedgerEvent,
|
|
14
15
|
buildChildLifecycleEvent,
|
|
15
16
|
buildRecord,
|
|
16
17
|
buildWorkspaceLeaseEvent,
|
|
18
|
+
buildWorkflowFactEvent,
|
|
19
|
+
WORKFLOW_FACT_KINDS,
|
|
20
|
+
WORKFLOW_FACT_PROVENANCE,
|
|
21
|
+
WORKFLOW_FACT_STATES,
|
|
17
22
|
isEscalationAttempt,
|
|
18
23
|
LEDGER_VERSION,
|
|
19
24
|
type CheckReceiptLedgerEvent,
|
|
@@ -23,6 +28,10 @@ export {
|
|
|
23
28
|
type RuntimeLedgerEvent,
|
|
24
29
|
type WorkspaceLeaseEvent,
|
|
25
30
|
type WorkspaceLeaseOutcome,
|
|
31
|
+
type WorkflowFactEvent,
|
|
32
|
+
type WorkflowFactKind,
|
|
33
|
+
type WorkflowFactProvenance,
|
|
34
|
+
type WorkflowFactState,
|
|
26
35
|
} from "./ledger.ts";
|
|
27
36
|
|
|
28
37
|
export { planSpawn, type SpawnPlan, type SpawnPlanInput } from "./spawn.ts";
|
|
@@ -96,6 +105,23 @@ export {
|
|
|
96
105
|
type CheckRegistry,
|
|
97
106
|
} from "./check-runner.ts";
|
|
98
107
|
|
|
108
|
+
export {
|
|
109
|
+
isExecutionId,
|
|
110
|
+
newExecutionId,
|
|
111
|
+
type ExecutionId,
|
|
112
|
+
} from "./execution-id.ts";
|
|
113
|
+
|
|
114
|
+
export {
|
|
115
|
+
parseDashboardLedger,
|
|
116
|
+
type DashboardNode,
|
|
117
|
+
type DashboardProjection,
|
|
118
|
+
type DashboardState,
|
|
119
|
+
type DashboardWorkflow,
|
|
120
|
+
type DashboardWorkflowFact,
|
|
121
|
+
} from "./dashboard-projection.ts";
|
|
122
|
+
|
|
123
|
+
export { renderDashboard, type DashboardRenderOptions } from "./dashboard-render.ts";
|
|
124
|
+
|
|
99
125
|
export {
|
|
100
126
|
createApprovalGate,
|
|
101
127
|
createApprovalGateProvider,
|
package/src/init.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { mkdir, open, rm, writeFile } from "node:fs/promises";
|
|
26
26
|
import { join } from "node:path";
|
|
27
|
-
import { agentCapability } from "./capabilities.ts";
|
|
27
|
+
import { agentCapability, workspaceCapability } from "./capabilities.ts";
|
|
28
28
|
import { ceilingForDefinition } from "./definitions.ts";
|
|
29
29
|
import { ALWAYS_LIVE, assertGrantIsWritable, isLiveByDefault, renderGrantEnv, type GrantEnvSkill } from "./grant-env.ts";
|
|
30
30
|
import { PI_BUILTIN_TOOLS } from "./pi-tools.ts";
|
|
@@ -78,6 +78,8 @@ export interface InitPlan {
|
|
|
78
78
|
grant: Capability[];
|
|
79
79
|
/** Withheld capability → the definitions that declared it (ADR-0029). Emitted commented. */
|
|
80
80
|
withheldCapabilities: Map<Capability, string[]>;
|
|
81
|
+
/** `workspace:<id>` this project could route to (ADR-0035). Always commented — `init` does not choose. */
|
|
82
|
+
routableWorkspaces: Capability[];
|
|
81
83
|
grantEnvPath: string;
|
|
82
84
|
grantEnvContent: string;
|
|
83
85
|
/** Capabilities a declared ceiling names that pi 0.84.1 has no tool for — a caution, not a verdict. */
|
|
@@ -143,7 +145,20 @@ function unknownToolIds(capabilities: Capability[]): Capability[] {
|
|
|
143
145
|
* tools at all and the whole file is inert. Everything else is emitted commented, named, and one uncomment
|
|
144
146
|
* away (ADR-0029).
|
|
145
147
|
*/
|
|
146
|
-
export function planInit(
|
|
148
|
+
export function planInit(
|
|
149
|
+
packages: SkillPackage[],
|
|
150
|
+
cwd: string,
|
|
151
|
+
/**
|
|
152
|
+
* Ids from the operator's workspace registry, when one is configured — read by the CALLER, because
|
|
153
|
+
* `planInit` is pure and stays that way.
|
|
154
|
+
*
|
|
155
|
+
* ADR-0035 made routing a capability and said `init` "scaffolds the registered ids so the common path is a
|
|
156
|
+
* one-line grant edit". It did not: `init` had never heard of the registry, so the ADR's own stated
|
|
157
|
+
* migration path for a breaking change did not exist. These are emitted **commented**, never live —
|
|
158
|
+
* offering the ids while refusing to choose among them is exactly ADR-0028's position.
|
|
159
|
+
*/
|
|
160
|
+
registeredWorkspaceIds: readonly string[] = [],
|
|
161
|
+
): InitPlan {
|
|
147
162
|
const skills: PlannedSkill[] = [];
|
|
148
163
|
const collisions: string[] = [];
|
|
149
164
|
const seen = new Set<string>();
|
|
@@ -190,6 +205,20 @@ export function planInit(packages: SkillPackage[], cwd: string): InitPlan {
|
|
|
190
205
|
for (const skill of declared) {
|
|
191
206
|
for (const capability of skill.ceiling) {
|
|
192
207
|
if (isLiveByDefault(capability)) continue;
|
|
208
|
+
// A routing destination is withheld but does NOT belong in this map, and the difference is not
|
|
209
|
+
// cosmetic. This map drives two things: the "WITHHELD BY DEFAULT — these can change your machine"
|
|
210
|
+
// block, and `/grants init`'s dialog. Review found a `workspace:` id reaching both — described to the
|
|
211
|
+
// operator with `tool:bash`'s rationale (routing does not change your machine, and unlike `bash` it
|
|
212
|
+
// *is* gateable), and then granted **live and persisted** on one "Yes", off a third-party package's
|
|
213
|
+
// declaration. That is the rule `grant-env.ts` states — "does not become live because a package asked
|
|
214
|
+
// for it" — honoured by the rendered file and broken by the dialog beside it: two surfaces of one
|
|
215
|
+
// command disagreeing, which is R-28's shape inside the fix for R-28.
|
|
216
|
+
//
|
|
217
|
+
// Which worktree a child starts in is not derivable from a declaration (ADR-0028), so `init` lists
|
|
218
|
+
// routing and never grants it. `routableWorkspaces` below is where these go; the definition that
|
|
219
|
+
// declared one still loses its live `agent:` id via the `needs-withheld` pass, so nothing becomes
|
|
220
|
+
// spawnable behind the operator's back either.
|
|
221
|
+
if (capability.startsWith("workspace:")) continue;
|
|
193
222
|
withheldCapabilities.set(capability, [...(withheldCapabilities.get(capability) ?? []), skill.name]);
|
|
194
223
|
}
|
|
195
224
|
}
|
|
@@ -246,11 +275,19 @@ export function planInit(packages: SkillPackage[], cwd: string): InitPlan {
|
|
|
246
275
|
...(s.withheld ? { unspawnable: describe[s.withheld](s) } : {}),
|
|
247
276
|
}));
|
|
248
277
|
|
|
278
|
+
// Registry ids the operator could route to, plus any a copied definition actually declares — a package
|
|
279
|
+
// naming `workspace:prod` is evidence that id matters here, and it must still be uncommented by hand.
|
|
280
|
+
const declaredWorkspaces = skills.flatMap((s) => s.ceiling.filter((c) => c.startsWith("workspace:")));
|
|
281
|
+
const routableWorkspaces = [
|
|
282
|
+
...new Set([...registeredWorkspaceIds.map(workspaceCapability), ...declaredWorkspaces]),
|
|
283
|
+
].sort();
|
|
284
|
+
|
|
249
285
|
return {
|
|
250
286
|
skills,
|
|
251
287
|
collisions,
|
|
252
288
|
grant,
|
|
253
289
|
withheldCapabilities,
|
|
290
|
+
routableWorkspaces,
|
|
254
291
|
grantEnvPath: join(cwd, ".pi", "grants.env"),
|
|
255
292
|
grantEnvContent: renderGrantEnv({
|
|
256
293
|
skills: grantEnvSkills,
|
|
@@ -259,6 +296,7 @@ export function planInit(packages: SkillPackage[], cwd: string): InitPlan {
|
|
|
259
296
|
withheldDefinitions: skills.filter((s) => s.withheld === "needs-withheld").map((s) => s.name),
|
|
260
297
|
crossReferences,
|
|
261
298
|
cautions,
|
|
299
|
+
routableWorkspaces,
|
|
262
300
|
}),
|
|
263
301
|
cautions,
|
|
264
302
|
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The lock helper: a separate program, and the parent's side of its pipes.**
|
|
3
|
+
*
|
|
4
|
+
* Split out of `workspace-lease.ts` because `test/file-size.test.ts` refused that file — at 405 lines on the
|
|
5
|
+
* ADR-0035 branch when PR #14 merged into it, and at 435 here once R-152's guards landed. Both branches take
|
|
6
|
+
* the same seam so they converge rather than diverge, and the cap has never been raised: `delegate.ts` was
|
|
7
|
+
* split at 413 the same way (rule: when a guard fails, obey it).
|
|
8
|
+
*
|
|
9
|
+
* The seam is not arbitrary. Everything here concerns the process that HOLDS the kernel lock — the source it
|
|
10
|
+
* runs, the readiness token it prints, and the parent-side handles that keep it referenced.
|
|
11
|
+
* `workspace-lease.ts` keeps the lease lifecycle that talks to it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const LEASE_READY = "PI_DADDY_LEASE_READY";
|
|
15
|
+
// The lock holder also owns crash cleanup for the governed child. If the parent dies, stdin closes;
|
|
16
|
+
// the helper signals the attached process, or closes the herdr tab, before releasing flock. A raw
|
|
17
|
+
// descendant deliberately detached by bash remains ADR-0012's OS-containment boundary, not a lease
|
|
18
|
+
// guarantee. The process branch SIGTERMs, escalates to SIGKILL at +500ms, and releases at +750ms
|
|
19
|
+
// WITHOUT confirming death (R-101). The herdr branch retries `tab close` a BOUNDED number of times
|
|
20
|
+
// and then releases anyway, leaving a marker file: an unreleasable lock strands a worktree forever
|
|
21
|
+
// with no in-product recovery, which is strictly worse than a recorded failure to close (R-102).
|
|
22
|
+
//
|
|
23
|
+
// **Each attempt is bounded in WALL CLOCK, not just in count (R-146).** `execFile` with no `timeout`
|
|
24
|
+
// never calls back if `herdr` accepts the close and does not answer, so the retry counter never
|
|
25
|
+
// decrements, `giveUp` never runs and no marker is written — the lock is held forever. That was masked
|
|
26
|
+
// while the parent could not exit, because the operator saw a hung `pi` instead; measured with a `herdr`
|
|
27
|
+
// that sleeps, the parent then exited in 82ms and left a silent strand, which is R-102's rejected outcome
|
|
28
|
+
// reached quietly. A bound on retries is not a bound on time.
|
|
29
|
+
export const HELPER_SOURCE = `
|
|
30
|
+
import { execFile } from "node:child_process";
|
|
31
|
+
import { writeFileSync } from "node:fs";
|
|
32
|
+
let clean=false, target=null, buffered="";
|
|
33
|
+
process.stdout.write(${JSON.stringify(`${LEASE_READY}:`)}+process.pid+"\\n");
|
|
34
|
+
process.stdin.setEncoding("utf8");
|
|
35
|
+
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{}}});
|
|
36
|
+
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=last=>{try{if(process.env.PI_DADDY_LEASE_MARKER)writeFileSync(process.env.PI_DADDY_LEASE_MARKER,JSON.stringify({reason:last&&(last.killed||last.signal==="SIGKILL")?"herdr-close-timeout":"herdr-close-failed",herdr_tab:target.herdr_tab})+"\\n")}catch{}process.exit(0)};const close=()=>execFile("herdr",["tab","close",target.herdr_tab],{timeout:Number(process.env.PI_DADDY_LEASE_CLOSE_TIMEOUT_MS||15000),killSignal:"SIGKILL"},error=>{if(!error)return process.exit(0);if(--left<=0)return giveUp(error);setTimeout(close,1000)});close();});
|
|
37
|
+
process.stdin.resume();`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `unref` the parent's end of one of the helper's pipes.
|
|
41
|
+
*
|
|
42
|
+
* A spawned pipe is a `net.Socket`, which has `unref`; `ChildProcess.stdin` is typed `Writable`, which does
|
|
43
|
+
* not. Hence the narrow cast. The optional call is **defensive, not load-bearing** — the holder is spawned at
|
|
44
|
+
* exactly one site with three pipes, so none of these is ever `null`, and an earlier version of this comment
|
|
45
|
+
* claimed otherwise by citing a `stdio: "ignore"` caller that does not exist.
|
|
46
|
+
*
|
|
47
|
+
* Only `stdout` and `stderr` are unref'ed. `stdin` was too, until a line-by-line reversion showed the suite
|
|
48
|
+
* stayed green without it — an unforced line inside the fix that exists to be forced, which is exactly the
|
|
49
|
+
* shape R-122 was about.
|
|
50
|
+
*/
|
|
51
|
+
export const unrefStream = (stream: unknown): void => {
|
|
52
|
+
(stream as { unref?: () => void } | null)?.unref?.();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The bounds on the helper's `herdr tab close` attempts live here, with the attempts they bound — moved out of
|
|
57
|
+
* `workspace-lease.ts` when the line ceiling refused it at 402 lines, once both this branch's ADR-0035 work and
|
|
58
|
+
* `main`'s R-152 guards had landed in it. Third time that guard has fired on this file and third time the
|
|
59
|
+
* answer was a seam rather than a bigger cap.
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* **A bound that is not a bound throws, and it throws a `RangeError` (R-146, R-152).**
|
|
63
|
+
*
|
|
64
|
+
* Both ends fail, in opposite directions and both silently:
|
|
65
|
+
*
|
|
66
|
+
* - `0` reads as "no limit" and Node agrees — `execFile` treats `timeout: 0` as *no* timeout (measured: the
|
|
67
|
+
* callback for a 3s sleep arrives at 3004ms with no error), reinstating the unbounded hang the bound exists
|
|
68
|
+
* to prevent. Negatives behave identically.
|
|
69
|
+
* - anything above `2^31 - 1` truncates: `setTimeout` warns `TimeoutOverflowWarning … set to 1`, so
|
|
70
|
+
* `Number.MAX_SAFE_INTEGER` — the plausible "effectively no limit" sentinel, given the argument about `0`
|
|
71
|
+
* — SIGKILLs every `herdr tab close` after 1ms, before herdr can act. Measured: callback at 3ms.
|
|
72
|
+
*
|
|
73
|
+
* **Not a `GovernanceRefusal`.** It was one, carrying `WORKSPACE_LEASE_STALE`, which everywhere else in this
|
|
74
|
+
* package means *the lease went stale or was lost* — so an ADR-0034 controller switching on codes (the reason
|
|
75
|
+
* codes exist, R-103) would classify a permanent caller bug as transient and retry a call that can never
|
|
76
|
+
* succeed. A refusal is a governance outcome that gets ledgered; a bad argument is neither.
|
|
77
|
+
*
|
|
78
|
+
* Checked for read leases too, which the first version did not: it sat below the read-lease early return, so a
|
|
79
|
+
* controller smoke-testing its configuration against a read lease got a false all-clear.
|
|
80
|
+
*/
|
|
81
|
+
export const assertCloseBounds = (input: { herdrCloseTimeoutMs?: number; herdrCloseAttempts?: number }): void => {
|
|
82
|
+
const MAX_TIMER = 2_147_483_647;
|
|
83
|
+
for (const [name, value, ceiling] of [
|
|
84
|
+
["herdrCloseTimeoutMs", input.herdrCloseTimeoutMs, MAX_TIMER],
|
|
85
|
+
["herdrCloseAttempts", input.herdrCloseAttempts, Number.MAX_SAFE_INTEGER],
|
|
86
|
+
] as const) {
|
|
87
|
+
if (value === undefined) continue;
|
|
88
|
+
if (!Number.isInteger(value) || value < 1 || value > ceiling) {
|
|
89
|
+
throw new RangeError(
|
|
90
|
+
`${name} must be a whole number between 1 and ${ceiling}, not ${String(value)}. Zero and negatives ` +
|
|
91
|
+
`are not "no limit" but no bound at all, a value past ${MAX_TIMER} truncates to 1ms, and a ` +
|
|
92
|
+
`fractional count is not a count — this bound exists so a hung herdr cannot hold the writer lock ` +
|
|
93
|
+
`forever (R-146).`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
package/src/lease-record.ts
CHANGED
|
@@ -53,13 +53,18 @@ export interface WorkspaceLease {
|
|
|
53
53
|
* Needed because a retained lease never calls `release()`, so the metadata stays `state: "active"` and
|
|
54
54
|
* whoever acquires next reports `recovered: true` — blaming a crash on a known-good path.
|
|
55
55
|
*/
|
|
56
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Record that the lease is being KEPT rather than handed back, and answer what actually happened — the
|
|
58
|
+
* caller ledgers this word (R-152). It is not always `retained`: a helper that has already died makes the
|
|
59
|
+
* fact `lost`, and a lease already settled by `release()` keeps the outcome it had.
|
|
60
|
+
*/
|
|
61
|
+
markRetained(reason?: string): Promise<LeaseReleaseOutcome>;
|
|
57
62
|
/** Reads the give-up marker the helper leaves when it could not close a herdr writer tab. */
|
|
58
63
|
readCloseFailure(): Promise<{ reason: string; herdr_tab?: string } | null>;
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
/**
|
|
62
|
-
* What a release actually did.
|
|
67
|
+
* What a release actually did. Six members rather than three, because the first version conflated facts
|
|
63
68
|
* that call for different responses — and one of them is an alarm:
|
|
64
69
|
* `released` the lock went back and THIS owner wrote its own handover;
|
|
65
70
|
* `released-unrecorded` the lock went back and the record does not say so, so the next owner will report
|
|
@@ -77,7 +82,14 @@ export type LeaseReleaseOutcome =
|
|
|
77
82
|
| "released-unrecorded"
|
|
78
83
|
| "released-superseded"
|
|
79
84
|
| "not-held"
|
|
80
|
-
| "lost"
|
|
85
|
+
| "lost"
|
|
86
|
+
/**
|
|
87
|
+
* The lease was RETAINED and is therefore already settled — `release()` after `markRetained()` answers
|
|
88
|
+
* this instead of running the clean handshake (R-146). It was previously expressible only as the
|
|
89
|
+
* `| "retained"` bolted onto two signatures, which is why `release()` could not say it and claimed
|
|
90
|
+
* `released` instead: a clean handover for a lease kept precisely because a pane would not close.
|
|
91
|
+
*/
|
|
92
|
+
| "retained";
|
|
81
93
|
|
|
82
94
|
export function leasePaths(leaseDir: string, root: string) {
|
|
83
95
|
// Canonical root, never caller-chosen workspace ID: aliases for one worktree must contend.
|