Fix bidirectional RF-001A coordination

This commit is contained in:
LittleSam129
2026-09-21 18:05:28 +03:00
parent bc55d0516d
commit a516b99015
17 changed files with 1745 additions and 244 deletions

View File

@@ -0,0 +1,737 @@
{
"schema_version": 1,
"test_id": "RF001_QPSK_500K_R23_256B_100P_GAP5MS",
"timestamp_utc": "2026-09-18T12:23:31.127702+00:00",
"modulation": "QPSK",
"symbol_rate": 500000,
"sample_rate": 8000000,
"fec_rate": "2/3",
"digital_carrier_offset_hz": 125000.0,
"application_payload_bytes": 256,
"packet_count": 100,
"inter_packet_gap_ms": 5,
"inter_packet_gap_samples": 40000,
"inter_array_zero_samples": 39872,
"seed": 20260917,
"iq_format": "c64le",
"dtype": "<c8",
"waveform_sample_count": 8833828,
"waveform_duration_s": 1.1042285,
"packet_waveform_start_samples": [
0,
88737,
177474,
266211,
354948,
443685,
532422,
621159,
709896,
798633,
887370,
976107,
1064844,
1153581,
1242318,
1331055,
1419792,
1508529,
1597266,
1686003,
1774740,
1863477,
1952214,
2040951,
2129688,
2218425,
2307162,
2395899,
2484636,
2573373,
2662110,
2750847,
2839584,
2928321,
3017058,
3105795,
3194532,
3283269,
3372006,
3460743,
3549480,
3638217,
3726954,
3815691,
3904428,
3993165,
4081902,
4170639,
4259376,
4348113,
4436850,
4525587,
4614324,
4703061,
4791798,
4880535,
4969272,
5058009,
5146746,
5235483,
5324220,
5412957,
5501694,
5590431,
5679168,
5767905,
5856642,
5945379,
6034116,
6122853,
6211590,
6300327,
6389064,
6477801,
6566538,
6655275,
6744012,
6832749,
6921486,
7010223,
7098960,
7187697,
7276434,
7365171,
7453908,
7542645,
7631382,
7720119,
7808856,
7897593,
7986330,
8075067,
8163804,
8252541,
8341278,
8430015,
8518752,
8607489,
8696226,
8784963
],
"expected_packet_hashes": [
{
"sequence_number": 0,
"payload_sha256": "4e95e78189b1156739263a383aa26e568b369a24737b78f169a02d7182b66f0b",
"packet_sha256": "e50f93a6389e4911db17eb822d583f06fae05df6708c64019de08c6ec6834960"
},
{
"sequence_number": 1,
"payload_sha256": "8d2104b86fe512059111192d7ff0bc089f1931a57756c8b930fa03c238352e75",
"packet_sha256": "35c7fbd31f2452da87e40be898553a29d05ae1303ab7e9928d514ca7f94f19e9"
},
{
"sequence_number": 2,
"payload_sha256": "a1c20c33df93076fcb95cbfc1e5c6eeeb7c0b35733a4e28a8c7330c994850aca",
"packet_sha256": "d672a74d80a3e16265bb4fe531579c907dcff464ae036e4cf129c3e22de8ec80"
},
{
"sequence_number": 3,
"payload_sha256": "d3ffdd0789e7bb3f7dba60b96a61175414912253001475d16ec6f837eb8b17a5",
"packet_sha256": "7083d4c56b3cda44f9d34be4588809519dd65a4072dbfcc6d563470a2a527547"
},
{
"sequence_number": 4,
"payload_sha256": "03f62ee09971f903b3610e9b73a8484021e76d09a5758a4aafc0e951ca1a4399",
"packet_sha256": "8e74c8ac395bfca64ad1a2b72f755ff909626735d375bdc75d12750dae79334f"
},
{
"sequence_number": 5,
"payload_sha256": "a7915faacc162ae02f5b962eafb6ee221d4320e60a9a29f9aaafaa23358e76b7",
"packet_sha256": "46d3e3d8b40beadb6a881c821c460e44a4cdc6e43ae2c94649677f7b26087617"
},
{
"sequence_number": 6,
"payload_sha256": "9484962525e16284e0a5efb2d1e3672c5ee688f7edce17c25d0eb4d2884eee5d",
"packet_sha256": "3c72b11fc893d6b28ec966f0677897ffdb33da26c820fe29a5528be0535c8638"
},
{
"sequence_number": 7,
"payload_sha256": "b9476ffd787f7d88612de8763244274f5b89b0b3744dbac7d5286992de7be12c",
"packet_sha256": "ed6a563a919e2ada004520042696bb2b18af80eb81afcb9c35ba0b4ce96b712e"
},
{
"sequence_number": 8,
"payload_sha256": "1e8ce60401efc25916c78b94fd5ee1d5ad9f61fd4ca0f79377815bf7e2e8cdc0",
"packet_sha256": "cc377708eae5a9da1fd7238f124b1a8658769ec8b19f405b9f3d902eece165f2"
},
{
"sequence_number": 9,
"payload_sha256": "1dd003103ed9065ad42ad177c6cdcb6abe95e1b6d85703a4b82a550d9fe1a5f8",
"packet_sha256": "a11f702f42659771e907d969c53f6ae953ee8e7576ee0e5d29a1e9a95caeeccc"
},
{
"sequence_number": 10,
"payload_sha256": "09106da81d922fef36a31ba4632ec1bce31061f3b6da3322e6be0e1c4806f0c4",
"packet_sha256": "cebbd0b3f5ba39659a8f784542f97b68d47ce271a7349e616ee453933bdf5fd0"
},
{
"sequence_number": 11,
"payload_sha256": "ff2de9370b5817127586092275a38c8878693962ba91b7ff5836334e835020b8",
"packet_sha256": "93d38f1a1f5d1b103e970d606424847838b2e8c3bcedbf538e963e369f6d05f5"
},
{
"sequence_number": 12,
"payload_sha256": "194d4eea8b148f8097e18882d97d1089b7e46896fe5bb98befa37ebb3c50aa29",
"packet_sha256": "c1043bf2927f0c94afe3e5166e264eb38660dea6f3e62f715bd9eee7dc0f716c"
},
{
"sequence_number": 13,
"payload_sha256": "074c679bb903935c6bf5cc52c3ced97408376b1c7a0cc15eb72b514063bf4810",
"packet_sha256": "55feddabdea9a8e154e787b587a1f1ce09b0a85d4b0cddd79bb09e721b7d66e1"
},
{
"sequence_number": 14,
"payload_sha256": "257814be75560af99a9c4a8f63c01ab7c6ed12a13487cfd1d0f8c1c872ff9bb6",
"packet_sha256": "6963cf0fa929c33bbf44fd44044d7cd70ca20ec3ceb5ac17f35b06baf6b290b3"
},
{
"sequence_number": 15,
"payload_sha256": "9d2c9736b139bea03b80a21f2b6d7c8055e167ecdbea0e69f812b6cb3dd37fe4",
"packet_sha256": "84537c730059fb93ce34ded7ca5b71f74db0055931132568233325fa6710cce8"
},
{
"sequence_number": 16,
"payload_sha256": "a6ee2a88458190abfbe8ebdfcc93d9346442ed8041c47d032d75eeae81d1a5e0",
"packet_sha256": "2c6781c0fa023af965f994cf246d6138dd878235c96e0f1ed641bb9b631102bf"
},
{
"sequence_number": 17,
"payload_sha256": "421f076061c433b5515bc5eb42e06ed42355a46e5099c6caf9f364433f41b140",
"packet_sha256": "fac6f86486df9d66931360844789e8d0f350b9ec0140e925b52eaec011252e27"
},
{
"sequence_number": 18,
"payload_sha256": "e93c614ff590771cba9d220c81f44c8ebdba3f7ed6406768648872b889050fb6",
"packet_sha256": "8c20ec5bd30a1a8091e6b0b173d01ee0018b52017d32c62742434fa032c5fb98"
},
{
"sequence_number": 19,
"payload_sha256": "d14f3bfce872f3ae12d5b10c0861ff438c1dedefd652cb7762c94cdef48b052f",
"packet_sha256": "f34f48c6a7327425f7fc42d244e65b57fc13dd5caf0079b37a94e76719a5e16f"
},
{
"sequence_number": 20,
"payload_sha256": "54fa05ab31402bd66ccb9fd7bf130c167d2d11546bcb34530923fa14103f07f1",
"packet_sha256": "4c44637f18540a4bc7eb765e0b331abcfd683ab21136d24522f91298e5329a38"
},
{
"sequence_number": 21,
"payload_sha256": "dcf67daa5a735ca45f4609f9981a00bab143f2fd5f7d1d5ea56240e1650ffade",
"packet_sha256": "da583f81b26a0035fb829ec1707a33dddcca2662ddae9ca22d22bacaa4580cb6"
},
{
"sequence_number": 22,
"payload_sha256": "f0578ad0a65f0f9df231fd508d9f6f27fe0043e2720d1ba20a9927c730f0601f",
"packet_sha256": "677b77e784fa91edc4c2896f9ca574bd9f3e71fc18e355082674200a7166d9c5"
},
{
"sequence_number": 23,
"payload_sha256": "12246760d0aa098ffe08470487f15d832ef6731dbce3ed9c4b1ba11db94f15c2",
"packet_sha256": "c91339dfc311df65afbbb88b0f5f7ca06c1cbffb0e2b330bb3a4b4202586b308"
},
{
"sequence_number": 24,
"payload_sha256": "2d7d070265ae85f0014490a9b53046c143df8947f99161b6770619c34f6a69dc",
"packet_sha256": "8bc0d37edd1b0e0fceefe84150fa240133221e75b4c7dd632a8211aaab27a2f2"
},
{
"sequence_number": 25,
"payload_sha256": "74a152e7fa829dfc448669927bf714c5b610947f7225bbc8bf5d491ed2a300ad",
"packet_sha256": "9d2b1fe0c9d1722093944105bfe64d99109a595a6e4ce5504030f23f10f10d09"
},
{
"sequence_number": 26,
"payload_sha256": "7fb9a08bbd3fb7c8004bcb54ac76e016c37f5686f23c4f14bd3b6f6db7bfaa85",
"packet_sha256": "6461326859169e27329f26c064a87445d33ceb7846368ae3d7b683d9aaec912b"
},
{
"sequence_number": 27,
"payload_sha256": "18d0572fc2bb366b04198f0c0f451ea79c06f93e8b729a6194db3ce950e35d0f",
"packet_sha256": "627b69cd08eb2c539cee8b470ba7c007d78cfa9eb5464b91274ef6749a3bf24e"
},
{
"sequence_number": 28,
"payload_sha256": "6ae0006e493efe63ba7f482832b32e59c1556eae2e845402e36685ea9d64f2eb",
"packet_sha256": "64511c3982b1c77ec76d75e9036fbfb91fc378ea3275e420c66de5acba93a0c0"
},
{
"sequence_number": 29,
"payload_sha256": "c8810ded095de421272cb2ac06d03a4dae07603c92aa54ed6f95c9e92b94aa4d",
"packet_sha256": "5c363e0729272ef745715362825755ac4d5bf64e9ce6245a116226ab7a3860fb"
},
{
"sequence_number": 30,
"payload_sha256": "b738ff110f5bc52700094b698f2f814b59f62edd1553073634818cf41634a7b9",
"packet_sha256": "69f0ce4e421548ffe79b5262eec9cf8c1f0e01e338e4a5c47c344c095203cc7e"
},
{
"sequence_number": 31,
"payload_sha256": "f70565bb9395029c857ea85ebb9fbeca8c55f37cff2de52a43aa5dcaee49fce4",
"packet_sha256": "826f2108ecd64fe1c4f7b750fcd6f0c0d586d9092e7a608b050ec9d298039291"
},
{
"sequence_number": 32,
"payload_sha256": "a6110fb9975df074ec37b653a288fbcc3356eb60460e9b7c77fab99cf519d54e",
"packet_sha256": "35a4f770aeb60a0b269fd70b48c0856230a2661fc6f85ace9e3fe7ffeaaedd1b"
},
{
"sequence_number": 33,
"payload_sha256": "e6ee539b6824c69e0cfac0f166d8ca08bdfe1b72a4dc47bbdaf3c1bfdfd20af4",
"packet_sha256": "7530f6f6480163162bb5c799f114b068ee3fa6e840d837b5d45f307dc78842b2"
},
{
"sequence_number": 34,
"payload_sha256": "3320f4036487dba8c882d2b2a3b0fc03375f803e73ee8ca4a170699bb2b49c73",
"packet_sha256": "6a990db6b516d4c264b965d54851dbde6782a56b5b810996ffd04a6f8b58a200"
},
{
"sequence_number": 35,
"payload_sha256": "049538d4d23c057af2aa77989ff2260b72fd67920e864f6e7c7761500a777ae5",
"packet_sha256": "4f247fe687e7a91ca3a7362e47098ffee42e351e0e346bed46c9c684ceb8935d"
},
{
"sequence_number": 36,
"payload_sha256": "72102a33749d838277ea1e8243ebdf4ae30b6dbda739c334fdf11cf20d408724",
"packet_sha256": "60d3b3d234ac875609f83497e1137c05579663a2d9d1ea0911309f5cfdfca12a"
},
{
"sequence_number": 37,
"payload_sha256": "fb8457ec7bcda3c70b61181c95c2d8a844e4b341f8d6d4f9533f594344952bd7",
"packet_sha256": "2e5e79705c683ed6cec2b0705124a3a51d7c918cd5477334173378eed5ad72c9"
},
{
"sequence_number": 38,
"payload_sha256": "e21bd69eaa967ab61212dc57e211d76733099b2534d76591710eef5c4bbb68c0",
"packet_sha256": "deb2bdd76ae1bdaab5cf45693cc913bf860720b8a6369ea6dab984398635c142"
},
{
"sequence_number": 39,
"payload_sha256": "e440830eb1dcb5e952ffd38a6c4a8aa7a455c73f483fd96487ca3b1b5814f74a",
"packet_sha256": "9377bc52bb374a1f148e30b8a583b2504f5cd9754d79a488219ae6594add0d49"
},
{
"sequence_number": 40,
"payload_sha256": "ab22ef291546afad3c096faf4c11a917ba530b71063a74899e748e291ddc1888",
"packet_sha256": "50a5d4f1af820acc6611ad05cca7e2a58743ffb75e2e21c5e537bcb4f01d1ca1"
},
{
"sequence_number": 41,
"payload_sha256": "3a77bddd7867d943b8c70673f63e0b7dc45af645d82f27779ca3b716d4688772",
"packet_sha256": "21192e0633a549a6863c1412050379a37f784c2850fe3b0fd9a6189e6fa04716"
},
{
"sequence_number": 42,
"payload_sha256": "349255af17303f3ad7de96079f17001db1120bdc1d015414ab1189450ca23867",
"packet_sha256": "799d9e8af80186dd693bea85869ae8e05bbdbacb188fdbdcd355ee568563152e"
},
{
"sequence_number": 43,
"payload_sha256": "70eb1217481f5a17dad4ba973f99ae149bf7d591b2739a2fe7a44b33946fc2de",
"packet_sha256": "ece74d595de7a24e04d7c17afbdeef079337f316d6da6f9f0a7936a3a0ed2a51"
},
{
"sequence_number": 44,
"payload_sha256": "64127d2496375c8c508b39c0a80bff78eb26ed00c57d462de27b75dfb852f23f",
"packet_sha256": "8ffcae35363d9f1ca2f6bfc02c0833cff9fad1e75312d587db44db6d290a316e"
},
{
"sequence_number": 45,
"payload_sha256": "94e697b1bb63efb563fe8f23a7ac717bcbe55ec12b612b5946fa95a34804d2ad",
"packet_sha256": "9a893918e5441504c77c49107975c34671e6ab572dedfa64b52d554b3b220057"
},
{
"sequence_number": 46,
"payload_sha256": "f0b9ffe40da709a93f71477c7e7ef7e5c0c7960a81249e73032e45b389b23a37",
"packet_sha256": "ba04e7b50f0a2c3dce78694aa977d6e4f6a17842761c94b90395aa8dc136b57b"
},
{
"sequence_number": 47,
"payload_sha256": "2177b36f9aac5bfbef359e662f9c947c98f800526ffe9f99665912c303f22abb",
"packet_sha256": "3da0bafcd46fc6a917505c5615c830dd27e4d3c58e97603d089c79282fc9f9c0"
},
{
"sequence_number": 48,
"payload_sha256": "3aa5a3818488ea9cbf9fdcf655aaaa22dcf01d7b00ffcc5e127a9a2fd9332cd8",
"packet_sha256": "2d2424d3024c65353da8065ba8a32e8f8a6a18633267a8b01e32cbe734e2d9dc"
},
{
"sequence_number": 49,
"payload_sha256": "365bef881aa8d57ddce1849485d33938f6ea85b9f75383c99eaf4fd95b4233b9",
"packet_sha256": "471f68e3c998b8a65917b5e5ea978aa9e1fb1a8ba9586c3e10ba69ef67f5f373"
},
{
"sequence_number": 50,
"payload_sha256": "47043687b94a7c4abbe6fd405bcf92cc843cd0185bd1246b50e3678ded1331c3",
"packet_sha256": "58610a2248c28fb815df0acba42d2de1d474f5bba54fb634572771e22bb841a5"
},
{
"sequence_number": 51,
"payload_sha256": "1cc173ed5a928de1a49017d88f233ed64c4212edeffb618dd99b03c7a06f9e69",
"packet_sha256": "e91154fb19ca789296e09a1884243dda26a20ff6e964a99350b63a56f9900d6d"
},
{
"sequence_number": 52,
"payload_sha256": "d073a047015bc48b7249996ac967cbe573e70d0a383685ecb5fd65ae0fa7f5d0",
"packet_sha256": "4fabd2ece3ba7b35ce77ab185e5689d9a69ec25e97d25431ba06c4fe41ce3ca1"
},
{
"sequence_number": 53,
"payload_sha256": "9bd5a56c09781c5358e2e0aad364a470667f519550f18eb38c7de982c1080280",
"packet_sha256": "5a9fff30607546c10719fc8b2373751936d85c9cbb825351d40ff192c35e67c3"
},
{
"sequence_number": 54,
"payload_sha256": "80673128b291980917d288d4909f2024f331bada7884dedcb38a3b25e115a4e2",
"packet_sha256": "291d6753b048005cc93ff7916df1c702ceffe6ad34dd20824f9fc93c802839cc"
},
{
"sequence_number": 55,
"payload_sha256": "86a03ba0f472f0627123e22e11014f42b2f318cee7055b439e5ebca516061370",
"packet_sha256": "038b17c9da0844d003d4780cd61217e904a51e113bf0705862c30285bf4c216d"
},
{
"sequence_number": 56,
"payload_sha256": "1497fa9c6aae68d2a41256f9257c13264d50bd32dcc79b76c3be0fbff53d14f8",
"packet_sha256": "203e9373cc6fa3d52ffcc4a9e3ce9675bf86f2e154bcba9217a302355819c6b1"
},
{
"sequence_number": 57,
"payload_sha256": "acd390d05a2a51bdb14c74c3a26588e544af4167d3907b49a86897e8c5d82f22",
"packet_sha256": "1af6cc447cc333df9af40721d7c126efb1fcb065bef62ba42f00ba2e812b320d"
},
{
"sequence_number": 58,
"payload_sha256": "5366d7d4670582403cbd0e2f6fa12974384278b03ad2899b2c5e4b92ab32a564",
"packet_sha256": "8c500c11f4f412bd2eaa86862398c4ccf9fc499c32a8717e5931df5b8c9e7836"
},
{
"sequence_number": 59,
"payload_sha256": "99a4565ae1db6cb094cbddc0adaa442bd9b0fa04e02115ac5f82b1ba0cb021f3",
"packet_sha256": "1ae3de9c1f2a4ea0a97dce9df11929f82caf63e17e404498cb08b678acfaafaa"
},
{
"sequence_number": 60,
"payload_sha256": "1785424f2d9427a9c13bbdcaca2975f4ea2893daf6f3642deac0b7929acd7d4d",
"packet_sha256": "662b48a8e61ada3671dfff3af459c5ee979849856be2602b4b5f85828b87e3f8"
},
{
"sequence_number": 61,
"payload_sha256": "1fb826298448fd4183522f7ccd3f45e9889ab78314ef78749cf9d679501d2efb",
"packet_sha256": "73e2f50f4cf77ef91e0b374b68aa07bb7a7cc9601cfa5d0e19475bedda4a9847"
},
{
"sequence_number": 62,
"payload_sha256": "86b46d9e32a85309e34183b5fadb8e64030dc804c6ba54d685e32b2e367ec362",
"packet_sha256": "32de517b56537ead8cb46eabdfcfa41bb3924528c5bc31dd1f04b9c8865246ee"
},
{
"sequence_number": 63,
"payload_sha256": "19cb46c3f3fbf8d804aa64f66031f4cb9c551dd198083394568ba12e7f4140e1",
"packet_sha256": "964a64272e72c8377f10baf43845d1023e2f66363d10ea14861005e3496201d0"
},
{
"sequence_number": 64,
"payload_sha256": "cce7d5957bf9573b07cd537ad1882e4f9ca8e40a06e7ee651b45eb1083ac062e",
"packet_sha256": "2a9f1cd3eb0a6fd78a79ea5b7ee20eb90266967ad60519ffd52d379f27968cc5"
},
{
"sequence_number": 65,
"payload_sha256": "a3022b2ccdddfb3ae31666802fc50862d328b90fa3f1fd919a7ec282a803d01b",
"packet_sha256": "9fb0676a2a05c48791b14dcac0fe694462123d07aa1f0539a25ae2c85edcb467"
},
{
"sequence_number": 66,
"payload_sha256": "d700f2d05d13f0397b8314a413b3bdeefdb29b19fe266ed6fadd7847c8d370a8",
"packet_sha256": "4db24f6fcccde301f1a7f3c7f93a22f809b6304ecada8ff08964bf47f97c7d44"
},
{
"sequence_number": 67,
"payload_sha256": "a7c6780dfa088e82142e99839a3794d068e7dbed9c1c984ab89e1c18341aa165",
"packet_sha256": "47eb548881c5c2bb7f4568ec803d62744041363a8df836ac24f212dd043b194d"
},
{
"sequence_number": 68,
"payload_sha256": "4e94ad2174f8379eb311155969ef7619b862d6db93c0ce2b6b0b8222641869e0",
"packet_sha256": "16d123eac8487f32dfbef2836b9ca28bf0bbbc9ec9cbde9473be3ad49dad8561"
},
{
"sequence_number": 69,
"payload_sha256": "19375b8ab0a37f5eff619977273c547c0698ed9264d91b725568bb4dd7ccb6c4",
"packet_sha256": "e806ca6e9be15f5e5d3e9ab21ddfe5a81cba66c5dc40283e3543bbbb1de8ad23"
},
{
"sequence_number": 70,
"payload_sha256": "d60d45967a1dcf5188222ed928adb9afa9599b1c036db00c3b7c1ff2290edf30",
"packet_sha256": "c542d6426d41d9245715b262c3353c05c3e2459f6e57b92b197d0e325b6f1a39"
},
{
"sequence_number": 71,
"payload_sha256": "408ab0b95a563333d7705a01201fba72079d78a840ac2c0aebc85a807fafc760",
"packet_sha256": "1a40abde7d1ca18c86ccb8cad98005adb259a7262d04f655fb6b59f713339a90"
},
{
"sequence_number": 72,
"payload_sha256": "9960aee518000cf96d71300a043bb7826e611314dbfb41b544c64aa62eae8de1",
"packet_sha256": "b96ec2a35c3f04a1eb1d11416b6bba6c464eb9223823bc90286e93496995aef2"
},
{
"sequence_number": 73,
"payload_sha256": "a0b867b3f9d3e09113a663acc1310c9245d49c985d624d6407f8553cc0f0a8eb",
"packet_sha256": "4cd1b3313cd7f2270e083c16fe77f4ce5b843cc57834382e30d8fb3350eb6ac8"
},
{
"sequence_number": 74,
"payload_sha256": "6d8f21d468de22c1939ae0f72374cee7e505528e49c052fb5c2295574195c595",
"packet_sha256": "1d3512901cb78072a773198320330ac528009c6aff7fb4f42fe10eeb2134a498"
},
{
"sequence_number": 75,
"payload_sha256": "918f46919ed53f70bd031a739886dc8b93b212e054388076076bb81624d9a4cb",
"packet_sha256": "d423db049f9e9ef07dd9c358c3315239e745ab0f8bf6dee1c93ee027b63550f4"
},
{
"sequence_number": 76,
"payload_sha256": "c898050384f25ea8e8a06121ca9c870b21ffdf5d7d66941ea80f1f43cc7d6c48",
"packet_sha256": "2ed7dbda8de3ad2b55fb5631aa0d6adb52e0e7a2e87b3ccbf4c4d14b93737224"
},
{
"sequence_number": 77,
"payload_sha256": "420551db636aa35fa85dae39b8166056a028338da82fdaeab3d2e2f90fb692f9",
"packet_sha256": "ea584a2d123b4969df4bc52dd721f9983fa5a34c9e2a98a0b857a99ee7e7bae3"
},
{
"sequence_number": 78,
"payload_sha256": "ff37d42b153f57cd46808c831894c180ec8710827cbe5ddd84891a349e78584e",
"packet_sha256": "44f213f517d5ecf2b419426c14eaae5c03176485db73b78b29a145ad5c952a66"
},
{
"sequence_number": 79,
"payload_sha256": "795aa97056eb0c8b5e4134ba9204581c06a3f73eb72a26cfd52cdfba5c2d3ebd",
"packet_sha256": "a7a4fff4bf520ebe57db314995036ce76eeb09a778ccedd5eec4fa4179782fc0"
},
{
"sequence_number": 80,
"payload_sha256": "772225bc6d1c9d7da39b90cf545409d21b8a267ec3881abb8b4cf737dc8b5586",
"packet_sha256": "c04ae816948e9f645dde14c688bf7a881fd761c12666169bf3cf944397d2ae1f"
},
{
"sequence_number": 81,
"payload_sha256": "5d7b128857e4eea0290eb5748b26da0ff30136e2d2b0cbf6527b08cb84b576ee",
"packet_sha256": "19d35293afa0ff3705dd9fe7dff134aec57655a2cfacfc10b3b56792488cc379"
},
{
"sequence_number": 82,
"payload_sha256": "6f13fedc3925d328f7aa448715f4f88bf7f8397430f015474d1ec0b8c2524f19",
"packet_sha256": "7f5da368cdb8d93f01a743046842a94b779408bf03be46ea749b7d3d875efb68"
},
{
"sequence_number": 83,
"payload_sha256": "db4fae9f025383ee9fcb272bf591391be9a7511480a6155bd16368b2a7526da8",
"packet_sha256": "bc5c2b1a99bb0f03281a6a8977fb91497c1fcb08f824fe37f3a5dc58272e135f"
},
{
"sequence_number": 84,
"payload_sha256": "c7263f589c0b9a8bfed09664ba80c2c98bef86e4e190ecadf5a4764be50af600",
"packet_sha256": "19e7ac7b9331adb5b9e58a91612203767498150463300b360e0ba603b20ca104"
},
{
"sequence_number": 85,
"payload_sha256": "8813e4ed36a33b415487386e94ee7e07bc33825b7516e662cf51f5b43f2f25a6",
"packet_sha256": "9714f48963007f0802169f73e765a3d19d958d130ce82f022919aa1f29cf97e3"
},
{
"sequence_number": 86,
"payload_sha256": "ae35ed2b855402a457dc0ebf13e9c6915a1964a87a9a6f58b678ada2549e36a0",
"packet_sha256": "a76f078847dc4afb6bb6f7c55ae2c7d04770e2ea8d5ecf11cb9a1a72e2374efc"
},
{
"sequence_number": 87,
"payload_sha256": "1e508aacf860bfa4fa124c366386be546cebd62a375f14e771be08999506fe85",
"packet_sha256": "5531ebb6a8138ee6b9f549be25ee2cf47c0c321bfe05eebb7232e611b6124fff"
},
{
"sequence_number": 88,
"payload_sha256": "96942711e476e7bdb2aa57c3845c4594825f4dd8b8fd8f3203c87acccdc6d527",
"packet_sha256": "a23c2b138284b533fbbc7ff2350056c82878d92572e7b96ccdc60e28977858de"
},
{
"sequence_number": 89,
"payload_sha256": "e356c8ef03d32d28983c088d6c0bad625ca577351dd6e067763b212e817d75c4",
"packet_sha256": "6f003c9766914ee11f6d4bd5cb8f80f05451e06f4ba21e589e7ee288ee0ec87f"
},
{
"sequence_number": 90,
"payload_sha256": "bd6da495c09b3b533bd4e8c6f0ac5751c0b26174172fc5085e22b48f3b53a577",
"packet_sha256": "5b2a1a43ebf039b998af09037edf1054d913dfa4d364fa502ac5e89279055fa7"
},
{
"sequence_number": 91,
"payload_sha256": "648b2da1112dcfeb229519fde8ef8034449937e7b42602588c223855382925be",
"packet_sha256": "90a075a8c1804a32a42c5d0ebce27bc7dc3e23feb7e9d8cdffe3f3486ec5cf0f"
},
{
"sequence_number": 92,
"payload_sha256": "669afb8bb74b9dbdf96708d2a48ee1fce635ae22294e06ed4500586e57213762",
"packet_sha256": "2567a20ec8033be6286594ce3a542566866544bc083ad278f9340ca2b48aae24"
},
{
"sequence_number": 93,
"payload_sha256": "a2f6f9d77355b3a04c32d069af37031a06f2c484d58b51849bb55b3cdec572e5",
"packet_sha256": "9192777c9fecb9f5ed4f065b7d5bfafcc41f6f76ac5ce742ccedc5b2a6f61de3"
},
{
"sequence_number": 94,
"payload_sha256": "055d60647878f18901ec2a1e009363e3d5517fccbbe92fa2cd6b6feeadf58391",
"packet_sha256": "b54786e36fd680a81066bddf3289f17aa95b57d46bf006cf08c1e07a7aaa5a27"
},
{
"sequence_number": 95,
"payload_sha256": "d10f469fbad708685b16c1ddcceca68a84f77a802b3ad04b0d394e45f754d0a2",
"packet_sha256": "f37eb95b8e0344bedc4ad85436cbe4db62593dde31fdb3daf0284e49a2ac32c1"
},
{
"sequence_number": 96,
"payload_sha256": "12a224b34c176ef7fed560197a39ad3bc448eee1467733f84250b29f3a06ce03",
"packet_sha256": "fe3ce30a3d7649061f208a86c8ede5aac568803d079d8da1a0a5a11a962dd358"
},
{
"sequence_number": 97,
"payload_sha256": "db488bd0b7c4c38e5fce4a309d323955bd870a5b9947c753fd9065a0ce42b210",
"packet_sha256": "a60f4e0dfe9b40ae18e03deb331022e53292368803bed492bc3069dbc564bb38"
},
{
"sequence_number": 98,
"payload_sha256": "3f12c8bc119739279484078bc5e17c39a7c36e4078ae55fc6a25aba0b7457348",
"packet_sha256": "88e79568d2845aa646c91d1e211ee97e10c3a7cb43696607c6d56c7da0c5bb8b"
},
{
"sequence_number": 99,
"payload_sha256": "c91c2b855495e75e3751b1e4c3abc5525db4d7500b1845d0e9907d8f60ccb2dc",
"packet_sha256": "ea7ce09526a21d432e7ae7a9007a9e69d69197f1a6b2c2668cd66c95c1e42688"
}
],
"rf_tx": 0,
"sdr_hardware": "NOT_ACCESSED",
"status": "PREPARED_NOT_RUN",
"iq_sha256": "7cd81395efaa88b623b7e4e5a02e3a80b52185007397ea56ceaacc13353974b2",
"shared_spec_test_id": "RF001A_COMMON_CLOCK_001",
"payload_size": 256,
"expected_sequence_numbers": [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
31,
32,
33,
34,
35,
36,
37,
38,
39,
40,
41,
42,
43,
44,
45,
46,
47,
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59,
60,
61,
62,
63,
64,
65,
66,
67,
68,
69,
70,
71,
72,
73,
74,
75,
76,
77,
78,
79,
80,
81,
82,
83,
84,
85,
86,
87,
88,
89,
90,
91,
92,
93,
94,
95,
96,
97,
98,
99
],
"tx_waveform_sha256": "75f3fa39bfa4ee419f0676c54e4b9c486d7ff64bd0fe2fc06343b20c9d117ecd",
"tx_waveform_format": "cs8",
"fec": "2/3",
"gap_ms": 5,
"source_provenance": "Generated from deterministic RF-001 seed and verified against configs/rf/rf001a_common_clock.json"
}

View File

@@ -1,7 +1,7 @@
TASK_ID = RF001A_OTA_CAPTURE_002
TASK_ID = RF001A_OTA_CAPTURE_003
TASK_TITLE = Start coordinated RF-001A common-clock OTA capture
SOURCE_BRANCH = video-development
EXPECTED_HEAD = 34f5ace54540f947311ada7e81a463c08abfd2d3
EXPECTED_HEAD = bc55d0516dc1ee36cefa1ff096e7d86f60645303
REQUIRES_APPROVAL = YES
HARDWARE_ACCESS = YES
RF_RX = YES
@@ -11,7 +11,7 @@ ALLOW_PUSH = RX_RESULT_ONLY
RISK_LEVEL = YELLOW
This file is the authoritative current task for the RX laptop.
The RX assistant must read RX_NODE_STATE.md first, then execute this file.
The RX assistant must read RX_NODE_STATE.md and RF_HANDOFF_CHANNEL_TEST.md first, then execute this file.
If chat history conflicts with these files, the shared RF-001A spec or current Git HEAD, stop and report the conflict.
# Current RX task
@@ -19,13 +19,25 @@ If chat history conflicts with these files, the shared RF-001A spec or current G
TASK =
START COORDINATED RF-001A COMMON-CLOCK OTA CAPTURE
This is a new YELLOW task. Do not reuse approval or completion state from RF001A_OTA_CAPTURE_001 or any earlier task.
This is a new YELLOW task. Do not reuse approval or completion state from RF001A_OTA_CAPTURE_001, RF001A_OTA_CAPTURE_002 or any earlier task.
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.
One approval for this task covers the model-free channel test, read-only checks, one bounded RF RX capture and publication of the two small allowlisted result files to the existing rx-node branch. It never authorizes RF TX.
## 1. Preconditions
The preceding RF001A_OTA_CAPTURE_002 produced a real 120.1413067 s, 1920000000-byte capture with CLKIN detected before and after, zero reported overruns and SHA256 99a3e7cb51d3ad4e3b914b63da0695ed6658aa169110c32659aba995b542fa0c. Its READY publication failed, its exact child exit code was not recorded and no synchronized truth metadata was available. Treat that as historical evidence only.
After the user chooses [A] Approve for this exact TASK_ID:
## 1. Model-free handoff channel gate
After the user chooses [A] Approve for this exact TASK_ID, run before any hardware access:
python -B tools/rf_handoff_smoke.py --hold-seconds 7
This subtask uses TASK_ID RF_HANDOFF_CHANNEL_TEST_001. It accesses no SDR and keeps RF TX = 0. It publishes fake READY and COMPLETE results through the real origin of the main RX checkout. Require the MAIN watcher to observe both states, require confirmed remote commit hashes, and require COMPLETE to remain as the final origin/rx-node state. Require the MAIN watcher state to contain both channel_test_ready_digest and channel_test_complete_digest for this run.
If any channel check or publication fails, do not access HackRF, do not start capture and STOP with RF TX = 0.
## 2. Preconditions
After the same [A] approval for this exact TASK_ID and after the channel gate passes:
1. Run git pull --ff-only origin video-development.
2. Require branch video-development.
@@ -33,7 +45,7 @@ After the user chooses [A] Approve for this exact TASK_ID:
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 RX serial 000000000000000097c063dc2e5327d3.
7. Confirm that configs/rf/rf001a_common_clock.json exists and matches the fixed RF-001A frequency, sample rate, modulation and common-clock plan.
7. Confirm that configs/rf/rf001a_common_clock.json and configs/rf/rf001a_truth_metadata.json exist. Require the tracked truth metadata test ID, seed, 100 packet hashes, sequence numbers, waveform SHA256 and PHY settings to agree with the shared RF-001A spec.
8. With MASTER CLKOUT physically connected to SLAVE CLKIN, run:
hackrf_clock.exe -d 000000000000000097c063dc2e5327d3 -i
@@ -42,13 +54,13 @@ After the user chooses [A] Approve for this exact TASK_ID:
CLKIN status: clock signal detected
10. Require the remote branch origin/rx-node to exist. Do not create it in this task.
10. Require the remote branch origin/rx-node to exist through both git fetch origin rx-node and git ls-remote --heads origin rx-node. 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.
If any condition fails, do not start capture. Report the failed condition, keep RF TX = 0, and STOP.
## 2. Authorized bounded RX capture
## 3. Authorized bounded RX capture
Only this finite receive operation is authorized:
@@ -80,16 +92,16 @@ The hackrf_transfer.exe arguments are fixed:
Do not add repeat mode. Do not change gain, clock, frequency or sample rate.
## 3. Background runner and durable evidence
## 4. Background runner and durable evidence
Use a local runner outside Git. The runner must:
Use the tracked tools/capture_process_runner.ps1. Write the exact hackrf_transfer argument array to a unique local JSON file outside Git. Start the runner itself as a controlled background PowerShell process and pass unique paths for the capture, complete stdout, complete stderr, capture_started.json and capture_result.json. The runner must:
- start exactly one hackrf_transfer child;
- record its PID and start time immediately;
- record the expected end time as start time plus 120 seconds;
- redirect complete stdout and stderr to unique local files;
- wait for that exact child;
- record its exact exit code, finish time, duration and output size;
- wait for that exact child, refresh its process object and read ExitCode only after WaitForExit;
- record its exact integer exit code, finish time, duration and output size;
- calculate SHA256 after the file is closed;
- write started and final JSON atomically;
- never start a second capture automatically.
@@ -103,13 +115,13 @@ Start the runner as a controlled background process. Within five seconds, requir
If any fact is false, publish no READY state. Preserve local evidence, report RX CAPTURE STARTED = NO, keep RF TX = 0, and STOP.
## 4. Publish READY immediately
## 5. Publish READY immediately
As soon as the child is alive and the file is growing, build a local JSON file outside Git with every field below:
{
"schema_version": 1,
"task_id": "RF001A_OTA_CAPTURE_002",
"task_id": "RF001A_OTA_CAPTURE_003",
"state": "RX_READY_FOR_TX",
"rx_ready_for_tx": true,
"process_alive": true,
@@ -142,14 +154,16 @@ The publisher may change only:
docs/handoff/results/RX_RESULT.json
docs/handoff/results/RX_RESULT.md
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.
The publisher checks origin/rx-node through the real origin of the main checkout. It creates a temporary Git worktree from that repository, stages exactly the two allowlisted result paths, pushes HEAD:rx-node, confirms the new remote HEAD with git ls-remote and removes the worktree. It must not switch the source checkout, push video-development, or add raw IQ, video, waveform or large logs.
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.
Use this order without exception: verify CLKIN, start capture, verify the child is alive, verify the file is growing, publish READY, verify RX RESULT REMOTE HEAD equals the published commit, and only then report RX READY FOR TX = YES.
If publication or remote confirmation fails, show the complete error and report RX READY FOR TX = NO. The finite capture may finish normally, but TX coordination does not start and RF TX remains 0.
After successful publication, immediately return control to the user without waiting for capture completion:
TASK_ID =
RF001A_OTA_CAPTURE_002
RF001A_OTA_CAPTURE_003
RX CAPTURE STARTED =
YES
@@ -168,14 +182,16 @@ After successful publication, immediately return control to the user without wai
RX RESULT PUBLISH =
PASS
RX RESULT REMOTE HEAD =
RF TX =
0
The READY state describes a live receiver only. It does not authorize RF TX on either computer.
## 5. Capture completion
## 6. Capture completion
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.
After the child exits, read capture_result.json and require an integer exit_code recorded by the runner. 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.
Run the read-only clock check again:
@@ -193,7 +209,7 @@ Require CLKIN to remain detected. The expected complete capture has:
A shorter file is incomplete. Any reported nonzero error remains evidence. Do not retry automatically.
## 6. Publish final result
## 7. Publish final result
Build a second local JSON document with the same identity and timing fields. Set:
@@ -219,17 +235,17 @@ Publish with:
python -B tools/rx_agent_watcher.py --publish-result <final-json-path> --result-branch rx-node
If the push fails, preserve the local JSON and logs, report the error, and do not claim publication success.
Require RX RESULT REMOTE HEAD to equal the final result commit. If the push or confirmation fails, preserve the local JSON and logs, report the error, and do not claim publication success.
## 7. Offline decode
## 8. Offline decode
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:
Use the tracked synchronized truth artifact without copying TX files manually:
OFFLINE DECODE = BLOCKED_BY_TRUTH_METADATA
python -B -X utf8 tools/qpsk_rf_rx.py --iq-file <capture-path> --iq-format cs8 --truth-metadata configs/rf/rf001a_truth_metadata.json --report <unique-path-outside-git>\rf001a_ota_decode.json
Do not modify the receiver or invent results.
Report exact packets, missing packets, duplicates, BER and post-FEC errors from the generated report. Preserve a decoder failure as evidence; do not modify the receiver or invent results.
## 8. Prohibited actions
## 9. Prohibited actions
On the RX laptop, do not perform:
@@ -244,10 +260,12 @@ On the RX laptop, do not perform:
- creation of rx-node;
- publication of raw IQ, video, waveform or large logs.
## 9. Final RX report
## 10. Final RX report
TASK_ID =
RF001A_OTA_CAPTURE_002
RF001A_OTA_CAPTURE_003
CHANNEL TEST =
RX CAPTURE COMPLETE =
@@ -271,8 +289,20 @@ On the RX laptop, do not perform:
FINAL RX RESULT PUBLISH =
FINAL RX RESULT REMOTE HEAD =
OFFLINE DECODE =
EXACT PACKETS =
MISSING =
DUPLICATES =
BER =
POST-FEC ERRORS =
RF TX =
0

View File

@@ -1,7 +1,7 @@
TASK_ID = RF001A_OTA_TX_002
TASK_ID = RF001A_OTA_TX_003
TASK_TITLE = Transmit one coordinated RF-001A QPSK burst
SOURCE_BRANCH = video-development
EXPECTED_HEAD = 34f5ace54540f947311ada7e81a463c08abfd2d3
EXPECTED_HEAD = bc55d0516dc1ee36cefa1ff096e7d86f60645303
REQUIRES_APPROVAL = YES
HARDWARE_ACCESS = YES
RF_RX = NO
@@ -18,7 +18,7 @@ Read TX_NODE_STATE.md first. If chat history, this file, the shared RF-001A spec
TASK =
TRANSMIT ONE COORDINATED RF-001A QPSK BURST
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.
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, RF001A_OTA_TX_002, the RX task, a previous watcher prompt or a previous RF run.
## 1. Coordination gate
@@ -28,7 +28,7 @@ Start the local model-free watcher before the RX task begins:
The watcher polls origin/rx-node every 5 seconds and accepts only a strict result for:
RX TASK_ID = RF001A_OTA_CAPTURE_002
RX TASK_ID = RF001A_OTA_CAPTURE_003
STATE = RX_READY_FOR_TX
RX_READY_FOR_TX = YES
PROCESS_ALIVE = YES
@@ -36,7 +36,7 @@ The watcher polls origin/rx-node every 5 seconds and accepts only a strict resul
COMMON_CLOCK_VERIFIED = YES
RF_TX = 0
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.
The watcher records RF_HANDOFF_CHANNEL_TEST_001 READY and COMPLETE states without prompting for RED approval and never treats them as production readiness. A production READY publication must be no more than 30 seconds old. Its planned capture window must be 120 seconds. At least 10 seconds must remain. COMMON_CLOCK_VERIFIED must be YES, source_head must equal the expected video-development HEAD, and the result commit must be the current valid origin/rx-node HEAD.
When a valid new READY result appears, the watcher prints:
@@ -47,7 +47,7 @@ When a valid new READY result appears, the watcher prints:
CAPTURE END EXPECTED =
TX TASK =
RF001A_OTA_TX_002
RF001A_OTA_TX_003
RF TX REQUIRES RED APPROVAL
@@ -56,7 +56,7 @@ When a valid new READY result appears, the watcher prints:
Without the exact phrase AUTHORIZE RF TX, do not transmit.
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.
After the phrase, the watcher fetches rx-node again and repeats the result commit, digest, task ID, source HEAD, READY, freshness, common-clock 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:
@@ -103,7 +103,7 @@ Before the watcher can request RED approval:
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
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 configs/rf/rf001a_truth_metadata.json --report <new-path-outside-git>\rf001a_tx_offline_verify.json
Require:
@@ -112,7 +112,7 @@ Require:
offline hardware-format roundtrip = 100/100 exact
TX waveform path = data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8
TX waveform size = 17667656 bytes
TX waveform SHA256 = 75f3fa39bfa88b623b7e4e5a02e3a80b52185007397ea56ceaacc13353974b2
TX waveform SHA256 = 75f3fa39bfa4ee419f0676c54e4b9c486d7ff64bd0fe2fc06343b20c9d117ecd
sample count = 8833828 complex samples
expected duration = 1.1042285 s
@@ -122,9 +122,9 @@ If any value differs, do not transmit.
The controlled TX executor must read the authorization record immediately before TX and require:
- task_id = RF001A_OTA_TX_002;
- task_id = RF001A_OTA_TX_003;
- authorization = AUTHORIZE RF TX;
- rx_task_id = RF001A_OTA_CAPTURE_002;
- rx_task_id = RF001A_OTA_CAPTURE_003;
- dry_run = false;
- action = MANUAL_EXECUTOR_GATE_OPEN;
- rf_tx_started = false;
@@ -165,7 +165,7 @@ Set RF TX STOPPED = YES only after the finite process exits. Never retry automat
## 6. Final TX report
TASK_ID =
RF001A_OTA_TX_002
RF001A_OTA_TX_003
RED AUTHORIZATION =
AUTHORIZE RF TX / MISSING

View File

@@ -0,0 +1,41 @@
TASK_ID = RF_HANDOFF_CHANNEL_TEST_001
TASK_TITLE = Verify the bidirectional Git handoff channel without SDR
SOURCE_BRANCH = video-development
EXPECTED_HEAD = bc55d0516dc1ee36cefa1ff096e7d86f60645303
REQUIRES_APPROVAL = NO
HARDWARE_ACCESS = NO
RF_RX = NO
RF_TX = NO
ALLOW_COMMIT = NO
ALLOW_PUSH = RX_RESULT_ONLY
RISK_LEVEL = GREEN
# Model-free handoff channel test
Run this gate before RF001A_OTA_CAPTURE_003 and while the MAIN watcher is active:
python -B tools/rf_handoff_smoke.py --hold-seconds 7
The script uses the real origin configured in the main RX checkout. It publishes a synthetic READY result to the existing origin/rx-node branch, waits seven seconds, then publishes a synthetic COMPLETE result. Both publications may change only:
docs/handoff/results/RX_RESULT.json
docs/handoff/results/RX_RESULT.md
The MAIN watcher must observe both states and must not request RED approval. Its state file must record both channel_test_ready_digest and channel_test_complete_digest. The final remote state must be CAPTURE_COMPLETE, with rx_ready_for_tx false. A synthetic READY state must not remain active.
The script and watcher do not access SDR hardware, do not run hackrf_transfer and do not authorize any RF transmission.
Required report:
TASK_ID = RF_HANDOFF_CHANNEL_TEST_001
READY PUBLISH = PASS / FAIL
READY REMOTE HEAD =
MAIN WATCHER RECEIVES READY = PASS / FAIL
COMPLETE PUBLISH = PASS / FAIL
COMPLETE REMOTE HEAD =
MAIN WATCHER RECEIVES COMPLETE = PASS / FAIL
FINAL REMOTE STATE = CAPTURE_COMPLETE / OTHER
REAL RX = NOT_RUN
RF TX = 0
If any field fails, do not start the hardware RX task. STOP.

View File

@@ -10,7 +10,7 @@ The reverse result channel uses the separate origin/rx-node branch. The RX lapto
docs/handoff/NEXT_RX_TASK.md starts with these plain key-value fields:
TASK_ID = RF001A_OTA_CAPTURE_002
TASK_ID = RF001A_OTA_CAPTURE_003
TASK_TITLE = Start coordinated RF-001A common-clock OTA capture
SOURCE_BRANCH = video-development
EXPECTED_HEAD = <full 40-character base commit hash>
@@ -69,7 +69,7 @@ After the task actually finishes, mark the local ID complete:
The coordinated RF-001A result is a strict JSON object. Unknown or missing keys are rejected. READY requires:
- task_id = RF001A_OTA_CAPTURE_002;
- task_id = RF001A_OTA_CAPTURE_003;
- state = RX_READY_FOR_TX;
- rx_ready_for_tx, process_alive, file_growing and common_clock_verified all true;
- a positive capture_pid;
@@ -105,12 +105,12 @@ The publisher checks:
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:
It checks the branch with git fetch and git ls-remote through the real origin configured in the main checkout. It creates a temporary worktree from that repository and 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.
It checks the staged path set, commits, pushes HEAD:rx-node, and confirms that git ls-remote returns the new commit. It then removes the temporary worktree. 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.
@@ -118,9 +118,9 @@ Raw IQ, video, waveform files, large logs, secrets and machine-local state remai
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.
For RF_HANDOFF_CHANNEL_TEST_001 the watcher records synthetic READY and COMPLETE states without requesting RED approval. For a new valid production READY result it checks task ID, source HEAD, current result branch commit, 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.
After the phrase, it fetches rx-node again and repeats every check. A changed result or commit, 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.
@@ -135,6 +135,8 @@ Duplicate result digests are not shown repeatedly.
- Neither watcher starts RF TX.
- The TX executor remains a separate RED-controlled action.
## Dry-run contract
## Model-free channel and dry-run contracts
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.
The preflight channel command publishes synthetic READY and COMPLETE results with TASK_ID RF_HANDOFF_CHANNEL_TEST_001 through the real origin. The MAIN watcher must observe both without a RED prompt, and COMPLETE must be the final remote state. It does not access SDR hardware or run hackrf_transfer.
Automated coordination tests use a temporary local bare Git repository and branch named rx-node. They cover worktree publication, READY, COMPLETE, push failure and watcher reception without accessing the real remote or SDR hardware and without transmitting RF.

View File

@@ -41,11 +41,11 @@ When the task is actually complete, run:
## Publish READY
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:
For RF001A_OTA_CAPTURE_003, first complete RF_HANDOFF_CHANNEL_TEST_001. Then create the strict READY JSON outside Git only after CLKIN is detected, the capture child is alive and the file is growing. Run:
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.
On success, the watcher prints RX RESULT PUBLISH = PUBLISHED or UNCHANGED, the result commit, the independently confirmed RX RESULT REMOTE HEAD, and RF TX = 0. Report RX READY FOR TX = YES only after the remote HEAD equals the result commit.
The command fails if:
@@ -58,6 +58,14 @@ The command fails if:
A failed publication is not READY evidence and cannot authorize TX.
## Model-free channel test
Before hardware access, while the MAIN watcher is running, execute:
python -B tools/rf_handoff_smoke.py --hold-seconds 7
It publishes synthetic READY and COMPLETE results through the real origin. Require the MAIN watcher to observe both and require COMPLETE as the final remote state. Failure blocks the hardware capture. REAL RX remains NOT_RUN and RF TX remains 0.
## Publish COMPLETE
After capture completion, build the strict final JSON with state CAPTURE_COMPLETE and publish it with the same command:

View File

@@ -6,7 +6,7 @@ The publisher creates both docs/handoff/results/RX_RESULT.json and docs/handoff/
{
"schema_version": 1,
"task_id": "RF001A_OTA_CAPTURE_002",
"task_id": "RF001A_OTA_CAPTURE_003",
"state": "RX_READY_FOR_TX",
"rx_ready_for_tx": true,
"process_alive": true,

View File

@@ -14,17 +14,19 @@ The visible console polls every 5 seconds. A missing rx-node branch or invalid r
## 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:
RF_HANDOFF_CHANNEL_TEST_001 READY and COMPLETE states are recorded without a RED prompt and never open the production gate.
For a fresh valid RF001A_OTA_CAPTURE_003 READY result, the watcher checks the expected source HEAD, current origin/rx-node commit, common clock and capture window. It then prints the task ID, capture start and expected end and 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.
After the exact phrase, the watcher fetches rx-node again. It requires the same result digest and commit, the expected source HEAD, a fresh publication, an active 120-second capture window, verified common clock and at least 10 seconds remaining.
On success it writes:
.local\tx_authorizations\RF001A_OTA_TX_002_authorized.json
.local\tx_authorizations\RF001A_OTA_TX_003_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.

View File

@@ -25,6 +25,7 @@
"tools/rf001a_prepare.py",
"tools/rx_agent_watcher.py",
"tools/rf_handoff_channel.py",
"tools/rf_handoff_smoke.py",
"tools/tx_agent_watcher.py",
"tools/clock001.py",
"tests/test_video005_iq.py",
@@ -33,6 +34,8 @@
"tests/test_rf001a_prepare.py",
"tests/test_rx_agent_watcher.py",
"tests/test_rf_handoff_coordination.py",
"tests/test_capture_process_runner.py",
"tests/test_rf001a_truth_metadata.py",
"tests/test_clock001.py"
],
"typeCheckingMode": "basic",

View File

@@ -0,0 +1,87 @@
"""Software-only checks for the durable capture process runner."""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import subprocess
import pytest
ROOT = Path(__file__).resolve().parents[1]
RUNNER = ROOT / "tools" / "capture_process_runner.ps1"
@pytest.mark.parametrize("expected_exit_code", [0, 7])
def test_runner_persists_exact_child_exit_code(
tmp_path: Path,
expected_exit_code: int,
) -> None:
child = tmp_path / f"child_{expected_exit_code}.cmd"
child.write_text(
f"@echo off\r\nexit /b {expected_exit_code}\r\n",
encoding="ascii",
)
capture = tmp_path / "fake_capture.cs8"
capture_bytes = b"software-only-capture-evidence\x00\x01"
capture.write_bytes(capture_bytes)
arguments_path = tmp_path / "arguments.json"
arguments_path.write_text(
json.dumps(["/d", "/c", str(child)]),
encoding="utf-8",
)
stdout_path = tmp_path / "child.stdout.txt"
stderr_path = tmp_path / "child.stderr.txt"
started_path = tmp_path / "capture_started.json"
result_path = tmp_path / "capture_result.json"
completed = subprocess.run(
[
"powershell.exe",
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
str(RUNNER),
"-Executable",
str(Path(os.environ["COMSPEC"])),
"-ArgumentsJsonPath",
str(arguments_path),
"-CapturePath",
str(capture),
"-StdoutPath",
str(stdout_path),
"-StderrPath",
str(stderr_path),
"-StartedStatusPath",
str(started_path),
"-ResultStatusPath",
str(result_path),
],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
assert completed.returncode == expected_exit_code
started = json.loads(started_path.read_text(encoding="utf-8-sig"))
result = json.loads(result_path.read_text(encoding="utf-8-sig"))
assert started["state"] == "RUNNING"
assert isinstance(started["pid"], int) and started["pid"] > 0
assert result["pid"] == started["pid"]
assert result["started_at"] == started["started_at"]
assert result["finished_at"]
assert result["duration"] >= 0
assert result["exit_code"] == expected_exit_code
assert result["capture_path"] == str(capture)
assert result["capture_size"] == len(capture_bytes)
assert result["sha256"] == hashlib.sha256(capture_bytes).hexdigest()
assert result["runner_error"] is None

View File

@@ -0,0 +1,49 @@
"""Checks for the tracked RF-001A decoder truth artifact."""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SPEC_PATH = ROOT / "configs" / "rf" / "rf001a_common_clock.json"
TRUTH_PATH = ROOT / "configs" / "rf" / "rf001a_truth_metadata.json"
def test_truth_metadata_is_small_complete_and_spec_aligned() -> None:
spec = json.loads(SPEC_PATH.read_text(encoding="utf-8"))
truth = json.loads(TRUTH_PATH.read_text(encoding="utf-8"))
assert TRUTH_PATH.stat().st_size < 64 * 1024
assert truth["shared_spec_test_id"] == spec["test_id"]
assert truth["test_id"] == spec["legacy_waveform_test_id"]
assert truth["seed"] == spec["seed"]
assert truth["packet_count"] == spec["packet_count"] == 100
assert truth["payload_size"] == spec["application_payload_bytes"] == 256
assert truth["application_payload_bytes"] == truth["payload_size"]
assert truth["expected_sequence_numbers"] == list(range(100))
assert len(truth["expected_packet_hashes"]) == 100
assert [
row["sequence_number"] for row in truth["expected_packet_hashes"]
] == list(range(100))
assert all(
len(row["payload_sha256"]) == 64 and len(row["packet_sha256"]) == 64
for row in truth["expected_packet_hashes"]
)
assert truth["tx_waveform_sha256"] == spec["expected_hardware_sha256"]
assert truth["iq_sha256"] == spec["expected_canonical_sha256"]
assert truth["modulation"] == spec["modulation"]
assert truth["symbol_rate"] == spec["symbol_rate_sps"]
assert truth["sample_rate"] == spec["sample_rate_hz"]
assert truth["fec"] == spec["inner_fec"]
assert truth["fec_rate"] == truth["fec"]
assert truth["gap_ms"] == spec["inter_packet_gap_ms"]
assert truth["inter_packet_gap_ms"] == truth["gap_ms"]
assert (
truth["digital_carrier_offset_hz"]
== spec["digital_carrier_offset_hz"]
)
assert truth["tx_waveform_format"] == spec["hardware_format"]
assert "waveform" not in truth
assert "raw_iq" not in truth

View File

@@ -11,6 +11,7 @@ import subprocess
import pytest
from tools.rf_handoff_channel import (
CHANNEL_TEST_TASK_ID,
COMPLETE_STATE,
EXPECTED_RX_TASK_ID,
GitResultChannel,
@@ -18,18 +19,26 @@ from tools.rf_handoff_channel import (
RESULT_JSON_PATH,
RESULT_MARKDOWN_PATH,
ResultBranchMissing,
ResultChannelError,
RxHandoffResult,
canonical_result_json,
evaluate_ready,
result_digest,
result_from_mapping,
)
from tools.rf_handoff_smoke import run_smoke
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
from tools.tx_agent_watcher import (
TxWatcherState,
handle_result,
load_state as load_tx_state,
request_stop,
save_state as save_tx_state,
)
NOW = datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc)
@@ -65,6 +74,40 @@ def ready_result(**changes: object) -> RxHandoffResult:
return replace(result, **changes)
def channel_ready_result(**changes: object) -> RxHandoffResult:
return ready_result(
task_id=CHANNEL_TEST_TASK_ID,
capture_path="MODEL_FREE_FAKE_CAPTURE",
common_clock_verified=False,
**changes,
)
def complete_result(
result: RxHandoffResult | None = None,
**changes: object,
) -> RxHandoffResult:
source = result or ready_result()
complete = replace(
source,
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" if source.common_clock_verified else "NOT_RUN",
rf_tx_observed="UNKNOWN",
)
return replace(complete, **changes)
def _git(arguments: list[str], cwd: Path) -> str:
completed = subprocess.run(
["git", *arguments],
@@ -106,6 +149,7 @@ class FakeSource:
def fetch(self) -> None:
self.fetch_count += 1
return None
def read(self) -> tuple[RxHandoffResult, str, str]:
return self.result, self.digest, self.head
@@ -136,7 +180,7 @@ class FakeExecutor:
return self.path
def test_result_parsing_rejects_extra_and_inconsistent_fields() -> None:
def test_result_parsing_rejects_extra_types_and_inconsistent_fields() -> None:
raw = asdict(ready_result())
assert result_from_mapping(raw) == ready_result()
@@ -157,35 +201,20 @@ def test_result_parsing_rejects_extra_and_inconsistent_fields() -> None:
incomplete = dict(raw)
incomplete["file_growing"] = False
with pytest.raises(ValueError, match="live capture"):
with pytest.raises(ValueError, match="live process"):
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",
)
def test_complete_result_accepts_explicit_unreported_counters() -> None:
final = complete_result()
assert result_from_mapping(asdict(final)) == final
unreported_counters = replace(
unreported = replace(
final,
overruns=None,
usb_errors=None,
file_errors=None,
)
assert result_from_mapping(asdict(unreported_counters)) == unreported_counters
assert result_from_mapping(asdict(unreported)) == unreported
@pytest.mark.parametrize(
@@ -213,41 +242,37 @@ def test_complete_result_requires_final_evidence() -> None:
NOW,
"CAPTURE_WINDOW_EXPIRED",
),
(ready_result(task_id="WRONG_TASK_001"), NOW, "WRONG_TASK_ID"),
(
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",
),
complete_result(),
NOW,
"MISSING_READY",
),
(
ready_result(source_head="c" * 40),
NOW,
"WRONG_SOURCE_HEAD",
),
],
)
def test_freshness_and_window_rejections(
def test_freshness_window_task_and_source_rejections(
result: RxHandoffResult,
now: datetime,
reason: str,
) -> None:
assert evaluate_ready(result, now=now).reason == reason
assert evaluate_ready(
result,
now=now,
expected_source_head=HEAD,
).reason == reason
def test_ready_evaluation_passes_with_more_than_ten_seconds() -> None:
evaluation = evaluate_ready(ready_result(), now=NOW + timedelta(seconds=2))
evaluation = evaluate_ready(
ready_result(),
now=NOW + timedelta(seconds=2),
expected_source_head=HEAD,
)
assert evaluation.ready
assert evaluation.reason == "READY"
assert evaluation.seconds_remaining == pytest.approx(118.0)
@@ -271,6 +296,7 @@ def test_red_approval_and_duplicate_suppression(tmp_path: Path) -> None:
input_function=lambda _prompt: "DENY",
output_function=output.append,
dry_run=True,
expected_source_head=HEAD,
)
assert status == "RED_APPROVAL_BLOCKED"
assert executor.calls == 0
@@ -287,12 +313,13 @@ def test_red_approval_and_duplicate_suppression(tmp_path: Path) -> None:
input_function=lambda _prompt: "AUTHORIZE RF TX",
output_function=output.append,
dry_run=True,
expected_source_head=HEAD,
)
assert status == "DUPLICATE_SUPPRESSED"
assert executor.calls == 0
def test_window_is_rechecked_after_exact_approval(tmp_path: Path) -> None:
def test_window_and_commit_are_rechecked_after_exact_approval(tmp_path: Path) -> None:
result = ready_result()
digest = result_digest(result)
source = FakeSource(result, digest)
@@ -310,57 +337,134 @@ def test_window_is_rechecked_after_exact_approval(tmp_path: Path) -> None:
input_function=lambda _prompt: "AUTHORIZE RF TX",
output_function=lambda _text: None,
dry_run=True,
expected_source_head=HEAD,
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,
source = FakeSource(result, digest, head="d" * 40)
status, _ = handle_result(
result,
digest,
head,
HEAD,
TxWatcherState(),
source,
executor,
now_function=lambda: NOW + timedelta(seconds=2),
input_function=lambda _prompt: "AUTHORIZE RF TX",
output_function=output.append,
output_function=lambda _text: None,
dry_run=True,
expected_source_head=HEAD,
)
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 status == "RESULT_CHANGED"
assert executor.calls == 0
def test_stop_request_clears_stale_watcher_pid(tmp_path: Path) -> None:
state_path = tmp_path / "watcher_state.json"
save_tx_state(state_path, TxWatcherState(running=True, pid=2_000_000_000))
assert request_stop(state_path, timeout_s=1.0)
state = load_tx_state(state_path)
assert not state.running
assert state.pid is None
assert not state_path.with_suffix(".json.stop").exists()
def test_real_origin_detection_and_worktree_publication(tmp_path: Path) -> None:
remote, work = git_fixture(tmp_path)
branch_before = _git(["branch", "--show-current"], work)
channel = GitResultChannel(work)
assert channel.branch_exists()
remote_before = channel.fetch()
outcome = channel.publish(ready_result())
assert outcome.status == "PUBLISHED"
assert outcome.commit == outcome.remote_head
assert outcome.commit != remote_before
assert _git(["branch", "--show-current"], work) == branch_before
assert not (work / RESULT_JSON_PATH).exists()
assert not (work / RESULT_MARKDOWN_PATH).exists()
remote_head = _git(
["--git-dir", str(remote), "rev-parse", "refs/heads/rx-node"],
tmp_path,
)
assert any(line == "RF TX = 0" for line in output)
assert remote_head == outcome.commit
worktrees = _git(["worktree", "list", "--porcelain"], work)
assert worktrees.count("worktree ") == 1
def test_ready_then_complete_reaches_main_watcher_without_red_prompt(
tmp_path: Path,
) -> None:
_remote, work = git_fixture(tmp_path)
channel = GitResultChannel(work)
ready = channel_ready_result()
ready_outcome = channel.publish(ready)
observed_ready, ready_digest, ready_head = channel.read()
prompts: list[str] = []
status, state = handle_result(
observed_ready,
ready_digest,
ready_head,
TxWatcherState(),
channel,
FakeExecutor(tmp_path / "forbidden.txt"),
now_function=lambda: NOW,
input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX",
output_function=lambda _text: None,
expected_source_head=HEAD,
)
assert status == "CHANNEL_TEST_READY"
assert state.channel_test_ready_digest == ready_digest
assert prompts == []
complete = complete_result(
ready,
published_at=(NOW + timedelta(seconds=1)).isoformat(),
file_size=4,
duration_seconds=1.0,
sha256="d" * 64,
)
complete_outcome = channel.publish(complete)
observed_complete, complete_digest, complete_head = channel.read()
status, state = handle_result(
observed_complete,
complete_digest,
complete_head,
state,
channel,
FakeExecutor(tmp_path / "forbidden.txt"),
now_function=lambda: NOW + timedelta(seconds=1),
input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX",
output_function=lambda _text: None,
expected_source_head=HEAD,
)
assert status == "CHANNEL_TEST_COMPLETE"
assert state.channel_test_complete_digest == complete_digest
assert ready_outcome.remote_head != complete_outcome.remote_head
assert prompts == []
def test_smoke_utility_leaves_complete_not_ready(tmp_path: Path) -> None:
_remote, work = git_fixture(tmp_path)
ready, complete, expected = run_smoke(
work,
source_head=HEAD,
hold_seconds=0,
sleep_function=lambda _seconds: None,
)
assert ready.status == "PUBLISHED"
assert complete.status == "PUBLISHED"
assert ready.remote_head != complete.remote_head
observed, _digest, head = GitResultChannel(work).read()
assert observed == expected
assert observed.state == COMPLETE_STATE
assert not observed.rx_ready_for_tx
assert head == complete.remote_head
def test_missing_result_branch_is_blocked(tmp_path: Path) -> None:
@@ -371,11 +475,14 @@ def test_missing_result_branch_is_blocked(tmp_path: Path) -> None:
_git(["init"], work)
_git(["remote", "add", "origin", str(remote)], work)
channel = GitResultChannel(work)
assert not channel.branch_exists()
with pytest.raises(ResultBranchMissing):
channel.publish(ready_result())
def test_push_failure_is_reported(tmp_path: Path) -> None:
def test_push_failure_is_reported_and_remote_head_is_unchanged(
tmp_path: Path,
) -> None:
_remote, work = git_fixture(tmp_path)
class FailingPushChannel(GitResultChannel):
@@ -387,17 +494,23 @@ def test_push_failure_is_reported(tmp_path: Path) -> None:
check: bool = True,
) -> subprocess.CompletedProcess[str]:
if arguments and arguments[0] == "push":
raise subprocess.CalledProcessError(
1,
return subprocess.CompletedProcess(
["git", *arguments],
stderr="simulated push failure",
1,
"",
"simulated push failure",
)
return super()._run(arguments, cwd=cwd, check=check)
with pytest.raises(subprocess.CalledProcessError, match="git"):
FailingPushChannel(work).publish(ready_result())
channel = FailingPushChannel(work)
before = channel.remote_head()
with pytest.raises(ResultChannelError, match="simulated push failure"):
channel.publish(ready_result())
assert channel.remote_head() == before
assert _git(["worktree", "list", "--porcelain"], work).count("worktree ") == 1
def test_rx_watcher_publishes_ready_only_after_local_task_approval(
def test_rx_watcher_publishes_ready_after_local_task_approval(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
@@ -453,8 +566,8 @@ Fake task used only by the local coordination test.
assert publish_result(arguments) == 0
output = capsys.readouterr().out
assert "RX RESULT PUBLISH = PUBLISHED" in output
assert "RX RESULT REMOTE HEAD" in output
assert "RF TX = 0" in output
channel = GitResultChannel(work)
published, _digest, _head = channel.read()
published, _digest, _head = GitResultChannel(work).read()
assert published == ready_result(source_head=source_head)

View File

@@ -0,0 +1,104 @@
param(
[Parameter(Mandatory = $true)][string]$Executable,
[Parameter(Mandatory = $true)][string]$ArgumentsJsonPath,
[Parameter(Mandatory = $true)][string]$CapturePath,
[Parameter(Mandatory = $true)][string]$StdoutPath,
[Parameter(Mandatory = $true)][string]$StderrPath,
[Parameter(Mandatory = $true)][string]$StartedStatusPath,
[Parameter(Mandatory = $true)][string]$ResultStatusPath
)
$ErrorActionPreference = 'Stop'
function Write-JsonAtomically {
param(
[Parameter(Mandatory = $true)][object]$Value,
[Parameter(Mandatory = $true)][string]$Path
)
$temporaryPath = "$Path.tmp"
$json = $Value | ConvertTo-Json -Depth 8
$encoding = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($temporaryPath, $json + [Environment]::NewLine, $encoding)
Move-Item -LiteralPath $temporaryPath -Destination $Path -Force
}
$decodedArguments = Get-Content -Raw -LiteralPath $ArgumentsJsonPath |
ConvertFrom-Json
if ($decodedArguments -isnot [System.Array]) {
throw 'ArgumentsJsonPath must contain a JSON array.'
}
$childArguments = @(
foreach ($argument in $decodedArguments) {
[string]$argument
}
)
$startedAt = [DateTimeOffset]::UtcNow
$finishedAt = $null
$childProcess = $null
$exitCode = $null
$runnerError = $null
$captureSize = 0
$sha256 = $null
try {
$startParameters = @{
FilePath = $Executable
ArgumentList = $childArguments
RedirectStandardOutput = $StdoutPath
RedirectStandardError = $StderrPath
PassThru = $true
WindowStyle = 'Hidden'
}
$childProcess = Start-Process @startParameters
# PowerShell 5 can lose ExitCode for redirected processes unless the
# underlying process handle is opened before WaitForExit().
$null = $childProcess.Handle
Write-JsonAtomically -Path $StartedStatusPath -Value ([ordered]@{
state = 'RUNNING'
pid = $childProcess.Id
started_at = $startedAt.ToString('o')
capture_path = $CapturePath
})
$childProcess.WaitForExit()
$childProcess.Refresh()
$exitCode = [int]$childProcess.ExitCode
} catch {
$runnerError = $_.Exception.ToString()
} finally {
$finishedAt = [DateTimeOffset]::UtcNow
if (Test-Path -LiteralPath $CapturePath) {
try {
$captureSize = [int64](Get-Item -LiteralPath $CapturePath).Length
$sha256 = (
Get-FileHash -LiteralPath $CapturePath -Algorithm SHA256
).Hash.ToLowerInvariant()
} catch {
$additionalError = $_.Exception.ToString()
if ([string]::IsNullOrWhiteSpace($runnerError)) {
$runnerError = $additionalError
} else {
$runnerError = $runnerError + [Environment]::NewLine + $additionalError
}
}
}
Write-JsonAtomically -Path $ResultStatusPath -Value ([ordered]@{
pid = if ($null -ne $childProcess) { $childProcess.Id } else { $null }
started_at = $startedAt.ToString('o')
finished_at = $finishedAt.ToString('o')
duration = ($finishedAt - $startedAt).TotalSeconds
exit_code = $exitCode
capture_path = $CapturePath
capture_size = $captureSize
sha256 = $sha256
runner_error = $runnerError
})
}
if ($null -eq $exitCode) {
exit 255
}
exit $exitCode

View File

@@ -14,8 +14,9 @@ from typing import Any, Mapping
SCHEMA_VERSION = 1
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_002"
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_002"
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_003"
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_003"
CHANNEL_TEST_TASK_ID = "RF_HANDOFF_CHANNEL_TEST_001"
READY_STATE = "RX_READY_FOR_TX"
COMPLETE_STATE = "CAPTURE_COMPLETE"
RESULT_JSON_PATH = "docs/handoff/results/RX_RESULT.json"
@@ -72,6 +73,7 @@ class ReadyEvaluation:
class PublishOutcome:
status: str
commit: str
remote_head: str
json_path: str = RESULT_JSON_PATH
markdown_path: str = RESULT_MARKDOWN_PATH
@@ -172,9 +174,13 @@ def _validate_ready(result: RxHandoffResult) -> None:
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")
raise ValueError("READY requires a live process and growing capture")
if result.task_id == CHANNEL_TEST_TASK_ID:
if result.common_clock_verified:
raise ValueError("channel smoke READY must not claim hardware clock proof")
elif not result.common_clock_verified:
raise ValueError("production READY requires verified common clock")
if result.capture_pid is None or result.capture_pid <= 0:
raise ValueError("READY requires a positive capture_pid")
final_fields = (
@@ -289,15 +295,22 @@ def evaluate_ready(
*,
now: datetime,
expected_task_id: str = EXPECTED_RX_TASK_ID,
expected_source_head: str | None = None,
max_age_seconds: float = 30.0,
min_remaining_seconds: float = 10.0,
) -> ReadyEvaluation:
validate_result(result)
if now.tzinfo is None:
raise ValueError("now must contain a timezone")
now_utc = now.astimezone(timezone.utc)
if result.task_id != expected_task_id:
return ReadyEvaluation(False, "WRONG_TASK_ID")
if expected_source_head is not None and result.source_head != expected_source_head:
return ReadyEvaluation(False, "WRONG_SOURCE_HEAD")
if result.state != READY_STATE or not result.rx_ready_for_tx:
return ReadyEvaluation(False, "MISSING_READY")
if not result.common_clock_verified:
return ReadyEvaluation(False, "COMMON_CLOCK_NOT_VERIFIED")
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")
@@ -316,7 +329,7 @@ def evaluate_ready(
class GitResultChannel:
"""Read and publish the two allowlisted result files on an existing branch."""
"""Publish through a temporary worktree of the configured main repository."""
def __init__(
self,
@@ -346,59 +359,89 @@ class GitResultChannel:
check=check,
)
def branch_exists(self) -> bool:
def remote_head(self) -> str | None:
result = self._run(
["ls-remote", "--exit-code", "--heads", self.remote, self.branch],
["ls-remote", "--heads", self.remote, self.branch],
check=False,
)
return result.returncode == 0 and bool(result.stdout.strip())
if result.returncode != 0:
raise ResultChannelError(
f"git ls-remote failed for {self.remote}/{self.branch}: "
f"{result.stderr.strip()}"
)
lines = [line for line in result.stdout.splitlines() if line.strip()]
if not lines:
return None
if len(lines) != 1:
raise ResultChannelError("remote result branch lookup is ambiguous")
head, reference = lines[0].split(maxsplit=1)
if reference != f"refs/heads/{self.branch}" or not _HEAD_RE.fullmatch(head):
raise ResultChannelError("remote result branch returned an invalid HEAD")
return head
def _require_branch(self) -> None:
if not self.branch_exists():
def branch_exists(self) -> bool:
return self.remote_head() is not None
def fetch(self) -> str:
initial_fetch = self._run(
["fetch", "--quiet", self.remote, self.branch],
check=False,
)
remote_head = self.remote_head()
if remote_head is None:
raise ResultBranchMissing(
f"remote branch {self.remote}/{self.branch} does not exist; "
"separate authorization is required before creating it"
f"remote branch {self.remote}/{self.branch} does not exist"
)
def fetch(self) -> None:
self._require_branch()
self._run(
[
"fetch",
"--quiet",
self.remote,
f"refs/heads/{self.branch}:refs/remotes/{self.remote}/{self.branch}",
]
if initial_fetch.returncode != 0:
raise ResultChannelError(
f"git fetch failed for {self.remote}/{self.branch}: "
f"{initial_fetch.stderr.strip()}"
)
tracking_reference = (
f"refs/heads/{self.branch}:"
f"refs/remotes/{self.remote}/{self.branch}"
)
tracking_fetch = self._run(
["fetch", "--quiet", self.remote, tracking_reference],
check=False,
)
if tracking_fetch.returncode != 0:
raise ResultChannelError(
f"tracking fetch failed for {self.remote}/{self.branch}: "
f"{tracking_fetch.stderr.strip()}"
)
tracking_head = self._run(
["rev-parse", f"refs/remotes/{self.remote}/{self.branch}"]
).stdout.strip()
if tracking_head != remote_head:
raise ResultChannelError("remote-tracking result branch is stale")
return remote_head
def read(self) -> tuple[RxHandoffResult, str, str]:
self.fetch()
remote_head = 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
observed_head = self._run(["rev-parse", reference]).stdout.strip()
if observed_head != remote_head:
raise ResultChannelError("result commit differs from remote HEAD")
return result, result_digest(result), observed_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"
remote_before = self.fetch()
tracking_reference = f"refs/remotes/{self.remote}/{self.branch}"
with tempfile.TemporaryDirectory(prefix="rx-result-worktree-") as directory:
checkout = Path(directory) / "rx-node"
worktree_added = False
try:
self._run(
[
"clone",
"--quiet",
"--single-branch",
"--branch",
self.branch,
remote_url,
str(checkout),
]
["worktree", "add", "--detach", str(checkout), tracking_reference]
)
worktree_added = True
json_path = checkout / RESULT_JSON_PATH
markdown_path = checkout / RESULT_MARKDOWN_PATH
json_path.parent.mkdir(parents=True, exist_ok=True)
@@ -409,7 +452,11 @@ class GitResultChannel:
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")
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", "--"],
@@ -421,24 +468,54 @@ class GitResultChannel:
if line.strip()
}
if not staged:
commit = self._run(["rev-parse", "HEAD"], cwd=checkout).stdout.strip()
return PublishOutcome("UNCHANGED", commit)
return PublishOutcome("UNCHANGED", remote_before, remote_before)
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}"],
[
"-c",
"user.name=RX Node",
"-c",
"user.email=rx-node@local.invalid",
"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}"],
push = self._run(
[
"push",
"--quiet",
self.remote,
f"HEAD:refs/heads/{self.branch}",
],
cwd=checkout,
check=False,
)
return PublishOutcome("PUBLISHED", commit)
if push.returncode != 0:
raise ResultChannelError(
f"result push failed: {push.stderr.strip()}"
)
remote_after = self.remote_head()
if remote_after is None or remote_after != commit:
raise ResultChannelError(
"remote result HEAD does not confirm the published commit"
)
return PublishOutcome("PUBLISHED", commit, remote_after)
finally:
if worktree_added:
removal = self._run(
["worktree", "remove", "--force", str(checkout)],
check=False,
)
if removal.returncode != 0:
raise ResultChannelError(
f"temporary worktree cleanup failed: "
f"{removal.stderr.strip()}"
)
self._run(["worktree", "prune"], check=False)

154
tools/rf_handoff_smoke.py Normal file
View File

@@ -0,0 +1,154 @@
"""Publish a model-free READY/COMPLETE smoke sequence to the RX result branch."""
from __future__ import annotations
import argparse
from dataclasses import replace
from datetime import datetime, timedelta, timezone
import hashlib
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import time
from typing import Callable
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
CHANNEL_TEST_TASK_ID,
COMPLETE_STATE,
READY_STATE,
GitResultChannel,
PublishOutcome,
RxHandoffResult,
)
def _git_head(repository: Path) -> str:
return subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=repository,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=True,
).stdout.strip()
def run_smoke(
repository: Path,
*,
remote: str = "origin",
branch: str = "rx-node",
source_head: str | None = None,
hold_seconds: float = 7.0,
sleep_function: Callable[[float], None] = time.sleep,
) -> tuple[PublishOutcome, PublishOutcome, RxHandoffResult]:
"""Publish fake READY then COMPLETE; never access SDR or open a TX gate."""
repository = repository.resolve()
observed_source_head = source_head or _git_head(repository)
channel = GitResultChannel(repository, remote=remote, branch=branch)
channel.fetch()
with tempfile.TemporaryDirectory(prefix="rf-handoff-smoke-") as directory:
fake_capture = Path(directory) / "model_free_fake_capture.txt"
started = datetime.now(timezone.utc)
fake_capture.write_text(
"RF_HANDOFF_CHANNEL_TEST_001\nREADY\nRF TX = 0\n",
encoding="utf-8",
)
published = datetime.now(timezone.utc)
ready = RxHandoffResult(
schema_version=1,
task_id=CHANNEL_TEST_TASK_ID,
state=READY_STATE,
rx_ready_for_tx=True,
process_alive=True,
file_growing=True,
capture_pid=max(1, os.getpid()),
published_at=published.isoformat(),
capture_started_at=started.isoformat(),
expected_capture_end=(started + timedelta(seconds=120)).isoformat(),
capture_path=str(fake_capture.resolve()),
common_clock_verified=False,
source_head=observed_source_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",
)
ready_outcome = channel.publish(ready)
sleep_function(hold_seconds)
fake_capture.write_text(
"RF_HANDOFF_CHANNEL_TEST_001\nCOMPLETE\nRF TX = 0\n",
encoding="utf-8",
)
finished = datetime.now(timezone.utc)
content = fake_capture.read_bytes()
complete = replace(
ready,
state=COMPLETE_STATE,
rx_ready_for_tx=False,
process_alive=False,
file_growing=False,
published_at=finished.isoformat(),
exit_code=0,
file_size=len(content),
duration_seconds=(finished - started).total_seconds(),
overruns=0,
usb_errors=0,
file_errors=0,
sha256=hashlib.sha256(content).hexdigest(),
)
complete_outcome = channel.publish(complete)
return ready_outcome, complete_outcome, complete
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("--source-head")
parser.add_argument("--hold-seconds", type=float, default=7.0)
return parser
def main() -> None:
arguments = build_parser().parse_args()
if arguments.hold_seconds < 0:
raise SystemExit("--hold-seconds cannot be negative")
ready, complete, result = run_smoke(
arguments.repository,
remote=arguments.remote,
branch=arguments.result_branch,
source_head=arguments.source_head,
hold_seconds=arguments.hold_seconds,
)
print("TASK_ID =")
print(CHANNEL_TEST_TASK_ID)
print(f"READY PUBLISH = {ready.status}")
print(f"READY REMOTE HEAD = {ready.remote_head}")
print(f"COMPLETE PUBLISH = {complete.status}")
print(f"COMPLETE REMOTE HEAD = {complete.remote_head}")
print(f"FINAL STATE = {result.state}")
print("REAL RX = NOT_RUN")
print("RF TX = 0")
if __name__ == "__main__":
main()

View File

@@ -528,6 +528,7 @@ def publish_result(arguments: argparse.Namespace) -> int:
outcome = channel.publish(result)
print(f"RX RESULT PUBLISH = {outcome.status}")
print(f"RX RESULT HEAD = {outcome.commit}")
print(f"RX RESULT REMOTE HEAD = {outcome.remote_head}")
print("RF TX = 0")
return 0

View File

@@ -8,6 +8,7 @@ from datetime import datetime, timezone
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import time
@@ -19,8 +20,11 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from tools.rf_handoff_channel import ( # noqa: E402
CHANNEL_TEST_TASK_ID,
COMPLETE_STATE,
EXPECTED_TX_TASK_ID,
GitResultChannel,
READY_STATE,
ResultBranchMissing,
ResultChannelError,
RxHandoffResult,
@@ -32,10 +36,11 @@ from tools.rf_handoff_channel import ( # noqa: E402
DEFAULT_STATE_PATH = ROOT / ".local" / "tx_agent_watcher_state.json"
DEFAULT_OUTBOX = ROOT / ".local" / "tx_authorizations"
_HEAD_RE = re.compile(r"^[0-9a-f]{40}$")
class ResultSource(Protocol):
def fetch(self) -> None: ...
def fetch(self) -> str | None: ...
def read(self) -> tuple[RxHandoffResult, str, str]: ...
@@ -58,6 +63,8 @@ class TxWatcherState:
last_seen_digest: str | None = None
last_prompted_digest: str | None = None
last_authorized_digest: str | None = None
channel_test_ready_digest: str | None = None
channel_test_complete_digest: str | None = None
class StaticResultSource:
@@ -98,6 +105,7 @@ class ManualTxExecutor:
"rx_task_id": result.task_id,
"rx_result_digest": digest,
"rx_result_head": result_head,
"source_head": result.source_head,
"capture_started_at": result.capture_started_at,
"expected_capture_end": result.expected_capture_end,
"prepared_at": datetime.now(timezone.utc).isoformat(),
@@ -117,16 +125,13 @@ class ManualTxExecutor:
def load_state(path: Path) -> TxWatcherState:
if not path.exists():
return TxWatcherState()
raw = json.loads(path.read_text(encoding="utf-8"))
raw = json.loads(path.read_text(encoding="utf-8-sig"))
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"),
)
allowed = set(TxWatcherState.__dataclass_fields__)
if any(key not in allowed for key in raw):
raise ValueError("TX watcher state contains an unknown field")
return TxWatcherState(**raw)
def save_state(path: Path, state: TxWatcherState) -> None:
@@ -148,7 +153,7 @@ def process_exists(pid: int | None) -> bool:
return False
try:
os.kill(pid, 0)
except OSError:
except (OSError, SystemError):
return False
return True
@@ -160,7 +165,14 @@ def request_stop(state_path: Path, timeout_s: float) -> bool:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
state = load_state(state_path)
if not state.running or not process_exists(state.pid):
if not state.running:
stop_path.unlink(missing_ok=True)
return True
if not process_exists(state.pid):
state.running = False
state.pid = None
save_state(state_path, state)
stop_path.unlink(missing_ok=True)
return True
time.sleep(0.2)
return False
@@ -206,6 +218,46 @@ def ready_message(result: RxHandoffResult) -> str:
)
def _valid_result_head(result_head: str) -> bool:
return _HEAD_RE.fullmatch(result_head) is not None
def handle_channel_test(
result: RxHandoffResult,
digest: str,
result_head: str,
state: TxWatcherState,
*,
output_function: Callable[[str], None] = print,
) -> tuple[str, TxWatcherState]:
"""Record model-free channel smoke states without opening the RED gate."""
if digest == state.last_seen_digest:
return "DUPLICATE_SUPPRESSED", state
state.last_seen_digest = digest
if result.task_id != CHANNEL_TEST_TASK_ID:
return "WRONG_TASK_ID", state
if not _valid_result_head(result_head):
return "INVALID_RESULT_COMMIT", state
if result.common_clock_verified:
return "CHANNEL_TEST_CLAIMS_HARDWARE", state
if result.state == READY_STATE:
state.channel_test_ready_digest = digest
output_function("CHANNEL TEST READY RECEIVED = YES")
output_function(f"RX RESULT HEAD = {result_head}")
output_function("RF TX = 0")
return "CHANNEL_TEST_READY", state
if result.state == COMPLETE_STATE:
state.channel_test_complete_digest = digest
output_function("CHANNEL TEST COMPLETE RECEIVED = YES")
output_function(f"RX RESULT HEAD = {result_head}")
output_function("RF TX = 0")
if state.channel_test_ready_digest is None:
return "CHANNEL_TEST_COMPLETE_WITHOUT_READY", state
return "CHANNEL_TEST_COMPLETE", state
return "CHANNEL_TEST_INVALID_STATE", state
def handle_result(
result: RxHandoffResult,
digest: str,
@@ -218,17 +270,30 @@ def handle_result(
input_function: Callable[[str], str],
output_function: Callable[[str], None] = print,
dry_run: bool = False,
expected_source_head: str | None = None,
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 result.task_id == CHANNEL_TEST_TASK_ID:
return handle_channel_test(
result,
digest,
result_head,
state,
output_function=output_function,
)
if digest == state.last_seen_digest:
return "DUPLICATE_SUPPRESSED", state
if not _valid_result_head(result_head):
state.last_seen_digest = digest
return "INVALID_RESULT_COMMIT", state
evaluation = evaluate_ready(
result,
now=now_function(),
expected_source_head=expected_source_head,
max_age_seconds=max_age_seconds,
min_remaining_seconds=min_remaining_seconds,
)
@@ -246,7 +311,7 @@ def handle_result(
source.fetch()
refreshed, refreshed_digest, refreshed_head = source.read()
if refreshed_digest != digest:
if refreshed_digest != digest or refreshed_head != result_head:
output_function("RX RESULT CHANGED; NEW APPROVAL REQUIRED")
output_function("RF TX = 0")
return "RESULT_CHANGED", state
@@ -254,6 +319,7 @@ def handle_result(
refreshed_evaluation = evaluate_ready(
refreshed,
now=now_function(),
expected_source_head=expected_source_head,
max_age_seconds=max_age_seconds,
min_remaining_seconds=min_remaining_seconds,
)
@@ -274,6 +340,18 @@ def handle_result(
return "TX_AUTHORIZATION_PREPARED", state
def _git_output(repository: Path, 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()
def run_watcher(arguments: argparse.Namespace) -> int:
state_path = arguments.state_file.resolve()
if arguments.request_stop:
@@ -287,6 +365,7 @@ def run_watcher(arguments: argparse.Namespace) -> int:
if arguments.approval is not None and not arguments.dry_run:
raise ValueError("--approval is allowed only with --dry-run")
repository = arguments.repository.resolve()
state = load_state(state_path)
if state.running and process_exists(state.pid):
raise RuntimeError(f"TX watcher already running with PID {state.pid}")
@@ -298,12 +377,24 @@ def run_watcher(arguments: argparse.Namespace) -> int:
if arguments.result_file is not None:
source: ResultSource = StaticResultSource(load_result(arguments.result_file))
expected_source_head = (
arguments.expected_source_head or source.result.source_head
)
else:
branch = _git_output(repository, ["branch", "--show-current"])
if branch != arguments.source_branch:
raise ValueError("TX watcher must run on the configured source branch")
expected_source_head = (
arguments.expected_source_head
or _git_output(repository, ["rev-parse", "HEAD"])
)
source = GitResultChannel(
arguments.repository.resolve(),
repository,
remote=arguments.remote,
branch=arguments.result_branch,
)
if not _HEAD_RE.fullmatch(expected_source_head):
raise ValueError("expected source HEAD must be a full lowercase Git hash")
executor = ManualTxExecutor(arguments.outbox.resolve())
def input_function(prompt: str) -> str:
@@ -316,7 +407,6 @@ def run_watcher(arguments: argparse.Namespace) -> int:
try:
while not stop_path.exists():
try:
source.fetch()
result, digest, result_head = source.read()
status, state = handle_result(
result,
@@ -328,6 +418,7 @@ def run_watcher(arguments: argparse.Namespace) -> int:
now_function=lambda: datetime.now(timezone.utc),
input_function=input_function,
dry_run=arguments.dry_run,
expected_source_head=expected_source_head,
max_age_seconds=arguments.max_age,
min_remaining_seconds=arguments.min_remaining,
)
@@ -366,7 +457,9 @@ 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("--source-branch", default="video-development")
parser.add_argument("--result-branch", default="rx-node")
parser.add_argument("--expected-source-head")
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)