Add bidirectional RF handoff coordination
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
TASK_ID = RF001A_OTA_CAPTURE_001
|
TASK_ID = RF001A_OTA_CAPTURE_002
|
||||||
TASK_TITLE = Capture first RF-001A QPSK OTA transmission
|
TASK_TITLE = Start coordinated RF-001A common-clock OTA capture
|
||||||
SOURCE_BRANCH = video-development
|
SOURCE_BRANCH = video-development
|
||||||
EXPECTED_HEAD = 1e3aa25d7007c7c8e84608cddb02f06f77e63396
|
EXPECTED_HEAD = 34f5ace54540f947311ada7e81a463c08abfd2d3
|
||||||
REQUIRES_APPROVAL = YES
|
REQUIRES_APPROVAL = YES
|
||||||
HARDWARE_ACCESS = YES
|
HARDWARE_ACCESS = YES
|
||||||
RF_RX = YES
|
RF_RX = YES
|
||||||
@@ -17,369 +17,263 @@ If chat history conflicts with these files, the shared RF-001A spec or current G
|
|||||||
# Current RX task
|
# Current RX task
|
||||||
|
|
||||||
TASK =
|
TASK =
|
||||||
CAPTURE FIRST RF-001A QPSK OTA TRANSMISSION
|
START COORDINATED RF-001A COMMON-CLOCK OTA CAPTURE
|
||||||
|
|
||||||
This is a new YELLOW task. Do not reuse approval or completion state from any earlier task ID.
|
This is a new YELLOW task. Do not reuse approval or completion state from RF001A_OTA_CAPTURE_001 or any earlier task.
|
||||||
|
|
||||||
## Accepted preflight evidence
|
One approval for this task covers its read-only checks, bounded RF RX capture, and publication of the two small allowlisted result files to the existing rx-node branch. It never authorizes RF TX.
|
||||||
|
|
||||||
The user supplied these completed preflight results:
|
|
||||||
|
|
||||||
```text
|
|
||||||
COMMON CLOCK VERIFIED = YES
|
|
||||||
CLKIN BEFORE SHORT RX = DETECTED
|
|
||||||
SHORT RX = PASS, 65536 samples, overruns 0
|
|
||||||
CLKIN AFTER SHORT RX = DETECTED
|
|
||||||
CLKIN AFTER 20 s CAPTURE = DETECTED
|
|
||||||
CAPTURE SIZE = 320000000 bytes
|
|
||||||
CAPTURE DURATION = 20.00486 s
|
|
||||||
RX OVERRUNS = 0
|
|
||||||
USB ERRORS = 0
|
|
||||||
FILE ERRORS = 0
|
|
||||||
RAW IQ SHA256 = c0abdc2a41a0265a8037b4a5296d20b843eb75bcee83a6f2fac5813a533b7147
|
|
||||||
RX HARDWARE PREFLIGHT = PASS WITH PROCESS-EXIT-CODE OBSERVABILITY LIMITATION
|
|
||||||
```
|
|
||||||
|
|
||||||
The exact child process exit code was not retained. Do not repeat the 20-second preflight solely to recover that missing value. This task fixes exit-code observability for the new OTA capture.
|
|
||||||
|
|
||||||
## 1. Preconditions
|
## 1. Preconditions
|
||||||
|
|
||||||
After one explicit `[A] Approve` for this task:
|
After the user chooses [A] Approve for this exact TASK_ID:
|
||||||
|
|
||||||
1. Run `git pull --ff-only origin video-development`.
|
1. Run git pull --ff-only origin video-development.
|
||||||
2. Require branch `video-development`.
|
2. Require branch video-development.
|
||||||
3. Require `git status --short --untracked-files=no` to produce no output.
|
3. Require git status --short --untracked-files=no to produce no output.
|
||||||
4. Require local HEAD to equal `origin/video-development` and `EXPECTED_HEAD` to be its ancestor.
|
4. Require local HEAD to equal origin/video-development and EXPECTED_HEAD to be its ancestor.
|
||||||
5. Run `hackrf_info` and preserve complete stdout.
|
5. Run hackrf_info and preserve complete stdout.
|
||||||
6. Require RX serial `000000000000000097c063dc2e5327d3`.
|
6. Require RX serial 000000000000000097c063dc2e5327d3.
|
||||||
7. Run the official read-only clock check and preserve complete stdout:
|
7. Confirm that configs/rf/rf001a_common_clock.json exists and matches the fixed RF-001A frequency, sample rate, modulation and common-clock plan.
|
||||||
|
8. With MASTER CLKOUT physically connected to SLAVE CLKIN, run:
|
||||||
|
|
||||||
```powershell
|
hackrf_clock.exe -d 000000000000000097c063dc2e5327d3 -i
|
||||||
& '<verified hackrf_clock.exe path>' -d 000000000000000097c063dc2e5327d3 -i
|
|
||||||
```
|
|
||||||
|
|
||||||
Require the exact line `CLKIN status: clock signal detected`. If it is absent, do not start the capture; keep `RF TX = 0` and STOP. Do not reconfigure the clock.
|
9. Preserve complete stdout and require the exact line:
|
||||||
|
|
||||||
Create a new unique capture directory outside the Git checkout. Refuse any existing directory and never overwrite an earlier capture. Before starting, require at least `1100000000` free bytes on the target volume and record the measured free-byte count.
|
CLKIN status: clock signal detected
|
||||||
|
|
||||||
## 2. Authorized finite RX capture
|
10. Require the remote branch origin/rx-node to exist. Do not create it in this task.
|
||||||
|
11. Create a unique capture directory outside Git. Refuse an existing directory and never overwrite an earlier capture.
|
||||||
|
12. Require at least 2500000000 free bytes on the target volume. Record the measured free-byte count.
|
||||||
|
|
||||||
Only this capture is authorized:
|
If any condition fails, do not start capture. Report the failed condition, keep RF TX = 0, and STOP.
|
||||||
|
|
||||||
```text
|
## 2. Authorized bounded RX capture
|
||||||
RX serial = 000000000000000097c063dc2e5327d3
|
|
||||||
center frequency = 1400000000 Hz
|
|
||||||
sample rate = 8000000 samples/s
|
|
||||||
RX LNA = 24 dB
|
|
||||||
RX VGA = 24 dB
|
|
||||||
RF amp = OFF
|
|
||||||
antenna bias = OFF
|
|
||||||
duration = 60 s
|
|
||||||
sample count = 480000000 complex samples
|
|
||||||
hardware format = cs8
|
|
||||||
expected file size = 960000000 bytes
|
|
||||||
common clock = DETECTED
|
|
||||||
```
|
|
||||||
|
|
||||||
The required `hackrf_transfer.exe` arguments are:
|
Only this finite receive operation is authorized:
|
||||||
|
|
||||||
```text
|
RX serial = 000000000000000097c063dc2e5327d3
|
||||||
-d 000000000000000097c063dc2e5327d3
|
center frequency = 1400000000 Hz
|
||||||
-r <unique_capture_path>\rf001a_ota_capture.cs8
|
sample rate = 8000000 samples/s
|
||||||
-f 1400000000
|
RX LNA = 24 dB
|
||||||
-s 8000000
|
RX VGA = 24 dB
|
||||||
-n 480000000
|
RF amp = OFF
|
||||||
-l 24
|
antenna bias = OFF
|
||||||
-g 24
|
duration = 120 s
|
||||||
-a 0
|
sample count = 960000000 complex samples
|
||||||
-p 0
|
hardware format = cs8
|
||||||
-B
|
expected file size = 1920000000 bytes
|
||||||
```
|
common clock = DETECTED
|
||||||
|
|
||||||
## 3. Durable process and exit-code recording
|
The hackrf_transfer.exe arguments are fixed:
|
||||||
|
|
||||||
Use a local runner outside Git. The runner owns the `hackrf_transfer` process, records its actual PID immediately, waits for that exact child, and writes the exact child `ExitCode` to an atomic result JSON file. This remains observable even if the assistant turn or PowerShell parent that started the runner ends.
|
-d 000000000000000097c063dc2e5327d3
|
||||||
|
-r <unique_capture_path>\rf001a_ota_capture.cs8
|
||||||
|
-f 1400000000
|
||||||
|
-s 8000000
|
||||||
|
-n 960000000
|
||||||
|
-l 24
|
||||||
|
-g 24
|
||||||
|
-a 0
|
||||||
|
-p 0
|
||||||
|
-B
|
||||||
|
|
||||||
Prepare paths:
|
Do not add repeat mode. Do not change gain, clock, frequency or sample rate.
|
||||||
|
|
||||||
```powershell
|
## 3. Background runner and durable evidence
|
||||||
$runId = '{0}_{1}' -f (Get-Date -Format 'yyyyMMdd_HHmmss'), ([guid]::NewGuid().ToString('N').Substring(0, 8))
|
|
||||||
$captureRoot = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'SDR_Rover_Captures'
|
|
||||||
$captureDirectory = Join-Path $captureRoot "RF001A_OTA_CAPTURE_001_$runId"
|
|
||||||
if (Test-Path -LiteralPath $captureDirectory) { throw "Capture directory already exists: $captureDirectory" }
|
|
||||||
New-Item -ItemType Directory -Path $captureDirectory -ErrorAction Stop | Out-Null
|
|
||||||
|
|
||||||
$targetRoot = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($captureDirectory))
|
Use a local runner outside Git. The runner must:
|
||||||
$targetDrive = Get-PSDrive -Name $targetRoot.Substring(0, 1)
|
|
||||||
if ($targetDrive.Free -lt 1100000000) { throw "Insufficient free space: $($targetDrive.Free) bytes" }
|
|
||||||
|
|
||||||
$capturePath = Join-Path $captureDirectory 'rf001a_ota_capture.cs8'
|
- start exactly one hackrf_transfer child;
|
||||||
$stdoutPath = Join-Path $captureDirectory 'hackrf_transfer.stdout.log'
|
- record its PID and start time immediately;
|
||||||
$stderrPath = Join-Path $captureDirectory 'hackrf_transfer.stderr.log'
|
- record the expected end time as start time plus 120 seconds;
|
||||||
$startedStatusPath = Join-Path $captureDirectory 'capture_started.json'
|
- redirect complete stdout and stderr to unique local files;
|
||||||
$resultStatusPath = Join-Path $captureDirectory 'capture_result.json'
|
- wait for that exact child;
|
||||||
$runnerPath = Join-Path $captureDirectory 'capture_runner.ps1'
|
- record its exact exit code, finish time, duration and output size;
|
||||||
$runnerStdoutPath = Join-Path $captureDirectory 'capture_runner.stdout.log'
|
- calculate SHA256 after the file is closed;
|
||||||
$runnerStderrPath = Join-Path $captureDirectory 'capture_runner.stderr.log'
|
- write started and final JSON atomically;
|
||||||
$hackrfTransfer = '<verified absolute path to hackrf_transfer.exe>'
|
- never start a second capture automatically.
|
||||||
```
|
|
||||||
|
|
||||||
Write this runner to `$runnerPath`:
|
Start the runner as a controlled background process. Within five seconds, require all four facts:
|
||||||
|
|
||||||
```powershell
|
runner process alive = YES
|
||||||
param(
|
hackrf_transfer child alive = YES
|
||||||
[Parameter(Mandatory = $true)][string]$HackrfTransfer,
|
capture file created = YES
|
||||||
[Parameter(Mandatory = $true)][string]$CapturePath,
|
capture file growing = YES
|
||||||
[Parameter(Mandatory = $true)][string]$StdoutPath,
|
|
||||||
[Parameter(Mandatory = $true)][string]$StderrPath,
|
|
||||||
[Parameter(Mandatory = $true)][string]$StartedStatusPath,
|
|
||||||
[Parameter(Mandatory = $true)][string]$ResultStatusPath
|
|
||||||
)
|
|
||||||
|
|
||||||
function Write-JsonAtomically {
|
If any fact is false, publish no READY state. Preserve local evidence, report RX CAPTURE STARTED = NO, keep RF TX = 0, and STOP.
|
||||||
param([Parameter(Mandatory = $true)][object]$Value,
|
|
||||||
[Parameter(Mandatory = $true)][string]$Path)
|
|
||||||
$temporaryPath = "$Path.tmp"
|
|
||||||
$Value | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $temporaryPath -Encoding Ascii
|
|
||||||
Move-Item -LiteralPath $temporaryPath -Destination $Path -Force
|
|
||||||
}
|
|
||||||
|
|
||||||
$arguments = @(
|
## 4. Publish READY immediately
|
||||||
'-d', '000000000000000097c063dc2e5327d3',
|
|
||||||
'-r', $CapturePath,
|
|
||||||
'-f', '1400000000',
|
|
||||||
'-s', '8000000',
|
|
||||||
'-n', '480000000',
|
|
||||||
'-l', '24',
|
|
||||||
'-g', '24',
|
|
||||||
'-a', '0',
|
|
||||||
'-p', '0',
|
|
||||||
'-B'
|
|
||||||
)
|
|
||||||
|
|
||||||
$startedAt = [DateTimeOffset]::Now
|
As soon as the child is alive and the file is growing, build a local JSON file outside Git with every field below:
|
||||||
$transferProcess = $null
|
|
||||||
$exitCode = $null
|
|
||||||
$runnerError = $null
|
|
||||||
|
|
||||||
try {
|
{
|
||||||
$transferProcess = Start-Process -FilePath $HackrfTransfer -ArgumentList $arguments `
|
"schema_version": 1,
|
||||||
-RedirectStandardOutput $StdoutPath -RedirectStandardError $StderrPath `
|
"task_id": "RF001A_OTA_CAPTURE_002",
|
||||||
-PassThru -WindowStyle Hidden
|
"state": "RX_READY_FOR_TX",
|
||||||
|
"rx_ready_for_tx": true,
|
||||||
Write-JsonAtomically -Path $StartedStatusPath -Value ([ordered]@{
|
"process_alive": true,
|
||||||
state = 'RUNNING'
|
"file_growing": true,
|
||||||
capture_pid = $transferProcess.Id
|
"capture_pid": <actual positive child PID>,
|
||||||
started_at = $startedAt.ToString('o')
|
"published_at": "<current UTC ISO-8601 time>",
|
||||||
capture_path = $CapturePath
|
"capture_started_at": "<actual UTC ISO-8601 start time>",
|
||||||
})
|
"expected_capture_end": "<start time plus 120 seconds>",
|
||||||
|
"capture_path": "<absolute local capture path>",
|
||||||
$transferProcess.WaitForExit()
|
"common_clock_verified": true,
|
||||||
$transferProcess.Refresh()
|
"source_head": "<current local 40-character Git HEAD>",
|
||||||
$exitCode = $transferProcess.ExitCode
|
"rf_tx": 0,
|
||||||
} catch {
|
"exit_code": null,
|
||||||
$runnerError = $_.Exception.ToString()
|
"file_size": null,
|
||||||
} finally {
|
"duration_seconds": null,
|
||||||
$finishedAt = [DateTimeOffset]::Now
|
"overruns": null,
|
||||||
$captureSize = if (Test-Path -LiteralPath $CapturePath) {
|
"usb_errors": null,
|
||||||
(Get-Item -LiteralPath $CapturePath).Length
|
"file_errors": null,
|
||||||
} else {
|
"sha256": null,
|
||||||
0
|
"clkin_after": "NOT_RUN",
|
||||||
|
"rf_tx_observed": "UNKNOWN"
|
||||||
}
|
}
|
||||||
|
|
||||||
$sha256 = $null
|
Publish it with the approved RX watcher state:
|
||||||
try {
|
|
||||||
if (Test-Path -LiteralPath $CapturePath) {
|
|
||||||
$sha256 = (Get-FileHash -LiteralPath $CapturePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
$errorParts = @($runnerError, $_.Exception.ToString()) | Where-Object { $null -ne $_ -and $_ -ne '' }
|
|
||||||
$runnerError = $errorParts -join [Environment]::NewLine
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-JsonAtomically -Path $ResultStatusPath -Value ([ordered]@{
|
python -B tools/rx_agent_watcher.py --publish-result <ready-json-path> --result-branch rx-node
|
||||||
state = 'COMPLETE'
|
|
||||||
capture_pid = if ($null -ne $transferProcess) { $transferProcess.Id } else { $null }
|
|
||||||
started_at = $startedAt.ToString('o')
|
|
||||||
finished_at = $finishedAt.ToString('o')
|
|
||||||
duration_seconds = ($finishedAt - $startedAt).TotalSeconds
|
|
||||||
exit_code = $exitCode
|
|
||||||
capture_path = $CapturePath
|
|
||||||
capture_size_bytes = $captureSize
|
|
||||||
sha256 = $sha256
|
|
||||||
runner_error = $runnerError
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($null -eq $exitCode) { exit 255 }
|
The publisher may change only:
|
||||||
exit $exitCode
|
|
||||||
```
|
|
||||||
|
|
||||||
Start the runner as a controlled background process. Quote every path passed through `-ArgumentList`:
|
docs/handoff/results/RX_RESULT.json
|
||||||
|
docs/handoff/results/RX_RESULT.md
|
||||||
|
|
||||||
```powershell
|
It uses a temporary checkout of the existing rx-node branch. It must not switch the source checkout, push video-development, or add raw IQ, video, waveform or large logs.
|
||||||
$currentPowerShell = (Get-Process -Id $PID).Path
|
|
||||||
$runnerArguments = @(
|
|
||||||
'-NoLogo',
|
|
||||||
'-NoProfile',
|
|
||||||
'-NonInteractive',
|
|
||||||
'-ExecutionPolicy', 'Bypass',
|
|
||||||
'-File', ('"{0}"' -f $runnerPath),
|
|
||||||
'-HackrfTransfer', ('"{0}"' -f $hackrfTransfer),
|
|
||||||
'-CapturePath', ('"{0}"' -f $capturePath),
|
|
||||||
'-StdoutPath', ('"{0}"' -f $stdoutPath),
|
|
||||||
'-StderrPath', ('"{0}"' -f $stderrPath),
|
|
||||||
'-StartedStatusPath', ('"{0}"' -f $startedStatusPath),
|
|
||||||
'-ResultStatusPath', ('"{0}"' -f $resultStatusPath)
|
|
||||||
)
|
|
||||||
$runnerProcess = Start-Process -FilePath $currentPowerShell -ArgumentList $runnerArguments `
|
|
||||||
-RedirectStandardOutput $runnerStdoutPath -RedirectStandardError $runnerStderrPath `
|
|
||||||
-PassThru -WindowStyle Hidden
|
|
||||||
```
|
|
||||||
|
|
||||||
Wait only long enough to prove that the child process is alive and the file is growing:
|
If publication fails, show the complete error. Do not claim RX READY FOR TX and do not trigger TX automatically. The finite capture may finish normally, but RF TX remains 0.
|
||||||
|
|
||||||
```powershell
|
After successful publication, immediately return control to the user without waiting for capture completion:
|
||||||
$startDeadline = (Get-Date).AddSeconds(5)
|
|
||||||
$startedStatus = $null
|
|
||||||
$captureBytes = 0
|
|
||||||
$captureAlive = $false
|
|
||||||
|
|
||||||
do {
|
TASK_ID =
|
||||||
Start-Sleep -Milliseconds 100
|
RF001A_OTA_CAPTURE_002
|
||||||
$runnerProcess.Refresh()
|
|
||||||
if (Test-Path -LiteralPath $startedStatusPath) {
|
|
||||||
$startedStatus = Get-Content -Raw -LiteralPath $startedStatusPath | ConvertFrom-Json
|
|
||||||
$captureProcess = Get-Process -Id ([int]$startedStatus.capture_pid) -ErrorAction SilentlyContinue
|
|
||||||
$captureAlive = $null -ne $captureProcess
|
|
||||||
}
|
|
||||||
if (Test-Path -LiteralPath $capturePath) {
|
|
||||||
$captureBytes = (Get-Item -LiteralPath $capturePath).Length
|
|
||||||
}
|
|
||||||
} until ($runnerProcess.HasExited -or ($captureAlive -and $captureBytes -gt 0) -or (Get-Date) -ge $startDeadline)
|
|
||||||
```
|
|
||||||
|
|
||||||
The start passes only when:
|
RX CAPTURE STARTED =
|
||||||
|
YES
|
||||||
|
|
||||||
```text
|
RX READY FOR TX =
|
||||||
runner process alive = YES
|
YES
|
||||||
hackrf_transfer child alive = YES
|
|
||||||
capture file created = YES
|
|
||||||
capture file growing = YES
|
|
||||||
```
|
|
||||||
|
|
||||||
If any check fails, report `RX CAPTURE STARTED = NO`, preserve all local files, keep `RF TX = 0`, and STOP.
|
CAPTURE PID =
|
||||||
|
|
||||||
## 4. Immediate READY report
|
CAPTURE PATH =
|
||||||
|
|
||||||
When all start checks pass, immediately return control to the user with:
|
CAPTURE START TIME =
|
||||||
|
|
||||||
```text
|
EXPECTED CAPTURE END =
|
||||||
TASK_ID =
|
|
||||||
RF001A_OTA_CAPTURE_001
|
|
||||||
|
|
||||||
RX CAPTURE STARTED =
|
RX RESULT PUBLISH =
|
||||||
YES
|
PASS
|
||||||
|
|
||||||
RX READY FOR TX =
|
RF TX =
|
||||||
YES
|
0
|
||||||
|
|
||||||
CAPTURE PID =
|
The READY state describes a live receiver only. It does not authorize RF TX on either computer.
|
||||||
<actual hackrf_transfer child PID>
|
|
||||||
|
|
||||||
CAPTURE PATH =
|
## 5. Capture completion
|
||||||
<absolute path>
|
|
||||||
|
|
||||||
CAPTURE START TIME =
|
After the child exits, read the durable final status. Preserve all local logs and inspect every line for RX overruns, USB errors and file write errors. Do not infer zero when a tool did not report a counter; use JSON null and report NOT_REPORTED.
|
||||||
<started_status started_at>
|
|
||||||
|
|
||||||
EXPECTED END TIME =
|
Run the read-only clock check again:
|
||||||
<started_at plus 60 seconds>
|
|
||||||
|
|
||||||
RF TX =
|
hackrf_clock.exe -d 000000000000000097c063dc2e5327d3 -i
|
||||||
0
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not wait for the 60-second capture to finish before sending `RX READY FOR TX = YES`. This status means only that RX is actively recording. It does not authorize RF TX on the RX laptop or MAIN PC.
|
Require CLKIN to remain detected. The expected complete capture has:
|
||||||
|
|
||||||
## 5. Completion evidence
|
exit code = 0
|
||||||
|
file size = 1920000000 bytes
|
||||||
|
duration = consistent with 120 s
|
||||||
|
RX overruns = 0 or NOT_REPORTED
|
||||||
|
USB errors = 0 or NOT_REPORTED
|
||||||
|
file write errors = 0 or NOT_REPORTED
|
||||||
|
CLKIN after capture = DETECTED
|
||||||
|
|
||||||
After the capture window, read `capture_result.json`. The exact child process exit code must be present. Also preserve complete stdout, stderr and runner logs.
|
A shorter file is incomplete. Any reported nonzero error remains evidence. Do not retry automatically.
|
||||||
|
|
||||||
Require and report:
|
## 6. Publish final result
|
||||||
|
|
||||||
- process exit code;
|
Build a second local JSON document with the same identity and timing fields. Set:
|
||||||
- actual capture size;
|
|
||||||
- actual duration;
|
|
||||||
- SHA256;
|
|
||||||
- every RX overrun counter or event from the complete logs;
|
|
||||||
- every USB error;
|
|
||||||
- every file write error;
|
|
||||||
- CLKIN status after capture.
|
|
||||||
|
|
||||||
Run the read-only clock check again. Require `CLKIN status: clock signal detected`.
|
state = CAPTURE_COMPLETE
|
||||||
|
rx_ready_for_tx = false
|
||||||
|
process_alive = false
|
||||||
|
file_growing = false
|
||||||
|
exit_code = <exact integer>
|
||||||
|
file_size = <actual integer bytes>
|
||||||
|
duration_seconds = <actual number>
|
||||||
|
overruns = <integer or null when not reported>
|
||||||
|
usb_errors = <integer or null when not reported>
|
||||||
|
file_errors = <integer or null when not reported>
|
||||||
|
sha256 = <lowercase 64-character SHA256>
|
||||||
|
clkin_after = DETECTED or NOT_DETECTED
|
||||||
|
rf_tx_observed = EXPECTED_EXTERNAL_TX or UNKNOWN
|
||||||
|
published_at = <current UTC ISO-8601 time>
|
||||||
|
rf_tx = 0
|
||||||
|
|
||||||
The capture is complete only when:
|
Use EXPECTED_EXTERNAL_TX only when the coordinated MAIN TX event is known to have occurred inside this capture window. Otherwise use UNKNOWN.
|
||||||
|
|
||||||
```text
|
Publish with:
|
||||||
exit code = 0
|
|
||||||
capture size = 960000000 bytes
|
|
||||||
duration = consistent with 60 s
|
|
||||||
RX overruns = 0
|
|
||||||
USB errors = 0
|
|
||||||
file write errors = 0
|
|
||||||
CLKIN after capture = DETECTED
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not infer zero from a missing counter. Use `NOT_REPORTED` when the tool did not report a value. Treat every nonzero intermediate or final overrun/error line as evidence; do not keep only the last counter. Do not retry automatically.
|
python -B tools/rx_agent_watcher.py --publish-result <final-json-path> --result-branch rx-node
|
||||||
|
|
||||||
## 6. Offline decode
|
If the push fails, preserve the local JSON and logs, report the error, and do not claim publication success.
|
||||||
|
|
||||||
Use the saved OTA IQ without changing the receiver for this capture:
|
## 7. Offline decode
|
||||||
|
|
||||||
```powershell
|
Do not start automatic decode unless synchronized truth metadata for this exact transmitted waveform is available and consistent. If it is absent, preserve the IQ file and report:
|
||||||
python -B -X utf8 tools/qpsk_rf_rx.py `
|
|
||||||
--iq-file '<absolute capture path>' `
|
|
||||||
--iq-format cs8 `
|
|
||||||
--truth-metadata data/raw/rf001/rf001a_common_clock_001/tx_metadata.json `
|
|
||||||
--report '<new unique path outside Git>\rf001a_ota_decode.json'
|
|
||||||
```
|
|
||||||
|
|
||||||
Require the existing truth metadata for the transmitted waveform. If it is absent or inconsistent, preserve the IQ file and report `OFFLINE DECODE = BLOCKED_BY_TRUTH_METADATA`; do not alter the receiver or invent results.
|
OFFLINE DECODE = BLOCKED_BY_TRUTH_METADATA
|
||||||
|
|
||||||
Report at minimum:
|
Do not modify the receiver or invent results.
|
||||||
|
|
||||||
```text
|
## 8. Prohibited actions
|
||||||
DETECTED PREAMBLES =
|
|
||||||
CRC VALID PACKETS =
|
|
||||||
EXACT PACKETS =
|
|
||||||
MISSING PACKETS =
|
|
||||||
DUPLICATES =
|
|
||||||
OUT OF ORDER =
|
|
||||||
RAW BER =
|
|
||||||
RAW BER SCOPE =
|
|
||||||
POST-FEC ERROR RATE =
|
|
||||||
CFO MEDIAN =
|
|
||||||
CFO P95 =
|
|
||||||
TIMING OFFSET =
|
|
||||||
TIMING DRIFT =
|
|
||||||
RX CLIPPING =
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the existing receiver JSON fields directly. Derive `OUT OF ORDER` only from `packet_sequences`. Derive timing summaries only from `phase_and_timing_estimates`; if the available data does not support a value, report `NOT_MEASURED`. Do not fabricate a metric.
|
On the RX laptop, do not perform:
|
||||||
|
|
||||||
Classify this first smoke test:
|
- RF TX;
|
||||||
|
- continuous RX;
|
||||||
|
- automatic capture retry;
|
||||||
|
- firmware or CPLD update;
|
||||||
|
- hackrf_spiflash;
|
||||||
|
- Zadig or USB driver changes;
|
||||||
|
- clock reconfiguration;
|
||||||
|
- commit or push to video-development;
|
||||||
|
- creation of rx-node;
|
||||||
|
- publication of raw IQ, video, waveform or large logs.
|
||||||
|
|
||||||
```text
|
## 9. Final RX report
|
||||||
99-100 exact = GOOD
|
|
||||||
95-98 exact = PARTIAL / DIAGNOSTIC
|
|
||||||
<95 exact = FAIL FOR VIDEO PROGRESSION
|
|
||||||
```
|
|
||||||
|
|
||||||
One run is not statistical reliability evidence.
|
TASK_ID =
|
||||||
|
RF001A_OTA_CAPTURE_002
|
||||||
|
|
||||||
## 7. Prohibited actions
|
RX CAPTURE COMPLETE =
|
||||||
|
|
||||||
On the RX laptop, do not perform RF TX, firmware or CPLD updates, Zadig operations, USB driver changes, clock reconfiguration, continuous RX, automatic capture retries, commits to `video-development`, or pushes to `video-development`.
|
EXIT CODE =
|
||||||
|
|
||||||
`ALLOW_PUSH = RX_RESULT_ONLY` permits only a separately approved small result publication under the handoff protocol. Raw IQ, large logs and local runner files remain outside Git.
|
CAPTURE SIZE =
|
||||||
|
|
||||||
|
CAPTURE DURATION =
|
||||||
|
|
||||||
|
RX OVERRUNS =
|
||||||
|
|
||||||
|
USB ERRORS =
|
||||||
|
|
||||||
|
FILE WRITE ERRORS =
|
||||||
|
|
||||||
|
RAW IQ SHA256 =
|
||||||
|
|
||||||
|
CLKIN AFTER =
|
||||||
|
|
||||||
|
RF TX OBSERVED =
|
||||||
|
|
||||||
|
FINAL RX RESULT PUBLISH =
|
||||||
|
|
||||||
|
OFFLINE DECODE =
|
||||||
|
|
||||||
|
RF TX =
|
||||||
|
0
|
||||||
|
|
||||||
|
STOP.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
TASK_ID = RF001A_OTA_TX_001
|
TASK_ID = RF001A_OTA_TX_002
|
||||||
TASK_TITLE = Transmit first RF-001A QPSK packet burst
|
TASK_TITLE = Transmit one coordinated RF-001A QPSK burst
|
||||||
SOURCE_BRANCH = video-development
|
SOURCE_BRANCH = video-development
|
||||||
EXPECTED_HEAD = 1e3aa25d7007c7c8e84608cddb02f06f77e63396
|
EXPECTED_HEAD = 34f5ace54540f947311ada7e81a463c08abfd2d3
|
||||||
REQUIRES_APPROVAL = YES
|
REQUIRES_APPROVAL = YES
|
||||||
HARDWARE_ACCESS = YES
|
HARDWARE_ACCESS = YES
|
||||||
RF_RX = NO
|
RF_RX = NO
|
||||||
@@ -11,205 +11,209 @@ ALLOW_PUSH = NO
|
|||||||
RISK_LEVEL = RED
|
RISK_LEVEL = RED
|
||||||
|
|
||||||
This file is the authoritative current task for the MAIN PC TX node.
|
This file is the authoritative current task for the MAIN PC TX node.
|
||||||
Read TX_NODE_STATE.md first. If chat history, this file, the shared RF-001A spec and current Git HEAD conflict, stop and report the conflict.
|
Read TX_NODE_STATE.md first. If chat history, this file, the shared RF-001A spec or current Git HEAD conflicts, stop and report the conflict.
|
||||||
|
|
||||||
# Current TX task
|
# Current TX task
|
||||||
|
|
||||||
TASK =
|
TASK =
|
||||||
TRANSMIT FIRST RF-001A QPSK PACKET BURST
|
TRANSMIT ONE COORDINATED RF-001A QPSK BURST
|
||||||
|
|
||||||
This is a new RED task. It does not inherit the RX task approval. Publication of this file is not permission to transmit.
|
This is a new RED task. Publication of this file is not permission to transmit. It does not inherit any approval from RF001A_OTA_TX_001, the RX task, a previous watcher prompt or a previous RF run.
|
||||||
|
|
||||||
Before any RF TX, the user must:
|
## 1. Coordination gate
|
||||||
|
|
||||||
1. separately provide the exact authorization `AUTHORIZE RF TX`;
|
Start the local model-free watcher before the RX task begins:
|
||||||
2. confirm the live RX status `RX READY FOR TX = YES` for task `RF001A_OTA_CAPTURE_001`.
|
|
||||||
|
|
||||||
A normal approval, an earlier RF authorization, or an RX approval is insufficient.
|
tools\start_tx_watcher.cmd
|
||||||
|
|
||||||
## 1. Fixed RF-001A parameters
|
The watcher polls origin/rx-node every 5 seconds and accepts only a strict result for:
|
||||||
|
|
||||||
```text
|
RX TASK_ID = RF001A_OTA_CAPTURE_002
|
||||||
center frequency = 1400000000 Hz
|
STATE = RX_READY_FOR_TX
|
||||||
sample rate = 8000000 samples/s
|
RX_READY_FOR_TX = YES
|
||||||
modulation = QPSK
|
PROCESS_ALIVE = YES
|
||||||
symbol rate = 500000 symbols/s
|
FILE_GROWING = YES
|
||||||
inner FEC = 2/3
|
COMMON_CLOCK_VERIFIED = YES
|
||||||
payload = 256 bytes
|
RF_TX = 0
|
||||||
packet count = 100
|
|
||||||
inter-packet gap = 5 ms
|
|
||||||
digital carrier offset = +125000 Hz
|
|
||||||
TX VGA = 20 dB
|
|
||||||
RF amp = OFF
|
|
||||||
antenna bias = OFF
|
|
||||||
common 10 MHz reference = VERIFIED
|
|
||||||
distance = approximately 2 m
|
|
||||||
LOS = YES
|
|
||||||
video = OFF
|
|
||||||
```
|
|
||||||
|
|
||||||
The waveform is one finite burst. Never add `-R`.
|
The READY publication must be no more than 30 seconds old. Its planned capture window must be 120 seconds. At least 10 seconds must remain.
|
||||||
|
|
||||||
## 2. Preconditions
|
When a valid new READY result appears, the watcher prints:
|
||||||
|
|
||||||
Before asking for RED authorization, prepare all read-only evidence:
|
RX IS READY
|
||||||
|
|
||||||
1. Run `git pull --ff-only origin video-development`.
|
TASK ID =
|
||||||
2. Require branch `video-development`.
|
CAPTURE STARTED =
|
||||||
3. Require `git status --short --untracked-files=no` to produce no output.
|
CAPTURE END EXPECTED =
|
||||||
4. Require local HEAD to equal `origin/video-development` and `EXPECTED_HEAD` to be its ancestor.
|
|
||||||
5. Run `hackrf_info` and preserve complete stdout.
|
|
||||||
6. Require TX serial `000000000000000097c063dc323b97d3`.
|
|
||||||
7. Require the recorded MASTER CLKOUT command result = `PASS`.
|
|
||||||
8. Require the user to confirm both TX and RX antennas connected, approximately 2 m LOS and the intended antenna geometry.
|
|
||||||
9. Require the current RX report to show `RX READY FOR TX = YES`, task ID `RF001A_OTA_CAPTURE_001`, a live capture PID, capture path, start time and expected end time.
|
|
||||||
10. Require current time to be before the RX expected end time.
|
|
||||||
|
|
||||||
Verify the prepared waveform without accessing radio hardware:
|
TX TASK =
|
||||||
|
RF001A_OTA_TX_002
|
||||||
|
|
||||||
```powershell
|
RF TX REQUIRES RED APPROVAL
|
||||||
python -B -X utf8 tools/rf001a_prepare.py --verify-dir data/raw/rf001/rf001a_common_clock_001
|
|
||||||
```
|
|
||||||
|
|
||||||
Then verify the actual hardware-format bytes through the existing receiver. The report path must be new and outside Git:
|
Enter:
|
||||||
|
AUTHORIZE RF TX
|
||||||
|
|
||||||
```powershell
|
Without the exact phrase AUTHORIZE RF TX, do not transmit.
|
||||||
python -B -X utf8 tools/qpsk_rf_rx.py `
|
|
||||||
--iq-file data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8 `
|
|
||||||
--iq-format cs8 `
|
|
||||||
--truth-metadata data/raw/rf001/rf001a_common_clock_001/tx_metadata.json `
|
|
||||||
--report '<new unique path outside Git>\rf001a_tx_offline_verify.json'
|
|
||||||
```
|
|
||||||
|
|
||||||
Require the receiver report to contain `crc_valid_packets = 100`, `exact_packets = 100`, no missing packets and no duplicates.
|
After the phrase, the watcher fetches rx-node again and repeats all READY, freshness and capture-window checks. If the result changed or less than 10 seconds remain, it refuses authorization and requires a new RX capture.
|
||||||
|
|
||||||
|
The watcher never opens HackRF hardware. On success it writes one local authorization record under:
|
||||||
|
|
||||||
|
.local\tx_authorizations\
|
||||||
|
|
||||||
|
The record has rf_tx_started = false and rf_tx = 0. It is the handoff to the separately controlled TX executor. It is not evidence that transmission occurred.
|
||||||
|
|
||||||
|
## 2. Fixed RF-001A parameters
|
||||||
|
|
||||||
|
center frequency = 1400000000 Hz
|
||||||
|
sample rate = 8000000 samples/s
|
||||||
|
modulation = QPSK
|
||||||
|
symbol rate = 500000 symbols/s
|
||||||
|
inner FEC = 2/3
|
||||||
|
payload = 256 bytes
|
||||||
|
packet count = 100
|
||||||
|
inter-packet gap = 5 ms
|
||||||
|
digital carrier offset = +125000 Hz
|
||||||
|
TX VGA = 20 dB
|
||||||
|
RF amp = OFF
|
||||||
|
antenna bias = OFF
|
||||||
|
common 10 MHz reference = VERIFIED
|
||||||
|
distance = approximately 2 m
|
||||||
|
LOS = YES
|
||||||
|
video = OFF
|
||||||
|
|
||||||
|
The waveform is one finite burst. Never add repeat mode.
|
||||||
|
|
||||||
|
## 3. Read-only preflight
|
||||||
|
|
||||||
|
Before the watcher can request RED approval:
|
||||||
|
|
||||||
|
1. Run git pull --ff-only origin video-development.
|
||||||
|
2. Require branch video-development.
|
||||||
|
3. Require git status --short --untracked-files=no to produce no output.
|
||||||
|
4. Require local HEAD to equal origin/video-development and EXPECTED_HEAD to be its ancestor.
|
||||||
|
5. Run hackrf_info and preserve complete stdout.
|
||||||
|
6. Require TX serial 000000000000000097c063dc323b97d3.
|
||||||
|
7. Require the recorded MASTER CLKOUT command result = PASS.
|
||||||
|
8. Require confirmation that both antennas are connected, the nodes are approximately 2 m apart, and LOS is clear.
|
||||||
|
9. Verify the prepared waveform without radio access:
|
||||||
|
|
||||||
|
python -B -X utf8 tools/rf001a_prepare.py --verify-dir data/raw/rf001/rf001a_common_clock_001
|
||||||
|
|
||||||
|
10. Verify the actual cs8 bytes through the existing offline receiver:
|
||||||
|
|
||||||
|
python -B -X utf8 tools/qpsk_rf_rx.py --iq-file data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8 --iq-format cs8 --truth-metadata data/raw/rf001/rf001a_common_clock_001/tx_metadata.json --report <new-path-outside-git>\rf001a_tx_offline_verify.json
|
||||||
|
|
||||||
Require:
|
Require:
|
||||||
|
|
||||||
```text
|
canonical waveform = PASS
|
||||||
canonical waveform = PASS
|
hardware-format cs8 = PASS
|
||||||
hardware-format cs8 = PASS
|
offline hardware-format roundtrip = 100/100 exact
|
||||||
offline hardware-format roundtrip = 100/100 exact
|
TX waveform path = data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8
|
||||||
TX waveform path = data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8
|
TX waveform size = 17667656 bytes
|
||||||
TX waveform size = 17667656 bytes
|
TX waveform SHA256 = 75f3fa39bfa88b623b7e4e5a02e3a80b52185007397ea56ceaacc13353974b2
|
||||||
TX waveform SHA256 = 75f3fa39bfa88b623b7e4e5a02e3a80b52185007397ea56ceaacc13353974b2
|
sample count = 8833828 complex samples
|
||||||
sample count = 8833828 complex samples
|
expected duration = 1.1042285 s
|
||||||
expected duration = 1.1042285 s
|
|
||||||
```
|
|
||||||
|
|
||||||
If any value differs, if RX is not ready, or if the RX capture window has ended, do not transmit and STOP.
|
If any value differs, do not transmit.
|
||||||
|
|
||||||
Only after every precondition passes may the user provide the separate exact phrase `AUTHORIZE RF TX`.
|
## 4. Final check before opening TX hardware
|
||||||
|
|
||||||
## 3. One authorized TX execution
|
The controlled TX executor must read the authorization record immediately before TX and require:
|
||||||
|
|
||||||
After the separate RED authorization and the live `RX READY FOR TX = YES` confirmation, create a new unique local evidence directory outside Git. Refuse an existing directory and never overwrite earlier evidence.
|
- task_id = RF001A_OTA_TX_002;
|
||||||
|
- authorization = AUTHORIZE RF TX;
|
||||||
|
- rx_task_id = RF001A_OTA_CAPTURE_002;
|
||||||
|
- dry_run = false;
|
||||||
|
- action = MANUAL_EXECUTOR_GATE_OPEN;
|
||||||
|
- rf_tx_started = false;
|
||||||
|
- rf_tx = 0;
|
||||||
|
- the authorization references the latest fetched rx-node result commit and digest;
|
||||||
|
- current time is still inside the READY capture window with at least 10 seconds remaining.
|
||||||
|
|
||||||
Use this single finite command:
|
Fetch origin/rx-node once more and compare its current result digest and commit with the authorization record. Re-run the READY and time checks. If anything differs, if the result is stale, or if fewer than 10 seconds remain, do not transmit and require a new RX capture and a new exact RED approval.
|
||||||
|
|
||||||
```text
|
## 5. One authorized TX execution
|
||||||
hackrf_transfer.exe
|
|
||||||
-d 000000000000000097c063dc323b97d3
|
|
||||||
-t data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8
|
|
||||||
-f 1400000000
|
|
||||||
-s 8000000
|
|
||||||
-n 8833828
|
|
||||||
-x 20
|
|
||||||
-a 0
|
|
||||||
-p 0
|
|
||||||
-B
|
|
||||||
```
|
|
||||||
|
|
||||||
Run it once, synchronously, and preserve the exact process exit code:
|
Only after every preceding condition passes may the executor create a new unique evidence directory outside Git and run exactly one finite command:
|
||||||
|
|
||||||
```powershell
|
hackrf_transfer.exe
|
||||||
$txRunId = '{0}_{1}' -f (Get-Date -Format 'yyyyMMdd_HHmmss'), ([guid]::NewGuid().ToString('N').Substring(0, 8))
|
-d 000000000000000097c063dc323b97d3
|
||||||
$txEvidenceRoot = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'SDR_Rover_TX_Evidence'
|
-t data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8
|
||||||
$txEvidenceDirectory = Join-Path $txEvidenceRoot "RF001A_OTA_TX_001_$txRunId"
|
-f 1400000000
|
||||||
if (Test-Path -LiteralPath $txEvidenceDirectory) { throw "TX evidence directory already exists: $txEvidenceDirectory" }
|
-s 8000000
|
||||||
New-Item -ItemType Directory -Path $txEvidenceDirectory -ErrorAction Stop | Out-Null
|
-n 8833828
|
||||||
|
-x 20
|
||||||
|
-a 0
|
||||||
|
-p 0
|
||||||
|
-B
|
||||||
|
|
||||||
$hackrfTransfer = 'C:\Users\user\Tools\HackRF\Library\bin\hackrf_transfer.exe'
|
Run it once, synchronously, and preserve:
|
||||||
$waveformPath = (Resolve-Path -LiteralPath 'data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8').Path
|
|
||||||
$stdoutPath = Join-Path $txEvidenceDirectory 'hackrf_transfer.stdout.log'
|
|
||||||
$stderrPath = Join-Path $txEvidenceDirectory 'hackrf_transfer.stderr.log'
|
|
||||||
$txArguments = @(
|
|
||||||
'-d', '000000000000000097c063dc323b97d3',
|
|
||||||
'-t', $waveformPath,
|
|
||||||
'-f', '1400000000',
|
|
||||||
'-s', '8000000',
|
|
||||||
'-n', '8833828',
|
|
||||||
'-x', '20',
|
|
||||||
'-a', '0',
|
|
||||||
'-p', '0',
|
|
||||||
'-B'
|
|
||||||
)
|
|
||||||
|
|
||||||
$txStartedAt = [DateTimeOffset]::Now
|
- exact process PID;
|
||||||
$txProcess = Start-Process -FilePath $hackrfTransfer -ArgumentList $txArguments `
|
- start and end UTC timestamps;
|
||||||
-RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath `
|
- exact exit code;
|
||||||
-PassThru -Wait -WindowStyle Hidden
|
- complete stdout and stderr;
|
||||||
$txFinishedAt = [DateTimeOffset]::Now
|
- every TX underrun line;
|
||||||
$txProcess.Refresh()
|
- every USB error;
|
||||||
$txExitCode = $txProcess.ExitCode
|
- waveform path, size and SHA256;
|
||||||
```
|
- command parameters.
|
||||||
|
|
||||||
Immediately write the exit code, process ID, timestamps, duration, command parameters, waveform size and waveform SHA256 to a new JSON metadata file in `$txEvidenceDirectory`. Preserve complete stdout and stderr.
|
Set RF TX STOPPED = YES only after the finite process exits. Never retry automatically. Never start a second burst.
|
||||||
|
|
||||||
Inspect every buffer-statistics line. Record:
|
## 6. Final TX report
|
||||||
|
|
||||||
```text
|
TASK_ID =
|
||||||
TX start time =
|
RF001A_OTA_TX_002
|
||||||
TX end time =
|
|
||||||
TX PID =
|
|
||||||
TX exit code =
|
|
||||||
TX underruns =
|
|
||||||
USB errors =
|
|
||||||
RF TX STOPPED =
|
|
||||||
YES / NO
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not infer zero from a missing counter. Use `NOT_REPORTED` when no counter exists. Any nonzero intermediate or final underrun/error line remains evidence even if a later line reports zero.
|
RED AUTHORIZATION =
|
||||||
|
AUTHORIZE RF TX / MISSING
|
||||||
|
|
||||||
Set `RF TX STOPPED = YES` only after the finite process exits. Do not start a second burst for any reason. Do not retry automatically.
|
RX READY FOR TX =
|
||||||
|
YES / NO
|
||||||
|
|
||||||
## 4. Final TX report
|
RX RESULT HEAD =
|
||||||
|
|
||||||
```text
|
RX RESULT DIGEST =
|
||||||
TASK_ID =
|
|
||||||
RF001A_OTA_TX_001
|
|
||||||
|
|
||||||
RED AUTHORIZATION =
|
TX SERIAL =
|
||||||
AUTHORIZE RF TX / MISSING
|
|
||||||
|
|
||||||
RX READY FOR TX =
|
TX WAVEFORM SHA256 =
|
||||||
YES / NO
|
|
||||||
|
|
||||||
TX SERIAL =
|
TX START TIME =
|
||||||
|
|
||||||
TX WAVEFORM SHA256 =
|
TX END TIME =
|
||||||
|
|
||||||
TX START TIME =
|
TX EXIT CODE =
|
||||||
|
|
||||||
TX END TIME =
|
TX UNDERRUNS =
|
||||||
|
|
||||||
TX EXIT CODE =
|
USB ERRORS =
|
||||||
|
|
||||||
TX UNDERRUNS =
|
RF TX STOPPED =
|
||||||
|
YES / NO
|
||||||
|
|
||||||
USB ERRORS =
|
BURSTS TRANSMITTED =
|
||||||
|
0 / 1
|
||||||
|
|
||||||
RF TX STOPPED =
|
RF TX =
|
||||||
YES / NO
|
0
|
||||||
|
|
||||||
BURSTS TRANSMITTED =
|
At report time, RF TX = 0 means no transmission process remains active.
|
||||||
0 / 1
|
|
||||||
|
|
||||||
RF TX =
|
## 7. Prohibited actions
|
||||||
0
|
|
||||||
```
|
|
||||||
|
|
||||||
At report time, `RF TX = 0` means no transmission process remains active.
|
Do not:
|
||||||
|
|
||||||
## 5. Prohibited actions
|
- transmit without the fresh READY state and exact RED phrase;
|
||||||
|
- reuse an earlier authorization record;
|
||||||
|
- use repeat mode;
|
||||||
|
- transmit a second burst;
|
||||||
|
- retry automatically;
|
||||||
|
- change frequency, gains, clock configuration or waveform;
|
||||||
|
- update firmware or CPLD;
|
||||||
|
- use Zadig or change USB drivers;
|
||||||
|
- commit or push from this task.
|
||||||
|
|
||||||
Do not transmit before the separate exact RED authorization. Do not inherit approval from the RX task. Do not use repeat mode, start a second burst, change frequency, gains, clock configuration, waveform, firmware, CPLD, Zadig or USB drivers. Do not commit or push from this task.
|
STOP after the single finite burst or any failed gate.
|
||||||
|
|||||||
@@ -2,84 +2,139 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
The RX laptop watches `origin/video-development` with ordinary local Python code. Git polling does not call an LLM, ChatGPT or an agent. A detected task is never executed until the user approves it. The first implementation uses only `ManualExecutor`: it prepares a local task outbox and does not start a model itself.
|
The RX laptop watches origin/video-development with ordinary local Python code. Git polling does not call an LLM, ChatGPT or an agent. A detected task is not prepared until the user approves it.
|
||||||
|
|
||||||
|
The reverse result channel uses the separate origin/rx-node branch. The RX laptop publishes small machine-readable readiness and completion results. The MAIN PC reads those results without merging rx-node into video-development.
|
||||||
|
|
||||||
## Task header
|
## Task header
|
||||||
|
|
||||||
`docs/handoff/NEXT_RX_TASK.md` starts with these plain key-value fields:
|
docs/handoff/NEXT_RX_TASK.md starts with these plain key-value fields:
|
||||||
|
|
||||||
```text
|
TASK_ID = RF001A_OTA_CAPTURE_002
|
||||||
TASK_ID = RF001A_CLKIN_001
|
TASK_TITLE = Start coordinated RF-001A common-clock OTA capture
|
||||||
TASK_TITLE = Verify RF-001A slave CLKIN
|
SOURCE_BRANCH = video-development
|
||||||
SOURCE_BRANCH = video-development
|
EXPECTED_HEAD = <full 40-character base commit hash>
|
||||||
EXPECTED_HEAD = <full 40-character base commit hash>
|
REQUIRES_APPROVAL = YES
|
||||||
REQUIRES_APPROVAL = YES
|
HARDWARE_ACCESS = YES
|
||||||
HARDWARE_ACCESS = YES
|
RF_RX = YES
|
||||||
RF_RX = YES
|
RF_TX = NO
|
||||||
RF_TX = NO
|
ALLOW_COMMIT = NO
|
||||||
ALLOW_COMMIT = NO
|
ALLOW_PUSH = RX_RESULT_ONLY
|
||||||
ALLOW_PUSH = RX_RESULT_ONLY
|
RISK_LEVEL = YELLOW
|
||||||
RISK_LEVEL = YELLOW
|
|
||||||
```
|
|
||||||
|
|
||||||
`TASK_ID` is immutable and unique. A revised task receives a new ID. `EXPECTED_HEAD` names the known base commit against which the task was written; the watcher requires it to be an ancestor of the fetched task commit. This avoids an impossible self-reference to the commit that contains the header. `ALLOW_PUSH = RX_RESULT_ONLY` permits only a small result publication to `rx-node`; it never permits a push to `video-development`. `ALLOW_COMMIT` governs task changes in the source checkout; a separately authorized result-only commit on `rx-node` follows `ALLOW_PUSH`. `RISK_LEVEL` is an optional plain field used to declare RED actions other than RF TX.
|
TASK_ID is immutable and unique. A revised task receives a new ID. EXPECTED_HEAD identifies the known base commit against which the task was written; the watcher requires it to be an ancestor of the fetched task commit.
|
||||||
|
|
||||||
The watcher rejects missing, duplicate or invalid fields. It reads the file with `git show origin/video-development:docs/handoff/NEXT_RX_TASK.md`, so it does not need to change the local checkout.
|
ALLOW_PUSH = RX_RESULT_ONLY authorizes publication of the two allowlisted result files to an existing rx-node branch after the task passes its local approval gate. It never permits a push to video-development and never permits branch creation.
|
||||||
|
|
||||||
|
The watcher rejects missing, duplicate or invalid fields. It reads the task with git show origin/video-development:docs/handoff/NEXT_RX_TASK.md and does not need to change the local checkout.
|
||||||
|
|
||||||
## Safety levels and approval
|
## Safety levels and approval
|
||||||
|
|
||||||
| Level | Operations | Approval rule |
|
| Level | Operations | Approval rule |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GREEN | Software-only checks, parsing and local result preparation | The task's normal approval applies |
|
| GREEN | Software-only checks, parsing and local result preparation | The task's normal approval applies |
|
||||||
| YELLOW | Hardware inventory, clock, gain or bounded RF RX | The user must choose `[A] Approve` |
|
| YELLOW | Hardware inventory, clock checks, bounded RF RX and result-only publication | The user must choose [A] Approve for the exact TASK_ID |
|
||||||
| RED | RF TX, firmware, CPLD, Zadig, USB driver changes, destructive Git or filesystem operations | A normal approval is insufficient; an executor must require the exact separate action `AUTHORIZE RF TX` or an equivalent task-specific explicit confirmation |
|
| RED | RF TX, firmware, CPLD, Zadig, USB driver changes, destructive Git or filesystem operations | A normal approval is insufficient; RF TX requires the exact separate phrase AUTHORIZE RF TX |
|
||||||
|
|
||||||
The watcher derives RED whenever `RF_TX = YES` and rejects a lower declared level. Firmware, CPLD, Zadig, USB driver changes and destructive actions must declare `RISK_LEVEL = RED`. A non-RF RED approval requires `AUTHORIZE <TASK_ID>`; RF TX requires exactly `AUTHORIZE RF TX`. The watcher cannot authorize RED itself, and the executor must reject a body/header conflict.
|
The watcher derives RED whenever RF_TX = YES and rejects a lower declared level. A non-RF RED task requires AUTHORIZE <TASK_ID>. An RF TX task requires exactly AUTHORIZE RF TX.
|
||||||
|
|
||||||
At the prompt the minimum choices are:
|
At the RX prompt the choices are:
|
||||||
|
|
||||||
```text
|
[A] Approve
|
||||||
[A] Approve
|
[D] Defer
|
||||||
[D] Defer
|
[S] Stop watcher
|
||||||
[S] Stop watcher
|
|
||||||
```
|
|
||||||
|
|
||||||
The watcher records the task ID before leaving the prompt. Deferred and already seen IDs are not shown again on every polling cycle. A changed task must have a new `TASK_ID`.
|
The watcher records last_approved_task_id only after approval succeeds. Result publication requires the result TASK_ID to match that approved ID.
|
||||||
|
|
||||||
## Local state and executor boundary
|
## Local state and executor boundary
|
||||||
|
|
||||||
Runtime state is stored in `.local/rx_agent_state.json`; prepared prompts and result skeletons are stored in `.local/rx_agent_outbox/<TASK_ID>/`. `.local/` is ignored by Git. State includes `last_seen_task_id`, `last_completed_task_id`, `deferred_task_id`, `running` and the watcher PID. Writes use a temporary file followed by an atomic replace.
|
Runtime state is stored in .local/rx_agent_state.json. Prepared prompts and result skeletons are stored in .local/rx_agent_outbox/<TASK_ID>/. The .local directory is ignored by Git.
|
||||||
|
|
||||||
`TaskExecutor` is the backend interface. Version 1 provides `ManualExecutor`, which writes `TASK.md`, `RX_RESULT.md` and `RX_RESULT.json` after approval. It refuses to overwrite an existing task outbox. It does not invoke an agent, access SDR hardware, commit or push. A `CodexExecutor` may be added only after the Codex CLI is found and tested on the RX laptop. Availability on the main PC is not sufficient evidence.
|
State includes:
|
||||||
|
|
||||||
After a human or separately authorized executor finishes the task, mark the local ID complete:
|
- last_seen_task_id;
|
||||||
|
- last_completed_task_id;
|
||||||
|
- last_approved_task_id;
|
||||||
|
- deferred_task_id;
|
||||||
|
- running;
|
||||||
|
- watcher PID.
|
||||||
|
|
||||||
```powershell
|
ManualExecutor writes TASK.md, RX_RESULT.md and RX_RESULT.json after approval. It does not invoke an agent, access SDR hardware, commit or push.
|
||||||
python -B tools/rx_agent_watcher.py --mark-completed <TASK_ID>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Result and `rx-node` branch
|
After the task actually finishes, mark the local ID complete:
|
||||||
|
|
||||||
The result follows [RX_RESULT_TEMPLATE.md](RX_RESULT_TEMPLATE.md) and has matching Markdown and JSON. The proposed publication branch is `rx-node`.
|
python -B tools/rx_agent_watcher.py --mark-completed <TASK_ID>
|
||||||
|
|
||||||
The RX laptop must never commit or push to `video-development`. A separately approved publication step may place only these items on `rx-node`:
|
## Strict result schema
|
||||||
|
|
||||||
- handoff result Markdown;
|
The coordinated RF-001A result is a strict JSON object. Unknown or missing keys are rejected. READY requires:
|
||||||
- result JSON;
|
|
||||||
- small log metadata needed to interpret the result.
|
|
||||||
|
|
||||||
Raw IQ, video, waveform files, large logs, secrets and machine-local state remain outside Git. The watcher does not create `rx-node`, switch branches, commit or push. This keeps branch creation and publication subject to the repository's current instructions and a separate explicit authorization.
|
- task_id = RF001A_OTA_CAPTURE_002;
|
||||||
|
- state = RX_READY_FOR_TX;
|
||||||
|
- rx_ready_for_tx, process_alive, file_growing and common_clock_verified all true;
|
||||||
|
- a positive capture_pid;
|
||||||
|
- 120-second planned duration;
|
||||||
|
- rf_tx = 0;
|
||||||
|
- final capture fields set to null.
|
||||||
|
|
||||||
The main PC later uses `git fetch origin rx-node` and reads the result without merging it blindly.
|
COMPLETE requires:
|
||||||
|
|
||||||
|
- state = CAPTURE_COMPLETE;
|
||||||
|
- rx_ready_for_tx, process_alive and file_growing all false;
|
||||||
|
- exact exit code, size, duration and SHA256;
|
||||||
|
- error counters as non-negative integers or null when the tool did not report them;
|
||||||
|
- clkin_after = DETECTED or NOT_DETECTED;
|
||||||
|
- rf_tx_observed = EXPECTED_EXTERNAL_TX or UNKNOWN;
|
||||||
|
- rf_tx = 0.
|
||||||
|
|
||||||
|
Timestamps use timezone-aware ISO-8601. source_head is the actual local 40-character Git HEAD.
|
||||||
|
|
||||||
|
## Publishing to rx-node
|
||||||
|
|
||||||
|
Publish a prepared local JSON file with:
|
||||||
|
|
||||||
|
python -B tools/rx_agent_watcher.py --publish-result <result-json> --result-branch rx-node
|
||||||
|
|
||||||
|
The publisher checks:
|
||||||
|
|
||||||
|
1. the current task and result TASK_ID agree;
|
||||||
|
2. ALLOW_PUSH = RX_RESULT_ONLY;
|
||||||
|
3. RF_TX = NO;
|
||||||
|
4. last_approved_task_id matches;
|
||||||
|
5. result source_head equals the current checkout HEAD;
|
||||||
|
6. origin/rx-node already exists;
|
||||||
|
7. the JSON schema is valid.
|
||||||
|
|
||||||
|
It clones only the existing result branch into a temporary directory, writes exactly:
|
||||||
|
|
||||||
|
docs/handoff/results/RX_RESULT.json
|
||||||
|
docs/handoff/results/RX_RESULT.md
|
||||||
|
|
||||||
|
It checks the staged path set, commits, and pushes HEAD to origin/rx-node. It never switches the source checkout. It rejects any additional staged path and enforces a 64 KiB limit per result file.
|
||||||
|
|
||||||
|
Raw IQ, video, waveform files, large logs, secrets and machine-local state remain outside Git. A missing result branch or failed push is reported and never authorizes TX.
|
||||||
|
|
||||||
|
## MAIN PC watcher
|
||||||
|
|
||||||
|
tools/tx_agent_watcher.py polls origin/rx-node every 5 seconds by default. It does not call an LLM or open SDR hardware.
|
||||||
|
|
||||||
|
For a new valid READY result it checks task ID, state, live-process flags, common clock, publication age, planned duration and remaining capture time. It then requests the exact phrase AUTHORIZE RF TX.
|
||||||
|
|
||||||
|
After the phrase, it fetches rx-node again and repeats the checks. A changed result, stale publication or fewer than 10 seconds remaining blocks authorization.
|
||||||
|
|
||||||
|
A successful gate writes one local authorization record under .local/tx_authorizations/. The record has rf_tx_started = false and rf_tx = 0. The separately controlled TX executor must re-fetch and compare the result commit and digest immediately before opening hardware.
|
||||||
|
|
||||||
|
Duplicate result digests are not shown repeatedly.
|
||||||
|
|
||||||
## Token and model behavior
|
## Token and model behavior
|
||||||
|
|
||||||
- Git polling itself does not call an LLM.
|
- RX task polling does not call an LLM.
|
||||||
- When no new task exists, no model runs.
|
- MAIN result polling does not call an LLM.
|
||||||
- A detected but unapproved or deferred task does not run a model.
|
- No model runs while there is no new task or READY result.
|
||||||
- After approval, `ManualExecutor` only prepares an outbox. A separately selected executor may invoke one agent.
|
- ManualExecutor prepares local files only.
|
||||||
- The executor stops after task completion. The watcher returns to Git polling and consumes no model tokens.
|
- Neither watcher starts RF TX.
|
||||||
|
- The TX executor remains a separate RED-controlled action.
|
||||||
|
|
||||||
## Dry-run contract
|
## Dry-run contract
|
||||||
|
|
||||||
Dry-run requires a local `--task-file`; it does not fetch Git, access SDR, invoke an agent, perform RF RX/TX, commit or push. The tracked fake task is `tests/fixtures/watcher_test_task.md` with `TASK_ID = WATCHER_TEST_001` and `RF_TX = NO`.
|
The fake coordination tests use a temporary local bare Git repository and branch named rx-node. They publish a fake READY result, detect it, require the exact RED phrase and invoke a fake TX action that writes RF TX = 0. They do not fetch the real remote, access SDR hardware, run hackrf_transfer, create the production rx-node branch or transmit RF.
|
||||||
|
|||||||
@@ -1,76 +1,83 @@
|
|||||||
# RX watcher usage
|
# RX watcher usage
|
||||||
|
|
||||||
## START
|
## Start
|
||||||
|
|
||||||
On the RX laptop, pull `video-development`, install the project's normal Python dependencies, then double-click:
|
On the RX laptop, pull video-development and double-click:
|
||||||
|
|
||||||
```text
|
tools\start_rx_watcher.cmd
|
||||||
tools\start_rx_watcher.cmd
|
|
||||||
```
|
|
||||||
|
|
||||||
The watcher stays in a visible console and polls `origin/video-development` every 30 seconds. The interval is configurable with `--poll-interval`. It does not start ChatGPT, Codex or another agent.
|
The watcher stays in a visible console and polls origin/video-development every 30 seconds. It does not start ChatGPT, Codex or another agent.
|
||||||
|
|
||||||
## IDLE
|
## Idle
|
||||||
|
|
||||||
While no new `TASK_ID` exists, the watcher performs only `git fetch`, reads the remote task file and compares IDs with `.local/rx_agent_state.json`. There is no model execution or token use.
|
While no new TASK_ID exists, the watcher performs only git fetch, reads the remote task file and compares IDs with .local/rx_agent_state.json. There is no model execution or token use.
|
||||||
|
|
||||||
## NEW TASK
|
## New task
|
||||||
|
|
||||||
The console shows:
|
The console shows:
|
||||||
|
|
||||||
```text
|
NEW TASK DETECTED
|
||||||
NEW TASK DETECTED
|
|
||||||
|
|
||||||
TASK ID =
|
TASK ID =
|
||||||
TITLE =
|
TITLE =
|
||||||
SAFETY LEVEL =
|
SAFETY LEVEL =
|
||||||
HARDWARE ACCESS =
|
HARDWARE ACCESS =
|
||||||
RF RX =
|
RF RX =
|
||||||
RF TX =
|
RF TX =
|
||||||
```
|
|
||||||
|
|
||||||
Choose `[A] Approve`, `[D] Defer` or `[S] Stop watcher`. An RF TX task also requires the exact separate phrase `AUTHORIZE RF TX`; another RED task requires `AUTHORIZE <TASK_ID>`. This only opens the executor gate; the first version's `ManualExecutor` still performs no RF action and starts no model.
|
Choose [A] Approve, [D] Defer or [S] Stop watcher. RF TX also requires the exact phrase AUTHORIZE RF TX. Another RED action requires AUTHORIZE <TASK_ID>.
|
||||||
|
|
||||||
## APPROVE
|
## Approve and execute
|
||||||
|
|
||||||
After approval, open:
|
After approval, open:
|
||||||
|
|
||||||
```text
|
.local\rx_agent_outbox\<TASK_ID>\TASK.md
|
||||||
.local\rx_agent_outbox\<TASK_ID>\TASK.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Transfer that prepared task to the chosen agent manually. Fill the adjacent `RX_RESULT.md` and `RX_RESULT.json` with measured results. When work is actually complete, run:
|
Transfer that prepared task to the chosen executor. The watcher records last_approved_task_id, which is required for result publication. Preparing the outbox is not evidence that the hardware task ran.
|
||||||
|
|
||||||
```powershell
|
When the task is actually complete, run:
|
||||||
python -B tools/rx_agent_watcher.py --mark-completed <TASK_ID>
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not claim completion merely because the prompt was prepared.
|
python -B tools/rx_agent_watcher.py --mark-completed <TASK_ID>
|
||||||
|
|
||||||
## DONE and result publication
|
## Publish READY
|
||||||
|
|
||||||
If the task says `ALLOW_PUSH = RX_RESULT_ONLY` and the user separately authorizes publication, publish only small result Markdown/JSON and log metadata to `rx-node`. Never commit or push from the RX laptop to `video-development`. Do not put raw IQ, video or waveform files in Git.
|
For RF001A_OTA_CAPTURE_002, create the strict READY JSON outside Git only after the capture child is alive and the file is growing. Then run:
|
||||||
|
|
||||||
## STOP
|
python -B tools/rx_agent_watcher.py --publish-result <ready-json-path> --result-branch rx-node
|
||||||
|
|
||||||
|
On success, the watcher prints RX RESULT PUBLISH = PUBLISHED or UNCHANGED, the result commit, and RF TX = 0.
|
||||||
|
|
||||||
|
The command fails if:
|
||||||
|
|
||||||
|
- the task was not approved locally;
|
||||||
|
- the TASK_ID differs;
|
||||||
|
- source_head differs from the checkout HEAD;
|
||||||
|
- origin/rx-node does not exist;
|
||||||
|
- the schema is invalid;
|
||||||
|
- Git commit or push fails.
|
||||||
|
|
||||||
|
A failed publication is not READY evidence and cannot authorize TX.
|
||||||
|
|
||||||
|
## Publish COMPLETE
|
||||||
|
|
||||||
|
After capture completion, build the strict final JSON with state CAPTURE_COMPLETE and publish it with the same command:
|
||||||
|
|
||||||
|
python -B tools/rx_agent_watcher.py --publish-result <final-json-path> --result-branch rx-node
|
||||||
|
|
||||||
|
Only the two fixed result files are committed. Raw IQ, video, waveform, stdout, stderr and large logs stay outside Git.
|
||||||
|
|
||||||
|
## Stop
|
||||||
|
|
||||||
Double-click:
|
Double-click:
|
||||||
|
|
||||||
```text
|
tools\stop_rx_watcher.cmd
|
||||||
tools\stop_rx_watcher.cmd
|
|
||||||
```
|
|
||||||
|
|
||||||
The script creates a local stop request and waits for the watcher PID to clear. The approval prompt is interruptible on Windows, and the polling delay checks the stop request every 0.1 seconds. A successful stop prints `WATCHER STOPPED`; no polling process remains.
|
The script creates a local stop request and waits for the watcher PID to clear. A successful stop prints WATCHER STOPPED.
|
||||||
|
|
||||||
## DRY RUN
|
## Dry run
|
||||||
|
|
||||||
From the repository root:
|
From the repository root:
|
||||||
|
|
||||||
```powershell
|
python -B tools/rx_agent_watcher.py --dry-run --once --approval approve --task-file tests/fixtures/watcher_test_task.md
|
||||||
python -B tools/rx_agent_watcher.py --dry-run --once --approval approve --task-file tests/fixtures/watcher_test_task.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected behavior: the fake task is detected, approved and written under `.local/rx_agent_outbox/WATCHER_TEST_001/`. No Git fetch, SDR access, agent call, RF RX/TX, commit or push occurs. Delete `.local/` at any time if the local state and outbox are no longer needed.
|
The fake task is written under .local/rx_agent_outbox/WATCHER_TEST_001/. No Git fetch, SDR access, agent call, RF RX, RF TX, commit or push occurs.
|
||||||
|
|
||||||
## Executor availability
|
|
||||||
|
|
||||||
Version 1 always provides `ManualExecutor`. Before adding or selecting a Codex backend on RX, run `Get-Command codex` on that laptop and verify a harmless local invocation. Do not infer RX availability from the main PC.
|
|
||||||
|
|||||||
@@ -1,23 +1,55 @@
|
|||||||
TASK_ID =
|
# RX result schema
|
||||||
TASK_STATUS =
|
|
||||||
SOURCE_HEAD =
|
|
||||||
STARTED_AT =
|
|
||||||
FINISHED_AT =
|
|
||||||
|
|
||||||
SUMMARY =
|
The publisher creates both docs/handoff/results/RX_RESULT.json and docs/handoff/results/RX_RESULT.md on the existing rx-node branch. JSON is authoritative. Markdown is generated from the same validated object.
|
||||||
|
|
||||||
CHECKS =
|
## READY JSON
|
||||||
|
|
||||||
HARDWARE_ACTIONS =
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"task_id": "RF001A_OTA_CAPTURE_002",
|
||||||
|
"state": "RX_READY_FOR_TX",
|
||||||
|
"rx_ready_for_tx": true,
|
||||||
|
"process_alive": true,
|
||||||
|
"file_growing": true,
|
||||||
|
"capture_pid": 1234,
|
||||||
|
"published_at": "2026-09-21T12:00:02+00:00",
|
||||||
|
"capture_started_at": "2026-09-21T12:00:00+00:00",
|
||||||
|
"expected_capture_end": "2026-09-21T12:02:00+00:00",
|
||||||
|
"capture_path": "C:\\Users\\Professional\\Documents\\SDR_Rover_Captures\\<run>\\rf001a_ota_capture.cs8",
|
||||||
|
"common_clock_verified": true,
|
||||||
|
"source_head": "<40-character lowercase Git HEAD>",
|
||||||
|
"rf_tx": 0,
|
||||||
|
"exit_code": null,
|
||||||
|
"file_size": null,
|
||||||
|
"duration_seconds": null,
|
||||||
|
"overruns": null,
|
||||||
|
"usb_errors": null,
|
||||||
|
"file_errors": null,
|
||||||
|
"sha256": null,
|
||||||
|
"clkin_after": "NOT_RUN",
|
||||||
|
"rf_tx_observed": "UNKNOWN"
|
||||||
|
}
|
||||||
|
|
||||||
RF_TX =
|
## COMPLETE JSON
|
||||||
YES / NO
|
|
||||||
|
|
||||||
FILES_CREATED =
|
The final object keeps the same identity, timing, path and PID. It changes these fields:
|
||||||
|
|
||||||
ERRORS =
|
state = CAPTURE_COMPLETE
|
||||||
|
rx_ready_for_tx = false
|
||||||
|
process_alive = false
|
||||||
|
file_growing = false
|
||||||
|
published_at = <final publication UTC time>
|
||||||
|
exit_code = <integer>
|
||||||
|
file_size = <integer bytes>
|
||||||
|
duration_seconds = <number>
|
||||||
|
overruns = <non-negative integer or null>
|
||||||
|
usb_errors = <non-negative integer or null>
|
||||||
|
file_errors = <non-negative integer or null>
|
||||||
|
sha256 = <lowercase 64-character SHA256>
|
||||||
|
clkin_after = DETECTED or NOT_DETECTED
|
||||||
|
rf_tx_observed = EXPECTED_EXTERNAL_TX or UNKNOWN
|
||||||
|
rf_tx = 0
|
||||||
|
|
||||||
NEXT_ACTION =
|
Use null when the capture tool did not report an error counter. Do not infer zero.
|
||||||
|
|
||||||
The result publisher must also create `RX_RESULT.json` with the same fields.
|
Raw IQ, video, waveform files and large logs must stay outside Git.
|
||||||
Raw IQ, video and waveform files must stay outside Git.
|
|
||||||
|
|||||||
47
docs/handoff/TX_AGENT_USAGE.md
Normal file
47
docs/handoff/TX_AGENT_USAGE.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# MAIN TX watcher usage
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
tools/tx_agent_watcher.py is an ordinary local Python process. It polls the small RX result on origin/rx-node. It does not call an LLM and does not open HackRF hardware.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
From the repository root, double-click:
|
||||||
|
|
||||||
|
tools\start_tx_watcher.cmd
|
||||||
|
|
||||||
|
The visible console polls every 5 seconds. A missing rx-node branch or invalid result is reported. No model runs while polling.
|
||||||
|
|
||||||
|
## READY and RED approval
|
||||||
|
|
||||||
|
For a fresh valid RF001A_OTA_CAPTURE_002 READY result, the watcher prints the task ID, capture start and expected end. It then asks for:
|
||||||
|
|
||||||
|
AUTHORIZE RF TX
|
||||||
|
|
||||||
|
Any other input blocks authorization.
|
||||||
|
|
||||||
|
After the exact phrase, the watcher fetches rx-node again. It requires the same result digest, a fresh publication, an active 120-second capture window and at least 10 seconds remaining.
|
||||||
|
|
||||||
|
On success it writes:
|
||||||
|
|
||||||
|
.local\tx_authorizations\RF001A_OTA_TX_002_authorized.json
|
||||||
|
|
||||||
|
This record opens the gate for the separately controlled TX executor. The watcher itself does not run hackrf_transfer. The record explicitly says rf_tx_started = false and rf_tx = 0.
|
||||||
|
|
||||||
|
The TX executor must re-fetch rx-node and compare the result commit and digest immediately before hardware access.
|
||||||
|
|
||||||
|
## Stop
|
||||||
|
|
||||||
|
Double-click:
|
||||||
|
|
||||||
|
tools\stop_tx_watcher.cmd
|
||||||
|
|
||||||
|
The stop command writes a local stop request and waits for the watcher PID to clear.
|
||||||
|
|
||||||
|
## Fake mode
|
||||||
|
|
||||||
|
Automated tests use a local bare Git repository. For a single local JSON fixture, the CLI dry-run form is:
|
||||||
|
|
||||||
|
python -B tools/tx_agent_watcher.py --dry-run --once --result-file <ready-json> --approval authorize
|
||||||
|
|
||||||
|
The --approval option is rejected outside --dry-run. Fake mode writes a local fake authorization and never fetches the production remote or accesses SDR hardware.
|
||||||
@@ -24,12 +24,15 @@
|
|||||||
"tools/qpsk_rf_rx.py",
|
"tools/qpsk_rf_rx.py",
|
||||||
"tools/rf001a_prepare.py",
|
"tools/rf001a_prepare.py",
|
||||||
"tools/rx_agent_watcher.py",
|
"tools/rx_agent_watcher.py",
|
||||||
|
"tools/rf_handoff_channel.py",
|
||||||
|
"tools/tx_agent_watcher.py",
|
||||||
"tools/clock001.py",
|
"tools/clock001.py",
|
||||||
"tests/test_video005_iq.py",
|
"tests/test_video005_iq.py",
|
||||||
"tests/test_video005_video.py",
|
"tests/test_video005_video.py",
|
||||||
"tests/test_rf001_preparation.py",
|
"tests/test_rf001_preparation.py",
|
||||||
"tests/test_rf001a_prepare.py",
|
"tests/test_rf001a_prepare.py",
|
||||||
"tests/test_rx_agent_watcher.py",
|
"tests/test_rx_agent_watcher.py",
|
||||||
|
"tests/test_rf_handoff_coordination.py",
|
||||||
"tests/test_clock001.py"
|
"tests/test_clock001.py"
|
||||||
],
|
],
|
||||||
"typeCheckingMode": "basic",
|
"typeCheckingMode": "basic",
|
||||||
|
|||||||
460
tests/test_rf_handoff_coordination.py
Normal file
460
tests/test_rf_handoff_coordination.py
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
"""Tests for the RX-to-MAIN Git coordination channel without SDR or RF TX."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from argparse import Namespace
|
||||||
|
from dataclasses import asdict, replace
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tools.rf_handoff_channel import (
|
||||||
|
COMPLETE_STATE,
|
||||||
|
EXPECTED_RX_TASK_ID,
|
||||||
|
GitResultChannel,
|
||||||
|
READY_STATE,
|
||||||
|
RESULT_JSON_PATH,
|
||||||
|
RESULT_MARKDOWN_PATH,
|
||||||
|
ResultBranchMissing,
|
||||||
|
RxHandoffResult,
|
||||||
|
canonical_result_json,
|
||||||
|
evaluate_ready,
|
||||||
|
result_digest,
|
||||||
|
result_from_mapping,
|
||||||
|
)
|
||||||
|
from tools.rx_agent_watcher import (
|
||||||
|
WatcherState as RxWatcherState,
|
||||||
|
publish_result,
|
||||||
|
save_state as save_rx_state,
|
||||||
|
)
|
||||||
|
from tools.tx_agent_watcher import TxWatcherState, handle_result
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc)
|
||||||
|
HEAD = "a" * 40
|
||||||
|
|
||||||
|
|
||||||
|
def ready_result(**changes: object) -> RxHandoffResult:
|
||||||
|
result = RxHandoffResult(
|
||||||
|
schema_version=1,
|
||||||
|
task_id=EXPECTED_RX_TASK_ID,
|
||||||
|
state=READY_STATE,
|
||||||
|
rx_ready_for_tx=True,
|
||||||
|
process_alive=True,
|
||||||
|
file_growing=True,
|
||||||
|
capture_pid=1234,
|
||||||
|
published_at=NOW.isoformat(),
|
||||||
|
capture_started_at=NOW.isoformat(),
|
||||||
|
expected_capture_end=(NOW + timedelta(seconds=120)).isoformat(),
|
||||||
|
capture_path=r"C:\captures\rf001a_ota_capture.cs8",
|
||||||
|
common_clock_verified=True,
|
||||||
|
source_head=HEAD,
|
||||||
|
rf_tx=0,
|
||||||
|
exit_code=None,
|
||||||
|
file_size=None,
|
||||||
|
duration_seconds=None,
|
||||||
|
overruns=None,
|
||||||
|
usb_errors=None,
|
||||||
|
file_errors=None,
|
||||||
|
sha256=None,
|
||||||
|
clkin_after="NOT_RUN",
|
||||||
|
rf_tx_observed="UNKNOWN",
|
||||||
|
)
|
||||||
|
return replace(result, **changes)
|
||||||
|
|
||||||
|
|
||||||
|
def _git(arguments: list[str], cwd: Path) -> str:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["git", *arguments],
|
||||||
|
cwd=cwd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return completed.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def git_fixture(tmp_path: Path) -> tuple[Path, Path]:
|
||||||
|
remote = tmp_path / "remote.git"
|
||||||
|
work = tmp_path / "work"
|
||||||
|
_git(["init", "--bare", str(remote)], tmp_path)
|
||||||
|
work.mkdir()
|
||||||
|
_git(["init"], work)
|
||||||
|
_git(["config", "user.name", "Test"], work)
|
||||||
|
_git(["config", "user.email", "test@example.invalid"], work)
|
||||||
|
(work / "README.md").write_text(
|
||||||
|
"result branch fixture\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
_git(["add", "README.md"], work)
|
||||||
|
_git(["commit", "-m", "Seed result branch"], work)
|
||||||
|
_git(["remote", "add", "origin", str(remote)], work)
|
||||||
|
_git(["push", "origin", "HEAD:refs/heads/rx-node"], work)
|
||||||
|
return remote, work
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSource:
|
||||||
|
def __init__(self, result: RxHandoffResult, digest: str, head: str = HEAD) -> None:
|
||||||
|
self.result = result
|
||||||
|
self.digest = digest
|
||||||
|
self.head = head
|
||||||
|
self.fetch_count = 0
|
||||||
|
|
||||||
|
def fetch(self) -> None:
|
||||||
|
self.fetch_count += 1
|
||||||
|
|
||||||
|
def read(self) -> tuple[RxHandoffResult, str, str]:
|
||||||
|
return self.result, self.digest, self.head
|
||||||
|
|
||||||
|
|
||||||
|
class FakeExecutor:
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def prepare(
|
||||||
|
self,
|
||||||
|
result: RxHandoffResult,
|
||||||
|
digest: str,
|
||||||
|
result_head: str,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Path:
|
||||||
|
assert result.task_id == EXPECTED_RX_TASK_ID
|
||||||
|
assert digest == result_digest(result)
|
||||||
|
assert result_head
|
||||||
|
assert dry_run
|
||||||
|
self.calls += 1
|
||||||
|
self.path.write_text(
|
||||||
|
"FAKE TX ONLY\nRF TX = 0\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return self.path
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_parsing_rejects_extra_and_inconsistent_fields() -> None:
|
||||||
|
raw = asdict(ready_result())
|
||||||
|
assert result_from_mapping(raw) == ready_result()
|
||||||
|
|
||||||
|
extra = dict(raw)
|
||||||
|
extra["raw_iq"] = "forbidden"
|
||||||
|
with pytest.raises(ValueError, match="result fields disagree"):
|
||||||
|
result_from_mapping(extra)
|
||||||
|
|
||||||
|
wrong = dict(raw)
|
||||||
|
wrong["rf_tx"] = 1
|
||||||
|
with pytest.raises(ValueError, match="rf_tx"):
|
||||||
|
result_from_mapping(wrong)
|
||||||
|
|
||||||
|
wrong_type = dict(raw)
|
||||||
|
wrong_type["capture_pid"] = True
|
||||||
|
with pytest.raises(ValueError, match="integer result fields"):
|
||||||
|
result_from_mapping(wrong_type)
|
||||||
|
|
||||||
|
incomplete = dict(raw)
|
||||||
|
incomplete["file_growing"] = False
|
||||||
|
with pytest.raises(ValueError, match="live capture"):
|
||||||
|
result_from_mapping(incomplete)
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_result_requires_final_evidence() -> None:
|
||||||
|
final = ready_result(
|
||||||
|
state=COMPLETE_STATE,
|
||||||
|
rx_ready_for_tx=False,
|
||||||
|
process_alive=False,
|
||||||
|
file_growing=False,
|
||||||
|
published_at=(NOW + timedelta(seconds=121)).isoformat(),
|
||||||
|
exit_code=0,
|
||||||
|
file_size=1_920_000_000,
|
||||||
|
duration_seconds=120.01,
|
||||||
|
overruns=0,
|
||||||
|
usb_errors=0,
|
||||||
|
file_errors=0,
|
||||||
|
sha256="b" * 64,
|
||||||
|
clkin_after="DETECTED",
|
||||||
|
rf_tx_observed="EXPECTED_EXTERNAL_TX",
|
||||||
|
)
|
||||||
|
assert result_from_mapping(asdict(final)) == final
|
||||||
|
unreported_counters = replace(
|
||||||
|
final,
|
||||||
|
overruns=None,
|
||||||
|
usb_errors=None,
|
||||||
|
file_errors=None,
|
||||||
|
)
|
||||||
|
assert result_from_mapping(asdict(unreported_counters)) == unreported_counters
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("result", "now", "reason"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
ready_result(
|
||||||
|
published_at=(NOW - timedelta(seconds=31)).isoformat(),
|
||||||
|
capture_started_at=(NOW - timedelta(seconds=31)).isoformat(),
|
||||||
|
expected_capture_end=(NOW + timedelta(seconds=89)).isoformat(),
|
||||||
|
),
|
||||||
|
NOW,
|
||||||
|
"STALE_READY",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ready_result(expected_capture_end=(NOW + timedelta(seconds=9)).isoformat()),
|
||||||
|
NOW,
|
||||||
|
"WRONG_CAPTURE_WINDOW",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ready_result(
|
||||||
|
capture_started_at=(NOW - timedelta(seconds=111)).isoformat(),
|
||||||
|
expected_capture_end=(NOW + timedelta(seconds=9)).isoformat(),
|
||||||
|
),
|
||||||
|
NOW,
|
||||||
|
"CAPTURE_WINDOW_EXPIRED",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ready_result(task_id="WRONG_TASK_001"),
|
||||||
|
NOW,
|
||||||
|
"WRONG_TASK_ID",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ready_result(
|
||||||
|
state=COMPLETE_STATE,
|
||||||
|
rx_ready_for_tx=False,
|
||||||
|
process_alive=False,
|
||||||
|
file_growing=False,
|
||||||
|
exit_code=0,
|
||||||
|
file_size=1_920_000_000,
|
||||||
|
duration_seconds=120.0,
|
||||||
|
overruns=0,
|
||||||
|
usb_errors=0,
|
||||||
|
file_errors=0,
|
||||||
|
sha256="c" * 64,
|
||||||
|
clkin_after="DETECTED",
|
||||||
|
),
|
||||||
|
NOW,
|
||||||
|
"MISSING_READY",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_freshness_and_window_rejections(
|
||||||
|
result: RxHandoffResult,
|
||||||
|
now: datetime,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
assert evaluate_ready(result, now=now).reason == reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_ready_evaluation_passes_with_more_than_ten_seconds() -> None:
|
||||||
|
evaluation = evaluate_ready(ready_result(), now=NOW + timedelta(seconds=2))
|
||||||
|
assert evaluation.ready
|
||||||
|
assert evaluation.reason == "READY"
|
||||||
|
assert evaluation.seconds_remaining == pytest.approx(118.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_approval_and_duplicate_suppression(tmp_path: Path) -> None:
|
||||||
|
result = ready_result()
|
||||||
|
digest = result_digest(result)
|
||||||
|
source = FakeSource(result, digest)
|
||||||
|
executor = FakeExecutor(tmp_path / "fake_tx.txt")
|
||||||
|
output: list[str] = []
|
||||||
|
|
||||||
|
status, state = handle_result(
|
||||||
|
result,
|
||||||
|
digest,
|
||||||
|
HEAD,
|
||||||
|
TxWatcherState(),
|
||||||
|
source,
|
||||||
|
executor,
|
||||||
|
now_function=lambda: NOW + timedelta(seconds=2),
|
||||||
|
input_function=lambda _prompt: "DENY",
|
||||||
|
output_function=output.append,
|
||||||
|
dry_run=True,
|
||||||
|
)
|
||||||
|
assert status == "RED_APPROVAL_BLOCKED"
|
||||||
|
assert executor.calls == 0
|
||||||
|
assert any("RX IS READY" in line for line in output)
|
||||||
|
|
||||||
|
status, _ = handle_result(
|
||||||
|
result,
|
||||||
|
digest,
|
||||||
|
HEAD,
|
||||||
|
state,
|
||||||
|
source,
|
||||||
|
executor,
|
||||||
|
now_function=lambda: NOW + timedelta(seconds=3),
|
||||||
|
input_function=lambda _prompt: "AUTHORIZE RF TX",
|
||||||
|
output_function=output.append,
|
||||||
|
dry_run=True,
|
||||||
|
)
|
||||||
|
assert status == "DUPLICATE_SUPPRESSED"
|
||||||
|
assert executor.calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_window_is_rechecked_after_exact_approval(tmp_path: Path) -> None:
|
||||||
|
result = ready_result()
|
||||||
|
digest = result_digest(result)
|
||||||
|
source = FakeSource(result, digest)
|
||||||
|
executor = FakeExecutor(tmp_path / "fake_tx.txt")
|
||||||
|
times = iter((NOW + timedelta(seconds=2), NOW + timedelta(seconds=111)))
|
||||||
|
|
||||||
|
status, _ = handle_result(
|
||||||
|
result,
|
||||||
|
digest,
|
||||||
|
HEAD,
|
||||||
|
TxWatcherState(),
|
||||||
|
source,
|
||||||
|
executor,
|
||||||
|
now_function=lambda: next(times),
|
||||||
|
input_function=lambda _prompt: "AUTHORIZE RF TX",
|
||||||
|
output_function=lambda _text: None,
|
||||||
|
dry_run=True,
|
||||||
|
max_age_seconds=200.0,
|
||||||
|
)
|
||||||
|
assert status == "CAPTURE_WINDOW_EXPIRED"
|
||||||
|
assert source.fetch_count == 1
|
||||||
|
assert executor.calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fake_end_to_end_publish_detect_authorize_without_rf(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
_remote, work = git_fixture(tmp_path)
|
||||||
|
result = ready_result()
|
||||||
|
publisher = GitResultChannel(work)
|
||||||
|
outcome = publisher.publish(result)
|
||||||
|
assert outcome.status == "PUBLISHED"
|
||||||
|
publisher.fetch()
|
||||||
|
|
||||||
|
branch_files = set(
|
||||||
|
_git(["ls-tree", "-r", "--name-only", "origin/rx-node"], work).splitlines()
|
||||||
|
)
|
||||||
|
assert RESULT_JSON_PATH in branch_files
|
||||||
|
assert RESULT_MARKDOWN_PATH in branch_files
|
||||||
|
assert not any(path.endswith((".cs8", ".iq", ".mp4")) for path in branch_files)
|
||||||
|
|
||||||
|
source = GitResultChannel(work)
|
||||||
|
source.fetch()
|
||||||
|
observed, digest, head = source.read()
|
||||||
|
assert observed == result
|
||||||
|
assert head == outcome.commit
|
||||||
|
|
||||||
|
executor = FakeExecutor(tmp_path / "fake_tx.txt")
|
||||||
|
output: list[str] = []
|
||||||
|
status, state = handle_result(
|
||||||
|
observed,
|
||||||
|
digest,
|
||||||
|
head,
|
||||||
|
TxWatcherState(),
|
||||||
|
source,
|
||||||
|
executor,
|
||||||
|
now_function=lambda: NOW + timedelta(seconds=2),
|
||||||
|
input_function=lambda _prompt: "AUTHORIZE RF TX",
|
||||||
|
output_function=output.append,
|
||||||
|
dry_run=True,
|
||||||
|
)
|
||||||
|
assert status == "TX_AUTHORIZATION_PREPARED"
|
||||||
|
assert state.last_authorized_digest == digest
|
||||||
|
assert executor.calls == 1
|
||||||
|
assert (tmp_path / "fake_tx.txt").read_text(encoding="utf-8") == (
|
||||||
|
"FAKE TX ONLY\nRF TX = 0\n"
|
||||||
|
)
|
||||||
|
assert any(line == "RF TX = 0" for line in output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_result_branch_is_blocked(tmp_path: Path) -> None:
|
||||||
|
remote = tmp_path / "remote.git"
|
||||||
|
work = tmp_path / "work"
|
||||||
|
_git(["init", "--bare", str(remote)], tmp_path)
|
||||||
|
work.mkdir()
|
||||||
|
_git(["init"], work)
|
||||||
|
_git(["remote", "add", "origin", str(remote)], work)
|
||||||
|
channel = GitResultChannel(work)
|
||||||
|
with pytest.raises(ResultBranchMissing):
|
||||||
|
channel.publish(ready_result())
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_failure_is_reported(tmp_path: Path) -> None:
|
||||||
|
_remote, work = git_fixture(tmp_path)
|
||||||
|
|
||||||
|
class FailingPushChannel(GitResultChannel):
|
||||||
|
def _run(
|
||||||
|
self,
|
||||||
|
arguments: list[str],
|
||||||
|
*,
|
||||||
|
cwd: Path | None = None,
|
||||||
|
check: bool = True,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
if arguments and arguments[0] == "push":
|
||||||
|
raise subprocess.CalledProcessError(
|
||||||
|
1,
|
||||||
|
["git", *arguments],
|
||||||
|
stderr="simulated push failure",
|
||||||
|
)
|
||||||
|
return super()._run(arguments, cwd=cwd, check=check)
|
||||||
|
|
||||||
|
with pytest.raises(subprocess.CalledProcessError, match="git"):
|
||||||
|
FailingPushChannel(work).publish(ready_result())
|
||||||
|
|
||||||
|
def test_rx_watcher_publishes_ready_only_after_local_task_approval(
|
||||||
|
tmp_path: Path,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
_remote, work = git_fixture(tmp_path)
|
||||||
|
base_head = _git(["rev-parse", "HEAD"], work)
|
||||||
|
_git(["checkout", "-b", "video-development"], work)
|
||||||
|
handoff = work / "docs" / "handoff"
|
||||||
|
handoff.mkdir(parents=True)
|
||||||
|
task = f"""TASK_ID = {EXPECTED_RX_TASK_ID}
|
||||||
|
TASK_TITLE = Fake coordinated capture
|
||||||
|
SOURCE_BRANCH = video-development
|
||||||
|
EXPECTED_HEAD = {base_head}
|
||||||
|
REQUIRES_APPROVAL = YES
|
||||||
|
HARDWARE_ACCESS = YES
|
||||||
|
RF_RX = YES
|
||||||
|
RF_TX = NO
|
||||||
|
ALLOW_COMMIT = NO
|
||||||
|
ALLOW_PUSH = RX_RESULT_ONLY
|
||||||
|
RISK_LEVEL = YELLOW
|
||||||
|
|
||||||
|
Fake task used only by the local coordination test.
|
||||||
|
"""
|
||||||
|
(handoff / "NEXT_RX_TASK.md").write_text(task, encoding="utf-8")
|
||||||
|
_git(["add", "docs/handoff/NEXT_RX_TASK.md"], work)
|
||||||
|
_git(["commit", "-m", "Add fake RX task"], work)
|
||||||
|
_git(
|
||||||
|
["push", "--set-upstream", "origin", "HEAD:refs/heads/video-development"],
|
||||||
|
work,
|
||||||
|
)
|
||||||
|
source_head = _git(["rev-parse", "HEAD"], work)
|
||||||
|
|
||||||
|
result_path = tmp_path / "ready.json"
|
||||||
|
result_path.write_text(
|
||||||
|
canonical_result_json(ready_result(source_head=source_head)),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
state_path = tmp_path / "rx_state.json"
|
||||||
|
save_rx_state(
|
||||||
|
state_path,
|
||||||
|
RxWatcherState(
|
||||||
|
last_seen_task_id=EXPECTED_RX_TASK_ID,
|
||||||
|
last_approved_task_id=EXPECTED_RX_TASK_ID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
arguments = Namespace(
|
||||||
|
repository=work,
|
||||||
|
state_file=state_path,
|
||||||
|
publish_result=result_path,
|
||||||
|
remote="origin",
|
||||||
|
result_branch="rx-node",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert publish_result(arguments) == 0
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "RX RESULT PUBLISH = PUBLISHED" in output
|
||||||
|
assert "RF TX = 0" in output
|
||||||
|
|
||||||
|
channel = GitResultChannel(work)
|
||||||
|
published, _digest, _head = channel.read()
|
||||||
|
assert published == ready_result(source_head=source_head)
|
||||||
@@ -169,6 +169,7 @@ def test_dry_run_approval_writes_prompt_and_matching_results(tmp_path: Path) ->
|
|||||||
assert status == "PREPARED"
|
assert status == "PREPARED"
|
||||||
assert state.last_seen_task_id == "WATCHER_TEST_001"
|
assert state.last_seen_task_id == "WATCHER_TEST_001"
|
||||||
assert state.last_completed_task_id is None
|
assert state.last_completed_task_id is None
|
||||||
|
assert state.last_approved_task_id == "WATCHER_TEST_001"
|
||||||
assert (directory / "TASK.md").exists()
|
assert (directory / "TASK.md").exists()
|
||||||
markdown = (directory / "RX_RESULT.md").read_text(encoding="utf-8")
|
markdown = (directory / "RX_RESULT.md").read_text(encoding="utf-8")
|
||||||
result = json.loads((directory / "RX_RESULT.json").read_text(encoding="utf-8"))
|
result = json.loads((directory / "RX_RESULT.json").read_text(encoding="utf-8"))
|
||||||
|
|||||||
444
tools/rf_handoff_channel.py
Normal file
444
tools/rf_handoff_channel.py
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
"""Strict Git result channel for coordinated RX and MAIN PC handoff."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, fields
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_002"
|
||||||
|
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_002"
|
||||||
|
READY_STATE = "RX_READY_FOR_TX"
|
||||||
|
COMPLETE_STATE = "CAPTURE_COMPLETE"
|
||||||
|
RESULT_JSON_PATH = "docs/handoff/results/RX_RESULT.json"
|
||||||
|
RESULT_MARKDOWN_PATH = "docs/handoff/results/RX_RESULT.md"
|
||||||
|
RESULT_PATHS = frozenset((RESULT_JSON_PATH, RESULT_MARKDOWN_PATH))
|
||||||
|
MAX_RESULT_BYTES = 64 * 1024
|
||||||
|
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
_HEAD_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
|
||||||
|
|
||||||
|
class ResultChannelError(RuntimeError):
|
||||||
|
"""The result channel cannot be read or updated safely."""
|
||||||
|
|
||||||
|
|
||||||
|
class ResultBranchMissing(ResultChannelError):
|
||||||
|
"""The configured result branch does not exist on the remote."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RxHandoffResult:
|
||||||
|
schema_version: int
|
||||||
|
task_id: str
|
||||||
|
state: str
|
||||||
|
rx_ready_for_tx: bool
|
||||||
|
process_alive: bool
|
||||||
|
file_growing: bool
|
||||||
|
capture_pid: int | None
|
||||||
|
published_at: str
|
||||||
|
capture_started_at: str
|
||||||
|
expected_capture_end: str
|
||||||
|
capture_path: str
|
||||||
|
common_clock_verified: bool
|
||||||
|
source_head: str
|
||||||
|
rf_tx: int
|
||||||
|
exit_code: int | None
|
||||||
|
file_size: int | None
|
||||||
|
duration_seconds: float | None
|
||||||
|
overruns: int | None
|
||||||
|
usb_errors: int | None
|
||||||
|
file_errors: int | None
|
||||||
|
sha256: str | None
|
||||||
|
clkin_after: str
|
||||||
|
rf_tx_observed: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReadyEvaluation:
|
||||||
|
ready: bool
|
||||||
|
reason: str
|
||||||
|
seconds_remaining: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PublishOutcome:
|
||||||
|
status: str
|
||||||
|
commit: str
|
||||||
|
json_path: str = RESULT_JSON_PATH
|
||||||
|
markdown_path: str = RESULT_MARKDOWN_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_utc(value: str, field_name: str) -> datetime:
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise ValueError(f"{field_name} must be an ISO-8601 timestamp") from error
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError(f"{field_name} must contain a timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_types(result: RxHandoffResult) -> None:
|
||||||
|
string_fields = (
|
||||||
|
result.task_id,
|
||||||
|
result.state,
|
||||||
|
result.published_at,
|
||||||
|
result.capture_started_at,
|
||||||
|
result.expected_capture_end,
|
||||||
|
result.capture_path,
|
||||||
|
result.source_head,
|
||||||
|
result.clkin_after,
|
||||||
|
result.rf_tx_observed,
|
||||||
|
)
|
||||||
|
if any(type(value) is not str for value in string_fields):
|
||||||
|
raise ValueError("string result fields must contain strings")
|
||||||
|
boolean_fields = (
|
||||||
|
result.rx_ready_for_tx,
|
||||||
|
result.process_alive,
|
||||||
|
result.file_growing,
|
||||||
|
result.common_clock_verified,
|
||||||
|
)
|
||||||
|
if any(type(value) is not bool for value in boolean_fields):
|
||||||
|
raise ValueError("boolean result fields must contain booleans")
|
||||||
|
if type(result.schema_version) is not int or type(result.rf_tx) is not int:
|
||||||
|
raise ValueError("schema_version and rf_tx must contain integers")
|
||||||
|
optional_integer_fields = (
|
||||||
|
result.capture_pid,
|
||||||
|
result.exit_code,
|
||||||
|
result.file_size,
|
||||||
|
result.overruns,
|
||||||
|
result.usb_errors,
|
||||||
|
result.file_errors,
|
||||||
|
)
|
||||||
|
if any(
|
||||||
|
value is not None and type(value) is not int
|
||||||
|
for value in optional_integer_fields
|
||||||
|
):
|
||||||
|
raise ValueError("integer result fields must contain integers or null")
|
||||||
|
if (
|
||||||
|
result.duration_seconds is not None
|
||||||
|
and (
|
||||||
|
isinstance(result.duration_seconds, bool)
|
||||||
|
or not isinstance(result.duration_seconds, (int, float))
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError("duration_seconds must contain a number or null")
|
||||||
|
if result.sha256 is not None and type(result.sha256) is not str:
|
||||||
|
raise ValueError("sha256 must contain a string or null")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_common(result: RxHandoffResult) -> None:
|
||||||
|
_validate_types(result)
|
||||||
|
if result.schema_version != SCHEMA_VERSION:
|
||||||
|
raise ValueError(f"schema_version must be {SCHEMA_VERSION}")
|
||||||
|
if result.state not in {READY_STATE, COMPLETE_STATE}:
|
||||||
|
raise ValueError("state is not supported")
|
||||||
|
if result.rf_tx != 0:
|
||||||
|
raise ValueError("rf_tx must remain 0 on the RX node")
|
||||||
|
if not result.task_id or any(character in result.task_id for character in "\r\n"):
|
||||||
|
raise ValueError("task_id is invalid")
|
||||||
|
if not _HEAD_RE.fullmatch(result.source_head):
|
||||||
|
raise ValueError("source_head must be a lowercase 40-character Git hash")
|
||||||
|
if not result.capture_path or any(
|
||||||
|
character in result.capture_path for character in "\r\n"
|
||||||
|
):
|
||||||
|
raise ValueError("capture_path is invalid")
|
||||||
|
published = _parse_utc(result.published_at, "published_at")
|
||||||
|
started = _parse_utc(result.capture_started_at, "capture_started_at")
|
||||||
|
expected_end = _parse_utc(result.expected_capture_end, "expected_capture_end")
|
||||||
|
if published < started:
|
||||||
|
raise ValueError("published_at cannot precede capture_started_at")
|
||||||
|
if expected_end <= started:
|
||||||
|
raise ValueError("expected_capture_end must follow capture_started_at")
|
||||||
|
if result.clkin_after not in {"DETECTED", "NOT_DETECTED", "NOT_RUN"}:
|
||||||
|
raise ValueError("clkin_after is invalid")
|
||||||
|
if result.rf_tx_observed not in {
|
||||||
|
"EXPECTED_EXTERNAL_TX",
|
||||||
|
"UNKNOWN",
|
||||||
|
}:
|
||||||
|
raise ValueError("rf_tx_observed is invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_ready(result: RxHandoffResult) -> None:
|
||||||
|
if not (
|
||||||
|
result.rx_ready_for_tx
|
||||||
|
and result.process_alive
|
||||||
|
and result.file_growing
|
||||||
|
and result.common_clock_verified
|
||||||
|
):
|
||||||
|
raise ValueError("READY requires a live capture and verified common clock")
|
||||||
|
if result.capture_pid is None or result.capture_pid <= 0:
|
||||||
|
raise ValueError("READY requires a positive capture_pid")
|
||||||
|
final_fields = (
|
||||||
|
result.exit_code,
|
||||||
|
result.file_size,
|
||||||
|
result.duration_seconds,
|
||||||
|
result.overruns,
|
||||||
|
result.usb_errors,
|
||||||
|
result.file_errors,
|
||||||
|
result.sha256,
|
||||||
|
)
|
||||||
|
if any(value is not None for value in final_fields):
|
||||||
|
raise ValueError("READY must not contain final capture evidence")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_complete(result: RxHandoffResult) -> None:
|
||||||
|
if result.rx_ready_for_tx or result.process_alive or result.file_growing:
|
||||||
|
raise ValueError("COMPLETE cannot advertise a live capture")
|
||||||
|
if result.capture_pid is None or result.capture_pid <= 0:
|
||||||
|
raise ValueError("COMPLETE requires the capture_pid")
|
||||||
|
if result.exit_code is None or result.file_size is None:
|
||||||
|
raise ValueError("COMPLETE requires exit_code and file_size")
|
||||||
|
if result.file_size < 0:
|
||||||
|
raise ValueError("file_size cannot be negative")
|
||||||
|
counters = (result.overruns, result.usb_errors, result.file_errors)
|
||||||
|
if any(value is not None and value < 0 for value in counters):
|
||||||
|
raise ValueError("error counters cannot be negative")
|
||||||
|
if result.duration_seconds is None or result.duration_seconds < 0:
|
||||||
|
raise ValueError("COMPLETE requires a non-negative duration_seconds")
|
||||||
|
if result.sha256 is None or not _SHA256_RE.fullmatch(result.sha256):
|
||||||
|
raise ValueError("COMPLETE requires a lowercase SHA256")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_result(result: RxHandoffResult) -> RxHandoffResult:
|
||||||
|
_validate_common(result)
|
||||||
|
if result.state == READY_STATE:
|
||||||
|
_validate_ready(result)
|
||||||
|
else:
|
||||||
|
_validate_complete(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def result_from_mapping(raw: Mapping[str, Any]) -> RxHandoffResult:
|
||||||
|
expected_fields = {field.name for field in fields(RxHandoffResult)}
|
||||||
|
actual_fields = set(raw)
|
||||||
|
if actual_fields != expected_fields:
|
||||||
|
missing = sorted(expected_fields - actual_fields)
|
||||||
|
extra = sorted(actual_fields - expected_fields)
|
||||||
|
raise ValueError(f"result fields disagree: missing={missing}, extra={extra}")
|
||||||
|
result = RxHandoffResult(**{name: raw[name] for name in expected_fields})
|
||||||
|
return validate_result(result)
|
||||||
|
|
||||||
|
|
||||||
|
def load_result(path: Path) -> RxHandoffResult:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError("result JSON root must be an object")
|
||||||
|
return result_from_mapping(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_result_json(result: RxHandoffResult) -> str:
|
||||||
|
validate_result(result)
|
||||||
|
return json.dumps(
|
||||||
|
asdict(result),
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
ensure_ascii=False,
|
||||||
|
) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def result_digest(result: RxHandoffResult) -> str:
|
||||||
|
return hashlib.sha256(canonical_result_json(result).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def render_result_markdown(result: RxHandoffResult) -> str:
|
||||||
|
validate_result(result)
|
||||||
|
rows = [
|
||||||
|
"# RX result",
|
||||||
|
"",
|
||||||
|
f"SCHEMA_VERSION = {result.schema_version}",
|
||||||
|
f"TASK_ID = {result.task_id}",
|
||||||
|
f"STATE = {result.state}",
|
||||||
|
f"RX_READY_FOR_TX = {'YES' if result.rx_ready_for_tx else 'NO'}",
|
||||||
|
f"PROCESS_ALIVE = {'YES' if result.process_alive else 'NO'}",
|
||||||
|
f"FILE_GROWING = {'YES' if result.file_growing else 'NO'}",
|
||||||
|
f"CAPTURE_PID = {result.capture_pid}",
|
||||||
|
f"PUBLISHED_AT = {result.published_at}",
|
||||||
|
f"CAPTURE_STARTED_AT = {result.capture_started_at}",
|
||||||
|
f"EXPECTED_CAPTURE_END = {result.expected_capture_end}",
|
||||||
|
f"CAPTURE_PATH = {result.capture_path}",
|
||||||
|
f"COMMON_CLOCK_VERIFIED = {'YES' if result.common_clock_verified else 'NO'}",
|
||||||
|
f"SOURCE_HEAD = {result.source_head}",
|
||||||
|
f"EXIT_CODE = {result.exit_code}",
|
||||||
|
f"FILE_SIZE = {result.file_size}",
|
||||||
|
f"DURATION_SECONDS = {result.duration_seconds}",
|
||||||
|
f"OVERRUNS = {result.overruns}",
|
||||||
|
f"USB_ERRORS = {result.usb_errors}",
|
||||||
|
f"FILE_ERRORS = {result.file_errors}",
|
||||||
|
f"SHA256 = {result.sha256}",
|
||||||
|
f"CLKIN_AFTER = {result.clkin_after}",
|
||||||
|
f"RF_TX_OBSERVED = {result.rf_tx_observed}",
|
||||||
|
f"RF_TX = {result.rf_tx}",
|
||||||
|
"",
|
||||||
|
"Raw IQ, video, waveforms, and large logs are not stored in this branch.",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
return "\n".join(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_ready(
|
||||||
|
result: RxHandoffResult,
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
expected_task_id: str = EXPECTED_RX_TASK_ID,
|
||||||
|
max_age_seconds: float = 30.0,
|
||||||
|
min_remaining_seconds: float = 10.0,
|
||||||
|
) -> ReadyEvaluation:
|
||||||
|
validate_result(result)
|
||||||
|
now_utc = now.astimezone(timezone.utc)
|
||||||
|
if result.task_id != expected_task_id:
|
||||||
|
return ReadyEvaluation(False, "WRONG_TASK_ID")
|
||||||
|
if result.state != READY_STATE or not result.rx_ready_for_tx:
|
||||||
|
return ReadyEvaluation(False, "MISSING_READY")
|
||||||
|
started = _parse_utc(result.capture_started_at, "capture_started_at")
|
||||||
|
expected_end = _parse_utc(result.expected_capture_end, "expected_capture_end")
|
||||||
|
published = _parse_utc(result.published_at, "published_at")
|
||||||
|
planned_duration = (expected_end - started).total_seconds()
|
||||||
|
if not 119.0 <= planned_duration <= 121.0:
|
||||||
|
return ReadyEvaluation(False, "WRONG_CAPTURE_WINDOW")
|
||||||
|
age = (now_utc - published).total_seconds()
|
||||||
|
if age < -5.0:
|
||||||
|
return ReadyEvaluation(False, "FUTURE_READY")
|
||||||
|
if age > max_age_seconds:
|
||||||
|
return ReadyEvaluation(False, "STALE_READY")
|
||||||
|
remaining = (expected_end - now_utc).total_seconds()
|
||||||
|
if remaining < min_remaining_seconds:
|
||||||
|
return ReadyEvaluation(False, "CAPTURE_WINDOW_EXPIRED", remaining)
|
||||||
|
return ReadyEvaluation(True, "READY", remaining)
|
||||||
|
|
||||||
|
|
||||||
|
class GitResultChannel:
|
||||||
|
"""Read and publish the two allowlisted result files on an existing branch."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repository: Path,
|
||||||
|
*,
|
||||||
|
remote: str = "origin",
|
||||||
|
branch: str = "rx-node",
|
||||||
|
) -> None:
|
||||||
|
self.repository = repository
|
||||||
|
self.remote = remote
|
||||||
|
self.branch = branch
|
||||||
|
|
||||||
|
def _run(
|
||||||
|
self,
|
||||||
|
arguments: list[str],
|
||||||
|
*,
|
||||||
|
cwd: Path | None = None,
|
||||||
|
check: bool = True,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *arguments],
|
||||||
|
cwd=cwd or self.repository,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
check=check,
|
||||||
|
)
|
||||||
|
|
||||||
|
def branch_exists(self) -> bool:
|
||||||
|
result = self._run(
|
||||||
|
["ls-remote", "--exit-code", "--heads", self.remote, self.branch],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return result.returncode == 0 and bool(result.stdout.strip())
|
||||||
|
|
||||||
|
def _require_branch(self) -> None:
|
||||||
|
if not self.branch_exists():
|
||||||
|
raise ResultBranchMissing(
|
||||||
|
f"remote branch {self.remote}/{self.branch} does not exist; "
|
||||||
|
"separate authorization is required before creating it"
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch(self) -> None:
|
||||||
|
self._require_branch()
|
||||||
|
self._run(
|
||||||
|
[
|
||||||
|
"fetch",
|
||||||
|
"--quiet",
|
||||||
|
self.remote,
|
||||||
|
f"refs/heads/{self.branch}:refs/remotes/{self.remote}/{self.branch}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def read(self) -> tuple[RxHandoffResult, str, str]:
|
||||||
|
self.fetch()
|
||||||
|
reference = f"refs/remotes/{self.remote}/{self.branch}"
|
||||||
|
json_result = self._run(["show", f"{reference}:{RESULT_JSON_PATH}"])
|
||||||
|
raw = json.loads(json_result.stdout)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ResultChannelError("remote result JSON root is not an object")
|
||||||
|
result = result_from_mapping(raw)
|
||||||
|
head = self._run(["rev-parse", reference]).stdout.strip()
|
||||||
|
return result, result_digest(result), head
|
||||||
|
|
||||||
|
def publish(self, result: RxHandoffResult) -> PublishOutcome:
|
||||||
|
validate_result(result)
|
||||||
|
self._require_branch()
|
||||||
|
remote_url = self._run(["remote", "get-url", self.remote]).stdout.strip()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="rx-result-publish-") as directory:
|
||||||
|
checkout = Path(directory) / "result-branch"
|
||||||
|
self._run(
|
||||||
|
[
|
||||||
|
"clone",
|
||||||
|
"--quiet",
|
||||||
|
"--single-branch",
|
||||||
|
"--branch",
|
||||||
|
self.branch,
|
||||||
|
remote_url,
|
||||||
|
str(checkout),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
json_path = checkout / RESULT_JSON_PATH
|
||||||
|
markdown_path = checkout / RESULT_MARKDOWN_PATH
|
||||||
|
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
json_text = canonical_result_json(result)
|
||||||
|
markdown_text = render_result_markdown(result)
|
||||||
|
if len(json_text.encode("utf-8")) > MAX_RESULT_BYTES:
|
||||||
|
raise ResultChannelError("result JSON exceeds the size limit")
|
||||||
|
if len(markdown_text.encode("utf-8")) > MAX_RESULT_BYTES:
|
||||||
|
raise ResultChannelError("result Markdown exceeds the size limit")
|
||||||
|
json_path.write_text(json_text, encoding="utf-8", newline="\n")
|
||||||
|
markdown_path.write_text(markdown_text, encoding="utf-8", newline="\n")
|
||||||
|
self._run(["add", "--", *sorted(RESULT_PATHS)], cwd=checkout)
|
||||||
|
staged_text = self._run(
|
||||||
|
["diff", "--cached", "--name-only", "--"],
|
||||||
|
cwd=checkout,
|
||||||
|
).stdout
|
||||||
|
staged = {
|
||||||
|
line.strip().replace("\\", "/")
|
||||||
|
for line in staged_text.splitlines()
|
||||||
|
if line.strip()
|
||||||
|
}
|
||||||
|
if not staged:
|
||||||
|
commit = self._run(["rev-parse", "HEAD"], cwd=checkout).stdout.strip()
|
||||||
|
return PublishOutcome("UNCHANGED", commit)
|
||||||
|
if staged != RESULT_PATHS:
|
||||||
|
raise ResultChannelError(
|
||||||
|
f"refusing unexpected staged paths: {sorted(staged)}"
|
||||||
|
)
|
||||||
|
self._run(["config", "user.name", "RX Node"], cwd=checkout)
|
||||||
|
self._run(
|
||||||
|
["config", "user.email", "rx-node@local.invalid"],
|
||||||
|
cwd=checkout,
|
||||||
|
)
|
||||||
|
self._run(
|
||||||
|
["commit", "--quiet", "-m", f"Publish {result.task_id} {result.state}"],
|
||||||
|
cwd=checkout,
|
||||||
|
)
|
||||||
|
commit = self._run(["rev-parse", "HEAD"], cwd=checkout).stdout.strip()
|
||||||
|
self._run(
|
||||||
|
["push", "--quiet", "origin", f"HEAD:refs/heads/{self.branch}"],
|
||||||
|
cwd=checkout,
|
||||||
|
)
|
||||||
|
return PublishOutcome("PUBLISHED", commit)
|
||||||
@@ -17,6 +17,12 @@ from typing import Callable, Protocol
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from tools.rf_handoff_channel import GitResultChannel, load_result # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_TASK_PATH = "docs/handoff/NEXT_RX_TASK.md"
|
DEFAULT_TASK_PATH = "docs/handoff/NEXT_RX_TASK.md"
|
||||||
DEFAULT_STATE_PATH = ROOT / ".local/rx_agent_state.json"
|
DEFAULT_STATE_PATH = ROOT / ".local/rx_agent_state.json"
|
||||||
DEFAULT_OUTBOX = ROOT / ".local/rx_agent_outbox"
|
DEFAULT_OUTBOX = ROOT / ".local/rx_agent_outbox"
|
||||||
@@ -71,6 +77,7 @@ class WatcherState:
|
|||||||
|
|
||||||
last_seen_task_id: str | None = None
|
last_seen_task_id: str | None = None
|
||||||
last_completed_task_id: str | None = None
|
last_completed_task_id: str | None = None
|
||||||
|
last_approved_task_id: str | None = None
|
||||||
deferred_task_id: str | None = None
|
deferred_task_id: str | None = None
|
||||||
running: bool = False
|
running: bool = False
|
||||||
pid: int | None = None
|
pid: int | None = None
|
||||||
@@ -404,6 +411,7 @@ def handle_task(
|
|||||||
if decision != ApprovalDecision.APPROVE:
|
if decision != ApprovalDecision.APPROVE:
|
||||||
return "APPROVAL_BLOCKED", state
|
return "APPROVAL_BLOCKED", state
|
||||||
state.deferred_task_id = None
|
state.deferred_task_id = None
|
||||||
|
state.last_approved_task_id = task.task_id
|
||||||
paths = executor.prepare(task, task_text, source_head, dry_run=dry_run)
|
paths = executor.prepare(task, task_text, source_head, dry_run=dry_run)
|
||||||
output_function(f"TASK PREPARED = {paths[0]}")
|
output_function(f"TASK PREPARED = {paths[0]}")
|
||||||
return "PREPARED", state
|
return "PREPARED", state
|
||||||
@@ -476,8 +484,58 @@ def mark_completed(state_path: Path, task_id: str) -> None:
|
|||||||
save_state(state_path, state)
|
save_state(state_path, state)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_result(arguments: argparse.Namespace) -> int:
|
||||||
|
"""Publish only the approved small RX result to an existing branch."""
|
||||||
|
|
||||||
|
repository = arguments.repository.resolve()
|
||||||
|
task = parse_task((repository / DEFAULT_TASK_PATH).read_text(encoding="utf-8-sig"))
|
||||||
|
state = load_state(arguments.state_file.resolve())
|
||||||
|
result = load_result(arguments.publish_result.resolve())
|
||||||
|
if task.task_id != result.task_id:
|
||||||
|
raise ValueError("result TASK_ID disagrees with current RX task")
|
||||||
|
if task.allow_push != "RX_RESULT_ONLY" or task.rf_tx:
|
||||||
|
raise ValueError("current task does not allow RX result publication")
|
||||||
|
if state.last_approved_task_id != result.task_id:
|
||||||
|
raise ValueError("current RX task has not passed the local approval gate")
|
||||||
|
|
||||||
|
def git_output(arguments: list[str]) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *arguments],
|
||||||
|
cwd=repository,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
branch = git_output(["branch", "--show-current"])
|
||||||
|
if branch != task.source_branch:
|
||||||
|
raise ValueError("current branch disagrees with task SOURCE_BRANCH")
|
||||||
|
if git_output(["status", "--porcelain", "--untracked-files=no"]):
|
||||||
|
raise ValueError("tracked worktree must be clean before result publication")
|
||||||
|
head = git_output(["rev-parse", "HEAD"])
|
||||||
|
remote_head = git_output(["rev-parse", f"{arguments.remote}/{task.source_branch}"])
|
||||||
|
if head != remote_head:
|
||||||
|
raise ValueError("current HEAD disagrees with the remote task branch")
|
||||||
|
if result.source_head != head:
|
||||||
|
raise ValueError("result SOURCE_HEAD disagrees with current checkout")
|
||||||
|
channel = GitResultChannel(
|
||||||
|
repository,
|
||||||
|
remote=arguments.remote,
|
||||||
|
branch=arguments.result_branch,
|
||||||
|
)
|
||||||
|
outcome = channel.publish(result)
|
||||||
|
print(f"RX RESULT PUBLISH = {outcome.status}")
|
||||||
|
print(f"RX RESULT HEAD = {outcome.commit}")
|
||||||
|
print("RF TX = 0")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def run_watcher(arguments: argparse.Namespace) -> int:
|
def run_watcher(arguments: argparse.Namespace) -> int:
|
||||||
state_path = arguments.state_file.resolve()
|
state_path = arguments.state_file.resolve()
|
||||||
|
if arguments.publish_result is not None:
|
||||||
|
return publish_result(arguments)
|
||||||
if arguments.request_stop:
|
if arguments.request_stop:
|
||||||
stopped = request_stop(state_path, arguments.stop_timeout)
|
stopped = request_stop(state_path, arguments.stop_timeout)
|
||||||
print("WATCHER STOPPED" if stopped else "WATCHER STOP TIMEOUT")
|
print("WATCHER STOPPED" if stopped else "WATCHER STOP TIMEOUT")
|
||||||
@@ -576,6 +634,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
parser.add_argument("--request-stop", action="store_true")
|
parser.add_argument("--request-stop", action="store_true")
|
||||||
parser.add_argument("--stop-timeout", type=float, default=35.0)
|
parser.add_argument("--stop-timeout", type=float, default=35.0)
|
||||||
parser.add_argument("--mark-completed")
|
parser.add_argument("--mark-completed")
|
||||||
|
parser.add_argument("--publish-result", type=Path)
|
||||||
|
parser.add_argument("--result-branch", default="rx-node")
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
9
tools/start_tx_watcher.cmd
Normal file
9
tools/start_tx_watcher.cmd
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0.."
|
||||||
|
python -B tools\tx_agent_watcher.py --poll-interval 5
|
||||||
|
set "TX_WATCHER_EXIT=%ERRORLEVEL%"
|
||||||
|
echo.
|
||||||
|
echo TX watcher exited with code %TX_WATCHER_EXIT%.
|
||||||
|
pause
|
||||||
|
exit /b %TX_WATCHER_EXIT%
|
||||||
8
tools/stop_tx_watcher.cmd
Normal file
8
tools/stop_tx_watcher.cmd
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0.."
|
||||||
|
python -B tools\tx_agent_watcher.py --request-stop --stop-timeout 15
|
||||||
|
set "TX_WATCHER_EXIT=%ERRORLEVEL%"
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b %TX_WATCHER_EXIT%
|
||||||
398
tools/tx_agent_watcher.py
Normal file
398
tools/tx_agent_watcher.py
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
"""Poll the RX result branch and gate a single RF-001A TX authorization."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Callable, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from tools.rf_handoff_channel import ( # noqa: E402
|
||||||
|
EXPECTED_TX_TASK_ID,
|
||||||
|
GitResultChannel,
|
||||||
|
ResultBranchMissing,
|
||||||
|
ResultChannelError,
|
||||||
|
RxHandoffResult,
|
||||||
|
evaluate_ready,
|
||||||
|
load_result,
|
||||||
|
result_digest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_STATE_PATH = ROOT / ".local" / "tx_agent_watcher_state.json"
|
||||||
|
DEFAULT_OUTBOX = ROOT / ".local" / "tx_authorizations"
|
||||||
|
|
||||||
|
|
||||||
|
class ResultSource(Protocol):
|
||||||
|
def fetch(self) -> None: ...
|
||||||
|
|
||||||
|
def read(self) -> tuple[RxHandoffResult, str, str]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class TxExecutor(Protocol):
|
||||||
|
def prepare(
|
||||||
|
self,
|
||||||
|
result: RxHandoffResult,
|
||||||
|
digest: str,
|
||||||
|
result_head: str,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Path: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TxWatcherState:
|
||||||
|
running: bool = False
|
||||||
|
pid: int | None = None
|
||||||
|
last_seen_digest: str | None = None
|
||||||
|
last_prompted_digest: str | None = None
|
||||||
|
last_authorized_digest: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StaticResultSource:
|
||||||
|
"""Read one local fixture without network or Git writes."""
|
||||||
|
|
||||||
|
def __init__(self, result: RxHandoffResult) -> None:
|
||||||
|
self.result = result
|
||||||
|
self.digest = result_digest(result)
|
||||||
|
|
||||||
|
def fetch(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def read(self) -> tuple[RxHandoffResult, str, str]:
|
||||||
|
return self.result, self.digest, self.result.source_head
|
||||||
|
|
||||||
|
|
||||||
|
class ManualTxExecutor:
|
||||||
|
"""Write a local authorization record; never access SDR hardware."""
|
||||||
|
|
||||||
|
def __init__(self, outbox: Path) -> None:
|
||||||
|
self.outbox = outbox
|
||||||
|
|
||||||
|
def prepare(
|
||||||
|
self,
|
||||||
|
result: RxHandoffResult,
|
||||||
|
digest: str,
|
||||||
|
result_head: str,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Path:
|
||||||
|
self.outbox.mkdir(parents=True, exist_ok=True)
|
||||||
|
suffix = "fake" if dry_run else "authorized"
|
||||||
|
target = self.outbox / f"{EXPECTED_TX_TASK_ID}_{suffix}.json"
|
||||||
|
temporary = target.with_suffix(target.suffix + ".tmp")
|
||||||
|
payload = {
|
||||||
|
"task_id": EXPECTED_TX_TASK_ID,
|
||||||
|
"authorization": "AUTHORIZE RF TX",
|
||||||
|
"rx_task_id": result.task_id,
|
||||||
|
"rx_result_digest": digest,
|
||||||
|
"rx_result_head": result_head,
|
||||||
|
"capture_started_at": result.capture_started_at,
|
||||||
|
"expected_capture_end": result.expected_capture_end,
|
||||||
|
"prepared_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"action": "FAKE_TX_ONLY" if dry_run else "MANUAL_EXECUTOR_GATE_OPEN",
|
||||||
|
"rf_tx_started": False,
|
||||||
|
"rf_tx": 0,
|
||||||
|
}
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(path: Path) -> TxWatcherState:
|
||||||
|
if not path.exists():
|
||||||
|
return TxWatcherState()
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError("TX watcher state must be a JSON object")
|
||||||
|
return TxWatcherState(
|
||||||
|
running=bool(raw.get("running", False)),
|
||||||
|
pid=raw.get("pid"),
|
||||||
|
last_seen_digest=raw.get("last_seen_digest"),
|
||||||
|
last_prompted_digest=raw.get("last_prompted_digest"),
|
||||||
|
last_authorized_digest=raw.get("last_authorized_digest"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(path: Path, state: TxWatcherState) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(asdict(state), indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_path_for(state_path: Path) -> Path:
|
||||||
|
return state_path.with_suffix(state_path.suffix + ".stop")
|
||||||
|
|
||||||
|
|
||||||
|
def process_exists(pid: int | None) -> bool:
|
||||||
|
if pid is None or pid <= 0:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def request_stop(state_path: Path, timeout_s: float) -> bool:
|
||||||
|
stop_path = stop_path_for(state_path)
|
||||||
|
stop_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
stop_path.write_text("STOP\n", encoding="ascii")
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
state = load_state(state_path)
|
||||||
|
if not state.running or not process_exists(state.pid):
|
||||||
|
return True
|
||||||
|
time.sleep(0.2)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def interruptible_input(prompt: str, stop_path: Path) -> str:
|
||||||
|
if os.name != "nt":
|
||||||
|
return input(prompt)
|
||||||
|
|
||||||
|
import msvcrt
|
||||||
|
|
||||||
|
print(prompt, end="", flush=True)
|
||||||
|
characters: list[str] = []
|
||||||
|
while True:
|
||||||
|
if stop_path.exists():
|
||||||
|
print()
|
||||||
|
return "S"
|
||||||
|
if not msvcrt.kbhit():
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
character = msvcrt.getwch()
|
||||||
|
if character in {"\r", "\n"}:
|
||||||
|
print()
|
||||||
|
return "".join(characters)
|
||||||
|
if character == "\b":
|
||||||
|
if characters:
|
||||||
|
characters.pop()
|
||||||
|
print("\b \b", end="", flush=True)
|
||||||
|
continue
|
||||||
|
characters.append(character)
|
||||||
|
print(character, end="", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def ready_message(result: RxHandoffResult) -> str:
|
||||||
|
return (
|
||||||
|
"RX IS READY\n\n"
|
||||||
|
f"TASK ID =\n{result.task_id}\n\n"
|
||||||
|
f"CAPTURE STARTED =\n{result.capture_started_at}\n\n"
|
||||||
|
f"CAPTURE END EXPECTED =\n{result.expected_capture_end}\n\n"
|
||||||
|
f"TX TASK =\n{EXPECTED_TX_TASK_ID}\n\n"
|
||||||
|
"RF TX REQUIRES RED APPROVAL\n\n"
|
||||||
|
"Enter:\nAUTHORIZE RF TX"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_result(
|
||||||
|
result: RxHandoffResult,
|
||||||
|
digest: str,
|
||||||
|
result_head: str,
|
||||||
|
state: TxWatcherState,
|
||||||
|
source: ResultSource,
|
||||||
|
executor: TxExecutor,
|
||||||
|
*,
|
||||||
|
now_function: Callable[[], datetime],
|
||||||
|
input_function: Callable[[str], str],
|
||||||
|
output_function: Callable[[str], None] = print,
|
||||||
|
dry_run: bool = False,
|
||||||
|
max_age_seconds: float = 30.0,
|
||||||
|
min_remaining_seconds: float = 10.0,
|
||||||
|
) -> tuple[str, TxWatcherState]:
|
||||||
|
"""Validate READY, request exact approval, then revalidate remote state."""
|
||||||
|
|
||||||
|
if digest == state.last_seen_digest:
|
||||||
|
return "DUPLICATE_SUPPRESSED", state
|
||||||
|
|
||||||
|
evaluation = evaluate_ready(
|
||||||
|
result,
|
||||||
|
now=now_function(),
|
||||||
|
max_age_seconds=max_age_seconds,
|
||||||
|
min_remaining_seconds=min_remaining_seconds,
|
||||||
|
)
|
||||||
|
state.last_seen_digest = digest
|
||||||
|
if not evaluation.ready:
|
||||||
|
return evaluation.reason, state
|
||||||
|
|
||||||
|
output_function(ready_message(result))
|
||||||
|
state.last_prompted_digest = digest
|
||||||
|
phrase = input_function("Type AUTHORIZE RF TX to prepare one burst: ").strip()
|
||||||
|
if phrase != "AUTHORIZE RF TX":
|
||||||
|
output_function("RED APPROVAL BLOCKED")
|
||||||
|
output_function("RF TX = 0")
|
||||||
|
return "RED_APPROVAL_BLOCKED", state
|
||||||
|
|
||||||
|
source.fetch()
|
||||||
|
refreshed, refreshed_digest, refreshed_head = source.read()
|
||||||
|
if refreshed_digest != digest:
|
||||||
|
output_function("RX RESULT CHANGED; NEW APPROVAL REQUIRED")
|
||||||
|
output_function("RF TX = 0")
|
||||||
|
return "RESULT_CHANGED", state
|
||||||
|
|
||||||
|
refreshed_evaluation = evaluate_ready(
|
||||||
|
refreshed,
|
||||||
|
now=now_function(),
|
||||||
|
max_age_seconds=max_age_seconds,
|
||||||
|
min_remaining_seconds=min_remaining_seconds,
|
||||||
|
)
|
||||||
|
if not refreshed_evaluation.ready:
|
||||||
|
output_function(f"TX WINDOW BLOCKED = {refreshed_evaluation.reason}")
|
||||||
|
output_function("RF TX = 0")
|
||||||
|
return refreshed_evaluation.reason, state
|
||||||
|
|
||||||
|
authorization_path = executor.prepare(
|
||||||
|
refreshed,
|
||||||
|
refreshed_digest,
|
||||||
|
refreshed_head,
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
state.last_authorized_digest = refreshed_digest
|
||||||
|
output_function(f"TX AUTHORIZATION PREPARED = {authorization_path}")
|
||||||
|
output_function("RF TX = 0")
|
||||||
|
return "TX_AUTHORIZATION_PREPARED", state
|
||||||
|
|
||||||
|
|
||||||
|
def run_watcher(arguments: argparse.Namespace) -> int:
|
||||||
|
state_path = arguments.state_file.resolve()
|
||||||
|
if arguments.request_stop:
|
||||||
|
stopped = request_stop(state_path, arguments.stop_timeout)
|
||||||
|
print("TX WATCHER STOPPED" if stopped else "TX WATCHER STOP TIMEOUT")
|
||||||
|
return 0 if stopped else 1
|
||||||
|
if arguments.result_file is not None and not arguments.dry_run:
|
||||||
|
raise ValueError("--result-file is allowed only with --dry-run")
|
||||||
|
if arguments.dry_run and arguments.result_file is None:
|
||||||
|
raise ValueError("--dry-run requires --result-file and never fetches Git")
|
||||||
|
if arguments.approval is not None and not arguments.dry_run:
|
||||||
|
raise ValueError("--approval is allowed only with --dry-run")
|
||||||
|
|
||||||
|
state = load_state(state_path)
|
||||||
|
if state.running and process_exists(state.pid):
|
||||||
|
raise RuntimeError(f"TX watcher already running with PID {state.pid}")
|
||||||
|
state.running = True
|
||||||
|
state.pid = os.getpid()
|
||||||
|
save_state(state_path, state)
|
||||||
|
stop_path = stop_path_for(state_path)
|
||||||
|
stop_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
if arguments.result_file is not None:
|
||||||
|
source: ResultSource = StaticResultSource(load_result(arguments.result_file))
|
||||||
|
else:
|
||||||
|
source = GitResultChannel(
|
||||||
|
arguments.repository.resolve(),
|
||||||
|
remote=arguments.remote,
|
||||||
|
branch=arguments.result_branch,
|
||||||
|
)
|
||||||
|
executor = ManualTxExecutor(arguments.outbox.resolve())
|
||||||
|
|
||||||
|
def input_function(prompt: str) -> str:
|
||||||
|
if arguments.approval == "authorize":
|
||||||
|
return "AUTHORIZE RF TX"
|
||||||
|
if arguments.approval == "deny":
|
||||||
|
return "DENY"
|
||||||
|
return interruptible_input(prompt, stop_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not stop_path.exists():
|
||||||
|
try:
|
||||||
|
source.fetch()
|
||||||
|
result, digest, result_head = source.read()
|
||||||
|
status, state = handle_result(
|
||||||
|
result,
|
||||||
|
digest,
|
||||||
|
result_head,
|
||||||
|
state,
|
||||||
|
source,
|
||||||
|
executor,
|
||||||
|
now_function=lambda: datetime.now(timezone.utc),
|
||||||
|
input_function=input_function,
|
||||||
|
dry_run=arguments.dry_run,
|
||||||
|
max_age_seconds=arguments.max_age,
|
||||||
|
min_remaining_seconds=arguments.min_remaining,
|
||||||
|
)
|
||||||
|
save_state(state_path, state)
|
||||||
|
if status != "DUPLICATE_SUPPRESSED":
|
||||||
|
print(f"TX WATCHER STATUS = {status}")
|
||||||
|
if arguments.once or arguments.dry_run:
|
||||||
|
break
|
||||||
|
except (
|
||||||
|
OSError,
|
||||||
|
ValueError,
|
||||||
|
ResultBranchMissing,
|
||||||
|
ResultChannelError,
|
||||||
|
RuntimeError,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
subprocess.SubprocessError,
|
||||||
|
) as error:
|
||||||
|
print(f"TX WATCHER ERROR = {error}", file=sys.stderr)
|
||||||
|
if arguments.once or arguments.dry_run:
|
||||||
|
return 1
|
||||||
|
if arguments.once:
|
||||||
|
break
|
||||||
|
for _ in range(max(1, int(arguments.poll_interval * 10))):
|
||||||
|
if stop_path.exists():
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
finally:
|
||||||
|
state.running = False
|
||||||
|
state.pid = None
|
||||||
|
save_state(state_path, state)
|
||||||
|
stop_path.unlink(missing_ok=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--repository", type=Path, default=ROOT)
|
||||||
|
parser.add_argument("--remote", default="origin")
|
||||||
|
parser.add_argument("--result-branch", default="rx-node")
|
||||||
|
parser.add_argument("--poll-interval", type=float, default=5.0)
|
||||||
|
parser.add_argument("--max-age", type=float, default=30.0)
|
||||||
|
parser.add_argument("--min-remaining", type=float, default=10.0)
|
||||||
|
parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE_PATH)
|
||||||
|
parser.add_argument("--outbox", type=Path, default=DEFAULT_OUTBOX)
|
||||||
|
parser.add_argument("--once", action="store_true")
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
parser.add_argument("--result-file", type=Path)
|
||||||
|
parser.add_argument("--approval", choices=("authorize", "deny"))
|
||||||
|
parser.add_argument("--request-stop", action="store_true")
|
||||||
|
parser.add_argument("--stop-timeout", type=float, default=15.0)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
arguments = build_parser().parse_args()
|
||||||
|
positive = (
|
||||||
|
arguments.poll_interval,
|
||||||
|
arguments.max_age,
|
||||||
|
arguments.min_remaining,
|
||||||
|
arguments.stop_timeout,
|
||||||
|
)
|
||||||
|
if any(value <= 0 for value in positive):
|
||||||
|
raise SystemExit("intervals must be positive")
|
||||||
|
raise SystemExit(run_watcher(arguments))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user