@sqdr2/dsh-fusion 0.0.0-stage → 0.3.2

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.
Files changed (213) hide show
  1. package/INSTALL.md +89 -0
  2. package/INSTALL.zh.md +86 -0
  3. package/LICENSE +201 -0
  4. package/README.en.md +193 -0
  5. package/README.md +182 -2
  6. package/RELEASING.md +123 -0
  7. package/RELEASING.zh.md +115 -0
  8. package/contracts/fusion.ts +279 -0
  9. package/contracts/storage.sql +86 -0
  10. package/cordis.patch.yml +3 -0
  11. package/docs/COMPATIBILITY.md +162 -0
  12. package/docs/EVIDENCE.md +141 -0
  13. package/dshx.yml +39 -0
  14. package/lib/client.js +1920 -0
  15. package/lib/client.js.map +1 -0
  16. package/lib/dsh-fusion.js +12664 -0
  17. package/lib/types/activity.d.ts +20 -0
  18. package/lib/types/approval/logical.d.ts +10 -0
  19. package/lib/types/benchmark/naive.d.ts +37 -0
  20. package/lib/types/benchmark/native-run.d.ts +68 -0
  21. package/lib/types/benchmark/output-limits.d.ts +19 -0
  22. package/lib/types/bilingual.d.ts +2 -0
  23. package/lib/types/client/ActivityRows.d.ts +8 -0
  24. package/lib/types/client/FusionCache.d.ts +4 -0
  25. package/lib/types/client/FusionHint.d.ts +9 -0
  26. package/lib/types/client/FusionHistory.d.ts +5 -0
  27. package/lib/types/client/FusionPanelIcon.d.ts +13 -0
  28. package/lib/types/client/FusionRecovery.d.ts +5 -0
  29. package/lib/types/client/FusionSettings.d.ts +9 -0
  30. package/lib/types/client/FusionStatus.d.ts +5 -0
  31. package/lib/types/client/FusionTool.d.ts +4 -0
  32. package/lib/types/client/activity-model.d.ts +14 -0
  33. package/lib/types/client/i18n.d.ts +15 -0
  34. package/lib/types/client/index.d.ts +22 -0
  35. package/lib/types/client/settings-events.d.ts +2 -0
  36. package/lib/types/client/status-polling.d.ts +9 -0
  37. package/lib/types/context/guard.d.ts +21 -0
  38. package/lib/types/context/handoff.d.ts +17 -0
  39. package/lib/types/contracts.d.ts +449 -0
  40. package/lib/types/digest.d.ts +5 -0
  41. package/lib/types/dsh-fusion.d.ts +13 -0
  42. package/lib/types/errors.d.ts +28 -0
  43. package/lib/types/evidence/gate.d.ts +23 -0
  44. package/lib/types/evidence/receipts.d.ts +65 -0
  45. package/lib/types/execution/write-lease.d.ts +42 -0
  46. package/lib/types/host/bindings.d.ts +30 -0
  47. package/lib/types/host/cache-defaults.d.ts +48 -0
  48. package/lib/types/host/cache-policy.d.ts +155 -0
  49. package/lib/types/host/change-evidence.d.ts +38 -0
  50. package/lib/types/host/compaction-routing.d.ts +15 -0
  51. package/lib/types/host/coordinator.d.ts +169 -0
  52. package/lib/types/host/evidence-page.d.ts +13 -0
  53. package/lib/types/host/history.d.ts +88 -0
  54. package/lib/types/host/http.d.ts +4 -0
  55. package/lib/types/host/model-output.d.ts +13 -0
  56. package/lib/types/host/native-activity.d.ts +26 -0
  57. package/lib/types/host/native-approvals.d.ts +29 -0
  58. package/lib/types/host/native-auxiliary.d.ts +17 -0
  59. package/lib/types/host/native-budget.d.ts +33 -0
  60. package/lib/types/host/native-checks.d.ts +104 -0
  61. package/lib/types/host/native-compaction.d.ts +8 -0
  62. package/lib/types/host/native-context.d.ts +36 -0
  63. package/lib/types/host/native-effects.d.ts +75 -0
  64. package/lib/types/host/native-evidence.d.ts +4 -0
  65. package/lib/types/host/native-exploration.d.ts +15 -0
  66. package/lib/types/host/native-keepalive.d.ts +82 -0
  67. package/lib/types/host/native-model-control.d.ts +99 -0
  68. package/lib/types/host/native-on-demand-context.d.ts +39 -0
  69. package/lib/types/host/native-request.d.ts +5 -0
  70. package/lib/types/host/native-review.d.ts +11 -0
  71. package/lib/types/host/native-role-sandbox.d.ts +49 -0
  72. package/lib/types/host/native-scopes.d.ts +49 -0
  73. package/lib/types/host/native-selection.d.ts +29 -0
  74. package/lib/types/host/native-task-context.d.ts +39 -0
  75. package/lib/types/host/native-usage.d.ts +46 -0
  76. package/lib/types/host/native-worker-brief.d.ts +74 -0
  77. package/lib/types/host/native-worker-notices.d.ts +15 -0
  78. package/lib/types/host/native-worker.d.ts +44 -0
  79. package/lib/types/host/native-workflow.d.ts +41 -0
  80. package/lib/types/host/route-transport.d.ts +22 -0
  81. package/lib/types/host/settings.d.ts +39 -0
  82. package/lib/types/host/shell.d.ts +11 -0
  83. package/lib/types/host/status.d.ts +7 -0
  84. package/lib/types/host/subagent-seam.d.ts +10 -0
  85. package/lib/types/host/tool-table.d.ts +35 -0
  86. package/lib/types/host/worker-terminal.d.ts +68 -0
  87. package/lib/types/host/workspace-guard.d.ts +13 -0
  88. package/lib/types/host/workspace.d.ts +52 -0
  89. package/lib/types/identity.d.ts +64 -0
  90. package/lib/types/index.d.ts +34 -0
  91. package/lib/types/message-source.d.ts +29 -0
  92. package/lib/types/profile/compactor.d.ts +5 -0
  93. package/lib/types/profile/keepalive.d.ts +3 -0
  94. package/lib/types/profile/resolve.d.ts +17 -0
  95. package/lib/types/prompts.d.ts +8 -0
  96. package/lib/types/recovery/reconcile.d.ts +28 -0
  97. package/lib/types/review/dispatch.d.ts +20 -0
  98. package/lib/types/review/protocol.d.ts +51 -0
  99. package/lib/types/review/ticket.d.ts +32 -0
  100. package/lib/types/status.d.ts +88 -0
  101. package/lib/types/store-migration.d.ts +29 -0
  102. package/lib/types/task/deletion.d.ts +64 -0
  103. package/lib/types/task/reducer.d.ts +6 -0
  104. package/lib/types/task/sqlite-store.d.ts +71 -0
  105. package/lib/types/task/state.d.ts +51 -0
  106. package/lib/types/task/status-index.d.ts +28 -0
  107. package/lib/types/task/store.d.ts +48 -0
  108. package/lib/types/usage/budget.d.ts +27 -0
  109. package/lib/types/usage/ledger.d.ts +66 -0
  110. package/lib/types/usage/normalize.d.ts +50 -0
  111. package/lib/types/usage/pricing.d.ts +18 -0
  112. package/package.json +178 -4
  113. package/profiles/fusion-auto.example.json +45 -0
  114. package/prompts/compact.md +7 -0
  115. package/prompts/lead.md +42 -0
  116. package/prompts/model-lead.md +13 -0
  117. package/prompts/model-worker.md +5 -0
  118. package/prompts/worker.md +21 -0
  119. package/src/activity.ts +23 -0
  120. package/src/approval/logical.ts +44 -0
  121. package/src/benchmark/naive.ts +216 -0
  122. package/src/benchmark/native-run.ts +215 -0
  123. package/src/benchmark/output-limits.ts +33 -0
  124. package/src/bilingual.ts +2 -0
  125. package/src/client/ActivityRows.tsx +59 -0
  126. package/src/client/FusionCache.tsx +130 -0
  127. package/src/client/FusionHint.tsx +39 -0
  128. package/src/client/FusionHistory.tsx +247 -0
  129. package/src/client/FusionPanelIcon.tsx +39 -0
  130. package/src/client/FusionRecovery.tsx +69 -0
  131. package/src/client/FusionSettings.tsx +205 -0
  132. package/src/client/FusionStatus.tsx +103 -0
  133. package/src/client/FusionTool.tsx +66 -0
  134. package/src/client/activity-model.ts +31 -0
  135. package/src/client/i18n.ts +30 -0
  136. package/src/client/index.ts +50 -0
  137. package/src/client/settings-events.ts +3 -0
  138. package/src/client/status-polling.ts +88 -0
  139. package/src/context/guard.ts +81 -0
  140. package/src/context/handoff.ts +28 -0
  141. package/src/contracts.ts +449 -0
  142. package/src/digest.ts +28 -0
  143. package/src/dsh-fusion.ts +321 -0
  144. package/src/errors.ts +34 -0
  145. package/src/evidence/gate.ts +290 -0
  146. package/src/evidence/receipts.ts +175 -0
  147. package/src/execution/write-lease.ts +187 -0
  148. package/src/host/bindings.ts +91 -0
  149. package/src/host/cache-defaults.ts +103 -0
  150. package/src/host/cache-policy.ts +168 -0
  151. package/src/host/change-evidence.ts +156 -0
  152. package/src/host/compaction-routing.ts +46 -0
  153. package/src/host/coordinator.ts +2160 -0
  154. package/src/host/evidence-page.ts +25 -0
  155. package/src/host/history.ts +114 -0
  156. package/src/host/http.ts +30 -0
  157. package/src/host/model-output.ts +22 -0
  158. package/src/host/native-activity.ts +199 -0
  159. package/src/host/native-approvals.ts +196 -0
  160. package/src/host/native-auxiliary.ts +19 -0
  161. package/src/host/native-budget.ts +112 -0
  162. package/src/host/native-checks.ts +474 -0
  163. package/src/host/native-compaction.ts +34 -0
  164. package/src/host/native-context.ts +204 -0
  165. package/src/host/native-effects.ts +260 -0
  166. package/src/host/native-evidence.ts +31 -0
  167. package/src/host/native-exploration.ts +114 -0
  168. package/src/host/native-keepalive.ts +292 -0
  169. package/src/host/native-model-control.ts +298 -0
  170. package/src/host/native-on-demand-context.ts +74 -0
  171. package/src/host/native-request.ts +12 -0
  172. package/src/host/native-review.ts +51 -0
  173. package/src/host/native-role-sandbox.ts +171 -0
  174. package/src/host/native-scopes.ts +203 -0
  175. package/src/host/native-selection.ts +110 -0
  176. package/src/host/native-task-context.ts +264 -0
  177. package/src/host/native-usage.ts +121 -0
  178. package/src/host/native-worker-brief.ts +122 -0
  179. package/src/host/native-worker-notices.ts +72 -0
  180. package/src/host/native-worker.ts +215 -0
  181. package/src/host/native-workflow.ts +103 -0
  182. package/src/host/route-transport.ts +54 -0
  183. package/src/host/settings.ts +93 -0
  184. package/src/host/shell.ts +13 -0
  185. package/src/host/status.ts +112 -0
  186. package/src/host/subagent-seam.ts +20 -0
  187. package/src/host/tool-table.ts +56 -0
  188. package/src/host/worker-terminal.ts +91 -0
  189. package/src/host/workspace-guard.ts +17 -0
  190. package/src/host/workspace.ts +184 -0
  191. package/src/identity.ts +76 -0
  192. package/src/index.ts +50 -0
  193. package/src/message-source.ts +31 -0
  194. package/src/profile/compactor.ts +26 -0
  195. package/src/profile/keepalive.ts +13 -0
  196. package/src/profile/resolve.ts +131 -0
  197. package/src/prompts.ts +32 -0
  198. package/src/recovery/reconcile.ts +38 -0
  199. package/src/review/dispatch.ts +48 -0
  200. package/src/review/protocol.ts +212 -0
  201. package/src/review/ticket.ts +166 -0
  202. package/src/status.ts +55 -0
  203. package/src/store-migration.ts +65 -0
  204. package/src/task/deletion.ts +105 -0
  205. package/src/task/reducer.ts +679 -0
  206. package/src/task/sqlite-store.ts +386 -0
  207. package/src/task/state.ts +72 -0
  208. package/src/task/status-index.ts +198 -0
  209. package/src/task/store.ts +287 -0
  210. package/src/usage/budget.ts +88 -0
  211. package/src/usage/ledger.ts +218 -0
  212. package/src/usage/normalize.ts +308 -0
  213. package/src/usage/pricing.ts +95 -0
@@ -0,0 +1,175 @@
1
+ import type { Digest, SnapshotId, TaskId, ToolEvidence, WorkOrder, WorkOrderId } from '../contracts.js'
2
+ import { digestOf } from '../digest.js'
3
+ import { EVENT_CONFLICT, FusionError } from '../errors.js'
4
+
5
+ export interface InvocationHeader {
6
+ taskId: ToolEvidence['taskId']
7
+ workOrderId?: WorkOrderId
8
+ revision?: number
9
+ operationId: ToolEvidence['operationId']
10
+ actor: ToolEvidence['actor']
11
+ nativeToolCallId: string
12
+ argvDigest: Digest
13
+ cwdDigest: Digest
14
+ inputSnapshot: SnapshotId
15
+ startedAt: string
16
+ verifierPlanDigest?: Digest
17
+ }
18
+
19
+ export interface CheckPlan {
20
+ id: string
21
+ version: number
22
+ taskId: TaskId
23
+ workOrderId: WorkOrderId
24
+ revision: number
25
+ criterionId: string
26
+ kind: 'test' | 'static-check' | 'human' | 'review'
27
+ snapshot: SnapshotId
28
+ /**
29
+ * Version 2 may freeze the verifier before editing. `snapshot` then names the
30
+ * WorkOrder base; native invocation receipts must still bind both input and
31
+ * output to the exact current candidate. Omission retains v1 exact semantics.
32
+ */
33
+ snapshotBinding?: 'candidate'
34
+ cwdDigest: Digest
35
+ argvDigest: Digest
36
+ definitionDigest: Digest
37
+ policyDigest: Digest
38
+ minExecutedTests?: number
39
+ /**
40
+ * Baseline-relative test check: ids that already failed on the untouched workspace, frozen before the
41
+ * Worker started. Only these may fail on the candidate; every other executed test must pass.
42
+ */
43
+ allowedFailures?: readonly string[]
44
+ }
45
+
46
+ export interface PlannedReceipt {
47
+ id: string
48
+ planDigest: Digest
49
+ evidence: ToolEvidence
50
+ counts?: { executed: number; passed: number; failed: number
51
+ /** Failures frozen in the plan's baseline (not counted as executed). */
52
+ knownFailures?: readonly string[]
53
+ /** Failures absent from the baseline; counted in `failed`. */
54
+ newFailures?: readonly string[] }
55
+ }
56
+
57
+ export interface VerificationRegistry {
58
+ getPlan(planId: string): CheckPlan | undefined
59
+ getReceipt(receiptId: string): PlannedReceipt | undefined
60
+ }
61
+
62
+ export function memoryVerificationRegistry(
63
+ plans: readonly CheckPlan[],
64
+ receipts: readonly PlannedReceipt[],
65
+ ): VerificationRegistry {
66
+ const planById = new Map(plans.map(plan => [plan.id, freezePlan(plan)]))
67
+ const receiptById = new Map(receipts.map(receipt => [receipt.id, structuredClone(receipt)]))
68
+ return {
69
+ getPlan: id => planById.get(id),
70
+ getReceipt: id => {
71
+ const receipt = receiptById.get(id)
72
+ return receipt === undefined ? undefined : structuredClone(receipt)
73
+ },
74
+ }
75
+ }
76
+
77
+ function freezePlan(plan: CheckPlan): CheckPlan {
78
+ return Object.freeze(structuredClone(plan))
79
+ }
80
+
81
+ function requireThat(value: unknown, code: string): asserts value {
82
+ if (!value) throw new FusionError(EVENT_CONFLICT, code)
83
+ }
84
+
85
+ export function assertPlannedCheck(
86
+ workOrder: WorkOrder,
87
+ criterionId: string,
88
+ plan: CheckPlan,
89
+ receipt: PlannedReceipt,
90
+ actualSnapshot: SnapshotId,
91
+ ): void {
92
+ requireThat(plan.taskId === workOrder.taskId && plan.workOrderId === workOrder.id && plan.revision === workOrder.revision, 'PLAN_SCOPE_MISMATCH')
93
+ requireThat(plan.criterionId === criterionId, 'CRITERION_SCOPE_MISMATCH')
94
+ requireThat(receipt.planDigest === digestOf(plan), 'PLAN_DIGEST_MISMATCH')
95
+ if (plan.snapshotBinding === undefined) {
96
+ requireThat(plan.snapshot === actualSnapshot, 'PLAN_SNAPSHOT_MISMATCH')
97
+ } else {
98
+ requireThat(plan.snapshotBinding === 'candidate' && plan.version === 2, 'UNSUPPORTED_SNAPSHOT_BINDING')
99
+ requireThat(plan.snapshot === workOrder.baseSnapshot, 'PLAN_BASE_SNAPSHOT_MISMATCH')
100
+ }
101
+ const criterion = workOrder.acceptance.find(item => item.id === criterionId)
102
+ requireThat(criterion && criterion.verificationKind === plan.kind, 'VERIFIER_KIND_MISMATCH')
103
+ requireThat(criterion.planId === plan.id && criterion.planDigest === digestOf(plan), 'PLAN_NOT_FROZEN_IN_WORK_ORDER')
104
+ requireThat(plan.kind === 'test' || plan.kind === 'static-check', 'USE_TYPED_HUMAN_OR_REVIEW_RECEIPT')
105
+ requireThat(plan.cwdDigest && plan.argvDigest, 'EXACT_VERIFIER_CONTEXT_REQUIRED')
106
+ const evidence = receipt.evidence
107
+ requireThat(evidence.taskId === workOrder.taskId && evidence.cwdDigest === plan.cwdDigest && evidence.argvDigest === plan.argvDigest, 'INVOCATION_SCOPE_MISMATCH')
108
+ // A baseline-relative run exits non-zero while frozen pre-existing failures remain; its counts must then
109
+ // show that every failure was one of them.
110
+ const knownOnly = plan.kind === 'test' && plan.allowedFailures !== undefined && receipt.counts?.failed === 0
111
+ && (receipt.counts.knownFailures ?? []).every(id => plan.allowedFailures!.includes(id))
112
+ requireThat(evidence.state === 'completed' && evidence.endedAt !== null
113
+ && (evidence.exitCode === 0 || (knownOnly && evidence.exitCode !== null && (receipt.counts?.knownFailures?.length ?? 0) > 0)), 'CHECK_NOT_SUCCESSFUL')
114
+ requireThat(evidence.inputSnapshot === actualSnapshot && evidence.outputSnapshot === actualSnapshot, 'CHECK_SNAPSHOT_MISMATCH')
115
+ if (plan.kind === 'test') {
116
+ const counts = receipt.counts
117
+ requireThat(counts && [counts.executed, counts.passed, counts.failed].every(value => Number.isSafeInteger(value) && value >= 0), 'TEST_COUNTS_REQUIRED')
118
+ requireThat(counts.executed >= (plan.minExecutedTests ?? 1) && counts.failed === 0 && counts.passed === counts.executed, 'TEST_SUITE_NOT_PASSED')
119
+ }
120
+ }
121
+
122
+ export function invocationHeader(record: ToolEvidence, extras: Partial<InvocationHeader> = {}): InvocationHeader {
123
+ return {
124
+ taskId: record.taskId,
125
+ operationId: record.operationId,
126
+ actor: record.actor,
127
+ nativeToolCallId: record.nativeToolCallId,
128
+ argvDigest: record.argvDigest,
129
+ cwdDigest: record.cwdDigest,
130
+ inputSnapshot: record.inputSnapshot,
131
+ startedAt: record.startedAt,
132
+ ...extras,
133
+ }
134
+ }
135
+
136
+ export function sameInvocationHeader(left: InvocationHeader, right: InvocationHeader): boolean {
137
+ return digestOf(left) === digestOf(right)
138
+ }
139
+
140
+ export function artifactFingerprint(ref: ToolEvidence['stdout']): string {
141
+ return digestOf({
142
+ id: ref.id,
143
+ digest: ref.digest,
144
+ bytes: ref.bytes,
145
+ ownerTaskId: ref.ownerTaskId,
146
+ mediaType: ref.mediaType,
147
+ })
148
+ }
149
+
150
+ export function resolveInvocation(records: readonly ToolEvidence[]): ToolEvidence {
151
+ if (!records.length) throw new FusionError(EVENT_CONFLICT, 'NO_INVOCATION')
152
+ const header = invocationHeader(records[0]!)
153
+ for (const record of records) {
154
+ if (!sameInvocationHeader(header, invocationHeader(record))) {
155
+ throw new FusionError(EVENT_CONFLICT, 'IMMUTABLE_INVOCATION_CHANGED')
156
+ }
157
+ }
158
+ const terminals = records.filter(record => record.state !== 'started')
159
+ if (!terminals.length) return records[0]!
160
+ const first = terminals[0]!
161
+ for (const terminal of terminals) {
162
+ if (digestOf({ ...first, id: undefined }) !== digestOf({ ...terminal, id: undefined })) {
163
+ throw new FusionError(EVENT_CONFLICT, 'EVIDENCE_TERMINAL_CONFLICT')
164
+ }
165
+ }
166
+ if (first.endedAt === null) throw new FusionError(EVENT_CONFLICT, 'TERMINAL_END_REQUIRED')
167
+ if (first.state === 'completed' && (first.exitCode === null || !Number.isSafeInteger(first.exitCode))) {
168
+ throw new FusionError(EVENT_CONFLICT, 'EXIT_STATUS_REQUIRED')
169
+ }
170
+ return first
171
+ }
172
+
173
+ export function checkPlanDigest(plan: CheckPlan): Digest {
174
+ return digestOf(plan)
175
+ }
@@ -0,0 +1,187 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { DatabaseSync } from 'node:sqlite'
3
+ import * as fs from 'node:fs'
4
+ import * as os from 'node:os'
5
+ import * as path from 'node:path'
6
+ import type { OperationId, SessionId, TaskId, WriteLease } from '../contracts.js'
7
+ import { FusionError, LEASE_BUSY, LEASE_GENERATION } from '../errors.js'
8
+
9
+ export interface LeaseRecord extends WriteLease {
10
+ readonly owner: string
11
+ readonly pid: number
12
+ readonly acquiredAt: string
13
+ readonly liveProcessRefs: readonly number[]
14
+ }
15
+
16
+ export function canonicalWorkspace(cwd: string): string {
17
+ return fs.realpathSync.native(cwd)
18
+ }
19
+
20
+ const DURABLE_LEASE_ROOT = path.join(os.homedir(), '.local', 'state', 'dsh-fusion', 'leases')
21
+ /** Where this plugin kept durable leases before it was renamed from `dsh-model-fusion`. */
22
+ const LEGACY_LEASE_ROOT = path.join(os.homedir(), '.local', 'state', 'dsh-model-fusion', 'leases')
23
+
24
+ /**
25
+ * Move a pre-rename lease registry to its current name.
26
+ *
27
+ * The registry outlives a single process, so a rename must carry it: a fresh
28
+ * empty directory beside a lock nobody reads would report the workspace as
29
+ * free while a previous holder believes it still owns the write lease. A move
30
+ * keeps that holder visible to the next process; a cross-device install copies
31
+ * the directory instead, and a failed copy removes only what it created.
32
+ *
33
+ * @param next - registry directory the current release uses.
34
+ * @param legacy - registry directory the previous name used.
35
+ * @returns whether a legacy registry was adopted.
36
+ */
37
+ export function adoptLegacyLeaseRoot(next: string, legacy = LEGACY_LEASE_ROOT): boolean {
38
+ if (fs.existsSync(next) || !fs.existsSync(legacy)) return false
39
+ try {
40
+ fs.renameSync(legacy, next)
41
+ return true
42
+ } catch {
43
+ // Cross-device or a locked file: fall back to a copy, then drop the source.
44
+ }
45
+ try {
46
+ fs.cpSync(legacy, next, { recursive: true })
47
+ } catch {
48
+ fs.rmSync(next, { recursive: true, force: true })
49
+ return false
50
+ }
51
+ try {
52
+ fs.rmSync(legacy, { recursive: true, force: true })
53
+ } catch {
54
+ // The new copy is complete; a leftover legacy directory is harmless.
55
+ }
56
+ return true
57
+ }
58
+
59
+ export function leasePath(workspaceId: string, root = DURABLE_LEASE_ROOT): string {
60
+ const key = createHash('sha256').update(workspaceId).digest('hex').slice(0, 24)
61
+ return path.join(root, `${key}.lock`)
62
+ }
63
+
64
+ export class WorkspaceWriteLease {
65
+ constructor(private readonly root = DURABLE_LEASE_ROOT) {
66
+ if (!path.isAbsolute(root)) throw new TypeError('lease registry directory must be absolute')
67
+ adoptLegacyLeaseRoot(root)
68
+ }
69
+
70
+ acquire(input: {
71
+ cwd: string
72
+ holder: SessionId
73
+ taskId: TaskId
74
+ operationId: OperationId
75
+ liveProcessRefs?: readonly number[]
76
+ }): LeaseRecord {
77
+ const workspaceId = canonicalWorkspace(input.cwd)
78
+ return this.#transaction(workspaceId, db => {
79
+ const row = this.#row(db)
80
+ const prior = this.#record(row, workspaceId)
81
+ if (prior) throw new FusionError(LEASE_BUSY, `workspace is held by ${prior.holder}; reconcile before handoff`)
82
+ if (row.generation === Number.MAX_SAFE_INTEGER) throw new FusionError(LEASE_GENERATION, 'lease generation exhausted')
83
+ const refs = [...new Set([process.pid, ...(input.liveProcessRefs ?? [])])]
84
+ if (refs.some(pid => !Number.isSafeInteger(pid) || pid <= 0)) throw new FusionError(LEASE_BUSY, 'invalid process reference')
85
+ const record: LeaseRecord = {
86
+ workspaceId, holder: input.holder, taskId: input.taskId, generation: row.generation + 1,
87
+ activeOperationIds: [input.operationId], owner: `${process.pid}:${randomUUID()}`, pid: process.pid,
88
+ acquiredAt: new Date().toISOString(), liveProcessRefs: refs,
89
+ }
90
+ db.prepare('UPDATE lease SET generation=?, record=? WHERE id=1').run(record.generation, JSON.stringify(record))
91
+ return record
92
+ })
93
+ }
94
+
95
+ assertGeneration(lease: WriteLease, generation: number): void {
96
+ const current = this.current(lease.workspaceId)
97
+ if (lease.generation !== generation || current?.generation !== generation
98
+ || current.holder !== lease.holder || current.taskId !== lease.taskId) {
99
+ throw new FusionError(LEASE_GENERATION, `stale write generation ${generation}`)
100
+ }
101
+ }
102
+
103
+ stillHeld(lease: LeaseRecord): boolean {
104
+ const current = this.current(lease.workspaceId)
105
+ return current?.owner === lease.owner && current.generation === lease.generation
106
+ }
107
+
108
+ /** Caller must prove native Worker/tool quiescence before releasing. */
109
+ release(lease: LeaseRecord): void {
110
+ this.#transaction(lease.workspaceId, db => {
111
+ const current = this.#record(this.#row(db), lease.workspaceId)
112
+ if (current?.owner === lease.owner && current.generation === lease.generation) {
113
+ db.prepare('UPDATE lease SET record=NULL WHERE id=1 AND generation=?').run(lease.generation)
114
+ }
115
+ })
116
+ }
117
+
118
+ /** Trusted recovery inspection. A dead PID by itself never authorizes stealing. */
119
+ current(cwd: string): LeaseRecord | undefined {
120
+ const workspaceId = canonicalWorkspace(cwd)
121
+ return this.#transaction(workspaceId, db => this.#record(this.#row(db), workspaceId))
122
+ }
123
+
124
+ #row(db: DatabaseSync): { generation: number; record: unknown } {
125
+ const row = db.prepare('SELECT generation, record FROM lease WHERE id=1').get()
126
+ if (!row || typeof row.generation !== 'number' || !Number.isSafeInteger(row.generation) || row.generation < 0) {
127
+ throw new FusionError(LEASE_BUSY, 'durable lease generation is corrupt')
128
+ }
129
+ return { generation: row.generation, record: row.record }
130
+ }
131
+
132
+ #record(row: { generation: number; record: unknown }, workspaceId: string): LeaseRecord | undefined {
133
+ if (row.record === null) return undefined
134
+ if (typeof row.record !== 'string') throw new FusionError(LEASE_BUSY, 'invalid durable lease record')
135
+ const value = JSON.parse(row.record) as Partial<LeaseRecord>
136
+ if (value.workspaceId !== workspaceId || value.generation !== row.generation
137
+ || typeof value.owner !== 'string' || typeof value.holder !== 'string' || typeof value.taskId !== 'string'
138
+ || !Number.isSafeInteger(value.pid) || Number(value.pid) <= 0 || typeof value.acquiredAt !== 'string'
139
+ || !Array.isArray(value.activeOperationIds) || !Array.isArray(value.liveProcessRefs)) {
140
+ throw new FusionError(LEASE_BUSY, 'durable lease requires reconciliation')
141
+ }
142
+ return value as LeaseRecord
143
+ }
144
+
145
+ /** Atomic publication/CAS; generations survive release and process restart. */
146
+ #transaction<T>(workspaceId: string, body: (db: DatabaseSync) => T): T {
147
+ const legacy = leasePath(workspaceId, this.root)
148
+ fs.mkdirSync(path.dirname(legacy), { recursive: true, mode: 0o700 })
149
+ const oldTemporary = leasePath(workspaceId, path.join(fs.existsSync('/tmp') ? '/tmp' : os.tmpdir(), 'dsh-model-fusion-leases'))
150
+ // A lock written under the pre-rename registry, or by the pre-rename
151
+ // temporary one, still names a writer: refuse rather than take the
152
+ // workspace out from under it.
153
+ const oldDurable = leasePath(workspaceId, LEGACY_LEASE_ROOT)
154
+ for (const lock of new Set([legacy, oldDurable, oldTemporary])) {
155
+ try {
156
+ fs.lstatSync(lock)
157
+ throw new FusionError(LEASE_BUSY, 'legacy writer lock requires explicit reconciliation')
158
+ } catch (error) {
159
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
160
+ }
161
+ }
162
+ const db = new DatabaseSync(`${legacy}.sqlite`)
163
+ let transaction = false
164
+ try {
165
+ db.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL; BEGIN IMMEDIATE;')
166
+ transaction = true
167
+ const version = db.prepare('PRAGMA user_version').get()?.user_version
168
+ if (version !== 0 && version !== 1) throw new FusionError(LEASE_BUSY, 'unknown lease database version')
169
+ if (version === 0) {
170
+ const tables = db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").get()?.n
171
+ if (tables !== 0) throw new FusionError(LEASE_BUSY, 'unversioned nonempty lease database')
172
+ db.exec('CREATE TABLE lease (id INTEGER PRIMARY KEY CHECK(id=1), generation INTEGER NOT NULL, record TEXT); INSERT INTO lease VALUES (1, 0, NULL); PRAGMA user_version=1;')
173
+ }
174
+ const result = body(db)
175
+ db.exec('COMMIT'); transaction = false
176
+ return result
177
+ } catch (error) {
178
+ if (transaction) db.exec('ROLLBACK')
179
+ throw error
180
+ } finally { db.close() }
181
+ }
182
+ }
183
+
184
+ export function classifyEffect(kind: 'read-tool' | 'write-tool' | 'unknown-shell'): 'read' | 'write' {
185
+ if (kind === 'read-tool') return 'read'
186
+ return 'write'
187
+ }
@@ -0,0 +1,91 @@
1
+ import type { PairProfile, TaskId } from '../contracts.js'
2
+ import { TaskId as taskId } from '../contracts.js'
3
+ import { digestOf } from '../digest.js'
4
+ import { completeProfile } from '../profile/resolve.js'
5
+ import type { PromptBundle, ResolvedProfile } from '../profile/resolve.js'
6
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
7
+
8
+ export interface SessionBinding {
9
+ readonly schemaVersion: 1
10
+ readonly sessionId: string
11
+ readonly taskId: TaskId
12
+ readonly selected: boolean
13
+ /** One native continuable Worker for this selection, across completed tasks. */
14
+ readonly workerId?: string
15
+ readonly profile: PairProfile
16
+ readonly prompts: PromptBundle
17
+ }
18
+
19
+ function object(value: unknown): Record<string, unknown> {
20
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid Fusion session binding')
21
+ return value as Record<string, unknown>
22
+ }
23
+
24
+ export function readBinding(value: unknown, sessionId: string): SessionBinding {
25
+ const row = object(value)
26
+ if (row.schemaVersion !== 1 || row.sessionId !== sessionId || typeof row.taskId !== 'string'
27
+ || typeof row.selected !== 'boolean') throw new Error('Fusion binding migration or reconciliation required')
28
+ const prompt = object(row.prompts)
29
+ if (row.workerId !== undefined && (typeof row.workerId !== 'string' || !row.workerId || row.workerId.includes('\0'))) {
30
+ throw new Error('Invalid persistent Fusion Worker identity')
31
+ }
32
+ if (typeof prompt.lead !== 'string' || typeof prompt.worker !== 'string' || typeof prompt.compact !== 'string') {
33
+ throw new Error('Frozen Fusion prompts are missing')
34
+ }
35
+ const prompts: PromptBundle = { lead: prompt.lead, worker: prompt.worker, compact: prompt.compact }
36
+ const profile = completeProfile(object(row.profile), prompts)
37
+ if (digestOf(profile) !== digestOf(row.profile)) throw new Error('Frozen Fusion profile or prompt digest changed')
38
+ return { schemaVersion: 1, sessionId, taskId: taskId(row.taskId), selected: row.selected, profile, prompts,
39
+ ...(row.workerId === undefined ? {} : { workerId: row.workerId as string }) }
40
+ }
41
+
42
+ /** Selection is durable and scoped by the native Lead identity, never a global default. */
43
+ export class BindingRepository {
44
+ constructor(private readonly store: SqliteFusionStore) {}
45
+
46
+ read(sessionId: string): { revision: number; binding: SessionBinding } | undefined {
47
+ const document = this.store.readDocument(`binding:${sessionId}`)
48
+ return document ? { revision: document.revision, binding: readBinding(document.value, sessionId) } : undefined
49
+ }
50
+
51
+ select(sessionId: string, id: TaskId, resolved: ResolvedProfile): SessionBinding {
52
+ const prior = this.read(sessionId)
53
+ if (prior?.binding.selected) throw new Error('Fusion is already selected for this session')
54
+ const binding: SessionBinding = {
55
+ schemaVersion: 1, sessionId, taskId: id, selected: true,
56
+ profile: structuredClone(resolved.profile), prompts: structuredClone(resolved.prompts),
57
+ }
58
+ readBinding(binding, sessionId)
59
+ this.store.writeDocument(`binding:${sessionId}`, prior?.revision ?? 0, binding)
60
+ return binding
61
+ }
62
+
63
+ clear(sessionId: string): void {
64
+ const prior = this.read(sessionId)
65
+ if (!prior || !prior.binding.selected) return
66
+ this.store.writeDocument(`binding:${sessionId}`, prior.revision, { ...prior.binding, selected: false })
67
+ }
68
+
69
+ /** Reserve identity before native dispatch; an existing selection cannot swap workers. */
70
+ assignWorker(sessionId: string, expectedTask: TaskId, workerId: string): void {
71
+ const prior = this.read(sessionId)
72
+ if (!prior?.binding.selected || prior.binding.taskId !== expectedTask) throw new Error('Fusion binding changed before Worker dispatch')
73
+ if (prior.binding.workerId && prior.binding.workerId !== workerId) throw new Error('Persistent Worker identity cannot change within a Fusion selection')
74
+ const binding = { ...prior.binding, workerId }
75
+ readBinding(binding, sessionId)
76
+ this.store.writeDocument(`binding:${sessionId}`, prior.revision, binding)
77
+ }
78
+
79
+ /** A new native user turn starts a new task while preserving the frozen pair. */
80
+ rollover(sessionId: string, expectedTask: TaskId, nextTask: TaskId): SessionBinding {
81
+ const prior = this.read(sessionId)
82
+ if (!prior?.binding.selected || prior.binding.taskId !== expectedTask) throw new Error('Fusion binding changed during task rollover')
83
+ const binding = { ...prior.binding, taskId: nextTask }
84
+ this.store.writeDocument(`binding:${sessionId}`, prior.revision, binding)
85
+ return binding
86
+ }
87
+
88
+ selected(): readonly SessionBinding[] {
89
+ return this.store.listDocumentIds('binding:').map(id => this.read(id.slice('binding:'.length))!.binding).filter(row => row.selected)
90
+ }
91
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Default prompt-cache behaviour for the models most people pair in Fusion, from each provider's official
3
+ * documentation. Matching uses the model id (stable across DSH setups), never the route name, except for the
4
+ * routes of this project's own OAuth plugins, whose billing we know. Everything here is a default: the user's
5
+ * per-model setting always wins, and the runtime can shorten an interval it observes to be too long.
6
+ */
7
+
8
+ export const CACHE_DEFAULTS_CHECKED = '2026-09-27'
9
+ /** Safe for every provider documented here (the shortest documented lifetime is 5 minutes). */
10
+ export const GENERIC_INTERVAL_SECONDS = 285
11
+
12
+ export type CacheMode = 'auto' | 'on' | 'off'
13
+
14
+ export interface CacheFamily {
15
+ id: string
16
+ label: string
17
+ match: RegExp
18
+ /** Official lifetime text as documented (not a guarantee; caches are best-effort). */
19
+ lifetime: { zh: string; en: string }
20
+ /** Cached input price relative to uncached input, as documented. */
21
+ discount: string
22
+ keepalive: CacheMode
23
+ intervalSeconds: number
24
+ docs: string
25
+ }
26
+
27
+ export const CACHE_FAMILIES: readonly CacheFamily[] = [
28
+ { id: 'openai', label: 'OpenAI', match: /^(gpt-|o\d|codex|chatgpt)/i, discount: '1/10', keepalive: 'auto', intervalSeconds: 285,
29
+ lifetime: { zh: '空闲 5–10 分钟,最长 1 小时;部分新模型至少 30 分钟(ChatGPT 订阅通道实测:空闲 10 分钟仍命中,15 分钟已过期)', en: '5–10 min idle, up to 1 h; some newer models ≥ 30 min (ChatGPT subscription measured: still cached after 10 idle min, gone at 15)' },
30
+ docs: 'https://developers.openai.com/api/docs/guides/prompt-caching' },
31
+ { id: 'anthropic', label: 'Anthropic', match: /^claude/i, discount: '1/10', keepalive: 'auto', intervalSeconds: 270,
32
+ lifetime: { zh: '5 分钟,每次命中重新计时(可选 1 小时)', en: '5 min, refreshed on each hit (1 h option)' },
33
+ docs: 'https://platform.claude.com/docs/en/build-with-claude/prompt-caching' },
34
+ { id: 'google', label: 'Google', match: /^gemini/i, discount: '1/10', keepalive: 'auto', intervalSeconds: 285,
35
+ lifetime: { zh: '隐式缓存自动,时长未公布', en: 'implicit, lifetime not published' },
36
+ docs: 'https://ai.google.dev/gemini-api/docs/caching' },
37
+ { id: 'deepseek', label: 'DeepSeek', match: /^deepseek/i, discount: '1/10–1/50', keepalive: 'off', intervalSeconds: 285,
38
+ lifetime: { zh: '硬盘缓存,通常保留几小时到几天(等待期间不会过期,无需保活)', en: 'disk cache, hours to days (no keepalive needed)' },
39
+ docs: 'https://api-docs.deepseek.com/guides/kv_cache/' },
40
+ { id: 'xai', label: 'xAI', match: /^grok/i, discount: '≈1/6', keepalive: 'auto', intervalSeconds: 285,
41
+ lifetime: { zh: '自动,时长未公布,内存紧张时可能被清除', en: 'automatic, lifetime not published, evicted under memory pressure' },
42
+ docs: 'https://docs.x.ai/developers/advanced-api-usage/prompt-caching' },
43
+ { id: 'zhipu', label: 'Z.AI / 智谱', match: /^glm/i, discount: '≈1/5', keepalive: 'auto', intervalSeconds: 285,
44
+ lifetime: { zh: '自动,时长未公布', en: 'automatic, lifetime not published' },
45
+ docs: 'https://docs.bigmodel.cn/cn/guide/capabilities/cache' },
46
+ { id: 'moonshot', label: 'Moonshot / Kimi', match: /^kimi|^moonshot/i, discount: '1/10', keepalive: 'auto', intervalSeconds: 270,
47
+ lifetime: { zh: '默认 5 分钟,每次命中重新计时(可选 1 小时)', en: '5 min default, refreshed on each hit (1 h tier)' },
48
+ docs: 'https://platform.moonshot.ai/docs/pricing/chat-k3' },
49
+ { id: 'alibaba', label: 'Alibaba / Qwen', match: /^qwen/i, discount: '1/5', keepalive: 'auto', intervalSeconds: 270,
50
+ lifetime: { zh: '隐式缓存由系统管理;显式缓存 5 分钟', en: 'implicit cache system-managed; explicit cache 5 min' },
51
+ docs: 'https://help.aliyun.com/zh/model-studio/context-cache' },
52
+ { id: 'xiaomi', label: 'Xiaomi MiMo', match: /^mimo/i, discount: '≈1/120', keepalive: 'auto', intervalSeconds: 285,
53
+ lifetime: { zh: '自动,时长未公布', en: 'automatic, lifetime not published' },
54
+ docs: 'https://mimo.mi.com/docs/en-US/price/pay-as-you-go' },
55
+ { id: 'stepfun', label: 'StepFun', match: /^step/i, discount: '1/5', keepalive: 'auto', intervalSeconds: 285,
56
+ lifetime: { zh: '自动,时长未公布', en: 'automatic, lifetime not published' },
57
+ docs: 'https://platform.stepfun.com/docs/guide/prompt_cache' },
58
+ { id: 'meta', label: 'Meta Muse', match: /^muse/i, discount: '≈1/8', keepalive: 'auto', intervalSeconds: 285,
59
+ lifetime: { zh: '自动;可申请 24 小时保留但不保证', en: 'automatic; 24 h retention is a hint, not a guarantee' },
60
+ docs: 'https://dev.meta.ai/docs/pricing-rate-limits' },
61
+ { id: 'minimax', label: 'MiniMax', match: /^minimax/i, discount: '1/5', keepalive: 'auto', intervalSeconds: 270,
62
+ lifetime: { zh: '5 分钟,每次命中重新计时', en: '5 min, refreshed on each hit' },
63
+ docs: 'https://platform.minimax.io/docs/api-reference/text-prompt-caching' },
64
+ ]
65
+
66
+ /** Routes of this project's OAuth plugins (dsh-oauth-login, dsh-antigravity-oauth): billing we know. */
67
+ export interface RouteBilling { match: RegExp; label: string; keepalive: CacheMode; reason: { zh: string; en: string }
68
+ /** Interval measured on this route; overrides the model family default. */
69
+ intervalSeconds?: number }
70
+ export const OWN_ROUTES: readonly RouteBilling[] = [
71
+ { match: /^pi-zai-coding/, label: 'GLM Coding Plan', keepalive: 'off',
72
+ reason: { zh: '按请求次数计额度,保活会占用次数', en: 'quota counts requests; keepalive pings would use it' } },
73
+ { match: /^pi-kimi-coding/, label: 'Kimi Code', keepalive: 'off',
74
+ reason: { zh: '订阅按请求次数计额度', en: 'subscription quota counts requests' } },
75
+ { match: /^pi-github-copilot/, label: 'GitHub Copilot', keepalive: 'off',
76
+ reason: { zh: '每次请求都算一次高级请求', en: 'every request counts as a premium request' } },
77
+ { match: /^agy-/, label: 'Antigravity', keepalive: 'off',
78
+ reason: { zh: '按请求次数限额', en: 'request-count limits' } },
79
+ // Probed 2026-10-01 on gpt-6-astra: a 20.7k-token prefix still hit after 4, 7 and 10 idle minutes and was gone
80
+ // at 15 and 22. 480 s is 0.8 x the shortest confirmed lifetime; it halves the pings of the 285 s family default.
81
+ { match: /^pi-openai-codex/, label: 'ChatGPT (Codex)', keepalive: 'auto', intervalSeconds: 480,
82
+ reason: { zh: '按用量计,缓存命中能省额度;实测缓存空闲 10 分钟仍在', en: 'usage-based; cache hits save quota; measured cache still warm after 10 idle min' } },
83
+ ...([[/^pi-anthropic/, 'Claude'], [/^pi-xai/, 'xAI'], [/^pi-openrouter/, 'OpenRouter']] as const)
84
+ .map(([match, label]): RouteBilling => ({ match, label, keepalive: 'auto',
85
+ reason: { zh: '按用量计,缓存命中能省额度', en: 'usage-based; cache hits save quota' } })),
86
+ ]
87
+
88
+ export interface CacheDefaults {
89
+ keepalive: CacheMode
90
+ intervalSeconds: number
91
+ source: 'route' | 'family' | 'generic'
92
+ family?: CacheFamily
93
+ route?: RouteBilling
94
+ }
95
+
96
+ /** Defaults for one physical route; the model id selects the family, our own routes set the billing mode. */
97
+ export function cacheDefaults(provider: string, model: string): CacheDefaults {
98
+ const family = CACHE_FAMILIES.find(item => item.match.test(model))
99
+ const route = OWN_ROUTES.find(item => item.match.test(provider))
100
+ if (route) return { keepalive: route.keepalive, intervalSeconds: route.intervalSeconds ?? family?.intervalSeconds ?? GENERIC_INTERVAL_SECONDS, source: 'route', family, route }
101
+ if (family) return { keepalive: family.keepalive, intervalSeconds: family.intervalSeconds, source: 'family', family }
102
+ return { keepalive: 'auto', intervalSeconds: GENERIC_INTERVAL_SECONDS, source: 'generic' }
103
+ }