@kontextmind/kxm 0.7.70 → 0.7.72

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.
@@ -4,15 +4,20 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
4
4
  import { isIP } from "node:net";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import {
7
+ DEFAULT_LEASE_TTL_MS,
7
8
  DEFAULT_MAX_HOPS,
8
9
  DEFAULT_MESSAGE_RETENTION_MS,
9
10
  DEFAULT_MESSAGE_TTL_MS,
10
11
  DEFAULT_RATE_LIMIT_MAX,
11
12
  DEFAULT_RATE_LIMIT_WINDOW_MS,
12
13
  DEFAULT_STALE_AFTER_MS,
14
+ MAX_AGENT_HOST_CHARS,
13
15
  MAX_BODY_BYTES,
14
16
  MAX_CONTENT_CHARS,
17
+ MAX_LEASE_RESOURCE_CHARS,
18
+ MAX_LEASE_TTL_MS,
15
19
  MAX_MESSAGE_TTL_MS,
20
+ MIN_LEASE_TTL_MS,
16
21
  MIN_MESSAGE_TTL_MS,
17
22
  MIN_MESSAGE_RETENTION_MS,
18
23
  ProtocolError,
@@ -22,9 +27,11 @@ import {
22
27
  parseBoundedInteger,
23
28
  parseDeliveryMode,
24
29
  requireString,
30
+ toAgentRecord,
25
31
  type AgentRecord,
26
32
  type DeliveryMode,
27
33
  type HubEvent,
34
+ type LeaseRecord,
28
35
  type MessageRecord,
29
36
  type WorkflowMessageContext,
30
37
  } from "./protocol.ts";
@@ -37,7 +44,7 @@ import { NativeStateProvider } from "./state.ts";
37
44
  import { SkillLifecycle } from "./skills.ts";
38
45
  import { compileKnowledgeWiki, lintKnowledgeWiki, type WikiSourcePool } from "./wiki.ts";
39
46
  import { buildRetrospective, writeRetrospective } from "./retrospective.ts";
40
- import { MeshStore, type StoredAgent } from "./store.ts";
47
+ import { MeshStore, type LeaseRefusal, type StoredAgent } from "./store.ts";
41
48
  import {
42
49
  canonicalWorkflowEvidenceKey,
43
50
  approveWorkflowDegradation,
@@ -89,6 +96,10 @@ export interface MeshHubOptions {
89
96
  skillsDir?: string;
90
97
  skillLifecycle?: SkillLifecycle;
91
98
  repoRoot?: string;
99
+ /** The hub clock every lease decision reads. Injectable so a test can move a
100
+ * lease past its deadline instead of sleeping through a real TTL; production
101
+ * leaves it at `Date.now`. It is never a client-supplied time. */
102
+ now?: () => number;
92
103
  }
93
104
 
94
105
  export interface MeshHub {
@@ -118,15 +129,50 @@ function isLoopback(host: string): boolean {
118
129
  return isIP(host) === 4 && host.startsWith("127.");
119
130
  }
120
131
 
121
- function publicAgent(agent: StoredAgent): AgentRecord {
122
- const { key: _key, ...record } = agent;
123
- return record;
132
+ /** Drop the agent key and attach hub-clocked presence. Every caller passes the
133
+ * hub's own `staleAfterMs` and clock, so no reader ever sees a lease derived
134
+ * from a client timestamp. */
135
+ function publicAgent(agent: StoredAgent, staleAfterMs: number, now = Date.now()): AgentRecord {
136
+ const { key: _key, ...identity } = agent;
137
+ return toAgentRecord(identity, staleAfterMs, now);
124
138
  }
125
139
 
126
140
  function safeTokenEqual(actual: string | undefined, expected: string): boolean {
127
141
  return timingSafeStringCompare(actual, expected);
128
142
  }
129
143
 
144
+ /** The wire answer for a lease the hub will not extend or release. Every one of
145
+ * these means the caller's token no longer authorizes a shared write: the
146
+ * holder must stop, not retry. The current lease rides along so the caller can
147
+ * record who holds the resource now. */
148
+ function leaseRefusal(reason: LeaseRefusal, name: string, lease?: LeaseRecord): ProtocolError {
149
+ if (reason === "missing") {
150
+ return new ProtocolError(404, `no lease is held on ${name}`, "lease_not_found");
151
+ }
152
+ if (reason === "expired") {
153
+ return new ProtocolError(
154
+ 409,
155
+ `lease on ${name} expired at ${lease?.expiresAt ?? "its deadline"}; re-acquire to take it over`,
156
+ "lease_expired",
157
+ { lease },
158
+ );
159
+ }
160
+ if (reason === "held") {
161
+ return new ProtocolError(
162
+ 409,
163
+ `lease on ${name} is held by ${lease?.holderAgentName ?? "another agent"} until ${lease?.expiresAt ?? "its deadline"}`,
164
+ "lease_held",
165
+ { lease },
166
+ );
167
+ }
168
+ return new ProtocolError(
169
+ 409,
170
+ `fencing token for ${name} was superseded; the hub is now at token ${lease?.fencingToken ?? "a newer value"}`,
171
+ "lease_superseded",
172
+ { lease },
173
+ );
174
+ }
175
+
130
176
  function bearerToken(request: IncomingMessage): string | undefined {
131
177
  const header = request.headers.authorization;
132
178
  return header?.startsWith("Bearer ") ? header.slice(7) : undefined;
@@ -404,6 +450,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
404
450
  };
405
451
  const webhookWorkflows = new Map((options.webhookWorkflows ?? []).map((workflow) => [workflow.id, workflow]));
406
452
  const logger = options.logger ?? (() => undefined);
453
+ const hubNow = options.now ?? (() => Date.now());
407
454
  const assetsDir = options.assetsDir;
408
455
  const hubRepoRoot = options.repoRoot ?? (options.dataPath && options.dataPath !== ":memory:" ? resolve(dirname(dirname(options.dataPath))) : process.cwd());
409
456
  const store = new MeshStore(options.dataPath);
@@ -456,6 +503,9 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
456
503
  contextRequests: 0,
457
504
  attemptLatencySecondsTotal: 0,
458
505
  meteredCostUsdTotal: 0,
506
+ leasesGranted: 0,
507
+ leasesRefused: 0,
508
+ leasesReleased: 0,
459
509
  };
460
510
  let cleanupTimer: NodeJS.Timeout | undefined;
461
511
  let closed = false;
@@ -623,9 +673,10 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
623
673
  }
624
674
 
625
675
  function opsSnapshot(project: string) {
676
+ const snapshotAt = Date.now();
626
677
  const projectAgents = [...agents.values()]
627
678
  .filter((agent) => agent.project === project)
628
- .map(publicAgent)
679
+ .map((agent) => publicAgent(agent, staleAfterMs, snapshotAt))
629
680
  .sort((left, right) => Number(right.online) - Number(left.online) || left.name.localeCompare(right.name));
630
681
  const open = store.getOpenMessages(project);
631
682
  const projectRuns = [...workflowRuns.values()]
@@ -683,7 +734,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
683
734
  function broadcastPresence(agent: StoredAgent): void {
684
735
  for (const candidate of agents.values()) {
685
736
  if (candidate.project === agent.project && candidate.id !== agent.id && candidate.online) {
686
- publish(candidate.id, { type: "presence", agent: publicAgent(agent) });
737
+ publish(candidate.id, { type: "presence", agent: publicAgent(agent, staleAfterMs) });
687
738
  }
688
739
  }
689
740
  publishOps(agent.project, "agents");
@@ -1059,6 +1110,16 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
1059
1110
  for (const runId of swept.purgedRuns) {
1060
1111
  logger({ event: "workflow_run_purged", runId });
1061
1112
  }
1113
+ for (const lease of swept.purgedLeases) {
1114
+ logger({
1115
+ event: "lease_purged",
1116
+ resource: lease.resource,
1117
+ project: lease.project,
1118
+ agentId: lease.holderAgentId,
1119
+ fencingToken: lease.fencingToken,
1120
+ expiresAt: lease.expiresAt,
1121
+ });
1122
+ }
1062
1123
  }
1063
1124
 
1064
1125
  function metricsBody(): string {
@@ -1106,6 +1167,12 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
1106
1167
  `kxm_attempt_latency_seconds_total ${counters.attemptLatencySecondsTotal}`,
1107
1168
  "# TYPE kxm_metered_cost_usd_total counter",
1108
1169
  `kxm_metered_cost_usd_total ${counters.meteredCostUsdTotal}`,
1170
+ "# TYPE kxm_leases_granted_total counter",
1171
+ `kxm_leases_granted_total ${counters.leasesGranted}`,
1172
+ "# TYPE kxm_leases_refused_total counter",
1173
+ `kxm_leases_refused_total ${counters.leasesRefused}`,
1174
+ "# TYPE kxm_leases_released_total counter",
1175
+ `kxm_leases_released_total ${counters.leasesReleased}`,
1109
1176
  "",
1110
1177
  ].join("\n");
1111
1178
  }
@@ -2109,6 +2176,9 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2109
2176
  const project = requireString(body.project, "project", { max: 128 });
2110
2177
  requireProjectAuth(request, project);
2111
2178
  const model = optionalString(body.model, "model", 128);
2179
+ // A client declares which box it runs on. The label is a reading aid
2180
+ // only: the project token is still the whole of admission.
2181
+ const hostLabel = optionalString(body.host, "host", MAX_AGENT_HOST_CHARS);
2112
2182
  const existing = [...agents.values()].find(
2113
2183
  (agent) => agent.project === project && agent.name.toLowerCase() === name.toLowerCase(),
2114
2184
  );
@@ -2135,20 +2205,34 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2135
2205
  agent.online = true;
2136
2206
  if (model) agent.model = model;
2137
2207
  else delete agent.model;
2208
+ if (hostLabel) agent.host = hostLabel;
2209
+ else delete agent.host;
2138
2210
  store.saveAgent(agent);
2139
2211
  counters.registrations += 1;
2140
- logger({ event: existing ? "agent_resumed" : "agent_registered", agentId: agent.id, name, project });
2212
+ logger({
2213
+ event: existing ? "agent_resumed" : "agent_registered",
2214
+ agentId: agent.id,
2215
+ name,
2216
+ project,
2217
+ ...(hostLabel ? { host: hostLabel } : {}),
2218
+ });
2141
2219
  broadcastPresence(agent);
2142
- json(response, existing ? 200 : 201, { agent: publicAgent(agent), agentKey: agent.key, resumed: Boolean(existing) });
2220
+ json(response, existing ? 200 : 201, { agent: publicAgent(agent, staleAfterMs), agentKey: agent.key, resumed: Boolean(existing) });
2143
2221
  return;
2144
2222
  }
2145
2223
 
2146
2224
  if (method === "GET" && url.pathname === "/v1/agents") {
2147
2225
  const current = requireAgent(request);
2148
2226
  requireProjectAuth(request, current.project);
2227
+ // Offline members are registered peers of the caller's own project
2228
+ // whose lease the sweep has already retired. They stay out of the
2229
+ // list unless a caller asks for them, so discovery keeps meaning
2230
+ // "who can answer right now".
2231
+ const includeOffline = url.searchParams.get("includeOffline") === "true";
2232
+ const listedAt = Date.now();
2149
2233
  const result = [...agents.values()]
2150
- .filter((agent) => agent.project === current.project && agent.online)
2151
- .map(publicAgent);
2234
+ .filter((agent) => agent.project === current.project && (agent.online || includeOffline))
2235
+ .map((agent) => publicAgent(agent, staleAfterMs, listedAt));
2152
2236
  json(response, 200, { agents: result });
2153
2237
  return;
2154
2238
  }
@@ -2158,7 +2242,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2158
2242
  const current = requireAgent(request, decodeURIComponent(heartbeatMatch[1]!));
2159
2243
  requireProjectAuth(request, current.project);
2160
2244
  await readJson(request);
2161
- json(response, 200, { agent: publicAgent(current) });
2245
+ json(response, 200, { agent: publicAgent(current, staleAfterMs) });
2162
2246
  return;
2163
2247
  }
2164
2248
 
@@ -2175,6 +2259,81 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2175
2259
  return;
2176
2260
  }
2177
2261
 
2262
+ // One writer, one clock. Every branch below decides inside a single store
2263
+ // transaction, so two agents racing for the same resource cannot both read it
2264
+ // free. The fencing token increments only on takeover, which is what lets a
2265
+ // holder that slept past its deadline be refused at commit instead of writing
2266
+ // behind whoever replaced it.
2267
+ const leaseMatch = url.pathname.match(/^\/v1\/leases\/([^/]+)\/(acquire|renew|release)$/);
2268
+ if (method === "POST" && leaseMatch) {
2269
+ const holder = requireAgent(request);
2270
+ requireProjectAuth(request, holder.project);
2271
+ const name = requireString(decodeURIComponent(leaseMatch[1]!), "resource", { max: MAX_LEASE_RESOURCE_CHARS });
2272
+ const action = leaseMatch[2]!;
2273
+ const body = await readJson(request);
2274
+ // The caller never names the prefix, so the same branch in two projects is
2275
+ // two leases and neither project can reach the other's.
2276
+ const resource = `${holder.project}/${name}`;
2277
+ const nowMs = hubNow();
2278
+ const leaseLog = { resource, project: holder.project, agentId: holder.id, agentName: holder.name };
2279
+
2280
+ if (action === "acquire") {
2281
+ const ttlMs = parseBoundedInteger(body.ttlMs, "ttlMs", DEFAULT_LEASE_TTL_MS, MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS);
2282
+ const outcome = store.acquireLease({
2283
+ resource,
2284
+ project: holder.project,
2285
+ name,
2286
+ holderAgentId: holder.id,
2287
+ holderAgentName: holder.name,
2288
+ ttlMs,
2289
+ nowMs,
2290
+ });
2291
+ if (!outcome.ok) {
2292
+ counters.leasesRefused += 1;
2293
+ logger({ event: "lease_denied", ...leaseLog, reason: outcome.reason, heldBy: outcome.lease?.holderAgentId });
2294
+ throw leaseRefusal(outcome.reason, name, outcome.lease);
2295
+ }
2296
+ counters.leasesGranted += 1;
2297
+ logger({
2298
+ event: outcome.renewed ? "lease_renewed" : "lease_acquired",
2299
+ ...leaseLog,
2300
+ fencingToken: outcome.lease.fencingToken,
2301
+ expiresAt: outcome.lease.expiresAt,
2302
+ });
2303
+ json(response, 200, { lease: outcome.lease, renewed: outcome.renewed });
2304
+ return;
2305
+ }
2306
+
2307
+ if (body.fencingToken === undefined) {
2308
+ throw new ProtocolError(400, "fencingToken is required", "lease_token_required");
2309
+ }
2310
+ const fencingToken = parseBoundedInteger(body.fencingToken, "fencingToken", 1, 1, Number.MAX_SAFE_INTEGER);
2311
+ if (action === "renew") {
2312
+ const ttlMs = parseBoundedInteger(body.ttlMs, "ttlMs", DEFAULT_LEASE_TTL_MS, MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS);
2313
+ const outcome = store.renewLease({ resource, holderAgentId: holder.id, fencingToken, ttlMs, nowMs });
2314
+ if (!outcome.ok) {
2315
+ counters.leasesRefused += 1;
2316
+ logger({ event: "lease_denied", ...leaseLog, reason: outcome.reason, fencingToken });
2317
+ throw leaseRefusal(outcome.reason, name, outcome.lease);
2318
+ }
2319
+ counters.leasesGranted += 1;
2320
+ logger({ event: "lease_renewed", ...leaseLog, fencingToken, expiresAt: outcome.lease.expiresAt });
2321
+ json(response, 200, { lease: outcome.lease, renewed: true });
2322
+ return;
2323
+ }
2324
+
2325
+ const outcome = store.releaseLease({ resource, holderAgentId: holder.id, fencingToken });
2326
+ if (!outcome.ok) {
2327
+ counters.leasesRefused += 1;
2328
+ logger({ event: "lease_denied", ...leaseLog, reason: outcome.reason, fencingToken });
2329
+ throw leaseRefusal(outcome.reason, name, outcome.lease);
2330
+ }
2331
+ counters.leasesReleased += 1;
2332
+ logger({ event: "lease_released", ...leaseLog, fencingToken });
2333
+ json(response, 200, { released: true, lease: outcome.lease });
2334
+ return;
2335
+ }
2336
+
2178
2337
  if (method === "GET" && url.pathname === "/v1/events") {
2179
2338
  const agentId = requireString(url.searchParams.get("agentId"), "agentId", { max: 80 });
2180
2339
  const current = requireAgent(request, agentId);
@@ -2191,7 +2350,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2191
2350
  "x-content-type-options": "nosniff",
2192
2351
  ...(presenceOnly ? { "x-kxm-events-mode": "presence" } : {}),
2193
2352
  });
2194
- response.write(`event: ready\ndata: ${JSON.stringify({ agent: publicAgent(current) })}\n\n`);
2353
+ response.write(`event: ready\ndata: ${JSON.stringify({ agent: publicAgent(current, staleAfterMs) })}\n\n`);
2195
2354
  const client: SseClient = {
2196
2355
  response,
2197
2356
  heartbeat: setInterval(() => response.write(": heartbeat\n\n"), 15_000),
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, join, resolve } from "node:path";
4
4
  import { DatabaseSync } from "./sqlite.ts";
5
- import type { AgentRecord, MessageRecord } from "./protocol.ts";
5
+ import type { AgentIdentity, AgentRecord, MessageRecord } from "./protocol.ts";
6
6
  import type { WorkflowRun } from "./workflow.ts";
7
7
  import { readRoutingRecords } from "./telemetry.ts";
8
8
  import type { RoutingRecord, RoutingRecordV2 } from "./routing.ts";
@@ -253,7 +253,15 @@ export function loadLocalMeshSnapshot(
253
253
  const database = new DatabaseSync(dataPath, { readOnly: true });
254
254
  try {
255
255
  database.exec("PRAGMA busy_timeout = 5000");
256
- agents = readJsonRows<AgentRecord>(database, "SELECT record FROM agents");
256
+ // Stored rows carry identity only. Presence is derived here against the
257
+ // default lease window, because the hub's configured `staleAfterMs` is
258
+ // not in the file; a reachable hub's own records replace these.
259
+ // Stored rows carry identity only. Presence is NOT computed here: the hub's
260
+ // configured staleAfterMs and its clock are not in the file, so a reader-side
261
+ // lease would mislabel agents against the hub's own projection. The stored
262
+ // `online` boolean is the only durable truth; the hub's /v1/agents or the
263
+ // ops snapshot is the authoritative presence source.
264
+ agents = readJsonRows<AgentIdentity>(database, "SELECT record FROM agents");
257
265
  openMessages = readOpenMessageMetadata(database);
258
266
  openMessageTotal = countRows(database, "messages", " WHERE json_extract(record, '$.status') IN ('queued', 'delivered')");
259
267
  legacyRuns = readJsonRows<WorkflowRun>(database, "SELECT record FROM workflow_runs ORDER BY rowid DESC LIMIT 8");
@@ -8,7 +8,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
8
8
  import { deliverInboxNotification } from "./inbox.ts";
9
9
  import type { HubEvent, MessageRecord } from "./protocol.ts";
10
10
 
11
- const VERSION = "0.7.70";
11
+ const VERSION = "0.7.72";
12
12
  const inbox = new Map<string, MessageRecord>();
13
13
  const notifiedInbox = new Set<string>();
14
14
  let meshClient: HubClient | undefined;
@@ -12,21 +12,87 @@ export const DEFAULT_RATE_LIMIT_MAX = 600;
12
12
  export const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
13
13
  export const MAX_BODY_BYTES = 256 * 1024;
14
14
  export const MAX_CONTENT_CHARS = 32_000;
15
+ export const MAX_AGENT_HOST_CHARS = 64;
16
+ export const MIN_LEASE_TTL_MS = 5_000;
17
+ export const MAX_LEASE_TTL_MS = 10 * 60_000;
18
+ export const DEFAULT_LEASE_TTL_MS = 5 * 60_000;
19
+ export const MAX_LEASE_RESOURCE_CHARS = 200;
15
20
 
16
21
  export type DeliveryMode = "steer" | "followUp" | "nextTurn";
17
22
  export type MessageStatus = "queued" | "delivered" | "replied" | "cancelled" | "expired" | "error";
23
+ export type AgentPresence = "online" | "stale" | "offline";
18
24
 
19
- export interface AgentRecord {
25
+ /** The durable half of an agent: exactly the fields the hub persists in the
26
+ * agents record JSON. Presence is never stored, so a restored database can
27
+ * never claim an agent was alive. */
28
+ export interface AgentIdentity {
20
29
  id: string;
21
30
  name: string;
22
31
  purpose: string;
23
32
  project: string;
24
33
  model?: string;
34
+ /** Client-declared label for the box this agent runs on. It exists so a
35
+ * reader can tell two boxes apart; the hub never reads it for
36
+ * authorization, project scope, or target resolution. */
37
+ host?: string;
25
38
  connectedAt: string;
26
39
  lastSeenAt: string;
27
40
  online: boolean;
28
41
  }
29
42
 
43
+ /** The public projection of an agent: durable identity plus presence derived
44
+ * from the hub clock at read time. */
45
+ export interface AgentRecord extends AgentIdentity {
46
+ /** `lastSeenAt + staleAfterMs`: the heartbeat lease the stale sweep enforces.
47
+ * Absent when projected from the local store without the hub's clock or lease. */
48
+ leaseExpiresAt?: string;
49
+ /** Hub-clocked presence. Absent when the reader has no hub projection. */
50
+ presence?: AgentPresence;
51
+ }
52
+
53
+ /** Presence is hub-clocked and never client-reported. An agent holds its lease
54
+ * until `lastSeenAt + staleAfterMs`; between lease expiry and the sweep that
55
+ * retires it, it reads `stale`; once retired it reads `offline`. An
56
+ * unparseable `lastSeenAt` leaves the lease at the epoch, which reads as an
57
+ * expired lease rather than as a live agent. */
58
+ export function agentPresenceView(
59
+ agent: AgentIdentity,
60
+ staleAfterMs: number = DEFAULT_STALE_AFTER_MS,
61
+ now: number = Date.now(),
62
+ ): { leaseExpiresAt: string; presence: AgentPresence } {
63
+ const lastSeenMs = Date.parse(agent.lastSeenAt);
64
+ const leaseExpiresAtMs = (Number.isFinite(lastSeenMs) ? lastSeenMs : 0) + staleAfterMs;
65
+ const leaseExpiresAt = new Date(leaseExpiresAtMs).toISOString();
66
+ if (!agent.online) return { leaseExpiresAt, presence: "offline" };
67
+ return { leaseExpiresAt, presence: now < leaseExpiresAtMs ? "online" : "stale" };
68
+ }
69
+
70
+ /** Project a durable identity onto the wire shape readers consume. */
71
+ export function toAgentRecord(agent: AgentIdentity, staleAfterMs?: number, now?: number): AgentRecord {
72
+ return { ...agent, ...agentPresenceView(agent, staleAfterMs, now) };
73
+ }
74
+
75
+ /** One hub-held fenced lease over a project-scoped resource.
76
+ *
77
+ * `resource` is what the store keys on: the caller's project prefixed onto the
78
+ * name it asked for, so two projects naming the same branch never contend.
79
+ * Expiry is the hub's clock, never the holder's, and `fencingToken` increments
80
+ * only when a new holder takes over an expired lease — a renewal keeps its
81
+ * token, so a writer that comes back after a takeover can be told its token
82
+ * was superseded instead of being allowed to commit. */
83
+ export interface LeaseRecord {
84
+ /** `${project}/${name}`. */
85
+ resource: string;
86
+ project: string;
87
+ /** The resource as the caller named it, without the project prefix. */
88
+ name: string;
89
+ holderAgentId: string;
90
+ holderAgentName: string;
91
+ fencingToken: number;
92
+ acquiredAt: string;
93
+ expiresAt: string;
94
+ }
95
+
30
96
  export interface MessageReply {
31
97
  content: string;
32
98
  createdAt: string;