@danceiny/gotry 0.2.0-rc.32 → 0.2.0-rc.33

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 (89) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/capabilities/session/bridge-ledger.js +168 -67
  4. package/dist/capabilities/session/extension-bridge.js +140 -46
  5. package/dist/capabilities/session/gui-flight.js +281 -69
  6. package/dist/capabilities/session/gui-hotel.js +74 -17
  7. package/dist/capabilities/session-search.js +6 -5
  8. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  9. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  10. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  11. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  12. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  13. package/dist/scripts/bridge-ledger-tests.js +20 -18
  14. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  15. package/dist/scripts/mutation-check.js +153 -0
  16. package/dist/scripts/nightly-evidence.js +4 -111
  17. package/dist/scripts/session-tests.js +42 -8
  18. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  19. package/dist/scripts/tenant-capacity-probe.js +263 -0
  20. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  21. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  22. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  23. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  24. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  25. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  26. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  27. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  28. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  29. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  30. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  31. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  32. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  33. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  34. package/dist/scripts/tenant-state-router-tests.js +464 -0
  35. package/dist/scripts/tenant-state-test-kit.js +92 -0
  36. package/dist/scripts/tenant-state-tests.js +564 -0
  37. package/dist/src/backend/modules/booking-executor.js +61 -6
  38. package/dist/src/backend/modules/session-search.js +136 -39
  39. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  40. package/dist/src/booking-surface/dsh-planner.js +150 -19
  41. package/dist/src/booking-surface/index.js +1 -0
  42. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  43. package/dist/src/booking-surface/server.js +193 -80
  44. package/dist/src/booking-surface/startup.js +153 -0
  45. package/dist/src/booking-surface/tenancy.js +188 -0
  46. package/dist/src/index.js +31 -1
  47. package/dist/src/llm-price-table.js +148 -0
  48. package/dist/src/tenant-seam/index.js +8 -0
  49. package/dist/src/tenant-seam/llm-broker.js +508 -0
  50. package/dist/src/tenant-seam/process-governor.js +281 -0
  51. package/dist/src/tenant-seam/quotas.js +360 -0
  52. package/dist/src/tenant-seam/seam.js +92 -0
  53. package/dist/src/tenant-state/durable-store.js +158 -0
  54. package/dist/src/tenant-state/epoch-copies.js +422 -0
  55. package/dist/src/tenant-state/errors.js +108 -0
  56. package/dist/src/tenant-state/ids.js +101 -0
  57. package/dist/src/tenant-state/index.js +11 -0
  58. package/dist/src/tenant-state/lease-store.js +203 -0
  59. package/dist/src/tenant-state/tenant-lease.js +150 -0
  60. package/dist/src/tenant-state/tenant-router.js +650 -0
  61. package/extension/manifest.json +2 -2
  62. package/package.json +4 -1
  63. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  64. package/ts/capabilities/session/extension-bridge.ts +176 -65
  65. package/ts/capabilities/session/gui-flight.ts +318 -88
  66. package/ts/capabilities/session/gui-hotel.ts +72 -18
  67. package/ts/capabilities/session-search.ts +17 -5
  68. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  69. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  70. package/ts/src/booking-surface/index.ts +1 -0
  71. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  72. package/ts/src/booking-surface/server.ts +215 -84
  73. package/ts/src/booking-surface/startup.ts +155 -0
  74. package/ts/src/booking-surface/tenancy.ts +242 -0
  75. package/ts/src/index.ts +28 -2
  76. package/ts/src/llm-price-table.ts +180 -0
  77. package/ts/src/tenant-seam/index.ts +9 -0
  78. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  79. package/ts/src/tenant-seam/process-governor.ts +343 -0
  80. package/ts/src/tenant-seam/quotas.ts +433 -0
  81. package/ts/src/tenant-seam/seam.ts +142 -0
  82. package/ts/src/tenant-state/durable-store.ts +171 -0
  83. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  84. package/ts/src/tenant-state/errors.ts +128 -0
  85. package/ts/src/tenant-state/ids.ts +108 -0
  86. package/ts/src/tenant-state/index.ts +12 -0
  87. package/ts/src/tenant-state/lease-store.ts +266 -0
  88. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  89. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Resource limits for the child process trees that run model-driven code on a replica
3
+ * (docs/rfc/aaas-rfc.md §4.8, threat S-17).
4
+ *
5
+ * The tenant boundary of the state layer says nothing about CPU and memory: every tenant on a replica shares them,
6
+ * so one runaway planner child could take the replica, and every tenant on it, down with it. This module bounds
7
+ * what one child tree can take before the operating system's OOM killer decides for us. It is a ceiling for a
8
+ * runaway, not a budget and not isolation: a tree that stays under its limits still shares the machine.
9
+ *
10
+ * The governor samples the process table (`ps`), finds the tree that starts at a watched root, and kills the
11
+ * whole tree the moment it is over a limit. The root is identified by its parent as well as by its pid, so a pid
12
+ * that the operating system has reused for an unrelated process is never sampled and never killed. Sampling is
13
+ * read-only, one `ps` per tick for every watched tree, and a failed sample kills nothing.
14
+ *
15
+ * Linux places the worker in a user-systemd scope and Darwin in its own process group, so neither a group id nor a
16
+ * cgroup is assumed here: the tree is the root and its descendants by parent pid, which both platforms report.
17
+ * A child that deliberately re-parents itself away leaves the tree; the planner children cannot start processes
18
+ * (their shell, file and subprocess plugins are disabled), and the provider-owned range cleanup of
19
+ * `dsh-subprocess-local` remains the primary teardown. This is the second line.
20
+ */
21
+
22
+ import { execFile } from 'node:child_process'
23
+
24
+ export interface TreeLimits {
25
+ /**
26
+ * Resident set of the whole tree in bytes. Pages that processes share are counted once per process, so this
27
+ * overstates: set it as a ceiling for a runaway.
28
+ */
29
+ readonly maxRssBytes?: number
30
+ /** CPU time consumed by the tree's live processes, in seconds, as the process table reports it (one-second resolution on Linux). */
31
+ readonly maxCpuSeconds?: number
32
+ }
33
+
34
+ export interface TreeBreach {
35
+ /**
36
+ * `blind` is the sampler failing closed: the tree could not be seen for `limit` ticks in a row (the table could not be
37
+ * read, timed out, or did not show the watched root), so nothing about its limits can be said and the owner is told.
38
+ */
39
+ readonly kind: 'memory' | 'cpu' | 'blind'
40
+ readonly limit: number
41
+ readonly observed: number
42
+ }
43
+
44
+ export interface ProcessRow {
45
+ readonly pid: number
46
+ readonly ppid: number
47
+ readonly rssBytes: number
48
+ readonly cpuSeconds: number
49
+ }
50
+
51
+ export type ProcessTable = () => Promise<readonly ProcessRow[]>
52
+
53
+ /** A tree to watch: the root and the process that must be its parent (this process, for a child it spawned). */
54
+ export interface TreeTarget {
55
+ readonly rootPid: number
56
+ readonly parentPid: number
57
+ }
58
+
59
+ export interface TreeSample {
60
+ readonly pids: readonly number[]
61
+ readonly rssBytes: number
62
+ readonly cpuSeconds: number
63
+ }
64
+
65
+ /** The process table cannot be read or does not show this process: the sampler would govern nothing. */
66
+ export class ProcessTableUnavailableError extends Error {
67
+ constructor(cause?: unknown) {
68
+ super('the process table cannot be read, so planner process trees cannot be governed', { cause })
69
+ this.name = 'ProcessTableUnavailableError'
70
+ }
71
+ }
72
+
73
+ export interface ProcessTreeGovernorOptions {
74
+ /** Time between samples. Default 1000. */
75
+ readonly intervalMs?: number
76
+ /** Ticks in a row on which a watched tree cannot be seen before it is treated as a breach. Default 10. */
77
+ readonly maxBlindTicks?: number
78
+ /** Longest a read of the process table may take before the tick counts it as a failure. Default 5000. */
79
+ readonly tableTimeoutMs?: number
80
+ /** The process table. Default: `ps`. Injected by the suites. */
81
+ readonly table?: ProcessTable
82
+ /** Kills the sampled processes. Default: SIGKILL each pid. Injected by the suites. */
83
+ readonly kill?: (pids: readonly number[]) => void
84
+ /** Receives a failed sample or a throwing breach handler. A failure here never stops the governor. */
85
+ readonly onFault?: (error: unknown) => void
86
+ }
87
+
88
+ const DEFAULT_INTERVAL_MS = 1_000
89
+ const DEFAULT_MAX_BLIND_TICKS = 10
90
+ const DEFAULT_TABLE_TIMEOUT_MS = 5_000
91
+ const PS_TIMEOUT_MS = 3_000
92
+ const PS_MAX_BUFFER_BYTES = 16 * 1024 * 1024
93
+
94
+ /** `[DD-][HH:]MM:SS[.ss]`: Linux prints `HH:MM:SS` and `DD-HH:MM:SS`, Darwin `M:SS.ss` and `H:MM:SS.ss`. NaN for anything else. */
95
+ export function parseCpuSeconds(text: string): number {
96
+ const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/.exec(text.trim())
97
+ if (!match) return Number.NaN
98
+ const [, days, hours, minutes, seconds] = match
99
+ return (Number(days ?? 0) * 24 + Number(hours ?? 0)) * 3_600 + Number(minutes) * 60 + Number(seconds)
100
+ }
101
+
102
+ /** Parses the output of `ps` with `PS_ARGS` (rss in KiB). Lines that do not parse are dropped, never guessed. */
103
+ export function parsePsTable(stdout: string): ProcessRow[] {
104
+ const rows: ProcessRow[] = []
105
+ for (const line of stdout.split('\n')) {
106
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line)
107
+ if (!match) continue
108
+ const cpuSeconds = parseCpuSeconds(match[4]!)
109
+ if (Number.isNaN(cpuSeconds)) continue
110
+ rows.push({ pid: Number(match[1]), ppid: Number(match[2]), rssBytes: Number(match[3]) * 1024, cpuSeconds })
111
+ }
112
+ return rows
113
+ }
114
+
115
+ /**
116
+ * `-A` selects every process on procps (Linux) and on BSD (Darwin) alike; the BSD-style `-ax` that the cleanup diagnostics
117
+ * use on Darwin makes procps warn on stderr. `pid=` and the like suppress the header. `rss` is in KiB on both.
118
+ */
119
+ export const PS_ARGS: readonly string[] = ['-A', '-o', 'pid=,ppid=,rss=,time=']
120
+
121
+ export function readPsTable(): Promise<readonly ProcessRow[]> {
122
+ return new Promise((resolve, reject) => {
123
+ execFile('/bin/ps', [...PS_ARGS], {
124
+ encoding: 'utf8', timeout: PS_TIMEOUT_MS, maxBuffer: PS_MAX_BUFFER_BYTES,
125
+ env: { PATH: '/usr/bin:/bin', LC_ALL: 'C' },
126
+ }, (error, stdout) => (error ? reject(error) : resolve(parsePsTable(stdout))))
127
+ })
128
+ }
129
+
130
+ /**
131
+ * The root and its descendants, or `null` when the root is not there or is not a child of the expected parent.
132
+ * Pure: the suites feed crafted tables.
133
+ */
134
+ export function treeOf(rows: readonly ProcessRow[], target: TreeTarget): TreeSample | null {
135
+ const byPid = new Map<number, ProcessRow>()
136
+ const children = new Map<number, ProcessRow[]>()
137
+ for (const row of rows) {
138
+ byPid.set(row.pid, row)
139
+ const siblings = children.get(row.ppid)
140
+ if (siblings) siblings.push(row)
141
+ else children.set(row.ppid, [row])
142
+ }
143
+ const root = byPid.get(target.rootPid)
144
+ if (!root || root.ppid !== target.parentPid) return null
145
+ const pids: number[] = []
146
+ let rssBytes = 0
147
+ let cpuSeconds = 0
148
+ const seen = new Set<number>()
149
+ const queue: ProcessRow[] = [root]
150
+ for (let row = queue.shift(); row; row = queue.shift()) {
151
+ if (seen.has(row.pid)) continue
152
+ seen.add(row.pid)
153
+ pids.push(row.pid)
154
+ rssBytes += row.rssBytes
155
+ cpuSeconds += row.cpuSeconds
156
+ for (const child of children.get(row.pid) ?? []) queue.push(child)
157
+ }
158
+ return { pids, rssBytes, cpuSeconds }
159
+ }
160
+
161
+ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
162
+ return new Promise<T>((resolve, reject) => {
163
+ const timer = setTimeout(() => reject(new Error('the process table did not answer in time')), ms)
164
+ promise.then(
165
+ (value) => { clearTimeout(timer); resolve(value) },
166
+ (error: unknown) => { clearTimeout(timer); reject(error) },
167
+ )
168
+ })
169
+ }
170
+
171
+ function breachOf(sample: TreeSample, limits: TreeLimits): TreeBreach | null {
172
+ if (limits.maxRssBytes !== undefined && sample.rssBytes > limits.maxRssBytes) {
173
+ return { kind: 'memory', limit: limits.maxRssBytes, observed: sample.rssBytes }
174
+ }
175
+ if (limits.maxCpuSeconds !== undefined && sample.cpuSeconds > limits.maxCpuSeconds) {
176
+ return { kind: 'cpu', limit: limits.maxCpuSeconds, observed: sample.cpuSeconds }
177
+ }
178
+ return null
179
+ }
180
+
181
+ function assertPositive(name: string, value: number | undefined): void {
182
+ if (value !== undefined && (!Number.isFinite(value) || value <= 0)) throw new RangeError(`${name} must be a positive number`)
183
+ }
184
+
185
+ function killEach(pids: readonly number[]): void {
186
+ for (const pid of pids) {
187
+ try { process.kill(pid, 'SIGKILL') } catch { /* already gone */ }
188
+ }
189
+ }
190
+
191
+ interface Watch {
192
+ readonly target: TreeTarget
193
+ readonly limits: TreeLimits
194
+ readonly onBreach: (breach: TreeBreach) => void
195
+ /** Ticks in a row on which this tree could not be seen. */
196
+ blind: number
197
+ }
198
+
199
+ export class ProcessTreeGovernor {
200
+ private readonly intervalMs: number
201
+ private readonly maxBlindTicks: number
202
+ private readonly tableTimeoutMs: number
203
+ private readonly table: ProcessTable
204
+ private readonly kill: (pids: readonly number[]) => void
205
+ private readonly onFault: ((error: unknown) => void) | undefined
206
+ private readonly watches = new Map<number, Watch>()
207
+ private nextId = 1
208
+ private timer: NodeJS.Timeout | undefined
209
+ private ticking = false
210
+ private sampleFailures = 0
211
+
212
+ constructor(options: ProcessTreeGovernorOptions = {}) {
213
+ this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS
214
+ assertPositive('intervalMs', this.intervalMs)
215
+ this.maxBlindTicks = options.maxBlindTicks ?? DEFAULT_MAX_BLIND_TICKS
216
+ assertPositive('maxBlindTicks', this.maxBlindTicks)
217
+ this.tableTimeoutMs = options.tableTimeoutMs ?? DEFAULT_TABLE_TIMEOUT_MS
218
+ assertPositive('tableTimeoutMs', this.tableTimeoutMs)
219
+ this.table = options.table ?? readPsTable
220
+ this.kill = options.kill ?? killEach
221
+ this.onFault = options.onFault
222
+ }
223
+
224
+ /** Trees under watch now. */
225
+ get size(): number {
226
+ return this.watches.size
227
+ }
228
+
229
+ /** Samples that failed since construction. A failed sample kills nothing. */
230
+ get failedSamples(): number {
231
+ return this.sampleFailures
232
+ }
233
+
234
+ /**
235
+ * Watches one tree. `onBreach` runs once, before the tree is killed, so the owner can record why. The returned
236
+ * function stops the watch and is safe to call after a breach. With no limit given nothing is watched.
237
+ */
238
+ watch(target: TreeTarget, limits: TreeLimits, onBreach: (breach: TreeBreach) => void): () => void {
239
+ if (!Number.isSafeInteger(target.rootPid) || target.rootPid <= 0) throw new RangeError('rootPid must be a positive integer')
240
+ if (!Number.isSafeInteger(target.parentPid) || target.parentPid <= 0) throw new RangeError('parentPid must be a positive integer')
241
+ assertPositive('maxRssBytes', limits.maxRssBytes)
242
+ assertPositive('maxCpuSeconds', limits.maxCpuSeconds)
243
+ if (limits.maxRssBytes === undefined && limits.maxCpuSeconds === undefined) return () => undefined
244
+ const id = this.nextId++
245
+ this.watches.set(id, { target, limits, onBreach, blind: 0 })
246
+ this.startTimer()
247
+ return () => {
248
+ this.watches.delete(id)
249
+ if (this.watches.size === 0) this.stopTimer()
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Proves the sampler can see: the table reads, and it shows this very process. A service that cannot see its own
255
+ * process table would run its children ungoverned and silently (for example where `ps` is a stripped-down build),
256
+ * so a deployment checks this once before the first tree starts and refuses to run children without it.
257
+ */
258
+ async probe(): Promise<void> {
259
+ let rows: readonly ProcessRow[]
260
+ try {
261
+ rows = await withTimeout(this.table(), this.tableTimeoutMs)
262
+ } catch (error) {
263
+ throw new ProcessTableUnavailableError(error)
264
+ }
265
+ const self = rows.find((row) => row.pid === process.pid)
266
+ if (!self || self.rssBytes <= 0) throw new ProcessTableUnavailableError()
267
+ }
268
+
269
+ /** One sampling pass. The timer calls it; the suites call it directly to stay deterministic. */
270
+ async tick(): Promise<void> {
271
+ if (this.ticking || this.watches.size === 0) return
272
+ this.ticking = true
273
+ try {
274
+ let rows: readonly ProcessRow[] | null = null
275
+ try {
276
+ rows = await withTimeout(this.table(), this.tableTimeoutMs)
277
+ } catch (error) {
278
+ this.sampleFailures += 1
279
+ this.report(error)
280
+ }
281
+ for (const [id, watch] of [...this.watches]) {
282
+ const sample = rows ? treeOf(rows, watch.target) : null
283
+ if (!sample) {
284
+ // A tree that cannot be seen is not a tree within its limits. A few such ticks happen (a worker exiting between
285
+ // two samples); a run of them means the sampler governs nothing, and failing open would hide it.
286
+ watch.blind += 1
287
+ if (watch.blind >= this.maxBlindTicks) this.end(id, watch, { kind: 'blind', limit: this.maxBlindTicks, observed: watch.blind }, [])
288
+ continue
289
+ }
290
+ watch.blind = 0
291
+ const breach = breachOf(sample, watch.limits)
292
+ if (breach) this.end(id, watch, breach, sample.pids)
293
+ }
294
+ } finally {
295
+ this.ticking = false
296
+ if (this.watches.size === 0) this.stopTimer()
297
+ }
298
+ }
299
+
300
+ close(): void {
301
+ this.watches.clear()
302
+ this.stopTimer()
303
+ }
304
+
305
+ /** The watch is over: the owner is told why, and whatever was seen of the tree is killed. */
306
+ private end(id: number, watch: Watch, breach: TreeBreach, pids: readonly number[]): void {
307
+ this.watches.delete(id)
308
+ try {
309
+ watch.onBreach(breach)
310
+ } catch (error) {
311
+ this.report(error)
312
+ } finally {
313
+ this.kill(pids)
314
+ }
315
+ }
316
+
317
+ private report(error: unknown): void {
318
+ try { this.onFault?.(error) } catch { /* reporting must never stop the governor */ }
319
+ }
320
+
321
+ private startTimer(): void {
322
+ if (this.timer) return
323
+ this.timer = setInterval(() => { this.tick().catch((error: unknown) => this.report(error)) }, this.intervalMs)
324
+ // The governor must not keep the process alive: a watched child cannot outlive its owner's own shutdown.
325
+ this.timer.unref()
326
+ }
327
+
328
+ private stopTimer(): void {
329
+ if (!this.timer) return
330
+ clearInterval(this.timer)
331
+ this.timer = undefined
332
+ }
333
+ }
334
+
335
+ let shared: ProcessTreeGovernor | undefined
336
+
337
+ /** The governor every managed port shares by default: one `ps` per tick, however many trees are watched. */
338
+ export function sharedProcessTreeGovernor(): ProcessTreeGovernor {
339
+ shared ??= new ProcessTreeGovernor({
340
+ onFault: (error) => { console.error('[process-governor] fault:', error instanceof Error ? error.message : String(error)) },
341
+ })
342
+ return shared
343
+ }