godcrm/backend/services/agent-permissions/reservations.js
GOD CRM Release 065f2edd23
Some checks are pending
CI / Lint / Typecheck / Test / Build (push) Waiting to run
CI / PostgreSQL Integration Tests (push) Waiting to run
GOD CRM — public scrubbed snapshot (2026-08-30)
Refresh of the open-core distribution from the private tree.

Included since the previous snapshot:
- Mail module (ADR-158/159/160/169): composer, labels, scheduling,
  attachments, reply-tokens, IMAP/SMTP bridge + migrations 079-083
- Crawler-readable SSR for /blog and public spaces (ADR-190):
  blogSeo, publicDocsSeo, per-space SEO prefs, blog index/post pages
- Registration policy + referral/promo settings (ADR-183/188)
- Message translation + language detection (ADR-185)
- Reddit connector for the agent-tool surface

Excised from the public distribution (unchanged policy): infrastructure
topology and host config, internal ops scripts, DB cleanup snapshots,
business documents, throwaway debug scripts, and two private product
lines (SC-SIM simulator, personal one-off tools). Real host addresses
are replaced with placeholders; credential-shaped literals are redacted.

Frontend build verified green on this tree.
2026-08-30 15:13:28 +03:00

250 lines
10 KiB
JavaScript

/**
* ADR-0181 — Agent Write-Reservations (advisory concurrent-loop soft-lock).
*
* Evaluated as a POST-ALLOW stage inside the ADR-0053 /check route handler
* (backend/routes/v3/agent-permissions.js). Only file-mutation tools are
* gated; everything else (Bash/Read/MCP) is untouched.
*
* Semantics:
* - Fresh path, or the SAME holder_session re-touching it (multi-file WP
* turn) → acquire/renew → ALLOW.
* - Path held live by a DIFFERENT holder_session → soft DENY carrying the
* holder + expiry, so the second loop defers / re-reads and converges
* instead of co-authoring.
* - Stale reservation (expires_at < now()) → transparently stolen on the
* next acquire (dead-loop self-release; no manual unlock ever needed).
*
* FAIL-OPEN (non-negotiable — this is what "advisory" means): any error in
* this stage returns null → the caller keeps the policy ALLOW. The guard
* reduces collision probability for a 2-3 loop fleet; it is NOT a correctness
* barrier and must never block or kill a live loop.
*/
import { dbAll, dbGet } from '../../database/connection.js';
import { apiLogger } from '../../utils/logger.js';
const log = apiLogger.child({ module: 'agent_write_reservations' });
// File-mutation tools that carry a target path. Bash/Read/MCP are NOT here →
// they skip the reservation stage entirely (ADR-0181 AC#6).
export const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
// TTL default 10 min — long enough to cover a multi-file WP turn, short enough
// that a crashed loop's reservation frees fast. Tunable via env.
const DEFAULT_TTL_SECONDS = Number(process.env.AGENT_WRITE_RESERVATION_TTL_SECONDS) || 600;
const BASE_TREE = '/root/production/business-crm/';
const DEPLOY_ARTIFACT = '/srv/godcrm/live/';
// Worktree prefix is NESTED inside BASE_TREE, so it must be stripped FIRST or
// the base-tree strip would leave `.claude/worktrees/agent-*/<p>` as the key.
const WORKTREE_RE = /^\/root\/production\/business-crm\/\.claude\/worktrees\/agent-[^/]+\//;
/**
* Normalize an absolute file path to a tree/worktree-agnostic repo-relative
* key, so two loops touching the same logical file collide regardless of which
* tree or worktree they edit in (ADR-0181 "which tree is canonical").
* @param {string} filePath
* @returns {string|null} repo-relative key, or null if unusable
*/
export function normalizeReservationPath(filePath) {
if (!filePath || typeof filePath !== 'string') return null;
let p = filePath.trim();
if (!p) return null;
if (WORKTREE_RE.test(p)) {
p = p.replace(WORKTREE_RE, '');
} else if (p.startsWith(BASE_TREE)) {
p = p.slice(BASE_TREE.length);
} else if (p.startsWith(DEPLOY_ARTIFACT)) {
p = p.slice(DEPLOY_ARTIFACT.length);
}
// Strip any leading ./ or / so a relative path and its absolute form collapse.
p = p.replace(/^\.?\/+/, '');
return p || null;
}
/**
* Extract the target path from a tool_input for a file-mutation tool.
* @returns {string|null}
*/
function extractPath(toolInput) {
if (!toolInput || typeof toolInput !== 'object') return null;
const raw = toolInput.file_path ?? toolInput.notebook_path ?? null;
return typeof raw === 'string' ? raw : null;
}
/**
* Atomic steal-if-expired-or-mine acquire (ON CONFLICT).
* @returns {Promise<{granted: true} | {granted: false, holder: string|null, wpId: string|null, expiresAt: string|null}>}
*/
export async function acquireWriteReservation({
filePath,
holderSession,
wpId = null,
agentId = null,
spaceId = null,
ttlSeconds = DEFAULT_TTL_SECONDS,
}) {
const interval = `${Math.max(1, Math.floor(ttlSeconds))} seconds`;
// dbGet returns the RETURNING row on insert/renew/steal, or undefined when
// the ON CONFLICT WHERE filters the update out (someone else holds it live).
const row = await dbGet(
`INSERT INTO _agent_write_reservations
(file_path, wp_id, holder_session, agent_id, space_id, expires_at)
VALUES (?, ?, ?, ?, ?, now() + ?::interval)
ON CONFLICT (file_path) DO UPDATE
SET holder_session = EXCLUDED.holder_session,
wp_id = EXCLUDED.wp_id,
agent_id = EXCLUDED.agent_id,
space_id = EXCLUDED.space_id,
renewed_at = now(),
expires_at = EXCLUDED.expires_at
WHERE _agent_write_reservations.holder_session = EXCLUDED.holder_session
OR _agent_write_reservations.expires_at < now()
RETURNING holder_session, wp_id, expires_at`,
[filePath, wpId, holderSession, agentId, spaceId, interval]
);
if (row && row.holder_session === holderSession) {
return { granted: true };
}
// Contested — read back the live holder for the deny reason.
const cur = await dbGet(
`SELECT holder_session, wp_id, expires_at
FROM _agent_write_reservations WHERE file_path = ?`,
[filePath]
);
return {
granted: false,
holder: cur?.holder_session ?? null,
wpId: cur?.wp_id ?? null,
expiresAt: cur?.expires_at ?? null,
};
}
/**
* Best-effort release (fast path — a Stop hook can call this at turn end).
* TTL is the backstop; a missed release just expires. Never throws.
* @returns {Promise<boolean>} true if a row was deleted
*/
export async function releaseWriteReservation({ filePath, holderSession }) {
try {
const key = normalizeReservationPath(filePath);
if (!key || !holderSession) return false;
const res = await dbGet(
`DELETE FROM _agent_write_reservations
WHERE file_path = ? AND holder_session = ? RETURNING file_path`,
[key, holderSession]
);
return Boolean(res);
} catch (err) {
log.warn({ err: String(err?.message || err) }, 'releaseWriteReservation failed (ignored)');
return false;
}
}
/**
* Release EVERY path held by one holder_session — the turn-end fast path.
*
* Why this exists: each chat turn runs as its own headless `claude -p` worker
* with a FRESH session UUID, while the agent identity stays the same. Without
* a turn-end release the reservation survives for the full TTL and the SAME
* agent's next turn hits its own lock as if it were a foreign loop. The Stop
* hook (scripts/agent-reservation-release-hook.js) calls this with the ending
* session's id. TTL remains the backstop; a missed release just expires.
*
* @returns {Promise<number>} number of reservations released
*/
export async function releaseSessionReservations({ holderSession }) {
try {
if (!holderSession) return 0;
const rows = await dbAll(
`DELETE FROM _agent_write_reservations
WHERE holder_session = ? RETURNING file_path`,
[holderSession]
);
return Array.isArray(rows) ? rows.length : 0;
} catch (err) {
log.warn({ err: String(err?.message || err) }, 'releaseSessionReservations failed (ignored)');
return 0;
}
}
/**
* Drop every already-expired reservation, regardless of holder.
*
* Purely hygienic: an expired row is transparently stolen by the next acquire
* (see the ON CONFLICT predicate), so deleting it changes NO decision — it only
* stops the table growing without bound. A live row can never be hit: the
* predicate is `expires_at < now()` evaluated server-side, so a reservation
* another loop acquires mid-statement is out of scope by construction.
*
* Called from the Stop hook so the table self-cleans at every turn end.
*
* @returns {Promise<number>} number of expired rows dropped
*/
export async function purgeExpiredReservations() {
try {
const rows = await dbAll(
`DELETE FROM _agent_write_reservations WHERE expires_at < now() RETURNING file_path`
);
return Array.isArray(rows) ? rows.length : 0;
} catch (err) {
log.warn({ err: String(err?.message || err) }, 'purgeExpiredReservations failed (ignored)');
return 0;
}
}
/**
* Read the current live holder of a normalized path (observability / tests).
* @returns {Promise<object|null>}
*/
export async function checkReservation(filePath) {
const key = normalizeReservationPath(filePath);
if (!key) return null;
const row = await dbGet(
`SELECT file_path, holder_session, wp_id, agent_id, space_id, acquired_at, renewed_at, expires_at
FROM _agent_write_reservations WHERE file_path = ?`,
[key]
);
return row || null;
}
/**
* The /check post-allow gate. Returns a soft-DENY decision object (same shape
* as resolver.resolve) when a DIFFERENT live loop holds the path, otherwise
* null (→ caller keeps the policy ALLOW). FULLY FAIL-OPEN.
*
* @returns {Promise<{decision:'deny', reason:string, matched_source:'reservation', matched_rule_id:null} | null>}
*/
export async function gateWrite({ toolName, toolInput, holderSession, wpId = null, agentId = null, spaceId = null }) {
try {
if (!WRITE_TOOLS.has(toolName)) return null; // non-mutation tool — untouched (AC#6)
if (!holderSession) return null; // no holder identity → cannot reserve → allow
const key = normalizeReservationPath(extractPath(toolInput));
if (!key) return null; // no target path → nothing to reserve
const result = await acquireWriteReservation({
filePath: key, holderSession, wpId, agentId, spaceId,
});
if (result.granted) return null; // acquired/renewed my own → allow
const until = result.expiresAt ? new Date(result.expiresAt).toISOString() : 'soon';
const who = result.holder ? `${result.holder}${result.wpId ? `/${result.wpId}` : ''}` : 'another loop';
log.info({ key, holder: result.holder, wpId: result.wpId }, 'write-reservation soft-deny');
return {
decision: 'deny',
reason: `File reserved by ${who} until ${until} — defer & re-read (ADR-0181 advisory soft-lock).`,
matched_source: 'reservation',
matched_rule_id: null,
};
} catch (err) {
// Fail-open: reservation must never break a live worker (ADR-0181 AC#5).
log.warn({ err: String(err?.message || err) }, 'gateWrite failed — fail-open allow');
return null;
}
}
export default { WRITE_TOOLS, normalizeReservationPath, acquireWriteReservation, releaseWriteReservation, releaseSessionReservations, purgeExpiredReservations, checkReservation, gateWrite };