ReviewOS

also looking at this

stacks/drone

feat: add scoped cargo receiving mission tasks

#102
Merged chris wants to merge codex/cargo-fleet-authority into main
21 files +1186 -15
.github/workflows/ci.ymlmodified+16-0
Changes to .github/workflows/ci.yml
@@ -30,6 +30,22 @@ jobs:
3030 - run: bun run parity:check
3131 - run: python3 -m py_compile resources/functions/drone/camera/imx500_worker.py
3232 - run: python3 -m unittest discover -s tests/hardware
33 - name: Qualify startup observations with actual exited and live native processes
34 env:
35 BROWSER_LAUNCH_EVIDENCE: ${{ runner.temp }}/browser-launch-exited
36 BROWSER_LAUNCH_LIVE_EVIDENCE: ${{ runner.temp }}/browser-launch-live
37 run: bun test tests/unit/public-safety-browser-launch.test.ts tests/unit/public-safety-browser-launch-evidence.test.ts tests/unit/public-safety-browser-startup-process.test.ts
38 - uses: actions/upload-artifact@v4
39 if: always()
40 with:
41 name: browser-launch-native-evidence
42 if-no-files-found: ignore
43 retention-days: 7
44 path: |
45 ${{ runner.temp }}/browser-launch-exited/browser-launch.json
46 ${{ runner.temp }}/browser-launch-exited/chrome-startup-stderr.log
47 ${{ runner.temp }}/browser-launch-live/browser-launch.json
48 ${{ runner.temp }}/browser-launch-live/chrome-startup-stderr.log
3349 - name: Qualify cold build setup across the former hook deadline
3450 run: bun scripts/qualify-runtime-cold-build.ts "$RUNNER_TEMP/runtime-cold-build"
3551 - uses: actions/upload-artifact@v4
config/docs.tsmodified+2-0
Changes to config/docs.ts
@@ -51,6 +51,7 @@ const config: BunPressOptions = {
5151 { text: 'Raspberry Pi setup', link: '/raspberry-pi' },
5252 { text: 'Library API', link: '/api' },
5353 { text: 'Validation evidence', link: '/validation' },
54 { text: 'Browser qualification evidence', link: '/browser-qualification' },
5455 { text: 'Flight-development gates', link: '/flight-development' },
5556 ],
5657 },
@@ -108,6 +109,7 @@ const config: BunPressOptions = {
108109 { text: 'Synthetic airborne environmental sampling', link: '/environment' },
109110 { text: 'Simulated carried-load characterization', link: '/cargo-load' },
110111 { text: 'Modeled cargo delivery and custody', link: '/cargo-delivery' },
112 { text: 'Modeled cargo station handling', link: '/cargo-station' },
111113 { text: 'Simulated POI camera reframe', link: '/cinema' },
112114 ],
113115 },
docs/api.mdmodified+5-0
Changes to docs/api.md
@@ -62,6 +62,11 @@ provides the separate [point-load intake prerequisite](cargo-delivery.md#point-l
6262It integrates real modeled mass/CG changes after one coincident inelastic point capture.
6363The caller still owns the parcel, finite station handling and captured fleet custody protocol.
6464
65`createCargoStationHandler(declaration)` in `cargo/station.ts` owns one existing point
66through ballistic capture, finite rail transport, cancellation braking and a single
67departure. See the [station model and integration limits](cargo-station.md). It does not
68grant vehicle authority or confirm custody.
69
6570## Target sources
6671
6772`createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes
docs/browser-qualification.mdadded+80-0
Changes to docs/browser-qualification.md
@@ -0,0 +1,80 @@
1# Browser qualification observations
2
3The rendered workspace and execution checks launch a fresh native Chrome process in the
4existing fixture-owned process group. They retain the original launch arguments and the
5400-read, 50 ms DevToolsActivePort discovery guard. No retry or browser flag bypass is added.
6The full page/route/Worker scenarios must still pass. A successful native launcher fixture
7does not qualify those scenarios or explain an intermittent Chrome startup failure.
8
9## Startup evidence
10
11`browser-launch.json` retains its exit/port counters and adds a `startup` observation. The
12observer requests samples near launch, 1, 5, 10 and 15 seconds, then at discovery end. Samples
13run asynchronously during discovery, with at most one actual task in flight and a 500 ms
14cooperative cancellation budget. Cancellation stops further process/resource reads after
15outstanding native calls settle. It never releases ownership of an unfinished task. A timeout
16disables later Linux samples and hashing, closes captured descriptors after settlement and
17emits a bounded pending-task notice. Finalization waits for that task; it cannot allow a page
18visit behind it. An uninterruptible filesystem call can hold finalization until the existing
19outer fixture guard fails and terminates its group. This is not a hard OS syscall deadline.
20Actual start/completion epochs are retained; labels are requested milestones, not
21promises that an event occurred at that exact time. A pre-page cleanup sample follows the
22existing root termination/2 second wait. No process or resource sample is requested after a
23page visit. Post-page stderr remains private.
24
25Each sample includes a native root PID existence probe. Linux adds selected `/proc` state:
26parent/group/session, start/user/system ticks, thread count, RSS and kernel wait channel.
27Names, argv, environment, stacks and memory contents are not exported. Main-task child
28traversal and previously observed descendants are capped at 64 processes per sample; root
29thread wait details are capped at 32. Start ticks flag observed PID reuse. Fork/exit/reparenting,
30permissions and truncation can make this incomplete. It is not proof of whole-group cleanup.
31The outer fixture owner separately terminates its isolated group before disposable removal.
32
33Expected discovery-file metadata, observer cgroup-v2 counters and system CPU/memory/IO pressure
34help distinguish missing files, a live process and resource contention. Cgroup counters may
35cover other runner work; system pressure covers other host workloads. Neither proves Chrome
36causality. Unsupported platforms, missing files, permission errors and observation timeouts
37are explicit outcomes. Numeric kernel fields are selected and bounded; raw status, cgroup
38membership, thread names and arbitrary text are not retained.
39
40Startup samples pin at most four distinct actual Linux `/proc/PID/exe` file descriptors
41without reading executable bytes. Only after discovery ends does it hash them under one
422 second cooperative hash budget, with a 512 MiB per-executable limit. Device/inode identity uses exact
43integer strings. Byte count and file timestamps are checked before retaining a digest; all
44pinned descriptors are then closed, with close errors reported. This identifies an observed
45backing executable rather than assuming the configured launcher is Chrome. It neither warms
46the binary before the attempt nor authenticates the code or its entire loaded graph.
47
48The browser version is retained only from a successful CDP version response and a closed
49Chrome version format. If discovery fails, it stays null. Do not infer the failed binary's
50version from another run without matching actual identity evidence. Opening descriptors,
51reading process files and hashing are observation work and can affect scheduling; the
52observed times and unavailable/timeout outcomes must accompany comparisons.
53
54## Native qualification
55
56```sh
57bun test tests/unit/public-safety-browser-launch.test.ts tests/unit/public-safety-browser-launch-evidence.test.ts tests/unit/public-safety-browser-startup-process.test.ts
58```
59
60One actual native fixture exits 23 before discovery. Another owns a live child until the
61original cleanup request. The checks preserve all 400 failed probes, inspect actual PIDs,
62verify parent/child shutdown and ensure argv/environment sentinels do not enter the report.
63On Linux the live fixture also checks actual descendant/start/RSS data and the retained
64executable hash against the independently read runtime binary. Optional `BROWSER_LAUNCH_EVIDENCE`
65and `BROWSER_LAUNCH_LIVE_EVIDENCE` retain reports in new directories. CI publishes only the
66allowlisted launch JSON and startup stderr, not scripts, credentials or private fixture files.
67A held OS-adapter promise separately verifies that cancellation cannot overlap another sample,
68finalize early or begin hashing while an operation remains unfinished. It does not qualify
69Linux field acquisition; the actual native Linux fixture provides that evidence.
70A Linux cancellation case also opens real executable descriptors and checks that they are
71closed without a digest once the held native sample settles.
72
73Linux-only observations require Linux evidence; a macOS pass does not establish them.
74[Issue #72](https://github.com/stacksjs/drone/issues/72) retains the original failed attempts and
75passing controls. Its root cause and correction remain open until reproduced and verified.
76These observations change evidence, not aircraft control, physical outputs or flight readiness.
77
78Field semantics follow the kernel's [proc reference](https://docs.kernel.org/filesystems/proc.html),
79[cgroup-v2 reference](https://docs.kernel.org/admin-guide/cgroup-v2.html) and
80[pressure reference](https://docs.kernel.org/accounting/psi.html).
docs/cargo-delivery.mdmodified+40-0
Changes to docs/cargo-delivery.md
@@ -179,6 +179,46 @@ and limits. This primitive supplies no finite-duration contact forces or impact
179179release/intake acknowledgement, station handling, authorization or fleet custody handover.
180180Those integrations remain open under issue #25.
181181
182## Finite station handling prerequisite
183
184The separate [station actor](cargo-station.md) now retains one released point through
185ballistic surface capture, finite powered motion, cancellation braking and one departure
186to a declared receiving joint. Its public integration test uses actual source separation
187and actual receiving-body intake. The world coordinator must still consume the one parcel,
188check the receiver's current authority and produce correlated custody acknowledgement.
189This model alone does not implement a fleet mission or qualify electrical endurance.
190
191## Receiving mission task prerequisite
192
193The shared owned mission engine accepts `material-receive` only for quadcopters, with a
194non-null `containerId`, a required fixed-origin `positionM` pad anchor and an integer
195100 to 60,000 ms timeout. The anchor must be inside the mission fence and on or up to
1961 m above its declared zero surface. Normal and optional one-way alternate landing
197routes contribute to the longest-path time and declared reserve calculation.
198
199Entering intake immediately creates its scoped pending command, including the first
200control frame when the plan starts there. The quad controller uses qualified surface
201contact to keep modeled motor commands neutral during intake. This avoids a one-frame
202spin-up while waiting for the next mission evaluation. It does not erase existing motor
203lag, change mass or generate an acknowledgement.
204
205Mission observations must retain contact, speed at most 0.1 m/s and position within
2060.2 m of the pad. Losing these interlocks blocks receipt even if an ACK is present.
207A confirmed ACK still needs exact event/task/operation/container/reference correlation,
208a capture inside its deadline, freshness and a finite spatial check. ACK position requires
209three finite own coordinates; sparse arrays are rejected before acknowledgement handling. Timeout,
210foreign scope, rejection or uncertainty can follow a declared counted alternate route;
211they never advance the normal takeoff path or retry intake. A late unsolicited ACK
212blocks rather than reviving the original command.
213
214`tests/unit/cargo-receive-mission.test.ts` covers the native task and an actually empty
215receiving body in its own raw-sensor, estimator and authority runtime. With ACK withheld,
216it stays at the declared pad with zero actual motor thrust and no phantom parcel mass.
217The port that verifies an actual point intake and emits captured custody evidence is
218still unimplemented. A caller-invented ACK is not proof of mechanical transfer. The
219two-vehicle coordinator, fresh mechanism authority, cancellation/uncertainty matrix and
220bounded fleet CLI remain open under #25.
221
182222## BVLOS operations evidence
183223
184224
docs/cargo-station.mdadded+108-0
Changes to docs/cargo-station.md
@@ -0,0 +1,108 @@
1# Modeled cargo station handling
2
3`createCargoStationHandler` in `resources/functions/drone/cargo/station.ts` owns one
4existing point-parcel record through free fall, surface capture, a finite powered rail
5and one departure. This is a software simulation prerequisite for
6[distinct-vehicle cargo handover](cargo-delivery.md#remaining-issue-scope).
7Physical outputs remain disabled.
8
9## Use an actual parcel
10
11Pass the parcel produced by a carrier's actual `separateCarriedLoad()` event, adding
12its declared id. An empty receiving body's `intakePoint()` supplies a possible rail
13endpoint; it does not supply a parcel. The world coordinator must remove the previous
14owner when it creates the station and consume the departure exactly once. Independent
15station instances cannot detect a caller cloning a parcel into multiple worlds.
16
17```ts
18import { createCargoStationHandler } from '../resources/functions/drone/cargo/station'
19
20const station = createCargoStationHandler({
21 atMs: 4,
22 parcel: { id: 'parcel-a', massKg: 0.1, positionM: [0.08, 0, -1], velocityMps: [0, 0, 0] },
23 pickup: { centerM: [0.08, 0, 0], radiusM: 0.1 },
24 destinationM: [1.08, 0, 0],
25 maxSpeedMps: 0.5,
26 maxAccelerationMps2: 1,
27})
28```
29
30The literal parcel above is an isolated numerical example. The integration test uses
31real source separation and real receiving dynamics rather than manufacturing a parcel
32from the receiving joint. All positions, velocities and forces use fixed-origin LOCAL_NED:
33metres, metres per second and newtons, with down positive. Mass is kilograms and time
34is one modeled local clock in milliseconds.
35
36## Lifecycle and clock
37
38The constructor captures strict own-data records and dense finite three-element arrays.
39Unknown fields, accessors and invalid declarations throw `cargo-station-constructor`.
40Later caller mutation cannot alter retained state. Bounds are:
41
42- Safe nonnegative initial epoch aligned to 4 ms, leaving 180,000 ms of safe clock range.
43- Parcel id matching `[a-z][a-z0-9-]{0,39}`, mass greater than zero and at most 0.25 kg.
44- Declared coordinates within +/-1,000 m; initial velocity components within +/-5 m/s.
45- Horizontal pickup radius 0.05 to 2 m. The infinite rough capture plane has down in [-1, 0] m;
46 the parcel starts on that plane or up to 3 m above it.
47- Destination on or up to 1 m above the plane. Center-to-destination distance plus pickup
48 radius is at most 20 m, with worst declared rail duration at most 120,000 ms.
49- Spatial speed 0.05 to 1 m/s and acceleration 0.05 to 2 m/s².
50
51Call `step(previousAtMs + 4)` to advance the actor. Repeated, skipped, backward, fractional
52or exhausted epochs throw `cargo-station-clock` before mutation. The lifetime is at most
53180,000 ms from construction; the owning world must bound its run accordingly.
54
55Free fall uses exact constant-gravity kinematics and a quadratic within-step contact time.
56The ideal rough plane stops all velocity components. The retained impact contains its
57fractional contact epoch, actual point, incoming velocity, impulse and kinetic loss.
58The snapshot epoch is the enclosing 4 ms step endpoint. No force peak or impact duration
59is invented. A point outside the declared horizontal disc becomes `missed` at its actual
60contact location and cannot start transport.
61
62`start()` accepts only `held`, with enough lifetime for the full rail path. It never
63retries, moves a missed point onto the rail or starts during free fall. Rail motion uses
64a rest-to-rest quintic position law with derived duration respecting both declared spatial
65limits. The parcel follows the whole path; its final analytic endpoint is the declared
66position with zero velocity. A zero-length route still has a finite 504 ms duration.
67
68`cancel()` is one-way. A moving parcel enters `braking`, with constant deceleration at
69the declared acceleration limit until it stops at the physically integrated location.
70A falling parcel continues under gravity until plane contact. These requests return
71`cancelling`; `cancelled` means it was already at rest. Snapshots distinguish the request
72epoch, current phase and fractional stop epoch. Cancellation retains the parcel and
73prevents transfer; it does not restore an aircraft's authority or land a vehicle.
74
75`takeReadyParcel()` accepts only `ready`, returns a detached parcel and clears the
76station's owned mass. Further departure attempts are refused. The receiving body's
77`attachParcel()` still independently checks its actual current joint. A stale destination
78can therefore fail intake; the world must retain the departed parcel and an uncertain
79outcome rather than moving or duplicating it to make receipt succeed.
80
81## Mechanical accounting
82
83Snapshots retain current and departed mass/energy, rail force, impact loss and work.
84Mechanical energy is `0.5 * mass * speed² - mass * gravity * down`. Free fall conserves
85that quantity to binary64 accumulation tolerance. Surface capture dissipates the incoming
86kinetic energy. Rail force includes gravity support; lifting increases potential energy.
87`stationNetWorkJ` accumulates the represented mechanical-energy change during rail motion
88and braking. Positive and negative work fields sum positive and signed negative **sampled
89energy increments**, not a continuous actuator-efficiency or power integration.
90
91The residual is `current + departed + planeDissipation - initial - stationNetWork`.
92It exposes numerical accumulation instead of substituting a declared battery budget.
93This model has no motor, gearbox, battery, measured efficiency, station recoil, parcel
94spin, deformation or physical mechanism qualification.
95
96## Evidence and remaining integration
97
98`tests/unit/cargo-station.test.ts` exercises actual source/station/receiver mass transfer,
99finite displacement, a worked midpoint, ballistic contact time/impulse/energy, pickup miss,
100cancellation during rising flight and powered motion, spatial speed/acceleration bounds,
101vertical work, strict admission, detached state and lifetime rejection.
102
103This actor does not grant a mechanism lease, authenticate a recipient, produce a custody
104ACK or coordinate two live vehicle mission engines. Those operations must use each
105vehicle's own sensor, estimator and current authority scope on the common clock. The
106[receiving mission task](cargo-delivery.md#receiving-mission-task-prerequisite) now holds
107a declared pad under the shared engine. Its physical-intake acknowledgement port, the
108two-vehicle custody protocol, cancellation/uncertainty matrix and bounded fleet CLI remain open under [#25](https://github.com/stacksjs/drone/issues/25).
docs/flight-development.mdmodified+3-0
Changes to docs/flight-development.md
@@ -23,3 +23,6 @@ The owned runtime follows the dependency-linked [roadmap](roadmap/README.md). Th
2323support comparison and development while native board controls are implemented and validated.
2424
2525Until those milestones are complete, the code here should be used for simulation and bench development.
26
27Rendered application checks have a separate [browser qualification contract](browser-qualification.md).
28A browser-startup failure does not establish a vehicle, estimator or physical flight failure.
docs/vehicles.mdmodified+5-0
Changes to docs/vehicles.md
@@ -101,6 +101,11 @@ Modeled acknowledgements preserve capture time separately from delivery. An unce
101101result blocks progression and is never automatically retried as a new action. Recovery during a
102102pending action also blocks. These identities do not establish exactly-once delivery on hardware.
103103
104The opt-in quadcopter `material-receive` task adds a declared pad anchor and a bounded
105wait for one correlated receipt. It enters with inhibited motor commands under qualified
106surface contact. Its [interlocks and counted failure routes](cargo-delivery.md#receiving-mission-task-prerequisite)
107are distinct from the still-pending mechanical-intake and fleet custody coordinator.
108
104109Surface sounding reduction retains raw acoustic corrections, local datum, capture-pose estimator
105110and gyro epochs and declared position/attitude/clock uncertainty. It projects the beam footprint and
106111rejects unqualified capture timing or uncertainty below the sensor declaration. This is a modeled
resources/functions/drone/cargo/station.tsadded+166-0
Changes to resources/functions/drone/cargo/station.ts
@@ -0,0 +1,166 @@
1import type { IntakeParcel } from '../controls/physics'
2import type { SensorVector } from '../sensors/types'
3import { multirotorModel } from '../controls/model'
4
5interface StationDeclaration {
6 atMs: number
7 parcel: IntakeParcel
8 pickup: { centerM: SensorVector, radiusM: number }
9 destinationM: SensorVector
10 maxSpeedMps: number
11 maxAccelerationMps2: number
12}
13export interface CargoStationSnapshot {
14 evidence: 'modeled-cargo-station-v1'
15 physicalActuationEnabled: false
16 phase: 'falling' | 'held' | 'missed' | 'moving' | 'braking' | 'cancelled' | 'ready' | 'departed'
17 atMs: number
18 startedAtMs: number | null
19 durationMs: number | null
20 cancelRequestedAtMs: number | null
21 stoppedAtMs: number | null
22 ownedMassKg: number
23 parcel: IntakeParcel | null
24 forceN: SensorVector
25 impact: { atMs: number, positionM: SensorVector, velocityBeforeMps: SensorVector, impulseNs: SensorVector, dissipationJ: number } | null
26 energy: { initialJ: number, currentJ: number, departedJ: number, stationNetWorkJ: number, positiveWorkJ: number, negativeWorkJ: number, planeDissipationJ: number, residualJ: number }
27}
28function record(input: unknown, names: string[]) {
29 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== names.length) throw new TypeError()
30 const captured: Record<string, unknown> = {}
31 for (const name of names) {
32 const d = Object.getOwnPropertyDescriptor(input, name)
33 if (!d?.enumerable || !Object.hasOwn(d, 'value')) throw new TypeError()
34 captured[name] = d.value
35 }
36 return captured
37}
38function vector(input: unknown, min: number, max: number): SensorVector {
39 if (!Array.isArray(input) || input.length !== 3 || Reflect.ownKeys(input).length !== 4) throw new TypeError()
40 const captured: number[] = []
41 for (let i = 0; i < 3; i++) {
42 const d = Object.getOwnPropertyDescriptor(input, String(i))
43 if (!d?.enumerable || !Object.hasOwn(d, 'value') || typeof d.value !== 'number' || !Number.isFinite(d.value) || d.value < min || d.value > max) throw new TypeError()
44 captured.push(d.value)
45 }
46 return captured as SensorVector
47}
48const within = (v: unknown, min: number, max: number): v is number => typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max
49const distance = (a: SensorVector, b: SensorVector) => Math.hypot(...a.map((n, i) => n - b[i]!))
50function durationMs(lengthM: number, speed: number, acceleration: number) {
51 // Peak derivatives of 10u^3 - 15u^4 + 6u^5 are 15/8 and 10/sqrt(3).
52 return Math.ceil(1000 * Math.max(.5, 1.875 * lengthM / speed, Math.sqrt(10 / Math.sqrt(3) * lengthM / acceleration)) / 8) * 8
53}
54function capture(input: unknown): StationDeclaration {
55 try {
56 const v = record(input, ['atMs', 'parcel', 'pickup', 'destinationM', 'maxSpeedMps', 'maxAccelerationMps2'])
57 const p = record(v.parcel, ['id', 'massKg', 'positionM', 'velocityMps']), pickup = record(v.pickup, ['centerM', 'radiusM'])
58 if (!Number.isSafeInteger(v.atMs) || !within(v.atMs, 0, Number.MAX_SAFE_INTEGER - 180000) || v.atMs % 4 !== 0
59 || typeof p.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(p.id) || !within(p.massKg, Number.MIN_VALUE, .25)
60 || !within(pickup.radiusM, .05, 2) || !within(v.maxSpeedMps, .05, 1) || !within(v.maxAccelerationMps2, .05, 2)) throw new TypeError()
61 const positionM = vector(p.positionM, -1000, 1000), velocityMps = vector(p.velocityMps, -5, 5), centerM = vector(pickup.centerM, -1000, 1000), destinationM = vector(v.destinationM, -1000, 1000)
62 if (!within(centerM[2], -1, 0) || !within(positionM[2], centerM[2] - 3, centerM[2]) || destinationM[2] < centerM[2] - 1 || destinationM[2] > centerM[2]
63 || distance(centerM, destinationM) + pickup.radiusM > 20
64 || durationMs(distance(centerM, destinationM) + pickup.radiusM, v.maxSpeedMps, v.maxAccelerationMps2) > 120000) throw new TypeError()
65 return { atMs: v.atMs, parcel: { id: p.id, massKg: p.massKg, positionM, velocityMps }, pickup: { centerM, radiusM: pickup.radiusM }, destinationM, maxSpeedMps: v.maxSpeedMps, maxAccelerationMps2: v.maxAccelerationMps2 }
66 }
67 catch { throw new TypeError('cargo-station-constructor') }
68}
69
70/** One point parcel on a prescribed, finite powered station rail. No hardware IO or custody ACK. */
71export function createCargoStationHandler(input: unknown) {
72 const config = capture(input), gravity = multirotorModel.gravityMps2
73 let parcel: IntakeParcel | null = config.parcel, phase: CargoStationSnapshot['phase'] = 'falling', nowMs = config.atMs
74 let startedAtMs: number | null = null, motionMs: number | null = null, fromM: SensorVector | null = null
75 let cancelRequestedAtMs: number | null = null, stoppedAtMs: number | null = null
76 let brake: { atMs: number, positionM: SensorVector, velocityMps: SensorVector, speedMps: number } | null = null
77 let forceN: SensorVector = [0, 0, 0], impact: CargoStationSnapshot['impact'] = null
78 const mechanical = (p: IntakeParcel) => .5 * p.massKg * p.velocityMps.reduce((sum, n) => sum + n * n, 0) - p.massKg * gravity * p.positionM[2]
79 const initialJ = mechanical(parcel)
80 let departedJ = 0, stationNetWorkJ = 0, positiveWorkJ = 0, negativeWorkJ = 0, planeDissipationJ = 0
81 function capturePlane(atMs: number) {
82 const p = parcel!, before = [...p.velocityMps] as SensorVector
83 const dissipationJ = .5 * p.massKg * before.reduce((sum, n) => sum + n * n, 0)
84 p.positionM[2] = config.pickup.centerM[2]
85 impact = { atMs, positionM: [...p.positionM], velocityBeforeMps: before, impulseNs: before.map(n => -p.massKg * n) as SensorVector, dissipationJ }
86 planeDissipationJ += dissipationJ
87 p.velocityMps = [0, 0, 0]; forceN = [0, 0, -p.massKg * gravity]
88 phase = cancelRequestedAtMs !== null ? 'cancelled' : Math.hypot(p.positionM[0] - config.pickup.centerM[0], p.positionM[1] - config.pickup.centerM[1]) <= config.pickup.radiusM ? 'held' : 'missed'
89 if (cancelRequestedAtMs !== null) stoppedAtMs = atMs
90 }
91 if (parcel.positionM[2] === config.pickup.centerM[2] && parcel.velocityMps[2] >= 0) capturePlane(nowMs)
92 function retainWork(beforeJ: number) {
93 const work = mechanical(parcel!) - beforeJ
94 stationNetWorkJ += work; positiveWorkJ += Math.max(0, work); negativeWorkJ += Math.min(0, work)
95 }
96 function snapshot(): CargoStationSnapshot {
97 const currentJ = parcel ? mechanical(parcel) : 0
98 return structuredClone({ evidence: 'modeled-cargo-station-v1', physicalActuationEnabled: false, phase, atMs: nowMs, startedAtMs, durationMs: motionMs, cancelRequestedAtMs, stoppedAtMs, parcel,
99 ownedMassKg: parcel?.massKg ?? 0, forceN, impact, energy: { initialJ, currentJ, departedJ, stationNetWorkJ, positiveWorkJ, negativeWorkJ, planeDissipationJ, residualJ: currentJ + departedJ + planeDissipationJ - initialJ - stationNetWorkJ } })
100 }
101 return {
102 snapshot,
103 start(...args: unknown[]): { outcome: 'started', atMs: number, durationMs: number } | { outcome: 'refused', reason: 'not-held' | 'deadline' } {
104 if (args.length) throw new TypeError('cargo-station-arguments')
105 if (phase !== 'held') return { outcome: 'refused', reason: 'not-held' }
106 const duration = durationMs(distance(parcel!.positionM, config.destinationM), config.maxSpeedMps, config.maxAccelerationMps2)
107 if (nowMs - config.atMs + duration > 180000) return { outcome: 'refused', reason: 'deadline' }
108 fromM = [...parcel!.positionM]; motionMs = duration; startedAtMs = nowMs; phase = 'moving'
109 return { outcome: 'started', atMs: nowMs, durationMs: duration }
110 },
111 cancel(...args: unknown[]): { outcome: 'cancelling' | 'cancelled', atMs: number } | { outcome: 'refused', reason: 'already-cancelled' | 'departed' } {
112 if (args.length) throw new TypeError('cargo-station-arguments')
113 if (phase === 'departed') return { outcome: 'refused', reason: 'departed' }
114 if (cancelRequestedAtMs !== null) return { outcome: 'refused', reason: 'already-cancelled' }
115 cancelRequestedAtMs = nowMs
116 const speedMps = Math.hypot(...parcel!.velocityMps)
117 if (phase === 'moving' && speedMps > 0) {
118 brake = { atMs: nowMs, positionM: [...parcel!.positionM], velocityMps: [...parcel!.velocityMps], speedMps }
119 phase = 'braking'
120 }
121 else if (phase !== 'falling') { phase = 'cancelled'; stoppedAtMs = nowMs }
122 return { outcome: phase === 'cancelled' ? 'cancelled' : 'cancelling', atMs: nowMs }
123 },
124 step(atMs: number, ...extra: unknown[]) {
125 if (extra.length || !Number.isSafeInteger(atMs) || atMs !== nowMs + 4 || atMs - config.atMs > 180000) throw new TypeError('cargo-station-clock')
126 if (phase === 'falling') {
127 const p = parcel!, down = p.positionM[2] - config.pickup.centerM[2], vz = p.velocityMps[2]
128 const speedAtPlane = Math.sqrt(vz * vz - 2 * gravity * down)
129 // Stable positive root for downward motion, including a point already at the plane.
130 const hitSeconds = vz >= 0 ? (down === 0 ? 0 : -2 * down / (vz + speedAtPlane)) : (-vz + speedAtPlane) / gravity
131 const seconds = Math.min(.004, hitSeconds)
132 p.positionM = p.positionM.map((n, i) => n + p.velocityMps[i]! * seconds + (i === 2 ? .5 * gravity * seconds ** 2 : 0)) as SensorVector
133 p.velocityMps[2] += gravity * seconds
134 if (hitSeconds <= .004) capturePlane(nowMs + hitSeconds * 1000)
135 }
136 else if (phase === 'braking') {
137 const b = brake!, p = parcel!, beforeJ = mechanical(p), stopSeconds = b.speedMps / config.maxAccelerationMps2
138 const seconds = Math.min((atMs - b.atMs) / 1000, stopSeconds), fraction = Math.max(0, 1 - seconds / stopSeconds)
139 p.positionM = b.positionM.map((n, i) => n + b.velocityMps[i]! * (seconds - .5 * seconds ** 2 / stopSeconds)) as SensorVector
140 p.velocityMps = b.velocityMps.map(n => n * fraction) as SensorVector
141 forceN = b.velocityMps.map((n, i) => p.massKg * (-n / stopSeconds - (i === 2 ? gravity : 0))) as SensorVector
142 retainWork(beforeJ)
143 if (seconds === stopSeconds) { phase = 'cancelled'; stoppedAtMs = b.atMs + stopSeconds * 1000; forceN = [0, 0, -p.massKg * gravity] }
144 }
145 else if (phase === 'moving') {
146 const beforeJ = mechanical(parcel!), seconds = motionMs! / 1000, u = Math.min(1, (atMs - startedAtMs!) / motionMs!)
147 const s = 10 * u ** 3 - 15 * u ** 4 + 6 * u ** 5, ds = 30 * u ** 2 * (1 - u) ** 2, dds = 60 * u * (1 - u) * (1 - 2 * u)
148 const delta = config.destinationM.map((n, i) => n - fromM![i]!) as SensorVector
149 parcel!.positionM = u === 1 ? [...config.destinationM] : fromM!.map((n, i) => n + delta[i]! * s) as SensorVector
150 parcel!.velocityMps = delta.map(n => n * ds / seconds) as SensorVector
151 forceN = delta.map((n, i) => parcel!.massKg * (n * dds / seconds ** 2 - (i === 2 ? gravity : 0))) as SensorVector
152 retainWork(beforeJ)
153 if (u === 1) phase = 'ready'
154 }
155 nowMs = atMs
156 return snapshot()
157 },
158 takeReadyParcel(...args: unknown[]): { outcome: 'departed', atMs: number, parcel: IntakeParcel } | { outcome: 'refused', reason: 'not-ready' } {
159 if (args.length) throw new TypeError('cargo-station-arguments')
160 if (phase !== 'ready') return { outcome: 'refused', reason: 'not-ready' }
161 const outgoing = structuredClone(parcel!)
162 departedJ = mechanical(parcel!); parcel = null; phase = 'departed'; forceN = [0, 0, 0]
163 return { outcome: 'departed', atMs: nowMs, parcel: outgoing }
164 },
165 }
166}
resources/functions/drone/missions/vehicle.tsmodified+25-6
Changes to resources/functions/drone/missions/vehicle.ts
@@ -1,3 +1,4 @@
1import type { SensorVector } from '../sensors/types'
12import type { VehicleMissionAction, VehicleMissionObservation, VehicleMissionPlan, VehicleMissionSnapshot, VehicleMissionTask, VehiclePayloadAck, VehiclePayloadCommand, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../vehicles/types'
23import type { MissionState } from './engine'
34import { vehicleKinds } from '../vehicles/types'
@@ -41,10 +42,13 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
4142 }
4243 else if (t.kind === 'payload') {
4344 fields.push('operation', 'containerId', 'timeoutMs', 'next', 'onFailure')
44 const supported = p.vehicle === 'quadcopter' ? ['camera-still', 'material-drop'] : p.vehicle === 'fixed-wing' || p.vehicle === 'vtol' ? ['camera-still'] : p.vehicle === 'ground' ? ['camera-still', 'material-drop', 'mower-enable', 'mower-disable'] : p.vehicle === 'surface' ? ['camera-still', 'water-sample'] : ['camera-still', 'inspection-capture']
45 if (!supported.includes(t.operation) || (t.containerId !== null && !id(t.containerId)) || (t.operation === 'water-sample' && !id(t.containerId)) || !number(t.timeoutMs, 100, 10000) || !id(t.next)) invalid()
46 if (p.vehicle === 'quadcopter' && t.operation === 'material-drop' && !id(t.containerId)) invalid()
47 if (t.onFailure !== undefined && (p.vehicle !== 'quadcopter' || t.operation !== 'material-drop' || !id(t.onFailure))) invalid()
45 const receiving = t.operation === 'material-receive'
46 if (receiving) fields.push('positionM')
47 const supported = p.vehicle === 'quadcopter' ? ['camera-still', 'material-drop', 'material-receive'] : p.vehicle === 'fixed-wing' || p.vehicle === 'vtol' ? ['camera-still'] : p.vehicle === 'ground' ? ['camera-still', 'material-drop', 'mower-enable', 'mower-disable'] : p.vehicle === 'surface' ? ['camera-still', 'water-sample'] : ['camera-still', 'inspection-capture']
48 if (!supported.includes(t.operation) || (t.containerId !== null && !id(t.containerId)) || (t.operation === 'water-sample' && !id(t.containerId)) || !number(t.timeoutMs, 100, receiving ? 60000 : 10000) || !id(t.next)) invalid()
49 if (p.vehicle === 'quadcopter' && ['material-drop', 'material-receive'].includes(t.operation) && !id(t.containerId)) invalid()
50 if (receiving && (!Number.isSafeInteger(t.timeoutMs) || !vehicleMissionContains(p, t.positionM) || t.positionM[2] < -1 || t.positionM[2] > 0)) invalid()
51 if (t.onFailure !== undefined && (p.vehicle !== 'quadcopter' || !['material-drop', 'material-receive'].includes(t.operation) || !id(t.onFailure))) invalid()
4852 durationMs = t.timeoutMs; edges = t.onFailure === undefined ? [t.next] : [t.next, t.onFailure]
4953 }
5054 else if (['takeoff', 'waypoint', 'land', 'stop', 'loiter', 'return'].includes(t.kind)) {
@@ -86,11 +90,18 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
8690 return { plan: structuredClone(p), worstCaseMs, requiredWh }
8791}
8892export interface VehicleMissionTrace { version: 2 | 3, evidence: 'owned-vehicle-mission-simulation', plan: VehicleMissionPlan, rows: { observation: VehicleMissionObservation, action: VehicleMissionAction | null, result: VehicleMissionSnapshot }[] }
93function validAckPosition(value: unknown): value is SensorVector {
94 return validVector(value) && [0, 1, 2].every(i => Object.hasOwn(value, i))
95}
8996function validAck(value: unknown): value is VehiclePayloadAck {
90 if (!object(value) || !keys(value, ['eventId', 'taskId', 'operation', 'containerId', 'capturedAtMs', 'reference', 'positionM', 'outcome', 'metadata']) || typeof value.eventId !== 'string' || value.eventId.length > 200 || !id(value.taskId) || !['camera-still', 'water-sample', 'material-drop', 'mower-enable', 'mower-disable', 'inspection-capture'].includes(value.operation as string) || (value.containerId !== null && !id(value.containerId)) || !Number.isSafeInteger(value.capturedAtMs) || (value.capturedAtMs as number) < 0 || typeof value.reference !== 'string' || value.reference.length > 120 || !validVector(value.positionM) || !['confirmed', 'rejected', 'uncertain'].includes(value.outcome as string)) return false
97 if (!object(value) || !keys(value, ['eventId', 'taskId', 'operation', 'containerId', 'capturedAtMs', 'reference', 'positionM', 'outcome', 'metadata']) || typeof value.eventId !== 'string' || value.eventId.length > 200 || !id(value.taskId) || !['camera-still', 'water-sample', 'material-drop', 'material-receive', 'mower-enable', 'mower-disable', 'inspection-capture'].includes(value.operation as string) || (value.containerId !== null && !id(value.containerId)) || !Number.isSafeInteger(value.capturedAtMs) || (value.capturedAtMs as number) < 0 || typeof value.reference !== 'string' || value.reference.length > 120 || !validAckPosition(value.positionM) || !['confirmed', 'rejected', 'uncertain'].includes(value.outcome as string)) return false
9198 if (value.metadata !== undefined && (!object(value.metadata) || Object.keys(value.metadata).length > 32 || Object.entries(value.metadata).some(([key, item]) => !/^[a-zA-Z][a-zA-Z0-9_-]{0,39}$/.test(key) || !(typeof item === 'boolean' || (typeof item === 'number' && Number.isFinite(item)) || (typeof item === 'string' && item.length <= 160))))) return false
9299 return true
93100}
101function receivingAtPad(task: Extract<VehicleMissionTask, { operation: 'material-receive' }>, o: VehicleMissionObservation) {
102 return o.onGround && o.positionM !== null && o.velocityMps !== null && Math.hypot(...o.velocityMps) <= .1
103 && Math.hypot(...o.positionM.map((n, i) => n - task.positionM[i]!)) <= .2
104}
94105function validTransition(value: unknown): value is VehicleVtolTransitionObservation {
95106 return object(value) && keys(value, ['evidence', 'eventId', 'taskId', 'mode', 'reference', 'capturedAtMs', 'measurementCapturedAtMs', 'phase', 'outcome', 'reason'])
96107 && value.evidence === 'modeled-vtol-controller-observation' && typeof value.eventId === 'string' && value.eventId.length <= 256 && id(value.taskId) && ['hover', 'cruise'].includes(value.mode as string)
@@ -253,11 +264,13 @@ export function createVehicleMissionEngine(input: unknown) {
253264 else reason = 'vtol-transition-progress'
254265 }
255266 }
267 else if (t.kind === 'payload' && t.operation === 'material-receive' && !receivingAtPad(t, o)) block('material-receive-interlocks')
256268 else if (t.kind === 'payload') {
257269 pending ??= { eventId: `${plan.id}:${reference}:${t.id}`, taskId: t.id, operation: t.operation, containerId: t.containerId, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + t.timeoutMs - taskElapsedMs, positionM: [...o.positionM], reference: reference! }
258270 const ack = o.payloadAck
259271 if (ack) {
260 const scopeMismatch = ack.eventId !== pending.eventId || ack.taskId !== pending.taskId || ack.operation !== pending.operation || ack.containerId !== pending.containerId || ack.reference !== pending.reference || ack.capturedAtMs < pending.issuedAtMs || ack.capturedAtMs >= pending.expiresAtMs || ack.capturedAtMs > o.nowMs || o.nowMs - ack.capturedAtMs > 200 || Math.hypot(...ack.positionM.map((n, i) => n - pending!.positionM[i]!)) > 1
272 const separationM = Math.hypot(...ack.positionM.map((n, i) => n - pending!.positionM[i]!))
273 const scopeMismatch = ack.eventId !== pending.eventId || ack.taskId !== pending.taskId || ack.operation !== pending.operation || ack.containerId !== pending.containerId || ack.reference !== pending.reference || ack.capturedAtMs < pending.issuedAtMs || ack.capturedAtMs >= pending.expiresAtMs || ack.capturedAtMs > o.nowMs || o.nowMs - ack.capturedAtMs > 200 || !Number.isFinite(separationM) || separationM > 1
261274 if (scopeMismatch || ack.outcome !== 'confirmed') { if (!divertPayload(t, o, scopeMismatch ? 'scope-mismatch' : ack.outcome === 'rejected' ? 'rejected' : 'uncertain')) block('payload-outcome-uncertain') }
262275 else { advance(t.next); reason = 'payload-confirmed' }
263276 }
@@ -277,6 +290,12 @@ export function createVehicleMissionEngine(input: unknown) {
277290 }
278291 }
279292 }
293 // Intake must inhibit the first control frame, including entry directly from start.
294 const entered = tasks.get(taskId)!
295 if (state === 'running' && entered.kind === 'payload' && entered.operation === 'material-receive') {
296 if (!receivingAtPad(entered, o)) block('material-receive-interlocks')
297 else pending ??= { eventId: `${plan.id}:${reference}:${entered.id}`, taskId: entered.id, operation: entered.operation, containerId: entered.containerId, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + entered.timeoutMs - taskElapsedMs, positionM: [...o.positionM], reference: reference! }
298 }
280299 const result = snapshot()
281300 rows.push({ observation: o, action: action ?? null, result: structuredClone(result) })
282301 return result
resources/functions/drone/vehicles/multirotor/profile.tsmodified+1-1
Changes to resources/functions/drone/vehicles/multirotor/profile.ts
@@ -48,7 +48,7 @@ function createSession(): VehicleSession {
4848 angularRateRadS: rate?.angularRateRadS ?? [0, 0, 0], rateCapturedAtMs: rate?.capturedAtMs ?? -Infinity,
4949 target: { positionM: context.mission.targetM ?? [0, 0, 0], yawRad: 0, issuedAtMs: context.nowMs } })
5050 const measurement = context.measurement
51 const contact = !output.inhibited && context.mission.taskKind === 'land' && measurement?.vehicle === 'quadcopter' && measurement.evidence === 'modeled-vehicle-sensors'
51 const contact = !output.inhibited && (context.mission.taskKind === 'land' || context.mission.payload?.operation === 'material-receive') && measurement?.vehicle === 'quadcopter' && measurement.evidence === 'modeled-vehicle-sensors'
5252 && measurement.reference === rate?.reference && measurement.capturedAtMs <= context.nowMs && context.nowMs - measurement.capturedAtMs <= 120 && measurement.receivedAtMs <= context.nowMs && measurement.values.onSurface === true
5353 return { kind: 'simulated-vehicle-control', vehicle: 'quadcopter', atMs: context.nowMs, actuators: contact ? [0, 0, 0, 0] : [...output.motorsN], inhibited: fault !== null || output.inhibited, reason: fault ?? (contact ? 'quad-qualified-contact' : output.reason), physicalFlightEnabled: false, boardOutputsInhibited: true }
5454 } }
resources/functions/drone/vehicles/types.tsmodified+3-2
Changes to resources/functions/drone/vehicles/types.ts
@@ -44,7 +44,7 @@ export interface VehicleBatteryObservation {
4444 terminalV: number
4545 cutoff: boolean
4646}
47export type VehiclePayloadOperation = 'camera-still' | 'material-drop' | 'water-sample' | 'mower-enable' | 'mower-disable' | 'inspection-capture'
47export type VehiclePayloadOperation = 'camera-still' | 'material-drop' | 'material-receive' | 'water-sample' | 'mower-enable' | 'mower-disable' | 'inspection-capture'
4848export interface VehiclePayloadCommand { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string }
4949export interface VehiclePayloadAck { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, capturedAtMs: number, reference: string, positionM: SensorVector, outcome: 'confirmed' | 'rejected' | 'uncertain', metadata?: Record<string, number | string | boolean> }
5050export interface VehiclePayloadPort { observe?(context: VehicleControllerContext): void, request(command: VehiclePayloadCommand): void, poll(nowMs: number): VehiclePayloadAck | null, close(): void }
@@ -89,7 +89,8 @@ export type VehicleMissionTask =
8989 | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string }
9090 | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
9191 | { id: string, kind: 'loiter', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
92 | { id: string, kind: 'payload', operation: VehiclePayloadOperation, containerId: string | null, timeoutMs: number, next: string, onFailure?: string }
92 | { id: string, kind: 'payload', operation: Exclude<VehiclePayloadOperation, 'material-receive'>, containerId: string | null, timeoutMs: number, next: string, onFailure?: string }
93 | { id: string, kind: 'payload', operation: 'material-receive', containerId: string, positionM: SensorVector, timeoutMs: number, next: string, onFailure?: string }
9394 | { id: string, kind: 'branch', batteryAtLeast: number, then: string, otherwise: string }
9495 | { id: string, kind: 'abort' }
9596/** Explicit v2 profile; v1 quadcopter plans and traces retain their original contract. */
tests/helpers/browser-launch-evidence.tsmodified+2-0
Changes to tests/helpers/browser-launch-evidence.ts
@@ -2,12 +2,14 @@ import { Buffer } from 'node:buffer'
22import { writeSync } from 'node:fs'
33import { mkdir, open, writeFile } from 'node:fs/promises'
44import { join } from 'node:path'
5import type { BrowserStartupEvidence } from './browser-startup-process'
56
67export interface BrowserLaunchObservation {
78 phase: 'startup-unavailable' | 'discovery-failed' | 'closed'
89 elapsedMs: number
910 launch: { pid: number, exitObserved: boolean, settledExitCode: number | null, signalCode: string | number | null, exitObservedBeforeCleanup: boolean, cleanupRequested: boolean, exitElapsedMs: number | null }
1011 portProbe: { attempts: number, successfulReads: number, failedReads: number, lastReadError: string | null, discovered: boolean }
12 startup?: BrowserStartupEvidence
1113}
1214export function browserFileErrorCode(error: unknown): string {
1315 const code = (error as NodeJS.ErrnoException | null)?.code
tests/helpers/browser-startup-linux.tsadded+167-0
Changes to tests/helpers/browser-startup-linux.ts
@@ -0,0 +1,167 @@
1import { createHash } from 'node:crypto'
2import { lstat, open, readFile, readlink, readdir } from 'node:fs/promises'
3import type { FileHandle } from 'node:fs/promises'
4import { join } from 'node:path'
5import { browserFileErrorCode } from './browser-launch-evidence'
6
7const processLimit = 64, threadLimit = 32, executableLimit = 4, executableByteLimit = 512 * 1024 * 1024
8async function text(path: string, signal: AbortSignal) {
9 if (signal.aborted) return { value: null, code: 'ABORT_ERR' }
10 try {
11 const value = await readFile(path, { encoding: 'utf8', signal })
12 return value.length <= 32768 ? { value, code: null } : { value: null, code: 'BYTE_LIMIT' }
13 }
14 catch (error) { return { value: null, code: browserFileErrorCode(error) } }
15}
16const decimal = (value: string | undefined) => value && /^\d{1,20}$/.test(value) ? value : null
17const integer = (value: string | undefined) => { const n = decimal(value); return n !== null && Number.isSafeInteger(Number(n)) ? Number(n) : null }
18async function processState(pid: number, signal: AbortSignal) {
19 const [raw, status, wait, children] = await Promise.all([text(`/proc/${pid}/stat`, signal), text(`/proc/${pid}/status`, signal), text(`/proc/${pid}/wchan`, signal), text(`/proc/${pid}/task/${pid}/children`, signal)])
20 signal.throwIfAborted()
21 const end = raw.value?.lastIndexOf(') ') ?? -1
22 // tcomm may contain spaces/parentheses and is deliberately discarded.
23 const fields = end >= 0 ? raw.value!.slice(end + 2).trim().split(/\s+/) : []
24 const valid = fields.length >= 22 && /^[RSDZTtXxKWPI]$/.test(fields[0]!) && decimal(fields[19]) !== null
25 const rss = status.value?.match(/^VmRSS:\s+(\d+)\s+kB$/m)?.[1]
26 const ids = children.value?.trim().split(/\s+/).map(integer).filter((n): n is number => n !== null && n > 0) ?? []
27 return {
28 record: { pid, code: valid ? null : raw.code ?? 'FORMAT', state: valid ? fields[0]! : null,
29 parentPid: valid ? integer(fields[1]) : null, group: valid ? integer(fields[2]) : null, session: valid ? integer(fields[3]) : null,
30 startTicks: valid ? decimal(fields[19]) : null, userTicks: valid ? decimal(fields[11]) : null, systemTicks: valid ? decimal(fields[12]) : null,
31 threads: valid ? integer(fields[17]) : null, rssKiB: integer(rss), statusCode: status.code,
32 wait: wait.value !== null && /^[A-Za-z0-9_.:]{1,80}$/.test(wait.value.trim()) ? wait.value.trim() : null, waitCode: wait.code, childrenCode: children.code },
33 children: ids.slice(0, processLimit), childrenCapped: ids.length > processLimit,
34 }
35}
36async function counters(path: string, allowed: string[], signal: AbortSignal) {
37 const result = await text(path, signal), values: Record<string, string> = {}
38 signal.throwIfAborted()
39 for (const line of result.value?.split('\n') ?? []) {
40 const [key, value] = line.trim().split(/\s+/)
41 if (key && allowed.includes(key) && decimal(value) !== null) values[key] = value!
42 }
43 return { values, code: result.code }
44}
45async function scalar(path: string, signal: AbortSignal) {
46 const result = await text(path, signal), value = result.value?.trim()
47 signal.throwIfAborted()
48 return { value: value === 'max' || decimal(value) !== null ? value! : null, code: result.code ?? (value === 'max' || decimal(value) !== null ? null : 'FORMAT') }
49}
50async function pressure(resource: string, signal: AbortSignal) {
51 const result = await text(`/proc/pressure/${resource}`, signal), values: Record<string, { avg10: number, avg60: number, avg300: number, totalMicroseconds: string }> = {}
52 for (const line of result.value?.split('\n') ?? []) {
53 const match = /^(some|full) avg10=(\d{1,3}(?:\.\d{1,6})?) avg60=(\d{1,3}(?:\.\d{1,6})?) avg300=(\d{1,3}(?:\.\d{1,6})?) total=(\d{1,20})$/.exec(line)
54 if (match) values[match[1]!] = { avg10: Number(match[2]), avg60: Number(match[3]), avg300: Number(match[4]), totalMicroseconds: match[5]! }
55 }
56 return { values, code: result.code }
57}
58async function resources(signal: AbortSignal) {
59 signal.throwIfAborted()
60 const membership = await text('/proc/self/cgroup', signal)
61 signal.throwIfAborted()
62 const path = membership.value?.split('\n').find(line => line.startsWith('0::'))?.slice(3)
63 const base = path?.startsWith('/') && !path.split('/').includes('..') ? join('/sys/fs/cgroup', path) : null
64 const [cpuPressure, memoryPressure, ioPressure] = await Promise.all(['cpu', 'memory', 'io'].map(name => pressure(name, signal)))
65 signal.throwIfAborted()
66 const cgroup = base ? {
67 memoryCurrent: await scalar(join(base, 'memory.current'), signal), memoryMax: await scalar(join(base, 'memory.max'), signal),
68 memoryEvents: await counters(join(base, 'memory.events'), ['low', 'high', 'max', 'oom', 'oom_kill', 'oom_group_kill', 'sock_throttled'], signal),
69 cpu: await counters(join(base, 'cpu.stat'), ['usage_usec', 'user_usec', 'system_usec', 'nr_periods', 'nr_throttled', 'throttled_usec', 'nr_bursts', 'burst_usec'], signal),
70 pidsCurrent: await scalar(join(base, 'pids.current'), signal), pidsMax: await scalar(join(base, 'pids.max'), signal),
71 } : null
72 return { scope: 'observer cgroup, potentially shared with other runner processes', pressureScope: 'system pressure, shared with other host workloads', membershipCode: membership.code ?? (base ? null : 'NOT_V2'), cgroup, cpuPressure, memoryPressure, ioPressure }
73}
74
75type Executable = { path: string, device: string, inode: string, byteLength: number, observedPids: number[], openedAtMs: number, sha256: string | null, hashedAfterDiscovery: boolean, code: string | null, closeCode: string | null }
76/** No argv, environment, comm, stack or memory dump is exported. */
77export function createLinuxStartupObservation(pid: number, profile: string, began: number) {
78 const tracked = new Map<number, string>(), pinned = new Map<string, { handle: FileHandle, mtimeNs: bigint, ctimeNs: bigint, evidence: Executable }>()
79 const identityErrors: Array<{ pid: number, code: string }> = []
80 let handlesSealed = false, executableLimitReached = false
81 async function executable(id: number, signal: AbortSignal) {
82 if (handlesSealed || signal.aborted) return
83 let handle: FileHandle | undefined
84 try {
85 handle = await open(`/proc/${id}/exe`, 'r')
86 if (handlesSealed || signal.aborted) return
87 const stat = await handle.stat({ bigint: true }), key = `${stat.dev}:${stat.ino}`
88 if (handlesSealed || signal.aborted) return
89 const prior = pinned.get(key)
90 if (prior) { if (!prior.evidence.observedPids.includes(id)) prior.evidence.observedPids.push(id); return }
91 if (pinned.size >= executableLimit) { executableLimitReached = true; return }
92 const path = await readlink(`/proc/self/fd/${handle.fd}`)
93 if (handlesSealed || signal.aborted) return
94 if (!stat.isFile() || stat.size <= 0n || stat.size > BigInt(executableByteLimit) || path.length > 4096) { if (identityErrors.length < 8) identityErrors.push({ pid: id, code: 'IDENTITY_LIMIT' }); return }
95 pinned.set(key, { handle, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs, evidence: { path, device: String(stat.dev), inode: String(stat.ino), byteLength: Number(stat.size), observedPids: [id], openedAtMs: performance.now() - began, sha256: null, hashedAfterDiscovery: false, code: null, closeCode: null } })
96 handle = undefined
97 }
98 catch (error) { if (identityErrors.length < 8) identityErrors.push({ pid: id, code: browserFileErrorCode(error) }) }
99 finally {
100 if (handle) {
101 try { await handle.close() }
102 catch (closeError) { if (identityErrors.length < 8) identityErrors.push({ pid: id, code: browserFileErrorCode(closeError) }) }
103 }
104 }
105 }
106 return {
107 async snapshot(signal: AbortSignal) {
108 signal.throwIfAborted()
109 const root = await processState(pid, signal), descendants: Array<typeof root.record & { reused: boolean }> = []
110 const check = (id: number, start: string | null) => {
111 if (!start) return false
112 const prior = tracked.get(id)
113 if (prior && prior !== start) return false
114 if (tracked.size < processLimit) tracked.set(id, start)
115 return true
116 }
117 const rootMatches = check(pid, root.record.startTicks)
118 if (rootMatches) await executable(pid, signal)
119 const queue = [...new Set([...(rootMatches ? root.children : []), ...tracked.keys()].filter(id => id !== pid))]
120 const seen = new Set<number>([pid]); let processLimitReached = root.childrenCapped
121 while (queue.length && seen.size < processLimit && !signal.aborted) {
122 const id = queue.shift()!
123 if (seen.has(id)) continue
124 seen.add(id)
125 const state = await processState(id, signal), matches = check(id, state.record.startTicks)
126 descendants.push({ ...state.record, reused: state.record.startTicks !== null && !matches })
127 if (matches) { await executable(id, signal); queue.push(...state.children.filter(n => !seen.has(n))); processLimitReached ||= state.childrenCapped }
128 }
129 processLimitReached ||= queue.length > 0 || tracked.size === processLimit
130 signal.throwIfAborted()
131 let threadCode: string | null = null
132 const allThreads = await readdir(`/proc/${pid}/task`).catch(error => { threadCode = browserFileErrorCode(error); return [] })
133 signal.throwIfAborted()
134 const threads = await Promise.all(allThreads.filter(id => /^\d+$/.test(id)).slice(0, threadLimit).map(async id => {
135 const wait = await text(`/proc/${pid}/task/${id}/wchan`, signal)
136 return { tid: Number(id), wait: wait.value !== null && /^[A-Za-z0-9_.:]{1,80}$/.test(wait.value.trim()) ? wait.value.trim() : null, code: wait.code }
137 }))
138 signal.throwIfAborted()
139 const discoveryFile = await lstat(join(profile, 'DevToolsActivePort')).then(value => ({ present: true, regular: value.isFile(), bytes: value.size, code: null })).catch(error => ({ present: false, regular: null, bytes: null, code: browserFileErrorCode(error) }))
140 signal.throwIfAborted()
141 return { status: 'observed' as const, root: { ...root.record, reused: root.record.startTicks !== null && !rootMatches }, descendants, processLimitReached,
142 childScope: 'main-task children and previously observed descendants; incomplete during fork/exit/reparenting', threads, threadCode, threadDetailsCapped: allThreads.length > threadLimit, discoveryFile, resources: await resources(signal) }
143 },
144 async settleExecutables(shouldHash: boolean) {
145 handlesSealed = true
146 const abort = new AbortController(), timer = setTimeout(() => abort.abort(), 2000)
147 try {
148 for (const { handle, evidence, mtimeNs, ctimeNs } of pinned.values()) {
149 try {
150 if (!shouldHash) { evidence.code = 'OBSERVATION_CANCELED'; continue }
151 if (abort.signal.aborted) { evidence.code = 'HASH_BUDGET'; continue }
152 const hash = createHash('sha256'); let bytes = 0
153 for await (const chunk of handle.createReadStream({ autoClose: false, start: 0, end: evidence.byteLength - 1, signal: abort.signal })) { hash.update(chunk); bytes += chunk.length }
154 const after = await handle.stat({ bigint: true })
155 if (abort.signal.aborted) { evidence.code = 'HASH_BUDGET'; continue }
156 if (bytes !== evidence.byteLength || after.size !== BigInt(bytes) || after.mtimeNs !== mtimeNs || after.ctimeNs !== ctimeNs) { evidence.code = 'EXECUTABLE_CHANGED'; continue }
157 evidence.sha256 = hash.digest('hex'); evidence.hashedAfterDiscovery = true
158 }
159 catch (error) { evidence.code = browserFileErrorCode(error) }
160 finally { await handle.close().catch(error => { evidence.closeCode = browserFileErrorCode(error) }) }
161 }
162 }
163 finally { clearTimeout(timer) }
164 },
165 evidence() { return { executables: [...pinned.values()].map(value => structuredClone(value.evidence)), identityErrors: structuredClone(identityErrors), executableLimitReached } },
166 }
167}
tests/helpers/browser-startup-process.tsadded+61-0
Changes to tests/helpers/browser-startup-process.ts
@@ -0,0 +1,61 @@
1import { browserFileErrorCode } from './browser-launch-evidence'
2import type { createLinuxStartupObservation } from './browser-startup-linux'
3
4type NativeSnapshot = Awaited<ReturnType<ReturnType<typeof createLinuxStartupObservation>['snapshot']>>
5export interface StartupProcessAdapter {
6 snapshot: (signal: AbortSignal) => Promise<NativeSnapshot | { status: 'unavailable', code: string | null }>
7 settleExecutables: (hash: boolean) => Promise<void>
8 evidence: ReturnType<typeof createLinuxStartupObservation>['evidence']
9}
10
11type Probe = 'present' | 'missing' | 'denied' | 'unavailable'
12function rootProbe(pid: number): Probe {
13 try { process.kill(pid, 0); return 'present' }
14 catch (error) {
15 const code = browserFileErrorCode(error)
16 return code === 'ESRCH' ? 'missing' : code === 'EPERM' ? 'denied' : 'unavailable'
17 }
18}
19
20/** Startup-only facts. A PID probe does not establish complete process-group ownership. */
21export function createBrowserStartupObserver(pid: number, began: number, linux: StartupProcessAdapter | null) {
22 type Linux = NativeSnapshot | { status: 'unsupported' | 'timeout' | 'unavailable', code: string | null }
23 const snapshots: Array<{ label: string, elapsedMs: number, completedAtMs: number, root: { pid: number, probe: Probe }, linux: Linux }> = []
24 const milestones = [0, 1000, 5000, 10000, 15000]
25 let next = 0, sealed = false, inFlight = false, canceled = false, browserVersion: string | null = null
26 const sample = async (label: string) => {
27 const elapsedMs = performance.now() - began, root = { pid, probe: rootProbe(pid) }
28 const abort = new AbortController()
29 let timer: ReturnType<typeof setTimeout> | undefined, expired = false
30 try {
31 let observation: Linux = { status: 'unsupported', code: null }
32 if (linux && canceled) observation = { status: 'unavailable', code: 'OBSERVATION_CANCELED' }
33 else if (linux) {
34 timer = setTimeout(() => {
35 expired = true; canceled = true; abort.abort()
36 console.error(JSON.stringify({ oracle: 'browser-startup-observation-pending', pid, elapsedMs: performance.now() - began }))
37 }, 500)
38 // Cancellation requests are not settlement. Own this actual task until it returns.
39 observation = await linux.snapshot(abort.signal).catch(error => ({ status: 'unavailable' as const, code: browserFileErrorCode(error) }))
40 if (expired) observation = { status: 'timeout', code: 'OBSERVATION_BUDGET' }
41 }
42 snapshots.push({ label, elapsedMs, completedAtMs: performance.now() - began, root, linux: observation })
43 }
44 finally { if (timer) clearTimeout(timer) }
45 }
46 let pending: Promise<void> = Promise.resolve()
47 return {
48 tick() {
49 if (!sealed && !canceled && !inFlight && next < milestones.length && performance.now() - began >= milestones[next]!) {
50 const at = milestones[next++]!
51 inFlight = true
52 pending = sample(at === 0 ? 'launch' : `elapsed-${at}ms`).finally(() => { inFlight = false })
53 }
54 },
55 async finishDiscovery() { sealed = true; await pending; await sample('discovery-ended'); await linux?.settleExecutables(!canceled) },
56 async cleanupEnded() { await sample('cleanup-ended') },
57 recordVersion(value: unknown) { if (typeof value === 'string' && /^(?:HeadlessChrome|Chrome)\/\d{1,6}(?:\.\d{1,8}){3}$/.test(value)) browserVersion = value },
58 evidence() { return { method: 'startup-only-process-observation-v1' as const, platform: process.platform, browserVersion, snapshots: structuredClone(snapshots), ...linux?.evidence() ?? { executables: [], identityErrors: [], executableLimitReached: false } } },
59 }
60}
61export type BrowserStartupEvidence = ReturnType<ReturnType<typeof createBrowserStartupObserver>['evidence']>
tests/helpers/workspace-browser.tsmodified+17-5
Changes to tests/helpers/workspace-browser.ts
@@ -4,6 +4,8 @@ import { join } from 'node:path'
44import { observeBrowserOperation } from './browser-operation'
55import { browserFileErrorCode, retainBrowserLaunchEvidence } from './browser-launch-evidence'
66import type { BrowserLaunchObservation } from './browser-launch-evidence'
7import { createBrowserStartupObserver } from './browser-startup-process'
8import { createLinuxStartupObservation } from './browser-startup-linux'
79
810/** Dependency-free CDP, with a fresh profile. Credentials never enter command arguments. */
911async function openSocket(url: string): Promise<WebSocket> {
@@ -22,31 +24,41 @@ export async function startWorkspaceBrowser(root: string) {
2224 const child = Bun.spawn([process.env.BROWSE_BROWSER ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '--headless=new', '--disable-gpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--disable-features=CalculateNativeWinOcclusion', '--disable-dev-shm-usage', '--disable-extensions', '--disable-component-update', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdout: 'ignore', stderr: Bun.file(join(root, 'chrome.log')) })
2325 const started = Date.now()
2426 const began = performance.now()
27 const startup = createBrowserStartupObserver(child.pid, began, process.platform === 'linux' ? createLinuxStartupObservation(child.pid, profile, began) : null)
2528 let closing = false
2629 const launch: BrowserLaunchObservation['launch'] = { pid: child.pid, exitObserved: false, settledExitCode: null, signalCode: null, exitObservedBeforeCleanup: false, cleanupRequested: false, exitElapsedMs: null }
2730 const portProbe: BrowserLaunchObservation['portProbe'] = { attempts: 0, successfulReads: 0, failedReads: 0, lastReadError: null, discovered: false }
2831 let pageVisited = false
29 const retain = (phase: BrowserLaunchObservation['phase']) => retainBrowserLaunchEvidence(root, { phase, elapsedMs: performance.now() - began, launch: { ...launch }, portProbe: { ...portProbe } }, { startupStderr: !pageVisited, exportDirectory: process.env.WORKSPACE_BROWSER_EVIDENCE ?? process.env.EXECUTION_BROWSER_EVIDENCE })
32 const retain = (phase: BrowserLaunchObservation['phase']) => retainBrowserLaunchEvidence(root, { phase, elapsedMs: performance.now() - began, launch: { ...launch }, portProbe: { ...portProbe }, startup: startup.evidence() }, { startupStderr: !pageVisited, exportDirectory: process.env.WORKSPACE_BROWSER_EVIDENCE ?? process.env.EXECUTION_BROWSER_EVIDENCE })
33 const cleanup = async () => {
34 closing = true; launch.cleanupRequested = true
35 if (child.exitCode === null) child.kill()
36 await Promise.race([child.exited, Bun.sleep(2000)])
37 if (!pageVisited) await startup.cleanupEnded()
38 }
3039 void child.exited.then(code => {
3140 launch.exitObserved = true; launch.settledExitCode = code; launch.signalCode = child.signalCode; launch.exitObservedBeforeCleanup = !closing; launch.exitElapsedMs = performance.now() - began
3241 if (!closing) console.log(`QA isolated Chrome exited unexpectedly: ${code}, elapsedMs:${Date.now() - started}`)
3342 })
3443 let port = ''
3544 for (let attempt = 0; attempt < 400; attempt++) {
45 startup.tick()
3646 portProbe.attempts++
3747 port = await readFile(join(profile, 'DevToolsActivePort'), 'utf8').then(value => { portProbe.successfulReads++; portProbe.lastReadError = null; return value.split('\n')[0]! }).catch(error => { portProbe.failedReads++; portProbe.lastReadError = browserFileErrorCode(error); return '' })
3848 if (port) { portProbe.discovered = true; break }
3949 await Bun.sleep(50)
4050 }
41 if (!port) { closing = true; launch.cleanupRequested = true; if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); await retain('startup-unavailable'); throw new Error('Fresh browser must start') }
51 await startup.finishDiscovery()
52 if (!port) { await cleanup(); await retain('startup-unavailable'); throw new Error('Fresh browser must start') }
4253 // Keep a browser-level session, as well as page sessions, for the owned lifecycle.
4354 let browserSocket: WebSocket
4455 try {
4556 const response = await observeBrowserOperation('version-fetch', () => fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(5000) }))
46 const version = await observeBrowserOperation('version-json', () => response.json()) as { webSocketDebuggerUrl: string }
57 const version = await observeBrowserOperation('version-json', () => response.json()) as { webSocketDebuggerUrl: string, Browser?: unknown }
58 startup.recordVersion(version.Browser)
4759 browserSocket = await observeBrowserOperation('browser-socket', () => openSocket(version.webSocketDebuggerUrl))
4860 }
49 catch (error) { closing = true; launch.cleanupRequested = true; if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); await retain('discovery-failed'); throw error }
61 catch (error) { await cleanup(); await retain('discovery-failed'); throw error }
5062 let browserSequence = 0
5163 browserSocket.onmessage = () => {}
5264 const keepAlive = setInterval(() => { if (browserSocket.readyState === WebSocket.OPEN) browserSocket.send(JSON.stringify({ id: ++browserSequence, method: 'Browser.getVersion' })) }, 5000)
@@ -96,6 +108,6 @@ export async function startWorkspaceBrowser(root: string) {
96108 await page.wait(javascript ? "document.querySelector('#incident-key') !== null && document.querySelector('[data-workspace-status]')?.textContent.trim() === 'disconnected'" : "document.querySelector('#incident-key') !== null", 'Compiled workspace must initialize')
97109 return page
98110 },
99 async close() { console.log(`QA browser.close requested, elapsedMs:${Date.now() - started}`); closing = true; launch.cleanupRequested = true; clearInterval(keepAlive); browserSocket.close(); for (const page of pages) page.close(); if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); await retain('closed') },
111 async close() { console.log(`QA browser.close requested, elapsedMs:${Date.now() - started}`); clearInterval(keepAlive); browserSocket.close(); for (const page of pages) page.close(); await cleanup(); await retain('closed') },
100112 }
101113}
tests/tsconfig.runtime.jsonmodified+2-0
Changes to tests/tsconfig.runtime.json
@@ -24,6 +24,8 @@
2424 "../resources/functions/drone/cargo/**/*.ts",
2525 "./unit/cargo-load.test.ts",
2626 "./unit/cargo-intake.test.ts",
27 "./unit/cargo-station.test.ts",
28 "./unit/cargo-receive-mission.test.ts",
2729 "./unit/cargo-delivery.test.ts",
2830 "./unit/cargo-delivery-config.test.ts",
2931 "./unit/cargo-delivery-phase-faults.test.ts",
tests/unit/cargo-receive-mission.test.tsadded+149-0
Changes to tests/unit/cargo-receive-mission.test.ts
@@ -0,0 +1,149 @@
1import { expect, test } from 'bun:test'
2import type { VehicleMissionObservation, VehiclePayloadCommand } from '../../resources/functions/drone/vehicles/types'
3import { createReceivingRigidBody } from '../../resources/functions/drone/controls/physics'
4import { createVehicleSimulationSession } from '../../resources/functions/drone/vehicles/runtime'
5import { createMultirotorPlant, createMultirotorProfile, multirotorMission } from '../../resources/functions/drone/vehicles/multirotor/profile'
6import { createVehicleMissionEngine, parseVehicleMissionTrace, replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle'
7
8function receivePlan() {
9 const spatial = { acceptanceM: .2, maxArrivalSpeedMps: .18, settleMs: 200, timeoutMs: 10000 }
10 return { ...multirotorMission(), id: 'cargo-receiver', tasks: [
11 { id: 'start', kind: 'start', next: 'intake' },
12 { id: 'intake', kind: 'payload', operation: 'material-receive', containerId: 'parcel-a', positionM: [3, 2, -.1], timeoutMs: 60000, next: 'depart', onFailure: 'divert' },
13 { id: 'depart', kind: 'takeoff', positionM: [3, 2, -1], ...spatial, next: 'land' },
14 { id: 'land', kind: 'land', positionM: [3, 2, 0], ...spatial },
15 { id: 'divert', kind: 'waypoint', positionM: [4, 2, -1], ...spatial, timeoutMs: 8000, next: 'alternate' },
16 { id: 'alternate', kind: 'land', positionM: [4, 2, 0], ...spatial, timeoutMs: 8000 },
17 ] }
18}
19function observation(nowMs: number): VehicleMissionObservation {
20 return { kind: 'simulation', nowMs, reference: '102:0,0,0,0,0', origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, healthy: true, positionM: [3, 2, -.1], velocityMps: [0, 0, 0], authority: 'autonomous', batteryPercent: 98, onGround: true, payloadAck: null }
21}
22
23// Native mission function seam: proves receipt command, anchor, scope and counted
24// plan behavior. It does not produce mechanical intake or current payload authority.
25test('a receiving mission holds its declared pad and advances only after its own correlated receipt ACK', () => {
26 const plan = receivePlan(), validated = validateVehicleMission(plan)
27 expect(validated.worstCaseMs).toBe(80020)
28 const engine = createVehicleMissionEngine(plan)
29 expect(engine.step(observation(600), 'start')).toMatchObject({ taskId: 'intake', state: 'running', targetM: [3, 2, -.1] })
30 const pending = engine.step(observation(620)).payload!
31 expect(pending).toMatchObject({ taskId: 'intake', operation: 'material-receive', containerId: 'parcel-a', reference: '102:0,0,0,0,0', issuedAtMs: 600, expiresAtMs: 60600 })
32 const result = engine.step({ ...observation(640), payloadAck: { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: pending.positionM, capturedAtMs: 640, outcome: 'confirmed' } })
33 expect(result.taskId).toBe('depart')
34 expect(result.reason).toBe('payload-confirmed')
35 expect(result.payload).toBeNull()
36 expect(replayVehicleMission(engine.trace()).snapshot()).toEqual(result)
37 expect(parseVehicleMissionTrace(JSON.stringify(engine.trace()))).toEqual(engine.trace())
38})
39
40
41// Actual receiving body plus raw-sensor/estimator/authority runtime. The only
42// external fixture is a payload wire that withholds its ACK, not an intake mock.
43test('an unacknowledged receiving mission keeps its actual parked body neutral under its own live authority', () => {
44 const body = createReceivingRigidBody({ positionM: [3, 2, -.1], groundDownM: -.1, expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } })
45 const profile = createMultirotorProfile(), create = profile.createSession
46 profile.mission = validateVehicleMission(receivePlan()).plan
47 const commands: VehiclePayloadCommand[] = []
48 profile.createSession = () => ({ ...create(), createPlant: () => createMultirotorPlant(body), createPayload: () => ({ request(command) { commands.push(structuredClone(command)) }, poll() { return null }, close() {} }) })
49 const session = createVehicleSimulationSession(profile, { seconds: 3, scope: { bootId: 102n, sessionId: 202n } })
50 try {
51 for (let nowMs = 0; nowMs <= 2000; nowMs += 4) {
52 const row = session.step(nowMs)
53 if (nowMs >= 600) {
54 expect(row.authority.state).toBe('autonomous')
55 expect(row.authority.sessionId).toBe(202n)
56 expect(row.reference.startsWith('102:')).toBe(true)
57 expect(row.board.actuators).toEqual([0, 0, 0, 0])
58 expect(body.snapshot().positionM).toEqual([3, 2, -.1])
59 expect(body.snapshot().motorsN).toEqual([0, 0, 0, 0])
60 expect(body.loadSnapshot().properties.totalMassKg).toBe(1.5)
61 }
62 }
63 expect(commands).toHaveLength(1)
64 expect(commands[0]!.operation).toBe('material-receive')
65 expect(commands[0]!.issuedAtMs).toBe(600)
66 expect(body.loadSnapshot().properties.phase).toBe('awaiting')
67 }
68 finally { session.close() }
69})
70
71test('a foreign, stale or uncertain receipt takes only the declared counted failure route', () => {
72 for (const changed of [
73 { reference: '101:0,0,0,0,0' }, { eventId: 'source-vehicle-event' }, { containerId: 'parcel-b' },
74 { capturedAtMs: 599 }, { capturedAtMs: 641 }, { outcome: 'uncertain' as const }, { outcome: 'rejected' as const },
75 ]) {
76 const engine = createVehicleMissionEngine(receivePlan())
77 const request = engine.step(observation(600), 'start').payload!
78 const ack = { eventId: request.eventId, taskId: request.taskId, operation: request.operation, containerId: request.containerId, reference: request.reference, positionM: request.positionM, capturedAtMs: 620, outcome: 'confirmed' as 'confirmed' | 'uncertain' | 'rejected', ...changed }
79 const row = engine.step({ ...observation(640), payloadAck: ack })
80 expect(row.taskId).toBe('divert')
81 expect(row.payloadFailure).toMatchObject({ taskId: 'intake', operation: 'material-receive', approachTaskId: 'divert', landingTaskId: 'alternate' })
82 expect(row.payload).toBeNull()
83 expect(row.reason).toBe('payload-failure-diversion')
84 }
85})
86
87test('receiving cannot start or accept an ACK away from its pad, moving or without contact', () => {
88 for (const change of [{ onGround: false }, { positionM: [3, 2, -.31] as [number, number, number] }, { velocityMps: [.101, 0, 0] as [number, number, number] }]) {
89 for (const atEntry of [true, false]) {
90 const engine = createVehicleMissionEngine(receivePlan())
91 let result
92 if (atEntry) result = engine.step({ ...observation(600), ...change }, 'start')
93 else {
94 const pending = engine.step(observation(600), 'start').payload!
95 result = engine.step({ ...observation(620), ...change, payloadAck: { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: pending.positionM, capturedAtMs: 620, outcome: 'confirmed' } })
96 }
97 expect(result.state).toBe('blocked')
98 expect(result.payload).toBeNull()
99 expect(result.taskId).not.toBe('depart')
100 expect(result.targetM).toBeNull()
101 }
102 }
103})
104
105test('receipt expiry diverts once and cannot be cleared by a late ACK', () => {
106 const engine = createVehicleMissionEngine(receivePlan())
107 const pending = engine.step(observation(600), 'start').payload!
108 for (let atMs = 620; atMs <= 60600; atMs += 20) engine.step(observation(atMs))
109 const timedOut = engine.snapshot()
110 expect(timedOut.taskId).toBe('divert')
111 expect(timedOut.payloadFailure).toMatchObject({ reason: 'timeout', triggeredAtMs: 60600 })
112 const late = engine.step({ ...observation(60620), payloadAck: { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: pending.positionM, capturedAtMs: 60600, outcome: 'confirmed' } })
113 expect(late.state).toBe('blocked')
114 expect(late.taskId).toBe('divert')
115 expect(late.reason).toBe('unsolicited-payload-ack')
116})
117
118test('receipt validation preserves finite graph reserve and rejects unsupported profiles, missing anchors and retry paths', () => {
119 for (const change of [
120 (p: ReturnType<typeof receivePlan>) => { p.vehicle = 'ground' },
121 (p: ReturnType<typeof receivePlan>) => { Reflect.set(p.tasks[1]!, 'containerId', null) },
122 (p: ReturnType<typeof receivePlan>) => { delete p.tasks[1]!.positionM },
123 (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.positionM = [3, 2, -1.001] },
124 (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.timeoutMs = 60001 },
125 (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.timeoutMs = 100.5 },
126 (p: ReturnType<typeof receivePlan>) => { p.tasks[5]!.next = 'intake' },
127 (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.onFailure = 'depart' },
128 (p: ReturnType<typeof receivePlan>) => { p.tasks[3]!.timeoutMs = 60000; p.tasks[5]!.timeoutMs = 60000; p.power.availableWh = 20 },
129 ]) {
130 const input = receivePlan(); change(input)
131 expect(() => validateVehicleMission(input)).toThrow()
132 }
133 const alternateBudget = receivePlan()
134 alternateBudget.tasks[3]!.timeoutMs = 1000; alternateBudget.tasks[4]!.timeoutMs = 30000; alternateBudget.tasks[5]!.timeoutMs = 30000
135 expect(validateVehicleMission(alternateBudget).worstCaseMs).toBe(120020)
136})
137
138test('missing or nonfinite ACK coordinates cannot confirm receipt or create an unreplayable accepted trace', () => {
139 for (const position of [Array<number>(3), [3, , -.1], [NaN, 2, -.1], [3, 2, Infinity]]) {
140 const engine = createVehicleMissionEngine(receivePlan()), pending = engine.step(observation(600), 'start').payload!
141 const ack = { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: position as [number, number, number], capturedAtMs: 620, outcome: 'confirmed' as const }
142 expect(() => engine.step({ ...observation(620), payloadAck: ack })).toThrow('Invalid vehicle mission observation')
143 expect(engine.snapshot().state).toBe('blocked')
144 expect(engine.snapshot().taskId).toBe('intake')
145 expect(engine.snapshot().payload).toBeNull()
146 expect(engine.snapshot().reason).not.toBe('payload-confirmed')
147 expect(() => parseVehicleMissionTrace(JSON.stringify(engine.trace()))).not.toThrow()
148 }
149})
tests/unit/cargo-station.test.tsadded+194-0
Changes to tests/unit/cargo-station.test.ts
@@ -0,0 +1,194 @@
1import { expect, test } from 'bun:test'
2import { createCargoStationHandler } from '../../resources/functions/drone/cargo/station'
3import { createReceivingRigidBody, createRigidBody } from '../../resources/functions/drone/controls/physics'
4
5// Physical function seam: one real separated parcel crosses a finite station path
6// and enters actual receiving dynamics. This is not vehicle authority or a custody ACK.
7test('one separated point load moves continuously to the receiving joint and leaves the station once', () => {
8 const load = { id: 'parcel-a', massKg: .1, forwardM: .08 }
9 const source = createRigidBody({ positionM: [0, 0, 0], carriedPointLoad: load })
10 const receiver = createReceivingRigidBody({ positionM: [1, 0, 0], expectedPointLoad: load })
11 source.step([0, 0, 0, 0], .004); receiver.step([0, 0, 0, 0], .004)
12 const release = source.separateCarriedLoad()
13 if (release.outcome !== 'separated') throw new Error('Expected actual source separation')
14 const station = createCargoStationHandler({ atMs: 4, parcel: { id: load.id, ...release.parcel }, pickup: { centerM: [.08, 0, 0], radiusM: .1 }, destinationM: receiver.intakePoint().positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 })
15 expect(station.snapshot().ownedMassKg).toBe(.1)
16 expect(source.loadSnapshot().properties.totalMassKg + receiver.loadSnapshot().properties.totalMassKg + station.snapshot().ownedMassKg).toBe(3.1)
17 expect(station.start()).toEqual({ outcome: 'started', atMs: 4, durationMs: 3752 })
18 let previous = station.snapshot().parcel!.positionM
19 for (let nowMs = 8; nowMs <= 3756; nowMs += 4) {
20 receiver.step([0, 0, 0, 0], .004)
21 const state = station.step(nowMs), parcel = state.parcel!
22 expect(Math.hypot(...parcel.positionM.map((n, i) => n - previous[i]!))).toBeLessThanOrEqual(.002 + 1e-12)
23 if (nowMs === 1880) { expect(parcel.positionM[0]).toBeCloseTo(.58, 14); expect(parcel.velocityMps[0]).toBeCloseTo(.4997334754797441, 13) }
24 previous = parcel.positionM
25 }
26 expect(station.snapshot().phase).toBe('ready')
27 const exit = station.takeReadyParcel()
28 if (exit.outcome !== 'departed') throw new Error('Expected actual station exit')
29 expect(receiver.attachParcel(exit.parcel).outcome).toBe('attached')
30 expect(station.snapshot().parcel).toBeNull()
31 expect(station.snapshot().ownedMassKg).toBe(0)
32 expect(source.loadSnapshot().properties.totalMassKg + receiver.loadSnapshot().properties.totalMassKg).toBe(3.1)
33 expect(station.takeReadyParcel()).toEqual({ outcome: 'refused', reason: 'not-ready' })
34 expect(Math.abs(station.snapshot().energy.residualJ)).toBeLessThan(1e-12)
35})
36
37test('the actual released point falls before surface capture and retains its impact energy and epoch', () => {
38 const load = { id: 'parcel-a', massKg: .1, forwardM: .08 }, gravity = 9.80665
39 const source = createRigidBody({ positionM: [0, 0, -1], carriedPointLoad: load })
40 source.step([0, 0, 0, 0], .004)
41 const release = source.separateCarriedLoad()
42 if (release.outcome !== 'separated') throw new Error('Expected airborne source separation')
43 const initial = release.parcel, v0 = initial.velocityMps[2], height = -initial.positionM[2]
44 const hitAtMs = 4 + 1000 * (Math.sqrt(v0 ** 2 + 2 * gravity * height) - v0) / gravity
45 const station = createCargoStationHandler({ atMs: 4, parcel: { id: load.id, ...initial }, pickup: { centerM: [.08, 0, 0], radiusM: .1 }, destinationM: [1.08, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 })
46 expect(station.snapshot().phase).toBe('falling')
47 expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' })
48 let nowMs = 4
49 while (station.snapshot().phase === 'falling') {
50 nowMs += 4
51 const state = station.step(nowMs), seconds = (nowMs - 4) / 1000
52 expect(Math.abs(state.energy.residualJ)).toBeLessThan(1e-12)
53 if (state.phase === 'falling') expect(state.parcel!.positionM[2]).toBeCloseTo(initial.positionM[2] + v0 * seconds + .5 * gravity * seconds ** 2, 12)
54 expect(nowMs).toBeLessThan(1000)
55 }
56 const landed = station.snapshot()
57 expect(landed.phase).toBe('held')
58 expect(landed.parcel!.positionM).toEqual([.08, 0, 0])
59 expect(landed.parcel!.velocityMps).toEqual([0, 0, 0])
60 expect(landed.impact!.atMs).toBeCloseTo(hitAtMs, 9)
61 expect(landed.impact!.impulseNs[2]).toBeCloseTo(-.1 * Math.sqrt(v0 ** 2 + 2 * gravity * height), 12)
62 expect(landed.energy.planeDissipationJ).toBeCloseTo(.05 * v0 ** 2 + .1 * gravity * height, 12)
63 expect(landed.energy.stationNetWorkJ).toBe(0)
64 expect(station.start().outcome).toBe('started')
65})
66
67test('cancellation brakes the real moving parcel finitely and retains it instead of transferring it', () => {
68 const station = createCargoStationHandler({ atMs: 4, parcel: { id: 'parcel-a', massKg: .1, positionM: [.08, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [.08, 0, 0], radiusM: .1 }, destinationM: [1.08, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 })
69 station.start()
70 for (let atMs = 8; atMs <= 1880; atMs += 4) station.step(atMs)
71 const before = station.snapshot(), v0 = before.parcel!.velocityMps[0]
72 expect(station.cancel()).toEqual({ outcome: 'cancelling', atMs: 1880 })
73 expect(station.snapshot().phase).toBe('braking')
74 expect(station.snapshot().parcel).toEqual(before.parcel)
75 expect(station.takeReadyParcel()).toEqual({ outcome: 'refused', reason: 'not-ready' })
76 let atMs = 1880, previous = before.parcel!
77 while (station.snapshot().phase === 'braking') {
78 atMs += 4
79 const row = station.step(atMs), p = row.parcel!
80 expect(p.velocityMps[0]).toBeGreaterThanOrEqual(0)
81 expect(p.velocityMps[0]).toBeLessThan(previous.velocityMps[0])
82 expect(Math.abs(p.velocityMps[0] - previous.velocityMps[0])).toBeLessThanOrEqual(.004 + 1e-12)
83 expect(Math.hypot(...p.positionM.map((n, i) => n - previous.positionM[i]!))).toBeLessThanOrEqual(.002 + 1e-12)
84 expect(Math.abs(row.energy.residualJ)).toBeLessThan(1e-12)
85 expect(atMs).toBeLessThan(2500)
86 previous = p
87 }
88 const stopped = station.snapshot()
89 expect(stopped.phase).toBe('cancelled')
90 expect(stopped.parcel!.velocityMps).toEqual([0, 0, 0])
91 expect(stopped.parcel!.positionM[0]).toBeCloseTo(.58 + v0 ** 2 / 2, 12)
92 expect(stopped.stoppedAtMs).toBeCloseTo(1880 + v0 * 1000, 9)
93 expect(stopped.ownedMassKg).toBe(.1)
94 expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' })
95 expect(station.cancel()).toEqual({ outcome: 'refused', reason: 'already-cancelled' })
96 expect(station.step(atMs + 4).parcel).toEqual(stopped.parcel)
97})
98
99test('a drifting parcel misses the declared pickup instead of being pulled onto the rail', () => {
100 const station = createCargoStationHandler({ atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, -1], velocityMps: [1, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 })
101 let atMs = 0
102 while (station.snapshot().phase === 'falling') station.step(atMs += 4)
103 const missed = station.snapshot()
104 expect(missed.phase).toBe('missed')
105 // Independent one-metre drop: flight time sqrt(2/g), with constant 1 m/s north velocity.
106 expect(missed.parcel!.positionM[0]).toBeCloseTo(Math.sqrt(2 / 9.80665), 12)
107 expect(missed.energy.planeDissipationJ).toBeCloseTo(1.030665, 12)
108 expect(missed.impact!.impulseNs[0]).toBe(-.1)
109 expect(missed.ownedMassKg).toBe(.1)
110 expect(Math.abs(missed.energy.residualJ)).toBeLessThan(1e-12)
111 expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' })
112 expect(station.takeReadyParcel()).toEqual({ outcome: 'refused', reason: 'not-ready' })
113 expect(station.step(atMs + 4).parcel).toEqual(missed.parcel)
114})
115
116test('cancelling a rising parcel retains gravity and impact before it can settle', () => {
117 const station = createCargoStationHandler({ atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, -1] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 })
118 expect(station.cancel()).toEqual({ outcome: 'cancelling', atMs: 0 })
119 expect(station.snapshot().phase).toBe('falling')
120 expect(station.snapshot().forceN).toEqual([0, 0, 0])
121 expect(station.step(4).parcel!.positionM[2]).toBeCloseTo(-.0039215468, 12)
122 let atMs = 4
123 while (station.snapshot().phase === 'falling') station.step(atMs += 4)
124 const stopped = station.snapshot()
125 expect(stopped.phase).toBe('cancelled')
126 expect(stopped.impact!.atMs).toBeCloseTo(2000 / 9.80665, 9)
127 expect(stopped.stoppedAtMs).toBe(stopped.impact!.atMs)
128 expect(stopped.energy.planeDissipationJ).toBeCloseTo(.05, 12)
129 expect(stopped.energy.stationNetWorkJ).toBe(0)
130 expect(Math.abs(stopped.energy.residualJ)).toBeLessThan(1e-12)
131 expect(station.start().outcome).toBe('refused')
132 expect(station.takeReadyParcel().outcome).toBe('refused')
133})
134
135test('lifting retains mechanical work and speed/acceleration limits on the full spatial path', () => {
136 const station = createCargoStationHandler({ atMs: 0, parcel: { id: 'parcel-a', massKg: .2, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 2, -1], maxSpeedMps: .5, maxAccelerationMps2: 1 })
137 station.start()
138 let atMs = 0, previous = station.snapshot()
139 while (station.snapshot().phase === 'moving') {
140 const row = station.step(atMs += 4)
141 expect(Math.hypot(...row.parcel!.velocityMps)).toBeLessThanOrEqual(.5 + 1e-12)
142 expect(Math.hypot(...row.parcel!.velocityMps.map((n, i) => (n - previous.parcel!.velocityMps[i]!) / .004))).toBeLessThanOrEqual(1 + 1e-12)
143 expect(Math.abs(row.energy.residualJ)).toBeLessThan(1e-12)
144 previous = row
145 }
146 expect(previous.parcel!.positionM).toEqual([1, 2, -1])
147 expect(Math.hypot(...previous.parcel!.velocityMps)).toBe(0)
148 expect(previous.energy.stationNetWorkJ).toBeCloseTo(1.96133, 12)
149 expect(previous.energy.positiveWorkJ).toBeGreaterThanOrEqual(previous.energy.stationNetWorkJ)
150 expect(previous.energy.positiveWorkJ + previous.energy.negativeWorkJ).toBeCloseTo(previous.energy.stationNetWorkJ, 12)
151 expect(station.takeReadyParcel().outcome).toBe('departed')
152 expect(station.snapshot().energy.departedJ).toBeCloseTo(1.96133, 12)
153 expect(Math.abs(station.snapshot().energy.residualJ)).toBeLessThan(1e-12)
154})
155
156test('station admission captures data without invoking caller accessors and rejects invalid declarations privately', () => {
157 const input = { atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }
158 const station = createCargoStationHandler(input), original = station.snapshot()
159 input.parcel.positionM[0] = 20; input.parcel.massKg = .2; input.pickup.centerM[0] = 10; input.destinationM[0] = 40
160 const detached = station.snapshot()
161 detached.parcel!.positionM[0] = 88; detached.impact!.impulseNs[0] = 19; detached.energy.initialJ = 500
162 expect(station.snapshot()).toEqual(original)
163 expect(station.start()).toEqual({ outcome: 'started', atMs: 0, durationMs: 3752 })
164 expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' })
165 const fresh = { atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }
166 const getter = structuredClone(fresh)
167 let invoked = 0
168 Object.defineProperty(getter.parcel, 'positionM', { enumerable: true, get() { invoked++; throw new Error('private-source') } })
169 const revoked = Proxy.revocable({}, {}); revoked.revoke()
170 const bad = [null, {}, { ...fresh, extra: true }, { ...fresh, atMs: 3 }, { ...fresh, atMs: -4 }, { ...fresh, atMs: Number.MAX_SAFE_INTEGER }, { ...fresh, maxSpeedMps: 0 }, { ...fresh, maxAccelerationMps2: Infinity }, { ...fresh, parcel: { ...fresh.parcel, massKg: .251 } }, { ...fresh, parcel: { ...fresh.parcel, positionM: [0, 0, .01] } }, { ...fresh, parcel: { ...fresh.parcel, positionM: [0, 0, -3.01] } }, { ...fresh, parcel: { ...fresh.parcel, velocityMps: [0, 0, NaN] } }, { ...fresh, destinationM: [50, 0, 0] }, getter, revoked.proxy]
171 for (const v of bad) expect(() => createCargoStationHandler(v)).toThrow('cargo-station-constructor')
172 expect(invoked).toBe(0)
173})
174
175test('invalid and exhausted clocks cannot change the parcel, and delayed starts preserve the lifetime bound', () => {
176 const config = { atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }
177 const station = createCargoStationHandler(config), initial = station.snapshot()
178 for (const invalid of [0, -4, 1, 8, NaN, Infinity]) expect(() => station.step(invalid)).toThrow('cargo-station-clock')
179 expect(() => station.step(4, 'extra')).toThrow('cargo-station-clock')
180 expect(() => station.start('extra')).toThrow('cargo-station-arguments')
181 expect(() => station.cancel('extra')).toThrow('cargo-station-arguments')
182 expect(() => station.takeReadyParcel('extra')).toThrow('cargo-station-arguments')
183 expect(station.snapshot()).toEqual(initial)
184 for (let atMs = 4; atMs <= 179996; atMs += 4) station.step(atMs)
185 const held = station.snapshot()
186 expect(station.start()).toEqual({ outcome: 'refused', reason: 'deadline' })
187 expect(station.snapshot()).toEqual(held)
188 station.step(180000)
189 const end = station.snapshot()
190 expect(() => station.step(180004)).toThrow('cargo-station-clock')
191 expect(station.snapshot()).toEqual(end)
192 expect(station.cancel()).toEqual({ outcome: 'cancelled', atMs: 180000 })
193 expect(station.takeReadyParcel().outcome).toBe('refused')
194})
tests/unit/public-safety-browser-launch.test.tsmodified+60-1
Changes to tests/unit/public-safety-browser-launch.test.ts
@@ -1,5 +1,5 @@
11import { expect, test } from 'bun:test'
2import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
33import { tmpdir } from 'node:os'
44import { join } from 'node:path'
55import process from 'node:process'
@@ -25,6 +25,11 @@ test.skipIf(process.platform === 'win32')('failed native launch retains startup
2525 expect(report.launch.cleanupRequested).toBe(true)
2626 expect(report.portProbe.attempts).toBe(400)
2727 expect(report.portProbe.lastReadError).toBe('ENOENT')
28 expect(report.startup.platform).toBe(process.platform)
29 expect(report.startup.method).toBe('startup-only-process-observation-v1')
30 expect(report.startup.snapshots.at(-1).label).toBe('cleanup-ended')
31 expect(report.startup.snapshots.at(-1).root.pid).toBe(actualPid)
32 expect(report.startup.snapshots.at(-1).root.probe).toBe('missing')
2833 expect(report.stderr.status).toBe('retained')
2934 expect(report.stderr.retainedBytes).toBeLessThanOrEqual(65536)
3035 expect(await readFile(join(root, 'chrome-startup-stderr.log'), 'utf8')).toContain('controlled native browser startup refusal')
@@ -46,3 +51,57 @@ test.skipIf(process.platform === 'win32')('failed native launch retains startup
4651 else console.log(JSON.stringify({ evidence: 'controlled-native-launch', retainedRoot: root, passed }))
4752 }
4853}, 30000)
54
55// The existing native launch seam checks observed processes, not the cause of a Chrome failure.
56test.skipIf(process.platform === 'win32')('a live native launcher and child remain observable until cleanup without exporting argv or environment', async () => {
57 const supplied = process.env.BROWSER_LAUNCH_LIVE_EVIDENCE
58 const root = supplied ? (await mkdir(supplied), supplied) : await mkdtemp(join(tmpdir(), 'drone-browser-live-'))
59 const executable = join(root, 'launch.ts'), pidPath = join(root, 'pids.json')
60 const original = process.env.BROWSE_BROWSER
61 let passed = false
62 try {
63 await writeFile(executable, `#!${process.execPath}\nimport { writeFileSync } from 'node:fs'\nconst child=Bun.spawn([process.execPath,'-e','setInterval(()=>{},1000)','PRIVATE_CHILD_ARG_SENTINEL'],{stdout:'ignore',stderr:'ignore',env:{...process.env,PRIVATE_FIXTURE_TOKEN:'PRIVATE_CHILD_ENV_SENTINEL'}})\nwriteFileSync(${JSON.stringify(pidPath)},JSON.stringify([process.pid,child.pid]))\nprocess.on('SIGTERM',async()=>{child.kill();await child.exited;process.exit(0)})\nconsole.error('controlled live browser startup fixture')\nsetInterval(()=>{},1000)\n`, { mode: 0o700 })
64 process.env.BROWSE_BROWSER = executable
65 await expect(startWorkspaceBrowser(root)).rejects.toThrow('Fresh browser must start')
66 const [pid, childPid] = JSON.parse(await readFile(pidPath, 'utf8')) as [number, number]
67 const text = await readFile(join(root, 'browser-launch.json'), 'utf8'), report = JSON.parse(text)
68 expect(text).not.toContain('PRIVATE_CHILD_ARG_SENTINEL')
69 expect(text).not.toContain('PRIVATE_CHILD_ENV_SENTINEL')
70 expect(report.portProbe.attempts).toBe(400)
71 expect(report.launch.exitObservedBeforeCleanup).toBe(false)
72 expect(report.launch.exitObserved).toBe(true)
73 const before = report.startup.snapshots.find((s: any) => s.label === 'discovery-ended')
74 expect(before.root.pid).toBe(pid)
75 expect(before.root.probe).toBe('present')
76 expect(report.startup.snapshots.at(-1).root.probe).toBe('missing')
77 expect(report.startup.executables).toBeArray()
78 if (process.platform === 'linux') {
79 expect(before.linux.status).toBe('observed')
80 expect(before.linux.root.startTicks).toMatch(/^\d+$/)
81 expect(before.linux.root.rssKiB).toBeGreaterThan(0)
82 expect(before.linux.descendants.some((p: any) => p.pid === childPid && p.parentPid === pid)).toBe(true)
83 const identity = report.startup.executables.find((e: any) => e.observedPids.includes(pid) && e.sha256 !== null)
84 expect(identity).toBeDefined()
85 expect(identity.byteLength).toBe((await stat(process.execPath)).size)
86 expect(identity.sha256).toBe(new Bun.CryptoHasher('sha256').update(await Bun.file(process.execPath).arrayBuffer()).digest('hex'))
87 expect(identity.hashedAfterDiscovery).toBe(true)
88 }
89 else expect(before.linux.status).toBe('unsupported')
90 expect(() => process.kill(pid, 0)).toThrow()
91 expect(() => process.kill(childPid, 0)).toThrow()
92 passed = true
93 }
94 finally {
95 if (original === undefined) delete process.env.BROWSE_BROWSER
96 else process.env.BROWSE_BROWSER = original
97 const pids: number[] = JSON.parse(await readFile(pidPath, 'utf8').catch(() => '[]'))
98 for (const pid of pids) {
99 let gone = false
100 try { process.kill(pid, 0) }
101 catch (error) { gone = (error as NodeJS.ErrnoException).code === 'ESRCH' }
102 expect(gone).toBe(true)
103 }
104 if (passed && !supplied) await rm(root, { recursive: true, force: true })
105 else console.log(JSON.stringify({ evidence: 'controlled-live-native-launch', retainedRoot: root, passed }))
106 }
107}, 35000)
tests/unit/public-safety-browser-startup-process.test.tsadded+80-0
Changes to tests/unit/public-safety-browser-startup-process.test.ts
@@ -0,0 +1,80 @@
1import { expect, test } from 'bun:test'
2import { mkdtemp, readlink, readdir, rm } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { createBrowserStartupObserver } from '../helpers/browser-startup-process'
6import type { StartupProcessAdapter } from '../helpers/browser-startup-process'
7import { createLinuxStartupObservation } from '../helpers/browser-startup-linux'
8
9// OS adapter seam: a held syscall promise checks ownership/cancellation, not Linux field acquisition.
10test('an expired observation remains owned through actual settlement and prevents later provider work', async () => {
11 let release!: () => void
12 const held = new Promise<void>(resolve => { release = resolve })
13 let calls = 0, active = 0, maximumActive = 0, finalized = false
14 const settlements: boolean[] = []
15 const adapter: StartupProcessAdapter = {
16 async snapshot() {
17 calls++; active++; maximumActive = Math.max(maximumActive, active)
18 try { if (calls === 1) await held; return { status: 'unavailable', code: 'CONTROLLED_OS' } }
19 finally { active-- }
20 },
21 async settleExecutables(hash) { expect(active).toBe(0); settlements.push(hash) },
22 evidence: () => ({ executables: [], identityErrors: [], executableLimitReached: false }),
23 }
24 const observer = createBrowserStartupObserver(process.pid, performance.now() - 1001, adapter)
25 let finishing: Promise<void> | undefined
26 try {
27 observer.tick()
28 await Bun.sleep(600)
29 observer.tick()
30 expect(calls).toBe(1)
31 finishing = observer.finishDiscovery().then(() => { finalized = true })
32 await Bun.sleep(10)
33 expect(finalized).toBe(false)
34 expect(settlements).toEqual([])
35 release(); await finishing
36 expect(maximumActive).toBe(1)
37 expect(calls).toBe(1)
38 expect(settlements).toEqual([false])
39 const final = observer.evidence()
40 observer.tick(); await Bun.sleep(10)
41 expect(observer.evidence()).toEqual(final)
42 expect(final.snapshots[0]!.linux.status).toBe('timeout')
43 expect(final.snapshots[0]!.completedAtMs).toBeGreaterThan(final.snapshots[0]!.elapsedMs + 500)
44 }
45 finally { release(); await finishing; await Bun.sleep(0) }
46})
47
48test.skipIf(process.platform !== 'linux')('cancellation closes actual pinned executable descriptors without hashing after the held native sample settles', async () => {
49 const root = await mkdtemp(join(tmpdir(), 'drone-observer-descriptors-'))
50 const executable = await readlink('/proc/self/exe')
51 const descriptors = async () => {
52 const ids = await readdir('/proc/self/fd')
53 const targets = await Promise.all(ids.map(id => readlink(`/proc/self/fd/${id}`).catch(() => null)))
54 return targets.filter(path => path === executable).length
55 }
56 const before = await descriptors(), began = performance.now()
57 const native = createLinuxStartupObservation(process.pid, root, began)
58 let release!: () => void, ready!: () => void
59 const held = new Promise<void>(resolve => { release = resolve }), captured = new Promise<void>(resolve => { ready = resolve })
60 const observer = createBrowserStartupObserver(process.pid, began, {
61 async snapshot(signal) {
62 const result = await native.snapshot(signal).catch(() => ({ status: 'unavailable' as const, code: 'NATIVE_UNAVAILABLE' }))
63 ready(); await held; return result
64 },
65 settleExecutables: hash => native.settleExecutables(hash), evidence: native.evidence,
66 })
67 let finishing: Promise<void> | undefined
68 try {
69 observer.tick(); await captured
70 expect(await descriptors()).toBeGreaterThan(before)
71 await Bun.sleep(600)
72 finishing = observer.finishDiscovery()
73 release(); await finishing
74 const report = observer.evidence()
75 expect(report.executables.length).toBeGreaterThan(0)
76 expect(report.executables.every(e => e.sha256 === null && e.code === 'OBSERVATION_CANCELED' && e.closeCode === null)).toBe(true)
77 expect(await descriptors()).toBe(before)
78 }
79 finally { release(); await (finishing ?? observer.finishDiscovery()); await rm(root, { recursive: true, force: true }) }
80})