@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.
- 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 +83 -19
- package/plugins/kxm/dist/client.js +50 -3
- package/plugins/kxm/dist/core.js +31 -4
- package/plugins/kxm/dist/extension.js +59 -7
- package/plugins/kxm/dist/mcp-server.js +60 -8
- package/plugins/kxm/dist/runtime.js +4 -2
- package/plugins/kxm/dist/server.js +317 -20
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm/references/protocol.md +18 -0
- package/plugins/kxm/skills/kxm-peer/SKILL.md +3 -1
- package/plugins/kxm/src/cli.ts +2 -1
- package/plugins/kxm/src/client.ts +62 -3
- package/plugins/kxm/src/commands.ts +10 -4
- package/plugins/kxm/src/database.ts +6 -2
- package/plugins/kxm/src/external-effects.ts +390 -66
- package/plugins/kxm/src/hub.ts +171 -12
- package/plugins/kxm/src/local-snapshot.ts +10 -2
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/protocol.ts +67 -1
- package/plugins/kxm/src/store.ts +217 -6
- package/plugins/kxm/src/tui.ts +20 -4
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import
|
|
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
|
-
|
|
212
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|