@deftai/directive-core 0.107.0 → 0.109.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/cache/operations.js +1 -1
- package/dist/check/cached-orchestrator.js +28 -3
- package/dist/check/gate-lists.d.ts +14 -0
- package/dist/check/gate-lists.js +26 -3
- package/dist/check/named-cause.js +18 -3
- package/dist/check/session-completed-ac.d.ts +1 -1
- package/dist/check/session-completed-ac.js +1 -1
- package/dist/codebase/provider.js +7 -1
- package/dist/consumer-check-contract/evaluate.d.ts +47 -0
- package/dist/consumer-check-contract/evaluate.js +181 -15
- package/dist/delivery-attempt/evaluate.d.ts +9 -1
- package/dist/delivery-attempt/evaluate.js +69 -0
- package/dist/delivery-attempt/handoff.js +1 -1
- package/dist/delivery-attempt/index.d.ts +1 -1
- package/dist/delivery-attempt/index.js +1 -1
- package/dist/deposit/live-procedure-exclusions.d.ts +18 -0
- package/dist/deposit/live-procedure-exclusions.js +110 -0
- package/dist/deposit/live-procedure-targets.d.ts +45 -0
- package/dist/deposit/live-procedure-targets.js +274 -0
- package/dist/deposit/python-free.d.ts +6 -0
- package/dist/deposit/python-free.js +15 -0
- package/dist/deposit/rewrite-deposit-links.d.ts +42 -0
- package/dist/deposit/rewrite-deposit-links.js +148 -0
- package/dist/deposit/run-stage-content-pack.d.ts +2 -0
- package/dist/deposit/run-stage-content-pack.js +3 -0
- package/dist/deposit/stage-content-pack.d.ts +17 -0
- package/dist/deposit/stage-content-pack.js +91 -0
- package/dist/design-critique/citation-grammar.d.ts +60 -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 +277 -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 +89 -0
- package/dist/hooks/classify/host-session-identity.js +467 -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 +9 -0
- package/dist/hooks/classify/paths.js +39 -0
- package/dist/hooks/classify/stdin.d.ts +5 -0
- package/dist/hooks/classify/stdin.js +94 -1
- package/dist/hooks/dest-form.d.ts +20 -1
- package/dist/hooks/dest-form.js +158 -21
- package/dist/hooks/dispatcher.d.ts +58 -3
- package/dist/hooks/dispatcher.js +762 -73
- package/dist/hooks/fixtures/cases.d.ts +22 -2
- package/dist/hooks/fixtures/cases.js +228 -0
- package/dist/hooks/git-destructive-log.d.ts +32 -0
- package/dist/hooks/git-destructive-log.js +46 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.js +2 -0
- package/dist/hooks/scope.js +3 -1
- package/dist/hooks/shell-write-targets.d.ts +10 -0
- package/dist/hooks/shell-write-targets.js +274 -0
- package/dist/hooks/tools.d.ts +20 -2
- package/dist/hooks/tools.js +30 -1
- package/dist/init-deposit/agent-hooks.d.ts +2 -1
- package/dist/init-deposit/agent-hooks.js +8 -1
- package/dist/init-deposit/gitignore.d.ts +7 -0
- package/dist/init-deposit/gitignore.js +24 -0
- package/dist/init-deposit/hook-runtime-travel.d.ts +77 -0
- package/dist/init-deposit/hook-runtime-travel.js +269 -0
- package/dist/init-deposit/init-deposit.js +3 -0
- package/dist/init-deposit/refresh.js +7 -0
- package/dist/init-deposit/runtime-writers.d.ts +14 -0
- package/dist/init-deposit/runtime-writers.js +33 -0
- package/dist/intake/clause-derivation.js +9 -3
- package/dist/intake/issue-ingest.d.ts +6 -1
- package/dist/intake/issue-ingest.js +83 -4
- package/dist/intake/platform-capabilities.d.ts +9 -2
- package/dist/intake/platform-capabilities.js +38 -9
- package/dist/intake/reconcile-issues.js +13 -13
- package/dist/lifecycle/brief-envelope.d.ts +25 -0
- package/dist/lifecycle/brief-envelope.js +42 -0
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.js +1 -0
- package/dist/literal-acceptance/evaluate.js +14 -5
- package/dist/literal-acceptance/index.d.ts +1 -1
- package/dist/literal-acceptance/index.js +1 -1
- package/dist/literal-acceptance/run.d.ts +2 -0
- package/dist/literal-acceptance/run.js +19 -1
- package/dist/orphan-active/candidate-scope.d.ts +53 -0
- package/dist/orphan-active/candidate-scope.js +157 -0
- package/dist/orphan-active/evaluate.d.ts +52 -0
- package/dist/orphan-active/evaluate.js +250 -128
- package/dist/orphan-active/index.d.ts +2 -0
- package/dist/orphan-active/index.js +2 -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/merge-approval-head.js +7 -6
- 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/value-feedback.js +9 -18
- 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-closing-keywords/gh.js +32 -9
- package/dist/pr-closing-keywords/main.d.ts +1 -0
- package/dist/pr-closing-keywords/main.js +68 -3
- package/dist/pr-closing-keywords/types.d.ts +2 -0
- package/dist/pr-merge-readiness/gh.js +32 -9
- package/dist/pr-protected-issues/gh.d.ts +6 -2
- package/dist/pr-protected-issues/gh.js +42 -11
- 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 +14 -3
- package/dist/preflight/evaluate.d.ts +15 -0
- package/dist/preflight/evaluate.js +29 -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 +23 -9
- package/dist/product-first-done-gate/types.js +2 -0
- package/dist/release/cli-drift-report.d.ts +68 -0
- package/dist/release/cli-drift-report.js +189 -0
- package/dist/release/consumer-hard-stops.d.ts +48 -0
- package/dist/release/consumer-hard-stops.js +140 -0
- package/dist/release/consumer-readiness-disclosure.d.ts +18 -0
- package/dist/release/consumer-readiness-disclosure.js +51 -0
- package/dist/release/index.d.ts +3 -0
- package/dist/release/index.js +3 -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 +74 -0
- package/dist/release/run-consumer-readiness.d.ts +15 -0
- package/dist/release/run-consumer-readiness.js +24 -0
- package/dist/release/types.d.ts +20 -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 +186 -4
- package/dist/review-monitor/lease-comment.d.ts +36 -0
- package/dist/review-monitor/lease-comment.js +103 -2
- package/dist/run-summary/types.d.ts +2 -2
- package/dist/scm/build-command.d.ts +2 -2
- package/dist/scm/build-command.js +2 -2
- package/dist/scm/call-shape.d.ts +25 -0
- package/dist/scm/call-shape.js +59 -0
- package/dist/scm/call.d.ts +7 -4
- package/dist/scm/call.js +48 -9
- package/dist/scm/design-critique-chip.d.ts +1 -0
- package/dist/scm/design-critique-chip.js +22 -6
- package/dist/scm/gh-rest.d.ts +34 -13
- package/dist/scm/gh-rest.js +168 -15
- package/dist/scm/index.d.ts +2 -0
- package/dist/scm/index.js +2 -0
- package/dist/scm/readiness.d.ts +2 -0
- package/dist/scm/readiness.js +28 -4
- package/dist/scm/spawn-status.d.ts +33 -0
- package/dist/scm/spawn-status.js +53 -0
- package/dist/scope/acceptance-evidence.d.ts +1 -1
- package/dist/scope/project-definition-sync.js +14 -7
- package/dist/scope/transition.js +4 -14
- package/dist/session/ac-pass-banking.d.ts +2 -2
- package/dist/session/ac-pass-banking.js +2 -2
- package/dist/session/git.d.ts +24 -0
- package/dist/session/git.js +138 -2
- package/dist/session/host-session-owner.d.ts +53 -0
- package/dist/session/host-session-owner.js +89 -0
- package/dist/session/index.d.ts +1 -0
- package/dist/session/index.js +1 -0
- package/dist/session/occupancy.d.ts +259 -13
- package/dist/session/occupancy.js +877 -33
- 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 +5 -1
- package/dist/session/session-start.js +32 -17
- package/dist/session/toolchain-preflight.d.ts +13 -3
- package/dist/session/toolchain-preflight.js +93 -18
- package/dist/session/verify-ac-session-cache.d.ts +2 -2
- package/dist/session/verify-ac-session-cache.js +2 -2
- package/dist/session/verify-session-ritual.d.ts +11 -0
- package/dist/session/verify-session-ritual.js +60 -15
- 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 +8 -0
- package/dist/swarm/launch.js +63 -43
- 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/evaluate/worktrees.js +153 -6
- 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/umbrella-current-shape/index.d.ts +51 -3
- package/dist/umbrella-current-shape/index.js +106 -18
- package/dist/validate-content/deposit-required.d.ts +39 -0
- package/dist/validate-content/deposit-required.js +147 -0
- package/dist/validate-content/index.d.ts +1 -0
- package/dist/validate-content/index.js +1 -0
- package/dist/validate-content/validate-links.d.ts +2 -3
- package/dist/validate-content/validate-links.js +29 -2
- package/dist/vbrief-activate/activate.d.ts +7 -2
- package/dist/vbrief-activate/activate.js +26 -13
- 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-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/verify-ac/clauses.d.ts +62 -2
- package/dist/verify-ac/clauses.js +183 -111
- package/dist/verify-ac/evaluate.d.ts +9 -0
- package/dist/verify-ac/evaluate.js +32 -9
- 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/deposit-closure.d.ts +23 -0
- package/dist/verify-source/deposit-closure.js +162 -0
- package/dist/verify-source/index.d.ts +2 -0
- package/dist/verify-source/index.js +2 -0
- 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/verify-source/semantic-single-source.d.ts +36 -0
- package/dist/verify-source/semantic-single-source.js +349 -0
- package/package.json +7 -3
|
@@ -4,14 +4,34 @@
|
|
|
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
6
|
* ritual-state.json. Ordinary end is occupancy:release / session:end (#3604).
|
|
7
|
-
*
|
|
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.
|
|
8
21
|
*
|
|
9
22
|
* Concurrency model:
|
|
10
23
|
* - Assumptions: local filesystem; cooperating processes on one machine.
|
|
11
24
|
* - Guarantees: mutual exclusion under crash-free operation; detect-and-abort
|
|
12
25
|
* if the sidecar lock is compromised (fence before rename/unlink).
|
|
13
26
|
* - Non-goals: network filesystems; Byzantine processes; perfect off-Linux
|
|
14
|
-
* 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.
|
|
15
35
|
*/
|
|
16
36
|
import { randomUUID } from "node:crypto";
|
|
17
37
|
import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
@@ -19,41 +39,244 @@ import { dirname, join, resolve } from "node:path";
|
|
|
19
39
|
import { containedRemove, containedWrite } from "../fs/contained-write.js";
|
|
20
40
|
import { assertWriteTargetSafe } from "../fs/projection-containment.js";
|
|
21
41
|
import { assertAppendLockOwned, withAppendLock } from "../slice/lock.js";
|
|
42
|
+
import { SWARM_WORKER_ROLES } from "../swarm/routing.js";
|
|
43
|
+
import { ambientHostSessionOwner } from "./host-session-owner.js";
|
|
22
44
|
import { stableJson } from "./json.js";
|
|
23
45
|
import { parseTimestamp, timestampIso } from "./time.js";
|
|
24
46
|
export const OCCUPANCY_SCHEMA_VERSION = 1;
|
|
25
47
|
export const OCCUPANCY_RELPATH = [".deft", "occupancy.json"];
|
|
26
48
|
/** Crash recovery TTL: 20 minutes without heartbeat (15–30 window). */
|
|
27
49
|
export const OCCUPANCY_TTL_MS = 20 * 60 * 1000;
|
|
50
|
+
/**
|
|
51
|
+
* Owner-allow re-stamp floor (#3599). The write gate runs on every gated write,
|
|
52
|
+
* so refreshing unconditionally would rewrite the lease file per keystroke-scale
|
|
53
|
+
* event. A quarter of the TTL bounds that without shortening the safe window:
|
|
54
|
+
* a write at any age past this floor resets the clock, so an owner that writes
|
|
55
|
+
* at least once per TTL never expires.
|
|
56
|
+
*/
|
|
57
|
+
export const OCCUPANCY_REFRESH_AFTER_MS = OCCUPANCY_TTL_MS / 4;
|
|
58
|
+
/** Owner-allow staleness warning floor: three quarters of the TTL (#3599). */
|
|
59
|
+
export const OCCUPANCY_STALE_WARN_MS = (OCCUPANCY_TTL_MS * 3) / 4;
|
|
60
|
+
/**
|
|
61
|
+
* Absolute lease age cap, keyed on `claimedAt` and independent of refresh
|
|
62
|
+
* (#3599). Occupancy admits whoever presents the occupant's session id, so
|
|
63
|
+
* "the owner is still writing" only proves that some process holds that
|
|
64
|
+
* string. Without a bound on claim age, refresh would turn the heartbeat TTL —
|
|
65
|
+
* the sole mechanism that reclaims a worktree from a dead session — into
|
|
66
|
+
* something a writer can extend forever.
|
|
67
|
+
*
|
|
68
|
+
* Thirty-six TTLs is twelve hours, sized by the stalled owner rather than the
|
|
69
|
+
* busy one. Refresh keys on writes, so an agent that finishes overnight and
|
|
70
|
+
* waits for its operator is alive, correct, and silent — it stops refreshing
|
|
71
|
+
* while staying entirely legitimate. Twelve hours spans a 23:00 dispatch to a
|
|
72
|
+
* 09:00 handoff and still bounds reclaim well inside a day. Reaching the cap
|
|
73
|
+
* costs the owner one re-claim, not its work.
|
|
74
|
+
*
|
|
75
|
+
* Known limitation: a pure time cap cannot tell a stalled-but-live owner from a
|
|
76
|
+
* dead one, because the only liveness signal on this path is a write. If that
|
|
77
|
+
* ambiguity starts to bite, the answer is a liveness signal that needs no write
|
|
78
|
+
* — an explicit parked state, or refresh on non-write activity — not a larger
|
|
79
|
+
* number here.
|
|
80
|
+
*/
|
|
81
|
+
export const OCCUPANCY_MAX_LEASE_MS = OCCUPANCY_TTL_MS * 36;
|
|
28
82
|
export const OCCUPANCY_INTENTS = ["mutation", "swarm", "review"];
|
|
29
83
|
export const OCCUPANCY_JOIN_PROTOCOLS = ["none", "heartbeat-file", "parent-message"];
|
|
84
|
+
/**
|
|
85
|
+
* Default life of a grant (#3755), sized by one dispatched unit of work:
|
|
86
|
+
* implement, open the PR, run the review cycle. Four hours is a third of the
|
|
87
|
+
* absolute lease cap, so a grant that outlives its child still dies well inside
|
|
88
|
+
* the lease that issued it, and re-granting costs the owner one command.
|
|
89
|
+
*/
|
|
90
|
+
export const OCCUPANCY_GRANT_TTL_MS = 4 * 60 * 60 * 1000;
|
|
91
|
+
/**
|
|
92
|
+
* Grants a single lease may carry (#3755). Bounded because the list is rewritten
|
|
93
|
+
* into the lease file on every touch and the topology it serves is a nuclear
|
|
94
|
+
* family (#3155), not a mesh — a lease needing more than this is a design
|
|
95
|
+
* problem, not a capacity one.
|
|
96
|
+
*/
|
|
97
|
+
export const OCCUPANCY_MAX_GRANTS = 32;
|
|
30
98
|
export function occupancyPath(projectRoot) {
|
|
31
99
|
return join(resolve(projectRoot), ...OCCUPANCY_RELPATH);
|
|
32
100
|
}
|
|
33
101
|
export function heartbeatAgeSeconds(record, now = new Date()) {
|
|
34
102
|
return Math.max(0, Math.round((now.getTime() - record.heartbeatAt.getTime()) / 1000));
|
|
35
103
|
}
|
|
36
|
-
|
|
37
|
-
|
|
104
|
+
/** Age of the occupant's last recorded product write, or null when none (#3599). */
|
|
105
|
+
export function lastWriteAgeSeconds(record, now = new Date()) {
|
|
106
|
+
if (record.lastWriteAt === null)
|
|
107
|
+
return null;
|
|
108
|
+
return Math.max(0, Math.round((now.getTime() - record.lastWriteAt.getTime()) / 1000));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Human phrase for how recently the occupant wrote (#3599). Heartbeat age alone
|
|
112
|
+
* cannot distinguish an occupant mid-edit from one that merely claimed and left.
|
|
113
|
+
*/
|
|
114
|
+
export function formatLastWritePhrase(record, now = new Date()) {
|
|
115
|
+
const age = lastWriteAgeSeconds(record, now);
|
|
116
|
+
return age === null ? "no recorded write" : `last write ${age}s ago`;
|
|
117
|
+
}
|
|
118
|
+
/** Age of the lease itself, measured from the claim that opened it (#3599). */
|
|
119
|
+
export function leaseAgeSeconds(record, now = new Date()) {
|
|
120
|
+
return Math.max(0, Math.round((now.getTime() - record.claimedAt.getTime()) / 1000));
|
|
121
|
+
}
|
|
122
|
+
export function occupancyLiveness(record, now = new Date(), ttlMs = OCCUPANCY_TTL_MS, maxLeaseMs = OCCUPANCY_MAX_LEASE_MS) {
|
|
123
|
+
// The cap is checked first because it is the answer that survives (#3599).
|
|
124
|
+
// A lease can be both, and then the stale reading is actively misleading:
|
|
125
|
+
// it sends the holder to refresh, which a capped lease cannot accept. Order
|
|
126
|
+
// also decides the write gate — the capped-holder refusal below keys on this
|
|
127
|
+
// value, so reading a doubly-dead lease as merely stale would give the more
|
|
128
|
+
// dead lease the more permissive answer.
|
|
129
|
+
if (now.getTime() - record.claimedAt.getTime() > maxLeaseMs)
|
|
130
|
+
return "age-capped";
|
|
131
|
+
if (now.getTime() - record.heartbeatAt.getTime() > ttlMs)
|
|
132
|
+
return "heartbeat-stale";
|
|
133
|
+
return "live";
|
|
134
|
+
}
|
|
135
|
+
export function isOccupancyExpired(record, now = new Date(), ttlMs = OCCUPANCY_TTL_MS, maxLeaseMs = OCCUPANCY_MAX_LEASE_MS) {
|
|
136
|
+
return occupancyLiveness(record, now, ttlMs, maxLeaseMs) !== "live";
|
|
137
|
+
}
|
|
138
|
+
/** Grants that still admit somebody (#3755). An expired grant admits nobody. */
|
|
139
|
+
export function liveOccupancyGrants(record, now = new Date()) {
|
|
140
|
+
return record.grants.filter((grant) => grant.expiresAt.getTime() > now.getTime());
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The grant admitting `sessionId`, or null (#3755). Expiry is refused here, on
|
|
144
|
+
* read, rather than trusted to a sweep: nothing guarantees a lease is ever
|
|
145
|
+
* touched again after the grant is written, so a grant that outlived its clock
|
|
146
|
+
* must stop admitting the moment it is read, not the next time it is rewritten.
|
|
147
|
+
*/
|
|
148
|
+
export function occupancyGrantFor(record, sessionId, now = new Date()) {
|
|
149
|
+
const presented = sessionId.trim();
|
|
150
|
+
if (presented.length === 0)
|
|
151
|
+
return null;
|
|
152
|
+
// The owner holds the lease outright; a grant naming it would add nothing.
|
|
153
|
+
if (presented === record.sessionId)
|
|
154
|
+
return null;
|
|
155
|
+
return (liveOccupancyGrants(record, now).find((grant) => grant.childSessionId === presented) ?? null);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* What the presented id is to this lease (#3755). Deliberately not a liveness
|
|
159
|
+
* question: it answers who, and callers pair it with `occupancyLiveness` to
|
|
160
|
+
* answer whether the lease is still worth anything.
|
|
161
|
+
*/
|
|
162
|
+
export function occupancyAdmission(record, sessionId, now = new Date()) {
|
|
163
|
+
const presented = sessionId.trim();
|
|
164
|
+
if (presented.length === 0)
|
|
165
|
+
return "stranger";
|
|
166
|
+
if (presented === record.sessionId)
|
|
167
|
+
return "owner";
|
|
168
|
+
return occupancyGrantFor(record, presented, now) === null ? "stranger" : "member";
|
|
169
|
+
}
|
|
170
|
+
// Session ids reach remediation text from operator flags, host environments and
|
|
171
|
+
// whatever a peer wrote into the lease, so a value can carry whitespace or shell
|
|
172
|
+
// metacharacters. Only a value a shell would take as one bare token is inlined
|
|
173
|
+
// into a printed command; anything else keeps its placeholder, because the right
|
|
174
|
+
// quoting differs per shell and a mis-parsed copyable command is worse than one
|
|
175
|
+
// the reader has to fill in. The id itself is still named in the prose above.
|
|
176
|
+
// A leading dash is excluded as well: every CLI parser here reads such a value
|
|
177
|
+
// as another option, so `--occupant --weird-id` fails argument parsing even
|
|
178
|
+
// though the shell itself would have passed the token through intact.
|
|
179
|
+
const SHELL_SAFE_SESSION_ID = /^(?!-)[A-Za-z0-9_.:+=,/-]+$/;
|
|
180
|
+
function commandSessionId(sessionId, placeholder) {
|
|
181
|
+
return SHELL_SAFE_SESSION_ID.test(sessionId) ? sessionId : placeholder;
|
|
38
182
|
}
|
|
39
183
|
function occupancyClockLine(record) {
|
|
40
|
-
|
|
184
|
+
const lastWrite = record.lastWriteAt === null ? "" : ` last_write_at=${timestampIso(record.lastWriteAt)}`;
|
|
185
|
+
return `claimed_at=${timestampIso(record.claimedAt)} heartbeat_at=${timestampIso(record.heartbeatAt)}${lastWrite}`;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Warn the holder that its own lease is inside the staleness window (#3599).
|
|
189
|
+
* Without this the owner learns it went stale only when a peer steals the lease.
|
|
190
|
+
*/
|
|
191
|
+
export function formatOccupancyStaleWarning(record, now = new Date(), ttlMs = OCCUPANCY_TTL_MS) {
|
|
192
|
+
const age = heartbeatAgeSeconds(record, now);
|
|
193
|
+
return (`Occupancy lease for session ${record.sessionId} has not beaten for ${age}s of its ` +
|
|
194
|
+
`${Math.round(ttlMs / 1000)}s window; another session may read it as abandoned. ` +
|
|
195
|
+
`Refresh it with \`deft occupancy:heartbeat --session-id=${commandSessionId(record.sessionId, "<your-session-id>")}\`.`);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Tell the holder its lease aged out of the absolute cap (#3599). Distinct
|
|
199
|
+
* remediation from a stale heartbeat: beating harder cannot help, because the
|
|
200
|
+
* lease is gone rather than merely quiet, so the answer is to re-claim.
|
|
201
|
+
*/
|
|
202
|
+
export function formatOccupancyAgeCapRemediation(record, now = new Date(), maxLeaseMs = OCCUPANCY_MAX_LEASE_MS) {
|
|
203
|
+
const hours = Math.round(maxLeaseMs / (60 * 60 * 1000));
|
|
204
|
+
return (`Occupancy lease for session ${record.sessionId} passed its ${hours}h absolute age cap ` +
|
|
205
|
+
`(claimed ${leaseAgeSeconds(record, now)}s ago, ${occupancyClockLine(record)}), so this ` +
|
|
206
|
+
"worktree is no longer held and a peer may claim it at any moment. Heartbeats cannot " +
|
|
207
|
+
"extend a capped lease — re-claim the worktree with " +
|
|
208
|
+
`\`deft session:start --session-id=${commandSessionId(record.sessionId, "<your-session-id>")}\` before writing again.`);
|
|
41
209
|
}
|
|
42
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Tell a refused caller who holds the lease and what it can actually run.
|
|
212
|
+
*
|
|
213
|
+
* `presented` is the id the refused caller offered (#3873). Without it the
|
|
214
|
+
* message can only print `<your-session-id>` placeholders, which is fine for a
|
|
215
|
+
* CLI caller that passed its own `--session-id` and useless to a hook process,
|
|
216
|
+
* which does not know what identity it presented. Passing it also keeps the
|
|
217
|
+
* message honest when there is none: a grant cannot name an empty child --
|
|
218
|
+
* `occupancy:grant --child-session-id=` is refused at parse and at membership --
|
|
219
|
+
* so that remediation is not printed to a caller who could never run it.
|
|
220
|
+
*/
|
|
221
|
+
export function formatOccupancyRemediation(record, now = new Date(), presented) {
|
|
43
222
|
const age = heartbeatAgeSeconds(record, now);
|
|
44
|
-
|
|
45
|
-
`${occupancyClockLine(record)}).\n
|
|
223
|
+
const header = `Worktree occupied by session ${record.sessionId} (intent=${record.intent}, heartbeat ${age}s ago, ` +
|
|
224
|
+
`${formatLastWritePhrase(record, now)}, ${occupancyClockLine(record)}).\n`;
|
|
225
|
+
const tail = "\nThe occupant may release (`occupancy:release` / `session:end`).";
|
|
226
|
+
if (presented === undefined) {
|
|
227
|
+
return (`${header}Stay read-only (\`session:start --read-only\`), use another worktree,\n` +
|
|
228
|
+
"ask the occupant for a write grant (`occupancy:grant --child-session-id=<your-session-id> " +
|
|
229
|
+
"--role <worker-role>`, run by the occupant), or run a confirmed owner transition " +
|
|
230
|
+
`(\`session:start --steal --confirm --occupant <reported-session-id> --session-id=<your-session-id>\`).${tail}`);
|
|
231
|
+
}
|
|
232
|
+
const actor = presented.trim();
|
|
233
|
+
const occupantArg = commandSessionId(record.sessionId, "<reported-session-id>");
|
|
234
|
+
if (actor.length === 0) {
|
|
235
|
+
return (`${header}This process presented no session identity, so a write grant cannot name it ` +
|
|
236
|
+
"and an owner transition would not be recognised on its next write.\n" +
|
|
237
|
+
"Stay read-only (`session:start --read-only`), use another worktree, or ask the occupant " +
|
|
238
|
+
`to release the lease (\`occupancy:release --session-id=${occupantArg}\` / \`session:end\`).${tail}`);
|
|
239
|
+
}
|
|
240
|
+
const actorArg = commandSessionId(actor, "<your-session-id>");
|
|
241
|
+
return (`${header}This process presented session ${actor}, which neither holds that lease nor has a ` +
|
|
242
|
+
"write grant on it.\n" +
|
|
46
243
|
"Stay read-only (`session:start --read-only`), use another worktree,\n" +
|
|
47
|
-
|
|
48
|
-
"
|
|
244
|
+
`ask the occupant for a write grant (\`occupancy:grant --child-session-id=${actorArg} ` +
|
|
245
|
+
"--role <worker-role>`, run by the occupant), or run a confirmed owner transition " +
|
|
246
|
+
`(\`session:start --steal --confirm --occupant ${occupantArg} --session-id=${actorArg}\`).${tail}`);
|
|
49
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Refuse an administrative verb to a granted child (#3755). Named apart from
|
|
250
|
+
* the stranger refusal because the answer differs: this caller is admitted, and
|
|
251
|
+
* telling it to steal or wait would send it to take the very lease its grant
|
|
252
|
+
* derives from. A grant admits writes; the lease has one owner.
|
|
253
|
+
*/
|
|
254
|
+
export function formatOccupancyMemberAdministrationRefusal(record, grant, verb) {
|
|
255
|
+
return (`${verb} is owner-only. Session ${grant.childSessionId} holds a write grant on this lease ` +
|
|
256
|
+
`(role=${grant.role}, expires ${timestampIso(grant.expiresAt)}), not the lease itself, and a ` +
|
|
257
|
+
"grant never escalates into administration.\n" +
|
|
258
|
+
`Ask the occupant (session ${record.sessionId}) to run it, or wait for the grant to expire.`);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The owner a claim is made under: an explicit id, then `DEFT_SESSION_ID`, then
|
|
262
|
+
* the id the running host published, then a mint.
|
|
263
|
+
*
|
|
264
|
+
* The host step is what makes an identified host's claim reachable (#3873).
|
|
265
|
+
* Minting instead binds the lease to an id no later hook process can present,
|
|
266
|
+
* so the session that claimed the worktree is refused by its own lease. The
|
|
267
|
+
* mint stays as the last resort for hosts that publish nothing.
|
|
268
|
+
*/
|
|
50
269
|
export function resolveOccupancySessionId(input = {}) {
|
|
51
270
|
const explicit = input.sessionId?.trim();
|
|
52
271
|
if (explicit)
|
|
53
272
|
return explicit;
|
|
54
|
-
const
|
|
273
|
+
const env = input.env ?? process.env;
|
|
274
|
+
const envId = env.DEFT_SESSION_ID?.trim();
|
|
55
275
|
if (envId)
|
|
56
276
|
return envId;
|
|
277
|
+
const hostOwner = ambientHostSessionOwner(env);
|
|
278
|
+
if (hostOwner !== null)
|
|
279
|
+
return hostOwner;
|
|
57
280
|
return (input.newSessionId ?? randomUUID)();
|
|
58
281
|
}
|
|
59
282
|
export function readOccupancy(projectRoot) {
|
|
@@ -74,9 +297,9 @@ export function readOccupancy(projectRoot) {
|
|
|
74
297
|
}
|
|
75
298
|
return parseOccupancy(payload, resolve(projectRoot));
|
|
76
299
|
}
|
|
77
|
-
export function liveOccupant(projectRoot, now = new Date(), ttlMs = OCCUPANCY_TTL_MS) {
|
|
300
|
+
export function liveOccupant(projectRoot, now = new Date(), ttlMs = OCCUPANCY_TTL_MS, maxLeaseMs = OCCUPANCY_MAX_LEASE_MS) {
|
|
78
301
|
const record = readOccupancy(projectRoot);
|
|
79
|
-
if (record === null || isOccupancyExpired(record, now, ttlMs))
|
|
302
|
+
if (record === null || isOccupancyExpired(record, now, ttlMs, maxLeaseMs))
|
|
80
303
|
return null;
|
|
81
304
|
return record;
|
|
82
305
|
}
|
|
@@ -130,10 +353,15 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
|
|
|
130
353
|
intent: input.intent ?? liveLocked?.intent ?? "mutation",
|
|
131
354
|
claimedAt: liveLocked?.claimedAt ?? now,
|
|
132
355
|
heartbeatAt: now,
|
|
356
|
+
lastWriteAt: input.markWrite === true ? now : (liveLocked?.lastWriteAt ?? null),
|
|
133
357
|
host: input.host ?? liveLocked?.host ?? occupancyHost(input.env),
|
|
134
358
|
address: input.address ?? liveLocked?.address ?? occupancyAddress(input.env),
|
|
135
359
|
retainCapable: input.retainCapable ?? liveLocked?.retainCapable ?? false,
|
|
136
360
|
joinProtocol: input.joinProtocol ?? liveLocked?.joinProtocol ?? "none",
|
|
361
|
+
// Grants belong to the lease that issued them (#3755): the same owner
|
|
362
|
+
// keeps its members across a heartbeat, and a fresh claim over expired
|
|
363
|
+
// residue starts with none.
|
|
364
|
+
grants: liveLocked === null ? [] : liveOccupancyGrants(liveLocked, now),
|
|
137
365
|
}, fence);
|
|
138
366
|
const action = liveLocked !== null ? "heartbeat" : "claimed";
|
|
139
367
|
return {
|
|
@@ -151,13 +379,20 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
|
|
|
151
379
|
export function stealOccupancy(projectRoot, input = {}) {
|
|
152
380
|
const now = input.now ?? new Date();
|
|
153
381
|
const path = occupancyPath(projectRoot);
|
|
382
|
+
const incoming = resolveOccupancySessionId(input);
|
|
154
383
|
if (input.confirm !== true) {
|
|
384
|
+
const current = readOccupancy(projectRoot);
|
|
385
|
+
// Show the occupant's write recency before the steal, not only after it
|
|
386
|
+
// (#3599): heartbeat age alone hides an occupant that is mid-edit.
|
|
387
|
+
const occupantDetail = current !== null && !isOccupancyExpired(current, now)
|
|
388
|
+
? `\n${formatOccupancyRemediation(current, now)}`
|
|
389
|
+
: "";
|
|
155
390
|
return {
|
|
156
391
|
action: "denied",
|
|
157
|
-
sessionId:
|
|
158
|
-
record:
|
|
392
|
+
sessionId: incoming,
|
|
393
|
+
record: current,
|
|
159
394
|
path,
|
|
160
|
-
message:
|
|
395
|
+
message: `occupancy:steal requires --confirm after naming the occupant.${occupantDetail}`,
|
|
161
396
|
code: 2,
|
|
162
397
|
};
|
|
163
398
|
}
|
|
@@ -165,7 +400,7 @@ export function stealOccupancy(projectRoot, input = {}) {
|
|
|
165
400
|
if (named.length === 0) {
|
|
166
401
|
return {
|
|
167
402
|
action: "denied",
|
|
168
|
-
sessionId:
|
|
403
|
+
sessionId: incoming,
|
|
169
404
|
record: readOccupancy(projectRoot),
|
|
170
405
|
path,
|
|
171
406
|
message: "occupancy:steal requires --occupant <session-id> to name the current occupant.",
|
|
@@ -174,10 +409,26 @@ export function stealOccupancy(projectRoot, input = {}) {
|
|
|
174
409
|
}
|
|
175
410
|
const existing = readOccupancy(projectRoot);
|
|
176
411
|
const live = existing !== null && !isOccupancyExpired(existing, now) ? existing : null;
|
|
412
|
+
// A grant admits writes, never the lease itself (#3755). Letting a child steal
|
|
413
|
+
// from the owner that admitted it would turn delegated write access into a
|
|
414
|
+
// path to replace the delegator — the escalation explicit membership exists to
|
|
415
|
+
// remove. Cooperative, like the rest of this file: a caller can present some
|
|
416
|
+
// other id, and then it is a stranger doing a confirmed steal, on the record.
|
|
417
|
+
const stealerGrant = live === null ? null : occupancyGrantFor(live, incoming, now);
|
|
418
|
+
if (live !== null && stealerGrant !== null) {
|
|
419
|
+
return {
|
|
420
|
+
action: "denied",
|
|
421
|
+
sessionId: incoming,
|
|
422
|
+
record: live,
|
|
423
|
+
path,
|
|
424
|
+
message: formatOccupancyMemberAdministrationRefusal(live, stealerGrant, "occupancy:steal"),
|
|
425
|
+
code: 1,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
177
428
|
if (live !== null && live.sessionId !== named) {
|
|
178
429
|
return {
|
|
179
430
|
action: "denied",
|
|
180
|
-
sessionId:
|
|
431
|
+
sessionId: incoming,
|
|
181
432
|
record: live,
|
|
182
433
|
path,
|
|
183
434
|
message: `occupancy:steal named occupant ${named} does not match live occupant ${live.sessionId}.\n` +
|
|
@@ -185,13 +436,36 @@ export function stealOccupancy(projectRoot, input = {}) {
|
|
|
185
436
|
code: 1,
|
|
186
437
|
};
|
|
187
438
|
}
|
|
439
|
+
if (input.write === false) {
|
|
440
|
+
return {
|
|
441
|
+
action: "stolen",
|
|
442
|
+
sessionId: incoming,
|
|
443
|
+
record: live,
|
|
444
|
+
path,
|
|
445
|
+
message: live === null
|
|
446
|
+
? `occupancy steal preview: writer would be session ${incoming}`
|
|
447
|
+
: `occupancy steal preview: ${live.sessionId} would be replaced by session ${incoming}`,
|
|
448
|
+
code: 0,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
188
451
|
return withOccupancyLock(projectRoot, (fence) => {
|
|
189
452
|
const existingLocked = readOccupancy(projectRoot);
|
|
190
453
|
const liveLocked = existingLocked !== null && !isOccupancyExpired(existingLocked, now) ? existingLocked : null;
|
|
454
|
+
const lockedStealerGrant = liveLocked === null ? null : occupancyGrantFor(liveLocked, incoming, now);
|
|
455
|
+
if (liveLocked !== null && lockedStealerGrant !== null) {
|
|
456
|
+
return {
|
|
457
|
+
action: "denied",
|
|
458
|
+
sessionId: incoming,
|
|
459
|
+
record: liveLocked,
|
|
460
|
+
path,
|
|
461
|
+
message: formatOccupancyMemberAdministrationRefusal(liveLocked, lockedStealerGrant, "occupancy:steal"),
|
|
462
|
+
code: 1,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
191
465
|
if (liveLocked !== null && liveLocked.sessionId !== named) {
|
|
192
466
|
return {
|
|
193
467
|
action: "denied",
|
|
194
|
-
sessionId:
|
|
468
|
+
sessionId: incoming,
|
|
195
469
|
record: liveLocked,
|
|
196
470
|
path,
|
|
197
471
|
message: `occupancy:steal named occupant ${named} does not match live occupant ${liveLocked.sessionId}.\n` +
|
|
@@ -199,25 +473,35 @@ export function stealOccupancy(projectRoot, input = {}) {
|
|
|
199
473
|
code: 1,
|
|
200
474
|
};
|
|
201
475
|
}
|
|
202
|
-
const
|
|
203
|
-
|
|
476
|
+
const priorClock = existingLocked !== null
|
|
477
|
+
? ` (${formatLastWritePhrase(existingLocked, now)}, ${occupancyClockLine(existingLocked)})`
|
|
478
|
+
: "";
|
|
204
479
|
const record = writeOccupancyRecord(projectRoot, {
|
|
205
480
|
sessionId: incoming,
|
|
206
481
|
worktreePath: resolve(projectRoot),
|
|
207
482
|
intent: input.intent ?? "mutation",
|
|
208
483
|
claimedAt: now,
|
|
209
484
|
heartbeatAt: now,
|
|
485
|
+
lastWriteAt: null,
|
|
210
486
|
host: input.host ?? occupancyHost(input.env),
|
|
211
487
|
address: input.address ?? occupancyAddress(input.env),
|
|
212
488
|
retainCapable: input.retainCapable ?? false,
|
|
213
489
|
joinProtocol: input.joinProtocol ?? "none",
|
|
490
|
+
// A steal replaces the owner, and grants are that owner's word about
|
|
491
|
+
// who may write. The new owner never said it, so it does not inherit
|
|
492
|
+
// the members either (#3755).
|
|
493
|
+
grants: [],
|
|
214
494
|
}, fence);
|
|
215
495
|
return {
|
|
216
496
|
action: "stolen",
|
|
217
497
|
sessionId: record.sessionId,
|
|
218
498
|
record,
|
|
219
499
|
path,
|
|
220
|
-
message: `occupancy stolen from ${named}${priorClock}; writer is now session ${record.sessionId}
|
|
500
|
+
message: `occupancy stolen from ${named}${priorClock}; writer is now session ${record.sessionId}. ` +
|
|
501
|
+
"This command changes the lease only; direct writes remain denied unless ritual state already names the same owner. " +
|
|
502
|
+
"If the owners differ, run `deft session:start --rearm --session-id=<same-session-id>` " +
|
|
503
|
+
"when re-arm is eligible; otherwise run `deft session:start --session-id=<same-session-id>` " +
|
|
504
|
+
"for a cold ceremony, using the writer ID above.",
|
|
221
505
|
code: 0,
|
|
222
506
|
};
|
|
223
507
|
}, input.lockDeps);
|
|
@@ -246,7 +530,7 @@ export function releaseOccupancy(projectRoot, input = {}) {
|
|
|
246
530
|
sessionId: caller,
|
|
247
531
|
record: existing,
|
|
248
532
|
path,
|
|
249
|
-
message:
|
|
533
|
+
message: membershipOwnerDenial(existing, caller, now, "occupancy:release"),
|
|
250
534
|
code: 1,
|
|
251
535
|
};
|
|
252
536
|
}
|
|
@@ -271,7 +555,7 @@ export function releaseOccupancy(projectRoot, input = {}) {
|
|
|
271
555
|
sessionId: caller,
|
|
272
556
|
record: still,
|
|
273
557
|
path,
|
|
274
|
-
message:
|
|
558
|
+
message: membershipOwnerDenial(still, caller, now, "occupancy:release"),
|
|
275
559
|
code: 1,
|
|
276
560
|
};
|
|
277
561
|
}
|
|
@@ -286,19 +570,506 @@ export function releaseOccupancy(projectRoot, input = {}) {
|
|
|
286
570
|
};
|
|
287
571
|
}, input.lockDeps);
|
|
288
572
|
}
|
|
573
|
+
function membershipOwnerDenial(live, caller, now, verb) {
|
|
574
|
+
const grant = occupancyGrantFor(live, caller, now);
|
|
575
|
+
return grant === null
|
|
576
|
+
? formatOccupancyRemediation(live, now)
|
|
577
|
+
: formatOccupancyMemberAdministrationRefusal(live, grant, verb);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Admit a dispatched child to this lease for writes (#3755).
|
|
581
|
+
*
|
|
582
|
+
* Owner-only, and the record is the point: a child that writes here is named
|
|
583
|
+
* on the lease it writes under, so an unexpected edit resolves to a session, a
|
|
584
|
+
* role and a tree instead of to "somebody who had the string". The grant cannot
|
|
585
|
+
* outlive the lease that issued it — expiry is clamped to the absolute lease
|
|
586
|
+
* cap — and it buys writes alone.
|
|
587
|
+
*/
|
|
588
|
+
export function grantOccupancyMembership(projectRoot, input = {}) {
|
|
589
|
+
const now = input.now ?? new Date();
|
|
590
|
+
const path = occupancyPath(projectRoot);
|
|
591
|
+
const owner = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
|
|
592
|
+
const child = input.childSessionId?.trim() ?? "";
|
|
593
|
+
const role = input.role?.trim() ?? "";
|
|
594
|
+
if (owner.length === 0) {
|
|
595
|
+
return {
|
|
596
|
+
action: "denied",
|
|
597
|
+
sessionId: "",
|
|
598
|
+
record: readOccupancy(projectRoot),
|
|
599
|
+
path,
|
|
600
|
+
message: "occupancy:grant needs the owner id: pass --session-id <your-session-id> or set " +
|
|
601
|
+
"DEFT_SESSION_ID. Only the occupant may admit a child to its lease.",
|
|
602
|
+
code: 2,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
if (child.length === 0) {
|
|
606
|
+
return {
|
|
607
|
+
action: "denied",
|
|
608
|
+
sessionId: owner,
|
|
609
|
+
record: readOccupancy(projectRoot),
|
|
610
|
+
path,
|
|
611
|
+
message: "occupancy:grant needs --child-session-id <session-id>: the id the dispatched child " +
|
|
612
|
+
"will present on its own writes.",
|
|
613
|
+
code: 2,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
if (child === owner) {
|
|
617
|
+
return {
|
|
618
|
+
action: "denied",
|
|
619
|
+
sessionId: owner,
|
|
620
|
+
record: readOccupancy(projectRoot),
|
|
621
|
+
path,
|
|
622
|
+
message: "occupancy:grant refuses a self-grant: the lease already admits its owner, so a grant " +
|
|
623
|
+
"naming the same id records nothing and would only blur who wrote what.",
|
|
624
|
+
code: 2,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
if (!SWARM_WORKER_ROLES.includes(role)) {
|
|
628
|
+
return {
|
|
629
|
+
action: "denied",
|
|
630
|
+
sessionId: owner,
|
|
631
|
+
record: readOccupancy(projectRoot),
|
|
632
|
+
path,
|
|
633
|
+
message: `occupancy:grant needs --role from ${SWARM_WORKER_ROLES.join(", ")}. The role is what the ` +
|
|
634
|
+
"grant is for; an unnamed role makes the record unreadable after the fact.",
|
|
635
|
+
code: 2,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
const ttlMs = input.ttlMs ?? OCCUPANCY_GRANT_TTL_MS;
|
|
639
|
+
return withOccupancyLock(projectRoot, (fence) => {
|
|
640
|
+
const current = readOccupancy(projectRoot);
|
|
641
|
+
const live = current !== null && !isOccupancyExpired(current, now) ? current : null;
|
|
642
|
+
if (live === null) {
|
|
643
|
+
const capped = current !== null &&
|
|
644
|
+
current.sessionId === owner &&
|
|
645
|
+
occupancyLiveness(current, now) === "age-capped";
|
|
646
|
+
return {
|
|
647
|
+
action: "denied",
|
|
648
|
+
sessionId: owner,
|
|
649
|
+
record: capped ? current : null,
|
|
650
|
+
path,
|
|
651
|
+
message: capped && current !== null
|
|
652
|
+
? formatOccupancyAgeCapRemediation(current, now)
|
|
653
|
+
: "occupancy:grant found no live lease to grant on. A grant is derived authority, " +
|
|
654
|
+
`so claim the worktree first with \`deft session:start --session-id=${commandSessionId(owner, "<your-session-id>")}\`.`,
|
|
655
|
+
code: 1,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
if (live.sessionId !== owner) {
|
|
659
|
+
return {
|
|
660
|
+
action: "denied",
|
|
661
|
+
sessionId: owner,
|
|
662
|
+
record: live,
|
|
663
|
+
path,
|
|
664
|
+
message: membershipOwnerDenial(live, owner, now, "occupancy:grant"),
|
|
665
|
+
code: 1,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
const requested = input.expiresAt ?? new Date(now.getTime() + ttlMs);
|
|
669
|
+
if (requested.getTime() <= now.getTime()) {
|
|
670
|
+
return {
|
|
671
|
+
action: "denied",
|
|
672
|
+
sessionId: owner,
|
|
673
|
+
record: live,
|
|
674
|
+
path,
|
|
675
|
+
message: "occupancy:grant refuses an expiry that is already past: a grant that admits nobody " +
|
|
676
|
+
"is indistinguishable from no grant, and recording one would only mislead.",
|
|
677
|
+
code: 2,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
// A grant is derived authority, so it dies with the lease it derives from
|
|
681
|
+
// (#3755). Without this clamp a chain of grants would outlast the absolute
|
|
682
|
+
// cap that keeps a worktree reclaimable.
|
|
683
|
+
const leaseEnds = live.claimedAt.getTime() + OCCUPANCY_MAX_LEASE_MS;
|
|
684
|
+
const expiresAt = requested.getTime() > leaseEnds ? new Date(leaseEnds) : requested;
|
|
685
|
+
const clamped = expiresAt.getTime() !== requested.getTime();
|
|
686
|
+
const kept = liveOccupancyGrants(live, now).filter((existing) => existing.childSessionId !== child);
|
|
687
|
+
if (kept.length >= OCCUPANCY_MAX_GRANTS) {
|
|
688
|
+
return {
|
|
689
|
+
action: "denied",
|
|
690
|
+
sessionId: owner,
|
|
691
|
+
record: live,
|
|
692
|
+
path,
|
|
693
|
+
message: `occupancy:grant refuses a ${OCCUPANCY_MAX_GRANTS + 1}th live grant on one lease. ` +
|
|
694
|
+
"Revoke a finished child (`occupancy:grant --revoke --child-session-id=<id>`) or let " +
|
|
695
|
+
"its grant expire.",
|
|
696
|
+
code: 1,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
const grant = {
|
|
700
|
+
ownerSessionId: owner,
|
|
701
|
+
childSessionId: child,
|
|
702
|
+
worktreePath: input.worktreePath?.trim() || live.worktreePath,
|
|
703
|
+
role: role,
|
|
704
|
+
expiresAt,
|
|
705
|
+
host: input.host?.trim() || "none",
|
|
706
|
+
address: input.address?.trim() || "none",
|
|
707
|
+
joinProtocol: input.joinProtocol ?? "parent-message",
|
|
708
|
+
};
|
|
709
|
+
const record = writeOccupancyRecord(projectRoot, {
|
|
710
|
+
sessionId: live.sessionId,
|
|
711
|
+
worktreePath: live.worktreePath,
|
|
712
|
+
intent: live.intent,
|
|
713
|
+
claimedAt: live.claimedAt,
|
|
714
|
+
// Issuing a grant is the owner touching its own lease, which is what
|
|
715
|
+
// heartbeat_at records; claimed_at is untouched, so the cap holds.
|
|
716
|
+
heartbeatAt: now,
|
|
717
|
+
lastWriteAt: live.lastWriteAt,
|
|
718
|
+
host: live.host,
|
|
719
|
+
address: live.address,
|
|
720
|
+
retainCapable: live.retainCapable,
|
|
721
|
+
joinProtocol: live.joinProtocol,
|
|
722
|
+
grants: [...kept, grant],
|
|
723
|
+
}, fence);
|
|
724
|
+
return {
|
|
725
|
+
action: "granted",
|
|
726
|
+
sessionId: owner,
|
|
727
|
+
record,
|
|
728
|
+
path,
|
|
729
|
+
message: `occupancy grant issued to session ${child} (role=${grant.role}, ` +
|
|
730
|
+
`worktree=${grant.worktreePath}, expires ${timestampIso(expiresAt)}` +
|
|
731
|
+
`${clamped ? ", clamped to this lease's absolute age cap" : ""}). ` +
|
|
732
|
+
"It admits writes only; release, steal, heartbeat and cohort close-out stay yours.",
|
|
733
|
+
code: 0,
|
|
734
|
+
};
|
|
735
|
+
}, input.lockDeps);
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Withdraw a child's grant early (#3755). Expiry already bounds every grant, so
|
|
739
|
+
* this exists for the case expiry cannot serve: the child finished, or should
|
|
740
|
+
* never have been admitted, and the owner wants that true now.
|
|
741
|
+
*/
|
|
742
|
+
export function revokeOccupancyMembership(projectRoot, input = {}) {
|
|
743
|
+
const now = input.now ?? new Date();
|
|
744
|
+
const path = occupancyPath(projectRoot);
|
|
745
|
+
const owner = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
|
|
746
|
+
const child = input.childSessionId?.trim() ?? "";
|
|
747
|
+
if (owner.length === 0 || child.length === 0) {
|
|
748
|
+
return {
|
|
749
|
+
action: "denied",
|
|
750
|
+
sessionId: owner,
|
|
751
|
+
record: readOccupancy(projectRoot),
|
|
752
|
+
path,
|
|
753
|
+
message: "occupancy:grant --revoke needs both the owner id (--session-id or DEFT_SESSION_ID) and " +
|
|
754
|
+
"--child-session-id <session-id>.",
|
|
755
|
+
code: 2,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
return withOccupancyLock(projectRoot, (fence) => {
|
|
759
|
+
const current = readOccupancy(projectRoot);
|
|
760
|
+
const live = current !== null && !isOccupancyExpired(current, now) ? current : null;
|
|
761
|
+
if (live === null) {
|
|
762
|
+
return {
|
|
763
|
+
action: "revoked",
|
|
764
|
+
sessionId: owner,
|
|
765
|
+
record: null,
|
|
766
|
+
path,
|
|
767
|
+
message: "occupancy:grant --revoke found no live lease, so no grant survives it either: " +
|
|
768
|
+
"grants die with the lease that issued them.",
|
|
769
|
+
code: 0,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
if (live.sessionId !== owner) {
|
|
773
|
+
return {
|
|
774
|
+
action: "denied",
|
|
775
|
+
sessionId: owner,
|
|
776
|
+
record: live,
|
|
777
|
+
path,
|
|
778
|
+
message: membershipOwnerDenial(live, owner, now, "occupancy:grant --revoke"),
|
|
779
|
+
code: 1,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
const before = liveOccupancyGrants(live, now);
|
|
783
|
+
const remaining = before.filter((existing) => existing.childSessionId !== child);
|
|
784
|
+
if (remaining.length === before.length) {
|
|
785
|
+
return {
|
|
786
|
+
action: "revoked",
|
|
787
|
+
sessionId: owner,
|
|
788
|
+
record: live,
|
|
789
|
+
path,
|
|
790
|
+
message: `occupancy has no live grant for session ${child}; nothing to revoke.`,
|
|
791
|
+
code: 0,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
const record = writeOccupancyRecord(projectRoot, {
|
|
795
|
+
sessionId: live.sessionId,
|
|
796
|
+
worktreePath: live.worktreePath,
|
|
797
|
+
intent: live.intent,
|
|
798
|
+
claimedAt: live.claimedAt,
|
|
799
|
+
heartbeatAt: now,
|
|
800
|
+
lastWriteAt: live.lastWriteAt,
|
|
801
|
+
host: live.host,
|
|
802
|
+
address: live.address,
|
|
803
|
+
retainCapable: live.retainCapable,
|
|
804
|
+
joinProtocol: live.joinProtocol,
|
|
805
|
+
grants: remaining,
|
|
806
|
+
}, fence);
|
|
807
|
+
return {
|
|
808
|
+
action: "revoked",
|
|
809
|
+
sessionId: owner,
|
|
810
|
+
record,
|
|
811
|
+
path,
|
|
812
|
+
message: `occupancy grant revoked for session ${child}; its writes are refused from now on.`,
|
|
813
|
+
code: 0,
|
|
814
|
+
};
|
|
815
|
+
}, input.lockDeps);
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Decide whether the presented session may write, and — on the owner-allow
|
|
819
|
+
* path — keep the owner's lease alive (#3599).
|
|
820
|
+
*
|
|
821
|
+
* Before this, the gate was read-only on owner-allow, so the one event that
|
|
822
|
+
* proves the owner is alive did not extend its lease: the live window was
|
|
823
|
+
* twenty minutes from claim, once, regardless of how long the session worked.
|
|
824
|
+
*/
|
|
289
825
|
export function evaluateOccupancyWriteGate(projectRoot, input = {}) {
|
|
290
826
|
const now = input.now ?? new Date();
|
|
291
|
-
const live = liveOccupant(projectRoot, now);
|
|
292
|
-
if (live === null)
|
|
293
|
-
return { allow: true, message: null, occupant: null };
|
|
294
827
|
const incoming = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
|
|
295
|
-
|
|
296
|
-
|
|
828
|
+
const record = readOccupancy(projectRoot);
|
|
829
|
+
const liveness = record === null ? null : occupancyLiveness(record, now);
|
|
830
|
+
const admission = record === null ? "stranger" : occupancyAdmission(record, incoming, now);
|
|
831
|
+
if (record !== null && liveness === "age-capped" && admission !== "stranger") {
|
|
832
|
+
// Refuse the capped holder rather than warn it (#3599). Its tree is now
|
|
833
|
+
// unheld, so allowing the write would let the very bearer the cap exists to
|
|
834
|
+
// bound keep mutating a worktree a peer may claim between this allow and
|
|
835
|
+
// the write itself. On gated writes the identity comes from the host
|
|
836
|
+
// payload, so the holder cannot present a stranger's id to dodge this.
|
|
837
|
+
//
|
|
838
|
+
// A granted child is refused on the same footing (#3755). Its grant is
|
|
839
|
+
// derived from this lease, so once the lease is gone the grant authorizes
|
|
840
|
+
// writes to a tree nobody holds — the exact bypass the cap exists to close,
|
|
841
|
+
// one hop removed.
|
|
842
|
+
return {
|
|
843
|
+
allow: false,
|
|
844
|
+
message: formatOccupancyAgeCapRemediation(record, now),
|
|
845
|
+
occupant: null,
|
|
846
|
+
refreshed: false,
|
|
847
|
+
warning: null,
|
|
848
|
+
admitted: null,
|
|
849
|
+
grant: null,
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
if (record === null || liveness !== "live") {
|
|
853
|
+
return {
|
|
854
|
+
allow: true,
|
|
855
|
+
message: null,
|
|
856
|
+
occupant: null,
|
|
857
|
+
refreshed: false,
|
|
858
|
+
warning: null,
|
|
859
|
+
admitted: null,
|
|
860
|
+
grant: null,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
const live = record;
|
|
864
|
+
if (admission === "stranger") {
|
|
865
|
+
return {
|
|
866
|
+
allow: false,
|
|
867
|
+
// The refused caller is told what identity it actually presented (#3873).
|
|
868
|
+
// A hook process cannot otherwise know, and the grant this message offers
|
|
869
|
+
// is only runnable when the occupant can name a non-empty child.
|
|
870
|
+
message: formatOccupancyRemediation(live, now, incoming),
|
|
871
|
+
occupant: live,
|
|
872
|
+
refreshed: false,
|
|
873
|
+
warning: null,
|
|
874
|
+
admitted: null,
|
|
875
|
+
grant: null,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
if (admission === "member") {
|
|
879
|
+
// A member's write keeps the lease alive (#3755). The lease answers "who may
|
|
880
|
+
// mutate this tree right now", and a tree a granted child is actively
|
|
881
|
+
// writing is in use — letting it lapse would hand the worktree to a peer
|
|
882
|
+
// mid-edit, which is the loss the TTL exists to prevent, not the abandonment
|
|
883
|
+
// it exists to detect. Two bounds still hold: `claimedAt` is untouched, so
|
|
884
|
+
// the absolute age cap is unmoved, and the grant expires on its own clock.
|
|
885
|
+
const memberAgeMs = now.getTime() - live.heartbeatAt.getTime();
|
|
886
|
+
const memberWarning = memberAgeMs >= OCCUPANCY_STALE_WARN_MS ? formatOccupancyStaleWarning(live, now) : null;
|
|
887
|
+
if (input.refresh !== true || memberAgeMs < OCCUPANCY_REFRESH_AFTER_MS) {
|
|
888
|
+
return {
|
|
889
|
+
allow: true,
|
|
890
|
+
message: null,
|
|
891
|
+
occupant: live,
|
|
892
|
+
refreshed: false,
|
|
893
|
+
warning: memberWarning,
|
|
894
|
+
admitted: "member",
|
|
895
|
+
grant: occupancyGrantFor(live, incoming, now),
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
const memberOutcome = restampOccupancyHeartbeat(projectRoot, live.sessionId, now, true, input.lockDeps, incoming);
|
|
899
|
+
if (memberOutcome.status !== "refreshed") {
|
|
900
|
+
// Same re-decide as the owner path: contention says nothing about who
|
|
901
|
+
// holds the lease now, so ask the file rather than the pre-lock snapshot.
|
|
902
|
+
return evaluateOccupancyWriteGate(projectRoot, { ...input, now, refresh: false });
|
|
903
|
+
}
|
|
904
|
+
return {
|
|
905
|
+
allow: true,
|
|
906
|
+
message: null,
|
|
907
|
+
occupant: memberOutcome.record,
|
|
908
|
+
refreshed: true,
|
|
909
|
+
warning: null,
|
|
910
|
+
admitted: "member",
|
|
911
|
+
grant: occupancyGrantFor(memberOutcome.record, incoming, now),
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
const ageMs = now.getTime() - live.heartbeatAt.getTime();
|
|
915
|
+
const warning = ageMs >= OCCUPANCY_STALE_WARN_MS ? formatOccupancyStaleWarning(live, now) : null;
|
|
916
|
+
if (input.refresh !== true || ageMs < OCCUPANCY_REFRESH_AFTER_MS) {
|
|
917
|
+
return {
|
|
918
|
+
allow: true,
|
|
919
|
+
message: null,
|
|
920
|
+
occupant: live,
|
|
921
|
+
refreshed: false,
|
|
922
|
+
warning,
|
|
923
|
+
admitted: "owner",
|
|
924
|
+
grant: null,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
const outcome = restampOccupancyHeartbeat(projectRoot, live.sessionId, now, true, input.lockDeps);
|
|
928
|
+
if (outcome.status !== "refreshed") {
|
|
929
|
+
// Neither failure leaves the pre-lock record usable. `lost` says the lease
|
|
930
|
+
// changed hands outright. `unavailable` says only that the lock could not
|
|
931
|
+
// be taken — but a peer takeover is one of the things that holds it, so a
|
|
932
|
+
// re-stamp that blocks until timeout hides the same handover. Decide
|
|
933
|
+
// against what is on disk now instead of the snapshot read at 598: a lease
|
|
934
|
+
// still ours under contention re-allows exactly as before (#3736), while a
|
|
935
|
+
// replacement owner wins (#3599). Re-entry cannot recurse — refresh is off.
|
|
936
|
+
return evaluateOccupancyWriteGate(projectRoot, { ...input, now, refresh: false });
|
|
937
|
+
}
|
|
938
|
+
return {
|
|
939
|
+
allow: true,
|
|
940
|
+
message: null,
|
|
941
|
+
occupant: outcome.record,
|
|
942
|
+
refreshed: true,
|
|
943
|
+
// `warning` was measured against the pre-refresh heartbeat. Returning it
|
|
944
|
+
// beside a successful re-stamp would tell the owner its lease is going
|
|
945
|
+
// stale on the very write that renewed it.
|
|
946
|
+
warning: null,
|
|
947
|
+
admitted: "owner",
|
|
948
|
+
grant: null,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Re-stamp an existing live lease held by `sessionId`. Reports `lost` when the
|
|
953
|
+
* lease is gone, expired, or now held by someone else — refresh must never
|
|
954
|
+
* claim or resurrect a lease, only extend one the caller already holds.
|
|
955
|
+
*
|
|
956
|
+
* Lock contention and IO errors report `unavailable` rather than `lost`: they
|
|
957
|
+
* observed no owner at all, so they are not evidence of replacement. `heartbeat`
|
|
958
|
+
* says so and leaves the lease alone; the write gate re-reads the file rather
|
|
959
|
+
* than trusting either its own stale snapshot or a denial the lock never earned.
|
|
960
|
+
*/
|
|
961
|
+
function restampOccupancyHeartbeat(projectRoot, sessionId, now, markWrite, lockDeps,
|
|
962
|
+
/** Refresh on behalf of this granted member rather than the owner (#3755). */
|
|
963
|
+
memberSessionId) {
|
|
964
|
+
try {
|
|
965
|
+
return withOccupancyLock(projectRoot, (fence) => {
|
|
966
|
+
const current = readOccupancy(projectRoot);
|
|
967
|
+
if (current === null || current.sessionId !== sessionId)
|
|
968
|
+
return { status: "lost" };
|
|
969
|
+
if (isOccupancyExpired(current, now))
|
|
970
|
+
return { status: "lost" };
|
|
971
|
+
// Re-check membership under the lock: the grant read before the wait may
|
|
972
|
+
// have been revoked or expired while it ran (#3755).
|
|
973
|
+
if (memberSessionId !== undefined &&
|
|
974
|
+
occupancyGrantFor(current, memberSessionId, now) === null) {
|
|
975
|
+
return { status: "lost" };
|
|
976
|
+
}
|
|
977
|
+
const record = writeOccupancyRecord(projectRoot, {
|
|
978
|
+
sessionId: current.sessionId,
|
|
979
|
+
worktreePath: current.worktreePath,
|
|
980
|
+
intent: current.intent,
|
|
981
|
+
claimedAt: current.claimedAt,
|
|
982
|
+
heartbeatAt: now,
|
|
983
|
+
lastWriteAt: markWrite ? now : current.lastWriteAt,
|
|
984
|
+
host: current.host,
|
|
985
|
+
address: current.address,
|
|
986
|
+
retainCapable: current.retainCapable,
|
|
987
|
+
joinProtocol: current.joinProtocol,
|
|
988
|
+
// Refresh extends the lease, so it also prunes the grants that died
|
|
989
|
+
// while it ran; expiry is already refused on read (#3755).
|
|
990
|
+
grants: liveOccupancyGrants(current, now),
|
|
991
|
+
}, fence);
|
|
992
|
+
return { status: "refreshed", record };
|
|
993
|
+
}, lockDeps);
|
|
994
|
+
}
|
|
995
|
+
catch {
|
|
996
|
+
return { status: "unavailable" };
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Refresh the caller's own live lease (#3599). Discoverable counterpart to the
|
|
1001
|
+
* automatic write-gate refresh, for sessions whose work is long and quiet:
|
|
1002
|
+
* reading, building, or waiting produces no gated write to ride on.
|
|
1003
|
+
*
|
|
1004
|
+
* Never claims and never mints an owner — an unheld or foreign lease is denied.
|
|
1005
|
+
*/
|
|
1006
|
+
export function heartbeatOccupancy(projectRoot, input = {}) {
|
|
1007
|
+
const now = input.now ?? new Date();
|
|
1008
|
+
const path = occupancyPath(projectRoot);
|
|
1009
|
+
const caller = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
|
|
1010
|
+
if (caller.length === 0) {
|
|
1011
|
+
return {
|
|
1012
|
+
action: "denied",
|
|
1013
|
+
sessionId: "",
|
|
1014
|
+
record: readOccupancy(projectRoot),
|
|
1015
|
+
path,
|
|
1016
|
+
message: "occupancy:heartbeat needs the owner id: pass --session-id <your-session-id> or set " +
|
|
1017
|
+
"DEFT_SESSION_ID. Refresh extends an existing lease and never mints an owner.",
|
|
1018
|
+
code: 2,
|
|
1019
|
+
};
|
|
297
1020
|
}
|
|
1021
|
+
const existing = readOccupancy(projectRoot);
|
|
1022
|
+
const live = existing !== null && !isOccupancyExpired(existing, now) ? existing : null;
|
|
1023
|
+
if (live === null) {
|
|
1024
|
+
const capped = existing !== null &&
|
|
1025
|
+
existing.sessionId === caller &&
|
|
1026
|
+
occupancyLiveness(existing, now) === "age-capped";
|
|
1027
|
+
return {
|
|
1028
|
+
action: "denied",
|
|
1029
|
+
sessionId: caller,
|
|
1030
|
+
record: capped ? existing : null,
|
|
1031
|
+
path,
|
|
1032
|
+
message: capped && existing !== null
|
|
1033
|
+
? formatOccupancyAgeCapRemediation(existing, now)
|
|
1034
|
+
: "occupancy:heartbeat found no live lease to refresh. Claim one with " +
|
|
1035
|
+
`\`deft session:start --session-id=${commandSessionId(caller, "<your-session-id>")}\`.`,
|
|
1036
|
+
code: 1,
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
if (live.sessionId !== caller) {
|
|
1040
|
+
return {
|
|
1041
|
+
action: "denied",
|
|
1042
|
+
sessionId: caller,
|
|
1043
|
+
record: live,
|
|
1044
|
+
path,
|
|
1045
|
+
message: membershipOwnerDenial(live, caller, now, "occupancy:heartbeat"),
|
|
1046
|
+
code: 1,
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
const outcome = restampOccupancyHeartbeat(projectRoot, caller, now, false, input.lockDeps);
|
|
1050
|
+
if (outcome.status !== "refreshed") {
|
|
1051
|
+
return {
|
|
1052
|
+
action: "denied",
|
|
1053
|
+
sessionId: caller,
|
|
1054
|
+
record: readOccupancy(projectRoot),
|
|
1055
|
+
path,
|
|
1056
|
+
message: outcome.status === "lost"
|
|
1057
|
+
? "occupancy:heartbeat could not refresh the lease: it expired or changed owner " +
|
|
1058
|
+
"while the refresh was running."
|
|
1059
|
+
: "occupancy:heartbeat could not take the occupancy lock, so the lease is " +
|
|
1060
|
+
"unchanged and still yours. Retry in a moment.",
|
|
1061
|
+
code: 1,
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
const record = outcome.record;
|
|
298
1065
|
return {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
1066
|
+
action: "heartbeat",
|
|
1067
|
+
sessionId: record.sessionId,
|
|
1068
|
+
record,
|
|
1069
|
+
path,
|
|
1070
|
+
message: `occupancy heartbeat session ${record.sessionId} (intent=${record.intent}, ` +
|
|
1071
|
+
`${occupancyClockLine(record)})`,
|
|
1072
|
+
code: 0,
|
|
302
1073
|
};
|
|
303
1074
|
}
|
|
304
1075
|
/** Close-out identity comes from the launch manifest or DEFT_SESSION_ID — never occupancy.json. */
|
|
@@ -313,7 +1084,9 @@ export function releaseSwarmOccupancy(projectRoot, input = {}) {
|
|
|
313
1084
|
record: occupant,
|
|
314
1085
|
path: occupancyPath(projectRoot),
|
|
315
1086
|
message: "swarm close-out has no occupancy_session_id (manifest missing or predates the field) " +
|
|
316
|
-
"and DEFT_SESSION_ID is unset.
|
|
1087
|
+
"and DEFT_SESSION_ID is unset. Re-establish an aligned owner with " +
|
|
1088
|
+
"session:start --steal --confirm --occupant <reported-session-id> " +
|
|
1089
|
+
"--session-id=<your-session-id>.",
|
|
317
1090
|
code: 1,
|
|
318
1091
|
};
|
|
319
1092
|
}
|
|
@@ -350,6 +1123,9 @@ function parseOccupancy(payload, fallbackWorktree) {
|
|
|
350
1123
|
const heartbeatAt = parseTimestamp(obj.heartbeat_at) ?? claimedAt;
|
|
351
1124
|
if (claimedAt === null || heartbeatAt === null)
|
|
352
1125
|
return null;
|
|
1126
|
+
// Additive and optional (#3599): records written before the field exists,
|
|
1127
|
+
// and by older CLIs, stay readable — absence means "no recorded write".
|
|
1128
|
+
const lastWriteAt = parseTimestamp(obj.last_write_at);
|
|
353
1129
|
const joinRaw = typeof obj.join_protocol === "string" ? obj.join_protocol : "none";
|
|
354
1130
|
const joinProtocol = OCCUPANCY_JOIN_PROTOCOLS.includes(joinRaw)
|
|
355
1131
|
? joinRaw
|
|
@@ -364,13 +1140,63 @@ function parseOccupancy(payload, fallbackWorktree) {
|
|
|
364
1140
|
intent,
|
|
365
1141
|
claimedAt,
|
|
366
1142
|
heartbeatAt,
|
|
1143
|
+
lastWriteAt,
|
|
367
1144
|
host: typeof obj.host === "string" && obj.host.length > 0 ? obj.host : "none",
|
|
368
1145
|
address: typeof obj.address === "string" && obj.address.length > 0 ? obj.address : "none",
|
|
369
1146
|
retainCapable: obj.retain_capable === true,
|
|
370
1147
|
joinProtocol,
|
|
1148
|
+
grants: parseOccupancyGrants(obj.grants, sessionId, worktreePath),
|
|
371
1149
|
raw: { ...obj },
|
|
372
1150
|
};
|
|
373
1151
|
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Read the grant list (#3755). A malformed entry is dropped rather than failing
|
|
1154
|
+
* the whole record: the lease still has an owner, and losing one grant denies a
|
|
1155
|
+
* child a write it can ask for again, while losing the record would strand the
|
|
1156
|
+
* tree. Expiry is parsed but not judged here — `occupancyGrantFor` decides that
|
|
1157
|
+
* against a clock, so an expired grant stays visible to the owner reading its
|
|
1158
|
+
* own lease and is still refused on admission.
|
|
1159
|
+
*/
|
|
1160
|
+
function parseOccupancyGrants(payload, ownerSessionId, fallbackWorktree) {
|
|
1161
|
+
if (!Array.isArray(payload))
|
|
1162
|
+
return [];
|
|
1163
|
+
const grants = [];
|
|
1164
|
+
for (const entry of payload.slice(0, OCCUPANCY_MAX_GRANTS)) {
|
|
1165
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
|
|
1166
|
+
continue;
|
|
1167
|
+
const obj = entry;
|
|
1168
|
+
const childSessionId = typeof obj.child_session_id === "string" ? obj.child_session_id.trim() : "";
|
|
1169
|
+
if (childSessionId.length === 0)
|
|
1170
|
+
continue;
|
|
1171
|
+
const owner = typeof obj.owner_session_id === "string" ? obj.owner_session_id.trim() : "";
|
|
1172
|
+
// A grant naming a different owner is residue from a lease that has since
|
|
1173
|
+
// changed hands; the current occupant never issued it, so it admits nobody.
|
|
1174
|
+
if (owner.length === 0 || owner !== ownerSessionId)
|
|
1175
|
+
continue;
|
|
1176
|
+
const expiresAt = parseTimestamp(obj.expires_at);
|
|
1177
|
+
if (expiresAt === null)
|
|
1178
|
+
continue;
|
|
1179
|
+
const roleRaw = typeof obj.role === "string" ? obj.role : "";
|
|
1180
|
+
if (!SWARM_WORKER_ROLES.includes(roleRaw))
|
|
1181
|
+
continue;
|
|
1182
|
+
const joinRaw = typeof obj.join_protocol === "string" ? obj.join_protocol : "none";
|
|
1183
|
+
grants.push({
|
|
1184
|
+
ownerSessionId: owner,
|
|
1185
|
+
childSessionId,
|
|
1186
|
+
worktreePath: typeof obj.worktree_path === "string" && obj.worktree_path.trim().length > 0
|
|
1187
|
+
? obj.worktree_path
|
|
1188
|
+
: fallbackWorktree,
|
|
1189
|
+
role: roleRaw,
|
|
1190
|
+
expiresAt,
|
|
1191
|
+
host: typeof obj.host === "string" && obj.host.length > 0 ? obj.host : "none",
|
|
1192
|
+
address: typeof obj.address === "string" && obj.address.length > 0 ? obj.address : "none",
|
|
1193
|
+
joinProtocol: OCCUPANCY_JOIN_PROTOCOLS.includes(joinRaw)
|
|
1194
|
+
? joinRaw
|
|
1195
|
+
: "none",
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
return grants;
|
|
1199
|
+
}
|
|
374
1200
|
function occupancyPayload(record) {
|
|
375
1201
|
return {
|
|
376
1202
|
schemaVersion: OCCUPANCY_SCHEMA_VERSION,
|
|
@@ -379,10 +1205,28 @@ function occupancyPayload(record) {
|
|
|
379
1205
|
intent: record.intent,
|
|
380
1206
|
claimed_at: timestampIso(record.claimedAt),
|
|
381
1207
|
heartbeat_at: timestampIso(record.heartbeatAt),
|
|
1208
|
+
...(record.lastWriteAt === null ? {} : { last_write_at: timestampIso(record.lastWriteAt) }),
|
|
382
1209
|
host: record.host,
|
|
383
1210
|
address: record.address,
|
|
384
1211
|
retain_capable: record.retainCapable,
|
|
385
1212
|
join_protocol: record.joinProtocol,
|
|
1213
|
+
// Additive (#3755): absent on records written before membership existed,
|
|
1214
|
+
// and omitted again when empty so an ungranted lease keeps its old shape.
|
|
1215
|
+
...(record.grants.length === 0
|
|
1216
|
+
? {}
|
|
1217
|
+
: { grants: record.grants.map((grant) => occupancyGrantPayload(grant)) }),
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
function occupancyGrantPayload(grant) {
|
|
1221
|
+
return {
|
|
1222
|
+
owner_session_id: grant.ownerSessionId,
|
|
1223
|
+
child_session_id: grant.childSessionId,
|
|
1224
|
+
worktree_path: grant.worktreePath,
|
|
1225
|
+
role: grant.role,
|
|
1226
|
+
expires_at: timestampIso(grant.expiresAt),
|
|
1227
|
+
host: grant.host,
|
|
1228
|
+
address: grant.address,
|
|
1229
|
+
join_protocol: grant.joinProtocol,
|
|
386
1230
|
};
|
|
387
1231
|
}
|
|
388
1232
|
function writeOccupancyRecord(projectRoot, record, fence) {
|