@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
|
@@ -9,83 +9,38 @@ export class SqlWorkflowJournalStore {
|
|
|
9
9
|
this.db = db;
|
|
10
10
|
this.resumeClaimTtlMs = opts?.resumeClaimTtlMs ?? DEFAULT_RESUME_CLAIM_TTL_MS;
|
|
11
11
|
}
|
|
12
|
-
/** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
|
|
13
12
|
q(tidb, pg) {
|
|
14
13
|
return this.db.dialect === "tidb" ? tidb : pg;
|
|
15
14
|
}
|
|
16
|
-
/** Record one agent's result. Idempotent per (run_id, ordinal): the PK upsert OVERWRITES (last-write-wins), so a
|
|
17
|
-
* resumed run re-appending its replayed prefix verbatim leaves no duplicate. callKeyOrdinal is the SOLE ordinal
|
|
18
|
-
* source (core's parser — a malformed key collapses to 0, matching InMemory). Throws on a REAL failure
|
|
19
|
-
* (LOAD-BEARING), EXCEPT an oversize result is skip-journaled (see below) rather than throwing the workflow dead. */
|
|
20
15
|
async append(runId, scope, entry) {
|
|
21
|
-
// codex R10 定谳:result 列=TEXT(PG)/MEDIUMTEXT(TiDB),存的是 stringify 输出——NUL/lone surrogate
|
|
22
|
-
// 在输出里恒为 \uXXXX 六字符**转义形**(纯 ASCII),TEXT 列可精确存取;22P05 只咬 ::jsonb cast。
|
|
23
|
-
// 故 journal 无需任何 NUL 守卫:直存=逐位回放(R8 skip/R9 invalidate 均为过度修,已撤——那会把
|
|
24
|
-
// 可精确回放的完成结果作废成不必要的活跑重执行,外部副作用可能重复)。
|
|
25
16
|
const serialized = JSON.stringify(entry.result);
|
|
26
|
-
// TiDB rejects a row over the ~6 MiB txn-entry-size-limit ("entry too large"); a large agent TaskResult would
|
|
27
|
-
// make this LOAD-BEARING append THROW + kill the workflow — and the PG twin (no such limit) would NOT, a
|
|
28
|
-
// dialect divergence. DEGRADE identically on both: skip-journal an oversize result (no-op, never insert/throw)
|
|
29
|
-
// → its ordinal isn't cached, so a resume re-runs that one agent live (resume is an optimization).
|
|
30
17
|
if (oversizeJournalResult(serialized))
|
|
31
18
|
return;
|
|
32
19
|
await this.db.query(this.q("INSERT INTO workflow_journal (run_id, ordinal, scope, call_key, result, created_at_ms) VALUES (?,?,?,?,?,?) " +
|
|
33
|
-
// scope NOT in the UPDATE: a re-append keeps the FIRST writer's scope (fail-closed — a re-append must never
|
|
34
|
-
// reassign tenancy). The owner is the sole writer + runId is unique per scope, so this never fires
|
|
35
|
-
// cross-scope in practice; the secure choice over matching InMemory's last-write-wins on scope.
|
|
36
20
|
"ON DUPLICATE KEY UPDATE call_key = VALUES(call_key), result = VALUES(result)", "INSERT INTO workflow_journal (run_id, ordinal, scope, call_key, result, created_at_ms) VALUES ($1,$2,$3,$4,$5,$6) " +
|
|
37
21
|
"ON CONFLICT (run_id, ordinal) DO UPDATE SET call_key = EXCLUDED.call_key, result = EXCLUDED.result"), [runId, callKeyOrdinal(entry.callKey), scope, entry.callKey, serialized, Date.now()]);
|
|
38
22
|
}
|
|
39
|
-
/** Entries for `runId` IF its recorded scope === `scope`, ASCENDING by ordinal; otherwise EMPTY. CORE-9 audit
|
|
40
|
-
* BLOCKER: scope in the WHERE (twin of WorkflowRunStore) — the LLM controls `resumeFromRunId`, so a cross-scope
|
|
41
|
-
* runId resolves to an empty journal (resume safely diverges to live, never discloses another tenant's results).
|
|
42
|
-
* Each row's `result` is the persisted JSON.stringify(TaskResult) → JSON.parse back. Throws on failure (LOAD-BEARING). */
|
|
43
23
|
async load(runId, scope) {
|
|
44
24
|
const { rows } = await this.db.query(this.q("SELECT call_key, result FROM workflow_journal WHERE run_id = ? AND scope = ? ORDER BY ordinal ASC", "SELECT call_key, result FROM workflow_journal WHERE run_id = $1 AND scope = $2 ORDER BY ordinal ASC"), [runId, scope]);
|
|
45
25
|
return rows.map((r) => ({ callKey: String(r.call_key), result: JSON.parse(String(r.result)) }));
|
|
46
26
|
}
|
|
47
|
-
/** [1412] locator 云形(core 1.353 WorkflowJournalStore.locator additive):SQL 店的 journal 坐标=
|
|
48
|
-
* [1402] HTTP 读面路由(file 店返 jsonl 路径=CC 本地形;此处返云形,core 铸 diagnostics 教句时单源引用)。 */
|
|
49
27
|
locator(runId, _scope) {
|
|
50
28
|
return `/v1/workflows/${runId}/journal`;
|
|
51
29
|
}
|
|
52
|
-
/** codex R1-H2:分页投影读——LIMIT/OFFSET 界行数,字节门界单行字节(超限行不取 result 全文,只回字节数;
|
|
53
|
-
* 读面标 truncated)。ORDER BY ordinal 与 load 同轴。
|
|
54
|
-
* 🔴 param ORDER differs by dialect (see file header): TiDB's `?`s bind in text order (maxResultBytes first,
|
|
55
|
-
* inside the CASE); PG's `$n` are positional, so its array keeps runId/scope first. Never unify — a swap
|
|
56
|
-
* silently mis-binds the OTHER dialect's placeholders. */
|
|
57
30
|
async loadPage(runId, scope, opts) {
|
|
58
31
|
const params = this.db.dialect === "tidb"
|
|
59
32
|
? [opts.maxResultBytes, runId, scope, opts.limit, opts.offset]
|
|
60
33
|
: [runId, scope, opts.maxResultBytes, opts.limit, opts.offset];
|
|
61
|
-
const { rows } = await this.db.query(this.q("SELECT call_key, CASE WHEN LENGTH(result) > ? THEN NULL ELSE result END AS result_json, LENGTH(result) AS result_bytes FROM workflow_journal WHERE run_id = ? AND scope = ? ORDER BY ordinal ASC LIMIT ? OFFSET ?",
|
|
62
|
-
// codex R3: PG's LENGTH(text) is CHARACTER count (TiDB's LENGTH is byte count) — a multi-byte UTF-8 row
|
|
63
|
-
// would slip past the byte-budget door on plain LENGTH; OCTET_LENGTH keeps this the byte count PG-side
|
|
64
|
-
// too (real dual-DB-suite-confirmed anti-drift).
|
|
65
|
-
"SELECT call_key, CASE WHEN OCTET_LENGTH(result) > $3 THEN NULL ELSE result END AS result_json, OCTET_LENGTH(result) AS result_bytes FROM workflow_journal WHERE run_id = $1 AND scope = $2 ORDER BY ordinal ASC LIMIT $4 OFFSET $5"), params);
|
|
34
|
+
const { rows } = await this.db.query(this.q("SELECT call_key, CASE WHEN LENGTH(result) > ? THEN NULL ELSE result END AS result_json, LENGTH(result) AS result_bytes FROM workflow_journal WHERE run_id = ? AND scope = ? ORDER BY ordinal ASC LIMIT ? OFFSET ?", "SELECT call_key, CASE WHEN OCTET_LENGTH(result) > $3 THEN NULL ELSE result END AS result_json, OCTET_LENGTH(result) AS result_bytes FROM workflow_journal WHERE run_id = $1 AND scope = $2 ORDER BY ordinal ASC LIMIT $4 OFFSET $5"), params);
|
|
66
35
|
return rows.map((r) => ({ callKey: String(r.call_key), resultJson: r.result_json === null ? null : String(r.result_json), resultBytes: Number(r.result_bytes) }));
|
|
67
36
|
}
|
|
68
|
-
/** GC — purge ALL journal entries for a run (the run reaper calls this once the run is terminal + retained). Scoped
|
|
69
|
-
* by run_id alone; idempotent (affected-row count). Mirrors the resume-anchor store's deleteBySession. */
|
|
70
37
|
async deleteByRun(runId) {
|
|
71
38
|
const res = await this.db.query(this.q("DELETE FROM workflow_journal WHERE run_id = ?", "DELETE FROM workflow_journal WHERE run_id = $1"), [runId]);
|
|
72
39
|
return res.affected;
|
|
73
40
|
}
|
|
74
|
-
/** RB-242 / WF2([1981] core 拍板 a 形,签名逐字)—— cross-replica resume admission lease。
|
|
75
|
-
*
|
|
76
|
-
* 语义(bake-store `idem_key UNIQUE` 先例):PK (source_run_id, scope) 上的原子赢或观察。四步,每步
|
|
77
|
-
* 单语句原子,并发交叉在任一步都收敛到「恰一个持有者」:
|
|
78
|
-
* ① 抢空位:INSERT..DO NOTHING / ON DUP KEY 无操作 —— affected=1 即赢;
|
|
79
|
-
* ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的 claimed_at_ms 刷新;
|
|
80
|
-
* ③ TTL 崩溃兜底接管:claimed_at_ms < now-ttl 守卫下的原子改持有者(engine 终态会显式释放,
|
|
81
|
-
* 走到这步=上一持有 engine 崩了没释放;两层分工见 SqlWorkflowJournalStoreOptions 注);
|
|
82
|
-
* ④ 都没赢 ⇒ 读在位者返 {granted:false, holder}(holder 进 engine 的拒绝文案供归因)。 */
|
|
83
41
|
async resumeClaim(input) {
|
|
84
42
|
const now = Date.now();
|
|
85
|
-
const ins = await this.db.query(this.q(
|
|
86
|
-
// INSERT IGNORE(非 ON DUP KEY 无操作形):TiDB 对「无变化的 DUP KEY UPDATE」affected 报 1
|
|
87
|
-
// (MySQL 报 0)——真双库跑出来的方言差;IGNORE 形两家都在冲突时报 0。
|
|
88
|
-
"INSERT IGNORE INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES (?, ?, ?, ?)", "INSERT INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES ($1, $2, $3, $4) ON CONFLICT (source_run_id, scope) DO NOTHING"), [input.sourceRunId, input.scope, input.newRunId, now]);
|
|
43
|
+
const ins = await this.db.query(this.q("INSERT IGNORE INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES (?, ?, ?, ?)", "INSERT INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES ($1, $2, $3, $4) ON CONFLICT (source_run_id, scope) DO NOTHING"), [input.sourceRunId, input.scope, input.newRunId, now]);
|
|
89
44
|
if (ins.affected === 1)
|
|
90
45
|
return { granted: true };
|
|
91
46
|
const refresh = await this.db.query(this.q("UPDATE workflow_resume_claim SET claimed_at_ms = ? WHERE source_run_id = ? AND scope = ? AND new_run_id = ?", "UPDATE workflow_resume_claim SET claimed_at_ms = $1 WHERE source_run_id = $2 AND scope = $3 AND new_run_id = $4"), [now, input.sourceRunId, input.scope, input.newRunId]);
|
|
@@ -95,36 +50,25 @@ export class SqlWorkflowJournalStore {
|
|
|
95
50
|
if (takeover.affected >= 1)
|
|
96
51
|
return { granted: true };
|
|
97
52
|
const holder = await this.db.query(this.q("SELECT new_run_id FROM workflow_resume_claim WHERE source_run_id = ? AND scope = ?", "SELECT new_run_id FROM workflow_resume_claim WHERE source_run_id = $1 AND scope = $2"), [input.sourceRunId, input.scope]);
|
|
98
|
-
// 行在①-③间被释放的窄窗:holder 读空 ⇒ 如实返 denied 无 holder(engine 下一次重试会在①赢)。
|
|
99
53
|
const row = holder.rows[0];
|
|
100
54
|
const holderId = row?.new_run_id === undefined ? undefined : String(row.new_run_id);
|
|
101
|
-
// ②的 UPDATE 在 MySQL 缺省协议下只数**被改变**的行——同毫秒重入(claimed_at_ms 未变)会 affected=0
|
|
102
|
-
// 掉到这里;持有者==自己仍是 granted(幂等重入语义不依赖 affected 的方言细节)。
|
|
103
55
|
if (holderId === input.newRunId)
|
|
104
56
|
return { granted: true };
|
|
105
57
|
return { granted: false, holder: holderId };
|
|
106
58
|
}
|
|
107
|
-
/** 只有 holder 能释放(new_run_id 守卫)—— engine 在 newRunId 终态/放弃时调;非 holder 调用=no-op。 */
|
|
108
59
|
async releaseResumeClaim(input) {
|
|
109
60
|
await this.db.query(this.q("DELETE FROM workflow_resume_claim WHERE source_run_id = ? AND scope = ? AND new_run_id = ?", "DELETE FROM workflow_resume_claim WHERE source_run_id = $1 AND scope = $2 AND new_run_id = $3"), [input.sourceRunId, input.scope, input.newRunId]);
|
|
110
61
|
}
|
|
111
|
-
/** GC — time-based sweep: purge journal entries older than `maxAgeMs` (created_at_ms < now - maxAgeMs). The
|
|
112
|
-
* per-run deleteByRun has no production caller (a run-store reap gives no per-run hook), so this bounded sweep
|
|
113
|
-
* — wired into the service reaper — is what keeps the heaviest table (TaskResult-bearing) from growing without
|
|
114
|
-
* bound. A resume of a journal older than the retention window simply re-runs live (resume is an optimization).
|
|
115
|
-
* Idempotent; returns the rows purged. */
|
|
116
62
|
async reapExpired(now, maxAgeMs) {
|
|
117
63
|
const res = await this.db.query(this.q("DELETE FROM workflow_journal WHERE created_at_ms < ?", "DELETE FROM workflow_journal WHERE created_at_ms < $1"), [now - maxAgeMs]);
|
|
118
64
|
return res.affected;
|
|
119
65
|
}
|
|
120
66
|
}
|
|
121
|
-
/** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */
|
|
122
67
|
export class TiDBWorkflowJournalStore extends SqlWorkflowJournalStore {
|
|
123
68
|
constructor(pool, opts) {
|
|
124
69
|
super(mysqlDriver(pool), opts);
|
|
125
70
|
}
|
|
126
71
|
}
|
|
127
|
-
/** PostgreSQL binding — historical class name + ctor shape preserved. */
|
|
128
72
|
export class PgWorkflowJournalStore extends SqlWorkflowJournalStore {
|
|
129
73
|
constructor(pool, opts) {
|
|
130
74
|
super(pgDriver(pool), opts);
|
|
@@ -2,19 +2,8 @@ import { summarizeWorkflowRun, isTerminalWorkflowStatus, WorkflowRunStoreError }
|
|
|
2
2
|
import { foldKeyFamily, MAX_PENDING_PER_SESSION, PURGE_FENCE_MS, SERVED_FENCE_MS, } from "../orchestration/workflow-completion-inbox.js";
|
|
3
3
|
import { mysqlDriver, pgDriver } from "./sql-driver.js";
|
|
4
4
|
import { isDupKeyError } from "./sql-errors.js";
|
|
5
|
-
/** Row cap guard: a run blob beyond this is NOT stored on update (the prior revision stays) — TiDB's
|
|
6
|
-
* ~6 MiB txn-entry limit would otherwise throw the (best-effort) progress write into the workflow loop.
|
|
7
|
-
* `put` (create, small) still throws on a real failure. Mirrors the journal's oversize skip-degrade. */
|
|
8
5
|
export const MAX_RUN_BLOB_BYTES = 4 * 1024 * 1024;
|
|
9
6
|
const onWarnType = (msg, meta) => void [msg, meta];
|
|
10
|
-
/** Review fix (1.108, lens④ HIGH): an oversize blob must NOT fail the update outright — core's persist chain
|
|
11
|
-
* treats `false` as a CAS loss and does NOT advance its tracked rev, so once a run outgrows the cap EVERY later
|
|
12
|
-
* write (including the TERMINAL one) keeps failing → the durable row is stuck `running` forever and the 24h
|
|
13
|
-
* orphan sweep finalizes a genuinely-completed run as `abandoned`. Instead degrade the PAYLOAD, not the write:
|
|
14
|
-
* step 1 truncate the two unbounded strings (result/error); step 2 drop the heavy arrays (phases/agents/groups)
|
|
15
|
-
* with an explicit marker. Returns null only when even the skeleton is oversize (physics — caller keeps the
|
|
16
|
-
* prior revision, matching the old behavior for that corner). Dialect-neutral (pure JS, no SQL) — shared
|
|
17
|
-
* verbatim by both twins. */
|
|
18
7
|
export function slimOversizeRun(run, maxBytes) {
|
|
19
8
|
const MARK = "…[truncated: run blob exceeded the store cap]";
|
|
20
9
|
const cut = (s) => s.slice(0, 4000) + MARK;
|
|
@@ -30,9 +19,6 @@ export function slimOversizeRun(run, maxBytes) {
|
|
|
30
19
|
blob = JSON.stringify(step2);
|
|
31
20
|
return Buffer.byteLength(blob) <= maxBytes ? blob : null;
|
|
32
21
|
}
|
|
33
|
-
/** Dual-dialect durable `WorkflowRunStore`. See the file header for the dialect-delta ledger.
|
|
34
|
-
* `onWarn` (optional, same shape as the completion-inbox's {@link InboxWarn}) is the C5 trace channel for
|
|
35
|
-
* `update`'s oversize-slim degrade (see there) — omit it and construction/behavior is unchanged (additive). */
|
|
36
22
|
export class SqlWorkflowRunStore {
|
|
37
23
|
db;
|
|
38
24
|
onWarn;
|
|
@@ -40,18 +26,15 @@ export class SqlWorkflowRunStore {
|
|
|
40
26
|
this.db = db;
|
|
41
27
|
this.onWarn = onWarn;
|
|
42
28
|
}
|
|
43
|
-
/** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
|
|
44
29
|
q(tidb, pg) {
|
|
45
30
|
return this.db.dialect === "tidb" ? tidb : pg;
|
|
46
31
|
}
|
|
47
32
|
async put(id, run) {
|
|
48
|
-
const stored = { ...run, id, rev: run.rev ?? 0 };
|
|
33
|
+
const stored = { ...run, id, rev: run.rev ?? 0 };
|
|
49
34
|
try {
|
|
50
35
|
await this.db.query(this.q("INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms, ended_at_ms) VALUES (?,?,?,?,?,?,?)", "INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms, ended_at_ms) VALUES ($1,$2,$3,$4,$5,$6,$7)"), [id, run.scope, run.status, JSON.stringify(stored), stored.rev, run.createdAt, run.endedAt ?? null]);
|
|
51
36
|
}
|
|
52
37
|
catch (e) {
|
|
53
|
-
// create-once error every backend throws (contract) — the dialect classification has a single
|
|
54
|
-
// owner (`sql-errors.ts`, A-032 P1-①); the old local form read only mysql2's string `code`.
|
|
55
38
|
if (isDupKeyError(this.db.dialect, e)) {
|
|
56
39
|
throw new WorkflowRunStoreError("workflow_run.already_exists", "workflow run id already exists");
|
|
57
40
|
}
|
|
@@ -64,36 +47,24 @@ export class SqlWorkflowRunStore {
|
|
|
64
47
|
if (!r)
|
|
65
48
|
return null;
|
|
66
49
|
const run = JSON.parse(String(r.run));
|
|
67
|
-
run.rev = Number(r.rev);
|
|
50
|
+
run.rev = Number(r.rev);
|
|
68
51
|
return run;
|
|
69
52
|
}
|
|
70
53
|
async update(id, scope, run, expect) {
|
|
71
|
-
// id/scope immutable in the stored payload (InMemory parity: key + partition are authoritative). The blob's
|
|
72
|
-
// rev is NOT trusted (see get's overlay) — write it as-is; the column bump below is the real OCC state.
|
|
73
54
|
let blob = JSON.stringify({ ...run, id, scope });
|
|
74
55
|
if (Buffer.byteLength(blob) > MAX_RUN_BLOB_BYTES) {
|
|
75
|
-
// Oversize degrade (1.108 review fix): slim the payload so the write — critically the TERMINAL one —
|
|
76
|
-
// still lands (a `false` here would never be retried with a smaller payload; see slimOversizeRun).
|
|
77
|
-
// C5 (2026-07-31): the degrade drops real data (phases/agents/groups → []) — that must leave a trace
|
|
78
|
-
// reaching whoever can act on it, not just a same-shaped `true` return. `onWarn` is optional so a
|
|
79
|
-
// caller that hasn't wired a sink yet keeps building/running unchanged.
|
|
80
56
|
this.onWarn?.("workflow_run_blob_oversize_slimmed", { id, scope, fullBytes: Buffer.byteLength(blob), capBytes: MAX_RUN_BLOB_BYTES });
|
|
81
57
|
const slim = slimOversizeRun({ ...run, id, scope }, MAX_RUN_BLOB_BYTES);
|
|
82
58
|
if (slim === null)
|
|
83
|
-
return false;
|
|
59
|
+
return false;
|
|
84
60
|
blob = slim;
|
|
85
61
|
}
|
|
86
62
|
const res = await this.db.query(this.q(`UPDATE workflow_run SET run = ?, rev = rev + 1, status = ?, ended_at_ms = ? WHERE id = ? AND scope = ?${expect !== undefined ? " AND rev = ?" : ""}`, `UPDATE workflow_run SET run = $1, rev = rev + 1, status = $2, ended_at_ms = $3 WHERE id = $4 AND scope = $5${expect !== undefined ? " AND rev = $6" : ""}`), expect !== undefined
|
|
87
63
|
? [blob, run.status, run.endedAt ?? null, id, scope, expect.rev]
|
|
88
64
|
: [blob, run.status, run.endedAt ?? null, id, scope]);
|
|
89
|
-
return res.affected === 1;
|
|
65
|
+
return res.affected === 1;
|
|
90
66
|
}
|
|
91
67
|
async listByScope(scope, opts) {
|
|
92
|
-
// 🔴 genuinely-divergent CONSTRUCTION (not just SQL text): PG's `$n` placeholders are POSITIONAL and must
|
|
93
|
-
// track `params.length` as each optional clause is appended, while TiDB's `?` is positionless — a single
|
|
94
|
-
// `q()` pair can't express that, so the where/limit assembly forks per dialect. The FILTER logic itself
|
|
95
|
-
// (status → session → limit, session dropping unmatched/absent-key rows via JSON extraction, limit applied
|
|
96
|
-
// last) is identical; only the placeholder bookkeeping differs.
|
|
97
68
|
const params = [scope];
|
|
98
69
|
let where;
|
|
99
70
|
let limit = "";
|
|
@@ -104,9 +75,6 @@ export class SqlWorkflowRunStore {
|
|
|
104
75
|
params.push(opts.status);
|
|
105
76
|
}
|
|
106
77
|
if (opts?.session !== undefined) {
|
|
107
|
-
// core 1.368([1516]②)session 下推臂:limit 前应用、严格等值、无字段行剔除(JSON path 缺席 →
|
|
108
|
-
// NULL → `= ?` 恒假 ✓)。JSON 提取而非投影列:对存量行零迁移直接生效;行量有 retention 界,
|
|
109
|
-
// 与本函数固有的全行 JSON.parse 同量级。
|
|
110
78
|
where += " AND JSON_UNQUOTE(JSON_EXTRACT(run, '$.originatingSessionId')) = ?";
|
|
111
79
|
params.push(opts.session);
|
|
112
80
|
}
|
|
@@ -122,7 +90,6 @@ export class SqlWorkflowRunStore {
|
|
|
122
90
|
where += ` AND status = $${params.length}`;
|
|
123
91
|
}
|
|
124
92
|
if (opts?.session !== undefined) {
|
|
125
|
-
// core 1.368([1516]②)session 下推臂(TiDB twin 同案注释):JSON 提取、limit 前、缺席键 → NULL → 拒 ✓
|
|
126
93
|
params.push(opts.session);
|
|
127
94
|
where += ` AND run::json->>'originatingSessionId' = $${params.length}`;
|
|
128
95
|
}
|
|
@@ -131,7 +98,6 @@ export class SqlWorkflowRunStore {
|
|
|
131
98
|
limit = ` LIMIT $${params.length}`;
|
|
132
99
|
}
|
|
133
100
|
}
|
|
134
|
-
// Sort in SQL (newest first), summarize via core's SHARED projection (anti-drift).
|
|
135
101
|
const { rows } = await this.db.query(`SELECT run, rev FROM workflow_run WHERE ${where} ORDER BY created_at_ms DESC, id DESC${limit}`, params);
|
|
136
102
|
return rows.map((r) => {
|
|
137
103
|
const run = JSON.parse(String(r.run));
|
|
@@ -139,11 +105,6 @@ export class SqlWorkflowRunStore {
|
|
|
139
105
|
return summarizeWorkflowRun(run);
|
|
140
106
|
});
|
|
141
107
|
}
|
|
142
|
-
/** Retention (the store never auto-purges; reap is explicit): the contract's `reap` is scope-keyed and the
|
|
143
|
-
* contract deliberately has NO cross-scope enumeration — but the SQL twin CAN enumerate (DISTINCT on the
|
|
144
|
-
* extracted scope column), which is exactly what a periodic deployment sweep needs. Walks every scope through
|
|
145
|
-
* the contract-faithful `reap` (terminal-only; running rows are the orphan-grace sweep's business). Service
|
|
146
|
-
* extension, not a contract member. Byte-identical text on both dialects (no params) — no q() needed. */
|
|
147
108
|
async reapAllScopes(now, opts) {
|
|
148
109
|
const { rows } = await this.db.query("SELECT DISTINCT scope FROM workflow_run");
|
|
149
110
|
let total = 0;
|
|
@@ -153,9 +114,7 @@ export class SqlWorkflowRunStore {
|
|
|
153
114
|
}
|
|
154
115
|
async reap(scope, now, opts) {
|
|
155
116
|
if (opts?.maxAgeMs === undefined && opts?.keep === undefined)
|
|
156
|
-
return 0;
|
|
157
|
-
// Cheap columns only; terminal-set + keep-N semantics computed in JS to match InMemory EXACTLY
|
|
158
|
-
// (isTerminalWorkflowStatus is core's — no status list duplicated into SQL).
|
|
117
|
+
return 0;
|
|
159
118
|
const { rows } = await this.db.query(this.q("SELECT id, status, created_at_ms, ended_at_ms FROM workflow_run WHERE scope = ? ORDER BY created_at_ms DESC, id DESC", "SELECT id, status, created_at_ms, ended_at_ms FROM workflow_run WHERE scope = $1 ORDER BY created_at_ms DESC, id DESC"), [scope]);
|
|
160
119
|
const terminal = rows.filter((r) => isTerminalWorkflowStatus(String(r.status)));
|
|
161
120
|
const doomed = [];
|
|
@@ -169,16 +128,12 @@ export class SqlWorkflowRunStore {
|
|
|
169
128
|
}
|
|
170
129
|
if (doomed.length === 0)
|
|
171
130
|
return 0;
|
|
172
|
-
// Bulk delete-by-id-set: TiDB expands a dynamic `IN (?,?,…)` (one placeholder per id, spread params);
|
|
173
|
-
// PG binds the whole set as one array via `= ANY($2::text[])`. Same result set, different statement shape.
|
|
174
131
|
const res = await this.db.query(this.q(`DELETE FROM workflow_run WHERE scope = ? AND id IN (${doomed.map(() => "?").join(",")})`, "DELETE FROM workflow_run WHERE scope = $1 AND id = ANY($2::text[])"), this.db.dialect === "tidb" ? [scope, ...doomed] : [scope, doomed]);
|
|
175
132
|
return res.affected;
|
|
176
133
|
}
|
|
177
134
|
}
|
|
178
|
-
/** The SQL fence row kinds. `run_id = ''` on a purge fence (session-wide). */
|
|
179
135
|
const KIND_SERVED = "served";
|
|
180
136
|
const KIND_PURGE = "purge";
|
|
181
|
-
/** Dual-dialect `WorkflowCompletionInbox`. See the file header for the dialect-delta ledger. */
|
|
182
137
|
export class SqlWorkflowCompletionInbox {
|
|
183
138
|
db;
|
|
184
139
|
onWarn;
|
|
@@ -195,43 +150,26 @@ export class SqlWorkflowCompletionInbox {
|
|
|
195
150
|
async enqueue(entry) {
|
|
196
151
|
const now = Date.now();
|
|
197
152
|
await this.sweepFences(now);
|
|
198
|
-
// Served fence FIRST (per-runId — cross-replica: a poll served on ANOTHER replica fences us here).
|
|
199
153
|
const served = await this.db.query(this.q("SELECT 1 FROM workflow_inbox_fence WHERE session_id = ? AND kind = ? AND run_id IN (?, ?) AND until_ms >= ?", "SELECT 1 FROM workflow_inbox_fence WHERE session_id = $1 AND kind = $2 AND run_id IN ($3, $4) AND until_ms >= $5"), [entry.sessionId, KIND_SERVED, entry.runId, foldKeyFamily(entry.runId), now]);
|
|
200
154
|
if (served.rows.length > 0) {
|
|
201
155
|
this.onWarn?.("workflow_completion_enqueue_after_poll_served", { sessionId: entry.sessionId, runId: entry.runId });
|
|
202
156
|
return;
|
|
203
157
|
}
|
|
204
|
-
// Purge fence: owner-scoped exactly like the File impl (owner_known=0 → conservative fence-all;
|
|
205
|
-
// owner_known=1 → fences only the SAME owner; a different owner = a new legitimate claimant, passes).
|
|
206
158
|
const purge = await this.db.query(this.q("SELECT owner, owner_known FROM workflow_inbox_fence WHERE session_id = ? AND kind = ? AND run_id = '' AND until_ms >= ?", "SELECT owner, owner_known FROM workflow_inbox_fence WHERE session_id = $1 AND kind = $2 AND run_id = '' AND until_ms >= $3"), [entry.sessionId, KIND_PURGE, now]);
|
|
207
159
|
const pf = purge.rows[0];
|
|
208
160
|
if (pf && (Number(pf.owner_known) === 0 || (pf.owner === null ? entry.owner === null : String(pf.owner) === entry.owner))) {
|
|
209
161
|
this.onWarn?.("workflow_completion_enqueue_after_purge", { sessionId: entry.sessionId, runId: entry.runId });
|
|
210
162
|
return;
|
|
211
163
|
}
|
|
212
|
-
// Atomic move-to-tail (1.108 review fix — the prior DELETE-then-INSERT had a crash window that dropped the
|
|
213
|
-
// old entry; self-healing via the notify journal, but atomic is free here). Genuinely divergent statement:
|
|
214
|
-
// TiDB `REPLACE` = delete-conflicting-row + insert atomically, and omitting `seq` mints a fresh
|
|
215
|
-
// AUTO_INCREMENT = tail. PG has no REPLACE, so the twin is an upsert that explicitly re-mints `seq` off the
|
|
216
|
-
// BIGSERIAL's own sequence — same outcome (idempotent-on-runId + move-to-tail in ONE statement).
|
|
217
164
|
await this.db.query(this.q("REPLACE INTO workflow_completion_inbox (session_id, run_id, owner, status, summary, enqueued_at_ms, kind, payload) VALUES (?,?,?,?,?,?,?,?)", "INSERT INTO workflow_completion_inbox (session_id, run_id, owner, status, summary, enqueued_at_ms, kind, payload) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) " +
|
|
218
165
|
"ON CONFLICT (session_id, run_id) DO UPDATE SET seq = nextval(pg_get_serial_sequence('workflow_completion_inbox','seq')), " +
|
|
219
166
|
"owner = EXCLUDED.owner, status = EXCLUDED.status, summary = EXCLUDED.summary, enqueued_at_ms = EXCLUDED.enqueued_at_ms, kind = EXCLUDED.kind, payload = EXCLUDED.payload"), [entry.sessionId, entry.runId, entry.owner, entry.status, entry.summary, entry.enqueuedAt, entry.kind ?? null, entry.payload ?? null]);
|
|
220
|
-
// 1.108 review (lens① MAJOR ×2): the File impl's check-then-set is atomic on one event loop; here the
|
|
221
|
-
// fence check and the INSERT are two network statements, so a fence written IN BETWEEN (a poll served on
|
|
222
|
-
// another replica, or a racing purge) would leave fence + pending row coexisting → the next drain re-pushes
|
|
223
|
-
// an already-served/purged completion. Close the window by RE-CHECKING after the insert and self-revoking —
|
|
224
|
-
// ordering guarantees the loser is always visible to someone: markTerminalServed/purge write their fence
|
|
225
|
-
// BEFORE their delete, so either their delete kills our row, or our re-check sees their fence.
|
|
226
|
-
// (Residual, documented: a purge racing a NEW claimant's enqueue between fence-write and session-DELETE can
|
|
227
|
-
// still drop that one push — ms window + sessionId reclaim in it is contrived; the WorkflowStatus poll floor
|
|
228
|
-
// + notify-journal recovery cover it. File parity: purge drops the whole session queue there too.)
|
|
229
167
|
const confirm = await this.db.query(this.q("SELECT kind, owner, owner_known FROM workflow_inbox_fence WHERE session_id = ? AND until_ms >= ? AND (run_id IN (?, ?) OR (kind = ? AND run_id = ''))", "SELECT kind, owner, owner_known FROM workflow_inbox_fence WHERE session_id = $1 AND until_ms >= $2 AND (run_id IN ($3, $4) OR (kind = $5 AND run_id = ''))"), [entry.sessionId, now, entry.runId, foldKeyFamily(entry.runId), KIND_PURGE]);
|
|
230
168
|
for (const f of confirm.rows) {
|
|
231
169
|
const isServed = String(f.kind) === KIND_SERVED;
|
|
232
170
|
const purgeFencesUs = !isServed && (Number(f.owner_known) === 0 || (f.owner === null ? entry.owner === null : String(f.owner) === entry.owner));
|
|
233
171
|
if (isServed || purgeFencesUs) {
|
|
234
|
-
await this.ack(entry.sessionId, entry.runId);
|
|
172
|
+
await this.ack(entry.sessionId, entry.runId);
|
|
235
173
|
this.onWarn?.(isServed ? "workflow_completion_enqueue_after_poll_served" : "workflow_completion_enqueue_after_purge", {
|
|
236
174
|
sessionId: entry.sessionId,
|
|
237
175
|
runId: entry.runId,
|
|
@@ -239,21 +177,14 @@ export class SqlWorkflowCompletionInbox {
|
|
|
239
177
|
return;
|
|
240
178
|
}
|
|
241
179
|
}
|
|
242
|
-
// Cap: drop-oldest beyond MAX_PENDING_PER_SESSION (warn per drop, File parity). TiDB requires a LIMIT
|
|
243
|
-
// alongside OFFSET (MySQL-protocol syntax); PG's OFFSET stands alone.
|
|
244
180
|
const over = await this.db.query(this.q("SELECT seq, run_id FROM workflow_completion_inbox WHERE session_id = ? ORDER BY seq DESC LIMIT 100000 OFFSET ?", "SELECT seq, run_id FROM workflow_completion_inbox WHERE session_id = $1 ORDER BY seq DESC OFFSET $2"), [entry.sessionId, MAX_PENDING_PER_SESSION]);
|
|
245
181
|
if (over.rows.length > 0) {
|
|
246
|
-
// Bulk delete-by-seq-set: dynamic `IN (?,?,…)` on TiDB vs array-bound `= ANY($2::bigint[])` on PG.
|
|
247
182
|
await this.db.query(this.q(`DELETE FROM workflow_completion_inbox WHERE session_id = ? AND seq IN (${over.rows.map(() => "?").join(",")})`, "DELETE FROM workflow_completion_inbox WHERE session_id = $1 AND seq = ANY($2::bigint[])"), this.db.dialect === "tidb" ? [entry.sessionId, ...over.rows.map((r) => r.seq)] : [entry.sessionId, over.rows.map((r) => r.seq)]);
|
|
248
183
|
for (const r of over.rows)
|
|
249
184
|
this.onWarn?.("workflow_completion_inbox_overflow", { sessionId: entry.sessionId, droppedRunId: String(r.run_id) });
|
|
250
185
|
}
|
|
251
186
|
}
|
|
252
187
|
async pending(sessionId) {
|
|
253
|
-
// 2026-07-11(同一缺陷的 SQL 半场):filter SERVED-fenced rows like the in-memory
|
|
254
|
-
// inbox does — markTerminalServed's fence-INSERT and ack-DELETE are two statements, and a drain on
|
|
255
|
-
// another replica between them re-emitted an already-served completion (the in-memory pending()
|
|
256
|
-
// filters by servedAt; this brings the SQL twin to the same semantics).
|
|
257
188
|
const { rows } = await this.db.query(this.q("SELECT i.run_id, i.owner, i.status, i.summary, i.enqueued_at_ms, i.kind, i.payload FROM workflow_completion_inbox i " +
|
|
258
189
|
"WHERE i.session_id = ? AND NOT EXISTS (SELECT 1 FROM workflow_inbox_fence f WHERE f.session_id = i.session_id AND f.kind = ? AND f.run_id IN (i.run_id, REGEXP_REPLACE(i.run_id, '#[0-9]+$', '')) AND f.until_ms >= ?) " +
|
|
259
190
|
"ORDER BY i.seq ASC", "SELECT i.run_id, i.owner, i.status, i.summary, i.enqueued_at_ms, i.kind, i.payload FROM workflow_completion_inbox i " +
|
|
@@ -276,7 +207,6 @@ export class SqlWorkflowCompletionInbox {
|
|
|
276
207
|
async purge(sessionId, purgedOwner) {
|
|
277
208
|
const now = Date.now();
|
|
278
209
|
await this.sweepFences(now);
|
|
279
|
-
// Fence FIRST (an enqueue racing the purge must lose), then clear. Upsert = a re-purge refreshes the window.
|
|
280
210
|
await this.db.query(this.q("INSERT INTO workflow_inbox_fence (session_id, kind, run_id, owner, owner_known, until_ms) VALUES (?,?,?,?,?,?) " +
|
|
281
211
|
"ON DUPLICATE KEY UPDATE owner = VALUES(owner), owner_known = VALUES(owner_known), until_ms = VALUES(until_ms)", "INSERT INTO workflow_inbox_fence (session_id, kind, run_id, owner, owner_known, until_ms) VALUES ($1,$2,'',$3,$4,$5) " +
|
|
282
212
|
"ON CONFLICT (session_id, kind, run_id) DO UPDATE SET owner = EXCLUDED.owner, owner_known = EXCLUDED.owner_known, until_ms = EXCLUDED.until_ms"), this.db.dialect === "tidb"
|
|
@@ -291,15 +221,9 @@ export class SqlWorkflowCompletionInbox {
|
|
|
291
221
|
"ON DUPLICATE KEY UPDATE until_ms = VALUES(until_ms)", "INSERT INTO workflow_inbox_fence (session_id, kind, run_id, owner, owner_known, until_ms) VALUES ($1,$2,$3,NULL,0,$4) " +
|
|
292
222
|
"ON CONFLICT (session_id, kind, run_id) DO UPDATE SET until_ms = EXCLUDED.until_ms"), [sessionId, KIND_SERVED, runId, now + SERVED_FENCE_MS]);
|
|
293
223
|
await this.ack(sessionId, runId);
|
|
294
|
-
// design/144 家族 ack(与 in-memory 语义对齐):fleet 帧无 seq 传家族前缀,park 行是 seq 键——
|
|
295
|
-
// 精确 DELETE 必 miss。剥行键尾部 #digits 与传入前缀比对(REGEXP_REPLACE/regexp_replace 语义=foldKeyFamily)。
|
|
296
224
|
await this.db.query(this.q("DELETE FROM workflow_completion_inbox WHERE session_id = ? AND run_id <> ? AND REGEXP_REPLACE(run_id, '#[0-9]+$', '') = ?", "DELETE FROM workflow_completion_inbox WHERE session_id = $1 AND run_id <> $2 AND regexp_replace(run_id, '#[0-9]+$', '') = $3"), [sessionId, runId, runId]);
|
|
297
225
|
}
|
|
298
226
|
}
|
|
299
|
-
/**
|
|
300
|
-
* Dual-dialect `WorkflowNotifyJournalStore` — the crash-safe recovery backlog for at-least-once completion
|
|
301
|
-
* notify. See the file header for the dialect-delta ledger.
|
|
302
|
-
*/
|
|
303
227
|
export class SqlWorkflowNotifyJournalStore {
|
|
304
228
|
db;
|
|
305
229
|
constructor(db) {
|
|
@@ -309,8 +233,6 @@ export class SqlWorkflowNotifyJournalStore {
|
|
|
309
233
|
return this.db.dialect === "tidb" ? tidb : pg;
|
|
310
234
|
}
|
|
311
235
|
async record(input) {
|
|
312
|
-
// Idempotent-on-runId (a second record for the same run — incl. an acked one — is a no-op): TiDB
|
|
313
|
-
// `INSERT IGNORE` vs PG `ON CONFLICT (run_id) DO NOTHING`.
|
|
314
236
|
await this.db.query(this.q("INSERT IGNORE INTO workflow_notify_journal (run_id, scope, acked, source_task_id, principal, created_at_ms) VALUES (?,?,0,?,?,?)", "INSERT INTO workflow_notify_journal (run_id, scope, acked, source_task_id, principal, created_at_ms) VALUES ($1,$2,0,$3,$4,$5) ON CONFLICT (run_id) DO NOTHING"), [input.runId, input.scope, input.sourceTaskId ?? null, input.principal ?? null, input.createdAt]);
|
|
315
237
|
}
|
|
316
238
|
async ack(runId, ackedAt) {
|
|
@@ -327,9 +249,6 @@ export class SqlWorkflowNotifyJournalStore {
|
|
|
327
249
|
createdAt: Number(r.created_at_ms),
|
|
328
250
|
}));
|
|
329
251
|
}
|
|
330
|
-
/** Retention (same sweep as reapAllScopes): ACKED rows are pure history — without this the twin re-opens
|
|
331
|
-
* the unbounded-growth hole the same release closed for workflow_run. Pending rows are NEVER reaped (they
|
|
332
|
-
* are the recovery backlog; the orphan-grace sweep is what retires a stuck pending run). */
|
|
333
252
|
async reapAcked(before) {
|
|
334
253
|
const res = await this.db.query(this.q("DELETE FROM workflow_notify_journal WHERE acked = 1 AND acked_at_ms < ?", "DELETE FROM workflow_notify_journal WHERE acked = 1 AND acked_at_ms < $1"), [before]);
|
|
335
254
|
return res.affected;
|
|
@@ -350,8 +269,6 @@ export class SqlWorkflowNotifyJournalStore {
|
|
|
350
269
|
};
|
|
351
270
|
}
|
|
352
271
|
}
|
|
353
|
-
/** MySQL-protocol (TiDB) bindings — historical class names preserved; `onWarn` is an ADDITIVE optional 2nd
|
|
354
|
-
* ctor arg (existing 1-arg call sites are unaffected — see {@link SqlWorkflowRunStore}'s C5 trace channel). */
|
|
355
272
|
export class TiDBWorkflowRunStore extends SqlWorkflowRunStore {
|
|
356
273
|
constructor(pool, onWarn) {
|
|
357
274
|
super(mysqlDriver(pool), onWarn);
|
|
@@ -367,8 +284,6 @@ export class TiDBWorkflowNotifyJournalStore extends SqlWorkflowNotifyJournalStor
|
|
|
367
284
|
super(mysqlDriver(pool));
|
|
368
285
|
}
|
|
369
286
|
}
|
|
370
|
-
/** PostgreSQL bindings — historical class names preserved; `onWarn` is an ADDITIVE optional 2nd ctor arg
|
|
371
|
-
* (existing 1-arg call sites are unaffected — see {@link SqlWorkflowRunStore}'s C5 trace channel). */
|
|
372
287
|
export class PgWorkflowRunStore extends SqlWorkflowRunStore {
|
|
373
288
|
constructor(pool, onWarn) {
|
|
374
289
|
super(pgDriver(pool), onWarn);
|
|
@@ -1,83 +1,17 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SVC-3 — git-WORKTREE ISOLATION wrapper for the deployment-level `executionEnvFactory` (design/97
|
|
3
|
-
* CORE-6). A higher-order factory that mints a per-AGENT isolated git worktree when (and ONLY
|
|
4
|
-
* when) core threads `ctx.isolation === "worktree"` through the TRUSTED `RunInternals` channel.
|
|
5
|
-
*
|
|
6
|
-
* WHY this lives in service, not core: core ships the env-AGNOSTIC primitive (`addWorktree` / `pruneWorktrees`
|
|
7
|
-
* on the base `ExecutionEnv` seam, plus the `ExecutionEnvFactoryContext.isolation` hint). The env-SPECIFIC
|
|
8
|
-
* bits — which concrete adapter roots an env at the worktree dir, which repo is the operator-trusted base,
|
|
9
|
-
* where the reaper runs — are deployment wiring, so they belong here (铁律 core-service-boundary). We consume
|
|
10
|
-
* the core export; we do NOT reimplement the git plumbing.
|
|
11
|
-
*
|
|
12
|
-
* SHAPE (per the task contract):
|
|
13
|
-
* const factory = withWorktreeIsolation(baseFactory, opts);
|
|
14
|
-
* - ctx.isolation === "worktree": `addWorktree(baseEnvForGit, { repoRoot, sessionId: ctx.sessionId,
|
|
15
|
-
* rootEnvAt, commit? })` → a worktree-rooted env WITH `destroy()` (the Runner calls it on task end →
|
|
16
|
-
* `git worktree remove --force`, which tears down ONLY that worktree, never the base repo).
|
|
17
|
-
* - otherwise: `baseFactory(ctx)` verbatim — the SHARED base env, NO `destroy` (the Runner must never tear
|
|
18
|
-
* the base down). Non-isolated tasks behave exactly as before this wrapper existed.
|
|
19
|
-
*
|
|
20
|
-
* 🔴 TRUST GATE (the whole point of SVC-3): the ONLY input that turns isolation ON is core's
|
|
21
|
-
* `ctx.isolation` — populated from `ctx.agent({ isolation })` via the TRUSTED `RunInternals`, which core
|
|
22
|
-
* guarantees is NEVER sourced from the untrusted `TaskSpec` (see core remote-env.ts §isolation). This wrapper
|
|
23
|
-
* reads NOTHING else from `ctx` to decide isolation, and `repoRoot` comes ENTIRELY from the operator-supplied
|
|
24
|
-
* closure (config), never from any task/`ctx` field — so an untrusted caller can neither flip isolation on a
|
|
25
|
-
* repo it shouldn't touch nor inject a `repoRoot` path to escape the allowed roots. We additionally assert the
|
|
26
|
-
* configured `repoRoot` is contained within an operator-trusted `allowedRoots` set at wrap time (defence in
|
|
27
|
-
* depth: a mis-wired deployment fails loud rather than git-worktree-ing an arbitrary path).
|
|
28
|
-
*
|
|
29
|
-
* 🔴 ISOLATE-ONLY (clay): the runtime NEVER merges a worktree back. The orchestrator script reads each
|
|
30
|
-
* worktree's result (via the worktree dir / the snapshot) and decides verify+merge in USERLAND. We only mint
|
|
31
|
-
* and reap; merge policy is not ours.
|
|
32
|
-
*
|
|
33
|
-
* ⚠️ BOUNDARY CAVEATS (from core's CORE-6 doc — kept here so a future wirer doesn't reintroduce
|
|
34
|
-
* a footgun):
|
|
35
|
-
* - durable-suspend is INCOMPATIBLE with a worktree env: core's prepare-task rejects a NON-remote factory env
|
|
36
|
-
* for a suspendable run (a host/NodeExecutionEnv-rooted worktree is not snapshotable). So an isolated agent
|
|
37
|
-
* MUST run to completion without parking — do NOT combine `isolation:"worktree"` with a suspend/HITL park
|
|
38
|
-
* on the host lane. (On an isolated lane like e2b/k8s the worktree would have to root a REMOTE env; that is
|
|
39
|
-
* a follow-on, see `rootEnvAt` below — host lane is v1.)
|
|
40
|
-
* - `git worktree` isolates the git WORKING COPY, not arbitrary shell access: bash inside the worktree can
|
|
41
|
-
* still reach the host filesystem/network. The worktree is a coordination boundary, NOT a security sandbox
|
|
42
|
-
* — pair it with a real sandbox lane (e2b/k8s/local-docker) when isolation from the host is required.
|
|
43
|
-
* - UNCOMMITTED changes in the base repo are ABSENT in the child worktree (core creates it `--detach` at
|
|
44
|
-
* `HEAD`/`commit`). An isolated agent sees the committed tree only — commit (or stash→apply, in userland)
|
|
45
|
-
* before fanning out if the base's dirty state must be visible.
|
|
46
|
-
* - a worktree orphaned by a process CRASH (the Runner never reached `destroy`) is reaped by
|
|
47
|
-
* {@link pruneWorktrees} — wired into the service reaper (best-effort, userland; core ships no post-kill
|
|
48
|
-
* Runner hook).
|
|
49
|
-
*/
|
|
50
1
|
import path from "node:path";
|
|
51
2
|
import { addWorktree, pruneWorktrees, } from "@sema-agent/core";
|
|
52
|
-
/** Normalize a path for containment checks: resolve to absolute + drop a trailing separator. */
|
|
53
3
|
function norm(p) {
|
|
54
4
|
const r = path.resolve(p);
|
|
55
5
|
return r.length > 1 && r.endsWith(path.sep) ? r.slice(0, -1) : r;
|
|
56
6
|
}
|
|
57
|
-
/** True when `child` is `root` itself or a descendant of it (segment-aware: `/a/b` is NOT under `/a/bc`). */
|
|
58
7
|
function isWithin(root, child) {
|
|
59
8
|
const r = norm(root);
|
|
60
9
|
const c = norm(child);
|
|
61
10
|
if (c === r)
|
|
62
11
|
return true;
|
|
63
12
|
const rel = path.relative(r, c);
|
|
64
|
-
// path.relative returns "" for equal, a non-"../"-leading relative for a descendant, and a "../"-leading
|
|
65
|
-
// (or absolute, on Windows drive change) path for an outside/sibling target.
|
|
66
13
|
return rel.length > 0 && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
67
14
|
}
|
|
68
|
-
/**
|
|
69
|
-
* SERIALIZE git worktree ADMIN ops per repo (MODULE-level, keyed by normalized repoRoot). `git worktree
|
|
70
|
-
* add`/`remove`/`prune` all mutate the same repo's `.git/worktrees` registry; running any two CONCURRENTLY
|
|
71
|
-
* against one repo races. Two callers share this registry:
|
|
72
|
-
* - the isolation wrapper's mint (`add`) and teardown (`remove`) legs;
|
|
73
|
-
* - the service reaper's `prune` leg (#62 svc3 race: prune used to bypass the wrapper's closure-local
|
|
74
|
-
* lock entirely — the adverse arm is prune observing a mid-mint "registered, dir not yet populated"
|
|
75
|
-
* window and DEREGISTERING the half-minted worktree, handing the task a dir git no longer tracks and
|
|
76
|
-
* making its eventual `git worktree remove` fail loud on a healthy run).
|
|
77
|
-
* A promise-chain mutex per repoRoot: each op runs after the prior one SETTLES (success OR failure — never
|
|
78
|
-
* deadlock). Git ops are ~ms and serial-safe; agents still run fully parallel. The map is bounded by the
|
|
79
|
-
* number of distinct configured repoRoots (operator config, typically 1) — no eviction needed.
|
|
80
|
-
*/
|
|
81
15
|
const gitAdminLocks = new Map();
|
|
82
16
|
function serializeGitAdmin(repoRoot, fn) {
|
|
83
17
|
const key = norm(repoRoot);
|
|
@@ -85,38 +19,21 @@ function serializeGitAdmin(repoRoot, fn) {
|
|
|
85
19
|
gitAdminLocks.set(key, run.then(() => undefined, () => undefined));
|
|
86
20
|
return run;
|
|
87
21
|
}
|
|
88
|
-
/**
|
|
89
|
-
* Wrap a deployment's `executionEnvFactory` so an agent with `ctx.isolation === "worktree"` runs in its own
|
|
90
|
-
* detached git worktree. See the file header for the full contract + trust gate + caveats.
|
|
91
|
-
*
|
|
92
|
-
* Throws at WRAP time (fail-loud, not faked) if `repoRoot` is not contained within `allowedRoots` — a
|
|
93
|
-
* mis-wired deployment must not silently git-worktree an arbitrary path.
|
|
94
|
-
*/
|
|
95
22
|
export function withWorktreeIsolation(baseFactory, opts) {
|
|
96
23
|
const repoRoot = norm(opts.repoRoot);
|
|
97
24
|
const allowedRoots = (opts.allowedRoots?.length ? opts.allowedRoots : [repoRoot]).map(norm);
|
|
98
|
-
// Trust gate (defence in depth): the configured repoRoot must live inside an operator-trusted root. This is
|
|
99
|
-
// a CONFIG invariant (repoRoot is never a task input), so a violation = mis-wire → fail loud at boot.
|
|
100
25
|
if (!allowedRoots.some((root) => isWithin(root, repoRoot))) {
|
|
101
26
|
throw new Error(`withWorktreeIsolation: repoRoot ${repoRoot} is not within any allowedRoots [${allowedRoots.join(", ")}] — refusing to git-worktree an untrusted path`);
|
|
102
27
|
}
|
|
103
28
|
const serialize = (fn) => serializeGitAdmin(repoRoot, fn);
|
|
104
29
|
return async (ctx) => {
|
|
105
|
-
// TRUST GATE: the ONLY trigger is core's RunInternals-sourced ctx.isolation. We read nothing else from ctx
|
|
106
|
-
// (and never anything from a TaskSpec) to decide isolation — see file header.
|
|
107
30
|
if (ctx.isolation !== "worktree") {
|
|
108
|
-
// Non-isolated: the SHARED base env, verbatim, NO destroy attached → the Runner never tears the base
|
|
109
|
-
// down (core's hasDestroy() returns false for a plain base env). Behaviour identical to no wrapper.
|
|
110
31
|
return baseFactory(ctx);
|
|
111
32
|
}
|
|
112
|
-
// S3([2113] core 裁定的 server 半场):keep-if-changed 需要在保留分支跑 inner teardown(LSP evict
|
|
113
|
-
// 链等)而不跑 `git worktree remove` —— core addWorktree 把两者焊成一个复合 destroy 拆不开,所以在
|
|
114
|
-
// rootEnvAt 铸造点捕获 inner env 的**原** destroy 引用(per-mint 变量;core 稍后才把复合 destroy
|
|
115
|
-
// 装回同一对象上)。
|
|
116
33
|
let innerDestroy;
|
|
117
34
|
const { env, worktreeDir } = await serialize(() => addWorktree(opts.baseEnvForGit, {
|
|
118
35
|
repoRoot,
|
|
119
|
-
sessionId: ctx.sessionId,
|
|
36
|
+
sessionId: ctx.sessionId,
|
|
120
37
|
rootEnvAt: async (dir) => {
|
|
121
38
|
const inner = await opts.rootEnvAt(dir);
|
|
122
39
|
const d = inner.destroy;
|
|
@@ -126,8 +43,6 @@ export function withWorktreeIsolation(baseFactory, opts) {
|
|
|
126
43
|
},
|
|
127
44
|
...(opts.commit ? { commit: opts.commit } : {}),
|
|
128
45
|
}));
|
|
129
|
-
// keep-if-changed 判定基点:mint 后立刻解析 worktree 的 HEAD(=detach 基点)。判定轴与 core Agent
|
|
130
|
-
// 车道逐字同款(subagent.js finish):dirty 文件 >0 ∥ base..HEAD commits >0 ⇒ changed。
|
|
131
46
|
let baseSha;
|
|
132
47
|
{
|
|
133
48
|
const r = await opts.baseEnvForGit.exec("git rev-parse HEAD", { cwd: worktreeDir });
|
|
@@ -135,7 +50,6 @@ export function withWorktreeIsolation(baseFactory, opts) {
|
|
|
135
50
|
baseSha = r.value.stdout.trim();
|
|
136
51
|
}
|
|
137
52
|
const isChanged = async () => {
|
|
138
|
-
// 判定失败 ⇒ 保守保留(Agent 车道 changed=true 缺省同款:删错=用户产出蒸发,留错=一个目录)。
|
|
139
53
|
const status = await opts.baseEnvForGit.exec("git status --porcelain", { cwd: worktreeDir });
|
|
140
54
|
if (!status.ok || status.value.exitCode !== 0)
|
|
141
55
|
return true;
|
|
@@ -154,37 +68,18 @@ export function withWorktreeIsolation(baseFactory, opts) {
|
|
|
154
68
|
...(ctx.taskId ? { taskId: ctx.taskId } : {}),
|
|
155
69
|
worktreeDir,
|
|
156
70
|
});
|
|
157
|
-
// Wrap core's `destroy()` (runs `git worktree remove`) through the SAME mutex so a concurrent teardown can't
|
|
158
|
-
// race the registry lock and orphan the worktree. The other env methods (exec/fs) keep their own binding —
|
|
159
|
-
// we only override `destroy`. Idempotent at the git level (remove of an already-gone worktree is a no-op).
|
|
160
|
-
//
|
|
161
|
-
// POST-MORTEM + RETRY (2026-07-26, the deterministic pin for the 30-round reaper-race intermittent):
|
|
162
|
-
// core's destroy is FAIL-OPEN on the remove — `git-worktree-env.js` neither checks the exec Result nor lets a
|
|
163
|
-
// rejection out (`.catch(() => undefined)`), so one transient failure (spawn EAGAIN/EMFILE under full-suite
|
|
164
|
-
// load) resolves destroy() normally while leaving dir+registration behind — and `git worktree prune` can NEVER
|
|
165
|
-
// reap a worktree whose dir still exists, so the orphan is permanent. Until core checks the result (reported),
|
|
166
|
-
// we verify the dir is actually gone after each destroy, retry ONCE on residue, and FAIL LOUD if it survives
|
|
167
|
-
// both — a thrown teardown is diagnosable; a silently-accumulating orphan tree is not.
|
|
168
71
|
const coreDestroy = env.destroy.bind(env);
|
|
169
72
|
const residue = async () => {
|
|
170
73
|
const r = await opts.baseEnvForGit.exists(worktreeDir);
|
|
171
|
-
return r.ok ? r.value : false;
|
|
74
|
+
return r.ok ? r.value : false;
|
|
172
75
|
};
|
|
173
|
-
// IDEMPOTENCY (core 1.423): the remove leg is now FAIL-LOUD ([1796]§二 → [1801] — the fail-open this wrapper's
|
|
174
|
-
// post-mortem was built against is fixed), so a REPEAT destroy would hit `git worktree remove` on an
|
|
175
|
-
// already-removed worktree and reject. A completed destroy + no residue ⇒ short-circuit (the goal state is
|
|
176
|
-
// already reached; double/concurrent destroy is a legal Runner shape). The first call always goes through
|
|
177
|
-
// coreDestroy (the inner env's own teardown must run).
|
|
178
76
|
let destroyedOnce = false;
|
|
179
77
|
let keptOnce = false;
|
|
180
78
|
env.destroy = () => serialize(async () => {
|
|
181
79
|
if (keptOnce)
|
|
182
|
-
return;
|
|
80
|
+
return;
|
|
183
81
|
if (destroyedOnce && !(await residue()))
|
|
184
|
-
return;
|
|
185
|
-
// S3 keep-if-changed:有改动(或判不出来)⇒ 保留目录+registration 给 userland verify+merge,
|
|
186
|
-
// 只跑 inner teardown(LSP evict/host 收尾;rootEnvAt 铸的是 persistent 形,不 rm 目录),
|
|
187
|
-
// 并披露坐标 —— 不披露的保留=只是换了个姿势的蒸发。残留检查在本分支让位:目录在=目的。
|
|
82
|
+
return;
|
|
188
83
|
if (await isChanged()) {
|
|
189
84
|
keptOnce = true;
|
|
190
85
|
try {
|
|
@@ -205,31 +100,16 @@ export function withWorktreeIsolation(baseFactory, opts) {
|
|
|
205
100
|
if (!(await residue()))
|
|
206
101
|
return;
|
|
207
102
|
opts.logger?.warn?.("worktree_destroy_residue_retry", { worktreeDir, sessionId: ctx.sessionId });
|
|
208
|
-
await coreDestroy();
|
|
103
|
+
await coreDestroy();
|
|
209
104
|
if (await residue()) {
|
|
210
105
|
throw new Error(`worktree destroy left residue at ${worktreeDir} (git worktree remove failed twice — see core git-worktree-env fail-open)`);
|
|
211
106
|
}
|
|
212
107
|
});
|
|
213
|
-
return env;
|
|
108
|
+
return env;
|
|
214
109
|
};
|
|
215
110
|
}
|
|
216
|
-
/**
|
|
217
|
-
* Best-effort reaper hook: deregister worktrees orphaned by a process crash (the Runner never reached
|
|
218
|
-
* `destroy`). Core's {@link pruneWorktrees} runs `git worktree prune` against the base repo — it cleans up
|
|
219
|
-
* registrations whose worktree dirs are already gone. Wired into the service reaper (userland; core ships no
|
|
220
|
-
* post-kill Runner hook). NEVER throws (a prune failure is logged, never fatal).
|
|
221
|
-
*
|
|
222
|
-
* NOTE: `git worktree prune` only deregisters worktrees whose DIRECTORY is missing. A crashed run that left
|
|
223
|
-
* the dir intact is NOT reaped by prune alone — that is acceptable for v1 (the worktree lives under the known
|
|
224
|
-
* `.sema-worktrees/` parent and is human-/script-reapable; a stricter age-based sweep is a follow-on).
|
|
225
|
-
*/
|
|
226
111
|
export async function reapOrphanWorktrees(baseEnvForGit, repoRoot, logger) {
|
|
227
112
|
try {
|
|
228
|
-
// #62:prune 动的是与 add/remove 同一张 `.git/worktrees` 注册表 ⇒ 必须过同一把 per-repoRoot 锁
|
|
229
|
-
// (曾绕锁直跑;恶劣臂见 serializeGitAdmin 注)。
|
|
230
|
-
// core 3.0.0(#94 提货):pruneWorktrees 从「失败 throw」改为回执 `{ok:true}|{ok:false,detail}`——
|
|
231
|
-
// 只靠下面的 catch 发警在 3.0.0 下= prune 失败**静默化**(CI svc3 钉抓出)。回执臂与 throw 臂
|
|
232
|
-
// (serializeGitAdmin/env 传输层仍可抛)两路都进同一条 warn,判据不变:失败必须留痕。
|
|
233
113
|
const r = await serializeGitAdmin(repoRoot, () => pruneWorktrees(baseEnvForGit, norm(repoRoot)));
|
|
234
114
|
if (r.ok === false)
|
|
235
115
|
logger?.warn?.("worktree_prune_failed", { repoRoot: norm(repoRoot), error: r.detail });
|