@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.
@@ -1,5 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import type { AgentRecord, DeliveryMode, HubEvent, MessageRecord, WorkflowMessageContext } from "./protocol.ts";
2
+ import { hostname } from "node:os";
3
+ import { MAX_AGENT_HOST_CHARS } from "./protocol.ts";
4
+ import type { AgentRecord, DeliveryMode, HubEvent, LeaseRecord, MessageRecord, WorkflowMessageContext } from "./protocol.ts";
3
5
  import type { ContextAuthority, ContextConfidence, ContextItem, ContextItemAuditMetadata, ContextItemKind, ContextPacket } from "./context.ts";
4
6
  import {
5
7
  canonicalWorkflowEvidenceKey,
@@ -52,6 +54,10 @@ export interface HubClientOptions {
52
54
  purpose: string;
53
55
  project: string;
54
56
  model?: string;
57
+ /** Label for the box this client runs on, declared at registration.
58
+ * Defaults to the machine hostname; the hub records it for readers and
59
+ * never uses it to authorize anything. */
60
+ host?: string;
55
61
  heartbeatMs?: number;
56
62
  reconnectMs?: number;
57
63
  requestTimeoutMs?: number;
@@ -94,6 +100,17 @@ class MeshWaitError extends Error {
94
100
  }
95
101
  }
96
102
 
103
+ /** The box label a client declares when it registers. Bounded to the hub's
104
+ * limit so a long hostname cannot turn registration into a 400, and dropped
105
+ * entirely when the platform cannot report one. */
106
+ function defaultHostLabel(): string | undefined {
107
+ try {
108
+ return hostname().trim().slice(0, MAX_AGENT_HOST_CHARS) || undefined;
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ }
113
+
97
114
  function completedFanoutResult(target: string, message: MessageRecord): FanoutResult {
98
115
  if (message.status === "queued" || message.status === "delivered") {
99
116
  throw new Error(`message ${message.id} is not complete`);
@@ -208,8 +225,11 @@ export class HubClient {
208
225
  this.eventLoop = undefined;
209
226
  }
210
227
 
211
- async listAgents(): Promise<AgentRecord[]> {
212
- const result = await this.request<{ agents: AgentRecord[] }>("/v1/agents");
228
+ /** Online peers of this client's project. `includeOffline` also returns
229
+ * registered members whose lease the hub has already retired. */
230
+ async listAgents(options: { includeOffline?: boolean } = {}): Promise<AgentRecord[]> {
231
+ const path = options.includeOffline ? "/v1/agents?includeOffline=true" : "/v1/agents";
232
+ const result = await this.request<{ agents: AgentRecord[] }>(path);
213
233
  return result.agents;
214
234
  }
215
235
 
@@ -321,6 +341,41 @@ export class HubClient {
321
341
  return result.message;
322
342
  }
323
343
 
344
+ // ----- Fenced leases over shared resources (P3) -----
345
+
346
+ /**
347
+ * Take or extend the lease over `resource` inside this client's project.
348
+ *
349
+ * The returned `fencingToken` is the whole point: hold it, present it on every
350
+ * renewal, and present it again before committing anything shared. A hub that
351
+ * has moved past it refuses, and the caller must stop rather than retry —
352
+ * another holder owns the resource now. Rejects `HubHttpError` with code
353
+ * `lease_held` when a live holder has it.
354
+ */
355
+ async acquireLease(resource: string, ttlMs?: number): Promise<{ lease: LeaseRecord; renewed: boolean }> {
356
+ return await this.request(`/v1/leases/${encodeURIComponent(resource)}/acquire`, {
357
+ method: "POST",
358
+ body: JSON.stringify(ttlMs === undefined ? {} : { ttlMs }),
359
+ });
360
+ }
361
+
362
+ /** Extend a lease this client holds. The token never changes on renewal; a
363
+ * `lease_superseded` or `lease_expired` refusal means it is gone. */
364
+ async renewLease(resource: string, fencingToken: number, ttlMs?: number): Promise<{ lease: LeaseRecord }> {
365
+ return await this.request(`/v1/leases/${encodeURIComponent(resource)}/renew`, {
366
+ method: "POST",
367
+ body: JSON.stringify(ttlMs === undefined ? { fencingToken } : { fencingToken, ttlMs }),
368
+ });
369
+ }
370
+
371
+ /** Give the resource back. */
372
+ async releaseLease(resource: string, fencingToken: number): Promise<{ released: boolean; lease: LeaseRecord }> {
373
+ return await this.request(`/v1/leases/${encodeURIComponent(resource)}/release`, {
374
+ method: "POST",
375
+ body: JSON.stringify({ fencingToken }),
376
+ });
377
+ }
378
+
324
379
  async listWorkflows(): Promise<WorkflowRun[]> {
325
380
  const result = await this.request<{ runs: WorkflowRun[] }>("/v1/workflows");
326
381
  return result.runs;
@@ -555,6 +610,7 @@ export class HubClient {
555
610
  purpose: this.options.purpose,
556
611
  project: this.options.project,
557
612
  model: this.options.model,
613
+ host: this.options.host ?? defaultHostLabel(),
558
614
  }),
559
615
  }, false);
560
616
  this.agent = registration.agent;
@@ -604,6 +660,9 @@ export class HubClient {
604
660
  for (const key of ["operation", "nextAction", "assignedCoordinatorName"]) {
605
661
  if (typeof body[key] === "string") extras[key] = body[key];
606
662
  }
663
+ // A lease refusal carries the lease that won, so the loser can record who
664
+ // holds the resource and at which token instead of guessing.
665
+ if (body.lease && typeof body.lease === "object") extras.lease = body.lease;
607
666
  throw new HubHttpError(
608
667
  response.status,
609
668
  String(body.error ?? `HTTP ${response.status}`),
@@ -194,14 +194,20 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
194
194
  group: "peer",
195
195
  verb: "list",
196
196
  label: "List hub peers",
197
- description: "List online peer agents in this project's hub pool, including their names and purposes.",
197
+ description:
198
+ "List peer agents in this project's hub pool with their names, purposes, host label, and hub-clocked presence (online, stale, offline). Registered offline peers are listed only when includeOffline is set.",
198
199
  parameters: {
199
200
  type: "object",
200
- properties: {},
201
+ properties: {
202
+ includeOffline: {
203
+ type: "boolean",
204
+ description: "Also list registered peers whose hub lease has expired",
205
+ },
206
+ },
201
207
  additionalProperties: false,
202
208
  },
203
- async execute(client) {
204
- return { agents: await client.listAgents() };
209
+ async execute(client, args) {
210
+ return { agents: await client.listAgents({ includeOffline: args.includeOffline === true }) };
205
211
  },
206
212
  },
207
213
  {
@@ -591,7 +591,9 @@ export function discoverProjectStores(projectRoot: string, options: { hubDataPat
591
591
 
592
592
  const hubPath = options.hubDataPath ? resolve(options.hubDataPath) : join(root, ".kxm", "state", "kxm.db");
593
593
  if (existsSync(hubPath)) {
594
- stores.push({ storeId: "hub-store", sourcePath: hubPath, maxSupportedVersion: 3 });
594
+ // Must track HUB_STORE_SCHEMA_VERSION in store.ts: the hub's own fresh backup is
595
+ // restored through this ceiling, so a bump left behind here refuses it.
596
+ stores.push({ storeId: "hub-store", sourcePath: hubPath, maxSupportedVersion: 4 });
595
597
  }
596
598
 
597
599
  const registryPath = join(root, ".kxm", "runtime", "registry.db");
@@ -727,7 +729,9 @@ export function restoreBackup(
727
729
  );
728
730
  }
729
731
 
730
- let maxSupported = 3;
732
+ // The hub-store ceiling. Must track HUB_STORE_SCHEMA_VERSION in store.ts for the
733
+ // same reason as the events ceiling below.
734
+ let maxSupported = 4;
731
735
  if (store.storeId === "registry" || store.storeId === "binding-store") {
732
736
  maxSupported = 1;
733
737
  } else if (store.storeId.startsWith("events:")) {
@@ -48,6 +48,11 @@ export interface ExternalEffectReceipt {
48
48
  executedAt: string;
49
49
  lastHeartbeatAt?: string | undefined;
50
50
  completedAt?: string | undefined;
51
+ /** The hub lease this effect executes under, for shared kinds only. The
52
+ * ledger's own CAS is per run/step; this pair is what fences the effect
53
+ * against a writer on another box. */
54
+ leaseResource?: string | undefined;
55
+ fencingToken?: number | undefined;
51
56
  }
52
57
 
53
58
  export interface RunBranchOptions {
@@ -115,7 +120,9 @@ export function computeEffectKey(
115
120
  return `eff_${createHash("sha256").update(raw).digest("hex").slice(0, 16)}`;
116
121
  }
117
122
 
118
- const EXTERNAL_EFFECTS_SCHEMA_VERSION = 1;
123
+ // v1 → v2 adds the hub lease identity a shared effect executes under. There is no
124
+ // migration lane here either: an older ledger file is refused at open, not reshaped.
125
+ const EXTERNAL_EFFECTS_SCHEMA_VERSION = 2;
119
126
 
120
127
  const EXTERNAL_EFFECTS_DDL = `
121
128
  CREATE TABLE IF NOT EXISTS external_effects (
@@ -130,7 +137,9 @@ CREATE TABLE IF NOT EXISTS external_effects (
130
137
  receipt_payload TEXT NOT NULL,
131
138
  executed_at TEXT NOT NULL,
132
139
  last_heartbeat_at TEXT,
133
- completed_at TEXT
140
+ completed_at TEXT,
141
+ lease_resource TEXT,
142
+ fencing_token INTEGER
134
143
  );
135
144
  CREATE INDEX IF NOT EXISTS idx_ext_effects_run ON external_effects(run_id);
136
145
  `;
@@ -139,9 +148,47 @@ const EXTERNAL_EFFECTS_SHAPE = {
139
148
  external_effects: [
140
149
  "effect_key", "run_id", "step_id", "attempt_id", "action_kind", "target_ref",
141
150
  "status", "payload_hash", "receipt_payload", "executed_at", "last_heartbeat_at", "completed_at",
151
+ "lease_resource", "fencing_token",
142
152
  ],
143
153
  } as const;
144
154
 
155
+ interface EffectRow {
156
+ effect_key: string;
157
+ run_id: string;
158
+ step_id: string;
159
+ attempt_id: string;
160
+ action_kind: ExternalActionKind;
161
+ target_ref: string;
162
+ status: ExternalEffectStatus;
163
+ payload_hash: string;
164
+ receipt_payload: string;
165
+ executed_at: string;
166
+ last_heartbeat_at: string | null;
167
+ completed_at: string | null;
168
+ lease_resource: string | null;
169
+ fencing_token: number | null;
170
+ }
171
+
172
+ function receiptFromRow(row: EffectRow): ExternalEffectReceipt {
173
+ return {
174
+ schema: EXTERNAL_EFFECT_SCHEMA,
175
+ effectKey: row.effect_key,
176
+ runId: row.run_id,
177
+ stepId: row.step_id,
178
+ attemptId: row.attempt_id,
179
+ actionKind: row.action_kind,
180
+ targetRef: row.target_ref,
181
+ status: row.status,
182
+ payloadHash: row.payload_hash,
183
+ receiptPayload: JSON.parse(row.receipt_payload) as Record<string, unknown>,
184
+ executedAt: row.executed_at,
185
+ lastHeartbeatAt: row.last_heartbeat_at ?? undefined,
186
+ completedAt: row.completed_at ?? undefined,
187
+ leaseResource: row.lease_resource ?? undefined,
188
+ fencingToken: row.fencing_token ?? undefined,
189
+ };
190
+ }
191
+
145
192
  export class ExternalEffectsLedger {
146
193
  private db: DatabaseSync;
147
194
 
@@ -173,6 +220,10 @@ export class ExternalEffectsLedger {
173
220
  targetRef: string;
174
221
  payload?: Record<string, unknown> | undefined;
175
222
  timeoutMs?: number | undefined;
223
+ /** The hub lease this effect runs under. Shared kinds carry it; unique
224
+ * namespaces (`git-branch`, `git-commit`) leave it unset. */
225
+ leaseResource?: string | undefined;
226
+ fencingToken?: number | undefined;
176
227
  }): { ok: true; effectKey: string } | { ok: false; error: string; existing?: ExternalEffectReceipt } {
177
228
  const effectKey = computeEffectKey(input.runId, input.stepId, input.actionKind, input.targetRef);
178
229
  const now = new Date().toISOString();
@@ -206,15 +257,18 @@ export class ExternalEffectsLedger {
206
257
  const stmt = this.db.prepare(`
207
258
  INSERT INTO external_effects (
208
259
  effect_key, run_id, step_id, attempt_id, action_kind, target_ref,
209
- status, payload_hash, receipt_payload, executed_at, last_heartbeat_at
210
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
260
+ status, payload_hash, receipt_payload, executed_at, last_heartbeat_at,
261
+ lease_resource, fencing_token
262
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
211
263
  ON CONFLICT(effect_key) DO UPDATE SET
212
264
  attempt_id = excluded.attempt_id,
213
265
  status = 'in-flight',
214
266
  executed_at = excluded.executed_at,
215
267
  last_heartbeat_at = excluded.last_heartbeat_at,
216
268
  payload_hash = excluded.payload_hash,
217
- receipt_payload = excluded.receipt_payload
269
+ receipt_payload = excluded.receipt_payload,
270
+ lease_resource = excluded.lease_resource,
271
+ fencing_token = excluded.fencing_token
218
272
  `);
219
273
 
220
274
  stmt.run(
@@ -229,6 +283,8 @@ export class ExternalEffectsLedger {
229
283
  payloadStr,
230
284
  now,
231
285
  now,
286
+ input.leaseResource ?? null,
287
+ input.fencingToken ?? null,
232
288
  );
233
289
 
234
290
  return { ok: true, effectKey };
@@ -286,74 +342,17 @@ export class ExternalEffectsLedger {
286
342
  const stmt = this.db.prepare(`
287
343
  SELECT * FROM external_effects WHERE effect_key = ?
288
344
  `);
289
- const row = stmt.get(effectKey) as {
290
- effect_key: string;
291
- run_id: string;
292
- step_id: string;
293
- attempt_id: string;
294
- action_kind: ExternalActionKind;
295
- target_ref: string;
296
- status: ExternalEffectStatus;
297
- payload_hash: string;
298
- receipt_payload: string;
299
- executed_at: string;
300
- last_heartbeat_at: string | null;
301
- completed_at: string | null;
302
- } | undefined;
303
-
345
+ const row = stmt.get(effectKey) as EffectRow | undefined;
304
346
  if (!row) return undefined;
305
-
306
- return {
307
- schema: EXTERNAL_EFFECT_SCHEMA,
308
- effectKey: row.effect_key,
309
- runId: row.run_id,
310
- stepId: row.step_id,
311
- attemptId: row.attempt_id,
312
- actionKind: row.action_kind,
313
- targetRef: row.target_ref,
314
- status: row.status,
315
- payloadHash: row.payload_hash,
316
- receiptPayload: JSON.parse(row.receipt_payload) as Record<string, unknown>,
317
- executedAt: row.executed_at,
318
- lastHeartbeatAt: row.last_heartbeat_at ?? undefined,
319
- completedAt: row.completed_at ?? undefined,
320
- };
347
+ return receiptFromRow(row);
321
348
  }
322
349
 
323
350
  listRunEffects(runId: string): ExternalEffectReceipt[] {
324
351
  const stmt = this.db.prepare(`
325
352
  SELECT * FROM external_effects WHERE run_id = ? ORDER BY executed_at ASC
326
353
  `);
327
- const rows = stmt.all(runId) as Array<{
328
- effect_key: string;
329
- run_id: string;
330
- step_id: string;
331
- attempt_id: string;
332
- action_kind: ExternalActionKind;
333
- target_ref: string;
334
- status: ExternalEffectStatus;
335
- payload_hash: string;
336
- receipt_payload: string;
337
- executed_at: string;
338
- last_heartbeat_at: string | null;
339
- completed_at: string | null;
340
- }>;
341
-
342
- return rows.map((row) => ({
343
- schema: EXTERNAL_EFFECT_SCHEMA,
344
- effectKey: row.effect_key,
345
- runId: row.run_id,
346
- stepId: row.step_id,
347
- attemptId: row.attempt_id,
348
- actionKind: row.action_kind,
349
- targetRef: row.target_ref,
350
- status: row.status,
351
- payloadHash: row.payload_hash,
352
- receiptPayload: JSON.parse(row.receipt_payload) as Record<string, unknown>,
353
- executedAt: row.executed_at,
354
- lastHeartbeatAt: row.last_heartbeat_at ?? undefined,
355
- completedAt: row.completed_at ?? undefined,
356
- }));
354
+ const rows = stmt.all(runId) as unknown as EffectRow[];
355
+ return rows.map(receiptFromRow);
357
356
  }
358
357
 
359
358
  close(): void {
@@ -361,6 +360,331 @@ export class ExternalEffectsLedger {
361
360
  }
362
361
  }
363
362
 
363
+ // ---------------------------------------------------------------------------
364
+ // Shared effects under a fenced hub lease (P3)
365
+ // ---------------------------------------------------------------------------
366
+
367
+ /**
368
+ * The lease surface a shared effect needs. Declared structurally so this module
369
+ * stays a leaf: `HubClient` satisfies it as written, and a test can stand in a
370
+ * stub without a hub.
371
+ */
372
+ export interface EffectLeaseGateway {
373
+ acquireLease(resource: string, ttlMs?: number): Promise<{ lease: EffectLease; renewed: boolean }>;
374
+ renewLease(resource: string, fencingToken: number, ttlMs?: number): Promise<{ lease: EffectLease }>;
375
+ releaseLease(resource: string, fencingToken: number): Promise<{ released: boolean }>;
376
+ }
377
+
378
+ export interface EffectLease {
379
+ resource: string;
380
+ fencingToken: number;
381
+ expiresAt: string;
382
+ holderAgentId?: string | undefined;
383
+ holderAgentName?: string | undefined;
384
+ }
385
+
386
+ export type SharedEffectRefusalCode =
387
+ /** No lease could be obtained: no gateway was bound, or the hub was unreachable. */
388
+ | "effect_lease_unavailable"
389
+ /** Another agent holds the resource right now. */
390
+ | "effect_lease_held"
391
+ /** The hub has moved past the token this effect holds. */
392
+ | "effect_lease_superseded"
393
+ | "effect_already_committed"
394
+ | "effect_in_flight"
395
+ | "effect_not_in_flight"
396
+ | "effect_not_found";
397
+
398
+ /** The engine's attempt state for an effect whose outcome cannot be proven. */
399
+ export type EffectAttemptState = "blocked_uncertain";
400
+
401
+ export interface SharedEffectRefusal {
402
+ ok: false;
403
+ code: SharedEffectRefusalCode;
404
+ error: string;
405
+ /** Present when the effect may already have touched the outside world. The
406
+ * attempt parks here and nothing retries it. */
407
+ attemptState?: EffectAttemptState | undefined;
408
+ existing?: ExternalEffectReceipt | undefined;
409
+ }
410
+
411
+ export interface SharedEffectClaim {
412
+ ok: true;
413
+ effectKey: string;
414
+ leaseResource?: string | undefined;
415
+ fencingToken?: number | undefined;
416
+ }
417
+
418
+ /** Kinds whose target is shared with every other box: two runs naming the same
419
+ * `targetRef` mean the same real thing, so exactly one may execute. */
420
+ export const SHARED_EFFECT_KINDS: readonly ExternalActionKind[] = Object.freeze([
421
+ "git-push",
422
+ "pr-create",
423
+ "tracker-issue",
424
+ "webhook",
425
+ ]);
426
+
427
+ /** Does `targetRef` name a branch this run owns? A run branch is a unique
428
+ * namespace — `deterministicRunBranch` derives it from the run id in all three
429
+ * of its formats — so pushing it contends with nobody. */
430
+ export function isRunBranchRef(runId: string, targetRef: string): boolean {
431
+ const cleanId = runId.replace(/^run_/, "");
432
+ if (!cleanId) return false;
433
+ const branch = targetRef.replace(/^refs\/heads\//, "");
434
+ if (!branch.startsWith("kxm/")) return false;
435
+ return branch === `kxm/run-${cleanId}`
436
+ || branch.startsWith(`kxm/run-${cleanId}-`)
437
+ || branch.endsWith(`-run-${cleanId}`);
438
+ }
439
+
440
+ /**
441
+ * Whether an effect must hold a hub lease before it executes.
442
+ *
443
+ * `git-branch` and `git-commit` write a namespace this run already owns, so
444
+ * they stay lease-free. A `git-push` to this run's own branch is the same case;
445
+ * a push to any other ref is shared and is fenced.
446
+ */
447
+ export function effectRequiresHubLease(
448
+ actionKind: ExternalActionKind,
449
+ targetRef: string,
450
+ runId: string,
451
+ ): boolean {
452
+ if (!SHARED_EFFECT_KINDS.includes(actionKind)) return false;
453
+ if (actionKind === "git-push") return !isRunBranchRef(runId, targetRef);
454
+ return true;
455
+ }
456
+
457
+ function errorCodeOf(error: unknown): string | undefined {
458
+ const code = (error as { code?: unknown } | undefined)?.code;
459
+ return typeof code === "string" ? code : undefined;
460
+ }
461
+
462
+ function errorText(error: unknown): string {
463
+ return error instanceof Error ? error.message : String(error);
464
+ }
465
+
466
+ /** A refusal the hub stated about the lease itself, as opposed to not reaching
467
+ * the hub at all. Any of these means the token is gone for good. */
468
+ function isLeaseLost(code: string | undefined): boolean {
469
+ return code === "lease_superseded" || code === "lease_expired" || code === "lease_not_found";
470
+ }
471
+
472
+ /**
473
+ * Claim an external effect, taking its hub lease first when the kind is shared.
474
+ *
475
+ * The lease comes before the ledger row on purpose: the receipt records the
476
+ * token it will have to present at commit, and a claim that cannot be fenced
477
+ * never becomes a claim at all. A shared effect with an unreachable hub is
478
+ * refused with `effect_lease_unavailable` and executes nothing — running it
479
+ * unfenced is exactly the two-writer failure the lease exists to prevent.
480
+ */
481
+ export async function claimSharedEffect(input: {
482
+ ledger: ExternalEffectsLedger;
483
+ lease?: EffectLeaseGateway | undefined;
484
+ runId: string;
485
+ stepId: string;
486
+ attemptId: string;
487
+ actionKind: ExternalActionKind;
488
+ targetRef: string;
489
+ payload?: Record<string, unknown> | undefined;
490
+ timeoutMs?: number | undefined;
491
+ /** Lease TTL. Defaults to the Q6 lease timeout so the existing heartbeat
492
+ * interval renews it well inside its deadline. */
493
+ leaseTtlMs?: number | undefined;
494
+ }): Promise<SharedEffectClaim | SharedEffectRefusal> {
495
+ const needsLease = effectRequiresHubLease(input.actionKind, input.targetRef, input.runId);
496
+ let lease: EffectLease | undefined;
497
+
498
+ if (needsLease) {
499
+ if (!input.lease) {
500
+ return {
501
+ ok: false,
502
+ code: "effect_lease_unavailable",
503
+ error: `effect_lease_unavailable: ${input.actionKind} on ${input.targetRef} is shared and no hub lease is bound`,
504
+ };
505
+ }
506
+ try {
507
+ const acquired = await input.lease.acquireLease(input.targetRef, input.leaseTtlMs ?? DEFAULT_LEASE_TIMEOUT_MS);
508
+ lease = acquired.lease;
509
+ } catch (error) {
510
+ const code = errorCodeOf(error);
511
+ if (code === "lease_held") {
512
+ return {
513
+ ok: false,
514
+ code: "effect_lease_held",
515
+ error: `effect_lease_held: ${input.targetRef} is leased by another agent: ${errorText(error)}`,
516
+ };
517
+ }
518
+ return {
519
+ ok: false,
520
+ code: "effect_lease_unavailable",
521
+ error: `effect_lease_unavailable: could not reach the bound hub for ${input.targetRef}: ${errorText(error)}`,
522
+ };
523
+ }
524
+ }
525
+
526
+ const claimed = input.ledger.claimEffect({
527
+ runId: input.runId,
528
+ stepId: input.stepId,
529
+ attemptId: input.attemptId,
530
+ actionKind: input.actionKind,
531
+ targetRef: input.targetRef,
532
+ payload: input.payload,
533
+ timeoutMs: input.timeoutMs,
534
+ ...(lease ? { leaseResource: lease.resource, fencingToken: lease.fencingToken } : {}),
535
+ });
536
+
537
+ if (!claimed.ok) {
538
+ // The ledger refused after the lease was taken, so give the resource back
539
+ // rather than parking it until the TTL runs out.
540
+ if (lease && input.lease) await releaseQuietly(input.lease, input.targetRef, lease.fencingToken);
541
+ return {
542
+ ok: false,
543
+ code: claimed.error.startsWith("effect_already_committed") ? "effect_already_committed" : "effect_in_flight",
544
+ error: claimed.error,
545
+ existing: claimed.existing,
546
+ };
547
+ }
548
+
549
+ return {
550
+ ok: true,
551
+ effectKey: claimed.effectKey,
552
+ ...(lease ? { leaseResource: lease.resource, fencingToken: lease.fencingToken } : {}),
553
+ };
554
+ }
555
+
556
+ /**
557
+ * The Q6 heartbeat for a shared effect: renew the hub lease under the same
558
+ * token, then refresh the ledger's own lease timestamp.
559
+ *
560
+ * A hub that has moved past the token parks the attempt — the effect may
561
+ * already have touched the outside world, and the resource now belongs to
562
+ * someone else.
563
+ */
564
+ export async function heartbeatSharedEffect(input: {
565
+ ledger: ExternalEffectsLedger;
566
+ lease?: EffectLeaseGateway | undefined;
567
+ effectKey: string;
568
+ leaseTtlMs?: number | undefined;
569
+ }): Promise<{ ok: true; lastHeartbeatAt: string; leaseExpiresAt?: string } | SharedEffectRefusal> {
570
+ const receipt = input.ledger.getReceipt(input.effectKey);
571
+ if (!receipt) {
572
+ return { ok: false, code: "effect_not_found", error: `effect_not_found: effect ${input.effectKey} does not exist` };
573
+ }
574
+
575
+ let leaseExpiresAt: string | undefined;
576
+ if (receipt.leaseResource !== undefined && receipt.fencingToken !== undefined) {
577
+ if (!input.lease) {
578
+ return {
579
+ ok: false,
580
+ code: "effect_lease_unavailable",
581
+ error: `effect_lease_unavailable: ${receipt.targetRef} holds a hub lease but no gateway is bound`,
582
+ };
583
+ }
584
+ try {
585
+ const renewed = await input.lease.renewLease(
586
+ receipt.targetRef,
587
+ receipt.fencingToken,
588
+ input.leaseTtlMs ?? DEFAULT_LEASE_TIMEOUT_MS,
589
+ );
590
+ leaseExpiresAt = renewed.lease.expiresAt;
591
+ } catch (error) {
592
+ const code = errorCodeOf(error);
593
+ if (isLeaseLost(code)) {
594
+ return {
595
+ ok: false,
596
+ code: "effect_lease_superseded",
597
+ error: `effect_lease_superseded: the hub no longer recognises token ${receipt.fencingToken} on ${receipt.targetRef}: ${errorText(error)}`,
598
+ attemptState: "blocked_uncertain",
599
+ existing: receipt,
600
+ };
601
+ }
602
+ return {
603
+ ok: false,
604
+ code: "effect_lease_unavailable",
605
+ error: `effect_lease_unavailable: could not renew the lease on ${receipt.targetRef}: ${errorText(error)}`,
606
+ existing: receipt,
607
+ };
608
+ }
609
+ }
610
+
611
+ const beat = input.ledger.heartbeatEffect(input.effectKey);
612
+ if (!beat.ok) {
613
+ // The row exists — `getReceipt` just read it — so the only remaining refusal
614
+ // is a status that no longer accepts a heartbeat.
615
+ return { ok: false, code: "effect_not_in_flight", error: beat.error, existing: receipt };
616
+ }
617
+ return { ok: true, lastHeartbeatAt: beat.lastHeartbeatAt, ...(leaseExpiresAt ? { leaseExpiresAt } : {}) };
618
+ }
619
+
620
+ /**
621
+ * Commit a shared effect, but only while the hub still recognises its token.
622
+ *
623
+ * The token is re-presented before the receipt is written. If the hub has
624
+ * superseded it — or cannot be reached to say either way — the effect is left
625
+ * `in-flight` and the attempt parks `blocked_uncertain`. Nothing retries: the
626
+ * write may or may not have landed, and re-running it is how a second writer
627
+ * lands the same change twice.
628
+ */
629
+ export async function commitSharedEffect(input: {
630
+ ledger: ExternalEffectsLedger;
631
+ lease?: EffectLeaseGateway | undefined;
632
+ effectKey: string;
633
+ receiptPayload: Record<string, unknown>;
634
+ leaseTtlMs?: number | undefined;
635
+ }): Promise<{ ok: true; receipt: ExternalEffectReceipt } | SharedEffectRefusal> {
636
+ const receipt = input.ledger.getReceipt(input.effectKey);
637
+ if (!receipt) {
638
+ return { ok: false, code: "effect_not_found", error: `effect_not_found: effect ${input.effectKey} does not exist` };
639
+ }
640
+
641
+ const fenced = receipt.leaseResource !== undefined && receipt.fencingToken !== undefined;
642
+ if (fenced) {
643
+ if (!input.lease) {
644
+ return {
645
+ ok: false,
646
+ code: "effect_lease_unavailable",
647
+ error: `effect_lease_unavailable: ${receipt.targetRef} holds a hub lease but no gateway is bound`,
648
+ attemptState: "blocked_uncertain",
649
+ existing: receipt,
650
+ };
651
+ }
652
+ try {
653
+ await input.lease.renewLease(receipt.targetRef, receipt.fencingToken!, input.leaseTtlMs ?? DEFAULT_LEASE_TIMEOUT_MS);
654
+ } catch (error) {
655
+ const code = errorCodeOf(error);
656
+ return {
657
+ ok: false,
658
+ code: isLeaseLost(code) ? "effect_lease_superseded" : "effect_lease_unavailable",
659
+ error: isLeaseLost(code)
660
+ ? `effect_lease_superseded: token ${receipt.fencingToken} on ${receipt.targetRef} was superseded before commit: ${errorText(error)}`
661
+ : `effect_lease_unavailable: the lease on ${receipt.targetRef} could not be confirmed before commit: ${errorText(error)}`,
662
+ attemptState: "blocked_uncertain",
663
+ existing: input.ledger.getReceipt(input.effectKey),
664
+ };
665
+ }
666
+ }
667
+
668
+ input.ledger.commitEffect(input.effectKey, input.receiptPayload);
669
+ if (fenced && input.lease) {
670
+ await releaseQuietly(input.lease, receipt.targetRef, receipt.fencingToken!);
671
+ }
672
+ return { ok: true, receipt: input.ledger.getReceipt(input.effectKey)! };
673
+ }
674
+
675
+ /** Hand the resource back. `resource` is always the name the caller asked for,
676
+ * never the hub's project-scoped form — the hub applies that prefix itself, and
677
+ * passing it back would name a second resource. A failed release is not an
678
+ * error the caller can act on: the lease falls to its deadline on the hub
679
+ * clock. */
680
+ async function releaseQuietly(gateway: EffectLeaseGateway, resource: string, fencingToken: number): Promise<void> {
681
+ try {
682
+ await gateway.releaseLease(resource, fencingToken);
683
+ } catch {
684
+ // Deliberately swallowed: the TTL frees it.
685
+ }
686
+ }
687
+
364
688
  export interface WorktreeLock {
365
689
  lockPath: string;
366
690
  release: () => void;