@sema-agent/server 7.91.2 → 7.92.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/README.md +1 -1
- package/USAGE.md +8 -0
- package/dist/approval-ask-machine.d.ts +1 -1
- package/dist/approval-reconciler.d.ts +1 -1
- package/dist/approval.d.ts +1 -1
- package/dist/bench/s1/repair-oracle-adapter.d.ts +1 -1
- package/dist/bench/s1/runner-ctx.d.ts +1 -1
- package/dist/boot/runner-deps.d.ts +9 -9
- package/dist/capabilities/memory-notice.d.ts +27 -10
- package/dist/capabilities/memory-notice.js +21 -2
- package/dist/config-catalog.js +2 -1
- package/dist/config-center/types.d.ts +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/env-facts.d.ts +1 -1
- package/dist/execution-lane-caps.d.ts +1 -1
- package/dist/file-history-disclosure.d.ts +2 -2
- package/dist/fleet/fleet-bus.d.ts +7 -1
- package/dist/fleet/fleet-bus.js +5 -3
- package/dist/http/admission.d.ts +1 -1
- package/dist/http/idempotency.d.ts +27 -0
- package/dist/http/route-ctx.d.ts +2 -5
- package/dist/http/routes/memory-compliance.d.ts +1 -1
- package/dist/http/routes/memory-origin.d.ts +2 -2
- package/dist/http/routes/rules.js +3 -1
- package/dist/http/routes/runs.js +3 -6
- package/dist/http/routes/session-sync.js +3 -2
- package/dist/http/routes/sessions.js +3 -2
- package/dist/http/routes/tasks.js +35 -19
- package/dist/http/wire-types.d.ts +29 -2
- package/dist/leader/wire.d.ts +1 -1
- package/dist/lsp/manager.d.ts +2 -2
- package/dist/memory-operator-faces.d.ts +13 -13
- package/dist/memory-scope.d.ts +2 -2
- package/dist/observability/fail-open.d.ts +1 -1
- package/dist/observability/fail-open.js +1 -1
- package/dist/observability/metrics.js +2 -1
- package/dist/orchestration/workflow-agent-session-index.d.ts +2 -2
- package/dist/peer-directory.d.ts +3 -2
- package/dist/permission-rule-vocab.d.ts +16 -1
- package/dist/plugins/file-run-store.d.ts +1 -1
- package/dist/plugins/local-session-store.d.ts +1 -1
- package/dist/plugins/memory-session-policy-store.d.ts +22 -3
- package/dist/plugins/memory-session-policy-store.js +11 -4
- package/dist/plugins/permission-rule-store-sql.js +16 -7
- package/dist/plugins/pg-pool.js +11 -0
- package/dist/plugins/session-policy-store-sql.d.ts +51 -3
- package/dist/plugins/session-policy-store-sql.js +77 -38
- package/dist/plugins/sql-errors.d.ts +25 -0
- package/dist/plugins/sql-errors.js +10 -0
- package/dist/plugins/store-backend.d.ts +5 -5
- package/dist/plugins/store-contracts.d.ts +2 -2
- package/dist/plugins/tidb-pool.d.ts +1 -1
- package/dist/plugins/tidb-pool.js +19 -0
- package/dist/rules-consent.d.ts +16 -9
- package/dist/rules-consent.js +14 -35
- package/dist/run-cancel-context.d.ts +1 -1
- package/dist/run-local.d.ts +1 -1
- package/dist/runtime-caps-resolver.d.ts +10 -10
- package/dist/runtime-governance.d.ts +2 -2
- package/dist/session-policy-wire.d.ts +25 -1
- package/dist/session-policy-wire.js +11 -0
- package/dist/spec-fields.d.ts +4 -4
- package/dist/task-cwd.d.ts +1 -1
- package/dist/task-settings.d.ts +2 -2
- package/dist/tool-approval.d.ts +1 -1
- package/dist/trace/core-keyset-guard.d.ts +1 -1
- package/dist/trace/project.d.ts +25 -1
- package/dist/trace/project.js +4 -0
- package/dist/trace/redact.d.ts +1 -1
- package/dist/trace/redact.js +8 -3
- package/package.json +3 -3
|
@@ -25,9 +25,57 @@ export declare class SqlSessionPolicyStore implements SessionPolicyStore {
|
|
|
25
25
|
listBySession(sessionId: string): Promise<SessionRulesRecord[]>;
|
|
26
26
|
/** Operator/owner write: CAS + tighten-only (unless operator) + rev bump, atomic in one txn; first-write race retried. */
|
|
27
27
|
putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
/**
|
|
29
|
+
* The store's ONE retry arm: re-run the whole read-check-write transaction while the failure is an engine
|
|
30
|
+
* ARBITRATION artifact rather than an answer. Two such artifacts, and only two:
|
|
31
|
+
* · **dup-key** — a concurrent FIRST write won the PK (no row ⇒ `FOR UPDATE` locked nothing). The re-read takes
|
|
32
|
+
* the UPDATE/CAS path, which is core's serialized outcome; converting it to `conflict` in place would be a
|
|
33
|
+
* spurious answer the caller can't tell from a real CAS loss.
|
|
34
|
+
* · **deadlock** — the engine picked THIS transaction as the victim and already rolled it back. Which side gets
|
|
35
|
+
* picked is arbitrary, so propagating it hands the caller an internal coin flip. 🔴 This arm is NOT a licence
|
|
36
|
+
* for sloppy lock order: the order is still policy row → mark row on BOTH methods, and the oracle for it is
|
|
37
|
+
* `test/sql-lock-shape-innodb-integration.test.ts` (site ⑩, three engines). It exists because on REAL InnoDB
|
|
38
|
+
* (REPEATABLE READ) a `FOR UPDATE` read of a MISSING primary key takes a GAP lock, and gap locks are mutually
|
|
39
|
+
* compatible while both block the other side's INSERT — so N concurrent first writes on one key deadlock NO
|
|
40
|
+
* MATTER what order any table is touched in. Measured on the real engine (2026-09-20, S-529, 8-way race on
|
|
41
|
+
* `main`'s statement sequence, zero mark table involved): InnoDB 1 winner / 7 `ER_LOCK_DEADLOCK`, TiDB 0,
|
|
42
|
+
* PG 0 — i.e. this is a PRE-EXISTING MySQL-leg defect that S-529's lock-shape site made visible, not a new one.
|
|
43
|
+
* After the first insert commits, the retried writers find the row and take the UPDATE path (record lock,
|
|
44
|
+
* no gap) ⇒ the loop converges in one extra round per missing-row window; the bound is the suite's concurrency.
|
|
45
|
+
* Lock-WAIT-TIMEOUT is deliberately NOT retried ({@link isDeadlockError}'s own note: it is a latency signal,
|
|
46
|
+
* and retrying it multiplies an already-long wait).
|
|
47
|
+
*/
|
|
48
|
+
private retrying;
|
|
49
|
+
/** E21 purge — drop ALL policy rows for a session (every principal), KEEPING each key's rev high-water mark.
|
|
50
|
+
* Scoped by session_id (the route owner-gates). Return shape aligned to core 1.423's optional interface seam
|
|
51
|
+
* (`Promise<void>`) — the affectedRows count was incidental and unconsumed; the integration test asserts the
|
|
52
|
+
* post-state (listBySession empty) instead.
|
|
53
|
+
*
|
|
54
|
+
* S-529 (`@contract session_policy.rev_survives_delete`): the rules ROW dies, the key's REV LINE does not, and the
|
|
55
|
+
* erase ALSO ENDS THE ROW'S LINEAGE (`gen` + 1 — the next write on this key belongs to another generation, which no
|
|
56
|
+
* reader treats as a continuation of the erased row whatever rev it wears) — one
|
|
57
|
+
* transaction, three statements, and BOTH the order and the locking verb are load-bearing:
|
|
58
|
+
* ① `SELECT … ORDER BY policy_key FOR UPDATE` — the rows to erase are read and X-LOCKED first, in a DETERMINISTIC
|
|
59
|
+
* order. This is the SAME lock order putRules uses (policy row → mark row), which is the whole point: the
|
|
60
|
+
* obvious shape (`INSERT … SELECT` the marks, then DELETE) locks the MARK first and takes only a SHARED lock
|
|
61
|
+
* on the policy rows it reads, i.e. a reversed order PLUS an S→X upgrade — the textbook deadlock this repo
|
|
62
|
+
* 已经吃过两次(retention-store-sql.ts ④a/④b 的 S-131 案:一边持 boundary 等哨兵,一边持哨兵等 boundary)。
|
|
63
|
+
* ② the marks (their revs come from ①'s already-locked rows) — `GREATEST` merge, so a mark only ever RISES and
|
|
64
|
+
* repeated erases never walk the line back.
|
|
65
|
+
* ③ the DELETE — scoped to ①'s POLICY_KEY SET, never re-scanned by `session_id`. 🔴 This is the whole of
|
|
66
|
+
* codex r1 [high] #1 (verified, not rebutted): both engines re-evaluate a `WHERE session_id = ?` DELETE
|
|
67
|
+
* against a CURRENT read (PG is pinned to READ COMMITTED, TiDB's DML is a current read), so a row for a
|
|
68
|
+
* DIFFERENT principal that a concurrent putRules commits BETWEEN ① and ③ would be deleted by a session-wide
|
|
69
|
+
* DELETE while ② never marked it — a silently reset rev line, exactly the defect this batch exists to close.
|
|
70
|
+
* Scoped to the key set, the invariant is exact and checkable: **every row this seam deletes leaves a mark**.
|
|
71
|
+
* A row born after ① is simply not part of this erase (same as one born a microsecond after ③ — the caller
|
|
72
|
+
* that needs "and nothing came back" re-issues; the seam is idempotent).
|
|
73
|
+
* No rows ⇒ return early: an erase of a session that never had rules writes NO mark (no line, nothing to keep),
|
|
74
|
+
* which is what makes a repeated/empty delete idempotent instead of resurrecting a mark.
|
|
75
|
+
* Why the mark is a separate table and not a tombstone row: see the DDL comment in tidb-pool.ts — a tombstone
|
|
76
|
+
* would keep `session_id` + the DENORMALIZED plaintext `principal` in the table after an E21 purge, i.e. the
|
|
77
|
+
* right-to-delete would degrade to buy rev ordering. The mark keeps only the one-way hash, the session id and
|
|
78
|
+
* an integer. */
|
|
31
79
|
deleteBySession(sessionId: string): Promise<void>;
|
|
32
80
|
/**
|
|
33
81
|
* The store's one read-check-write transaction. Its load-bearing read is line ~106's
|
|
@@ -2,10 +2,22 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { loosenReasons, SessionPolicyError, normalizeRules, stripRev } from "@sema-agent/core";
|
|
3
3
|
import { pgHasUnstorable } from "./pg-safe-json.js";
|
|
4
4
|
import { mysqlDriver, pgDriver } from "./sql-driver.js";
|
|
5
|
-
import { isDupKeyError } from "./sql-errors.js";
|
|
5
|
+
import { isDeadlockError, isDupKeyError } from "./sql-errors.js";
|
|
6
6
|
export const policyKeyFor = (sessionId, principal) => createHash("sha256").update(JSON.stringify([sessionId, principal ?? null])).digest("hex");
|
|
7
7
|
const policyKey = policyKeyFor;
|
|
8
|
+
const RETRY_LIMIT = 8;
|
|
8
9
|
const parseRules = (v) => (typeof v === "string" ? JSON.parse(v) : v);
|
|
10
|
+
function rowToStored(r, where) {
|
|
11
|
+
const rev = Number(r.rev);
|
|
12
|
+
if (!Number.isFinite(rev) || !Number.isInteger(rev) || rev < 1) {
|
|
13
|
+
throw new SessionPolicyError("corrupt", `session rules row ${where} carries rev ${String(r.rev)} — a stored row's rev is always ≥ 1 (the stamp is floor+1), and rev 0 is the live reader's synthetic empty seed; refusing rather than serve it as rules`);
|
|
14
|
+
}
|
|
15
|
+
const gen = Number(r.gen ?? 0);
|
|
16
|
+
if (!Number.isFinite(gen) || !Number.isInteger(gen) || gen < 0) {
|
|
17
|
+
throw new SessionPolicyError("corrupt", `session rules row ${where} carries lineage gen ${String(r.gen)} — a generation is a whole number ≥ 0; refusing rather than let a reader judge lineage by a value nothing minted`);
|
|
18
|
+
}
|
|
19
|
+
return { ...parseRules(r.rules), rev, gen };
|
|
20
|
+
}
|
|
9
21
|
export class SqlSessionPolicyStore {
|
|
10
22
|
db;
|
|
11
23
|
constructor(db) {
|
|
@@ -18,62 +30,89 @@ export class SqlSessionPolicyStore {
|
|
|
18
30
|
return isDupKeyError(this.db.dialect, e);
|
|
19
31
|
}
|
|
20
32
|
async getRules(sessionId, principal) {
|
|
21
|
-
const { rows } = await this.db.query(this.q("SELECT rules, rev FROM session_policy WHERE policy_key = ?", "SELECT rules, rev FROM session_policy WHERE policy_key = $1"), [policyKey(sessionId, principal)]);
|
|
33
|
+
const { rows } = await this.db.query(this.q("SELECT rules, rev, gen FROM session_policy WHERE policy_key = ?", "SELECT rules, rev, gen FROM session_policy WHERE policy_key = $1"), [policyKey(sessionId, principal)]);
|
|
22
34
|
if (!rows[0])
|
|
23
35
|
return null;
|
|
24
|
-
return
|
|
36
|
+
return rowToStored(rows[0], `for session ${JSON.stringify(sessionId)}`);
|
|
25
37
|
}
|
|
26
38
|
async listBySession(sessionId) {
|
|
27
|
-
const { rows } = await this.db.query(this.q("SELECT principal, rules, rev FROM session_policy WHERE session_id = ?", "SELECT principal, rules, rev FROM session_policy WHERE session_id = $1"), [sessionId]);
|
|
39
|
+
const { rows } = await this.db.query(this.q("SELECT principal, rules, rev, gen FROM session_policy WHERE session_id = ?", "SELECT principal, rules, rev, gen FROM session_policy WHERE session_id = $1"), [sessionId]);
|
|
28
40
|
return rows.map((r) => ({
|
|
29
41
|
principal: r.principal == null ? undefined : String(r.principal),
|
|
30
|
-
rules:
|
|
42
|
+
rules: rowToStored(r, `for session ${JSON.stringify(sessionId)} (enumeration)`),
|
|
31
43
|
}));
|
|
32
44
|
}
|
|
33
45
|
async putRules(sessionId, principal, rules, opts) {
|
|
34
46
|
const key = policyKey(sessionId, principal);
|
|
35
|
-
|
|
47
|
+
return this.retrying(() => this.tx(async (conn) => {
|
|
48
|
+
const { rows } = await conn.query(this.q("SELECT rules, rev, gen FROM session_policy WHERE policy_key = ? FOR UPDATE", "SELECT rules, rev, gen FROM session_policy WHERE policy_key = $1 FOR UPDATE"), [key]);
|
|
49
|
+
const prior = rows[0] ? rowToStored(rows[0], `for session ${JSON.stringify(sessionId)} (pre-write snapshot)`) : undefined;
|
|
50
|
+
const priorRev = prior?.rev ?? 0;
|
|
51
|
+
if (opts?.expectedRev !== undefined && priorRev !== opts.expectedRev) {
|
|
52
|
+
throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts.expectedRev}, have ${priorRev})`);
|
|
53
|
+
}
|
|
54
|
+
const { rows: markRows } = await conn.query(this.q("SELECT rev, gen FROM session_policy_rev_mark WHERE policy_key = ? FOR UPDATE", "SELECT rev, gen FROM session_policy_rev_mark WHERE policy_key = $1 FOR UPDATE"), [key]);
|
|
55
|
+
const markRev = markRows[0] ? Number(markRows[0].rev) : 0;
|
|
56
|
+
const markGen = markRows[0] ? Number(markRows[0].gen) : 0;
|
|
57
|
+
if (markRows[0] !== undefined && (!Number.isInteger(markGen) || markGen < 0 || !Number.isFinite(markRev) || markRev < 0)) {
|
|
58
|
+
throw new SessionPolicyError("corrupt", `the session rules rev mark for session ${JSON.stringify(sessionId)} is unreadable (rev ${String(markRows[0].rev)}, gen ${String(markRows[0].gen)}) — refusing rather than stamp a rev/lineage nothing proves`);
|
|
59
|
+
}
|
|
60
|
+
const clean = normalizeRules(stripRev(rules));
|
|
61
|
+
if (prior && opts?.operator !== true) {
|
|
62
|
+
const loosens = loosenReasons(prior, clean);
|
|
63
|
+
if (loosens.length > 0) {
|
|
64
|
+
throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (this.db.dialect === "pg" && pgHasUnstorable(clean)) {
|
|
68
|
+
throw new SessionPolicyError("loosen_forbidden", "rules contain unstorable bytes (NUL / lone surrogate) — refused; strip them at the source (authorization rules are never rewritten by the store)");
|
|
69
|
+
}
|
|
70
|
+
const nextRev = Math.max(priorRev, markRev) + 1;
|
|
71
|
+
const nextGen = prior?.gen ?? markGen;
|
|
72
|
+
const json = JSON.stringify(clean);
|
|
73
|
+
const now = new Date();
|
|
74
|
+
if (prior) {
|
|
75
|
+
const upd = await conn.query(this.q("UPDATE session_policy SET principal = ?, rules = ?, rev = ?, gen = ?, updated_at = ? WHERE policy_key = ? AND rev = ?", "UPDATE session_policy SET principal = $1, rules = $2::jsonb, rev = $3, gen = $4, updated_at = $5 WHERE policy_key = $6 AND rev = $7"), [principal ?? null, json, nextRev, nextGen, now, key, priorRev]);
|
|
76
|
+
if (upd.affected !== 1)
|
|
77
|
+
throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts?.expectedRev ?? priorRev}, have ${priorRev})`);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
await conn.query(this.q("INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, gen, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)", "INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, gen, created_at, updated_at) VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8)"), [key, sessionId, principal ?? null, json, nextRev, nextGen, now, now]);
|
|
81
|
+
}
|
|
82
|
+
return structuredClone({ ...clean, rev: nextRev, gen: nextGen });
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
async retrying(attempt) {
|
|
86
|
+
for (let tries = 0;; tries++) {
|
|
36
87
|
try {
|
|
37
|
-
return await
|
|
38
|
-
const { rows } = await conn.query(this.q("SELECT rules, rev FROM session_policy WHERE policy_key = ? FOR UPDATE", "SELECT rules, rev FROM session_policy WHERE policy_key = $1 FOR UPDATE"), [key]);
|
|
39
|
-
const prior = rows[0] ? { rules: parseRules(rows[0].rules), rev: Number(rows[0].rev) } : undefined;
|
|
40
|
-
const priorRev = prior?.rev ?? 0;
|
|
41
|
-
if (opts?.expectedRev !== undefined && priorRev !== opts.expectedRev) {
|
|
42
|
-
throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts.expectedRev}, have ${priorRev})`);
|
|
43
|
-
}
|
|
44
|
-
const clean = normalizeRules(stripRev(rules));
|
|
45
|
-
if (prior && opts?.operator !== true) {
|
|
46
|
-
const loosens = loosenReasons(prior.rules, clean);
|
|
47
|
-
if (loosens.length > 0) {
|
|
48
|
-
throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
if (this.db.dialect === "pg" && pgHasUnstorable(clean)) {
|
|
52
|
-
throw new SessionPolicyError("loosen_forbidden", "rules contain unstorable bytes (NUL / lone surrogate) — refused; strip them at the source (authorization rules are never rewritten by the store)");
|
|
53
|
-
}
|
|
54
|
-
const nextRev = priorRev + 1;
|
|
55
|
-
const json = JSON.stringify(clean);
|
|
56
|
-
const now = new Date();
|
|
57
|
-
if (prior) {
|
|
58
|
-
const upd = await conn.query(this.q("UPDATE session_policy SET principal = ?, rules = ?, rev = ?, updated_at = ? WHERE policy_key = ? AND rev = ?", "UPDATE session_policy SET principal = $1, rules = $2::jsonb, rev = $3, updated_at = $4 WHERE policy_key = $5 AND rev = $6"), [principal ?? null, json, nextRev, now, key, priorRev]);
|
|
59
|
-
if (upd.affected !== 1)
|
|
60
|
-
throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts?.expectedRev ?? priorRev}, have ${priorRev})`);
|
|
61
|
-
}
|
|
62
|
-
else {
|
|
63
|
-
await conn.query(this.q("INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, created_at, updated_at) VALUES (?,?,?,?,?,?,?)", "INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, created_at, updated_at) VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7)"), [key, sessionId, principal ?? null, json, nextRev, now, now]);
|
|
64
|
-
}
|
|
65
|
-
return structuredClone({ ...clean, rev: nextRev });
|
|
66
|
-
});
|
|
88
|
+
return await attempt();
|
|
67
89
|
}
|
|
68
90
|
catch (e) {
|
|
69
|
-
if (this.isDupKey(e)
|
|
91
|
+
if (tries < RETRY_LIMIT && (this.isDupKey(e) || isDeadlockError(this.db.dialect, e)))
|
|
70
92
|
continue;
|
|
71
93
|
throw e;
|
|
72
94
|
}
|
|
73
95
|
}
|
|
74
96
|
}
|
|
75
97
|
async deleteBySession(sessionId) {
|
|
76
|
-
await this.
|
|
98
|
+
await this.retrying(() => this.tx(async (conn) => {
|
|
99
|
+
const { rows } = await conn.query(this.q("SELECT policy_key, rev, gen FROM session_policy WHERE session_id = ? ORDER BY policy_key FOR UPDATE", "SELECT policy_key, rev, gen FROM session_policy WHERE session_id = $1 ORDER BY policy_key FOR UPDATE"), [sessionId]);
|
|
100
|
+
if (rows.length === 0)
|
|
101
|
+
return;
|
|
102
|
+
const now = new Date();
|
|
103
|
+
const keys = rows.map((r) => String(r.policy_key));
|
|
104
|
+
const params = [];
|
|
105
|
+
const tuples = rows.map((r) => {
|
|
106
|
+
params.push(String(r.policy_key), sessionId, Number(r.rev), Number(r.gen ?? 0) + 1, now);
|
|
107
|
+
const base = params.length - 5;
|
|
108
|
+
return this.db.dialect === "tidb" ? "(?,?,?,?,?)" : `($${base + 1},$${base + 2},$${base + 3},$${base + 4},$${base + 5})`;
|
|
109
|
+
});
|
|
110
|
+
await conn.query(this.q(`INSERT INTO session_policy_rev_mark (policy_key, session_id, rev, gen, erased_at) VALUES ${tuples.join(",")}` +
|
|
111
|
+
" ON DUPLICATE KEY UPDATE rev = GREATEST(session_policy_rev_mark.rev, VALUES(rev)), gen = GREATEST(session_policy_rev_mark.gen + 1, VALUES(gen)), erased_at = VALUES(erased_at)", `INSERT INTO session_policy_rev_mark (policy_key, session_id, rev, gen, erased_at) VALUES ${tuples.join(",")}` +
|
|
112
|
+
" ON CONFLICT (policy_key) DO UPDATE SET rev = GREATEST(session_policy_rev_mark.rev, EXCLUDED.rev), gen = GREATEST(session_policy_rev_mark.gen + 1, EXCLUDED.gen), erased_at = EXCLUDED.erased_at"), params);
|
|
113
|
+
const holes = keys.map((_, i) => (this.db.dialect === "tidb" ? "?" : `$${i + 1}`)).join(",");
|
|
114
|
+
await conn.query(`DELETE FROM session_policy WHERE policy_key IN (${holes})`, keys);
|
|
115
|
+
}));
|
|
77
116
|
}
|
|
78
117
|
async tx(fn) {
|
|
79
118
|
const conn = await this.db.connect();
|
|
@@ -68,4 +68,29 @@ export declare const MYSQL_ER_CHECK_VIOLATED_CODE = "ER_CHECK_CONSTRAINT_VIOLATE
|
|
|
68
68
|
* 它守的是一句**指向手工 DDL 的**错误指路,认不出的一律不是 CHECK 拒(原始错误原样上抛)。
|
|
69
69
|
*/
|
|
70
70
|
export declare function isCheckViolationError(err: unknown, dialect: SqlDialect): boolean;
|
|
71
|
+
/** MySQL/TiDB `ER_LOCK_DEADLOCK`(引擎挑了本事务当牺牲者)的数字 errno。 */
|
|
72
|
+
export declare const MYSQL_ER_LOCK_DEADLOCK_ERRNO = 1213;
|
|
73
|
+
/** MySQL/TiDB `ER_LOCK_DEADLOCK` 的字符串错误码。 */
|
|
74
|
+
export declare const MYSQL_ER_LOCK_DEADLOCK_CODE = "ER_LOCK_DEADLOCK";
|
|
75
|
+
/** PostgreSQL `deadlock_detected` 的 SQLSTATE。 */
|
|
76
|
+
export declare const PG_DEADLOCK_DETECTED_SQLSTATE = "40P01";
|
|
77
|
+
/**
|
|
78
|
+
* 「这个错误**是**『引擎判了死锁、本事务被挑成牺牲者』吗」——唯一的**可重试**锁故障判据。
|
|
79
|
+
*
|
|
80
|
+
* 🔴 **只认死锁,不认锁等待超时**(`ER_LOCK_WAIT_TIMEOUT` / 语句超时**刻意不在**识别集里):死锁是
|
|
81
|
+
* 引擎的**仲裁产物** —— 被挑中的一方已经整事务回滚、锁全放,立刻重来通常就过去了,而**谁**当牺牲者
|
|
82
|
+
* 是引擎的任意选择,把它抛给调用方等于把一次内部仲裁当成用户的错。锁等待超时是**另一件事**:它说的是
|
|
83
|
+
* 「有人把锁握了太久」,那是一个延迟信号,重试只会把一次已经很长的等待乘上重试次数(InnoDB 缺省 50s),
|
|
84
|
+
* 所以它必须响亮上抛。
|
|
85
|
+
*
|
|
86
|
+
* ⚠️ 重试的**前提**在调用方,不在这里:只有整事务重来后语义等价(读-查-写全部重做)的腿才可以用它;
|
|
87
|
+
* 它**不是**「锁序可以随便写」的许可证 —— 锁序的属主仍是店(真值神谕 =
|
|
88
|
+
* `test/sql-lock-shape-innodb-integration.test.ts` 三引擎并发套)。
|
|
89
|
+
*
|
|
90
|
+
* 引擎事实(2026-09-20 真 InnoDB 实测,S-529 取证):同一把缺行主键上 N=8 路并发「`FOR UPDATE` 缺行读
|
|
91
|
+
* → `INSERT`」,InnoDB(REPEATABLE READ,gap lock + insert-intention)恒定 1 成 7 死锁;TiDB(悲观、无
|
|
92
|
+
* gap lock)与 PG(READ COMMITTED,缺行 `FOR UPDATE` 不取锁)各自零死锁。⇒ 这条谓词实际的受众是
|
|
93
|
+
* **MySQL 协议腿**,两方言都认只是为了让店不必自己判引擎。
|
|
94
|
+
*/
|
|
95
|
+
export declare function isDeadlockError(dialect: SqlDialect, err: unknown): boolean;
|
|
71
96
|
//# sourceMappingURL=sql-errors.d.ts.map
|
|
@@ -35,4 +35,14 @@ export function isCheckViolationError(err, dialect) {
|
|
|
35
35
|
return keyOf(err, "code") === PG_CHECK_VIOLATION_SQLSTATE;
|
|
36
36
|
return keyOf(err, "errno") === MYSQL_ER_CHECK_VIOLATED_ERRNO || keyOf(err, "code") === MYSQL_ER_CHECK_VIOLATED_CODE;
|
|
37
37
|
}
|
|
38
|
+
export const MYSQL_ER_LOCK_DEADLOCK_ERRNO = 1213;
|
|
39
|
+
export const MYSQL_ER_LOCK_DEADLOCK_CODE = "ER_LOCK_DEADLOCK";
|
|
40
|
+
export const PG_DEADLOCK_DETECTED_SQLSTATE = "40P01";
|
|
41
|
+
export function isDeadlockError(dialect, err) {
|
|
42
|
+
if (err === null || typeof err !== "object")
|
|
43
|
+
return false;
|
|
44
|
+
if (dialect === "pg")
|
|
45
|
+
return keyOf(err, "code") === PG_DEADLOCK_DETECTED_SQLSTATE;
|
|
46
|
+
return keyOf(err, "errno") === MYSQL_ER_LOCK_DEADLOCK_ERRNO || keyOf(err, "code") === MYSQL_ER_LOCK_DEADLOCK_CODE;
|
|
47
|
+
}
|
|
38
48
|
//# sourceMappingURL=sql-errors.js.map
|
|
@@ -125,7 +125,7 @@ export type RateLimiterStore = TiDBRateLimiter | PgRateLimiter;
|
|
|
125
125
|
* 多一个**不指名平面**的缺省臂:`src()` = 「本部署记忆引擎自己那一只平面」。
|
|
126
126
|
*
|
|
127
127
|
* 🔴 为什么是工厂而不是一只实例(S-215 codex r1 [high] 验真后改形):core 的记忆平面**不止一个**
|
|
128
|
-
* (`prepare-memory.js
|
|
128
|
+
* (`prepare-memory.js` 的 personal 平面自带 `derivePersonalControlDir(engineRoot)`,与 project 平面的
|
|
129
129
|
* controlDir 不是同一只)。返回单只实例 ⇒ server 把所有平面折到一只目录上,于是升级前落在 personal
|
|
130
130
|
* 控制面的旧 opt-out 记录在升级后读作**缺席**=采集恢复(隐私面的静默 fail-open)。按平面取用后,
|
|
131
131
|
* file 载体与 core 的缺省**逐字节同址**,唯一的变化是这个席位现在**被声明了**(core 的能力信号要的就是它)。
|
|
@@ -224,8 +224,8 @@ export interface StoreBackend {
|
|
|
224
224
|
*
|
|
225
225
|
* 🔴 修前 local 恒 `undefined` 且注称「缺席是一个真答案」—— 那句话在**事实层面**成立(单机的正确载体
|
|
226
226
|
* 确实就是文件三腿)、在**接线层面**是假的:core 的 `captureCarrierUnsupported`
|
|
227
|
-
* (core 7.13.0 `prepare-task.js
|
|
228
|
-
* `isRemoteExecutionEnv` 的接口形鸭子判(`remote-env.js
|
|
227
|
+
* (core 7.13.0 `prepare-task.js`)判的是「这个席位在不在」而不是「文件形能不能用」,叠上
|
|
228
|
+
* `isRemoteExecutionEnv` 的接口形鸭子判(`remote-env.js`)把本仓 host lane 一并算作 remote,
|
|
229
229
|
* 于是单机默认部署上 `memoryCapture:"off"` 两条 ingress 结构性不可达(B-085)。
|
|
230
230
|
* 刻意仍不给 local 一个**内存** twin:内存形会在重启时把一条隐私记录静默丢掉(比文件三腿严格更差)。 */
|
|
231
231
|
sessionCaptureRecords(): SessionCaptureRecordSource | undefined;
|
|
@@ -246,8 +246,8 @@ export interface StoreBackend {
|
|
|
246
246
|
*
|
|
247
247
|
* ✅ WIRED (was "NOT YET WIRABLE, as of core 1.144.0" — that claim went STALE and sat here misleading readers
|
|
248
248
|
* until a 2026-07-25 doc-rot sweep caught it). core 1.145.0 added the seam this comment was waiting for:
|
|
249
|
-
* `RunnerDeps.workflowJournalStore` (now `@sema-agent/core` `dist/core/types.d.ts
|
|
250
|
-
* run_workflow tool deps (`dist/orchestration/run-workflow-tool.d.ts
|
|
249
|
+
* `RunnerDeps.workflowJournalStore` (now `@sema-agent/core` `dist/core/types.d.ts`) + `journalStore` on the
|
|
250
|
+
* run_workflow tool deps (`dist/orchestration/run-workflow-tool.d.ts`), and the boot chain consumes it
|
|
251
251
|
* end-to-end: built in `boot/workflow-orchestration.ts` (`const workflowJournalStore = …backend.workflowJournal()`),
|
|
252
252
|
* threaded into RunnerDeps (`boot/runner-deps.ts` 的 `workflowJournalStore`), reaped on the maintenance tick
|
|
253
253
|
* (`boot/reapers.ts` 同名字段), and read by `GET /v1/workflows/:id/journal` (`http/routes/workflows.ts`, [ref]). */
|
|
@@ -258,11 +258,11 @@ export declare function isParkedRunStatus(status: RunRecord["status"]): boolean;
|
|
|
258
258
|
* · 读不懂的词(滚动升级里新副本写入的新状态词经裸 cast 读到本进程)同判 —— 与
|
|
259
259
|
* {@link isTerminalRunStatus} 族的保守方向一致。
|
|
260
260
|
* · **缺席怎么渲染是 core 的辖域,本仓一个字都不复述**(S-514-JU:此处原写着「core 对缺席渲染成 `idle`」
|
|
261
|
-
* 并抄了当时 `list-agents-tool.js
|
|
261
|
+
* 并抄了当时 `list-agents-tool.js` 的旧三元 —— core 7.24.0 [ref] 把那一臂改成了一张 own-key 表 +
|
|
262
262
|
* `unknown` 兜底,于是那句复述当天就成了假话,而它在本仓被抄了六处)。本表只负责**铸不铸键**;
|
|
263
263
|
* 渲染词由**跑真 core 的黑盒**钉住(`test/peer-lane-e2e.test.ts` 的 G1 格),core 哪天再改那张表,
|
|
264
264
|
* 红的是那一格而不是一句注释。承重的不变量只有一条,且它与渲染词无关:**缺席绝不铸 `dead`** ——
|
|
265
|
-
* `liveness` 才是 core 会拿来**整行隐藏**的那一位(7.24.2 dist 的 `list-agents-tool.js
|
|
265
|
+
* `liveness` 才是 core 会拿来**整行隐藏**的那一位(7.24.2 dist 的 `list-agents-tool.js`),而任何 tempo 词
|
|
266
266
|
* (含缺席)都照样列出。
|
|
267
267
|
*/
|
|
268
268
|
export declare const SESSION_TEMPO_BY_RUN_CLASS: {
|
|
@@ -14,7 +14,7 @@ export declare function tidbPoolOptions(tidb: NonNullable<ServiceConfig["tidb"]>
|
|
|
14
14
|
/** ── [ref] 同查结论:mysql2 与 pg-pool **不同病**,本池不需要(也不应)挂进程保命监听 ────────────────
|
|
15
15
|
* pg 侧的病是「idle client 错误 emit 到 Pool 且无监听 ⇒ 未捕获 ⇒ 整进程 exit 1」(修在 pg-pool.ts
|
|
16
16
|
* createPgPool 头注)。mysql2@3.22.6 亲读(不信文档,以实现为准):
|
|
17
|
-
* · `lib/pool_connection.js
|
|
17
|
+
* · `lib/pool_connection.js` —— PoolConnection 构造器**恒挂** `once('error', () =>
|
|
18
18
|
* this._removeFromPool())`:每条池连接天生有 error 监听(**借出期同在**——不像 pg-pool 借出时
|
|
19
19
|
* 摘监听,mysql2 的 checked-out 连接也无裸奔窗),EventEmitter 不会把 idle 死转 throw,
|
|
20
20
|
* 坏连接就地淘汰;
|
|
@@ -364,10 +364,29 @@ export const SCHEMA_STATEMENTS = [
|
|
|
364
364
|
principal VARCHAR(190) NULL,
|
|
365
365
|
rules JSON NOT NULL,
|
|
366
366
|
rev BIGINT NOT NULL,
|
|
367
|
+
-- gen (core 7.25.0 StoredSessionRules.gen, S-529 二期):这一行的**血统**。rev 只在一条血统内计数,
|
|
368
|
+
-- deleteBySession 终结血统并把下一代的号留在 session_policy_rev_mark.gen 上,于是活规则读者能把
|
|
369
|
+
-- 「被抹掉又重建的行」与「它正在执行的那一行的延续」分开 —— 无论重建行的 rev 因高水位爬到多大。
|
|
370
|
+
-- DEFAULT 0 = **第 0 代**(本列到货前写下的行,以及从没被抹除过的键):core 契约逐字「Absent = generation 0」。
|
|
371
|
+
gen BIGINT NOT NULL DEFAULT 0,
|
|
367
372
|
created_at DATETIME(3) NOT NULL,
|
|
368
373
|
updated_at DATETIME(3) NOT NULL,
|
|
369
374
|
PRIMARY KEY (policy_key),
|
|
370
375
|
KEY idx_session_policy_session (session_id)
|
|
376
|
+
) COLLATE utf8mb4_bin`,
|
|
377
|
+
`CREATE TABLE IF NOT EXISTS session_policy_rev_mark (
|
|
378
|
+
policy_key CHAR(64) NOT NULL,
|
|
379
|
+
-- 被删会话。记号的读写一律按 policy_key 单键;这一列是**运维可读性 + 未来按会话/按龄回收**的那条腿
|
|
380
|
+
-- 的抓手(今天没有任何生产查询按它选行 —— 留存腿与收编腿都不碰本表,理由见上面那段)。
|
|
381
|
+
session_id VARCHAR(64) NOT NULL,
|
|
382
|
+
-- 抹除时刻该键带过的最高 rev(GREATEST 合并:记号只升不降)。
|
|
383
|
+
rev BIGINT NOT NULL,
|
|
384
|
+
-- 这把键的**下一行从第几代开始**(每次抹除 +1;与 core File 侧车 <stem>.hwm.json 的 __gen 同义)。
|
|
385
|
+
-- 血统必须比它终结的那一行活得久,理由与 rev 完全一样:留在行里就等于跟着行一起消失。
|
|
386
|
+
-- DEFAULT 0 = 本列到货前落下的记号(那些键的行也都是第 0 代 ⇒ 下一次抹除把它们抬到 1,前后自洽)。
|
|
387
|
+
gen BIGINT NOT NULL DEFAULT 0,
|
|
388
|
+
erased_at DATETIME(3) NOT NULL,
|
|
389
|
+
PRIMARY KEY (policy_key)
|
|
371
390
|
) COLLATE utf8mb4_bin`,
|
|
372
391
|
`CREATE TABLE IF NOT EXISTS snapshot_blob (
|
|
373
392
|
blob_hash CHAR(64) NOT NULL,
|
package/dist/rules-consent.d.ts
CHANGED
|
@@ -513,7 +513,7 @@ export declare function parseRuleScope(text: string): DurableRuleScope | undefin
|
|
|
513
513
|
* 🔴 **三元组,不是 `(rule, scope)` 二元组**(异源复核 [medium],验真后修):core 的 `sameRuleIdentity`
|
|
514
514
|
* 头注逐字 ——「a comparison that forgot the behavior member would let a delete aimed at an allow take the
|
|
515
515
|
* same-text deny」。漏掉 behavior 的比较在 wire 上的表现不是「删错行」,是**报错行的死活**:撤一条从来
|
|
516
|
-
* 没有过的 `allow` 会因为看见同文本的兄弟 `deny` 而答 `stillLive:
|
|
516
|
+
* 没有过的 `allow` 会因为看见同文本的兄弟 `deny` 而答 `stillLive: "yes"`,那句话说的是另一条规则。
|
|
517
517
|
*
|
|
518
518
|
* 凡「这份清单里有没有这条规则」一律走这一只(撤销口的存活回读 / 单步写口的写前问与写后读回)——
|
|
519
519
|
* 同一个问题有两个说话人,迟早会给两个答案。
|
|
@@ -590,10 +590,12 @@ export type RuleWritten =
|
|
|
590
590
|
/** `persisted` = 这一次**真的往店里写了**;`no-op` = 这条身份**此前就站在店里**,本次调用**一个字都没写**
|
|
591
591
|
* (幂等重写)。两支都带 `row` —— 它是**从店里读出来的**那一行,不是我方按入参拼出来的回声。
|
|
592
592
|
*
|
|
593
|
-
* 🔴
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
593
|
+
* 🔴 两个词的判别位是「**这次调用有没有写**」(codex 对抗复审 F1,验真后修)。
|
|
594
|
+
* S-511 时这条口借的是导入面的兑付腿,而那侧的 `deduped` 只说「等价规则此前已站」、**仍然落了一枚新的 dot**
|
|
595
|
+
* ⇒ 那一格必须如实报 `persisted`,否则一个照常重试的客户端会让 `adds` 台账无界增长、桶的 rev 逐次推进
|
|
596
|
+
* (在翻页的治理面全体重列)。**S-525 换成 core 的直写接缝之后这条歧义没了**:`addPersistedRule` 的 `no-op`
|
|
597
|
+
* 契约逐字是「等价规则已经在这只桶里活着,**没有铸第二枚 dot**」——与本口 `no-op` 承诺的那句话逐字同一件事,
|
|
598
|
+
* 所以现在两个词与 core 的两个词一一对应,不再需要翻译。 */
|
|
597
599
|
{
|
|
598
600
|
status: "persisted" | "no-op";
|
|
599
601
|
rev: number;
|
|
@@ -649,8 +651,11 @@ export interface RuleConsentLane {
|
|
|
649
651
|
* 是**无墓碑硬删**:sync 的 join 会让它复活,而运维以为自己收回了权限。
|
|
650
652
|
*
|
|
651
653
|
* 返回值逐字是 core 的 `RemoveResult` 三态(removed / no-op / failed),**不塌**:
|
|
652
|
-
* `removed.stillLive
|
|
653
|
-
*
|
|
654
|
+
* `removed.stillLive` 自 core 7.25.0 起是**三词**(`"yes"` / `"no"` / `"unknown"`)而不是布尔 ——
|
|
655
|
+
* `"yes"` 意思是「墓碑落了,但本次调用期间又落了一次新的批准,规则按 add-wins 仍然活着」(一个**有名字的
|
|
656
|
+
* 真结果**,不是异常,更不是「撤销完成」);`"unknown"` 意思是**回答这一问的那次读回没做成**,它与 `"no"`
|
|
657
|
+
* 的区别正是这个域唯一不肯说的那句话:「我们看不见规则」永远不等于「没有规则」。本车道**原样**交出去、
|
|
658
|
+
* 不折叠(折 `unknown` 进 `no` 就是把「没看见」渲染成「不在了」)。
|
|
654
659
|
*
|
|
655
660
|
* `principal` 裸串 = principal 简写(既有调用方逐字不变);{@link RuleBucketRef} 的结构形让
|
|
656
661
|
* local-owner 桶**真可撤**(core 5.24.0 起 `removePersistedRule` 的入参 union 扩,[ref] 收官件)。
|
|
@@ -669,8 +674,10 @@ export interface RuleConsentLane {
|
|
|
669
674
|
* 方向在类型上就到不了这里:`behavior` 收窄成 {@link DirectWritableRuleBehavior}。
|
|
670
675
|
*
|
|
671
676
|
* 🔴 **一行都不自造**:行的 tool/match/command 三格是规则文本的**派生事实**,由 core 的
|
|
672
|
-
* `parseRuleText`
|
|
673
|
-
*
|
|
677
|
+
* `parseRuleText` 铸;**内容判据**与导入面走**同一个函数**({@link screenLocalImportRow}),于是本口与
|
|
678
|
+
* 导入口对「一条规则能不能进店」永远给同一个答案。**落店**(S-525 / core 7.25.0)走 core 的**记录级
|
|
679
|
+
* 直写接缝** `addPersistedRule` —— 一步、无票、core 自己铸 `origin:"user"`、`allow` 在类型上不可拼;
|
|
680
|
+
* 此前借的是导入面的两跳票仪式,代价是出处词恒 `imported-cc` + 每次成功的写白落两行台账。
|
|
674
681
|
*
|
|
675
682
|
* `principal` 是裸串(与 prepare/redeem 两腿同形);local-owner 桶**不在本口**上 —— 那只桶没有
|
|
676
683
|
* 「谁在写」这个主体,而本口的授权基础就是已验明的 principal。
|
package/dist/rules-consent.js
CHANGED
|
@@ -5,7 +5,7 @@ import { createLogger } from "./observability/logger.js";
|
|
|
5
5
|
import { MAX_CWD_CHARS } from "./task-cwd.js";
|
|
6
6
|
import { isDirectWritableRuleBehavior } from "./permission-rule-vocab.js";
|
|
7
7
|
import { CONTROL_AND_BIDI_CHARS, hasBidiControls } from "./text-bidi.js";
|
|
8
|
-
import { confirmRuleApproval, RULE_BEHAVIORS, parseRuleText, ruleReachesProgramRun, ruleToolGrammarOf, precheckEditedRuleText, prepareCardApproval, prepareCcImport, redeemRuleBatch, redeemRuleTicket, removePersistedRule, effectiveOrThrow, readRowBehavior, } from "@sema-agent/core";
|
|
8
|
+
import { confirmRuleApproval, RULE_BEHAVIORS, parseRuleText, ruleReachesProgramRun, ruleToolGrammarOf, precheckEditedRuleText, prepareCardApproval, prepareCcImport, redeemRuleBatch, redeemRuleTicket, addPersistedRule, removePersistedRule, effectiveOrThrow, readRowBehavior, } from "@sema-agent/core";
|
|
9
9
|
import { buildRulePayloadHash } from "./plugins/permission-rule-store-sql.js";
|
|
10
10
|
export const RULE_IMPORT_TICKET_TTL_MS = 10 * 60_000;
|
|
11
11
|
export const RULE_IMPORT_RETRY_AFTER_SEC = 2;
|
|
@@ -740,45 +740,24 @@ export function createRuleConsentLane(stores, opts) {
|
|
|
740
740
|
const standing = rowOf(before.rows);
|
|
741
741
|
if (standing !== undefined)
|
|
742
742
|
return { status: "no-op", rev: before.rev, row: standing };
|
|
743
|
-
const
|
|
744
|
-
if (
|
|
745
|
-
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
if (member === undefined || member.admitted) {
|
|
750
|
-
throw new Error("the rule screening reported neither an admitted row nor a refusal for this write — refusing to guess which of the two happened");
|
|
751
|
-
}
|
|
752
|
-
return { status: "rejected", reason: member.reason, detail: member.detail };
|
|
753
|
-
}
|
|
754
|
-
const redeemed = await lane.redeemLocalImport(input.principal, prepared.ticket);
|
|
755
|
-
if (!redeemed.ok) {
|
|
756
|
-
localImportLogger.warn("direct_rule_write_ticket_refused", {
|
|
757
|
-
reason: redeemed.reason,
|
|
758
|
-
detail: redeemed.detail,
|
|
759
|
-
note: "a self-minted, immediately redeemed ticket was refused — the outcome of this write is not known",
|
|
760
|
-
});
|
|
761
|
-
return { status: "indeterminate", detail: `${redeemed.reason}: ${redeemed.detail}` };
|
|
762
|
-
}
|
|
763
|
-
const member = redeemed.outcome.members[0];
|
|
764
|
-
if (member === undefined) {
|
|
765
|
-
throw new Error("the redemption of a single-rule write came back with no member — refusing to report an outcome nobody computed");
|
|
766
|
-
}
|
|
767
|
-
switch (member.outcome) {
|
|
768
|
-
case "persisted":
|
|
743
|
+
const screened = screenLocalImportRow(row);
|
|
744
|
+
if ("reason" in screened)
|
|
745
|
+
return { status: "rejected", reason: screened.reason, detail: screened.detail };
|
|
746
|
+
const added = await addPersistedRule({ behavior: input.behavior, rule: written.rule.rule, scope: input.scope, principal: input.principal, provider: stores.provider });
|
|
747
|
+
switch (added.status) {
|
|
748
|
+
case "added":
|
|
769
749
|
break;
|
|
770
750
|
case "no-op":
|
|
771
751
|
break;
|
|
772
|
-
case "
|
|
773
|
-
return { status: "indeterminate", detail:
|
|
774
|
-
case "refused":
|
|
775
|
-
return { status: "indeterminate", detail: member.detail ?? "the store refused this rule without a machine-readable reason" };
|
|
752
|
+
case "failed":
|
|
753
|
+
return { status: "indeterminate", detail: added.error };
|
|
776
754
|
default: {
|
|
777
|
-
const unknown =
|
|
778
|
-
throw new Error(`unknown
|
|
755
|
+
const unknown = added;
|
|
756
|
+
throw new Error(`unknown addPersistedRule outcome: ${JSON.stringify(unknown)}`);
|
|
779
757
|
}
|
|
780
758
|
}
|
|
781
|
-
const
|
|
759
|
+
const readBack = await lane.listRules(input.principal);
|
|
760
|
+
const landed = rowOf(readBack.rows);
|
|
782
761
|
if (landed === undefined) {
|
|
783
762
|
localImportLogger.warn("direct_rule_write_vanished_before_read_back", {
|
|
784
763
|
behavior: input.behavior,
|
|
@@ -787,7 +766,7 @@ export function createRuleConsentLane(stores, opts) {
|
|
|
787
766
|
});
|
|
788
767
|
return { status: "indeterminate", detail: "this rule landed and was already gone when it was read back — a revocation landed in between" };
|
|
789
768
|
}
|
|
790
|
-
return { status: "persisted", rev:
|
|
769
|
+
return { status: added.status === "no-op" ? "no-op" : "persisted", rev: added.rev, row: landed };
|
|
791
770
|
},
|
|
792
771
|
};
|
|
793
772
|
return lane;
|
|
@@ -31,7 +31,7 @@ export declare const CANCELLED_CODE = "cancelled";
|
|
|
31
31
|
* 发 `done`,`terminal` 是一条**不带码**的 `failed`。这里在**它**之上补标,不另造一份结果(另造会把
|
|
32
32
|
* 引擎已经算出的 `stats` / `result` / `editedFiles` 全丢掉)。
|
|
33
33
|
* · **缺席**:根本没读到 done —— 客户端断连后迭代器早收,或**开跑之前**就已经立起的旗
|
|
34
|
-
* (亲核 core 7.11.2 `dist/core/runner/runtask.js
|
|
34
|
+
* (亲核 core 7.11.2 `dist/core/runner/runtask.js`:`runLocked` 入口对已 aborted 的 signal
|
|
35
35
|
* 抛 `AbortError`)⇒ 合成一条诚实的取消终局。
|
|
36
36
|
*
|
|
37
37
|
* ## 「只补空座」纪律(与 core 的 `amendTerminal` 同规),而且**补码与补快照同进同退**
|
package/dist/run-local.d.ts
CHANGED
|
@@ -113,7 +113,7 @@ export interface LocalApproverIo {
|
|
|
113
113
|
* 复刻一份 = 同源谎(两处判据日后各改各的)。裁决归 core,座位归这里。
|
|
114
114
|
*
|
|
115
115
|
* 🔴 非 TTY 臂答 `false`(真 deny),**绝不答 `"unavailable"`**:后者被 core 读成「席位够不到」并**改道
|
|
116
|
-
* `suspendAsk`**(dist `core/hooks.js
|
|
116
|
+
* `suspendAsk`**(dist `core/hooks.js` 的 `approverUnavailable` 重投)。今天本腿无店 ⇒ 那条改道无处
|
|
117
117
|
* 可去(fail-closed 拒),但这一条仍是本席的契约:答案是「不批」,不是「找不到人」。
|
|
118
118
|
*/
|
|
119
119
|
export declare function createLocalApprover(io: LocalApproverIo): OnAsk;
|
|
@@ -9,23 +9,23 @@
|
|
|
9
9
|
* (engine-can ∧ center-may ∧ shell-show): core calls it with `spec.principal`.
|
|
10
10
|
*
|
|
11
11
|
* ⚠️ **调用次数不是「每任务一次」**([ref]① 随 codex r1-[medium] 亲验后纠:旧句写「ONCE per task at
|
|
12
|
-
* prepare time」):prepare 期解析一次(prepare-task.js
|
|
13
|
-
* 就再解析一次(prepare-memory.js
|
|
12
|
+
* prepare time」):prepare 期解析一次(prepare-task.js),**并且** capture-opt-out 的 flip 动词每被打一次
|
|
13
|
+
* 就再解析一次(prepare-memory.js 的 `flip` → 同文件 `resolveCaptureEntitlementFresh` → 本 resolver)。
|
|
14
14
|
* 所以本 resolver 必须是**可重入、可多次调用**的,缓存的 TTL 语义对 flip 腿同样生效;把它当「每任务一次的
|
|
15
15
|
* 快照」来实现(例如在里面攒 per-task 状态)会在 flip 腿上出错。
|
|
16
16
|
*
|
|
17
17
|
* ⚠️ [ref]① 纠失实:核心那句曾写作「引擎只执法它能执法的**两**个 cap」—— 早已不属实。core 7.2.0
|
|
18
18
|
* 装树 dist **亲读**的消费点是**六**个(本仓 node_modules 实测;下列坐标除注明外均在 `dist/core/runner/` 下,
|
|
19
19
|
* `agents/subagent.js` 那一条已就地标出 —— 说「均在 runner 下」是 r1 抓到的第二处过头话):
|
|
20
|
-
* · `allowWorkflows` — prepare-task.js
|
|
21
|
-
* · `allowFork` — prepare-task.js
|
|
22
|
-
* · `allowObservers` — prepare-task.js
|
|
23
|
-
* · `forceDurableGate`— prepare-task.js
|
|
24
|
-
*
|
|
20
|
+
* · `allowWorkflows` — prepare-task.js `runtimeCaps?.allowWorkflows !== false`(deny 自编排;同处另发一条 onError)
|
|
21
|
+
* · `allowFork` — prepare-task.js `forkGovernanceDenial(spec.enableFork, runtimeCaps?.allowFork)`(拒由同处铸出,拒词在 agents/subagent.js)
|
|
22
|
+
* · `allowObservers` — prepare-task.js `=== true`(**极性与 allow* 相反**:显式 opt-in,默认 OFF)
|
|
23
|
+
* · `forceDurableGate`— prepare-task.js 的 durable/content mandate、活 content-ask 抑制、
|
|
24
|
+
* durable question face(该处合取 `resolveCheckpointStore(spec, deps) !== undefined`
|
|
25
25
|
* ⇒ **无 checkpoint 店的部署上这一面本就不成立**,该键在那里 inert)
|
|
26
|
-
* · `autoMode` — prepare-task.js
|
|
26
|
+
* · `autoMode` — prepare-task.js `=== true` **∧** `deps.autoMode !== undefined`(部署需先具备分类器;
|
|
27
27
|
* 显式 opt-in 极性,且 [ref] 起本侧传的是三态 org deny 位,见 toCoreRuntimeCaps 头注)
|
|
28
|
-
* · `allowMemoryOptOut` — prepare-memory.js
|
|
28
|
+
* · `allowMemoryOptOut` — prepare-memory.js `resolveCaptureEntitlementFresh`(+prepare-task.js
|
|
29
29
|
* 的 value/faulted 投影);**极性反转,但只在 `open` 姿态下**——判决全表在
|
|
30
30
|
* `dist/core/runner/memory-capture-optout.js` 的 `adjudicateCaptureOptOut`,按
|
|
31
31
|
* `RunnerDeps.memoryCapturePolicy` 三姿态分叉(codex r1-[medium] 亲验后纠:旧句无条件
|
|
@@ -112,7 +112,7 @@ export interface EntitlementsResolverOpts {
|
|
|
112
112
|
* 武装,真实用户诊断的病根)。
|
|
113
113
|
* 🔴 **上面那条武装式是 [ref] 的目标形,不是「本仓现在装的那版 core 的形」——引用它之前先看装的是哪版**
|
|
114
114
|
* ([ref]① 随 codex r2-[high] 补:我方在 WIRE 附录 F 照抄这一句,写成了「7.2.0 的行为」,而 npm **7.2.0
|
|
115
|
-
* 不含 [ref]** —— 它的 `dist/core/runner/prepare-task.js
|
|
115
|
+
* 不含 [ref]** —— 它的 `dist/core/runner/prepare-task.js` 逐字仍是 `runtimeCaps?.autoMode === true &&
|
|
116
116
|
* deps.autoMode !== undefined`,即旧「org 授予」极性、**缺键 ⇒ 不武装**)。判「现在是哪套极性」的唯一判据 =
|
|
117
117
|
* 安装包探针 `CORE_ARMS_AUTO_ON_INTENT`(`src/auto-mode-face.ts`;实测本树 7.2.0 ⇒ **false** = 旧式臂),
|
|
118
118
|
* 代码侧已按它分臂,失实的只是散落各处的散文。⚠️ 同形措辞在本文件 :209 / :362-364 / :548 与
|
|
@@ -42,8 +42,8 @@ export declare function isDelegationToolName(name: string): boolean;
|
|
|
42
42
|
* tool at all.
|
|
43
43
|
*
|
|
44
44
|
* 🔴 原始理由已被上游收编(2026-08-05 亲验安装包 core 5.13.x):子任务 spec 现在**继承** `handsReadOnly`
|
|
45
|
-
* (`dist/agents/subagent.js
|
|
46
|
-
*
|
|
45
|
+
* (`dist/agents/subagent.js` 的 `...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {})`,resume
|
|
46
|
+
* resume 腿同款)与 `shellGate`(`dist/core/runner/prepare-task.js` 的 `inheritedGateForChildren`),所以
|
|
47
47
|
* 「子任务会拿到可写的手」这个当年的缺口在 core 侧已经补上。本 strip 因此是**同向的第二层**(更严,不更松):
|
|
48
48
|
* 它保证 read-only 一档下连委派工具面都不铸,与继承是否存在无关。拆掉它是一次行为变更(read-only 任务将
|
|
49
49
|
* 重新能委派),需要单独决定——不要因为「上游修好了」就顺手删。
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* 「常量锚」):句子收进本文件**单一属主**,冻结职责搬到冻结门的**常量锚格** —— 那一格直接断言这两只
|
|
28
28
|
* 渲染器对每一种判词的输出字节,且对判词闭集穷尽(加一种判词 ⇒ 那一格编译红)。文案一个字节未改。
|
|
29
29
|
*/
|
|
30
|
-
import type { SessionPermissionRules, SessionRulesRecord } from "@sema-agent/core";
|
|
30
|
+
import type { SessionPermissionRules, SessionPolicyError, SessionRulesRecord } from "@sema-agent/core";
|
|
31
31
|
/** core `SessionPermissionRules` 的桶名全集(派生,不是手抄)。 */
|
|
32
32
|
export declare const SESSION_PERMISSION_RULE_FIELDS: readonly (keyof SessionPermissionRules)[];
|
|
33
33
|
/** 一条规则体的形问题。判词而非文案(见文件头注的「文案属主」一段)。 */
|
|
@@ -100,4 +100,28 @@ export declare function readSessionRulesRecords(raw: unknown): SessionRulesRecor
|
|
|
100
100
|
export declare function describeSessionPermissionRulesProblem(p: SessionPermissionRulesProblem): string;
|
|
101
101
|
/** `policy[]` 判词 → 人话(sync/import 腿在它前面拼 `policy: `)。闭集穷尽同上。 */
|
|
102
102
|
export declare function describeSessionRulesRecordProblem(p: SessionRulesRecordProblem): string;
|
|
103
|
+
/**
|
|
104
|
+
* 🔴 **`SessionPolicyError` 的闭集 → HTTP 的**唯一**映射(core 7.25.0 提货批新建)。**
|
|
105
|
+
*
|
|
106
|
+
* 此前这张映射在两条路由上各写了一个**二元三元表达式**:`sessions.ts` 写 `conflict ? 409 : 403`、
|
|
107
|
+
* `session-sync.ts` 写 `loosen_forbidden ? 403 : 409`。两句话在两个词的世界里都对,而 core 这一版给闭集
|
|
108
|
+
* 加了第三个词 `corrupt`(店读不出自己写的记号/行 ⇒ 拒写,而不是按 0 续号)—— 于是同一个新词在两条路由上
|
|
109
|
+
* 拿到**两个不同的、都不对的**答案:一边 403「你没权限」,一边 409「有人先改了」,而它其实是一次
|
|
110
|
+
* **数据完整性故障**。这正是 [ref] 词表纪律说的那一形:闭集必须穷尽 `switch`,未知词是**编译**错,
|
|
111
|
+
* 不是运行期落进某个恰好存在的臂。
|
|
112
|
+
*
|
|
113
|
+
* ⇒ 映射搬到这里(本文件已是 policy wire 形的单一读者/属主,S-496 先例),两条路由同调它。
|
|
114
|
+
* 新增一个词 ⇒ 本 switch 编译红,由人裁定它的状态码与码位,而不是让它静默继承邻居的。
|
|
115
|
+
*
|
|
116
|
+
* 逐词:
|
|
117
|
+
* · `conflict`(409)—— CAS 输了,重读 `rev` 再来。**码位与状态逐字不变**。
|
|
118
|
+
* · `loosen_forbidden`(403)—— 非 operator 想放松。**码位与状态逐字不变**。
|
|
119
|
+
* · `corrupt`(**500**)—— 店读不出它自己该读的那份字节(记号/行被列外篡改或撕裂)。
|
|
120
|
+
* 不是 4xx:调用方没做错任何事,它也改不了这件事;不是 503:重试改不了判(core 成文的修复路径是
|
|
121
|
+
* **人**去改回或删掉那一行,而删记号要交出它握着的那个号)。
|
|
122
|
+
*/
|
|
123
|
+
export declare function sessionPolicyErrorHttp(code: SessionPolicyError["code"]): {
|
|
124
|
+
status: number;
|
|
125
|
+
errorCode: string;
|
|
126
|
+
};
|
|
103
127
|
//# sourceMappingURL=session-policy-wire.d.ts.map
|