brainclaw 0.29.2 → 1.5.3
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/README.md +193 -170
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +673 -24
- package/dist/commands/accept.js +3 -0
- package/dist/commands/add-step.js +11 -26
- package/dist/commands/agent-board.js +70 -3
- package/dist/commands/audit.js +19 -0
- package/dist/commands/check-policy.js +54 -0
- package/dist/commands/check-security-mcp.js +145 -0
- package/dist/commands/check-security.js +106 -0
- package/dist/commands/claim-resource.js +1 -0
- package/dist/commands/codev.js +672 -0
- package/dist/commands/compact.js +74 -0
- package/dist/commands/complete-step.js +16 -26
- package/dist/commands/constraint.js +8 -20
- package/dist/commands/decision.js +9 -20
- package/dist/commands/delete-plan.js +10 -12
- package/dist/commands/delete-step.js +16 -0
- package/dist/commands/dispatch.js +163 -0
- package/dist/commands/doctor.js +1122 -49
- package/dist/commands/enable-agent.js +1 -0
- package/dist/commands/export.js +280 -22
- package/dist/commands/handoff.js +33 -0
- package/dist/commands/harvest.js +189 -0
- package/dist/commands/hooks.js +82 -25
- package/dist/commands/inbox.js +169 -0
- package/dist/commands/init.js +38 -31
- package/dist/commands/install-hooks.js +71 -44
- package/dist/commands/link.js +89 -0
- package/dist/commands/list-claims.js +48 -3
- package/dist/commands/list-plans.js +129 -25
- package/dist/commands/loops-handlers.js +409 -0
- package/dist/commands/mcp-read-handlers.js +1628 -0
- package/dist/commands/mcp-schemas.generated.js +74 -0
- package/dist/commands/mcp.js +4221 -1501
- package/dist/commands/plan-resource.js +64 -0
- package/dist/commands/plan.js +12 -26
- package/dist/commands/prune.js +37 -2
- package/dist/commands/reflect.js +20 -7
- package/dist/commands/release-claim.js +11 -6
- package/dist/commands/release-notes.js +170 -0
- package/dist/commands/repair.js +210 -0
- package/dist/commands/run-profile.js +57 -0
- package/dist/commands/sequence.js +113 -0
- package/dist/commands/session-end.js +423 -14
- package/dist/commands/session-start.js +214 -41
- package/dist/commands/setup-security.js +103 -0
- package/dist/commands/setup.js +42 -4
- package/dist/commands/stale.js +109 -0
- package/dist/commands/switch.js +100 -2
- package/dist/commands/trap.js +14 -31
- package/dist/commands/update-handoff.js +63 -4
- package/dist/commands/update-plan.js +21 -28
- package/dist/commands/update-step.js +37 -0
- package/dist/commands/upgrade.js +313 -6
- package/dist/commands/usage.js +102 -0
- package/dist/commands/version.js +20 -0
- package/dist/commands/who.js +33 -5
- package/dist/commands/worktree.js +105 -0
- package/dist/core/actions.js +315 -0
- package/dist/core/agent-capability.js +610 -17
- package/dist/core/agent-context.js +7 -1
- package/dist/core/agent-files.js +1169 -85
- package/dist/core/agent-integrations.js +160 -5
- package/dist/core/agent-inventory.js +2 -0
- package/dist/core/agent-profiles.js +93 -0
- package/dist/core/agent-registry.js +162 -30
- package/dist/core/agentrun-reconciler.js +345 -0
- package/dist/core/agentruns.js +424 -0
- package/dist/core/ai-agent-detection.js +31 -10
- package/dist/core/archival.js +77 -0
- package/dist/core/assignment-sweeper.js +82 -0
- package/dist/core/assignments.js +367 -0
- package/dist/core/audit.js +30 -0
- package/dist/core/brainclaw-version.js +94 -2
- package/dist/core/candidates.js +93 -2
- package/dist/core/claims.js +419 -0
- package/dist/core/codev-metrics.js +77 -0
- package/dist/core/codev-personas.js +31 -0
- package/dist/core/codev-plan-gen.js +35 -0
- package/dist/core/codev-prompts.js +74 -0
- package/dist/core/codev-responses.js +62 -0
- package/dist/core/codev-rounds.js +218 -0
- package/dist/core/config.js +4 -0
- package/dist/core/context.js +381 -34
- package/dist/core/coordination.js +201 -6
- package/dist/core/cross-project.js +230 -16
- package/dist/core/default-profiles/doctor.yaml +11 -0
- package/dist/core/default-profiles/janitor.yaml +11 -0
- package/dist/core/default-profiles/onboarder.yaml +11 -0
- package/dist/core/default-profiles/reviewer.yaml +13 -0
- package/dist/core/dispatcher.js +1189 -0
- package/dist/core/duplicates.js +2 -2
- package/dist/core/entity-operations.js +450 -0
- package/dist/core/entity-registry.js +344 -0
- package/dist/core/events.js +106 -2
- package/dist/core/execution-adapters.js +154 -0
- package/dist/core/execution-context.js +63 -0
- package/dist/core/execution-profile.js +270 -0
- package/dist/core/execution.js +255 -0
- package/dist/core/facade-schema.js +81 -0
- package/dist/core/federation-cloud.js +99 -0
- package/dist/core/federation-message.js +52 -0
- package/dist/core/federation-transport.js +65 -0
- package/dist/core/gc-semantic.js +482 -0
- package/dist/core/governance.js +247 -0
- package/dist/core/guards.js +19 -0
- package/dist/core/ideation.js +72 -0
- package/dist/core/identity.js +110 -25
- package/dist/core/ids.js +6 -0
- package/dist/core/input-validation.js +2 -2
- package/dist/core/instruction-templates.js +344 -136
- package/dist/core/io.js +90 -11
- package/dist/core/lock.js +6 -2
- package/dist/core/loops/brief-assembly.js +213 -0
- package/dist/core/loops/facade-schema.js +148 -0
- package/dist/core/loops/index.js +7 -0
- package/dist/core/loops/iteration-engine.js +139 -0
- package/dist/core/loops/lock.js +385 -0
- package/dist/core/loops/store.js +201 -0
- package/dist/core/loops/types.js +403 -0
- package/dist/core/loops/verbs.js +534 -0
- package/dist/core/markdown.js +15 -3
- package/dist/core/memory-compactor.js +432 -0
- package/dist/core/memory-git.js +152 -8
- package/dist/core/messaging.js +278 -0
- package/dist/core/migration.js +32 -1
- package/dist/core/mutation-pipeline.js +4 -2
- package/dist/core/operations/memory-mutation.js +129 -0
- package/dist/core/operations/memory-write.js +78 -0
- package/dist/core/operations/plan.js +190 -0
- package/dist/core/policy.js +169 -0
- package/dist/core/reputation.js +9 -3
- package/dist/core/schema.js +491 -6
- package/dist/core/search.js +21 -2
- package/dist/core/security-cache.js +71 -0
- package/dist/core/security-guard.js +152 -0
- package/dist/core/security-scoring.js +86 -0
- package/dist/core/sequence.js +130 -0
- package/dist/core/socket-client.js +113 -0
- package/dist/core/staleness.js +246 -0
- package/dist/core/state.js +98 -22
- package/dist/core/store-resolution.js +43 -11
- package/dist/core/toml-writer.js +76 -0
- package/dist/core/upgrades/backup.js +232 -0
- package/dist/core/upgrades/health-check.js +169 -0
- package/dist/core/upgrades/patches/candidate-archive.js +145 -0
- package/dist/core/upgrades/patches/handoff-review-strip.js +128 -0
- package/dist/core/upgrades/patches/provenance-rollout.js +136 -0
- package/dist/core/upgrades/schema-version.js +97 -0
- package/dist/core/worktree.js +606 -0
- package/dist/facts.js +114 -0
- package/dist/facts.json +111 -0
- package/docs/architecture/project-refs.md +5 -1
- package/docs/cli.md +690 -43
- package/docs/concepts/ideation-loop.md +317 -0
- package/docs/concepts/loop-engine.md +456 -0
- package/docs/concepts/mcp-governance.md +268 -0
- package/docs/concepts/memory-staleness.md +122 -0
- package/docs/concepts/multi-agent-workflows.md +166 -0
- package/docs/concepts/plans-and-claims.md +31 -6
- package/docs/concepts/project-md-convention.md +35 -0
- package/docs/concepts/troubleshooting.md +220 -0
- package/docs/concepts/upgrade-cli.md +202 -0
- package/docs/concepts/upgrade-dogfood-procedure.md +114 -0
- package/docs/context-format-changelog.md +2 -2
- package/docs/context-format.md +2 -2
- package/docs/index.md +68 -0
- package/docs/integrations/agents.md +15 -16
- package/docs/integrations/cline.md +88 -0
- package/docs/integrations/codex.md +75 -23
- package/docs/integrations/continue.md +60 -0
- package/docs/integrations/copilot.md +67 -9
- package/docs/integrations/kilocode.md +72 -0
- package/docs/integrations/mcp.md +304 -21
- package/docs/integrations/mistral-vibe.md +122 -0
- package/docs/integrations/opencode.md +84 -0
- package/docs/integrations/overview.md +23 -8
- package/docs/integrations/roo.md +74 -0
- package/docs/integrations/windsurf.md +83 -0
- package/docs/mcp-schema-changelog.md +191 -1
- package/docs/playbooks/integration/index.md +121 -0
- package/docs/playbooks/productivity/index.md +102 -0
- package/docs/playbooks/team/index.md +122 -0
- package/docs/product/agent-first-model.md +184 -0
- package/docs/product/entity-model-audit.md +462 -0
- package/docs/quickstart-existing-project.md +135 -0
- package/docs/quickstart.md +124 -37
- package/docs/release-maintenance.md +79 -0
- package/docs/review.md +2 -0
- package/docs/server-operations.md +118 -0
- package/package.json +20 -12
- package/dist/commands/claude-desktop-extension.js +0 -18
- package/dist/commands/diff.js +0 -99
- package/dist/core/claude-desktop-extension.js +0 -224
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { memoryDir } from '../io.js';
|
|
6
|
+
import { nowISO } from '../ids.js';
|
|
7
|
+
/**
|
|
8
|
+
* Per-loop exclusive lock + idempotency + fencing helpers.
|
|
9
|
+
*
|
|
10
|
+
* Implements the commit protocol from docs/concepts/loop-engine.md §Persistence.
|
|
11
|
+
* For synchronous MVP mutations, the lock window is short (< 100ms typically) so
|
|
12
|
+
* lease renewal via an internal heartbeat is not yet wired up; the hard_deadline
|
|
13
|
+
* is still recorded in the lock blob and used by the stale-lock recovery rules.
|
|
14
|
+
*/
|
|
15
|
+
export const LOCK_BACKOFF_BASE_MS = 10;
|
|
16
|
+
export const LOCK_BACKOFF_TOTAL_MS = 500;
|
|
17
|
+
export const LEASE_WINDOW_MS = 60_000;
|
|
18
|
+
export const LEASE_RENEWAL_INTERVAL_MS = 30_000;
|
|
19
|
+
export const LEASE_GRACE_MS = 30_000;
|
|
20
|
+
export const DEFAULT_MAX_MUTATION_DURATION_MS = {
|
|
21
|
+
open: 30_000,
|
|
22
|
+
turn: 30_000,
|
|
23
|
+
advance: 30_000,
|
|
24
|
+
pause: 30_000,
|
|
25
|
+
resume: 30_000,
|
|
26
|
+
close: 30_000,
|
|
27
|
+
add_artifact: 60_000,
|
|
28
|
+
complete_turn: 60_000,
|
|
29
|
+
};
|
|
30
|
+
export const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
export class LockTimeoutError extends Error {
|
|
32
|
+
lockPath;
|
|
33
|
+
constructor(lockPath) {
|
|
34
|
+
super(`lock_timeout at ${lockPath}`);
|
|
35
|
+
this.lockPath = lockPath;
|
|
36
|
+
this.name = 'LockTimeoutError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export class LockLostError extends Error {
|
|
40
|
+
lockPath;
|
|
41
|
+
expected;
|
|
42
|
+
actual;
|
|
43
|
+
constructor(lockPath, expected, actual) {
|
|
44
|
+
super(`lock_lost: expected mutation_id=${expected}, found=${actual ?? 'missing'} at ${lockPath}`);
|
|
45
|
+
this.lockPath = lockPath;
|
|
46
|
+
this.expected = expected;
|
|
47
|
+
this.actual = actual;
|
|
48
|
+
this.name = 'LockLostError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export class VersionConflictError extends Error {
|
|
52
|
+
loopId;
|
|
53
|
+
expected;
|
|
54
|
+
actual;
|
|
55
|
+
constructor(loopId, expected, actual) {
|
|
56
|
+
super(`version_conflict on ${loopId}: expected ${expected}, actual ${actual}`);
|
|
57
|
+
this.loopId = loopId;
|
|
58
|
+
this.expected = expected;
|
|
59
|
+
this.actual = actual;
|
|
60
|
+
this.name = 'VersionConflictError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export class IdempotencyKeyReusedError extends Error {
|
|
64
|
+
storedHash;
|
|
65
|
+
submittedHash;
|
|
66
|
+
constructor(storedHash, submittedHash) {
|
|
67
|
+
super('idempotency_key_reused_with_different_body');
|
|
68
|
+
this.storedHash = storedHash;
|
|
69
|
+
this.submittedHash = submittedHash;
|
|
70
|
+
this.name = 'IdempotencyKeyReusedError';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export class IdempotencyOwnerMismatchError extends Error {
|
|
74
|
+
storedOwner;
|
|
75
|
+
submittedOwner;
|
|
76
|
+
constructor(storedOwner, submittedOwner) {
|
|
77
|
+
super('idempotency_owner_mismatch');
|
|
78
|
+
this.storedOwner = storedOwner;
|
|
79
|
+
this.submittedOwner = submittedOwner;
|
|
80
|
+
this.name = 'IdempotencyOwnerMismatchError';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/* ============================ path resolution ============================= */
|
|
84
|
+
function loopsRoot(cwd) {
|
|
85
|
+
return path.join(memoryDir(cwd ?? process.cwd()), 'loops');
|
|
86
|
+
}
|
|
87
|
+
function locksDir(cwd) {
|
|
88
|
+
return path.join(loopsRoot(cwd), 'locks');
|
|
89
|
+
}
|
|
90
|
+
function openLocksDir(cwd) {
|
|
91
|
+
return path.join(locksDir(cwd), 'open');
|
|
92
|
+
}
|
|
93
|
+
function idempotencyDir(loopId, cwd) {
|
|
94
|
+
return path.join(loopsRoot(cwd), 'idempotency', loopId);
|
|
95
|
+
}
|
|
96
|
+
function idempotencyOpenDir(agentId, cwd) {
|
|
97
|
+
return path.join(loopsRoot(cwd), 'idempotency-open', agentId);
|
|
98
|
+
}
|
|
99
|
+
function conflictsDir(cwd) {
|
|
100
|
+
return path.join(loopsRoot(cwd), 'conflicts');
|
|
101
|
+
}
|
|
102
|
+
function loopLockPath(loopId, cwd) {
|
|
103
|
+
return path.join(locksDir(cwd), `${loopId}.lock`);
|
|
104
|
+
}
|
|
105
|
+
function openLockPath(agentId, clientRequestId, cwd) {
|
|
106
|
+
return path.join(openLocksDir(cwd), agentId, `${clientRequestId}.lock`);
|
|
107
|
+
}
|
|
108
|
+
function ensureDirFor(filePath) {
|
|
109
|
+
const dir = path.dirname(filePath);
|
|
110
|
+
if (!fs.existsSync(dir))
|
|
111
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
112
|
+
}
|
|
113
|
+
/* ============================== JSON helpers ============================= */
|
|
114
|
+
function readJsonIfExists(filePath) {
|
|
115
|
+
if (!fs.existsSync(filePath))
|
|
116
|
+
return undefined;
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function writeJsonAtomic(filePath, data) {
|
|
125
|
+
ensureDirFor(filePath);
|
|
126
|
+
const tmp = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
|
127
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
128
|
+
fs.renameSync(tmp, filePath);
|
|
129
|
+
}
|
|
130
|
+
function canonicalizeJson(value) {
|
|
131
|
+
if (Array.isArray(value)) {
|
|
132
|
+
return value.map((item) => canonicalizeJson(item));
|
|
133
|
+
}
|
|
134
|
+
if (value && typeof value === 'object') {
|
|
135
|
+
const record = value;
|
|
136
|
+
const canonical = {};
|
|
137
|
+
for (const key of Object.keys(record).sort()) {
|
|
138
|
+
canonical[key] = canonicalizeJson(record[key]);
|
|
139
|
+
}
|
|
140
|
+
return canonical;
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
export function hashRequest(payload) {
|
|
145
|
+
const canonical = JSON.stringify(canonicalizeJson(payload));
|
|
146
|
+
return crypto.createHash('sha256').update(canonical).digest('hex');
|
|
147
|
+
}
|
|
148
|
+
function loadIdempotencyRecord(kind, key, cwd) {
|
|
149
|
+
let filePath;
|
|
150
|
+
if (kind === 'loop') {
|
|
151
|
+
if (!key.loopId)
|
|
152
|
+
throw new Error('loadIdempotencyRecord: loopId required for loop kind');
|
|
153
|
+
filePath = path.join(idempotencyDir(key.loopId, cwd), `${key.clientRequestId}.json`);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
if (!key.agentId)
|
|
157
|
+
throw new Error('loadIdempotencyRecord: agentId required for open kind');
|
|
158
|
+
filePath = path.join(idempotencyOpenDir(key.agentId, cwd), `${key.clientRequestId}.json`);
|
|
159
|
+
}
|
|
160
|
+
const record = readJsonIfExists(filePath);
|
|
161
|
+
if (record && Date.parse(record.stored_at) + IDEMPOTENCY_TTL_MS < Date.now()) {
|
|
162
|
+
return { path: filePath, record: undefined };
|
|
163
|
+
}
|
|
164
|
+
return { path: filePath, record };
|
|
165
|
+
}
|
|
166
|
+
/* =============================== lock I/O ================================ */
|
|
167
|
+
function readLockBlob(lockPath) {
|
|
168
|
+
return readJsonIfExists(lockPath) ?? null;
|
|
169
|
+
}
|
|
170
|
+
function writeLockAtomic(lockPath, blob) {
|
|
171
|
+
writeJsonAtomic(lockPath, blob);
|
|
172
|
+
}
|
|
173
|
+
function processIsAlive(pid) {
|
|
174
|
+
try {
|
|
175
|
+
// Signal 0 just checks for the process's existence without actually sending a signal.
|
|
176
|
+
process.kill(pid, 0);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
const code = err.code;
|
|
181
|
+
return code === 'EPERM';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function lockIsStale(blob, now) {
|
|
185
|
+
if (now > Date.parse(blob.hard_deadline))
|
|
186
|
+
return true;
|
|
187
|
+
if (blob.host_id === os.hostname() && !processIsAlive(blob.pid))
|
|
188
|
+
return true;
|
|
189
|
+
if (now > Date.parse(blob.lease_until) + LEASE_GRACE_MS)
|
|
190
|
+
return true;
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
function acquireRaw(lockPath, blob) {
|
|
194
|
+
ensureDirFor(lockPath);
|
|
195
|
+
const tmp = path.join(path.dirname(lockPath), `.${path.basename(lockPath)}.${process.pid}.${Date.now()}.${crypto.randomBytes(3).toString('hex')}.tmp`);
|
|
196
|
+
fs.writeFileSync(tmp, JSON.stringify(blob, null, 2));
|
|
197
|
+
try {
|
|
198
|
+
// fs.renameSync is atomic on POSIX but will overwrite on Windows. We want exclusive:
|
|
199
|
+
// use link(tmp, lockPath) + unlink(tmp) which is atomic + fails on EEXIST.
|
|
200
|
+
fs.linkSync(tmp, lockPath);
|
|
201
|
+
fs.unlinkSync(tmp);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
const code = err.code;
|
|
206
|
+
try {
|
|
207
|
+
fs.unlinkSync(tmp);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
/* ignore */
|
|
211
|
+
}
|
|
212
|
+
if (code === 'EEXIST')
|
|
213
|
+
return false;
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
export function acquireLock(options) {
|
|
218
|
+
const durationMs = options.maxMutationDurationMs ?? DEFAULT_MAX_MUTATION_DURATION_MS[options.intent] ?? 30_000;
|
|
219
|
+
const timeoutMs = options.timeoutMs ?? LOCK_BACKOFF_TOTAL_MS;
|
|
220
|
+
const deadline = Date.now() + timeoutMs;
|
|
221
|
+
while (Date.now() < deadline) {
|
|
222
|
+
const existing = readLockBlob(options.lockPath);
|
|
223
|
+
if (existing) {
|
|
224
|
+
if (lockIsStale(existing, Date.now())) {
|
|
225
|
+
try {
|
|
226
|
+
fs.unlinkSync(options.lockPath);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
/* race with another reaper; retry */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const nowIso = nowISO();
|
|
234
|
+
const nowMs = Date.now();
|
|
235
|
+
const blob = {
|
|
236
|
+
pid: process.pid,
|
|
237
|
+
host_id: os.hostname(),
|
|
238
|
+
agent_id: options.agentId,
|
|
239
|
+
acquired_at: nowIso,
|
|
240
|
+
lease_until: new Date(nowMs + LEASE_WINDOW_MS).toISOString(),
|
|
241
|
+
hard_deadline: new Date(nowMs + durationMs).toISOString(),
|
|
242
|
+
mutation_id: crypto.randomUUID().replace(/-/g, ''),
|
|
243
|
+
};
|
|
244
|
+
if (acquireRaw(options.lockPath, blob)) {
|
|
245
|
+
const lockPath = options.lockPath;
|
|
246
|
+
return {
|
|
247
|
+
path: lockPath,
|
|
248
|
+
blob,
|
|
249
|
+
release() {
|
|
250
|
+
try {
|
|
251
|
+
const current = readLockBlob(lockPath);
|
|
252
|
+
if (current && current.mutation_id === blob.mutation_id) {
|
|
253
|
+
fs.unlinkSync(lockPath);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
/* best-effort */
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
fenceCheck() {
|
|
261
|
+
const current = readLockBlob(lockPath);
|
|
262
|
+
if (!current || current.mutation_id !== blob.mutation_id) {
|
|
263
|
+
throw new LockLostError(lockPath, blob.mutation_id, current?.mutation_id ?? null);
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
// Jittered backoff.
|
|
269
|
+
const base = Math.min(LOCK_BACKOFF_BASE_MS * 2, 80);
|
|
270
|
+
const jitter = crypto.randomInt(0, base);
|
|
271
|
+
const sleepMs = Math.min(deadline - Date.now(), base + jitter);
|
|
272
|
+
if (sleepMs <= 0)
|
|
273
|
+
break;
|
|
274
|
+
sleepSync(sleepMs);
|
|
275
|
+
}
|
|
276
|
+
throw new LockTimeoutError(options.lockPath);
|
|
277
|
+
}
|
|
278
|
+
function sleepSync(ms) {
|
|
279
|
+
const end = Date.now() + ms;
|
|
280
|
+
// Tight but cheap busy wait for short backoffs.
|
|
281
|
+
while (Date.now() < end) {
|
|
282
|
+
// Yield via SharedArrayBuffer wait if available; otherwise busy-loop.
|
|
283
|
+
try {
|
|
284
|
+
const sab = new SharedArrayBuffer(4);
|
|
285
|
+
const view = new Int32Array(sab);
|
|
286
|
+
Atomics.wait(view, 0, 0, end - Date.now());
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
/* fallback */
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
export function withLoopLock(options) {
|
|
295
|
+
const lockPath = options.scope.kind === 'loop'
|
|
296
|
+
? loopLockPath(options.scope.loopId, options.cwd)
|
|
297
|
+
: openLockPath(options.agentId, options.scope.clientRequestId, options.cwd);
|
|
298
|
+
const acquired = acquireLock({
|
|
299
|
+
lockPath,
|
|
300
|
+
agentId: options.agentId,
|
|
301
|
+
intent: options.intent,
|
|
302
|
+
});
|
|
303
|
+
try {
|
|
304
|
+
// Idempotency short-circuit (inside lock).
|
|
305
|
+
if (options.clientRequestId && options.requestPayload !== undefined) {
|
|
306
|
+
const loopIdForKey = options.scope.kind === 'loop'
|
|
307
|
+
? options.scope.loopId
|
|
308
|
+
: options.loopIdForIdempotency;
|
|
309
|
+
const { path: idPath, record } = loadIdempotencyRecord(loopIdForKey ? 'loop' : 'open', loopIdForKey
|
|
310
|
+
? { loopId: loopIdForKey, clientRequestId: options.clientRequestId }
|
|
311
|
+
: { agentId: options.agentId, clientRequestId: options.clientRequestId }, options.cwd);
|
|
312
|
+
const submittedHash = hashRequest(options.requestPayload);
|
|
313
|
+
if (record) {
|
|
314
|
+
if (record.request_hash !== submittedHash) {
|
|
315
|
+
throw new IdempotencyKeyReusedError(record.request_hash, submittedHash);
|
|
316
|
+
}
|
|
317
|
+
if (options.requireCallerMatch && record.owner_agent_id !== options.agentId) {
|
|
318
|
+
throw new IdempotencyOwnerMismatchError(record.owner_agent_id, options.agentId);
|
|
319
|
+
}
|
|
320
|
+
return record.response;
|
|
321
|
+
}
|
|
322
|
+
// CAS check (after idempotency short-circuit).
|
|
323
|
+
if (options.expectedVersion !== undefined && options.currentVersion) {
|
|
324
|
+
const actual = options.currentVersion();
|
|
325
|
+
if (actual !== options.expectedVersion) {
|
|
326
|
+
recordConflict({
|
|
327
|
+
loopId: loopIdForKey ?? 'unknown',
|
|
328
|
+
attemptedBy: options.agentId,
|
|
329
|
+
expectedVersion: options.expectedVersion,
|
|
330
|
+
actualVersion: actual,
|
|
331
|
+
rejectedIntent: options.intent,
|
|
332
|
+
clientRequestId: options.clientRequestId,
|
|
333
|
+
cwd: options.cwd,
|
|
334
|
+
});
|
|
335
|
+
throw new VersionConflictError(loopIdForKey ?? 'unknown', options.expectedVersion, actual);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const result = options.work({ fenceCheck: acquired.fenceCheck, mutationId: acquired.blob.mutation_id });
|
|
339
|
+
const storeRecord = {
|
|
340
|
+
response: result,
|
|
341
|
+
request_hash: submittedHash,
|
|
342
|
+
stored_at: nowISO(),
|
|
343
|
+
owner_agent_id: options.agentId,
|
|
344
|
+
};
|
|
345
|
+
writeJsonAtomic(idPath, storeRecord);
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
// No idempotency key — still honor CAS if supplied.
|
|
349
|
+
if (options.expectedVersion !== undefined && options.currentVersion && options.scope.kind === 'loop') {
|
|
350
|
+
const actual = options.currentVersion();
|
|
351
|
+
if (actual !== options.expectedVersion) {
|
|
352
|
+
recordConflict({
|
|
353
|
+
loopId: options.scope.loopId,
|
|
354
|
+
attemptedBy: options.agentId,
|
|
355
|
+
expectedVersion: options.expectedVersion,
|
|
356
|
+
actualVersion: actual,
|
|
357
|
+
rejectedIntent: options.intent,
|
|
358
|
+
clientRequestId: options.clientRequestId,
|
|
359
|
+
cwd: options.cwd,
|
|
360
|
+
});
|
|
361
|
+
throw new VersionConflictError(options.scope.loopId, options.expectedVersion, actual);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return options.work({ fenceCheck: acquired.fenceCheck, mutationId: acquired.blob.mutation_id });
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
acquired.release();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
export function recordConflict(input) {
|
|
371
|
+
const filePath = path.join(conflictsDir(input.cwd), `${input.loopId}.jsonl`);
|
|
372
|
+
ensureDirFor(filePath);
|
|
373
|
+
const entry = {
|
|
374
|
+
conflict_id: crypto.randomUUID(),
|
|
375
|
+
loop_id: input.loopId,
|
|
376
|
+
at: nowISO(),
|
|
377
|
+
attempted_by: input.attemptedBy,
|
|
378
|
+
expected_version: input.expectedVersion,
|
|
379
|
+
actual_version: input.actualVersion,
|
|
380
|
+
rejected_intent: input.rejectedIntent,
|
|
381
|
+
client_request_id: input.clientRequestId,
|
|
382
|
+
};
|
|
383
|
+
fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`);
|
|
384
|
+
}
|
|
385
|
+
//# sourceMappingURL=lock.js.map
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { memoryDir, writeFileAtomic } from '../io.js';
|
|
5
|
+
import { nowISO } from '../ids.js';
|
|
6
|
+
import { DEFAULT_PROTOCOLS, LoopEventSchema, LoopThreadSchema, } from './types.js';
|
|
7
|
+
function loopsDir(cwd) {
|
|
8
|
+
return path.join(memoryDir(cwd ?? process.cwd()), 'loops');
|
|
9
|
+
}
|
|
10
|
+
function threadsDir(cwd) {
|
|
11
|
+
return path.join(loopsDir(cwd), 'threads');
|
|
12
|
+
}
|
|
13
|
+
function eventsDir(cwd) {
|
|
14
|
+
return path.join(loopsDir(cwd), 'events');
|
|
15
|
+
}
|
|
16
|
+
export function ensureLoopsDir(cwd) {
|
|
17
|
+
const dirs = [loopsDir(cwd), threadsDir(cwd), eventsDir(cwd)];
|
|
18
|
+
for (const dir of dirs) {
|
|
19
|
+
if (!fs.existsSync(dir))
|
|
20
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function threadPath(id, cwd) {
|
|
24
|
+
return path.join(threadsDir(cwd), `${id}.json`);
|
|
25
|
+
}
|
|
26
|
+
function eventsPath(id, cwd) {
|
|
27
|
+
return path.join(eventsDir(cwd), `${id}.jsonl`);
|
|
28
|
+
}
|
|
29
|
+
function writeAtomic(filePath, contents) {
|
|
30
|
+
writeFileAtomic(filePath, contents);
|
|
31
|
+
}
|
|
32
|
+
function randomIdSegment() {
|
|
33
|
+
return crypto.randomBytes(8).toString('hex');
|
|
34
|
+
}
|
|
35
|
+
export function generateLoopId() {
|
|
36
|
+
return `lop_${randomIdSegment()}`;
|
|
37
|
+
}
|
|
38
|
+
export function generateSlotId() {
|
|
39
|
+
return `lsl_${randomIdSegment()}`;
|
|
40
|
+
}
|
|
41
|
+
export function generateMutationId() {
|
|
42
|
+
return crypto.randomUUID().replace(/-/g, '');
|
|
43
|
+
}
|
|
44
|
+
function resolveProtocol(kind, mode) {
|
|
45
|
+
// pln#492 phase 2.b — carry the iteration block from DEFAULT_PROTOCOLS
|
|
46
|
+
// into the thread's protocol so advance() / iteration-engine see it.
|
|
47
|
+
const iteration = DEFAULT_PROTOCOLS[kind].iteration;
|
|
48
|
+
if (kind === 'review') {
|
|
49
|
+
return { review_mode: mode ?? 'asymmetric' };
|
|
50
|
+
}
|
|
51
|
+
if (mode !== undefined) {
|
|
52
|
+
// mode is only meaningful for review loops today; ignore otherwise.
|
|
53
|
+
return iteration ? { iteration } : undefined;
|
|
54
|
+
}
|
|
55
|
+
return iteration ? { iteration } : undefined;
|
|
56
|
+
}
|
|
57
|
+
function buildSlot(partial) {
|
|
58
|
+
return {
|
|
59
|
+
slot_id: partial.slot_id ?? generateSlotId(),
|
|
60
|
+
role: partial.role,
|
|
61
|
+
agent: partial.agent,
|
|
62
|
+
agent_id: partial.agent_id,
|
|
63
|
+
assignment_id: partial.assignment_id,
|
|
64
|
+
claim_id: partial.claim_id,
|
|
65
|
+
phase: partial.phase,
|
|
66
|
+
status: partial.status ?? 'open',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function appendEvent(loopId, event, cwd) {
|
|
70
|
+
const parsed = LoopEventSchema.parse(event);
|
|
71
|
+
ensureLoopsDir(cwd);
|
|
72
|
+
fs.appendFileSync(eventsPath(loopId, cwd), `${JSON.stringify(parsed)}\n`);
|
|
73
|
+
}
|
|
74
|
+
export function writeThreadFile(thread, cwd) {
|
|
75
|
+
const parsed = LoopThreadSchema.parse(thread);
|
|
76
|
+
ensureLoopsDir(cwd);
|
|
77
|
+
writeAtomic(threadPath(parsed.id, cwd), `${JSON.stringify(parsed, null, 2)}\n`);
|
|
78
|
+
}
|
|
79
|
+
export function openLoop(input, cwd) {
|
|
80
|
+
const protocolDefaults = DEFAULT_PROTOCOLS[input.kind];
|
|
81
|
+
const phases = input.phases ?? protocolDefaults.phases;
|
|
82
|
+
if (phases.length === 0) {
|
|
83
|
+
throw new Error('openLoop: phases must be non-empty');
|
|
84
|
+
}
|
|
85
|
+
const phaseNames = new Set(phases.map((p) => p.name));
|
|
86
|
+
if (phaseNames.size !== phases.length) {
|
|
87
|
+
throw new Error('openLoop: phase names must be unique');
|
|
88
|
+
}
|
|
89
|
+
const now = nowISO();
|
|
90
|
+
const id = generateLoopId();
|
|
91
|
+
const mutation_id = generateMutationId();
|
|
92
|
+
const slots = (input.slots ?? []).map(buildSlot);
|
|
93
|
+
const protocol = resolveProtocol(input.kind, input.mode);
|
|
94
|
+
const thread = {
|
|
95
|
+
schema_version: 1,
|
|
96
|
+
id,
|
|
97
|
+
version: 1,
|
|
98
|
+
mutation_id,
|
|
99
|
+
kind: input.kind,
|
|
100
|
+
title: input.title,
|
|
101
|
+
goal: input.goal,
|
|
102
|
+
protocol,
|
|
103
|
+
status: 'open',
|
|
104
|
+
phases,
|
|
105
|
+
current_phase: phases[0].name,
|
|
106
|
+
iteration_count: 0,
|
|
107
|
+
slots,
|
|
108
|
+
artifacts: [],
|
|
109
|
+
linked: input.linked,
|
|
110
|
+
stop_condition: input.stop_condition ?? protocolDefaults.stop_condition,
|
|
111
|
+
created_at: now,
|
|
112
|
+
updated_at: now,
|
|
113
|
+
created_by: input.created_by,
|
|
114
|
+
};
|
|
115
|
+
appendEvent(id, {
|
|
116
|
+
event_id: crypto.randomUUID(),
|
|
117
|
+
loop_id: id,
|
|
118
|
+
seq: 1,
|
|
119
|
+
at: now,
|
|
120
|
+
by: input.created_by,
|
|
121
|
+
mutation_id,
|
|
122
|
+
kind: 'opened',
|
|
123
|
+
initial_phase: thread.current_phase,
|
|
124
|
+
created_by: input.created_by,
|
|
125
|
+
}, cwd);
|
|
126
|
+
writeThreadFile(thread, cwd);
|
|
127
|
+
return thread;
|
|
128
|
+
}
|
|
129
|
+
export function getLoop(id, cwd) {
|
|
130
|
+
const filePath = threadPath(id, cwd);
|
|
131
|
+
if (!fs.existsSync(filePath))
|
|
132
|
+
return undefined;
|
|
133
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
134
|
+
return LoopThreadSchema.parse(JSON.parse(raw));
|
|
135
|
+
}
|
|
136
|
+
export function listLoops(filters = {}, cwd) {
|
|
137
|
+
const dir = threadsDir(cwd);
|
|
138
|
+
if (!fs.existsSync(dir))
|
|
139
|
+
return [];
|
|
140
|
+
const loops = [];
|
|
141
|
+
for (const file of fs.readdirSync(dir)) {
|
|
142
|
+
if (!file.endsWith('.json'))
|
|
143
|
+
continue;
|
|
144
|
+
try {
|
|
145
|
+
const raw = fs.readFileSync(path.join(dir, file), 'utf8');
|
|
146
|
+
const loop = LoopThreadSchema.parse(JSON.parse(raw));
|
|
147
|
+
if (filters.kind && loop.kind !== filters.kind)
|
|
148
|
+
continue;
|
|
149
|
+
if (filters.status && loop.status !== filters.status)
|
|
150
|
+
continue;
|
|
151
|
+
loops.push(loop);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Skip malformed files; the CAS/replay layer will surface diagnostics.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return loops.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
158
|
+
}
|
|
159
|
+
export function listLoopEvents(id, cwd) {
|
|
160
|
+
const filePath = eventsPath(id, cwd);
|
|
161
|
+
if (!fs.existsSync(filePath))
|
|
162
|
+
return [];
|
|
163
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
|
|
164
|
+
return lines.map((line) => LoopEventSchema.parse(JSON.parse(line)));
|
|
165
|
+
}
|
|
166
|
+
export function closeLoop(input, cwd) {
|
|
167
|
+
const current = getLoop(input.id, cwd);
|
|
168
|
+
if (!current) {
|
|
169
|
+
throw new Error(`closeLoop: unknown loop_id ${input.id}`);
|
|
170
|
+
}
|
|
171
|
+
if (current.status !== 'open' && current.status !== 'paused') {
|
|
172
|
+
throw new Error(`closeLoop: loop ${input.id} is already ${current.status}`);
|
|
173
|
+
}
|
|
174
|
+
const now = nowISO();
|
|
175
|
+
const mutation_id = generateMutationId();
|
|
176
|
+
const version = current.version + 1;
|
|
177
|
+
const events = listLoopEvents(input.id, cwd);
|
|
178
|
+
const seq = (events[events.length - 1]?.seq ?? 0) + 1;
|
|
179
|
+
const next = {
|
|
180
|
+
...current,
|
|
181
|
+
version,
|
|
182
|
+
mutation_id,
|
|
183
|
+
status: input.final_status,
|
|
184
|
+
updated_at: now,
|
|
185
|
+
closed_at: now,
|
|
186
|
+
};
|
|
187
|
+
appendEvent(input.id, {
|
|
188
|
+
event_id: crypto.randomUUID(),
|
|
189
|
+
loop_id: input.id,
|
|
190
|
+
seq,
|
|
191
|
+
at: now,
|
|
192
|
+
by: input.actor,
|
|
193
|
+
mutation_id,
|
|
194
|
+
kind: 'closed',
|
|
195
|
+
final_status: input.final_status,
|
|
196
|
+
reason: input.reason,
|
|
197
|
+
}, cwd);
|
|
198
|
+
writeThreadFile(next, cwd);
|
|
199
|
+
return next;
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=store.js.map
|