clawmem 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +13 -3
- package/CLAUDE.md +13 -3
- package/README.md +31 -2
- package/SKILL.md +11 -3
- package/package.json +1 -1
- package/src/clawmem.ts +30 -2
- package/src/consolidation.ts +146 -16
- package/src/conversation-synthesis.ts +637 -0
- package/src/maintenance.ts +540 -0
- package/src/mcp.ts +34 -0
- package/src/store.ts +35 -0
- package/src/worker-lease.ts +141 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClawMem Worker Lease (v0.8.0 Ext 5)
|
|
3
|
+
*
|
|
4
|
+
* DB-backed exclusive lease for heavy-lane workers. Uses the `worker_leases`
|
|
5
|
+
* table (schema in store.ts) instead of module globals so multiple processes
|
|
6
|
+
* sharing a vault cannot run heavy maintenance concurrently.
|
|
7
|
+
*
|
|
8
|
+
* Lease lifecycle:
|
|
9
|
+
* 1. acquireWorkerLease inserts or reclaims an expired row via transaction
|
|
10
|
+
* and returns a random fencing token on success.
|
|
11
|
+
* 2. The holder runs its work.
|
|
12
|
+
* 3. releaseWorkerLease deletes the row only if the caller's token matches,
|
|
13
|
+
* so a lease reclaimed by another worker after TTL expiry cannot be
|
|
14
|
+
* torn down by the original holder.
|
|
15
|
+
*
|
|
16
|
+
* withWorkerLease wraps acquire/release around a callback; failure to acquire
|
|
17
|
+
* is a silent no-op (returns `{acquired: false}`) — callers should log a
|
|
18
|
+
* `skipped` journal row with reason `lease_unavailable`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { randomBytes } from "node:crypto";
|
|
22
|
+
import type { Store } from "./store.ts";
|
|
23
|
+
|
|
24
|
+
export interface LeaseAcquireResult {
|
|
25
|
+
acquired: boolean;
|
|
26
|
+
token?: string;
|
|
27
|
+
expiresAt?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function nowIso(now: Date = new Date()): string {
|
|
31
|
+
return now.toISOString();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function futureIso(now: Date, ttlMs: number): string {
|
|
35
|
+
return new Date(now.getTime() + ttlMs).toISOString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Attempt to acquire an exclusive lease on `workerName` for `ttlMs`.
|
|
40
|
+
*
|
|
41
|
+
* Returns `{acquired: true, token, expiresAt}` on success, or
|
|
42
|
+
* `{acquired: false}` if another worker holds a live (non-expired) lease.
|
|
43
|
+
*
|
|
44
|
+
* Race-safe under multi-process contention: uses a single
|
|
45
|
+
* `INSERT ... ON CONFLICT DO UPDATE ... WHERE expires_at <= ?` statement
|
|
46
|
+
* so the "no row → insert" and "expired row → update" paths cannot
|
|
47
|
+
* both fire for two concurrent callers. SQLite's changes() reports 1
|
|
48
|
+
* iff THIS call either inserted a fresh row or reclaimed an expired row;
|
|
49
|
+
* 0 means a live lease was held by someone else.
|
|
50
|
+
*
|
|
51
|
+
* Any SQLITE_BUSY / constraint failure is translated to
|
|
52
|
+
* `{ acquired: false }` so the advertised non-throw contract holds for
|
|
53
|
+
* callers that are layering `shouldRunHeavyMaintenance` above this.
|
|
54
|
+
*/
|
|
55
|
+
export function acquireWorkerLease(
|
|
56
|
+
store: Store,
|
|
57
|
+
workerName: string,
|
|
58
|
+
ttlMs: number,
|
|
59
|
+
now: Date = new Date(),
|
|
60
|
+
): LeaseAcquireResult {
|
|
61
|
+
if (ttlMs <= 0) {
|
|
62
|
+
throw new Error(`acquireWorkerLease: ttlMs must be positive, got ${ttlMs}`);
|
|
63
|
+
}
|
|
64
|
+
const token = randomBytes(16).toString("hex");
|
|
65
|
+
const acquiredAt = nowIso(now);
|
|
66
|
+
const expiresAt = futureIso(now, ttlMs);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
// Single-statement atomic acquire. The WHERE on the UPDATE clause
|
|
70
|
+
// only reclaims when the existing lease has expired (its expires_at
|
|
71
|
+
// <= our acquired_at); otherwise the ON CONFLICT DO UPDATE becomes
|
|
72
|
+
// a no-op and SQLite reports changes=0.
|
|
73
|
+
const result = store.db.prepare(
|
|
74
|
+
`INSERT INTO worker_leases
|
|
75
|
+
(worker_name, lease_token, acquired_at, expires_at)
|
|
76
|
+
VALUES (?, ?, ?, ?)
|
|
77
|
+
ON CONFLICT(worker_name) DO UPDATE SET
|
|
78
|
+
lease_token = excluded.lease_token,
|
|
79
|
+
acquired_at = excluded.acquired_at,
|
|
80
|
+
expires_at = excluded.expires_at
|
|
81
|
+
WHERE worker_leases.expires_at <= excluded.acquired_at`,
|
|
82
|
+
).run(workerName, token, acquiredAt, expiresAt);
|
|
83
|
+
|
|
84
|
+
if (result.changes === 0) {
|
|
85
|
+
return { acquired: false };
|
|
86
|
+
}
|
|
87
|
+
return { acquired: true, token, expiresAt };
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// Defensive fallback: any unexpected DB error (SQLITE_BUSY under
|
|
90
|
+
// extreme contention, constraint error from schema drift, etc.) is
|
|
91
|
+
// translated to a lease-unavailable result instead of bubbling up,
|
|
92
|
+
// so heavy-maintenance callers always get a deterministic
|
|
93
|
+
// "skipped/lease_unavailable" journal row.
|
|
94
|
+
console.error(
|
|
95
|
+
`[worker-lease] acquire error for ${workerName}: ${(err as Error).message}`,
|
|
96
|
+
);
|
|
97
|
+
return { acquired: false };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Release a lease if the caller's token still matches. Returns `true` if
|
|
103
|
+
* the lease was owned and deleted, `false` if a different token held it
|
|
104
|
+
* (e.g., TTL expired and another worker reclaimed).
|
|
105
|
+
*/
|
|
106
|
+
export function releaseWorkerLease(
|
|
107
|
+
store: Store,
|
|
108
|
+
workerName: string,
|
|
109
|
+
token: string,
|
|
110
|
+
): boolean {
|
|
111
|
+
const result = store.db.prepare(
|
|
112
|
+
`DELETE FROM worker_leases WHERE worker_name = ? AND lease_token = ?`,
|
|
113
|
+
).run(workerName, token);
|
|
114
|
+
return result.changes > 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Run `fn` under an exclusive lease on `workerName`. If the lease cannot
|
|
119
|
+
* be acquired, returns `{acquired: false}` without invoking `fn`. The
|
|
120
|
+
* lease is always released in a `finally` block, even if `fn` throws.
|
|
121
|
+
*
|
|
122
|
+
* Rethrows any error from `fn` — callers are responsible for translating
|
|
123
|
+
* exceptions into journal rows.
|
|
124
|
+
*/
|
|
125
|
+
export async function withWorkerLease<T>(
|
|
126
|
+
store: Store,
|
|
127
|
+
workerName: string,
|
|
128
|
+
ttlMs: number,
|
|
129
|
+
fn: () => Promise<T>,
|
|
130
|
+
): Promise<{ acquired: boolean; result?: T }> {
|
|
131
|
+
const lease = acquireWorkerLease(store, workerName, ttlMs);
|
|
132
|
+
if (!lease.acquired || !lease.token) {
|
|
133
|
+
return { acquired: false };
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const result = await fn();
|
|
137
|
+
return { acquired: true, result };
|
|
138
|
+
} finally {
|
|
139
|
+
releaseWorkerLease(store, workerName, lease.token);
|
|
140
|
+
}
|
|
141
|
+
}
|