@sema-agent/server 7.43.0 → 7.44.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/USAGE.md +18 -0
- package/dist/adoption/plan.js +31 -192
- package/dist/adoption/quiesce.js +3 -86
- package/dist/adoption/runner.js +3 -140
- package/dist/adoption/sql.js +0 -74
- package/dist/adoption/wire.js +0 -80
- package/dist/approval-ask-machine.js +0 -75
- package/dist/approval-card.js +0 -323
- package/dist/approval-deny-reasons.js +0 -42
- package/dist/approval-hmac.js +0 -35
- package/dist/approval-reconciler.js +8 -166
- package/dist/approval.js +4 -67
- package/dist/audit.js +1 -44
- package/dist/auth-bridge.js +4 -47
- package/dist/auth-keys.js +0 -23
- package/dist/bake-runner/main.js +4 -65
- package/dist/bake-runner/protocol.js +3 -58
- package/dist/bake-runner/runner.js +5 -91
- package/dist/bench/l8/artifact.js +2 -61
- package/dist/bench/l8/escape.js +0 -25
- package/dist/bench/l8/index.js +0 -14
- package/dist/bench/l8/probes.js +0 -60
- package/dist/bench/l8/run-probes.js +2 -45
- package/dist/bench/s1/arms.js +14 -189
- package/dist/bench/s1/live-deps.js +11 -253
- package/dist/bench/s1/oracle.js +1 -36
- package/dist/bench/s1/repair-oracle-adapter.js +0 -30
- package/dist/bench/s1/reviewer.js +0 -34
- package/dist/bench/s1/row.js +0 -62
- package/dist/bench/s1/run-firm.js +4 -70
- package/dist/bench/s1/runner-ctx.js +0 -40
- package/dist/bench/s1/tasks.js +0 -115
- package/dist/boot/adoption.js +0 -20
- package/dist/boot/budget-tracing.js +3 -55
- package/dist/boot/config-center.js +43 -551
- package/dist/boot/coordinators.js +1 -69
- package/dist/boot/crash-last.js +0 -18
- package/dist/boot/deferred-sandbox-path-env.js +3 -119
- package/dist/boot/execution-env.js +5 -171
- package/dist/boot/governance-seams.js +0 -92
- package/dist/boot/leader.js +0 -69
- package/dist/boot/limit-sync.js +0 -2
- package/dist/boot/memory-boundary.js +3 -91
- package/dist/boot/org-memory.js +1 -25
- package/dist/boot/parked-revive-gate.js +0 -150
- package/dist/boot/permission-rules-audit.js +1 -56
- package/dist/boot/reapers.js +15 -273
- package/dist/boot/resolve-spec.js +9 -768
- package/dist/boot/retention-lane.js +4 -87
- package/dist/boot/runner-deps.js +3 -297
- package/dist/boot/runtime-caps.js +0 -40
- package/dist/boot/session-faces.js +2 -133
- package/dist/boot/shutdown.js +9 -83
- package/dist/boot/side-query-lane.js +2 -137
- package/dist/boot/stores.js +15 -215
- package/dist/boot/task-list-lane.js +0 -18
- package/dist/boot/webfetch-summarize-lane.js +0 -47
- package/dist/boot/workflow-orchestration.js +3 -99
- package/dist/boot-reclaim.js +0 -20
- package/dist/bounded-session-map.js +0 -19
- package/dist/brain.js +2 -139
- package/dist/budget.js +9 -244
- package/dist/capabilities/builtin-tools.js +0 -3
- package/dist/capabilities/center-plugins.js +6 -51
- package/dist/capabilities/center-prompts.js +6 -71
- package/dist/capabilities/code-review-council.js +7 -40
- package/dist/capabilities/collab-workflows.js +1 -44
- package/dist/capabilities/hands-lane.js +0 -65
- package/dist/capabilities/memory-notice.js +0 -70
- package/dist/capabilities/prompt.js +0 -14
- package/dist/capabilities/prompts/code-review.js +0 -14
- package/dist/capabilities/prompts/identity.js +0 -6
- package/dist/capabilities/prompts/team.js +0 -4
- package/dist/capabilities/repo-tools.js +0 -34
- package/dist/capabilities/sandbox-file-send.js +3 -92
- package/dist/capabilities/scenarios.d.ts +0 -1
- package/dist/capabilities/scenarios.js +18 -279
- package/dist/capabilities/select-environment-tool.js +0 -32
- package/dist/capabilities/send-user-file-tool.js +2 -66
- package/dist/capabilities/skills.d.ts +1 -2
- package/dist/capabilities/skills.js +3 -21
- package/dist/capabilities/team.d.ts +3 -11
- package/dist/capabilities/team.js +3 -51
- package/dist/capabilities/tool-defer.js +0 -3
- package/dist/config-center/apply-effective.js +15 -330
- package/dist/config-center/apply-ledger.js +5 -38
- package/dist/config-center/facade.js +0 -41
- package/dist/config-center/hot-keys-registry.js +0 -20
- package/dist/config-center/http-client.js +2 -125
- package/dist/config-center/mcp-revocation.js +2 -34
- package/dist/config-center/read-face.js +0 -59
- package/dist/config-center/restart-signal.js +1 -85
- package/dist/config-center/skills-mcp.d.ts +1 -1
- package/dist/config-center/skills-mcp.js +9 -76
- package/dist/config-center/stage-limits.js +8 -35
- package/dist/config-invariants.js +0 -16
- package/dist/config-lkg.js +0 -42
- package/dist/config-provider.js +3 -186
- package/dist/config-types.js +0 -5
- package/dist/config.js +107 -1145
- package/dist/degenerate-instrument.js +3 -67
- package/dist/deployment-governance.js +0 -124
- package/dist/digest-form.js +0 -12
- package/dist/elicitation.js +3 -86
- package/dist/env-facts.js +7 -75
- package/dist/fleet/fleet-bus.js +34 -507
- package/dist/fleet/fleet-reconciler.js +9 -149
- package/dist/fleet/fleet-terminal-window.js +10 -178
- package/dist/fleet/subagent-tail-bus.js +3 -72
- package/dist/fleet-client.js +10 -70
- package/dist/fleet-lease.js +5 -79
- package/dist/git-api-kind.js +0 -3
- package/dist/governance-ask-marks.js +2 -78
- package/dist/hooks/branch-transcript.js +0 -74
- package/dist/hooks/cc-agent-hook-prompt.js +0 -29
- package/dist/hooks/cc-stop-prompt.js +1 -46
- package/dist/hooks/hook-llm.js +1 -53
- package/dist/hooks/hook-runner.js +20 -414
- package/dist/http/active-run-conflict.js +4 -129
- package/dist/http/cursor-fingerprint.d.ts +5 -0
- package/dist/http/cursor-fingerprint.js +5 -0
- package/dist/http/idempotency.js +0 -37
- package/dist/http/principal-gate.js +3 -40
- package/dist/http/route-ctx.js +0 -9
- package/dist/http/routes/a2a-serve.js +7 -319
- package/dist/http/routes/admin-config-refresh.js +0 -4
- package/dist/http/routes/admin-drain.js +0 -10
- package/dist/http/routes/adoption.js +1 -29
- package/dist/http/routes/agents-roster.js +1 -47
- package/dist/http/routes/approvals-assistant.js +32 -395
- package/dist/http/routes/attachments.js +4 -22
- package/dist/http/routes/capabilities.js +3 -420
- package/dist/http/routes/diagnostics.js +1 -81
- package/dist/http/routes/fleet.js +9 -185
- package/dist/http/routes/images.js +14 -239
- package/dist/http/routes/leader.js +0 -13
- package/dist/http/routes/memory-bundle.js +1 -61
- package/dist/http/routes/memory-policy.js +9 -99
- package/dist/http/routes/notify-wake.js +3 -37
- package/dist/http/routes/observability.js +2 -19
- package/dist/http/routes/retention-ops.js +2 -34
- package/dist/http/routes/rules.js +1 -93
- package/dist/http/routes/runs.js +64 -873
- package/dist/http/routes/session-sync.js +19 -258
- package/dist/http/routes/sessions-list.js +17 -43
- package/dist/http/routes/sessions.js +34 -226
- package/dist/http/routes/shared-memory.js +5 -36
- package/dist/http/routes/side-query.js +1 -87
- package/dist/http/routes/tasks.js +52 -722
- package/dist/http/routes/trace-usage.js +32 -195
- package/dist/http/routes/workflows.js +19 -195
- package/dist/http/run-meta.js +0 -6
- package/dist/http/send.js +0 -32
- package/dist/http/server.js +92 -1597
- package/dist/http/sse-lifecycle.js +2 -13
- package/dist/http/sse-log.js +3 -48
- package/dist/http/tar.js +5 -21
- package/dist/http/verify-rounds.js +0 -5
- package/dist/http/wire-gate.js +0 -9
- package/dist/http/workspace-content.js +0 -10
- package/dist/images/bake-validate.js +1 -70
- package/dist/images/manifest.js +1 -6
- package/dist/index.js +0 -21
- package/dist/key-resolver.js +2 -17
- package/dist/leader/diffout.js +1 -20
- package/dist/leader/diffup.js +0 -47
- package/dist/leader/endpoint.js +2 -57
- package/dist/leader/fanout.js +3 -45
- package/dist/leader/grader-env-factory.js +3 -72
- package/dist/leader/leader.js +5 -156
- package/dist/leader/merge.js +8 -115
- package/dist/leader/planner.js +3 -54
- package/dist/leader/repair-oracle.js +1 -60
- package/dist/leader/repair-wire.js +2 -79
- package/dist/leader/wire.js +8 -307
- package/dist/lsp/e2b-bridge.js +4 -64
- package/dist/lsp/e2b-manager.js +6 -94
- package/dist/lsp/lsp-frames.js +0 -12
- package/dist/lsp/manager.js +4 -96
- package/dist/lsp/ws-transport.js +5 -55
- package/dist/lsp-evict.js +1 -15
- package/dist/main.js +57 -765
- package/dist/memory-bundle-engine.js +0 -55
- package/dist/memory-export.js +0 -4
- package/dist/memory-posture.js +1 -15
- package/dist/memory-scope.js +11 -148
- package/dist/memory-sync-client.js +2 -44
- package/dist/memory-sync.js +1 -80
- package/dist/model-select.js +3 -80
- package/dist/observability/cost-quota.js +1 -17
- package/dist/observability/cost-taxonomy.js +0 -34
- package/dist/observability/fail-open.js +7 -86
- package/dist/observability/logger.js +0 -6
- package/dist/observability/metrics.js +0 -94
- package/dist/observability/otel-exporter.js +3 -13
- package/dist/observability/principal-context.js +0 -9
- package/dist/observability/prompt-manifest.js +1 -37
- package/dist/observability/rate-limit.js +0 -4
- package/dist/observability/secret-env-scrub.js +2 -56
- package/dist/observability/tool-trace.js +1 -70
- package/dist/orchestration/hardened-vm-runner.js +4 -118
- package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
- package/dist/orchestration/hardened-vm-worker.js +0 -27
- package/dist/orchestration/subagent-steer.js +1 -45
- package/dist/orchestration/workflow-agent-steer.js +1 -80
- package/dist/orchestration/workflow-completion-inbox.js +32 -285
- package/dist/orchestration/workflow-notify-journal.js +16 -259
- package/dist/org-memory-admission.js +3 -47
- package/dist/parent-watch.js +2 -48
- package/dist/parked-decide.js +1 -109
- package/dist/per-task-image.js +0 -57
- package/dist/plan-cache-probe.js +3 -27
- package/dist/plugins/adoption-log-sql.js +2 -119
- package/dist/plugins/approval-ask-store-memory.js +3 -38
- package/dist/plugins/approval-ask-store-sql.js +6 -188
- package/dist/plugins/approval-exemption-store.js +2 -28
- package/dist/plugins/background-agent-store-sql.js +4 -105
- package/dist/plugins/background-shell-support.js +14 -122
- package/dist/plugins/blob-backend.js +6 -169
- package/dist/plugins/breaker-state-sql.js +8 -46
- package/dist/plugins/caching-session-store.js +4 -106
- package/dist/plugins/checkpoint-store-sql.js +18 -505
- package/dist/plugins/e2b-orphan-reclaim.js +0 -45
- package/dist/plugins/file-outcome-sink.js +0 -9
- package/dist/plugins/file-resume-anchor-store.js +4 -43
- package/dist/plugins/file-run-store.js +26 -364
- package/dist/plugins/file-snapshot-store-sql.js +10 -181
- package/dist/plugins/fork-routing-session-store.js +8 -111
- package/dist/plugins/host-platform.js +2 -91
- package/dist/plugins/image-bake-store-sql.js +7 -250
- package/dist/plugins/image-index-sql.js +4 -123
- package/dist/plugins/k8s-bg-scripts.js +4 -88
- package/dist/plugins/k8s-exec-protocol.js +0 -41
- package/dist/plugins/leader-run-store-sql.js +0 -101
- package/dist/plugins/local-checkpoint-store.js +8 -128
- package/dist/plugins/local-session-store.js +34 -296
- package/dist/plugins/local-task-attachment-store.js +2 -16
- package/dist/plugins/mailbox-store-sql.js +8 -65
- package/dist/plugins/memory-embedder-fingerprint.js +5 -166
- package/dist/plugins/memory-embedder.js +4 -70
- package/dist/plugins/memory-engine-pg.js +6 -170
- package/dist/plugins/memory-engine-tidb.js +7 -155
- package/dist/plugins/memory-engine-vector-util.js +0 -10
- package/dist/plugins/memory-key-guards.js +0 -34
- package/dist/plugins/memory-origin-law.js +0 -187
- package/dist/plugins/memory-resume-anchor-store.js +0 -17
- package/dist/plugins/memory-run-store.js +12 -89
- package/dist/plugins/memory-session-policy-store.js +0 -17
- package/dist/plugins/memory-sync-store-pg.js +4 -49
- package/dist/plugins/memory-sync-store-tidb.js +3 -35
- package/dist/plugins/outcome-ledger-sql.js +3 -97
- package/dist/plugins/permission-rule-store-file.js +5 -133
- package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
- package/dist/plugins/permission-rule-store-sql.js +50 -307
- package/dist/plugins/pg-cost-quota.js +0 -7
- package/dist/plugins/pg-pool.js +0 -92
- package/dist/plugins/pg-rate-limiter.js +2 -13
- package/dist/plugins/pg-safe-json.js +4 -40
- package/dist/plugins/pg-session-storage.js +25 -189
- package/dist/plugins/posix-shell-fs.js +1 -31
- package/dist/plugins/remote-env-adb.js +12 -101
- package/dist/plugins/remote-env-e2b.js +40 -371
- package/dist/plugins/remote-env-file-error.js +0 -37
- package/dist/plugins/remote-env-host.js +63 -483
- package/dist/plugins/remote-env-k8s.js +32 -326
- package/dist/plugins/remote-env-local-docker.js +24 -157
- package/dist/plugins/remote-env-ssh.js +20 -128
- package/dist/plugins/remote-scratchpad.js +2 -32
- package/dist/plugins/remote-shell.js +1 -32
- package/dist/plugins/resume-anchor-store-sql.js +0 -11
- package/dist/plugins/retention-lane-store-sql.js +0 -108
- package/dist/plugins/retention-store-sql.js +4 -383
- package/dist/plugins/roster-store-sql.js +0 -55
- package/dist/plugins/run-store-sql.js +14 -251
- package/dist/plugins/s3-presign.js +2 -49
- package/dist/plugins/scheduler-support.js +3 -80
- package/dist/plugins/send-file-ledger.js +4 -53
- package/dist/plugins/send-user-file.js +4 -94
- package/dist/plugins/session-placement.js +1 -89
- package/dist/plugins/session-policy-store-sql.js +3 -81
- package/dist/plugins/session-store.js +0 -57
- package/dist/plugins/shared-memory-store-sql.js +4 -167
- package/dist/plugins/sql-driver.js +0 -17
- package/dist/plugins/sql-errors.js +0 -7
- package/dist/plugins/sql-escape.js +0 -8
- package/dist/plugins/sql-row-helpers.js +0 -25
- package/dist/plugins/store-backend.js +42 -222
- package/dist/plugins/store-contracts.js +2 -46
- package/dist/plugins/task-attachment-store.js +3 -49
- package/dist/plugins/task-list-store-sql.js +0 -79
- package/dist/plugins/tidb-cost-quota.js +1 -4
- package/dist/plugins/tidb-pool.js +1 -207
- package/dist/plugins/tidb-rate-limiter.js +3 -9
- package/dist/plugins/tidb-session-storage.js +4 -70
- package/dist/plugins/tidb-session-store.js +19 -341
- package/dist/plugins/tool-result-store-sql.js +4 -179
- package/dist/plugins/usage-window-store-sql.js +0 -8
- package/dist/plugins/web-search.js +10 -132
- package/dist/plugins/workflow-journal-store-sql.js +2 -58
- package/dist/plugins/workflow-run-store-sql.js +6 -91
- package/dist/plugins/worktree-isolation.js +6 -126
- package/dist/plugins/write-behind-counter.js +16 -75
- package/dist/principal-jwt.js +5 -60
- package/dist/project-memory.js +15 -146
- package/dist/prompts-domain-validate.js +1 -51
- package/dist/question.js +2 -111
- package/dist/resource-suspend.js +0 -18
- package/dist/router/route-orchestration.js +0 -77
- package/dist/rules-consent.d.ts +44 -5
- package/dist/rules-consent.js +75 -215
- package/dist/run-local.js +14 -381
- package/dist/runs.js +29 -617
- package/dist/runtime-caps-resolver.js +6 -132
- package/dist/runtime-governance.js +1 -232
- package/dist/sandbox-pkg-source.js +0 -37
- package/dist/sealed-key.js +3 -68
- package/dist/security.js +6 -291
- package/dist/session-leaf-bus.js +0 -32
- package/dist/session-sync-content.js +1 -69
- package/dist/session-sync-kernel.js +3 -60
- package/dist/session-sync.js +3 -66
- package/dist/session-titler.js +7 -41
- package/dist/session-watch.js +9 -73
- package/dist/shared-memory-scope-authorizer.js +0 -18
- package/dist/sighup-idle.js +1 -12
- package/dist/spec-fields.js +4 -143
- package/dist/store-live-probe.js +3 -49
- package/dist/task-a2a.js +1 -126
- package/dist/task-cwd.js +1 -103
- package/dist/task-mcp.js +1 -89
- package/dist/task-settings.js +10 -300
- package/dist/task-workflow.js +8 -75
- package/dist/tool-approval.d.ts +28 -1
- package/dist/tool-approval.js +70 -1430
- package/dist/trace/artifacts.js +5 -20
- package/dist/trace/engine-notice-wire.js +6 -138
- package/dist/trace/ledger-sink.js +7 -96
- package/dist/trace/project.js +4 -448
- package/dist/trace/redact.js +9 -77
- package/dist/turn-activity.js +1 -27
- package/dist/usage-analytics.js +4 -34
- package/dist/wall-clock-jump-guard.js +1 -68
- package/package.json +3 -3
- package/dist/capabilities/scenario-alias.d.ts +0 -27
- package/dist/capabilities/scenario-alias.js +0 -61
|
@@ -1,34 +1,12 @@
|
|
|
1
1
|
import { mysqlDriver, pgDriver } from "./sql-driver.js";
|
|
2
2
|
import { TASK_LIST_ITEM_TABLE, TASK_LIST_META_TABLE, taskListKeyFor } from "./task-list-store-sql.js";
|
|
3
|
-
/**
|
|
4
|
-
* 三只**被托管**的 SQL 店(session / checkpoint / tool-result)共用的 `retention` 声明常量。
|
|
5
|
-
*
|
|
6
|
-
* 🔴 为什么是一个共享常量而不是各写各的字面量:这一格是 core `assertRetentionCapability` 的**唯一**读点,
|
|
7
|
-
* 三只店的答案必须同进同退 —— 哪天本能力从某只店的行上撤了(比如 tool_result 改由别处清),漏改一处
|
|
8
|
-
* 就是一句谎,而谎的方向是「locked policy 放行了一只其实不删的店」。
|
|
9
|
-
*
|
|
10
|
-
* 🔴 声明的**读法**(与 core 契约的字面对齐,别读大也别读小):`"managed"` 说的是「**这只店的行**由本
|
|
11
|
-
* 部署的托管留存按期删除」,不是「本对象自己实现了三个方法」。三方法的实现体是本文件的
|
|
12
|
-
* `SqlRetentionStore` —— 它是 E21 purge 协调器的事务化孪生,横跨十余张表(理由见文件顶注的布局裁定)。
|
|
13
|
-
* core 的门读的是「这只店的数据会不会被按期删掉」(拒启文案逐字:「a locked retention policy over stores
|
|
14
|
-
* that cannot delete would be *policy locked, data immortal*」),SQL 三店的诚实答案就是 `"managed"`。
|
|
15
|
-
* file/in-memory 形没有任何东西会按期删它们的行 ⇒ 显式 `"none"`(缺席也读 none,但显式是文档义务)。
|
|
16
|
-
*/
|
|
17
3
|
export const MANAGED_RETENTION = "managed";
|
|
18
|
-
/** file/in-memory 形的诚实声明(见 {@link MANAGED_RETENTION} 的读法说明)。 */
|
|
19
4
|
export const UNMANAGED_RETENTION = "none";
|
|
20
|
-
/** 追加式审计表(设计稿 §6)。 */
|
|
21
5
|
export const RETENTION_AUDIT_TABLE = "retention_audit";
|
|
22
|
-
/** per-domain 法务保留标记(设计稿 §5)——**行在 = 该域整体冻结**。 */
|
|
23
6
|
export const RETENTION_HOLD_TABLE = "retention_hold";
|
|
24
|
-
/** sweep 互斥的单行租约表(设计稿 §3)。表建在车1(schema 一次齐),抢/续租的逻辑归车2。 */
|
|
25
7
|
export const RETENTION_LEASE_TABLE = "retention_lease";
|
|
26
|
-
/** 删除墓碑(设计稿 §2)——副本/备份收敛 **+ 域枚举并集的第四条腿**(codex F4)。 */
|
|
27
8
|
export const RETENTION_TOMBSTONE_TABLE = "retention_tombstone";
|
|
28
|
-
/** MySQL 协议方言的建表语句(真源;由 `tidb-pool.ts` 展开进中央 `SCHEMA_STATEMENTS`)。 */
|
|
29
9
|
export const TIDB_RETENTION_STATEMENTS = [
|
|
30
|
-
// ── §6 审计表:**追加不更新**。破坏性行(前三 action)由 store 在删除同一个事务内写(F5:先删后补记
|
|
31
|
-
// = crash-after-delete-before-audit 会留下无证删除,而幂等重试 deleted=0 再也重构不出原始计数)。
|
|
32
10
|
`CREATE TABLE IF NOT EXISTS ${RETENTION_AUDIT_TABLE} (
|
|
33
11
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
|
34
12
|
domain VARCHAR(190) NOT NULL,
|
|
@@ -52,9 +30,6 @@ export const TIDB_RETENTION_STATEMENTS = [
|
|
|
52
30
|
KEY idx_retention_audit_domain (domain, id),
|
|
53
31
|
KEY idx_retention_audit_executed (executed_at_ms)
|
|
54
32
|
) COLLATE utf8mb4_bin`,
|
|
55
|
-
// ── §5 法务保留 + **域级互斥哨兵**(一张表两个职责,理由在下面 held 列的注里)。
|
|
56
|
-
// 承重判在**店事务内**(设计稿 F2:lane 预检后、三方法之间 hold 才 commit ⇒ 数据在 operator 成功
|
|
57
|
-
// 放置 hold **之后**仍被不可逆删除)。放置/解除的路由与非破坏性审计行归车2。
|
|
58
33
|
`CREATE TABLE IF NOT EXISTS ${RETENTION_HOLD_TABLE} (
|
|
59
34
|
domain VARCHAR(190) NOT NULL,
|
|
60
35
|
-- held:**1 = 冻结中,0 = 只是一把锁**。
|
|
@@ -72,9 +47,6 @@ export const TIDB_RETENTION_STATEMENTS = [
|
|
|
72
47
|
note TEXT NULL,
|
|
73
48
|
PRIMARY KEY (domain)
|
|
74
49
|
) COLLATE utf8mb4_bin`,
|
|
75
|
-
// ── §3 sweep 租约(**单行**):互斥机制 = durable lease,不是 LEADER_ENABLED(codex F1:仓内
|
|
76
|
-
// `leaderEnabled` 是纯布尔配置门,零选举/零租约/零 fencing ⇒ 每台配 true 的副本都自认 leader)。
|
|
77
|
-
// 抢/续租(CAS)与 fencing token 自增归车2;本车只保证表在。
|
|
78
50
|
`CREATE TABLE IF NOT EXISTS ${RETENTION_LEASE_TABLE} (
|
|
79
51
|
-- singleton:恒 'x' 的单行键(全局唯一一只 sweep 租约)。用定值列而不是 id=1,是为了让"这张表只能有
|
|
80
52
|
-- 一行"写在**主键**上而不是写在注释里。
|
|
@@ -85,10 +57,6 @@ export const TIDB_RETENTION_STATEMENTS = [
|
|
|
85
57
|
expires_at_ms BIGINT NOT NULL,
|
|
86
58
|
PRIMARY KEY (singleton)
|
|
87
59
|
) COLLATE utf8mb4_bin`,
|
|
88
|
-
// ── §2 墓碑:删除收敛(副本/备份重放不复活)+ **域枚举并集的第四条腿**(codex F4)。
|
|
89
|
-
// 为什么枚举离不开它:`tool_result` 没有域列,它的域从属主 session 派生;一旦属主 session 行没了
|
|
90
|
-
// (上一轮删掉/崩在中途),那些孤儿结果的域在 SQL 里**无从得知** ⇒ 该域从枚举里消失、孤儿永不清。
|
|
91
|
-
// 墓碑行带 domain,于是"只剩孤儿的域"依然可枚举(V11)。
|
|
92
60
|
`CREATE TABLE IF NOT EXISTS ${RETENTION_TOMBSTONE_TABLE} (
|
|
93
61
|
domain VARCHAR(190) NOT NULL,
|
|
94
62
|
-- kind:闭集(session | tool_result),见 RetentionTombstoneKind。
|
|
@@ -110,8 +78,6 @@ export const TIDB_RETENTION_STATEMENTS = [
|
|
|
110
78
|
KEY idx_retention_tombstone_key (kind, row_key)
|
|
111
79
|
) COLLATE utf8mb4_bin`,
|
|
112
80
|
];
|
|
113
|
-
/** PG 方言的建表语句(MySQL 孪生的逐条翻译:AUTO_INCREMENT→BIGSERIAL,逐列 `COLLATE "C"`,内联 KEY→独立
|
|
114
|
-
* CREATE INDEX)。语义逐字见 MySQL 孪生的行内注,此处不复述(两份注释迟早分叉)。 */
|
|
115
81
|
export const PG_RETENTION_SCHEMA = [
|
|
116
82
|
`CREATE TABLE IF NOT EXISTS ${RETENTION_AUDIT_TABLE} (
|
|
117
83
|
id BIGSERIAL,
|
|
@@ -155,17 +121,10 @@ export const PG_RETENTION_SCHEMA = [
|
|
|
155
121
|
)`,
|
|
156
122
|
`CREATE INDEX IF NOT EXISTS idx_retention_tombstone_key ON ${RETENTION_TOMBSTONE_TABLE} (kind, row_key)`,
|
|
157
123
|
];
|
|
158
|
-
/** PG 侧的幂等 schema apply(由 `pg-pool.ts` 的中央 `ensurePgSchema` 组合;裸 `PgQueryFn` 形,与
|
|
159
|
-
* approval-ask / leader-run 同姿势 —— DDL 仍跑在中央那条 client 上,advisory lock 的 session 语义不受影响)。 */
|
|
160
124
|
export async function ensurePgRetentionSchema(query) {
|
|
161
125
|
for (const stmt of PG_RETENTION_SCHEMA)
|
|
162
126
|
await query(stmt);
|
|
163
127
|
}
|
|
164
|
-
/**
|
|
165
|
-
* 一次事务因为**中途出现的 hold**(或中途出现的活引用)而整体放弃 —— 私有哨兵,不外泄。
|
|
166
|
-
* 为什么用异常而不是返回值:放弃必须连带 ROLLBACK,而 rollback 的属主是 {@link SqlRetentionStore.tx};
|
|
167
|
-
* 让回调用返回值表达「请回滚」会让每个调用点都要记得回滚一次,漏一处就是一次真删除。
|
|
168
|
-
*/
|
|
169
128
|
class RetentionAborted extends Error {
|
|
170
129
|
why;
|
|
171
130
|
constructor(why) {
|
|
@@ -173,67 +132,29 @@ class RetentionAborted extends Error {
|
|
|
173
132
|
this.why = why;
|
|
174
133
|
}
|
|
175
134
|
}
|
|
176
|
-
/** 单次调用的候选上界(**安全界,不是分批语义**)。设计稿 §3 的 v1 口径是「先由 cutoff 天然限量」,
|
|
177
|
-
* 这里额外扣一个上界,理由是运维面而不是功能面:一个从未清过账的大租户会让「一个事务删十余张表的
|
|
178
|
-
* 全部历史」变成一场锁堆积。剩下的行在下一拍自然收敛(三方法皆幂等),读数因此偏保守而不会偏危险。 */
|
|
179
135
|
export const RETENTION_SESSION_BATCH = 500;
|
|
180
|
-
/** checkpoint / tool_result 腿的同款上界(单行代价远小于会话树,故取大一档)。 */
|
|
181
136
|
export const RETENTION_ROW_BATCH = 2000;
|
|
182
|
-
/**
|
|
183
|
-
* 托管留存能力的双方言实现 —— core `ManagedRetentionCapability` 的 server 侧真身。
|
|
184
|
-
* 方言差异台账见文件顶注;每个破坏性方法的事务形(hold 锁读 → 变更+墓碑 → 审计行,同 commit 同 rollback)
|
|
185
|
-
* 见各方法的头注。
|
|
186
|
-
*/
|
|
187
137
|
export class SqlRetentionStore {
|
|
188
138
|
db;
|
|
189
139
|
opts;
|
|
190
|
-
/** 本店自己也是一只 `retention: "managed"` 的店(它就是那三个方法的实现体)。 */
|
|
191
140
|
retention = MANAGED_RETENTION;
|
|
192
141
|
constructor(db, opts) {
|
|
193
142
|
this.db = db;
|
|
194
143
|
this.opts = opts;
|
|
195
144
|
}
|
|
196
|
-
/** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE (A12 判据)。 */
|
|
197
145
|
q(tidb, pg) {
|
|
198
146
|
return this.db.dialect === "tidb" ? tidb : pg;
|
|
199
147
|
}
|
|
200
|
-
/**
|
|
201
|
-
* 域谓词。**四种写法逐条写出来**(方言 × 有主/无主):
|
|
202
|
-
* · 有主:`col = ?` / `col = $n`;
|
|
203
|
-
* · 无主(域键 `""`):`(col IS NULL OR col = ?)` / `(col IS NULL OR col = $n)` —— 把 SQL NULL 与空串
|
|
204
|
-
* 合并进同一个桶。合并只发生在**无主侧**,永远伸不进一个真租户;拆开才会漏(NULL 那半永不被枚举)。
|
|
205
|
-
* 🔴 两支的**占位符数量刻意相同**(无主支也绑一个 `""`):arity 一变,PG 的 `$n` 编号就会在同一条 SQL 的
|
|
206
|
-
* 后续参数上错位——那是一类只在某个域值上才复现的 bug,不许存在。
|
|
207
|
-
*/
|
|
208
148
|
domainWhere(col, domain, pgIndex) {
|
|
209
149
|
return domain === ""
|
|
210
150
|
? { sql: this.q(`(${col} IS NULL OR ${col} = ?)`, `(${col} IS NULL OR ${col} = $${pgIndex})`), params: [""] }
|
|
211
151
|
: { sql: this.q(`${col} = ?`, `${col} = $${pgIndex}`), params: [domain] };
|
|
212
152
|
}
|
|
213
|
-
/**
|
|
214
|
-
* 多值集合谓词 —— **真方言分叉**(不是 SQL 文本差):MySQL 展开 N 个 `?`,PG 用**一枚**数组参数
|
|
215
|
-
* `= ANY($n::text[])`。占位符数量因此不同(N vs 1),这是先例(workflow-run-store-sql 的 reap)也是
|
|
216
|
-
* 必然:把 500 个 id 展开成 500 个 `$n` 会让语句文本随批量变形,PG 的预编译缓存跟着退化。
|
|
217
|
-
*/
|
|
218
153
|
idSet(col, ids, pgIndex) {
|
|
219
154
|
return this.db.dialect === "tidb"
|
|
220
155
|
? { sql: `${col} IN (${ids.map(() => "?").join(",")})`, params: [...ids] }
|
|
221
156
|
: { sql: `${col} = ANY($${pgIndex}::text[])`, params: [ids] };
|
|
222
157
|
}
|
|
223
|
-
/**
|
|
224
|
-
* 事务壳 —— **平凡 `begin()`,不是 `beginPessimistic()`**(真库实测定谳,不是口味):
|
|
225
|
-
*
|
|
226
|
-
* · MySQL-protocol 腿:`BEGIN PESSIMISTIC` 是 **TiDB 专有语法**,发到普通 MySQL 上是语法错
|
|
227
|
-
* (本机 MySQL 9.7 实测 `ER_PARSE_ERROR near 'PESSIMISTIC'`)—— 而发车门 2.6 的 canonical
|
|
228
|
-
* `TIDB_TEST_URL` 默认指向的正是一台**普通 MySQL**。用那条动词等于让本店在标准工作流上必红。
|
|
229
|
-
* · 语义上也不需要它:本店要的只是 `SELECT … FOR UPDATE` 是**当前读**。InnoDB 的加锁读在 RR 下本来
|
|
230
|
-
* 就读最新已提交版本;TiDB 自 3.0.8 起事务默认就是悲观模式,同样是当前读。显式动词只在「部署把
|
|
231
|
-
* TiDB 改回乐观模式」这一种配置下才多出保障,而那种部署会同时打破仓内其它 FOR UPDATE 腿
|
|
232
|
-
* (run-store 的 task_active 门、checkpoint 的 approval 门),不是本店能独自承担的假设。
|
|
233
|
-
* · PG:READ COMMITTED 逐语句取新快照,平凡 `BEGIN` 即所需。
|
|
234
|
-
*
|
|
235
|
-
* `protected` = 真库套件的语句录制注入点(判据「hold 锁读是事务首条 SQL」靠它自证)。
|
|
236
|
-
*/
|
|
237
158
|
async tx(fn) {
|
|
238
159
|
const conn = await this.db.connect();
|
|
239
160
|
try {
|
|
@@ -247,9 +168,6 @@ export class SqlRetentionStore {
|
|
|
247
168
|
await conn.rollback();
|
|
248
169
|
}
|
|
249
170
|
catch (rollbackErr) {
|
|
250
|
-
// 🔴 **回滚失败不许无声**(#191 门② 的形:兄弟店在这里普遍是空 catch,本店不跟)。原始错误仍然是
|
|
251
|
-
// 这次调用失败的原因、照旧上抛;但「一条破坏性事务连回滚都没回成」是运维**必须**看得见的事——
|
|
252
|
-
// 连接释放时事务会随之夭折,可事后要查「那一刻库里发生了什么」,得先知道它发生过。
|
|
253
171
|
this.opts.logger?.warn?.("retention_transaction_rollback_failed", { err: String(rollbackErr) });
|
|
254
172
|
}
|
|
255
173
|
throw err;
|
|
@@ -258,11 +176,6 @@ export class SqlRetentionStore {
|
|
|
258
176
|
conn.release();
|
|
259
177
|
}
|
|
260
178
|
}
|
|
261
|
-
/**
|
|
262
|
-
* 破坏性调用的**入门检**:审计上下文缺席 ⇒ 当场抛(fail-closed)。
|
|
263
|
-
* core 的契约形只有 `{domain, cutoffMs}`,所以一个拿着契约类型的调用方**在类型上**能省掉它 —— 那正是
|
|
264
|
-
* 这道运行时门存在的理由:省掉它的那次调用如果照删不误,库里就多了一批无证删除的行。
|
|
265
|
-
*/
|
|
266
179
|
requireAudit(input, action) {
|
|
267
180
|
if (!input.audit) {
|
|
268
181
|
throw new Error(`retention: refusing a destructive call (${action}) with no audit context — every destructive retention ` +
|
|
@@ -271,44 +184,15 @@ export class SqlRetentionStore {
|
|
|
271
184
|
}
|
|
272
185
|
return input.audit;
|
|
273
186
|
}
|
|
274
|
-
/**
|
|
275
|
-
* 事务**首步**:拿本域的互斥行锁,并读出「这个域是不是冻结中」(§5 F2 承重)。
|
|
276
|
-
*
|
|
277
|
-
* 两步,缺一不可:
|
|
278
|
-
* ① `INSERT IGNORE`(PG:`ON CONFLICT DO NOTHING`)一条 `held = 0` 的**哨兵行** —— 它永远不会改写
|
|
279
|
-
* 一条既存的 hold(两方言的这两个动词都只在缺行时写),只保证**接下来那把行锁有东西可锁**;
|
|
280
|
-
* ② `SELECT held … FOR UPDATE` —— 现在这行必定存在,于是锁真的拿到了。
|
|
281
|
-
*
|
|
282
|
-
* 🔴 为什么必须先造行(codex 对抗复审 R1-[high],验真后改):`FOR UPDATE` 锁不住缺席行(TiDB 无 gap
|
|
283
|
-
* lock;PG READ COMMITTED 同样)。presence 形(「行在 = 冻结」)下,operator 的 PUT 与本事务可以**同时**
|
|
284
|
-
* 都读到「没有 hold」,PUT 提交返回成功之后本事务照删不误 —— 设计稿 §5 声称由行锁给出的串行化在那一形
|
|
285
|
-
* 上根本不存在。哨兵行把 hold 的写点与删除的读点压到**同一把行锁**上,那句承诺才成立。
|
|
286
|
-
* ⚠️ 车2 交接:PUT/DELETE hold 必须走**同一 PK 的 upsert 改 `held` 列**(不是 INSERT/DELETE 行),
|
|
287
|
-
* 否则这把锁又会退回缺席形。
|
|
288
|
-
*/
|
|
289
187
|
async holdInForce(conn, domain) {
|
|
290
|
-
// 域键 `""` 的无主桶在这张表上落成一条 `domain = ''` 的真行(它是键,不是谓词——两条腿都用同一个
|
|
291
|
-
// 字面量,锁的是同一行)。
|
|
292
188
|
const key = domain;
|
|
293
189
|
await conn.query(this.q(`INSERT IGNORE INTO ${RETENTION_HOLD_TABLE} (domain, held, placed_by, placed_at_ms, note) VALUES (?,0,NULL,NULL,NULL)`, `INSERT INTO ${RETENTION_HOLD_TABLE} (domain, held, placed_by, placed_at_ms, note) VALUES ($1,0,NULL,NULL,NULL) ON CONFLICT (domain) DO NOTHING`), [key]);
|
|
294
190
|
const { rows } = await conn.query(this.q(`SELECT held FROM ${RETENTION_HOLD_TABLE} WHERE domain = ? FOR UPDATE`, `SELECT held FROM ${RETENTION_HOLD_TABLE} WHERE domain = $1 FOR UPDATE`), [key]);
|
|
295
191
|
return Number(rows[0]?.held ?? 0) === 1;
|
|
296
192
|
}
|
|
297
|
-
/**
|
|
298
|
-
* 审计行的**唯一**写点(§6 F5:破坏性行由店在删除同事务内写)。
|
|
299
|
-
* `protected` 是**有意的注入口**:真双库套件用一只覆盖本方法为抛错的子类来证 V10(「注入 audit 写失败
|
|
300
|
-
* ⇒ 整事务回滚、数据仍在」)。把注入点做成 mock 层的猴补丁反而证不了这件事 —— 它证的是 mock。
|
|
301
|
-
*/
|
|
302
193
|
async writeAuditRow(conn, row) {
|
|
303
194
|
await conn.query(this.q(`INSERT INTO ${RETENTION_AUDIT_TABLE} (domain, action, mode, policy_days, fencing_token, deleted, skipped, tombstones, executed_at_ms) VALUES (?,?,?,?,?,?,?,?,?)`, `INSERT INTO ${RETENTION_AUDIT_TABLE} (domain, action, mode, policy_days, fencing_token, deleted, skipped, tombstones, executed_at_ms) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`), [row.domain, row.action, row.audit.mode, row.audit.policyDays, row.audit.fencingToken, row.deleted, row.skipped, row.tombstones, Date.now()]);
|
|
304
195
|
}
|
|
305
|
-
/**
|
|
306
|
-
* 墓碑写入(幂等)。返回**真正新落地**的墓碑数 —— 重跑时既有墓碑不重复计数,于是 receipt 的
|
|
307
|
-
* `tombstones` 与 `deleted` 在幂等重跑上同步归零。
|
|
308
|
-
*
|
|
309
|
-
* 方言差异:MySQL 展开多值 `VALUES (…),(…)` + `INSERT IGNORE`;PG 用 `unnest($n::text[])` 定 arity +
|
|
310
|
-
* `ON CONFLICT (domain, kind, row_key) DO NOTHING`(数组形让语句文本不随批量变形)。
|
|
311
|
-
*/
|
|
312
196
|
async writeTombstones(conn, domain, kind, keys) {
|
|
313
197
|
if (keys.length === 0)
|
|
314
198
|
return 0;
|
|
@@ -324,35 +208,12 @@ export class SqlRetentionStore {
|
|
|
324
208
|
`SELECT $1, $2, k, $3 FROM unnest($4::text[]) AS k ON CONFLICT (domain, kind, row_key) DO NOTHING`, [domain, kind, now, [...keys]]);
|
|
325
209
|
return affected;
|
|
326
210
|
}
|
|
327
|
-
// ───────────────────────────── 契约面 ① 域枚举 ─────────────────────────────
|
|
328
|
-
/**
|
|
329
|
-
* 三保留表 ∪ 墓碑的域并集(v1.2 codex F4 形)。
|
|
330
|
-
*
|
|
331
|
-
* 🔴 为什么 `tool_result` 没有自己的 UNION 臂(如实说明,不是漏写):这张表**没有域列** —— 一行卸载结果的
|
|
332
|
-
* 域由它的属主 session 派生,所以「属主还在」的行的域 ⊆ session 臂;而属主 session 一旦没了(上一轮删掉/
|
|
333
|
-
* 崩在中途),它的域在 SQL 里**无从得知**。这正是墓碑臂存在的理由:会话树删除时落的墓碑带着 domain,于是
|
|
334
|
-
* 「只剩孤儿的域」依然可枚举(V11)。再写一条 join 臂只会得到 session 臂的子集,不会多枚举出任何东西。
|
|
335
|
-
*
|
|
336
|
-
* 文本**两方言逐字相同**(无占位符)⇒ 单条共享语句(先例:file-snapshot-store 的 gcOrphanBlobs)。
|
|
337
|
-
* 排序在 JS 侧,判据面因此与引擎的排序规则无关。
|
|
338
|
-
*/
|
|
339
211
|
async listRetentionDomains() {
|
|
340
212
|
const { rows } = await this.db.query(`SELECT DISTINCT COALESCE(owner, '') AS d FROM session_meta ` +
|
|
341
213
|
`UNION SELECT DISTINCT scope AS d FROM checkpoint ` +
|
|
342
214
|
`UNION SELECT DISTINCT domain AS d FROM ${RETENTION_TOMBSTONE_TABLE}`);
|
|
343
215
|
return [...new Set(rows.map((r) => String(r.d ?? "")))].sort();
|
|
344
216
|
}
|
|
345
|
-
// ───────────────────────────── 非契约窄读口 preview ─────────────────────────────
|
|
346
|
-
/**
|
|
347
|
-
* 三候选计数(audit-only 档的读数源)。**零写、零事务**:它是纯读口,开事务只会白占一条连接。
|
|
348
|
-
*
|
|
349
|
-
* 口径 = 与三个破坏性方法**同一套谓词**(候选而非上限):
|
|
350
|
-
* · checkpoints:本域过视界的 pending 行,**扣掉 live-pinned**(那些行本来就不会被删);
|
|
351
|
-
* · sessions:本域过视界、且当下无活引用的会话树;
|
|
352
|
-
* · toolResults:本域可归属的孤儿卸载结果。
|
|
353
|
-
* 🔴 preview **不看 hold**:hold 是「这一轮跳不跳」的域级裁决(lane 记 `skipped_legal_hold` 行),而
|
|
354
|
-
* preview 回答的是「命中集有多大」。两件事混在一起,operator 就看不出「冻结期间到底积压了多少候选」。
|
|
355
|
-
*/
|
|
356
217
|
async previewRetention(input) {
|
|
357
218
|
const [checkpoints, sessions, toolResults] = await Promise.all([
|
|
358
219
|
this.expiryCandidates(this.db, input).then((tokens) => tokens.length),
|
|
@@ -361,65 +222,28 @@ export class SqlRetentionStore {
|
|
|
361
222
|
]);
|
|
362
223
|
return { checkpoints, sessions, toolResults };
|
|
363
224
|
}
|
|
364
|
-
// ───────────────────────────── 契约面 ② checkpoint 过期 ─────────────────────────────
|
|
365
|
-
/**
|
|
366
|
-
* CAS 栅栏 `pending → expired`,每行恰一胜者(胜负由 `status = 'pending'` 谓词在引擎里裁,跨副本安全)。
|
|
367
|
-
*
|
|
368
|
-
* 事务形(§5 F2 + §6 F5,一步不省):
|
|
369
|
-
* ① `retention_hold` 本域行的**锁读** —— 行在 ⇒ 本事务零变更、全计 skipped 返回;
|
|
370
|
-
* ② 候选读 + CAS UPDATE;
|
|
371
|
-
* ③ 审计行(同事务)→ COMMIT。**没有**提交前的第二次 hold 复核:域的行锁在 ① 就拿住了并持有到
|
|
372
|
-
* COMMIT,一个并发的 PUT hold 在此期间提交不了(它抢同一把锁)——一段恒真的"复核"比没有更坏。
|
|
373
|
-
*
|
|
374
|
-
* 视界判据**两条臂都写出来**:`deadline` 在场按 deadline,缺席按 `created_at_ms`。少了后一条臂,一条
|
|
375
|
-
* 没有审批 TTL 的 pending 行就永远过不了期(design/80 §3 inv#3 的 terminal_at_ms 要挡的正是这种永久泄漏)。
|
|
376
|
-
*
|
|
377
|
-
* 🔴 **pin 释放 = 本方法的 `pending → expired` 翻转本身**(core [4180] 定谳:retention.ts 契约那句
|
|
378
|
-
* 「AND release the associated session pins」是真意,[4175] 引错了对象——它引的是引擎 live-path reap
|
|
379
|
-
* 的 JSDoc)。本仓 SQL 后端**没有独立的 pin 存储**:钉住 session 的正是 `checkpoint.status='pending'`
|
|
380
|
-
* 这一状态(它是 deleteExpiredSessions 活引用四腿之一,见下方 lineage 注 :79x),所以状态翻转 = 引用
|
|
381
|
-
* 解除 = pin 释放——不需要第二个写操作,而幂等(重跑对已 expired 行零命中)恰好满足 [4180] 的
|
|
382
|
-
* 「释放已释放 pin=no-op」要求。单写者不变式按 [4180] 改述:**每行的 pin 恰好释放一次,由终局该行
|
|
383
|
-
* 的车道执行**——live 行归 reaper,retention 行归本方法,两行集不交(下一段的 live-pin skip 正是
|
|
384
|
-
* 「不交」的执行判据)。
|
|
385
|
-
* 边界形([4175] 建议,[4180] 确认仍成立):**live-pinned 且已过视界**的行(`task_active` 还占着这条
|
|
386
|
-
* 会话)⇒ skip 计数、不动那行 —— 不在一条活会话脚下抽掉它的 resume 坐标,与 legal-hold 的 skip 同形。
|
|
387
|
-
*
|
|
388
|
-
* receipt 口径:`deleted` = **本次 CAS 赢下的行数**(过期销毁的是那道 pending 门,行本身按设计留在库里
|
|
389
|
-
* 供审计/对账);`tombstones` = 0(没有行被移除,写墓碑会让「墓碑 ⇒ 这行没了」这条读法失真)。
|
|
390
|
-
*/
|
|
391
225
|
async expireCheckpoints(input) {
|
|
392
226
|
const audit = this.requireAudit(input, "expired_checkpoints");
|
|
393
227
|
let skippedOnAbort = 0;
|
|
394
228
|
try {
|
|
395
229
|
return await this.tx(async (conn) => {
|
|
396
230
|
if (await this.holdInForce(conn, input.domain)) {
|
|
397
|
-
// 冻结时的 `skipped` = 本轮**本该动**的全部行(域级冻结跳过的是整批:可过期的 + 被 pin 的)。
|
|
398
|
-
// 这一支不写审计行,也就没有「负数进不可变账」的风险,两读相加在这里是安全的。
|
|
399
231
|
skippedOnAbort = (await this.expiryCandidates(conn, input)).length + (await this.countPinnedExpiryCandidates(conn, input));
|
|
400
232
|
throw new RetentionAborted("hold_in_force");
|
|
401
233
|
}
|
|
402
234
|
const doomed = await this.expiryCandidates(conn, input);
|
|
403
|
-
const pinned = await this.countPinnedExpiryCandidates(conn, input);
|
|
235
|
+
const pinned = await this.countPinnedExpiryCandidates(conn, input);
|
|
404
236
|
skippedOnAbort = doomed.length + pinned;
|
|
405
237
|
let expired = 0;
|
|
406
238
|
if (doomed.length > 0) {
|
|
407
239
|
const w = this.domainWhere("scope", input.domain, 2);
|
|
408
240
|
const set = this.idSet("token", doomed, 3);
|
|
409
|
-
// 🔴 **pin 判据必须写进 UPDATE 本身**(codex 对抗复审 R2-[high],验真后修):候选读、pinned 计数、
|
|
410
|
-
// CAS 更新是三条语句;若一次活跑 claim 落在前两条读之间,同一行会**既进 doomed 又被计进 pinned**,
|
|
411
|
-
// 最后照样被置成 expired —— 一条活任务的 resume 坐标被撤销,而回执自称"跳过了它"。把
|
|
412
|
-
// `NOT EXISTS(task_active)` 放进 UPDATE 的 WHERE(加锁读 = 当前读)之后,这条路径按构造不存在:
|
|
413
|
-
// 更新前一刻仍被占着的行,一定不会被这条语句动到。
|
|
414
241
|
const res = await conn.query(this.q(`UPDATE checkpoint SET status = 'expired', decided_at_ms = ? WHERE ${w.sql} AND status = 'pending' AND ${set.sql} ` +
|
|
415
242
|
`AND NOT EXISTS (SELECT 1 FROM task_active ta WHERE ta.session_id = checkpoint.session_id)`, `UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 WHERE ${w.sql} AND status = 'pending' AND ${set.sql} ` +
|
|
416
243
|
`AND NOT EXISTS (SELECT 1 FROM task_active ta WHERE ta.session_id = checkpoint.session_id)`), [Date.now(), ...w.params, ...set.params]);
|
|
417
244
|
expired = res.affected;
|
|
418
245
|
}
|
|
419
246
|
await this.writeAuditRow(conn, { domain: input.domain, action: "expired_checkpoints", audit, deleted: expired, skipped: pinned, tombstones: 0 });
|
|
420
|
-
// 🔴 **没有**提交前的第二次 hold 复核(旧形有,已删):域的 hold 行锁在事务首步就拿住了并持有到
|
|
421
|
-
// COMMIT —— 一个并发的 PUT hold 在此期间根本提交不了(它抢的是同一把行锁)。再读一次不会有新信息,
|
|
422
|
-
// 而一段"看起来在防什么、实际上恒真"的代码比没有更坏(它会让下一个读者以为窗口已经被这一步堵住)。
|
|
423
247
|
return { domain: input.domain, deleted: expired, skipped: pinned, tombstones: 0 };
|
|
424
248
|
});
|
|
425
249
|
}
|
|
@@ -429,26 +253,12 @@ export class SqlRetentionStore {
|
|
|
429
253
|
throw err;
|
|
430
254
|
}
|
|
431
255
|
}
|
|
432
|
-
/**
|
|
433
|
-
* 视界谓词的两条臂(`deadline` 在场按 deadline、缺席按 `created_at_ms`)—— 两方言逐字相同,占位符各自编号。
|
|
434
|
-
* 抽成一处是因为它同时被「可过期候选」与「被 pin 的计数」两条查询用,手抄两份必分家。
|
|
435
|
-
*/
|
|
436
256
|
horizonArms(pgFirst) {
|
|
437
257
|
return this.db.dialect === "tidb"
|
|
438
258
|
? `((c.deadline IS NOT NULL AND c.deadline <= ?) OR (c.deadline IS NULL AND c.created_at_ms <= ?))`
|
|
439
259
|
: `((c.deadline IS NOT NULL AND c.deadline <= $${pgFirst}) OR (c.deadline IS NULL AND c.created_at_ms <= $${pgFirst + 1}))`;
|
|
440
260
|
}
|
|
441
|
-
/** 「这条 checkpoint 的会话被一次活跑占着」= [4175] 的 live-pinned 判据(SQL 谓词形,两方言同文本)。 */
|
|
442
261
|
static PINNED_EXISTS = `EXISTS (SELECT 1 FROM task_active ta WHERE ta.session_id = c.session_id)`;
|
|
443
|
-
/**
|
|
444
|
-
* **可过期**的候选 token(保护谓词写在 SQL 里、扣在 `LIMIT` **之前**)。
|
|
445
|
-
*
|
|
446
|
-
* 🔴 为什么保护必须下推(codex 对抗复审 R1-[medium],验真后改):旧形先按时间取 N 行、再在 JS 里剔除
|
|
447
|
-
* pinned —— 只要最老的一批持续被占着,每轮取回的都是同一批、每轮删 0,**它们后面**那些同样过期、又
|
|
448
|
-
* 没有任何引用的行**永远轮不到**。那不是「下一拍自然收敛」,那是一个域的留存静默停摆(而且读数一直
|
|
449
|
-
* 是 deleted=0,看上去像"没有候选")。判据下推之后 LIMIT 扣的是**真能动的行**,头部被保护多少行都不
|
|
450
|
-
* 影响后面的收敛。
|
|
451
|
-
*/
|
|
452
262
|
async expiryCandidates(exec, input) {
|
|
453
263
|
const w = this.domainWhere("c.scope", input.domain, 1);
|
|
454
264
|
const { rows } = await exec.query(this.q(`SELECT c.token FROM checkpoint c WHERE ${w.sql} AND c.status = 'pending' AND ${this.horizonArms(2)} ` +
|
|
@@ -456,25 +266,6 @@ export class SqlRetentionStore {
|
|
|
456
266
|
`AND NOT ${SqlRetentionStore.PINNED_EXISTS} ORDER BY c.created_at_ms ASC LIMIT $4`), [...w.params, input.cutoffMs, input.cutoffMs, RETENTION_ROW_BATCH]);
|
|
457
267
|
return rows.map((r) => String(r.token));
|
|
458
268
|
}
|
|
459
|
-
/**
|
|
460
|
-
* 过期视界之内、**当下被 live-pin 护住**的 pending 行数 = receipt 的 `skipped`。
|
|
461
|
-
*
|
|
462
|
-
* 🔴 账法定谳(两轮对抗复审各推了一次,最终落在这一形):
|
|
463
|
-
* · 曾经用「候选 + pinned 两读相加当分母、差值当 skipped」——被 codex R2 抓到会把中途翻转 pin 的行数两遍;
|
|
464
|
-
* · 改成「视界总数当分母、差值当 skipped」——被 codex R3 抓到在 PG 的 READ COMMITTED 下可以**为负**
|
|
465
|
-
* (分母读完之后又有行被 put/reopen 成 pending,候选与 UPDATE 都看得见它 ⇒ expired > 分母),而一条
|
|
466
|
-
* 负数写进**追加式**审计表是比「小幅重复计数」坏得多的事(审计不可改,读它的人无从判断哪一格出了错);
|
|
467
|
-
* · ⇒ 现形:`deleted` = UPDATE 的**真实命中**(pin 判据写在它自己的 WHERE 里,数据面按构造正确),
|
|
468
|
-
* `skipped` = 同一事务内测得的**受保护行数**(本方法)。两者是**两个独立测得的事实**,不构成
|
|
469
|
-
* `deleted + skipped = 总数` 的恒等式,两者都恒 ≥ 0。中途翻转 pin 状态的行最多被两边各记一次
|
|
470
|
-
* (量级=翻转数,通常 0),这是刻意选的失真方向:**宁可轻微重复计数,也不让审计出现负数**。
|
|
471
|
-
*
|
|
472
|
-
* 🔴 **有界**(codex R2-[medium],验真后改):裸 `COUNT(*)` 对一个积压很深的域是无界扫描,而 EXISTS 关联的
|
|
473
|
-
* 都是没有为本用途建过索引的列。留存是每小时一拍的后台活,不该把一次精确到最后一行的计数买成数据库尖峰
|
|
474
|
-
* ⇒ 计数扣在与批量同阶的上界内(派生表 LIMIT),读数是「至少这么多」。少算的部分下一拍照样看得见
|
|
475
|
-
* (判据幂等),而运维要的是「有没有积压、量级多大」。索引面(workflow originating_session_id 物化列 /
|
|
476
|
-
* background-agent 活锚 / checkpoint 的 scope+status+deadline 复合索引)是独立一车,已进交接清单。
|
|
477
|
-
*/
|
|
478
269
|
async countPinnedExpiryCandidates(exec, input) {
|
|
479
270
|
const w = this.domainWhere("c.scope", input.domain, 1);
|
|
480
271
|
const { rows } = await exec.query(this.q(`SELECT COUNT(*) AS n FROM (SELECT 1 AS one FROM checkpoint c WHERE ${w.sql} AND c.status = 'pending' AND ${this.horizonArms(2)} ` +
|
|
@@ -482,39 +273,17 @@ export class SqlRetentionStore {
|
|
|
482
273
|
`AND ${SqlRetentionStore.PINNED_EXISTS} LIMIT $4) x`), [...w.params, input.cutoffMs, input.cutoffMs, RETENTION_ROW_BATCH]);
|
|
483
274
|
return Number(rows[0]?.n ?? 0);
|
|
484
275
|
}
|
|
485
|
-
/**
|
|
486
|
-
* 会话树的**四条活引用腿**的 SQL 谓词(`sm` = session_meta 的别名)。写成一处的理由与 {@link horizonArms}
|
|
487
|
-
* 相同,而且更重:它同时是「可删候选」的否定谓词与「被保护计数」的肯定谓词,两份手抄必然在某次改动后分家,
|
|
488
|
-
* 而分家的方向是**多删**(候选放宽、计数收紧,两边都不会有人红)。
|
|
489
|
-
*
|
|
490
|
-
* 腿的词表:活跑 claim / 挂起 park / 活着的后台子代(running|parked,三根会话锚)/ 在跑的 workflow
|
|
491
|
-
* (`originatingSessionId` 住在 run blob 里 ⇒ JSON 提取是真方言分叉)。fork 子代**结构上没有边**,
|
|
492
|
-
* 理由见 {@link SqlRetentionStore.deleteExpiredSessions} 的头注。
|
|
493
|
-
*/
|
|
494
|
-
/* 🔴 #292 C18 追加的**第五条腿**(placed 分区,core 5.48 design/323 义务 4 的 server 侧兑现):
|
|
495
|
-
* 一只 `placement_kind` 在场的会话是某条委派的**转录**,它的删除权**独家**属于联合 reap
|
|
496
|
-
* (`reapDurableAgents`:先按 rev 条件删 agent 行、赢了才 `release` 终结转录;以及行已不在时的分区
|
|
497
|
-
* 收割腿)。让视界腿也去删它会造出两个删除权威,而它们的交叠恰好是这条义务要关死的那个状态:
|
|
498
|
-
* 行还在、指着一份已经不存在的转录(core 的 `delegation.transcript_integrity` 通告说的就是它)。
|
|
499
|
-
* ⇒ 把「placed」当成一条**活引用**:候选谓词排除它,`countProtectedSessions` 同时把它记成 skipped
|
|
500
|
-
* (两侧共用本函数 ⇒ 不可能分家)。它不会因此永生:分区收割腿按 `backgroundAgentRetentionMs` 的
|
|
501
|
-
* 年龄 + 「行确实不在」联合判定后真删。 */
|
|
502
276
|
liveReferenceExists() {
|
|
503
277
|
const workflow = this.db.dialect === "tidb"
|
|
504
278
|
? `EXISTS (SELECT 1 FROM workflow_run wr WHERE wr.status = 'running' AND JSON_UNQUOTE(JSON_EXTRACT(wr.run, '$.originatingSessionId')) = sm.session_id)`
|
|
505
279
|
: `EXISTS (SELECT 1 FROM workflow_run wr WHERE wr.status = 'running' AND wr.run::json->>'originatingSessionId' = sm.session_id)`;
|
|
506
|
-
return (`(sm.placement_kind IS NOT NULL` +
|
|
280
|
+
return (`(sm.placement_kind IS NOT NULL` +
|
|
507
281
|
` OR EXISTS (SELECT 1 FROM task_active ta WHERE ta.session_id = sm.session_id)` +
|
|
508
282
|
` OR EXISTS (SELECT 1 FROM checkpoint c2 WHERE c2.session_id = sm.session_id AND c2.status = 'pending')` +
|
|
509
283
|
` OR EXISTS (SELECT 1 FROM background_agent ba WHERE ba.status IN ('running','parked')` +
|
|
510
284
|
` AND (ba.session_id = sm.session_id OR ba.parent_session_id = sm.session_id OR ba.root_session_id = sm.session_id))` +
|
|
511
285
|
` OR ${workflow})`);
|
|
512
286
|
}
|
|
513
|
-
/**
|
|
514
|
-
* 本域过视界、**当下被活引用护住**的会话数 = receipt 的 `skipped`。
|
|
515
|
-
* 账法与理由(为什么不是「总数减已删」的差值、为什么恒 ≥ 0、为什么有界)逐字见
|
|
516
|
-
* {@link SqlRetentionStore.countPinnedExpiryCandidates}:两条腿同一条账法,不许在这里另立一套。
|
|
517
|
-
*/
|
|
518
287
|
async countProtectedSessions(exec, input) {
|
|
519
288
|
const w = this.domainWhere("sm.owner", input.domain, 1);
|
|
520
289
|
const { rows } = await exec.query(this.q(`SELECT COUNT(*) AS n FROM (SELECT 1 AS one FROM session_meta sm WHERE ${w.sql} AND sm.updated_at < ? ` +
|
|
@@ -522,19 +291,6 @@ export class SqlRetentionStore {
|
|
|
522
291
|
`AND ${this.liveReferenceExists()} LIMIT $3) x`), [...w.params, new Date(input.cutoffMs), RETENTION_SESSION_BATCH]);
|
|
523
292
|
return Number(rows[0]?.n ?? 0);
|
|
524
293
|
}
|
|
525
|
-
/**
|
|
526
|
-
* **可删**的会话 id:本域、过视界、且四条活引用腿**一条都不命中**(保护谓词下推,扣在 LIMIT 之前 ——
|
|
527
|
-
* 理由与 {@link expiryCandidates} 逐字相同:头部被保护的树不许饿死它后面的树)。
|
|
528
|
-
*
|
|
529
|
-
* `lock = true`(删除事务里的读)追加 `FOR UPDATE`,这一格是**承重**的(codex 对抗复审 R1-[high]):
|
|
530
|
-
* · 会话行是**存在**的行 ⇒ 这把锁与 hold 哨兵不同,它真的锁得住;
|
|
531
|
-
* · 锁住之后,并发的 `touch()`(把会话重新拉进视界)与 session-sync 的 **owner 改写**(importEntries /
|
|
532
|
-
* replaceEntries / staged commit 都会重写 `session_meta.owner`)必须排队等我们提交 —— 否则「按旧候选集
|
|
533
|
-
* 删完附属表、最后那条属主门 DELETE 却落空」就成立,而下一句无条件的 `session_event` 删除会把一个
|
|
534
|
-
* **刚刚换了主**的会话的历史一并抹掉(跨租户不可逆丢失)。
|
|
535
|
-
* · 锁 + 谓词在同一条语句里,于是「候选」与「删除」之间不存在窗口;`purgeSessionTrees` 尾部再断言
|
|
536
|
-
* 「meta 命中数 == 预期数」,两道一起把这条路封死。
|
|
537
|
-
*/
|
|
538
294
|
async sessionCandidates(exec, input, lock = false) {
|
|
539
295
|
const w = this.domainWhere("sm.owner", input.domain, 1);
|
|
540
296
|
const { rows } = await exec.query(this.q(`SELECT sm.session_id FROM session_meta sm WHERE ${w.sql} AND sm.updated_at < ? AND NOT ${this.liveReferenceExists()} ` +
|
|
@@ -542,20 +298,9 @@ export class SqlRetentionStore {
|
|
|
542
298
|
`ORDER BY sm.updated_at ASC LIMIT $3${lock ? " FOR UPDATE" : ""}`), [...w.params, new Date(input.cutoffMs), RETENTION_SESSION_BATCH]);
|
|
543
299
|
return rows.map((r) => String(r.session_id));
|
|
544
300
|
}
|
|
545
|
-
/** 孤儿卸载结果的候选计数(实现随 `deleteOrphanToolResults` 同刀落地)。 */
|
|
546
301
|
async countOrphanToolResultCandidates(exec, input) {
|
|
547
302
|
return (await this.orphanToolResultCandidates(exec, input)).length;
|
|
548
303
|
}
|
|
549
|
-
/**
|
|
550
|
-
* 本域**可归属**的孤儿卸载结果(属主 session 已亡 + 墓碑归属本域 + 归属**无歧义**)。
|
|
551
|
-
*
|
|
552
|
-
* 🔴 第三个条件是 fail-closed 的归属门(codex 对抗复审 R3-[high],验真后加):`tool_result` 没有域列,
|
|
553
|
-
* 它的域完全靠「属主 sessionId 的墓碑」推。而会话 id 是**调用方可自选**的 ⇒ 两个租户先后用过同一个 id
|
|
554
|
-
* 完全合法;两边都删过之后,同一个 row_key 上会有**两个域**的墓碑,这时任何一边的 sweep 拿自己的墓碑
|
|
555
|
-
* 去认领这些字节都只是猜。判据因此是:**存在第二个域的同名墓碑 ⇒ 这批行不可归属 ⇒ 一根不删**,
|
|
556
|
-
* 实体清理退回既有的 TTL 腿(`reapOlderThan`)。少删一次是可接受的;删掉另一个租户的字节、还绕过它的
|
|
557
|
-
* legal hold 与审计域,不可接受。
|
|
558
|
-
*/
|
|
559
304
|
async orphanToolResultCandidates(exec, input) {
|
|
560
305
|
const w = this.domainWhere("ts.domain", input.domain, 1);
|
|
561
306
|
const ambiguous = this.q(`NOT EXISTS (SELECT 1 FROM ${RETENTION_TOMBSTONE_TABLE} other WHERE other.kind = 'session' AND other.row_key = tr.owner_session_id AND other.domain <> ts.domain)`, `NOT EXISTS (SELECT 1 FROM ${RETENTION_TOMBSTONE_TABLE} other WHERE other.kind = 'session' AND other.row_key = tr.owner_session_id AND other.domain <> ts.domain)`);
|
|
@@ -566,55 +311,17 @@ export class SqlRetentionStore {
|
|
|
566
311
|
`AND ${ambiguous} ORDER BY tr.created_at ASC LIMIT $3`), [...w.params, new Date(input.cutoffMs), RETENTION_ROW_BATCH]);
|
|
567
312
|
return rows.map((r) => String(r.ref));
|
|
568
313
|
}
|
|
569
|
-
// ───────────────────────────── 契约面 ③ 会话树删除 ─────────────────────────────
|
|
570
|
-
/**
|
|
571
|
-
* lineage-aware 的会话树删除。**引用在删除事务内重查**(core 契约逐字:「references are re-checked inside
|
|
572
|
-
* the deletion transaction, never assumed from a prior scan」),有活引用的树**整棵**跳过计 `skipped`。
|
|
573
|
-
*
|
|
574
|
-
* 事务形(§5 F2 + §6 F5):
|
|
575
|
-
* ① `retention_hold` 哨兵行的**锁读** —— `held=1` ⇒ 零删、全 skip 返回;锁持有到 COMMIT;
|
|
576
|
-
* ② **一条**加锁读同时完成「候选 + 活引用重查」(谓词下推 + `FOR UPDATE`,见
|
|
577
|
-
* {@link SqlRetentionStore.sessionCandidates});
|
|
578
|
-
* ③ 按 E21 顺序逐表删 → 墓碑 → 审计行(同事务)→ COMMIT。
|
|
579
|
-
*
|
|
580
|
-
* 🔴 活引用重查为什么是**候选查询本身**而不是第二条查询(codex 对抗复审 R1 的两条 high 合并采纳):
|
|
581
|
-
* · 两条查询之间有窗口,而下推之后「被查到的」按构造就是「四条腿一条都不命中的」;
|
|
582
|
-
* · `FOR UPDATE` 让这些会话行在本事务期间不可被 `touch()` 拉回视界、也不可被 session-sync 改写属主 ——
|
|
583
|
-
* 旧形(不加锁 + 尾部无条件删 `session_event`)可以在属主被并发改写后,把**别的租户**的历史删掉;
|
|
584
|
-
* · 旧形还在删完 `task_active` **之后**才去复核它,那道复核因此恒真(自己刚把证据删了)—— 一段恒真的
|
|
585
|
-
* "防护"比没有更坏。现在删除前的最后一次判据就是候选查询,删除后不再假装复核。
|
|
586
|
-
* ⚠️ **残余**(如实成文,不假装消灭):`createRun` 只写 `task_active`(亲读 run-store-sql 坐实,它不碰
|
|
587
|
-
* `session_meta`),所以本事务的会话行锁**不**与它构成互斥。一次恰好在候选查询之后、本事务提交之前
|
|
588
|
-
* 提交的新 claim 仍会被这一轮删掉。窗口 = 级联本身的耗时,且与**在线** purge 协调器的同款窗口一模一样
|
|
589
|
-
* (它那条 `SELECT … FOR UPDATE task_active` 同样锁不住缺席行)。真正的收口是让 `createRun` 也去锁
|
|
590
|
-
* `session_meta` 那一行(共同锁序)—— 那是在线热路径的改动,归属另一次裁定,已进交接清单。
|
|
591
|
-
*
|
|
592
|
-
* 活引用的四条腿(与设计稿 §2 的清单对表,并如实标注一条**结构上不存在**的边):
|
|
593
|
-
* · `task_active` —— 活跑 claim(在线协调器的第一道门,同一张表同一条判据);
|
|
594
|
-
* · `checkpoint.status='pending'` —— 还赎得回的 park;
|
|
595
|
-
* · `background_agent` 的 `running|parked` 行(session_id / parent_session_id / root_session_id 三根锚);
|
|
596
|
-
* · `workflow_run` 的 `running` 行(`originatingSessionId` 在 run blob 里,JSON 提取两方言各写各的)。
|
|
597
|
-
* · 🔴 **fork 子代没有 durable 边**(亲读 `TiDBSessionStore.fork` / `session_meta` 的 DDL 坐实):E17 的
|
|
598
|
-
* fork 是**整段历史拷贝**成一个独立会话,库里不留父子指针。这不是漏查 —— 拷贝形下删父会话不会让子
|
|
599
|
-
* 会话悬空(它的字节是它自己的),所以这条边**结构上不需要保护**。设计稿把「fork 子」列进 lineage
|
|
600
|
-
* 清单是按 core 契约的通用措辞抄的;本仓的 fork 语义使它落空,记在这里免得后人当缺口再"补"一次。
|
|
601
|
-
*
|
|
602
|
-
* receipt 口径:`deleted` = **删掉的会话树棵数**(= `session_meta` 的命中行数),不是级联删掉的总行数
|
|
603
|
-
* ——后者跨十余张表、随部署形态漂(附件表可能不在场),做不成稳定判据;而「幂等重跑 ⇒ 0」这条契约在
|
|
604
|
-
* 两种口径下同真。`tombstones` = 本次**新落地**的墓碑数(重跑时既有墓碑不重复计)。
|
|
605
|
-
*/
|
|
606
314
|
async deleteExpiredSessions(input) {
|
|
607
315
|
const audit = this.requireAudit(input, "deleted_sessions");
|
|
608
316
|
let skippedOnAbort = 0;
|
|
609
317
|
try {
|
|
610
318
|
return await this.tx(async (conn) => {
|
|
611
319
|
if (await this.holdInForce(conn, input.domain)) {
|
|
612
|
-
// 冻结时的 `skipped` = 本轮本该动的全部树(可删的 + 被护住的);该支不写审计行,故两读相加安全。
|
|
613
320
|
skippedOnAbort = (await this.sessionCandidates(conn, input)).length + (await this.countProtectedSessions(conn, input));
|
|
614
321
|
throw new RetentionAborted("hold_in_force");
|
|
615
322
|
}
|
|
616
|
-
const doomed = await this.sessionCandidates(conn, input, true);
|
|
617
|
-
const skipped = await this.countProtectedSessions(conn, input);
|
|
323
|
+
const doomed = await this.sessionCandidates(conn, input, true);
|
|
324
|
+
const skipped = await this.countProtectedSessions(conn, input);
|
|
618
325
|
skippedOnAbort = doomed.length + skipped;
|
|
619
326
|
let deleted = 0;
|
|
620
327
|
let tombstones = 0;
|
|
@@ -632,33 +339,8 @@ export class SqlRetentionStore {
|
|
|
632
339
|
throw err;
|
|
633
340
|
}
|
|
634
341
|
}
|
|
635
|
-
/**
|
|
636
|
-
* E21 顺序契约的**事务化搬入**:同一组 DELETE、同一个顺序,一次性发在一条事务连接上。
|
|
637
|
-
* 顺序的两条承重理由(其余是可读性):
|
|
638
|
-
* · `checkpoint` / `checkpoint_ctx` 的属主门是 `session_meta.owner` 的 EXISTS 子查询 ⇒ 它们**必须**排在
|
|
639
|
-
* `session_meta` 删除**之前**(meta 一没,门就恒假、一行都删不掉);
|
|
640
|
-
* · `task_event` / `task_active` 经 `task_run` 连删 ⇒ 必须排在 `task_run` 删除**之前**。
|
|
641
|
-
* 在线协调器那边「子表先删、meta 最后」的次序还额外承担**跨事务崩溃**语义(半途失败时 meta 仍在 ⇒ 幂等
|
|
642
|
-
* 重试能收敛);本方法整段在一个事务里,那条理由在这里不成立,但次序照抄不改 —— 两条腿的等价断言
|
|
643
|
-
* (E21)比的就是"同一条契约的两个执行体",让次序分家等于自愿放弃那条判据。
|
|
644
|
-
*
|
|
645
|
-
* 属主门的一处**有意改写**(而不是照抄 `<=>`):在线协调器传的是"这个会话的 owner"(可能是 SQL NULL),
|
|
646
|
-
* 留存腿传的是**域键**(字符串,`""` 表示无主桶)⇒ 用 {@link SqlRetentionStore.domainWhere} 表达
|
|
647
|
-
* `(owner IS NULL OR owner = '')`。对有主域两者逐字等价(`col = ?` ⟺ `col <=> ?`,参数非 NULL);
|
|
648
|
-
* 对无主域,`<=>` 拿字符串 `""` 去比 NULL 会**一行都不匹配** —— 那正是「无主会话永远清不掉」的病。
|
|
649
|
-
* 这条改写只对 **`session_meta.owner` 这一族门**(meta / checkpoint / checkpoint_ctx)成立。
|
|
650
|
-
*
|
|
651
|
-
* 🔴 运行账本(task_event / task_active / task_run)**没有**属主门,按 `session_id` 单键删(F-A,2026-08-17
|
|
652
|
-
* 合并码扫描;与在线协调器同刀改):`task_run.owner` 是**每次提交的 principal**,与会话主会分叉(无主存量
|
|
653
|
-
* 会话被带 principal 的壳续聊 ⇒ 新行 owner="default")。行级属主门于是只删得掉半棵树,而 `session_meta`
|
|
654
|
-
* 在同一事务里已经删掉 ⇒ {@link SqlRetentionStore.sessionCandidates} 从此**永远枚举不到**这些行:它们不是
|
|
655
|
-
* "下一轮再清",是**没有任何清理路径**的孤儿。域隔离不靠这道门承担 —— `ids` 已经是**本域** session_meta
|
|
656
|
-
* 里选出来并 `FOR UPDATE` 锁住的候选集,行级门去掉之后越域仍然结构上不可能。
|
|
657
|
-
*/
|
|
658
342
|
async purgeSessionTrees(conn, domain, ids) {
|
|
659
|
-
/** 每条腿都要一份新的谓词(PG 的 `$n` 编号随参数位置变)。 */
|
|
660
343
|
const set = (col, pgIndex) => this.idSet(col, ids, pgIndex);
|
|
661
|
-
// ① 运行账本:task_event → task_active → task_run(session_id 单键,无行级属主门;与协调器逐字同序)
|
|
662
344
|
{
|
|
663
345
|
const s = set("tr.session_id", 1);
|
|
664
346
|
await conn.query(this.q(`DELETE te FROM task_event te JOIN task_run tr ON te.task_id = tr.task_id WHERE ${s.sql}`, `DELETE FROM task_event te USING task_run tr WHERE te.task_id = tr.task_id AND ${s.sql}`), [...s.params]);
|
|
@@ -671,7 +353,6 @@ export class SqlRetentionStore {
|
|
|
671
353
|
const s = set("session_id", 1);
|
|
672
354
|
await conn.query(`DELETE FROM task_run WHERE ${s.sql}`, [...s.params]);
|
|
673
355
|
}
|
|
674
|
-
// ② checkpoint + checkpoint_ctx(属主门 = session_meta.owner 的 EXISTS;**必须**在 meta 删除之前)
|
|
675
356
|
{
|
|
676
357
|
const s = set("checkpoint.session_id", 1);
|
|
677
358
|
const w = this.domainWhere("sm.owner", domain, 2);
|
|
@@ -682,36 +363,20 @@ export class SqlRetentionStore {
|
|
|
682
363
|
const w = this.domainWhere("sm.owner", domain, 2);
|
|
683
364
|
await conn.query(`DELETE FROM checkpoint_ctx WHERE ${s.sql} AND EXISTS (SELECT 1 FROM session_meta sm WHERE sm.session_id = checkpoint_ctx.session_id AND ${w.sql})`, [...s.params, ...w.params]);
|
|
684
365
|
}
|
|
685
|
-
// ③ 卸载的工具结果 —— 只按**记录下来的出处**选行(core 契约:ref 是 opaque handle,禁解析)
|
|
686
366
|
{
|
|
687
367
|
const s = set("owner_session_id", 1);
|
|
688
368
|
await conn.query(`DELETE FROM tool_result WHERE ${s.sql}`, s.params);
|
|
689
369
|
}
|
|
690
|
-
// ④ 会话级附属表(协调器对它们一律 session_id 单键 scoping —— 路由已证会话所有权;留存腿的证明是
|
|
691
|
-
// 候选集本身来自本域的 session_meta,同一事务内成立)
|
|
692
370
|
for (const [table, col] of [
|
|
693
371
|
["resume_anchor", "session_id"],
|
|
694
372
|
["approval_exemption", "session_id"],
|
|
695
373
|
["session_policy", "session_id"],
|
|
696
374
|
...(this.opts.taskAttachmentTable ? [["task_attachment", "session_id"]] : []),
|
|
697
|
-
// E19 快照:本表的 `scope` 列存的就是 sessionId(见 file-snapshot-store 的 deleteBySession)
|
|
698
375
|
["snapshot_manifest", "scope"],
|
|
699
376
|
]) {
|
|
700
377
|
const s = set(col, 1);
|
|
701
378
|
await conn.query(`DELETE FROM ${table} WHERE ${s.sql}`, s.params);
|
|
702
379
|
}
|
|
703
|
-
// ④a #318 任务清单:与上面那批同族(per-session 用户内容,随会话删),但**键列不是 session_id**
|
|
704
|
-
// —— 本表按 `list_key` 分区,而会话的 list_key 是 sessionId 的确定性派生({@link taskListKeyFor},
|
|
705
|
-
// 属主在 task-list-store-sql.ts;在线协调器走同一只函数)。所以单独一格,谓词喂**派生后**的键集。
|
|
706
|
-
// meta 行(高水位 next_id)必须一起删:留着它,同一个会话 id 被别人重新登记后新主的第一条任务
|
|
707
|
-
// 会从旧主的高水位续号 —— 跨化身信息泄漏,与在线腿 `TaskListLane.deleteBySession` 的判据逐字同源。
|
|
708
|
-
// 🔴 **加锁次序必须与 `mutate` 同向**(codex 对抗复审 R2-[high],验真后修):清单的写事务是
|
|
709
|
-
// `meta 行 FOR UPDATE`(分区锁)→ 再读写 item 行;本腿若按 item→meta 的次序删,两条事务就是
|
|
710
|
-
// 经典的反向持锁 —— 留存持着 item 行等 meta,mutate 持着 meta 等 item,数据库挑一个牺牲者:
|
|
711
|
-
// 要么用户的一次 TaskUpdate 报错,要么**整轮**跨十余张表的留存事务回滚。所以先把本批 list_key
|
|
712
|
-
// 的 meta 行按**确定性次序**(list_key 升序)全锁住,再删 item、再删 meta —— 与在线腿
|
|
713
|
-
// `deleteTiDBTaskList`/`deletePgTaskList` 的事务姿势逐字同向。
|
|
714
|
-
// (meta 行不在 = 该会话从未写过任务:FOR UPDATE 锁 0 行,两条 DELETE 各命中 0 行,幂等。)
|
|
715
380
|
if (this.opts.taskListTables) {
|
|
716
381
|
const listKeys = ids.map((id) => taskListKeyFor(id)).sort();
|
|
717
382
|
{
|
|
@@ -723,35 +388,13 @@ export class SqlRetentionStore {
|
|
|
723
388
|
await conn.query(`DELETE FROM ${table} WHERE ${s.sql}`, s.params);
|
|
724
389
|
}
|
|
725
390
|
}
|
|
726
|
-
// ④b workflow 完成信箱:**只删行,不写 purge 围栏**(三轮对抗复审打完的定谳;这一格看着像"少做了
|
|
727
|
-
// 一步",实际是在两种残余里选了伤害小的那一种,理由必须留全)。
|
|
728
|
-
// · 在线协调器 `SqlWorkflowCompletionInbox.purge` 先写围栏再删行,挡住与删除赛跑的 enqueue;
|
|
729
|
-
// · 留存腿照抄过一版**事务内写**——变异实测证伪:事务内的围栏对别的连接不可见(PG READ COMMITTED /
|
|
730
|
-
// 无 gap lock 的 TiDB 都一样),挡不住任何东西,而那次 upsert 占住围栏行的排他锁,反把并发 enqueue
|
|
731
|
-
// 的 `sweepFences` 顶成 `Lock wait timeout exceeded`;
|
|
732
|
-
// · 再改成**事务前无锁预读 + 独立提交**——也不成立:预读无锁,会给最终**没被删**的会话(预读后被
|
|
733
|
-
// touch / 刚获得活引用 / 域中途进 hold)也写上围栏;而 SQL 版 enqueue 命中 purge 围栏是**成功返回
|
|
734
|
-
// 不落行**,上层随即把 notify journal 记成已投递 ⇒ 一个**活过来的**会话**永久**丢掉完成通知。
|
|
735
|
-
// 那是用户可见的损失,比下面那条泄漏重一个量级。
|
|
736
|
-
// ⇒ **保留的残余**(如实登记,交接件):workflow 已终态、完成通知仍在重试窗口、其会话又恰好过了留存
|
|
737
|
-
// 视界 ⇒ 迟到的 enqueue 会在删除之后落一行指向已删会话的 inbox 行。它**不会被投递**(drain 按
|
|
738
|
-
// session 取且带属主门),代价是一行残留;窗口窄(要"刚终态"与"闲置 ≥ maxAgeDays"同时成立)。
|
|
739
|
-
// ⇒ **真正的收口**有两条候选,都超出 store 半场:①把 `workflow_notify_journal.acked = 0` 作为第五条
|
|
740
|
-
// 活引用腿(需先确认 journal 行是"enqueue 之前写、投递之后 ack"——它的写点在 core 侧,本车没能亲验,
|
|
741
|
-
// 故不擅自加腿);②给会话删除一个 durable 的"预删除"态,使误围栏可回滚、通知可恢复。两者都跨 inbox
|
|
742
|
-
// 契约,归设计属主裁定。
|
|
743
391
|
{
|
|
744
392
|
const s = set("session_id", 1);
|
|
745
393
|
await conn.query(`DELETE FROM workflow_completion_inbox WHERE ${s.sql}`, s.params);
|
|
746
394
|
}
|
|
747
|
-
// ⑤ 会话本体:meta(域门)LAST,然后它的事件日志。返回值 = 删掉的树棵数。
|
|
748
395
|
const s = set("session_id", 1);
|
|
749
396
|
const w = this.domainWhere("owner", domain, 2);
|
|
750
397
|
const metaRes = await conn.query(`DELETE FROM session_meta WHERE ${s.sql} AND ${w.sql}`, [...s.params, ...w.params]);
|
|
751
|
-
// 🔴 **不变量断言,不是装饰**(codex 对抗复审 R1-[high] 的第二半):`session_event` 没有属主列,它的
|
|
752
|
-
// 删除只能靠上面那条属主门背书。候选是加锁读出来的(见 sessionCandidates 的 `FOR UPDATE`),所以命中数
|
|
753
|
-
// **必须**逐个对上;对不上只意味着一件事——有人绕过锁改了 `session_meta`(带外 SQL / 未来某条新写路径),
|
|
754
|
-
// 而那种局面下继续删事件日志就是在删一个**已经不属于本域**的会话的历史。fail-closed:整事务回滚。
|
|
755
398
|
if (metaRes.affected !== ids.length) {
|
|
756
399
|
throw new Error(`retention: session_meta owner-gated delete matched ${metaRes.affected} of ${ids.length} locked candidates in domain ` +
|
|
757
400
|
`${JSON.stringify(domain)} — refusing to delete session_event (which carries no owner column and is gated on this ` +
|
|
@@ -761,25 +404,6 @@ export class SqlRetentionStore {
|
|
|
761
404
|
await conn.query(`DELETE FROM session_event WHERE ${ev.sql}`, ev.params);
|
|
762
405
|
return metaRes.affected;
|
|
763
406
|
}
|
|
764
|
-
// ───────────────────────────── 契约面 ④ 孤儿卸载结果 ─────────────────────────────
|
|
765
|
-
/**
|
|
766
|
-
* 属主 session 已亡的 offloaded 结果(core 契约:「whose owning sessions are gone (or past the horizon),
|
|
767
|
-
* skipping refs still reachable from live transcripts」)。
|
|
768
|
-
*
|
|
769
|
-
* 三条口径,逐条都是判据(它们决定了这条腿**不会**碰什么):
|
|
770
|
-
* · **删的那一支**:`owner_session_id` 指向一个**不在 `session_meta` 里**的会话,且该会话在本域留有
|
|
771
|
-
* `kind='session'` 墓碑,且行本身过了视界。墓碑是**域归属的唯一证据** —— 没有它,一条孤儿行的租户
|
|
772
|
-
* 在 SQL 里根本问不出来(表没有域列),而按 ref 前缀猜属主是 core 明令禁止的(ref 是 opaque handle,
|
|
773
|
-
* 调用方可自铸;`tool-result-store-sql.deleteBySession` 的顶注逐字记着这条被 codex 收窄过的教训)。
|
|
774
|
-
* · **skip 的那一支**:属主会话**还在** = 转录活着 = 「still reachable from live transcripts」⇒ 过视界
|
|
775
|
-
* 也只计 `skipped` 不删。会话本体的清理归 {@link SqlRetentionStore.deleteExpiredSessions}(它会连着
|
|
776
|
-
* 这行一起删),两条腿因此不重叠也不互相抢行。
|
|
777
|
-
* · **一根不碰的那一支**:`owner_session_id IS NULL` 的**无出处**行。没有出处就没有域,按任何域去删它
|
|
778
|
-
* 都是猜;实体清理归既有的 TTL 腿(`reapOlderThan`,`boot/reapers.ts` 驱动)。这与 core 的
|
|
779
|
-
* 「an UNOWNED entry is never deleted」是同一条纪律的两处执行点。
|
|
780
|
-
*
|
|
781
|
-
* 事务形与另外两只方法逐字同款(hold 哨兵行锁读 → 删除+墓碑 → 审计行,同 commit 同 rollback)。
|
|
782
|
-
*/
|
|
783
407
|
async deleteOrphanToolResults(input) {
|
|
784
408
|
const audit = this.requireAudit(input, "deleted_tool_results");
|
|
785
409
|
let skippedOnAbort = 0;
|
|
@@ -809,20 +433,17 @@ export class SqlRetentionStore {
|
|
|
809
433
|
throw err;
|
|
810
434
|
}
|
|
811
435
|
}
|
|
812
|
-
/** 「活转录可达」的行数(属主会话仍在 `session_meta`、域是本域、行已过视界)= receipt 的 `skipped`。 */
|
|
813
436
|
async countLiveReachableToolResults(exec, input) {
|
|
814
437
|
const w = this.domainWhere("sm.owner", input.domain, 1);
|
|
815
438
|
const { rows } = await exec.query(this.q(`SELECT COUNT(*) AS n FROM tool_result tr JOIN session_meta sm ON sm.session_id = tr.owner_session_id WHERE ${w.sql} AND tr.created_at < ?`, `SELECT COUNT(*) AS n FROM tool_result tr JOIN session_meta sm ON sm.session_id = tr.owner_session_id WHERE ${w.sql} AND tr.created_at < $2`), [...w.params, new Date(input.cutoffMs)]);
|
|
816
439
|
return Number(rows[0]?.n ?? 0);
|
|
817
440
|
}
|
|
818
441
|
}
|
|
819
|
-
/** MySQL-protocol (TiDB) binding。 */
|
|
820
442
|
export class TiDBRetentionStore extends SqlRetentionStore {
|
|
821
443
|
constructor(pool, opts) {
|
|
822
444
|
super(mysqlDriver(pool), opts);
|
|
823
445
|
}
|
|
824
446
|
}
|
|
825
|
-
/** PostgreSQL binding。 */
|
|
826
447
|
export class PgRetentionStore extends SqlRetentionStore {
|
|
827
448
|
constructor(pool, opts) {
|
|
828
449
|
super(pgDriver(pool), opts);
|