@kontextmind/kxm 0.7.71 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +22 -0
- package/docs/operations.md +37 -5
- package/docs/test-matrix.md +1 -0
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +42 -2
- package/plugins/kxm/dist/client.js +34 -0
- package/plugins/kxm/dist/core.js +8 -0
- package/plugins/kxm/dist/extension.js +34 -0
- package/plugins/kxm/dist/mcp-server.js +35 -1
- package/plugins/kxm/dist/runtime.js +4 -2
- package/plugins/kxm/dist/server.js +275 -6
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm/references/protocol.md +18 -0
- package/plugins/kxm/src/client.ts +39 -1
- package/plugins/kxm/src/database.ts +6 -2
- package/plugins/kxm/src/external-effects.ts +390 -66
- package/plugins/kxm/src/hub.ts +137 -1
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/protocol.ts +25 -0
- package/plugins/kxm/src/store.ts +213 -5
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
328
|
-
|
|
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;
|