@deftai/directive-core 0.106.0 → 0.108.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/dist/architecture/sor-preflight.js +2 -0
- package/dist/check/cached-orchestrator.js +28 -3
- package/dist/check/gate-lists.js +2 -0
- package/dist/check/named-cause.js +18 -3
- package/dist/codebase/provider.js +7 -1
- package/dist/delivery-attempt/handoff.js +1 -1
- package/dist/design-critique/citation-grammar.d.ts +53 -0
- package/dist/design-critique/citation-grammar.js +303 -0
- package/dist/design-critique/completed-arc-record.d.ts +62 -0
- package/dist/design-critique/completed-arc-record.js +232 -0
- package/dist/design-critique/exclusive-chip.d.ts +33 -0
- package/dist/design-critique/exclusive-chip.js +68 -0
- package/dist/design-critique/parent-audit.d.ts +59 -0
- package/dist/design-critique/parent-audit.js +121 -0
- package/dist/doctor/checks.d.ts +6 -0
- package/dist/doctor/checks.js +35 -0
- package/dist/freshness/bind.d.ts +32 -3
- package/dist/freshness/bind.js +58 -12
- package/dist/hooks/classify/host-session-identity.d.ts +76 -0
- package/dist/hooks/classify/host-session-identity.js +470 -0
- package/dist/hooks/classify/index.d.ts +3 -2
- package/dist/hooks/classify/index.js +3 -2
- package/dist/hooks/classify/paths.d.ts +7 -0
- package/dist/hooks/classify/paths.js +35 -0
- package/dist/hooks/classify/stdin.d.ts +5 -0
- package/dist/hooks/classify/stdin.js +27 -0
- package/dist/hooks/dest-form.d.ts +46 -0
- package/dist/hooks/dest-form.js +575 -0
- package/dist/hooks/dispatcher.d.ts +69 -2
- package/dist/hooks/dispatcher.js +609 -45
- package/dist/hooks/fixtures/cases.d.ts +16 -2
- package/dist/hooks/fixtures/cases.js +175 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/scope.js +3 -1
- package/dist/init-deposit/agent-hooks.d.ts +2 -1
- package/dist/init-deposit/agent-hooks.js +8 -1
- package/dist/init-deposit/hook-runtime-travel.d.ts +77 -0
- package/dist/init-deposit/hook-runtime-travel.js +269 -0
- package/dist/intake/clause-derivation.js +9 -3
- package/dist/intake/github-auth-modes-cli.js +14 -2
- package/dist/intake/github-auth-modes.d.ts +54 -9
- package/dist/intake/github-auth-modes.js +326 -123
- package/dist/intake/issue-ingest.js +62 -2
- package/dist/intake/platform-capabilities.d.ts +9 -2
- package/dist/intake/platform-capabilities.js +38 -9
- package/dist/lifecycle/completed-tracked-on-delivery.d.ts +26 -0
- package/dist/lifecycle/completed-tracked-on-delivery.js +37 -14
- package/dist/lifecycle/completed-write-guard.d.ts +48 -0
- package/dist/lifecycle/completed-write-guard.js +373 -0
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.js +1 -0
- package/dist/literal-acceptance/capture.d.ts +16 -0
- package/dist/literal-acceptance/capture.js +75 -7
- package/dist/literal-acceptance/evaluate.js +25 -3
- package/dist/literal-acceptance/index.d.ts +2 -2
- package/dist/literal-acceptance/index.js +2 -2
- package/dist/literal-acceptance/run.js +9 -3
- package/dist/literal-acceptance/types.d.ts +6 -0
- package/dist/literal-acceptance/types.js +6 -0
- package/dist/orphan-active/evaluate.d.ts +25 -0
- package/dist/orphan-active/evaluate.js +193 -128
- package/dist/orphan-active/index.d.ts +1 -0
- package/dist/orphan-active/index.js +1 -0
- package/dist/orphan-active/issue-state.d.ts +121 -0
- package/dist/orphan-active/issue-state.js +278 -0
- package/dist/platform/cursor-managed-runtime.d.ts +95 -0
- package/dist/platform/cursor-managed-runtime.js +241 -0
- package/dist/platform/index.d.ts +1 -0
- package/dist/platform/index.js +1 -0
- package/dist/platform/platform-capabilities.d.ts +23 -1
- package/dist/platform/platform-capabilities.js +55 -15
- package/dist/policy/ceremony-dial.js +5 -9
- package/dist/policy/host-hooks.js +4 -9
- package/dist/policy/org-force-on-migration.js +5 -10
- package/dist/policy/plan-extensions.d.ts +14 -2
- package/dist/policy/plan-extensions.js +24 -3
- package/dist/policy/product-signal.js +5 -10
- package/dist/policy/require-human-merge.js +5 -9
- package/dist/policy/resolve.js +77 -16
- package/dist/policy/runtime-authority.d.ts +18 -0
- package/dist/policy/runtime-authority.js +11 -0
- package/dist/policy/value-feedback.js +9 -18
- package/dist/policy/write-fence.js +3 -0
- package/dist/pr-closeout-attestable/evaluate.d.ts +80 -0
- package/dist/pr-closeout-attestable/evaluate.js +306 -0
- package/dist/pr-closeout-attestable/index.d.ts +2 -0
- package/dist/pr-closeout-attestable/index.js +2 -0
- package/dist/pr-protected-issues/gh.d.ts +6 -2
- package/dist/pr-protected-issues/gh.js +7 -3
- package/dist/pr-wait-mergeable/cascade.d.ts +9 -1
- package/dist/pr-wait-mergeable/cascade.js +21 -1
- package/dist/pr-wait-mergeable/types.d.ts +2 -0
- package/dist/pr-wait-mergeable/wrappers.d.ts +6 -0
- package/dist/pr-wait-mergeable/wrappers.js +11 -0
- package/dist/preflight/evaluate.d.ts +15 -0
- package/dist/preflight/evaluate.js +29 -3
- package/dist/preflight-cache/evaluate.js +9 -3
- package/dist/product-first-done-gate/acceptance-resolver.d.ts +14 -2
- package/dist/product-first-done-gate/acceptance-resolver.js +13 -2
- package/dist/product-first-done-gate/evaluate.js +53 -12
- package/dist/release/cli-drift-report.d.ts +68 -0
- package/dist/release/cli-drift-report.js +189 -0
- package/dist/release/index.d.ts +1 -0
- package/dist/release/index.js +1 -0
- package/dist/release/issue-state-fetch.d.ts +5 -3
- package/dist/release/issue-state-fetch.js +86 -21
- package/dist/release/native-steps.js +1 -0
- package/dist/release/pipeline.js +19 -0
- package/dist/release/types.d.ts +14 -0
- package/dist/render/constants.d.ts +4 -0
- package/dist/render/constants.js +11 -8
- package/dist/render/export-spec.js +31 -5
- package/dist/render/index.d.ts +1 -1
- package/dist/render/index.js +1 -1
- package/dist/render/prd-render.d.ts +4 -1
- package/dist/render/prd-render.js +82 -23
- package/dist/render/project-render.js +14 -7
- package/dist/render/scope-outlook.d.ts +2 -0
- package/dist/render/scope-outlook.js +3 -0
- package/dist/render/spec-render.js +2 -2
- package/dist/resolution/package-manager.d.ts +33 -4
- package/dist/resolution/package-manager.js +210 -9
- package/dist/review-monitor/constants.d.ts +7 -0
- package/dist/review-monitor/constants.js +7 -0
- package/dist/review-monitor/github-lease.d.ts +98 -1
- package/dist/review-monitor/github-lease.js +182 -1
- package/dist/review-monitor/lease-comment.d.ts +36 -0
- package/dist/review-monitor/lease-comment.js +103 -2
- package/dist/scm/call.js +12 -3
- package/dist/scm/design-critique-chip.d.ts +38 -0
- package/dist/scm/design-critique-chip.js +187 -0
- package/dist/scm/gh-rest.d.ts +9 -1
- package/dist/scm/gh-rest.js +103 -0
- package/dist/scm/index.d.ts +1 -0
- package/dist/scm/index.js +1 -0
- package/dist/scm/main.d.ts +3 -0
- package/dist/scm/main.js +15 -1
- package/dist/scm/readiness-cli.d.ts +2 -0
- package/dist/scm/readiness-cli.js +42 -1
- package/dist/scm/readiness.d.ts +4 -1
- package/dist/scm/readiness.js +35 -5
- package/dist/scope/decompose.js +2 -1
- package/dist/scope/delivery-evidence.d.ts +27 -1
- package/dist/scope/index.d.ts +1 -0
- package/dist/scope/index.js +1 -0
- package/dist/scope/lifecycle-write.d.ts +24 -0
- package/dist/scope/lifecycle-write.js +64 -0
- package/dist/scope/main.js +3 -1
- package/dist/scope/project-definition-sync.js +14 -7
- package/dist/scope/transition.js +90 -11
- package/dist/scope-provenance/evaluate.js +5 -1
- package/dist/session/git.d.ts +42 -0
- package/dist/session/git.js +219 -3
- package/dist/session/occupancy.d.ts +239 -12
- package/dist/session/occupancy.js +820 -27
- package/dist/session/ritual-sentinel.d.ts +5 -0
- package/dist/session/ritual-sentinel.js +5 -0
- package/dist/session/session-ready.d.ts +5 -1
- package/dist/session/session-ready.js +98 -8
- package/dist/session/session-start.d.ts +17 -1
- package/dist/session/session-start.js +49 -17
- package/dist/session/toolchain-preflight.d.ts +13 -3
- package/dist/session/toolchain-preflight.js +93 -18
- package/dist/session/verify-session-ritual.d.ts +33 -3
- package/dist/session/verify-session-ritual.js +89 -19
- package/dist/slice/constants.d.ts +1 -1
- package/dist/spec-authority/constants.d.ts +10 -2
- package/dist/spec-authority/constants.js +53 -8
- package/dist/spec-authority/resolver.d.ts +3 -0
- package/dist/spec-authority/resolver.js +55 -7
- package/dist/subprocess/max-buffer.d.ts +14 -0
- package/dist/subprocess/max-buffer.js +15 -0
- package/dist/swarm/complete-cohort.d.ts +2 -0
- package/dist/swarm/complete-cohort.js +21 -13
- package/dist/swarm/index.d.ts +1 -0
- package/dist/swarm/index.js +1 -0
- package/dist/swarm/launch-cli.js +53 -33
- package/dist/swarm/launch.d.ts +77 -0
- package/dist/swarm/launch.js +248 -38
- package/dist/swarm/pre-dispatch-cli.js +2 -1
- package/dist/swarm/pre-dispatch.js +10 -1
- package/dist/swarm/subagent-status-dir.d.ts +27 -0
- package/dist/swarm/subagent-status-dir.js +42 -0
- package/dist/swarm/worktrees.d.ts +17 -3
- package/dist/swarm/worktrees.js +48 -4
- package/dist/triage/actions/index.d.ts +4 -0
- package/dist/triage/actions/index.js +6 -0
- package/dist/triage/evaluate/evaluate.d.ts +11 -0
- package/dist/triage/evaluate/evaluate.js +169 -0
- package/dist/triage/evaluate/github.d.ts +16 -0
- package/dist/triage/evaluate/github.js +153 -0
- package/dist/triage/evaluate/index.d.ts +9 -0
- package/dist/triage/evaluate/index.js +8 -0
- package/dist/triage/evaluate/paths.d.ts +7 -0
- package/dist/triage/evaluate/paths.js +25 -0
- package/dist/triage/evaluate/sink.d.ts +5 -0
- package/dist/triage/evaluate/sink.js +52 -0
- package/dist/triage/evaluate/types.d.ts +93 -0
- package/dist/triage/evaluate/types.js +13 -0
- package/dist/triage/evaluate/validity.d.ts +8 -0
- package/dist/triage/evaluate/validity.js +87 -0
- package/dist/triage/evaluate/value.d.ts +12 -0
- package/dist/triage/evaluate/value.js +33 -0
- package/dist/triage/evaluate/wip-census.d.ts +5 -0
- package/dist/triage/evaluate/wip-census.js +39 -0
- package/dist/triage/evaluate/worktrees.d.ts +8 -0
- package/dist/triage/evaluate/worktrees.js +68 -0
- package/dist/triage/evaluate/xbrief-refs.d.ts +8 -0
- package/dist/triage/evaluate/xbrief-refs.js +60 -0
- package/dist/triage/help/registry-data.d.ts +21 -7
- package/dist/triage/help/registry-data.js +53 -5
- package/dist/triage/index.d.ts +1 -0
- package/dist/triage/index.js +1 -0
- package/dist/triage/scope/mutations-core.d.ts +2 -6
- package/dist/triage/scope/mutations-core.js +23 -55
- package/dist/triage/scope-drift/add-ignore.js +5 -39
- package/dist/triage/subscribe/index.d.ts +2 -6
- package/dist/triage/subscribe/index.js +15 -65
- package/dist/triage/welcome/writers.js +15 -74
- package/dist/value/feedback-file.d.ts +17 -2
- package/dist/value/feedback-file.js +80 -6
- package/dist/vbrief-build/index.d.ts +2 -1
- package/dist/vbrief-build/index.js +2 -1
- package/dist/vbrief-build/parity-scenarios.js +5 -5
- package/dist/vbrief-build/project-definition-io.d.ts +63 -3
- package/dist/vbrief-build/project-definition-io.js +357 -31
- package/dist/vbrief-build/project-definition-mutation.d.ts +32 -0
- package/dist/vbrief-build/project-definition-mutation.js +43 -0
- package/dist/vbrief-reconcile/labels.d.ts +1 -0
- package/dist/vbrief-reconcile/labels.js +30 -0
- package/dist/vbrief-validate/conformance.d.ts +1 -0
- package/dist/vbrief-validate/conformance.js +85 -8
- package/dist/vbrief-validate/main.js +2 -0
- package/dist/vbrief-validate/precutover.js +5 -10
- package/dist/vbrief-validation/story-quality.d.ts +7 -0
- package/dist/vbrief-validation/story-quality.js +8 -1
- package/dist/verify-ac/clauses.d.ts +62 -2
- package/dist/verify-ac/clauses.js +183 -111
- package/dist/verify-ac/index.d.ts +1 -1
- package/dist/verify-ac/index.js +1 -1
- package/dist/verify-env/node-runtime.d.ts +8 -4
- package/dist/verify-env/node-runtime.js +9 -6
- package/dist/verify-env/toolchain-check.d.ts +35 -3
- package/dist/verify-env/toolchain-check.js +155 -36
- package/dist/verify-source/project-definition-mutation-boundary.d.ts +104 -0
- package/dist/verify-source/project-definition-mutation-boundary.js +326 -0
- package/dist/xbrief-migrate/agents-header.js +69 -7
- package/package.json +7 -3
|
@@ -3,14 +3,35 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Ritual-state is "this session completed ceremony." Occupancy is "who may
|
|
5
5
|
* mutate this tree right now." Those lifetimes differ; do not overload
|
|
6
|
-
* ritual-state.json.
|
|
6
|
+
* ritual-state.json. Ordinary end is occupancy:release / session:end (#3604).
|
|
7
|
+
*
|
|
8
|
+
* What this boundary is (#3755): a cooperative bearer-id boundary, not a
|
|
9
|
+
* lineage. The lease admits whoever presents an id the record itself names —
|
|
10
|
+
* the occupant's id, or a child id the occupant granted — so possession of a
|
|
11
|
+
* string is the whole credential. Nothing here observes parentage, so a
|
|
12
|
+
* dispatched child is admitted because a grant records it, never because it
|
|
13
|
+
* inherited the holder's rights. Membership is explicit, attributable and
|
|
14
|
+
* expiring (`grantOccupancyMembership`), and it admits writes only: release,
|
|
15
|
+
* steal, heartbeat and cohort close-out stay owner-only, so a grant cannot be
|
|
16
|
+
* spent on the lease itself. Child-initiated join queuing stays out of scope —
|
|
17
|
+
* the owner issues membership; the child does not request it. Ritual state is
|
|
18
|
+
* still single-owner, so a member writes under the occupant's ceremony: the
|
|
19
|
+
* composite hook write gate measures the tree's verified ritual owner against
|
|
20
|
+
* the occupant that issued the grant, not against the writer.
|
|
7
21
|
*
|
|
8
22
|
* Concurrency model:
|
|
9
23
|
* - Assumptions: local filesystem; cooperating processes on one machine.
|
|
10
24
|
* - Guarantees: mutual exclusion under crash-free operation; detect-and-abort
|
|
11
25
|
* if the sidecar lock is compromised (fence before rename/unlink).
|
|
12
26
|
* - Non-goals: network filesystems; Byzantine processes; perfect off-Linux
|
|
13
|
-
* PID-reuse detection (hard age cap
|
|
27
|
+
* PID-reuse detection (hard age cap — `OCCUPANCY_MAX_LEASE_MS` — plus fence
|
|
28
|
+
* instead).
|
|
29
|
+
* - Residual: the write gate authorizes a write it does not itself perform, so
|
|
30
|
+
* no verdict is atomic with the write. A takeover that publishes after the
|
|
31
|
+
* allow — including one already holding the lock but not yet written — is
|
|
32
|
+
* outside what this gate can see. Closing that would mean denying every
|
|
33
|
+
* owner whose lease file is momentarily locked, which is the load-shedding
|
|
34
|
+
* regression #3736 fixed. The bound is the TTL, not the gate.
|
|
14
35
|
*/
|
|
15
36
|
import { randomUUID } from "node:crypto";
|
|
16
37
|
import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
@@ -18,28 +39,181 @@ import { dirname, join, resolve } from "node:path";
|
|
|
18
39
|
import { containedRemove, containedWrite } from "../fs/contained-write.js";
|
|
19
40
|
import { assertWriteTargetSafe } from "../fs/projection-containment.js";
|
|
20
41
|
import { assertAppendLockOwned, withAppendLock } from "../slice/lock.js";
|
|
42
|
+
import { SWARM_WORKER_ROLES } from "../swarm/routing.js";
|
|
21
43
|
import { stableJson } from "./json.js";
|
|
22
44
|
import { parseTimestamp, timestampIso } from "./time.js";
|
|
23
45
|
export const OCCUPANCY_SCHEMA_VERSION = 1;
|
|
24
46
|
export const OCCUPANCY_RELPATH = [".deft", "occupancy.json"];
|
|
25
47
|
/** Crash recovery TTL: 20 minutes without heartbeat (15–30 window). */
|
|
26
48
|
export const OCCUPANCY_TTL_MS = 20 * 60 * 1000;
|
|
49
|
+
/**
|
|
50
|
+
* Owner-allow re-stamp floor (#3599). The write gate runs on every gated write,
|
|
51
|
+
* so refreshing unconditionally would rewrite the lease file per keystroke-scale
|
|
52
|
+
* event. A quarter of the TTL bounds that without shortening the safe window:
|
|
53
|
+
* a write at any age past this floor resets the clock, so an owner that writes
|
|
54
|
+
* at least once per TTL never expires.
|
|
55
|
+
*/
|
|
56
|
+
export const OCCUPANCY_REFRESH_AFTER_MS = OCCUPANCY_TTL_MS / 4;
|
|
57
|
+
/** Owner-allow staleness warning floor: three quarters of the TTL (#3599). */
|
|
58
|
+
export const OCCUPANCY_STALE_WARN_MS = (OCCUPANCY_TTL_MS * 3) / 4;
|
|
59
|
+
/**
|
|
60
|
+
* Absolute lease age cap, keyed on `claimedAt` and independent of refresh
|
|
61
|
+
* (#3599). Occupancy admits whoever presents the occupant's session id, so
|
|
62
|
+
* "the owner is still writing" only proves that some process holds that
|
|
63
|
+
* string. Without a bound on claim age, refresh would turn the heartbeat TTL —
|
|
64
|
+
* the sole mechanism that reclaims a worktree from a dead session — into
|
|
65
|
+
* something a writer can extend forever.
|
|
66
|
+
*
|
|
67
|
+
* Thirty-six TTLs is twelve hours, sized by the stalled owner rather than the
|
|
68
|
+
* busy one. Refresh keys on writes, so an agent that finishes overnight and
|
|
69
|
+
* waits for its operator is alive, correct, and silent — it stops refreshing
|
|
70
|
+
* while staying entirely legitimate. Twelve hours spans a 23:00 dispatch to a
|
|
71
|
+
* 09:00 handoff and still bounds reclaim well inside a day. Reaching the cap
|
|
72
|
+
* costs the owner one re-claim, not its work.
|
|
73
|
+
*
|
|
74
|
+
* Known limitation: a pure time cap cannot tell a stalled-but-live owner from a
|
|
75
|
+
* dead one, because the only liveness signal on this path is a write. If that
|
|
76
|
+
* ambiguity starts to bite, the answer is a liveness signal that needs no write
|
|
77
|
+
* — an explicit parked state, or refresh on non-write activity — not a larger
|
|
78
|
+
* number here.
|
|
79
|
+
*/
|
|
80
|
+
export const OCCUPANCY_MAX_LEASE_MS = OCCUPANCY_TTL_MS * 36;
|
|
27
81
|
export const OCCUPANCY_INTENTS = ["mutation", "swarm", "review"];
|
|
28
82
|
export const OCCUPANCY_JOIN_PROTOCOLS = ["none", "heartbeat-file", "parent-message"];
|
|
83
|
+
/**
|
|
84
|
+
* Default life of a grant (#3755), sized by one dispatched unit of work:
|
|
85
|
+
* implement, open the PR, run the review cycle. Four hours is a third of the
|
|
86
|
+
* absolute lease cap, so a grant that outlives its child still dies well inside
|
|
87
|
+
* the lease that issued it, and re-granting costs the owner one command.
|
|
88
|
+
*/
|
|
89
|
+
export const OCCUPANCY_GRANT_TTL_MS = 4 * 60 * 60 * 1000;
|
|
90
|
+
/**
|
|
91
|
+
* Grants a single lease may carry (#3755). Bounded because the list is rewritten
|
|
92
|
+
* into the lease file on every touch and the topology it serves is a nuclear
|
|
93
|
+
* family (#3155), not a mesh — a lease needing more than this is a design
|
|
94
|
+
* problem, not a capacity one.
|
|
95
|
+
*/
|
|
96
|
+
export const OCCUPANCY_MAX_GRANTS = 32;
|
|
29
97
|
export function occupancyPath(projectRoot) {
|
|
30
98
|
return join(resolve(projectRoot), ...OCCUPANCY_RELPATH);
|
|
31
99
|
}
|
|
32
100
|
export function heartbeatAgeSeconds(record, now = new Date()) {
|
|
33
101
|
return Math.max(0, Math.round((now.getTime() - record.heartbeatAt.getTime()) / 1000));
|
|
34
102
|
}
|
|
35
|
-
|
|
36
|
-
|
|
103
|
+
/** Age of the occupant's last recorded product write, or null when none (#3599). */
|
|
104
|
+
export function lastWriteAgeSeconds(record, now = new Date()) {
|
|
105
|
+
if (record.lastWriteAt === null)
|
|
106
|
+
return null;
|
|
107
|
+
return Math.max(0, Math.round((now.getTime() - record.lastWriteAt.getTime()) / 1000));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Human phrase for how recently the occupant wrote (#3599). Heartbeat age alone
|
|
111
|
+
* cannot distinguish an occupant mid-edit from one that merely claimed and left.
|
|
112
|
+
*/
|
|
113
|
+
export function formatLastWritePhrase(record, now = new Date()) {
|
|
114
|
+
const age = lastWriteAgeSeconds(record, now);
|
|
115
|
+
return age === null ? "no recorded write" : `last write ${age}s ago`;
|
|
116
|
+
}
|
|
117
|
+
/** Age of the lease itself, measured from the claim that opened it (#3599). */
|
|
118
|
+
export function leaseAgeSeconds(record, now = new Date()) {
|
|
119
|
+
return Math.max(0, Math.round((now.getTime() - record.claimedAt.getTime()) / 1000));
|
|
120
|
+
}
|
|
121
|
+
export function occupancyLiveness(record, now = new Date(), ttlMs = OCCUPANCY_TTL_MS, maxLeaseMs = OCCUPANCY_MAX_LEASE_MS) {
|
|
122
|
+
// The cap is checked first because it is the answer that survives (#3599).
|
|
123
|
+
// A lease can be both, and then the stale reading is actively misleading:
|
|
124
|
+
// it sends the holder to refresh, which a capped lease cannot accept. Order
|
|
125
|
+
// also decides the write gate — the capped-holder refusal below keys on this
|
|
126
|
+
// value, so reading a doubly-dead lease as merely stale would give the more
|
|
127
|
+
// dead lease the more permissive answer.
|
|
128
|
+
if (now.getTime() - record.claimedAt.getTime() > maxLeaseMs)
|
|
129
|
+
return "age-capped";
|
|
130
|
+
if (now.getTime() - record.heartbeatAt.getTime() > ttlMs)
|
|
131
|
+
return "heartbeat-stale";
|
|
132
|
+
return "live";
|
|
133
|
+
}
|
|
134
|
+
export function isOccupancyExpired(record, now = new Date(), ttlMs = OCCUPANCY_TTL_MS, maxLeaseMs = OCCUPANCY_MAX_LEASE_MS) {
|
|
135
|
+
return occupancyLiveness(record, now, ttlMs, maxLeaseMs) !== "live";
|
|
136
|
+
}
|
|
137
|
+
/** Grants that still admit somebody (#3755). An expired grant admits nobody. */
|
|
138
|
+
export function liveOccupancyGrants(record, now = new Date()) {
|
|
139
|
+
return record.grants.filter((grant) => grant.expiresAt.getTime() > now.getTime());
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The grant admitting `sessionId`, or null (#3755). Expiry is refused here, on
|
|
143
|
+
* read, rather than trusted to a sweep: nothing guarantees a lease is ever
|
|
144
|
+
* touched again after the grant is written, so a grant that outlived its clock
|
|
145
|
+
* must stop admitting the moment it is read, not the next time it is rewritten.
|
|
146
|
+
*/
|
|
147
|
+
export function occupancyGrantFor(record, sessionId, now = new Date()) {
|
|
148
|
+
const presented = sessionId.trim();
|
|
149
|
+
if (presented.length === 0)
|
|
150
|
+
return null;
|
|
151
|
+
// The owner holds the lease outright; a grant naming it would add nothing.
|
|
152
|
+
if (presented === record.sessionId)
|
|
153
|
+
return null;
|
|
154
|
+
return (liveOccupancyGrants(record, now).find((grant) => grant.childSessionId === presented) ?? null);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* What the presented id is to this lease (#3755). Deliberately not a liveness
|
|
158
|
+
* question: it answers who, and callers pair it with `occupancyLiveness` to
|
|
159
|
+
* answer whether the lease is still worth anything.
|
|
160
|
+
*/
|
|
161
|
+
export function occupancyAdmission(record, sessionId, now = new Date()) {
|
|
162
|
+
const presented = sessionId.trim();
|
|
163
|
+
if (presented.length === 0)
|
|
164
|
+
return "stranger";
|
|
165
|
+
if (presented === record.sessionId)
|
|
166
|
+
return "owner";
|
|
167
|
+
return occupancyGrantFor(record, presented, now) === null ? "stranger" : "member";
|
|
168
|
+
}
|
|
169
|
+
function occupancyClockLine(record) {
|
|
170
|
+
const lastWrite = record.lastWriteAt === null ? "" : ` last_write_at=${timestampIso(record.lastWriteAt)}`;
|
|
171
|
+
return `claimed_at=${timestampIso(record.claimedAt)} heartbeat_at=${timestampIso(record.heartbeatAt)}${lastWrite}`;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Warn the holder that its own lease is inside the staleness window (#3599).
|
|
175
|
+
* Without this the owner learns it went stale only when a peer steals the lease.
|
|
176
|
+
*/
|
|
177
|
+
export function formatOccupancyStaleWarning(record, now = new Date(), ttlMs = OCCUPANCY_TTL_MS) {
|
|
178
|
+
const age = heartbeatAgeSeconds(record, now);
|
|
179
|
+
return (`Occupancy lease for session ${record.sessionId} has not beaten for ${age}s of its ` +
|
|
180
|
+
`${Math.round(ttlMs / 1000)}s window; another session may read it as abandoned. ` +
|
|
181
|
+
`Refresh it with \`deft occupancy:heartbeat --session-id=${record.sessionId}\`.`);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Tell the holder its lease aged out of the absolute cap (#3599). Distinct
|
|
185
|
+
* remediation from a stale heartbeat: beating harder cannot help, because the
|
|
186
|
+
* lease is gone rather than merely quiet, so the answer is to re-claim.
|
|
187
|
+
*/
|
|
188
|
+
export function formatOccupancyAgeCapRemediation(record, now = new Date(), maxLeaseMs = OCCUPANCY_MAX_LEASE_MS) {
|
|
189
|
+
const hours = Math.round(maxLeaseMs / (60 * 60 * 1000));
|
|
190
|
+
return (`Occupancy lease for session ${record.sessionId} passed its ${hours}h absolute age cap ` +
|
|
191
|
+
`(claimed ${leaseAgeSeconds(record, now)}s ago, ${occupancyClockLine(record)}), so this ` +
|
|
192
|
+
"worktree is no longer held and a peer may claim it at any moment. Heartbeats cannot " +
|
|
193
|
+
"extend a capped lease — re-claim the worktree with " +
|
|
194
|
+
`\`deft session:start --session-id=${record.sessionId}\` before writing again.`);
|
|
37
195
|
}
|
|
38
196
|
export function formatOccupancyRemediation(record, now = new Date()) {
|
|
39
197
|
const age = heartbeatAgeSeconds(record, now);
|
|
40
|
-
return (`Worktree occupied by session ${record.sessionId} (intent=${record.intent}, heartbeat ${age}s ago
|
|
198
|
+
return (`Worktree occupied by session ${record.sessionId} (intent=${record.intent}, heartbeat ${age}s ago, ` +
|
|
199
|
+
`${formatLastWritePhrase(record, now)}, ${occupancyClockLine(record)}).\n` +
|
|
41
200
|
"Stay read-only (`session:start --read-only`), use another worktree,\n" +
|
|
42
|
-
"
|
|
201
|
+
"ask the occupant for a write grant (`occupancy:grant --child-session-id=<your-session-id> " +
|
|
202
|
+
"--role <worker-role>`, run by the occupant), or run a confirmed owner transition " +
|
|
203
|
+
"(`session:start --steal --confirm --occupant <reported-session-id> --session-id=<your-session-id>`).\n" +
|
|
204
|
+
"The occupant may release (`occupancy:release` / `session:end`).");
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Refuse an administrative verb to a granted child (#3755). Named apart from
|
|
208
|
+
* the stranger refusal because the answer differs: this caller is admitted, and
|
|
209
|
+
* telling it to steal or wait would send it to take the very lease its grant
|
|
210
|
+
* derives from. A grant admits writes; the lease has one owner.
|
|
211
|
+
*/
|
|
212
|
+
export function formatOccupancyMemberAdministrationRefusal(record, grant, verb) {
|
|
213
|
+
return (`${verb} is owner-only. Session ${grant.childSessionId} holds a write grant on this lease ` +
|
|
214
|
+
`(role=${grant.role}, expires ${timestampIso(grant.expiresAt)}), not the lease itself, and a ` +
|
|
215
|
+
"grant never escalates into administration.\n" +
|
|
216
|
+
`Ask the occupant (session ${record.sessionId}) to run it, or wait for the grant to expire.`);
|
|
43
217
|
}
|
|
44
218
|
export function resolveOccupancySessionId(input = {}) {
|
|
45
219
|
const explicit = input.sessionId?.trim();
|
|
@@ -68,9 +242,9 @@ export function readOccupancy(projectRoot) {
|
|
|
68
242
|
}
|
|
69
243
|
return parseOccupancy(payload, resolve(projectRoot));
|
|
70
244
|
}
|
|
71
|
-
export function liveOccupant(projectRoot, now = new Date(), ttlMs = OCCUPANCY_TTL_MS) {
|
|
245
|
+
export function liveOccupant(projectRoot, now = new Date(), ttlMs = OCCUPANCY_TTL_MS, maxLeaseMs = OCCUPANCY_MAX_LEASE_MS) {
|
|
72
246
|
const record = readOccupancy(projectRoot);
|
|
73
|
-
if (record === null || isOccupancyExpired(record, now, ttlMs))
|
|
247
|
+
if (record === null || isOccupancyExpired(record, now, ttlMs, maxLeaseMs))
|
|
74
248
|
return null;
|
|
75
249
|
return record;
|
|
76
250
|
}
|
|
@@ -124,10 +298,15 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
|
|
|
124
298
|
intent: input.intent ?? liveLocked?.intent ?? "mutation",
|
|
125
299
|
claimedAt: liveLocked?.claimedAt ?? now,
|
|
126
300
|
heartbeatAt: now,
|
|
301
|
+
lastWriteAt: input.markWrite === true ? now : (liveLocked?.lastWriteAt ?? null),
|
|
127
302
|
host: input.host ?? liveLocked?.host ?? occupancyHost(input.env),
|
|
128
303
|
address: input.address ?? liveLocked?.address ?? occupancyAddress(input.env),
|
|
129
304
|
retainCapable: input.retainCapable ?? liveLocked?.retainCapable ?? false,
|
|
130
305
|
joinProtocol: input.joinProtocol ?? liveLocked?.joinProtocol ?? "none",
|
|
306
|
+
// Grants belong to the lease that issued them (#3755): the same owner
|
|
307
|
+
// keeps its members across a heartbeat, and a fresh claim over expired
|
|
308
|
+
// residue starts with none.
|
|
309
|
+
grants: liveLocked === null ? [] : liveOccupancyGrants(liveLocked, now),
|
|
131
310
|
}, fence);
|
|
132
311
|
const action = liveLocked !== null ? "heartbeat" : "claimed";
|
|
133
312
|
return {
|
|
@@ -145,13 +324,20 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
|
|
|
145
324
|
export function stealOccupancy(projectRoot, input = {}) {
|
|
146
325
|
const now = input.now ?? new Date();
|
|
147
326
|
const path = occupancyPath(projectRoot);
|
|
327
|
+
const incoming = resolveOccupancySessionId(input);
|
|
148
328
|
if (input.confirm !== true) {
|
|
329
|
+
const current = readOccupancy(projectRoot);
|
|
330
|
+
// Show the occupant's write recency before the steal, not only after it
|
|
331
|
+
// (#3599): heartbeat age alone hides an occupant that is mid-edit.
|
|
332
|
+
const occupantDetail = current !== null && !isOccupancyExpired(current, now)
|
|
333
|
+
? `\n${formatOccupancyRemediation(current, now)}`
|
|
334
|
+
: "";
|
|
149
335
|
return {
|
|
150
336
|
action: "denied",
|
|
151
|
-
sessionId:
|
|
152
|
-
record:
|
|
337
|
+
sessionId: incoming,
|
|
338
|
+
record: current,
|
|
153
339
|
path,
|
|
154
|
-
message:
|
|
340
|
+
message: `occupancy:steal requires --confirm after naming the occupant.${occupantDetail}`,
|
|
155
341
|
code: 2,
|
|
156
342
|
};
|
|
157
343
|
}
|
|
@@ -159,7 +345,7 @@ export function stealOccupancy(projectRoot, input = {}) {
|
|
|
159
345
|
if (named.length === 0) {
|
|
160
346
|
return {
|
|
161
347
|
action: "denied",
|
|
162
|
-
sessionId:
|
|
348
|
+
sessionId: incoming,
|
|
163
349
|
record: readOccupancy(projectRoot),
|
|
164
350
|
path,
|
|
165
351
|
message: "occupancy:steal requires --occupant <session-id> to name the current occupant.",
|
|
@@ -168,10 +354,26 @@ export function stealOccupancy(projectRoot, input = {}) {
|
|
|
168
354
|
}
|
|
169
355
|
const existing = readOccupancy(projectRoot);
|
|
170
356
|
const live = existing !== null && !isOccupancyExpired(existing, now) ? existing : null;
|
|
357
|
+
// A grant admits writes, never the lease itself (#3755). Letting a child steal
|
|
358
|
+
// from the owner that admitted it would turn delegated write access into a
|
|
359
|
+
// path to replace the delegator — the escalation explicit membership exists to
|
|
360
|
+
// remove. Cooperative, like the rest of this file: a caller can present some
|
|
361
|
+
// other id, and then it is a stranger doing a confirmed steal, on the record.
|
|
362
|
+
const stealerGrant = live === null ? null : occupancyGrantFor(live, incoming, now);
|
|
363
|
+
if (live !== null && stealerGrant !== null) {
|
|
364
|
+
return {
|
|
365
|
+
action: "denied",
|
|
366
|
+
sessionId: incoming,
|
|
367
|
+
record: live,
|
|
368
|
+
path,
|
|
369
|
+
message: formatOccupancyMemberAdministrationRefusal(live, stealerGrant, "occupancy:steal"),
|
|
370
|
+
code: 1,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
171
373
|
if (live !== null && live.sessionId !== named) {
|
|
172
374
|
return {
|
|
173
375
|
action: "denied",
|
|
174
|
-
sessionId:
|
|
376
|
+
sessionId: incoming,
|
|
175
377
|
record: live,
|
|
176
378
|
path,
|
|
177
379
|
message: `occupancy:steal named occupant ${named} does not match live occupant ${live.sessionId}.\n` +
|
|
@@ -179,13 +381,36 @@ export function stealOccupancy(projectRoot, input = {}) {
|
|
|
179
381
|
code: 1,
|
|
180
382
|
};
|
|
181
383
|
}
|
|
384
|
+
if (input.write === false) {
|
|
385
|
+
return {
|
|
386
|
+
action: "stolen",
|
|
387
|
+
sessionId: incoming,
|
|
388
|
+
record: live,
|
|
389
|
+
path,
|
|
390
|
+
message: live === null
|
|
391
|
+
? `occupancy steal preview: writer would be session ${incoming}`
|
|
392
|
+
: `occupancy steal preview: ${live.sessionId} would be replaced by session ${incoming}`,
|
|
393
|
+
code: 0,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
182
396
|
return withOccupancyLock(projectRoot, (fence) => {
|
|
183
397
|
const existingLocked = readOccupancy(projectRoot);
|
|
184
398
|
const liveLocked = existingLocked !== null && !isOccupancyExpired(existingLocked, now) ? existingLocked : null;
|
|
399
|
+
const lockedStealerGrant = liveLocked === null ? null : occupancyGrantFor(liveLocked, incoming, now);
|
|
400
|
+
if (liveLocked !== null && lockedStealerGrant !== null) {
|
|
401
|
+
return {
|
|
402
|
+
action: "denied",
|
|
403
|
+
sessionId: incoming,
|
|
404
|
+
record: liveLocked,
|
|
405
|
+
path,
|
|
406
|
+
message: formatOccupancyMemberAdministrationRefusal(liveLocked, lockedStealerGrant, "occupancy:steal"),
|
|
407
|
+
code: 1,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
185
410
|
if (liveLocked !== null && liveLocked.sessionId !== named) {
|
|
186
411
|
return {
|
|
187
412
|
action: "denied",
|
|
188
|
-
sessionId:
|
|
413
|
+
sessionId: incoming,
|
|
189
414
|
record: liveLocked,
|
|
190
415
|
path,
|
|
191
416
|
message: `occupancy:steal named occupant ${named} does not match live occupant ${liveLocked.sessionId}.\n` +
|
|
@@ -193,24 +418,35 @@ export function stealOccupancy(projectRoot, input = {}) {
|
|
|
193
418
|
code: 1,
|
|
194
419
|
};
|
|
195
420
|
}
|
|
196
|
-
const
|
|
421
|
+
const priorClock = existingLocked !== null
|
|
422
|
+
? ` (${formatLastWritePhrase(existingLocked, now)}, ${occupancyClockLine(existingLocked)})`
|
|
423
|
+
: "";
|
|
197
424
|
const record = writeOccupancyRecord(projectRoot, {
|
|
198
425
|
sessionId: incoming,
|
|
199
426
|
worktreePath: resolve(projectRoot),
|
|
200
427
|
intent: input.intent ?? "mutation",
|
|
201
428
|
claimedAt: now,
|
|
202
429
|
heartbeatAt: now,
|
|
430
|
+
lastWriteAt: null,
|
|
203
431
|
host: input.host ?? occupancyHost(input.env),
|
|
204
432
|
address: input.address ?? occupancyAddress(input.env),
|
|
205
433
|
retainCapable: input.retainCapable ?? false,
|
|
206
434
|
joinProtocol: input.joinProtocol ?? "none",
|
|
435
|
+
// A steal replaces the owner, and grants are that owner's word about
|
|
436
|
+
// who may write. The new owner never said it, so it does not inherit
|
|
437
|
+
// the members either (#3755).
|
|
438
|
+
grants: [],
|
|
207
439
|
}, fence);
|
|
208
440
|
return {
|
|
209
441
|
action: "stolen",
|
|
210
442
|
sessionId: record.sessionId,
|
|
211
443
|
record,
|
|
212
444
|
path,
|
|
213
|
-
message: `occupancy stolen from ${named}; writer is now session ${record.sessionId}
|
|
445
|
+
message: `occupancy stolen from ${named}${priorClock}; writer is now session ${record.sessionId}. ` +
|
|
446
|
+
"This command changes the lease only; direct writes remain denied unless ritual state already names the same owner. " +
|
|
447
|
+
"If the owners differ, run `deft session:start --rearm --session-id=<same-session-id>` " +
|
|
448
|
+
"when re-arm is eligible; otherwise run `deft session:start --session-id=<same-session-id>` " +
|
|
449
|
+
"for a cold ceremony, using the writer ID above.",
|
|
214
450
|
code: 0,
|
|
215
451
|
};
|
|
216
452
|
}, input.lockDeps);
|
|
@@ -239,7 +475,7 @@ export function releaseOccupancy(projectRoot, input = {}) {
|
|
|
239
475
|
sessionId: caller,
|
|
240
476
|
record: existing,
|
|
241
477
|
path,
|
|
242
|
-
message:
|
|
478
|
+
message: membershipOwnerDenial(existing, caller, now, "occupancy:release"),
|
|
243
479
|
code: 1,
|
|
244
480
|
};
|
|
245
481
|
}
|
|
@@ -264,7 +500,7 @@ export function releaseOccupancy(projectRoot, input = {}) {
|
|
|
264
500
|
sessionId: caller,
|
|
265
501
|
record: still,
|
|
266
502
|
path,
|
|
267
|
-
message:
|
|
503
|
+
message: membershipOwnerDenial(still, caller, now, "occupancy:release"),
|
|
268
504
|
code: 1,
|
|
269
505
|
};
|
|
270
506
|
}
|
|
@@ -279,19 +515,503 @@ export function releaseOccupancy(projectRoot, input = {}) {
|
|
|
279
515
|
};
|
|
280
516
|
}, input.lockDeps);
|
|
281
517
|
}
|
|
518
|
+
function membershipOwnerDenial(live, caller, now, verb) {
|
|
519
|
+
const grant = occupancyGrantFor(live, caller, now);
|
|
520
|
+
return grant === null
|
|
521
|
+
? formatOccupancyRemediation(live, now)
|
|
522
|
+
: formatOccupancyMemberAdministrationRefusal(live, grant, verb);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Admit a dispatched child to this lease for writes (#3755).
|
|
526
|
+
*
|
|
527
|
+
* Owner-only, and the record is the point: a child that writes here is named
|
|
528
|
+
* on the lease it writes under, so an unexpected edit resolves to a session, a
|
|
529
|
+
* role and a tree instead of to "somebody who had the string". The grant cannot
|
|
530
|
+
* outlive the lease that issued it — expiry is clamped to the absolute lease
|
|
531
|
+
* cap — and it buys writes alone.
|
|
532
|
+
*/
|
|
533
|
+
export function grantOccupancyMembership(projectRoot, input = {}) {
|
|
534
|
+
const now = input.now ?? new Date();
|
|
535
|
+
const path = occupancyPath(projectRoot);
|
|
536
|
+
const owner = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
|
|
537
|
+
const child = input.childSessionId?.trim() ?? "";
|
|
538
|
+
const role = input.role?.trim() ?? "";
|
|
539
|
+
if (owner.length === 0) {
|
|
540
|
+
return {
|
|
541
|
+
action: "denied",
|
|
542
|
+
sessionId: "",
|
|
543
|
+
record: readOccupancy(projectRoot),
|
|
544
|
+
path,
|
|
545
|
+
message: "occupancy:grant needs the owner id: pass --session-id <your-session-id> or set " +
|
|
546
|
+
"DEFT_SESSION_ID. Only the occupant may admit a child to its lease.",
|
|
547
|
+
code: 2,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
if (child.length === 0) {
|
|
551
|
+
return {
|
|
552
|
+
action: "denied",
|
|
553
|
+
sessionId: owner,
|
|
554
|
+
record: readOccupancy(projectRoot),
|
|
555
|
+
path,
|
|
556
|
+
message: "occupancy:grant needs --child-session-id <session-id>: the id the dispatched child " +
|
|
557
|
+
"will present on its own writes.",
|
|
558
|
+
code: 2,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
if (child === owner) {
|
|
562
|
+
return {
|
|
563
|
+
action: "denied",
|
|
564
|
+
sessionId: owner,
|
|
565
|
+
record: readOccupancy(projectRoot),
|
|
566
|
+
path,
|
|
567
|
+
message: "occupancy:grant refuses a self-grant: the lease already admits its owner, so a grant " +
|
|
568
|
+
"naming the same id records nothing and would only blur who wrote what.",
|
|
569
|
+
code: 2,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
if (!SWARM_WORKER_ROLES.includes(role)) {
|
|
573
|
+
return {
|
|
574
|
+
action: "denied",
|
|
575
|
+
sessionId: owner,
|
|
576
|
+
record: readOccupancy(projectRoot),
|
|
577
|
+
path,
|
|
578
|
+
message: `occupancy:grant needs --role from ${SWARM_WORKER_ROLES.join(", ")}. The role is what the ` +
|
|
579
|
+
"grant is for; an unnamed role makes the record unreadable after the fact.",
|
|
580
|
+
code: 2,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
const ttlMs = input.ttlMs ?? OCCUPANCY_GRANT_TTL_MS;
|
|
584
|
+
return withOccupancyLock(projectRoot, (fence) => {
|
|
585
|
+
const current = readOccupancy(projectRoot);
|
|
586
|
+
const live = current !== null && !isOccupancyExpired(current, now) ? current : null;
|
|
587
|
+
if (live === null) {
|
|
588
|
+
const capped = current !== null &&
|
|
589
|
+
current.sessionId === owner &&
|
|
590
|
+
occupancyLiveness(current, now) === "age-capped";
|
|
591
|
+
return {
|
|
592
|
+
action: "denied",
|
|
593
|
+
sessionId: owner,
|
|
594
|
+
record: capped ? current : null,
|
|
595
|
+
path,
|
|
596
|
+
message: capped && current !== null
|
|
597
|
+
? formatOccupancyAgeCapRemediation(current, now)
|
|
598
|
+
: "occupancy:grant found no live lease to grant on. A grant is derived authority, " +
|
|
599
|
+
`so claim the worktree first with \`deft session:start --session-id=${owner}\`.`,
|
|
600
|
+
code: 1,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
if (live.sessionId !== owner) {
|
|
604
|
+
return {
|
|
605
|
+
action: "denied",
|
|
606
|
+
sessionId: owner,
|
|
607
|
+
record: live,
|
|
608
|
+
path,
|
|
609
|
+
message: membershipOwnerDenial(live, owner, now, "occupancy:grant"),
|
|
610
|
+
code: 1,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
const requested = input.expiresAt ?? new Date(now.getTime() + ttlMs);
|
|
614
|
+
if (requested.getTime() <= now.getTime()) {
|
|
615
|
+
return {
|
|
616
|
+
action: "denied",
|
|
617
|
+
sessionId: owner,
|
|
618
|
+
record: live,
|
|
619
|
+
path,
|
|
620
|
+
message: "occupancy:grant refuses an expiry that is already past: a grant that admits nobody " +
|
|
621
|
+
"is indistinguishable from no grant, and recording one would only mislead.",
|
|
622
|
+
code: 2,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
// A grant is derived authority, so it dies with the lease it derives from
|
|
626
|
+
// (#3755). Without this clamp a chain of grants would outlast the absolute
|
|
627
|
+
// cap that keeps a worktree reclaimable.
|
|
628
|
+
const leaseEnds = live.claimedAt.getTime() + OCCUPANCY_MAX_LEASE_MS;
|
|
629
|
+
const expiresAt = requested.getTime() > leaseEnds ? new Date(leaseEnds) : requested;
|
|
630
|
+
const clamped = expiresAt.getTime() !== requested.getTime();
|
|
631
|
+
const kept = liveOccupancyGrants(live, now).filter((existing) => existing.childSessionId !== child);
|
|
632
|
+
if (kept.length >= OCCUPANCY_MAX_GRANTS) {
|
|
633
|
+
return {
|
|
634
|
+
action: "denied",
|
|
635
|
+
sessionId: owner,
|
|
636
|
+
record: live,
|
|
637
|
+
path,
|
|
638
|
+
message: `occupancy:grant refuses a ${OCCUPANCY_MAX_GRANTS + 1}th live grant on one lease. ` +
|
|
639
|
+
"Revoke a finished child (`occupancy:grant --revoke --child-session-id=<id>`) or let " +
|
|
640
|
+
"its grant expire.",
|
|
641
|
+
code: 1,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
const grant = {
|
|
645
|
+
ownerSessionId: owner,
|
|
646
|
+
childSessionId: child,
|
|
647
|
+
worktreePath: input.worktreePath?.trim() || live.worktreePath,
|
|
648
|
+
role: role,
|
|
649
|
+
expiresAt,
|
|
650
|
+
host: input.host?.trim() || "none",
|
|
651
|
+
address: input.address?.trim() || "none",
|
|
652
|
+
joinProtocol: input.joinProtocol ?? "parent-message",
|
|
653
|
+
};
|
|
654
|
+
const record = writeOccupancyRecord(projectRoot, {
|
|
655
|
+
sessionId: live.sessionId,
|
|
656
|
+
worktreePath: live.worktreePath,
|
|
657
|
+
intent: live.intent,
|
|
658
|
+
claimedAt: live.claimedAt,
|
|
659
|
+
// Issuing a grant is the owner touching its own lease, which is what
|
|
660
|
+
// heartbeat_at records; claimed_at is untouched, so the cap holds.
|
|
661
|
+
heartbeatAt: now,
|
|
662
|
+
lastWriteAt: live.lastWriteAt,
|
|
663
|
+
host: live.host,
|
|
664
|
+
address: live.address,
|
|
665
|
+
retainCapable: live.retainCapable,
|
|
666
|
+
joinProtocol: live.joinProtocol,
|
|
667
|
+
grants: [...kept, grant],
|
|
668
|
+
}, fence);
|
|
669
|
+
return {
|
|
670
|
+
action: "granted",
|
|
671
|
+
sessionId: owner,
|
|
672
|
+
record,
|
|
673
|
+
path,
|
|
674
|
+
message: `occupancy grant issued to session ${child} (role=${grant.role}, ` +
|
|
675
|
+
`worktree=${grant.worktreePath}, expires ${timestampIso(expiresAt)}` +
|
|
676
|
+
`${clamped ? ", clamped to this lease's absolute age cap" : ""}). ` +
|
|
677
|
+
"It admits writes only; release, steal, heartbeat and cohort close-out stay yours.",
|
|
678
|
+
code: 0,
|
|
679
|
+
};
|
|
680
|
+
}, input.lockDeps);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Withdraw a child's grant early (#3755). Expiry already bounds every grant, so
|
|
684
|
+
* this exists for the case expiry cannot serve: the child finished, or should
|
|
685
|
+
* never have been admitted, and the owner wants that true now.
|
|
686
|
+
*/
|
|
687
|
+
export function revokeOccupancyMembership(projectRoot, input = {}) {
|
|
688
|
+
const now = input.now ?? new Date();
|
|
689
|
+
const path = occupancyPath(projectRoot);
|
|
690
|
+
const owner = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
|
|
691
|
+
const child = input.childSessionId?.trim() ?? "";
|
|
692
|
+
if (owner.length === 0 || child.length === 0) {
|
|
693
|
+
return {
|
|
694
|
+
action: "denied",
|
|
695
|
+
sessionId: owner,
|
|
696
|
+
record: readOccupancy(projectRoot),
|
|
697
|
+
path,
|
|
698
|
+
message: "occupancy:grant --revoke needs both the owner id (--session-id or DEFT_SESSION_ID) and " +
|
|
699
|
+
"--child-session-id <session-id>.",
|
|
700
|
+
code: 2,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
return withOccupancyLock(projectRoot, (fence) => {
|
|
704
|
+
const current = readOccupancy(projectRoot);
|
|
705
|
+
const live = current !== null && !isOccupancyExpired(current, now) ? current : null;
|
|
706
|
+
if (live === null) {
|
|
707
|
+
return {
|
|
708
|
+
action: "revoked",
|
|
709
|
+
sessionId: owner,
|
|
710
|
+
record: null,
|
|
711
|
+
path,
|
|
712
|
+
message: "occupancy:grant --revoke found no live lease, so no grant survives it either: " +
|
|
713
|
+
"grants die with the lease that issued them.",
|
|
714
|
+
code: 0,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
if (live.sessionId !== owner) {
|
|
718
|
+
return {
|
|
719
|
+
action: "denied",
|
|
720
|
+
sessionId: owner,
|
|
721
|
+
record: live,
|
|
722
|
+
path,
|
|
723
|
+
message: membershipOwnerDenial(live, owner, now, "occupancy:grant --revoke"),
|
|
724
|
+
code: 1,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
const before = liveOccupancyGrants(live, now);
|
|
728
|
+
const remaining = before.filter((existing) => existing.childSessionId !== child);
|
|
729
|
+
if (remaining.length === before.length) {
|
|
730
|
+
return {
|
|
731
|
+
action: "revoked",
|
|
732
|
+
sessionId: owner,
|
|
733
|
+
record: live,
|
|
734
|
+
path,
|
|
735
|
+
message: `occupancy has no live grant for session ${child}; nothing to revoke.`,
|
|
736
|
+
code: 0,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
const record = writeOccupancyRecord(projectRoot, {
|
|
740
|
+
sessionId: live.sessionId,
|
|
741
|
+
worktreePath: live.worktreePath,
|
|
742
|
+
intent: live.intent,
|
|
743
|
+
claimedAt: live.claimedAt,
|
|
744
|
+
heartbeatAt: now,
|
|
745
|
+
lastWriteAt: live.lastWriteAt,
|
|
746
|
+
host: live.host,
|
|
747
|
+
address: live.address,
|
|
748
|
+
retainCapable: live.retainCapable,
|
|
749
|
+
joinProtocol: live.joinProtocol,
|
|
750
|
+
grants: remaining,
|
|
751
|
+
}, fence);
|
|
752
|
+
return {
|
|
753
|
+
action: "revoked",
|
|
754
|
+
sessionId: owner,
|
|
755
|
+
record,
|
|
756
|
+
path,
|
|
757
|
+
message: `occupancy grant revoked for session ${child}; its writes are refused from now on.`,
|
|
758
|
+
code: 0,
|
|
759
|
+
};
|
|
760
|
+
}, input.lockDeps);
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Decide whether the presented session may write, and — on the owner-allow
|
|
764
|
+
* path — keep the owner's lease alive (#3599).
|
|
765
|
+
*
|
|
766
|
+
* Before this, the gate was read-only on owner-allow, so the one event that
|
|
767
|
+
* proves the owner is alive did not extend its lease: the live window was
|
|
768
|
+
* twenty minutes from claim, once, regardless of how long the session worked.
|
|
769
|
+
*/
|
|
282
770
|
export function evaluateOccupancyWriteGate(projectRoot, input = {}) {
|
|
283
771
|
const now = input.now ?? new Date();
|
|
284
|
-
const live = liveOccupant(projectRoot, now);
|
|
285
|
-
if (live === null)
|
|
286
|
-
return { allow: true, message: null, occupant: null };
|
|
287
772
|
const incoming = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
|
|
288
|
-
|
|
289
|
-
|
|
773
|
+
const record = readOccupancy(projectRoot);
|
|
774
|
+
const liveness = record === null ? null : occupancyLiveness(record, now);
|
|
775
|
+
const admission = record === null ? "stranger" : occupancyAdmission(record, incoming, now);
|
|
776
|
+
if (record !== null && liveness === "age-capped" && admission !== "stranger") {
|
|
777
|
+
// Refuse the capped holder rather than warn it (#3599). Its tree is now
|
|
778
|
+
// unheld, so allowing the write would let the very bearer the cap exists to
|
|
779
|
+
// bound keep mutating a worktree a peer may claim between this allow and
|
|
780
|
+
// the write itself. On gated writes the identity comes from the host
|
|
781
|
+
// payload, so the holder cannot present a stranger's id to dodge this.
|
|
782
|
+
//
|
|
783
|
+
// A granted child is refused on the same footing (#3755). Its grant is
|
|
784
|
+
// derived from this lease, so once the lease is gone the grant authorizes
|
|
785
|
+
// writes to a tree nobody holds — the exact bypass the cap exists to close,
|
|
786
|
+
// one hop removed.
|
|
787
|
+
return {
|
|
788
|
+
allow: false,
|
|
789
|
+
message: formatOccupancyAgeCapRemediation(record, now),
|
|
790
|
+
occupant: null,
|
|
791
|
+
refreshed: false,
|
|
792
|
+
warning: null,
|
|
793
|
+
admitted: null,
|
|
794
|
+
grant: null,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
if (record === null || liveness !== "live") {
|
|
798
|
+
return {
|
|
799
|
+
allow: true,
|
|
800
|
+
message: null,
|
|
801
|
+
occupant: null,
|
|
802
|
+
refreshed: false,
|
|
803
|
+
warning: null,
|
|
804
|
+
admitted: null,
|
|
805
|
+
grant: null,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
const live = record;
|
|
809
|
+
if (admission === "stranger") {
|
|
810
|
+
return {
|
|
811
|
+
allow: false,
|
|
812
|
+
message: formatOccupancyRemediation(live, now),
|
|
813
|
+
occupant: live,
|
|
814
|
+
refreshed: false,
|
|
815
|
+
warning: null,
|
|
816
|
+
admitted: null,
|
|
817
|
+
grant: null,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
if (admission === "member") {
|
|
821
|
+
// A member's write keeps the lease alive (#3755). The lease answers "who may
|
|
822
|
+
// mutate this tree right now", and a tree a granted child is actively
|
|
823
|
+
// writing is in use — letting it lapse would hand the worktree to a peer
|
|
824
|
+
// mid-edit, which is the loss the TTL exists to prevent, not the abandonment
|
|
825
|
+
// it exists to detect. Two bounds still hold: `claimedAt` is untouched, so
|
|
826
|
+
// the absolute age cap is unmoved, and the grant expires on its own clock.
|
|
827
|
+
const memberAgeMs = now.getTime() - live.heartbeatAt.getTime();
|
|
828
|
+
const memberWarning = memberAgeMs >= OCCUPANCY_STALE_WARN_MS ? formatOccupancyStaleWarning(live, now) : null;
|
|
829
|
+
if (input.refresh !== true || memberAgeMs < OCCUPANCY_REFRESH_AFTER_MS) {
|
|
830
|
+
return {
|
|
831
|
+
allow: true,
|
|
832
|
+
message: null,
|
|
833
|
+
occupant: live,
|
|
834
|
+
refreshed: false,
|
|
835
|
+
warning: memberWarning,
|
|
836
|
+
admitted: "member",
|
|
837
|
+
grant: occupancyGrantFor(live, incoming, now),
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
const memberOutcome = restampOccupancyHeartbeat(projectRoot, live.sessionId, now, true, input.lockDeps, incoming);
|
|
841
|
+
if (memberOutcome.status !== "refreshed") {
|
|
842
|
+
// Same re-decide as the owner path: contention says nothing about who
|
|
843
|
+
// holds the lease now, so ask the file rather than the pre-lock snapshot.
|
|
844
|
+
return evaluateOccupancyWriteGate(projectRoot, { ...input, now, refresh: false });
|
|
845
|
+
}
|
|
846
|
+
return {
|
|
847
|
+
allow: true,
|
|
848
|
+
message: null,
|
|
849
|
+
occupant: memberOutcome.record,
|
|
850
|
+
refreshed: true,
|
|
851
|
+
warning: null,
|
|
852
|
+
admitted: "member",
|
|
853
|
+
grant: occupancyGrantFor(memberOutcome.record, incoming, now),
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
const ageMs = now.getTime() - live.heartbeatAt.getTime();
|
|
857
|
+
const warning = ageMs >= OCCUPANCY_STALE_WARN_MS ? formatOccupancyStaleWarning(live, now) : null;
|
|
858
|
+
if (input.refresh !== true || ageMs < OCCUPANCY_REFRESH_AFTER_MS) {
|
|
859
|
+
return {
|
|
860
|
+
allow: true,
|
|
861
|
+
message: null,
|
|
862
|
+
occupant: live,
|
|
863
|
+
refreshed: false,
|
|
864
|
+
warning,
|
|
865
|
+
admitted: "owner",
|
|
866
|
+
grant: null,
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
const outcome = restampOccupancyHeartbeat(projectRoot, live.sessionId, now, true, input.lockDeps);
|
|
870
|
+
if (outcome.status !== "refreshed") {
|
|
871
|
+
// Neither failure leaves the pre-lock record usable. `lost` says the lease
|
|
872
|
+
// changed hands outright. `unavailable` says only that the lock could not
|
|
873
|
+
// be taken — but a peer takeover is one of the things that holds it, so a
|
|
874
|
+
// re-stamp that blocks until timeout hides the same handover. Decide
|
|
875
|
+
// against what is on disk now instead of the snapshot read at 598: a lease
|
|
876
|
+
// still ours under contention re-allows exactly as before (#3736), while a
|
|
877
|
+
// replacement owner wins (#3599). Re-entry cannot recurse — refresh is off.
|
|
878
|
+
return evaluateOccupancyWriteGate(projectRoot, { ...input, now, refresh: false });
|
|
879
|
+
}
|
|
880
|
+
return {
|
|
881
|
+
allow: true,
|
|
882
|
+
message: null,
|
|
883
|
+
occupant: outcome.record,
|
|
884
|
+
refreshed: true,
|
|
885
|
+
// `warning` was measured against the pre-refresh heartbeat. Returning it
|
|
886
|
+
// beside a successful re-stamp would tell the owner its lease is going
|
|
887
|
+
// stale on the very write that renewed it.
|
|
888
|
+
warning: null,
|
|
889
|
+
admitted: "owner",
|
|
890
|
+
grant: null,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Re-stamp an existing live lease held by `sessionId`. Reports `lost` when the
|
|
895
|
+
* lease is gone, expired, or now held by someone else — refresh must never
|
|
896
|
+
* claim or resurrect a lease, only extend one the caller already holds.
|
|
897
|
+
*
|
|
898
|
+
* Lock contention and IO errors report `unavailable` rather than `lost`: they
|
|
899
|
+
* observed no owner at all, so they are not evidence of replacement. `heartbeat`
|
|
900
|
+
* says so and leaves the lease alone; the write gate re-reads the file rather
|
|
901
|
+
* than trusting either its own stale snapshot or a denial the lock never earned.
|
|
902
|
+
*/
|
|
903
|
+
function restampOccupancyHeartbeat(projectRoot, sessionId, now, markWrite, lockDeps,
|
|
904
|
+
/** Refresh on behalf of this granted member rather than the owner (#3755). */
|
|
905
|
+
memberSessionId) {
|
|
906
|
+
try {
|
|
907
|
+
return withOccupancyLock(projectRoot, (fence) => {
|
|
908
|
+
const current = readOccupancy(projectRoot);
|
|
909
|
+
if (current === null || current.sessionId !== sessionId)
|
|
910
|
+
return { status: "lost" };
|
|
911
|
+
if (isOccupancyExpired(current, now))
|
|
912
|
+
return { status: "lost" };
|
|
913
|
+
// Re-check membership under the lock: the grant read before the wait may
|
|
914
|
+
// have been revoked or expired while it ran (#3755).
|
|
915
|
+
if (memberSessionId !== undefined &&
|
|
916
|
+
occupancyGrantFor(current, memberSessionId, now) === null) {
|
|
917
|
+
return { status: "lost" };
|
|
918
|
+
}
|
|
919
|
+
const record = writeOccupancyRecord(projectRoot, {
|
|
920
|
+
sessionId: current.sessionId,
|
|
921
|
+
worktreePath: current.worktreePath,
|
|
922
|
+
intent: current.intent,
|
|
923
|
+
claimedAt: current.claimedAt,
|
|
924
|
+
heartbeatAt: now,
|
|
925
|
+
lastWriteAt: markWrite ? now : current.lastWriteAt,
|
|
926
|
+
host: current.host,
|
|
927
|
+
address: current.address,
|
|
928
|
+
retainCapable: current.retainCapable,
|
|
929
|
+
joinProtocol: current.joinProtocol,
|
|
930
|
+
// Refresh extends the lease, so it also prunes the grants that died
|
|
931
|
+
// while it ran; expiry is already refused on read (#3755).
|
|
932
|
+
grants: liveOccupancyGrants(current, now),
|
|
933
|
+
}, fence);
|
|
934
|
+
return { status: "refreshed", record };
|
|
935
|
+
}, lockDeps);
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
return { status: "unavailable" };
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Refresh the caller's own live lease (#3599). Discoverable counterpart to the
|
|
943
|
+
* automatic write-gate refresh, for sessions whose work is long and quiet:
|
|
944
|
+
* reading, building, or waiting produces no gated write to ride on.
|
|
945
|
+
*
|
|
946
|
+
* Never claims and never mints an owner — an unheld or foreign lease is denied.
|
|
947
|
+
*/
|
|
948
|
+
export function heartbeatOccupancy(projectRoot, input = {}) {
|
|
949
|
+
const now = input.now ?? new Date();
|
|
950
|
+
const path = occupancyPath(projectRoot);
|
|
951
|
+
const caller = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
|
|
952
|
+
if (caller.length === 0) {
|
|
953
|
+
return {
|
|
954
|
+
action: "denied",
|
|
955
|
+
sessionId: "",
|
|
956
|
+
record: readOccupancy(projectRoot),
|
|
957
|
+
path,
|
|
958
|
+
message: "occupancy:heartbeat needs the owner id: pass --session-id <your-session-id> or set " +
|
|
959
|
+
"DEFT_SESSION_ID. Refresh extends an existing lease and never mints an owner.",
|
|
960
|
+
code: 2,
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
const existing = readOccupancy(projectRoot);
|
|
964
|
+
const live = existing !== null && !isOccupancyExpired(existing, now) ? existing : null;
|
|
965
|
+
if (live === null) {
|
|
966
|
+
const capped = existing !== null &&
|
|
967
|
+
existing.sessionId === caller &&
|
|
968
|
+
occupancyLiveness(existing, now) === "age-capped";
|
|
969
|
+
return {
|
|
970
|
+
action: "denied",
|
|
971
|
+
sessionId: caller,
|
|
972
|
+
record: capped ? existing : null,
|
|
973
|
+
path,
|
|
974
|
+
message: capped && existing !== null
|
|
975
|
+
? formatOccupancyAgeCapRemediation(existing, now)
|
|
976
|
+
: "occupancy:heartbeat found no live lease to refresh. Claim one with " +
|
|
977
|
+
`\`deft session:start --session-id=${caller}\`.`,
|
|
978
|
+
code: 1,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
if (live.sessionId !== caller) {
|
|
982
|
+
return {
|
|
983
|
+
action: "denied",
|
|
984
|
+
sessionId: caller,
|
|
985
|
+
record: live,
|
|
986
|
+
path,
|
|
987
|
+
message: membershipOwnerDenial(live, caller, now, "occupancy:heartbeat"),
|
|
988
|
+
code: 1,
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
const outcome = restampOccupancyHeartbeat(projectRoot, caller, now, false, input.lockDeps);
|
|
992
|
+
if (outcome.status !== "refreshed") {
|
|
993
|
+
return {
|
|
994
|
+
action: "denied",
|
|
995
|
+
sessionId: caller,
|
|
996
|
+
record: readOccupancy(projectRoot),
|
|
997
|
+
path,
|
|
998
|
+
message: outcome.status === "lost"
|
|
999
|
+
? "occupancy:heartbeat could not refresh the lease: it expired or changed owner " +
|
|
1000
|
+
"while the refresh was running."
|
|
1001
|
+
: "occupancy:heartbeat could not take the occupancy lock, so the lease is " +
|
|
1002
|
+
"unchanged and still yours. Retry in a moment.",
|
|
1003
|
+
code: 1,
|
|
1004
|
+
};
|
|
290
1005
|
}
|
|
1006
|
+
const record = outcome.record;
|
|
291
1007
|
return {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
1008
|
+
action: "heartbeat",
|
|
1009
|
+
sessionId: record.sessionId,
|
|
1010
|
+
record,
|
|
1011
|
+
path,
|
|
1012
|
+
message: `occupancy heartbeat session ${record.sessionId} (intent=${record.intent}, ` +
|
|
1013
|
+
`${occupancyClockLine(record)})`,
|
|
1014
|
+
code: 0,
|
|
295
1015
|
};
|
|
296
1016
|
}
|
|
297
1017
|
/** Close-out identity comes from the launch manifest or DEFT_SESSION_ID — never occupancy.json. */
|
|
@@ -306,7 +1026,9 @@ export function releaseSwarmOccupancy(projectRoot, input = {}) {
|
|
|
306
1026
|
record: occupant,
|
|
307
1027
|
path: occupancyPath(projectRoot),
|
|
308
1028
|
message: "swarm close-out has no occupancy_session_id (manifest missing or predates the field) " +
|
|
309
|
-
"and DEFT_SESSION_ID is unset.
|
|
1029
|
+
"and DEFT_SESSION_ID is unset. Re-establish an aligned owner with " +
|
|
1030
|
+
"session:start --steal --confirm --occupant <reported-session-id> " +
|
|
1031
|
+
"--session-id=<your-session-id>.",
|
|
310
1032
|
code: 1,
|
|
311
1033
|
};
|
|
312
1034
|
}
|
|
@@ -343,6 +1065,9 @@ function parseOccupancy(payload, fallbackWorktree) {
|
|
|
343
1065
|
const heartbeatAt = parseTimestamp(obj.heartbeat_at) ?? claimedAt;
|
|
344
1066
|
if (claimedAt === null || heartbeatAt === null)
|
|
345
1067
|
return null;
|
|
1068
|
+
// Additive and optional (#3599): records written before the field exists,
|
|
1069
|
+
// and by older CLIs, stay readable — absence means "no recorded write".
|
|
1070
|
+
const lastWriteAt = parseTimestamp(obj.last_write_at);
|
|
346
1071
|
const joinRaw = typeof obj.join_protocol === "string" ? obj.join_protocol : "none";
|
|
347
1072
|
const joinProtocol = OCCUPANCY_JOIN_PROTOCOLS.includes(joinRaw)
|
|
348
1073
|
? joinRaw
|
|
@@ -357,13 +1082,63 @@ function parseOccupancy(payload, fallbackWorktree) {
|
|
|
357
1082
|
intent,
|
|
358
1083
|
claimedAt,
|
|
359
1084
|
heartbeatAt,
|
|
1085
|
+
lastWriteAt,
|
|
360
1086
|
host: typeof obj.host === "string" && obj.host.length > 0 ? obj.host : "none",
|
|
361
1087
|
address: typeof obj.address === "string" && obj.address.length > 0 ? obj.address : "none",
|
|
362
1088
|
retainCapable: obj.retain_capable === true,
|
|
363
1089
|
joinProtocol,
|
|
1090
|
+
grants: parseOccupancyGrants(obj.grants, sessionId, worktreePath),
|
|
364
1091
|
raw: { ...obj },
|
|
365
1092
|
};
|
|
366
1093
|
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Read the grant list (#3755). A malformed entry is dropped rather than failing
|
|
1096
|
+
* the whole record: the lease still has an owner, and losing one grant denies a
|
|
1097
|
+
* child a write it can ask for again, while losing the record would strand the
|
|
1098
|
+
* tree. Expiry is parsed but not judged here — `occupancyGrantFor` decides that
|
|
1099
|
+
* against a clock, so an expired grant stays visible to the owner reading its
|
|
1100
|
+
* own lease and is still refused on admission.
|
|
1101
|
+
*/
|
|
1102
|
+
function parseOccupancyGrants(payload, ownerSessionId, fallbackWorktree) {
|
|
1103
|
+
if (!Array.isArray(payload))
|
|
1104
|
+
return [];
|
|
1105
|
+
const grants = [];
|
|
1106
|
+
for (const entry of payload.slice(0, OCCUPANCY_MAX_GRANTS)) {
|
|
1107
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
|
|
1108
|
+
continue;
|
|
1109
|
+
const obj = entry;
|
|
1110
|
+
const childSessionId = typeof obj.child_session_id === "string" ? obj.child_session_id.trim() : "";
|
|
1111
|
+
if (childSessionId.length === 0)
|
|
1112
|
+
continue;
|
|
1113
|
+
const owner = typeof obj.owner_session_id === "string" ? obj.owner_session_id.trim() : "";
|
|
1114
|
+
// A grant naming a different owner is residue from a lease that has since
|
|
1115
|
+
// changed hands; the current occupant never issued it, so it admits nobody.
|
|
1116
|
+
if (owner.length === 0 || owner !== ownerSessionId)
|
|
1117
|
+
continue;
|
|
1118
|
+
const expiresAt = parseTimestamp(obj.expires_at);
|
|
1119
|
+
if (expiresAt === null)
|
|
1120
|
+
continue;
|
|
1121
|
+
const roleRaw = typeof obj.role === "string" ? obj.role : "";
|
|
1122
|
+
if (!SWARM_WORKER_ROLES.includes(roleRaw))
|
|
1123
|
+
continue;
|
|
1124
|
+
const joinRaw = typeof obj.join_protocol === "string" ? obj.join_protocol : "none";
|
|
1125
|
+
grants.push({
|
|
1126
|
+
ownerSessionId: owner,
|
|
1127
|
+
childSessionId,
|
|
1128
|
+
worktreePath: typeof obj.worktree_path === "string" && obj.worktree_path.trim().length > 0
|
|
1129
|
+
? obj.worktree_path
|
|
1130
|
+
: fallbackWorktree,
|
|
1131
|
+
role: roleRaw,
|
|
1132
|
+
expiresAt,
|
|
1133
|
+
host: typeof obj.host === "string" && obj.host.length > 0 ? obj.host : "none",
|
|
1134
|
+
address: typeof obj.address === "string" && obj.address.length > 0 ? obj.address : "none",
|
|
1135
|
+
joinProtocol: OCCUPANCY_JOIN_PROTOCOLS.includes(joinRaw)
|
|
1136
|
+
? joinRaw
|
|
1137
|
+
: "none",
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
return grants;
|
|
1141
|
+
}
|
|
367
1142
|
function occupancyPayload(record) {
|
|
368
1143
|
return {
|
|
369
1144
|
schemaVersion: OCCUPANCY_SCHEMA_VERSION,
|
|
@@ -372,10 +1147,28 @@ function occupancyPayload(record) {
|
|
|
372
1147
|
intent: record.intent,
|
|
373
1148
|
claimed_at: timestampIso(record.claimedAt),
|
|
374
1149
|
heartbeat_at: timestampIso(record.heartbeatAt),
|
|
1150
|
+
...(record.lastWriteAt === null ? {} : { last_write_at: timestampIso(record.lastWriteAt) }),
|
|
375
1151
|
host: record.host,
|
|
376
1152
|
address: record.address,
|
|
377
1153
|
retain_capable: record.retainCapable,
|
|
378
1154
|
join_protocol: record.joinProtocol,
|
|
1155
|
+
// Additive (#3755): absent on records written before membership existed,
|
|
1156
|
+
// and omitted again when empty so an ungranted lease keeps its old shape.
|
|
1157
|
+
...(record.grants.length === 0
|
|
1158
|
+
? {}
|
|
1159
|
+
: { grants: record.grants.map((grant) => occupancyGrantPayload(grant)) }),
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
function occupancyGrantPayload(grant) {
|
|
1163
|
+
return {
|
|
1164
|
+
owner_session_id: grant.ownerSessionId,
|
|
1165
|
+
child_session_id: grant.childSessionId,
|
|
1166
|
+
worktree_path: grant.worktreePath,
|
|
1167
|
+
role: grant.role,
|
|
1168
|
+
expires_at: timestampIso(grant.expiresAt),
|
|
1169
|
+
host: grant.host,
|
|
1170
|
+
address: grant.address,
|
|
1171
|
+
join_protocol: grant.joinProtocol,
|
|
379
1172
|
};
|
|
380
1173
|
}
|
|
381
1174
|
function writeOccupancyRecord(projectRoot, record, fence) {
|