Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ec615a656 | ||
|
|
0fdfe195e7 | ||
|
|
71d2d63a36 | ||
|
|
0873b521e3 | ||
|
|
f7e6c5cff5 | ||
|
|
fc46dbea52 | ||
|
|
d8ede8f3f8 | ||
|
|
804c2f62df | ||
|
|
e5d10f18ae | ||
|
|
3ed58d48a9 | ||
|
|
6c997b6af4 | ||
|
|
9b5befb137 | ||
|
|
6899d859da | ||
|
|
8ef70448c3 | ||
|
|
55f268f978 | ||
|
|
320026a4a3 | ||
|
|
0e88b6bc08 | ||
|
|
004e5b3118 | ||
|
|
db15ae3650 | ||
|
|
d8ce21b76d | ||
|
|
d4d43dbf67 | ||
|
|
8a6c950140 | ||
|
|
d67fc46581 | ||
|
|
84b8d6b215 | ||
|
|
7f5d57236a | ||
|
|
bda07c68ee | ||
|
|
c5be22be09 | ||
|
|
4d6fec3d17 | ||
|
|
15dbbc562a | ||
|
|
6eb5e06119 | ||
|
|
78e4750a77 | ||
|
|
c3948bae98 | ||
|
|
b8184a0f01 | ||
|
|
469b91f217 | ||
|
|
bb960b1656 | ||
|
|
38c76a0c29 | ||
|
|
3186566514 | ||
|
|
24d14ff8e1 | ||
|
|
6d0d455033 | ||
|
|
b0a682b61f | ||
|
|
ab18540d7e | ||
|
|
f489467e1b | ||
|
|
64172d8675 | ||
|
|
8e75414907 | ||
|
|
7a9d823792 | ||
|
|
98b5a73ed1 | ||
|
|
4a9d8001c1 | ||
|
|
538bbeb821 | ||
|
|
cc3b3d81eb | ||
|
|
eb01156d9f | ||
|
|
290d264f31 | ||
|
|
16c147dbee | ||
|
|
15cba0bb24 | ||
|
|
1283b0bf94 | ||
|
|
1ee35d76c9 | ||
|
|
e8daf78eaa | ||
|
|
19976c310c | ||
|
|
e2430a9154 | ||
|
|
de5445357e | ||
|
|
e94c8e27a8 | ||
|
|
0816c5997d | ||
|
|
b502a1d7e8 | ||
|
|
0cd587dfd3 | ||
|
|
8404cddb1d | ||
|
|
17ec4862e3 | ||
|
|
6b712a9b75 | ||
|
|
31f49671ec | ||
|
|
103a42103b | ||
|
|
615da6bd09 | ||
|
|
693a494884 | ||
|
|
ee653c6a4c | ||
|
|
171f3fb250 | ||
|
|
9731181a6b | ||
|
|
544c19719e | ||
|
|
d571658b5a | ||
|
|
50c997beab | ||
|
|
9e6215e605 | ||
|
|
6429c5bb49 | ||
|
|
ea34f2d5ab | ||
|
|
0eff96a335 | ||
|
|
de68444e50 | ||
|
|
d58e88da5c | ||
|
|
c730196dba | ||
|
|
c750513fab | ||
|
|
27c4bf8d78 | ||
|
|
5b8023ca2a | ||
|
|
57eaa8f383 | ||
|
|
567fd39937 | ||
|
|
817a97abd9 | ||
|
|
1e6cb4d1ba | ||
|
|
4e11aa4268 | ||
|
|
1703c25367 | ||
|
|
7cc47de6de | ||
|
|
e93315d156 | ||
|
|
be49fb32d1 | ||
|
|
c41ed97373 | ||
|
|
558401844e | ||
|
|
26e4929b10 | ||
|
|
1462262476 | ||
|
|
0bf47265c8 | ||
|
|
02a9dc4a31 | ||
|
|
0f3aeff58b | ||
|
|
ff2abcd502 | ||
|
|
b61af7a526 | ||
|
|
497d14a449 | ||
|
|
2e68b6b054 | ||
|
|
969e39f6a3 | ||
|
|
273267763e | ||
|
|
8973caae77 | ||
|
|
3450a1cb83 | ||
|
|
9cb93e6573 | ||
|
|
4b0d1d24d2 | ||
|
|
114c394741 | ||
|
|
7c68e41a63 | ||
|
|
2ac2d0aefd | ||
|
|
6f44a87894 | ||
|
|
485623dbc4 | ||
|
|
43e7f5f7d2 | ||
|
|
54d2db97b0 | ||
|
|
523a859656 | ||
|
|
9b765852b8 | ||
|
|
f1e63b703e | ||
|
|
6d40f94f18 | ||
|
|
7a869476be | ||
|
|
e5292cce7b | ||
|
|
396ae120dc | ||
|
|
01b57297b8 | ||
|
|
497db5a144 | ||
|
|
9425ccc1f4 | ||
|
|
3ed547f86f | ||
|
|
5bc837c232 | ||
|
|
08bc1a6b62 | ||
|
|
201ca05b56 | ||
|
|
5cd90f2104 | ||
|
|
c8bc454e33 | ||
|
|
b81edb2029 | ||
|
|
87a890dce3 | ||
|
|
a0821be8a5 | ||
|
|
cb137bd37a | ||
|
|
72afb1a55a | ||
|
|
c9095f5af5 | ||
|
|
b5422f0772 | ||
|
|
da36b99184 | ||
|
|
873d0025ad | ||
|
|
a9ce967557 | ||
|
|
eb3a2c681c | ||
|
|
1bc1cdeaf0 | ||
|
|
31565844d7 | ||
|
|
c08b211f0e | ||
|
|
46c5032208 | ||
|
|
92584daeef | ||
|
|
bb5e243c88 | ||
|
|
ac936a8ba9 | ||
|
|
e6c8c5444d | ||
|
|
11270cb06a | ||
|
|
f8a568714b | ||
|
|
c3f81e60ec | ||
|
|
efb99d4682 | ||
|
|
489718ab1b | ||
|
|
9db580ac6d | ||
|
|
80ed978fbf | ||
|
|
3f119e50d8 | ||
|
|
517379313b | ||
|
|
643f9aa9d1 | ||
|
|
61a5082633 | ||
|
|
9121bbd8b0 | ||
|
|
cba9045982 | ||
|
|
0c92f482a7 | ||
|
|
3120e8ae08 | ||
|
|
ea3c20e205 | ||
|
|
a93327d107 | ||
|
|
e08625f629 | ||
|
|
021fef2065 | ||
|
|
3788875410 | ||
|
|
d96b89bd4c | ||
|
|
efe3871916 | ||
|
|
026a1bd919 | ||
|
|
d9bcebb730 | ||
|
|
2b2639783a | ||
|
|
634821efd4 | ||
|
|
c6c18ae041 | ||
|
|
e03ad9aad9 | ||
|
|
89b603024e | ||
|
|
dac6e97e16 | ||
|
|
fe45c6a8dd | ||
|
|
c7b2edd4fc | ||
|
|
96f0e7e263 | ||
|
|
a168cd935e | ||
|
|
a2c4a11b0d | ||
|
|
16164c6f5e | ||
|
|
0722dc2339 | ||
|
|
a7ec1b49d7 | ||
|
|
5b61f8a8bb | ||
|
|
c730719768 | ||
|
|
4fe0f8ee46 | ||
|
|
8999f29041 | ||
|
|
c288f4726d | ||
|
|
308bbb74b7 | ||
|
|
1f01e87453 | ||
|
|
4e8e081f48 | ||
|
|
6c681b97bf | ||
|
|
d01d6eb6d4 | ||
|
|
f90695197d | ||
|
|
279c2b86cb | ||
|
|
57206b7b31 | ||
|
|
b69ffcd44f | ||
|
|
c4b4ebd0af | ||
|
|
4220621cb0 | ||
|
|
4b54451444 | ||
|
|
962e3511e1 | ||
|
|
fb809135c6 | ||
|
|
10acdfe708 | ||
|
|
fd9e1bffb8 | ||
|
|
631426763e | ||
|
|
09cd18ad54 | ||
|
|
da500df487 | ||
|
|
51416aa95e | ||
|
|
b68f31ff0d | ||
|
|
dd41f15571 | ||
|
|
57e4b8672c | ||
|
|
6923128f45 | ||
|
|
9db239fcb4 | ||
|
|
658ec63627 | ||
|
|
427c3ac6bb | ||
|
|
509ec00c87 | ||
|
|
bea523a0e3 | ||
|
|
0a15a1675d | ||
|
|
81c6d1f9c6 | ||
|
|
d53bb9a535 | ||
|
|
023151acb9 | ||
|
|
2da9305d96 | ||
|
|
2e05ea6848 | ||
|
|
be161c2043 | ||
|
|
a6539986b9 | ||
|
|
8da216d155 | ||
|
|
05f2a74c6f | ||
|
|
1c5dc557a6 | ||
|
|
a6bccc1c5d | ||
|
|
f208b5e61c | ||
|
|
aa800acbad | ||
|
|
30be2c96b8 | ||
|
|
ca00be8259 | ||
|
|
50a63e587f | ||
|
|
a54ab559cb | ||
|
|
599c3ab1c2 | ||
|
|
483bfd7944 | ||
|
|
1650b470c6 | ||
|
|
853cf3e0ae | ||
|
|
875bb792f7 | ||
|
|
f06ddb3a20 | ||
|
|
2ebd9da139 | ||
|
|
65001196d8 | ||
|
|
76bb2b97be | ||
|
|
e765f654f9 | ||
|
|
f7141200bd | ||
|
|
53b4830b08 | ||
|
|
fa1b7c4be1 | ||
|
|
246f26663d | ||
|
|
5718a3ea85 | ||
|
|
02802f321f | ||
|
|
4ce121a493 | ||
|
|
8a8d3534c6 | ||
|
|
d0ec37925f | ||
|
|
f9936fa244 | ||
|
|
04a277f22c | ||
|
|
b2777d666f | ||
|
|
0982f7317f | ||
|
|
9583678997 | ||
|
|
5db8b202d9 | ||
|
|
78c0d97a43 | ||
|
|
551530c78c | ||
|
|
b669e4d4ed | ||
|
|
2b6a5a6b95 | ||
|
|
e2e7b74a8e | ||
|
|
db4ce7f6a6 | ||
|
|
505209b839 | ||
|
|
f7256aa29e | ||
|
|
26e3765319 | ||
|
|
e6451a5afc | ||
|
|
7008743620 | ||
|
|
f545f1ed8d | ||
|
|
73803e6a0d | ||
|
|
ed1696606e | ||
|
|
4944f9e41c | ||
|
|
6722066c79 | ||
|
|
df8262b9ed | ||
|
|
756ebe6201 | ||
|
|
f64ee95aad | ||
|
|
c3a0a48ca5 | ||
|
|
b4ed258121 | ||
|
|
bacea2d472 | ||
|
|
9acfc0953e | ||
|
|
818aa49043 | ||
|
|
212239ca7b | ||
|
|
ae6d4a0d98 | ||
|
|
00013f8c0b | ||
|
|
ba2966e631 | ||
|
|
20fcee3f40 | ||
|
|
493d9c2800 | ||
|
|
6b5d8ad023 | ||
|
|
f3ef4672f3 | ||
|
|
be6bc17537 | ||
|
|
40b3731928 | ||
|
|
caa77f8a35 | ||
|
|
4d215ea666 | ||
|
|
fda9b6dda0 | ||
|
|
116d1ffbeb | ||
|
|
040988cf00 | ||
|
|
ffed816465 | ||
|
|
72ab7dccf7 | ||
|
|
5d20dc7f51 | ||
|
|
9e903dacaf | ||
|
|
ed1b41abd5 | ||
|
|
ff1b950f11 | ||
|
|
3afc9ff872 | ||
|
|
28e9c8c1c1 | ||
|
|
8249854881 | ||
|
|
4aeef171d2 | ||
|
|
2ccac89ec4 | ||
|
|
232986f0c3 | ||
|
|
725c779ac7 | ||
|
|
edd6d06300 | ||
|
|
98cb3a98a5 | ||
|
|
7881e78e7f | ||
|
|
57a689d22f | ||
|
|
45869a77cd | ||
|
|
224fbfa4e9 | ||
|
|
417e824a51 | ||
|
|
ca2807e9b5 | ||
|
|
25f5219982 | ||
|
|
2d09bee39e | ||
|
|
77bc2161a6 | ||
|
|
74e18402f0 | ||
|
|
39ec00b361 | ||
|
|
95946b6efd | ||
|
|
72d3c909c8 | ||
|
|
ab2dfe7b09 | ||
|
|
1326713234 | ||
|
|
867321ed05 | ||
|
|
a44ced163e | ||
|
|
f40f7e7233 | ||
|
|
8bef4682f5 | ||
|
|
f939d9db5a | ||
|
|
dcead3fecc | ||
|
|
87654cbfd1 | ||
|
|
6dddb0a5ee | ||
|
|
f90725dba8 | ||
|
|
24b7dda24f | ||
|
|
c7c6b2d5ac | ||
|
|
6a761f2d87 | ||
|
|
9ef267f2e3 | ||
|
|
bd80fb0e3b | ||
|
|
759a836726 | ||
|
|
71f051d928 | ||
|
|
2b1d0ac245 | ||
|
|
fbb5018413 | ||
|
|
c8f0a8e353 | ||
|
|
fb7fb34ca5 | ||
|
|
abe24da81d | ||
|
|
15c54539b5 | ||
|
|
ad35a2f7f5 | ||
|
|
e2f536f71a | ||
|
|
5b2712f5e6 | ||
|
|
124b77e95b | ||
|
|
1b7995e093 | ||
|
|
fcd835883e | ||
|
|
37ce68eff8 | ||
|
|
2fa0a5f144 | ||
|
|
c3ef29c59d | ||
|
|
f079aa2cc9 | ||
|
|
c03c953a45 | ||
|
|
90ae1c2612 | ||
|
|
cb1b8303fe | ||
|
|
3c54782634 | ||
|
|
478ce45755 | ||
|
|
a96ed90b90 | ||
|
|
713f85a0e3 | ||
|
|
504935828b | ||
|
|
8e78bc936f | ||
|
|
a34762d512 | ||
|
|
772402ed39 | ||
|
|
80a1fd0a2d | ||
|
|
3fb28e2461 | ||
|
|
94ba2c97a3 | ||
|
|
94a4774853 | ||
|
|
efb2c6b9a1 | ||
|
|
99115889d7 | ||
|
|
2a971578e1 | ||
|
|
ec434f2a70 | ||
|
|
d9c50c66c9 | ||
|
|
a382c15562 | ||
|
|
186fdd1410 | ||
|
|
aa9750c59f | ||
|
|
ab114d260b | ||
|
|
b7865a76bb | ||
|
|
04b0f4ac96 | ||
|
|
9b328c9af1 | ||
|
|
bc0f244934 | ||
|
|
29570f404a | ||
|
|
927548e9a0 | ||
|
|
781b286f5b | ||
|
|
d1ccd7ebec | ||
|
|
3adfa1b1ed | ||
|
|
da4eece9e0 | ||
|
|
2e1f0c8cab | ||
|
|
6e2f5950bd | ||
|
|
c53c82bb35 | ||
|
|
35adcd8cb1 | ||
|
|
810d0027c3 | ||
|
|
4839a62b70 | ||
|
|
15408841ac | ||
|
|
c5c69de06f | ||
|
|
fcde0b2ce3 | ||
|
|
1f96055b69 | ||
|
|
923a21ba0b | ||
|
|
6348386869 | ||
|
|
55002ad838 | ||
|
|
6684289a8f | ||
|
|
e25ad0f964 | ||
|
|
c9dee14c62 | ||
|
|
fe9d154778 | ||
|
|
2a4f9c8367 | ||
|
|
7ddfa7eee7 | ||
|
|
986b450bc2 | ||
|
|
b51afea0cb | ||
|
|
c15840514f | ||
|
|
2833175329 | ||
|
|
4a06d9f11a | ||
|
|
c707a81933 | ||
|
|
37deb6085c | ||
|
|
affe852ff7 | ||
|
|
e1a71efeb9 | ||
|
|
5bd8cca941 | ||
|
|
fd5f31bba2 | ||
|
|
8a2a4f7b71 | ||
|
|
b2d6802dba | ||
|
|
3ebbf9a9b1 | ||
|
|
193d81961d | ||
|
|
94ad81245b | ||
|
|
196cf193df | ||
|
|
f95b2a63df | ||
|
|
708871e511 | ||
|
|
18ef9a53e3 | ||
|
|
05edd68027 | ||
|
|
d166e83d55 | ||
|
|
98b4c58d36 | ||
|
|
f5c8b07d15 | ||
|
|
292c72ec3d | ||
|
|
0a436b2f97 | ||
|
|
2ba4114fce | ||
|
|
eccb446a3c | ||
|
|
5e6a397e7c | ||
|
|
87fad6ff8b | ||
|
|
87252705a8 | ||
|
|
4423367ddb | ||
|
|
f85c654d9d | ||
|
|
7417a8ca02 | ||
|
|
ecfa80a7b3 | ||
|
|
2a106e39a3 | ||
|
|
54cee39b95 | ||
|
|
c27d13d9ac | ||
|
|
dd5030407e | ||
|
|
ba0ec0ae31 | ||
|
|
4d410319ce | ||
|
|
09ec2b81ba | ||
|
|
577695a1a3 | ||
|
|
2fad565016 | ||
|
|
6b805eea4a | ||
|
|
4ecd99023f | ||
|
|
d3410671fd | ||
|
|
a4c099a9ca | ||
|
|
09ba25473a | ||
|
|
b7a71e69f1 | ||
|
|
a30464df1a | ||
|
|
0f80334be1 | ||
|
|
c09a42f10a | ||
|
|
13704bd315 | ||
|
|
56f96f0d60 | ||
|
|
e86da59cdb | ||
|
|
143e6095ed | ||
|
|
fe4bc43a83 | ||
|
|
83426c0e1c | ||
|
|
b5c9a3c04e | ||
|
|
cb10db1e78 | ||
|
|
1d466a6926 | ||
|
|
fa51b96ca7 | ||
|
|
5796caa30a | ||
|
|
5169e02610 | ||
|
|
cceb607d99 | ||
|
|
c692013441 | ||
|
|
11ebc50a55 | ||
|
|
08c782fe91 | ||
|
|
4bcae0bb10 | ||
|
|
a8d1b98332 | ||
|
|
74272842aa | ||
|
|
aa67119d55 | ||
|
|
9681baa318 | ||
|
|
8a6b051149 | ||
|
|
6bb5507d33 | ||
|
|
8692f1dc76 | ||
|
|
78d70def5f | ||
|
|
c9260ace18 | ||
|
|
2a7e9dcfcf | ||
|
|
e3263ef9b1 | ||
|
|
ce328a8080 | ||
|
|
4df1f6f23f | ||
|
|
7484e175be | ||
|
|
d84060cc02 | ||
|
|
b102225d4e | ||
|
|
f86b2d1eec | ||
|
|
307b0bf814 | ||
|
|
d776f286de | ||
|
|
621f88c425 | ||
|
|
ef6811ff58 | ||
|
|
a4f02f227c | ||
|
|
5b315ac7c7 | ||
|
|
90bd2b3285 | ||
|
|
1bb4ea86b8 | ||
|
|
f98416e94a | ||
|
|
b3465176f8 | ||
|
|
aab5159ccc | ||
|
|
80f10b9be7 | ||
|
|
9d75e51b22 | ||
|
|
b6113fad7b | ||
|
|
f765365d03 | ||
|
|
b94a3aef61 | ||
|
|
8ca4628694 | ||
|
|
535c290917 | ||
|
|
6bc31c513e | ||
|
|
386137dc14 | ||
|
|
f87adc0d18 | ||
|
|
ed3a73a0a9 | ||
|
|
600008482d | ||
|
|
4bfa4f9b0a | ||
|
|
1d6213cd74 | ||
|
|
9c3f5f0a1d | ||
|
|
c0594ce192 | ||
|
|
dc0ac7b92e | ||
|
|
5e0a2def9d | ||
|
|
049a8dbd86 | ||
|
|
0a9b0648cd | ||
|
|
da2ec4df0e | ||
|
|
dcf992bb2f | ||
|
|
3dadf2d3f5 | ||
|
|
c8b317de53 | ||
|
|
ce330d3ac0 | ||
|
|
16b90fd8be | ||
|
|
c7a28e1db3 | ||
|
|
2731db03f4 | ||
|
|
eb2f1a3e0e | ||
|
|
e3b459dfdf | ||
|
|
b8d030e6c4 | ||
|
|
20a47fac86 | ||
|
|
36ee604646 | ||
|
|
20f493596d | ||
|
|
faf3575fb3 | ||
|
|
2e16fc4c89 | ||
|
|
12303bbd20 | ||
|
|
de30cd958e | ||
|
|
fd6e9b4bca | ||
|
|
24413e4228 | ||
|
|
407c23a689 | ||
|
|
982a9b07d5 | ||
|
|
ff506ebe55 | ||
|
|
2032819d99 | ||
|
|
e2464ae9d6 |
No files matched your search
@@ -15,6 +15,9 @@
|
||||
# Canary files are omitted: src/rune/canary_*.py are standalone CI
|
||||
# verification scripts executed directly by the canary job, never imported by
|
||||
# the test suite — counting them would make the global floor a fiction.
|
||||
# ffi_query_memory_smoke.py is the same class: a standalone evidence script
|
||||
# driven directly (and by tests/test_ffi_query_memory_smoke.py when the cdylib
|
||||
# is built), never imported by the suite.
|
||||
|
||||
[run]
|
||||
source = src/rune
|
||||
@@ -22,6 +25,7 @@ omit =
|
||||
tests/*
|
||||
scripts/*
|
||||
src/rune/canary_*.py
|
||||
src/rune/ffi_query_memory_smoke.py
|
||||
|
||||
[report]
|
||||
fail_under = 29
|
||||
|
||||
@@ -1,16 +1,46 @@
|
||||
---
|
||||
name: Bug Fix
|
||||
about: Fix a bug or issue
|
||||
about: Demonstrate currently incorrect behaviour — plain English first, proof required
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
## Plain summary
|
||||
<!-- 2-4 sentences, NON-TECHNICAL: what's broken and why it matters. -->
|
||||
<restate the issue title here in everyday words — this heading IS the
|
||||
plain-English summary; no meta-labels like "plain English" in it>
|
||||
<!-- The heading above carries the summary itself — a scan of issue
|
||||
headings alone should tell the whole story of the board. TOP PRIORITY:
|
||||
if a technical detail below contradicts this section, the issue is
|
||||
buggy — fix the issue. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
## Example
|
||||
<!-- A concrete before/after: "Doing X currently produces Y; it should produce Z." -->
|
||||
|
||||
What is broken: <what a person observes going wrong>
|
||||
|
||||
What should happen instead: <the correct behaviour>
|
||||
|
||||
How often / when: <always | after X | since change Y>
|
||||
|
||||
## Proof it is broken (required — no proof, no bug)
|
||||
<!-- A bug is DEMONSTRATED currently incorrect behaviour. Nothing else.
|
||||
If this section cannot be filled, file it as a question, not a bug. -->
|
||||
- [ ] Reproduction command or steps (deterministic where possible)
|
||||
- [ ] The exact wrong output observed: <paste/log excerpt>
|
||||
- [ ] Where the evidence lives: <log path, fixture, session id>
|
||||
|
||||
## Steps to reproduce
|
||||
<!-- How to trigger the bug -->
|
||||
|
||||
## What would let us close this
|
||||
- [ ] The reproduction command now shows correct behaviour
|
||||
- [ ] A regression test exists that fails before the fix and passes after
|
||||
- [ ] No other behaviour changed (or changes are listed and justified)
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
<!-- A bug fix is USUALLY a patch. Pick major or minor only with a reason. -->
|
||||
- [ ] patch — same behaviour, done correctly (usual for bugs)
|
||||
- [ ] major — the bug was in a published contract and fixing it breaks consumers (justify)
|
||||
- [ ] minor — fixing revealed a missing capability that had to be added (justify)
|
||||
|
||||
## Technical details
|
||||
<!-- WHAT (the bug) / WHERE (files) / EVIDENCE (how it was confirmed). -->
|
||||
@@ -29,10 +59,6 @@ assignees: conductor
|
||||
- [ ] Canary passes (name)
|
||||
- [ ] Closed with a properly formatted closing comment + commit references
|
||||
|
||||
## Steps to reproduce
|
||||
<!-- How to trigger the bug -->
|
||||
|
||||
|
||||
## Cognitive invariants (memory/cognition issues — both handoffs)
|
||||
<!-- Complete ONLY if this issue touches memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before creating modules)
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: New feature or improvement — DESCRIPTION GATE v4 format (plain summary + pipeline requirements)
|
||||
about: New feature or improvement — plain English first, verifiable close conditions, version intent
|
||||
title: "[FEATURE] "
|
||||
labels: feature
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
## Plain summary
|
||||
<!-- 2-4 sentences, NON-TECHNICAL: what this does and why it matters. Nick should
|
||||
understand the goal without decoding internals. -->
|
||||
<restate the issue title here in everyday words — this heading IS the
|
||||
plain-English summary; no meta-labels like "plain English" in it>
|
||||
<!-- The heading above carries the summary itself — a scan of issue
|
||||
headings alone should tell the whole story of the board. TOP PRIORITY:
|
||||
if a technical detail below contradicts this section, the issue is
|
||||
buggy — fix the issue. A non-technical reader must be able to retell this
|
||||
section. 3-6 sentences, no jargon. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
|
||||
What is this about: <what>
|
||||
|
||||
Why do we need it: <the itch — what happens today that shouldn't, or what becomes possible>
|
||||
|
||||
Why is it still open: <"not started yet" | "part done, X remains" | "deliberately parked as a future idea">
|
||||
|
||||
## Example
|
||||
<!-- A concrete example of the intended behavior, in plain terms:
|
||||
"If X happens, the system will Y instead of Z." -->
|
||||
<!-- A concrete before/after in plain terms: "If X happens, the system will Y instead of Z." -->
|
||||
|
||||
## What would let us close this
|
||||
<!-- Checkable statements, verifiable by looking at something that EXISTS —
|
||||
a file, a log line, a passing test. Not "when appropriate". -->
|
||||
- [ ] <condition 1 — observable>
|
||||
- [ ] <condition 2>
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
<!-- The pipeline derives the real bump from merged conventional commits and
|
||||
compares against this declaration. A mismatch is flagged, not fatal:
|
||||
either the declaration was wrong or the work grew. Both facts matter. -->
|
||||
- [ ] major — breaking: changes a contract, schema, or interface others depend on
|
||||
- [ ] minor — new capability: something becomes possible that wasn't
|
||||
- [ ] patch — repair: same behaviour, done correctly
|
||||
- [ ] none — touches no released artifact (docs, memory, process notes)
|
||||
|
||||
## Technical details
|
||||
<!-- WHAT — one-line objective.
|
||||
@@ -37,7 +63,6 @@ DEPENDENCIES — parent epic + preceding stage/plan refs. -->
|
||||
- [ ] Canary passes (name)
|
||||
- [ ] Closed with a properly formatted closing comment + commit/PR references
|
||||
|
||||
|
||||
## Cognitive invariants (memory/cognition issues — both handoffs)
|
||||
<!-- Complete ONLY if this issue touches memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before creating modules)
|
||||
@@ -69,4 +94,4 @@ Hard invariants: derived cognition preserves evidentiary ancestry · epistemic c
|
||||
- [ ] **P0 — critical** — breaks the spine / core correctness, or blocks other work
|
||||
- [ ] **P1 — high** — material user value or a real defect (money, correctness bugs, next-stage dependency)
|
||||
- [ ] **P2 — medium** — quality/consolidation improvement (check the fold-into mapping first)
|
||||
- [ ] **P3 — low** — measurement / benchmark / deferred policy
|
||||
- [ ] **P3 — low** — measurement / benchmark / deferred policy
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: Research Submission
|
||||
about: Buy down uncertainty before building — question, falsifier, budget, durable findings
|
||||
title: "[RESEARCH] "
|
||||
labels: research
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
<restate the issue title here in everyday words — this heading IS the
|
||||
plain-English summary; no meta-labels like "plain English" in it>
|
||||
<!-- The heading above carries the summary itself — a scan of issue
|
||||
headings alone should tell the whole story of the board. TOP PRIORITY:
|
||||
if a technical detail below contradicts this section, the issue is
|
||||
buggy — fix the issue. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
|
||||
The question: <what we're trying to learn, one sentence>
|
||||
|
||||
Why we're asking: <what decision this research will inform — a name, an issue
|
||||
number, or "exploratory: none yet">
|
||||
|
||||
What we believe today: <the current assumption, stated so it can be proven wrong>
|
||||
|
||||
## The shape of an answer
|
||||
- [ ] What evidence would settle the question: <observation, benchmark, or spike>
|
||||
- [ ] What evidence would KILL the current assumption (the falsifier): <be specific —
|
||||
this is stated BEFORE starting so the research cannot become a search for confirmation>
|
||||
- [ ] Budget: <time/effort cap — when research must stop and report back>
|
||||
|
||||
## What would let us close this
|
||||
- [ ] Findings written to a durable document: <path, e.g. research/issue-NNN/> —
|
||||
a comment thread is not a durable finding
|
||||
- [ ] The decision the research informs is recorded: made / deferred / changed
|
||||
- [ ] If a follow-up build issue was minted: <#N or "none needed">
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
- [ ] none — research changes no released artifact (usual)
|
||||
- [ ] minor/major — only if a code spike is being merged as part of this (justify)
|
||||
|
||||
## Technical detail
|
||||
<!-- Candidate approaches, prior art in the repo, related issues, constraints,
|
||||
rebuildable-projection or authority requirements if findings touch canonical state. -->
|
||||
|
||||
## Pipeline requirements
|
||||
- [ ] Findings committed under the stated durable path
|
||||
- [ ] Every citation verified against a real source (no fabricated references)
|
||||
- [ ] Closed with a properly formatted closing comment: findings summary + decision
|
||||
|
||||
## Cognitive invariants (memory/cognition research — both handoffs)
|
||||
<!-- Complete ONLY if findings touch memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before proposing modules)
|
||||
- [ ] **Read/write authority** — which canonical state is affected
|
||||
- [ ] **Provenance** — findings traceable to evidence
|
||||
- [ ] **Reversibility** — projections remain rebuildable; no unique truth moves into a derived index
|
||||
- [ ] **No duplicate authority** — why extend vs create
|
||||
|
||||
Hard invariants: derived cognition preserves evidentiary ancestry · retrieval never increases utility · raw evidence is never destroyed by promotion.
|
||||
|
||||
## Related umbrella
|
||||
- [ ] Memory
|
||||
- [ ] Governance
|
||||
- [ ] Identity
|
||||
- [ ] Growth
|
||||
- [ ] Perception
|
||||
- [ ] Infrastructure
|
||||
- [ ] Life (Nick's)
|
||||
- [ ] Custodian
|
||||
- [ ] Orchestration
|
||||
|
||||
## Priority (REQUIRED — the priority gate)
|
||||
<!-- Pick exactly one. An issue without a priority is not filed. -->
|
||||
- [ ] **P0 — critical** — blocks a decision that blocks other work
|
||||
- [ ] **P1 — high** — informs a next-stage dependency
|
||||
- [ ] **P2 — medium** — quality/consolidation understanding
|
||||
- [ ] **P3 — low** — watchlist / deferred / exploratory
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
backup:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
# actions/checkout v4.2.2, pinned to the audited upstream tag commit.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
|
||||
+104
-22
@@ -11,8 +11,8 @@ jobs:
|
||||
# P0/P1/P2/P3. An issue that lands without one gets 'unprioritized' +
|
||||
# a demand comment — filing is not complete until prioritized.
|
||||
priority-gate:
|
||||
if: github.event_name == 'issues' && github.event.action != 'labeled'
|
||||
runs-on: rune-infra-pi
|
||||
if: github.event_name == 'issues' && github.event.action != 'labeled' && github.event.issue.state == 'open'
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- name: Enforce priority label
|
||||
uses: actions/github-script@v7
|
||||
@@ -23,12 +23,13 @@ jobs:
|
||||
const labels = (issue.labels || []).map(l => l.name);
|
||||
const priorities = ['P0 — critical', 'P1 — high', 'P2 — medium', 'P3 — low'];
|
||||
const has = labels.filter(l => priorities.includes(l));
|
||||
if (has.length >= 1) {
|
||||
if (has.length === 1) {
|
||||
// Prioritized — clear the flag if it was set.
|
||||
if (labels.includes('unprioritized')) {
|
||||
await github.rest.issues.removeLabel({
|
||||
await github.request('DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{id}', {
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: issue.number, name: 'unprioritized',
|
||||
issue_number: issue.number,
|
||||
id: issue.labels.find(l => l.name === 'unprioritized').id,
|
||||
}).catch(e => { if (e.status !== 404) throw e; });
|
||||
}
|
||||
return;
|
||||
@@ -44,12 +45,26 @@ jobs:
|
||||
});
|
||||
|
||||
auto-label:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- name: Analyze and label
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// CLOSED-ISSUE GUARD (#407): labels are filing-time metadata.
|
||||
// A closed issue's lifecycle has ended; re-running label
|
||||
// inference on it re-adds stale type labels (bug = currently
|
||||
// incorrect behavior — a closed fixed issue must not carry
|
||||
// it) and makes any human label cleanup self-defeating,
|
||||
// because every PUT fires a 'labeled' event that re-triggers
|
||||
// this job. Post-close label curation belongs to humans and
|
||||
// the conductor, not this workflow.
|
||||
const issueState = context.payload.issue?.state;
|
||||
if (issueState === 'closed') {
|
||||
console.log(`Issue #${context.payload.issue?.number} is closed — auto-label skips (labels are curated post-close).`);
|
||||
return;
|
||||
}
|
||||
const action = context.payload.action;
|
||||
const title = context.payload.issue?.title || context.payload.pull_request?.title || '';
|
||||
const body = context.payload.issue?.body || context.payload.pull_request?.body || '';
|
||||
const text = (title + ' ' + body).toLowerCase();
|
||||
@@ -58,9 +73,44 @@ jobs:
|
||||
const managedLabels = new Set([
|
||||
'memory', 'governance', 'identity', 'growth',
|
||||
'perception', 'infrastructure', 'life', 'custodian',
|
||||
'priority: high', 'bug', 'feature',
|
||||
'bug', 'feature', 'research',
|
||||
'P0 — critical', 'P1 — high', 'P2 — medium', 'P3 — low',
|
||||
]);
|
||||
|
||||
// Type label: DECLARED over GUESSED. The template spine
|
||||
// (kind: bug|feature|research|epic|...) is authoritative.
|
||||
// A recognized kind maps to its label; ANY other declared kind
|
||||
// (e.g. epic) means NO type label and NO keyword guessing —
|
||||
// epics are containers, not bugs. Keyword heuristics apply
|
||||
// only to pre-template issues with no spine at all.
|
||||
let isBug = false, isFeature = false, isResearch = false;
|
||||
const kindMatch = body && body.match(/^kind:\s*([a-z_-]+)\s*$/m);
|
||||
if (kindMatch) {
|
||||
const kind = kindMatch[1];
|
||||
isBug = kind === 'bug';
|
||||
isFeature = kind === 'feature';
|
||||
isResearch = kind === 'research';
|
||||
} else if (action === 'opened') {
|
||||
// KEYWORD FALLBACK (#407): pre-template issues only, and
|
||||
// ONLY at filing time. Re-running keyword guesses on every
|
||||
// edit/label event overrides human curation of spine-less
|
||||
// issues (documented rule: keyword heuristics are the
|
||||
// pre-template fallback, not a standing authority).
|
||||
const textBug = title.startsWith('[BUG]') || text.includes('bug') || text.includes('fix') || text.includes('broken');
|
||||
isBug = textBug;
|
||||
isFeature = !isBug && (title.startsWith('[FEATURE]') || text.includes('feature') || text.includes('improve') || text.includes('add'));
|
||||
isResearch = !isBug && !isFeature && title.startsWith('[RESEARCH]');
|
||||
}
|
||||
if (isBug) {
|
||||
desiredLabels.add('bug');
|
||||
}
|
||||
if (isFeature) {
|
||||
desiredLabels.add('feature');
|
||||
}
|
||||
if (isResearch) {
|
||||
desiredLabels.add('research');
|
||||
}
|
||||
|
||||
// Umbrella labels
|
||||
if (text.includes('memory') || text.includes('store') || text.includes('recall') || text.includes('consolidat')) {
|
||||
desiredLabels.add('memory');
|
||||
@@ -87,19 +137,31 @@ jobs:
|
||||
desiredLabels.add('custodian');
|
||||
}
|
||||
|
||||
// Priority labels
|
||||
if (text.includes('urgent') || text.includes('critical') || text.includes('broken')) {
|
||||
desiredLabels.add('priority: high');
|
||||
}
|
||||
|
||||
// Type labels
|
||||
const isBug = title.startsWith('[BUG]') || text.includes('bug') || text.includes('fix') || text.includes('broken');
|
||||
const isFeature = !isBug && (title.startsWith('[FEATURE]') || text.includes('feature') || text.includes('improve') || text.includes('add'));
|
||||
if (isBug) {
|
||||
desiredLabels.add('bug');
|
||||
}
|
||||
if (isFeature) {
|
||||
desiredLabels.add('feature');
|
||||
// Priority labels (2026-10-05: PROGRAMMATIC P-ASSIGNMENT —
|
||||
// the gate demanded P0-P3 labels but nothing assigned them;
|
||||
// the dead 'priority: high' label is gone per the 2026-09-04
|
||||
// normalization). Assignment from the TITLE prefix first
|
||||
// ([P1]/P1:/priority=...), then keyword inference using the
|
||||
// gate's own rule set (the scale lives in ONE place — the
|
||||
// priority-gate job above).
|
||||
const pMatch = title.match(/\bP([0-3])\b/);
|
||||
const priorityLabelNames = ['P0 — critical', 'P1 — high', 'P2 — medium', 'P3 — low'];
|
||||
if (pMatch) {
|
||||
desiredLabels.add(priorityLabelNames[Number(pMatch[1])]);
|
||||
} else if (text.includes('urgent') || text.includes('critical') ||
|
||||
text.includes('spine') || text.includes('blocks other work')) {
|
||||
desiredLabels.add(priorityLabelNames[0]); // P0 — critical
|
||||
} else if (text.includes('broken') || text.includes('fix') ||
|
||||
text.includes('repair') || text.includes('defect') ||
|
||||
text.includes('real defect') || text.includes('money')) {
|
||||
desiredLabels.add(priorityLabelNames[1]); // P1 — high
|
||||
} else if (text.includes('improve') || text.includes('consolidat') ||
|
||||
text.includes('refactor') || text.includes('kernel') ||
|
||||
text.includes('port')) {
|
||||
desiredLabels.add(priorityLabelNames[2]); // P2 — medium
|
||||
} else if (text.includes('benchmark') || text.includes('measurement') ||
|
||||
text.includes('deferred')) {
|
||||
desiredLabels.add(priorityLabelNames[3]); // P3 — low
|
||||
}
|
||||
|
||||
const issueNumber = context.payload.issue?.number || context.payload.pull_request?.number;
|
||||
@@ -109,6 +171,7 @@ jobs:
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
const currentLabels = new Set(current.data.map(label => label.name));
|
||||
const currentLabelIds = new Map(current.data.map(label => [label.name, label.id]));
|
||||
const staleManaged = [...currentLabels].filter(
|
||||
label => managedLabels.has(label) && !desiredLabels.has(label)
|
||||
);
|
||||
@@ -118,11 +181,14 @@ jobs:
|
||||
|
||||
for (const name of staleManaged) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
// Gitea's DELETE /labels/{id} requires the numeric label ID.
|
||||
// Octokit's GitHub-style removeLabel(name) returned 404 and
|
||||
// left stale priority labels behind (#2467: P0 AND P2).
|
||||
await github.request('DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{id}', {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
name,
|
||||
id: currentLabelIds.get(name),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
@@ -140,5 +206,21 @@ jobs:
|
||||
labels: missingDesired,
|
||||
});
|
||||
}
|
||||
// The priority gate can flag a duplicate before this job
|
||||
// reconciles it. Clear the flag only AFTER verifying exactly one
|
||||
// surviving P-label against Gitea's current state.
|
||||
const reconciled = await github.rest.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
const actual = reconciled.data;
|
||||
const activePriority = actual.filter(l => priorityLabelNames.includes(l.name));
|
||||
const flag = actual.find(l => l.name === 'unprioritized');
|
||||
if (activePriority.length === 1 && flag) {
|
||||
await github.request('DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{id}', {
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: issueNumber, id: flag.id,
|
||||
});
|
||||
}
|
||||
console.log(`Removed managed labels: ${staleManaged.join(', ') || 'none'}`);
|
||||
console.log(`Added managed labels: ${missingDesired.join(', ') || 'none'}`);
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
update-gitea:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- name: Check current Gitea version
|
||||
id: current
|
||||
|
||||
@@ -11,7 +11,7 @@ permissions: write-all
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -131,8 +131,12 @@ jobs:
|
||||
"pyproject.toml": [(f'version = "{VERSION}"', f'version = "{NEW}"')],
|
||||
}
|
||||
|
||||
# Fetch current master content, apply replacements, VERIFY all five
|
||||
# surfaces agree on NEW before committing anything (TUI gate).
|
||||
# Fetch current master content, apply replacements, VERIFY all six
|
||||
# surface patterns (five files + the TUI footer) agree on NEW before
|
||||
# committing anything (TUI gate). The footer check was added after
|
||||
# the 0.89.0 half-bump: a stale RELEASE_VERSION made both replace
|
||||
# pairs no-op; the gate's RECOVER regex fixed the header only and
|
||||
# the footer silently stayed 0.88.4, failing the banner test.
|
||||
edited = {}
|
||||
for path, pairs in surfaces.items():
|
||||
status, cur = api(f"/contents/{path}?ref=master")
|
||||
@@ -151,6 +155,7 @@ jobs:
|
||||
checks = [
|
||||
("src/rune/__init__.py", rf'__version__ = "({VER})"'),
|
||||
("src/rune/rune_tui.py", rf"Version: v({VER})"),
|
||||
("src/rune/rune_tui.py", rf"Rune v({VER}) \|"),
|
||||
("src/rune/canary_gitea_automation.py", rf"'__version__ = \"({VER})\"'"),
|
||||
("pyproject.toml", rf'version = "({VER})"'),
|
||||
("README.md", rf"v({VER})"),
|
||||
@@ -213,38 +218,17 @@ jobs:
|
||||
env.write(f"RELEASE_VERSION={NEW}\n")
|
||||
env.write("AUTO_BUMPED=true\n")
|
||||
PYBUMP
|
||||
- name: Create release from merged pull request
|
||||
- name: Create evidence-derived release (SCAFFOLD-TRANCHE-009B)
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
PR_TITLE: ${{ gitea.event.pull_request.title }}
|
||||
PR_BODY: ${{ gitea.event.pull_request.body }}
|
||||
RELEASE_API_URL: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases
|
||||
LIST_API_URL: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases?limit=50
|
||||
RELEASE_SERVER_URL: ${{ gitea.server_url }}
|
||||
RELEASE_REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
# Skip idempotently if a release for this tag already exists (retry/replay safety).
|
||||
if curl --fail --silent "$LIST_API_URL" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
| python3 -c 'import json,sys,os; v=os.environ["RELEASE_VERSION"]; exists=[r for r in json.load(sys.stdin) if r.get("tag_name")=="v"+v]; sys.exit(0 if exists else 1)'; then
|
||||
echo "Release v$RELEASE_VERSION already exists — skipping creation"
|
||||
exit 0
|
||||
fi
|
||||
python3 - <<'PY' > /tmp/rune-release.json
|
||||
import json, os
|
||||
version = os.environ["RELEASE_VERSION"]
|
||||
body = f"## {os.environ['PR_TITLE']}\n\n{os.environ.get('PR_BODY', '')}"
|
||||
print(json.dumps({
|
||||
"tag_name": f"v{version}",
|
||||
"target_commitish": "master",
|
||||
"name": f"Rune v{version}",
|
||||
"body": body,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}))
|
||||
PY
|
||||
curl --fail-with-body --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/rune-release.json \
|
||||
"$RELEASE_API_URL"
|
||||
rm -f /tmp/rune-release.json
|
||||
# ONE note-generation authority: the tested release_notes module.
|
||||
# The old PR-title/body paste path (which produced empty '##'
|
||||
# bodies on direct-push releases) is REMOVED — governed evidence
|
||||
# from the scaffold store is the source, not PR prose.
|
||||
python3 scripts/release_history_cli.py init \
|
||||
--version "$RELEASE_VERSION" \
|
||||
--server "$RELEASE_SERVER_URL" --repo "$RELEASE_REPO" \
|
||||
--store store/scaffold/current.json
|
||||
+163
-8
@@ -67,6 +67,20 @@ jobs:
|
||||
unit-tests:
|
||||
runs-on: rune-ci-desktop
|
||||
if: ${{ !startsWith(gitea.event.pull_request.head.ref, 'chore/auto-bump') }}
|
||||
env:
|
||||
RUNE_HOME: /tmp/rune-ci-${{ github.run_id }}
|
||||
RUNE_MEMORY_DIR: /tmp/rune-ci-${{ github.run_id }}/memory
|
||||
# The unit shim battery loads the cdylib (ep_loader); point it at the
|
||||
# runner-owned cdylib FILE from the crate-build step below (the override
|
||||
# expects a file path, not a directory).
|
||||
EP_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libevidence_provenance.so
|
||||
SP_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/librune_spine.so
|
||||
MS_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libmemory_scoring.so
|
||||
CS_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libcapability_scoring.so
|
||||
TC_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libtoken_counting.so
|
||||
SC_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libscaffold_core.so
|
||||
MEMS_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libmemory_storage.so
|
||||
MR_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libmemory_retrieval.so
|
||||
steps:
|
||||
# actions/checkout v4.2.2, pinned to the audited upstream tag commit.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
@@ -102,6 +116,25 @@ jobs:
|
||||
fi
|
||||
python3 scripts/umbrella_impact.py check \
|
||||
--base "$(git merge-base origin/master HEAD)" --head HEAD ${BODY_FILE:+--body-file "$BODY_FILE"}
|
||||
- name: Build Rust evidence_provenance crate
|
||||
run: |
|
||||
# tests/test_evidence_provenance_shims.py drives the cdylib through
|
||||
# the shim layer. Toolchain law (see the canary job for the full
|
||||
# rationale): system rustc is broken on this host (LLVM_23.1
|
||||
# version-node symbol gap); use the healthy rustup toolchain, PATH
|
||||
# first so the rustup shim can't exec the broken system rustc.
|
||||
# Runner-owned /tmp target dir (the repo's target/ is nick's).
|
||||
export PATH="/home/nick/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH"
|
||||
export CARGO_TARGET_DIR="/tmp/rune-ci-target-${{ github.run_id }}"
|
||||
mkdir -p "$CARGO_TARGET_DIR"
|
||||
cargo build --release --manifest-path src/rust/evidence_provenance/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/rune_spine/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/memory_scoring/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/capability_scoring/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/token_counting/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/scaffold_core/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/memory_storage/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/memory_retrieval/Cargo.toml
|
||||
- name: Test with coverage (global floor + changed-lines ratchet)
|
||||
run: |
|
||||
# #149 coverage ratchet — both floors live in .coveragerc; raise,
|
||||
@@ -113,12 +146,45 @@ jobs:
|
||||
.test-venv/bin/coverage xml
|
||||
.test-venv/bin/diff-cover coverage.xml --compare-branch origin/master --fail-under=70 --diff-range-notation ..
|
||||
|
||||
go-surface:
|
||||
runs-on: rune-ci-desktop
|
||||
if: ${{ !startsWith(gitea.event.pull_request.head.ref, 'chore/auto-bump') }}
|
||||
steps:
|
||||
# actions/checkout v4.2.2, pinned to the audited upstream tag commit.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- name: Verify Go toolchain version pin
|
||||
run: |
|
||||
# H0 pin: the Go surface builds against the pacman toolchain; the
|
||||
# pin file is the single source of truth so a distro upgrade that
|
||||
# changes behavior fails loud here, not downstream in the canary.
|
||||
WANT=$(cat go-version.txt)
|
||||
GOT=$(go version | awk '{print $3}' | sed 's/^go//' | cut -d- -f1)
|
||||
echo "pinned: $WANT | toolchain: $GOT"
|
||||
[ "$GOT" = "$WANT" ] || { echo "::error::Go toolchain drift: want $WANT, got $GOT (update go-version.txt with the rollout)"; exit 1; }
|
||||
- name: go vet + go test + go build
|
||||
run: |
|
||||
cd src/go/rune-entry
|
||||
go vet ./...
|
||||
go test ./...
|
||||
go build -o /tmp/rune-entry-ci .
|
||||
|
||||
canary:
|
||||
runs-on: rune-ci-desktop
|
||||
if: ${{ !startsWith(gitea.event.pull_request.head.ref, 'chore/auto-bump') }}
|
||||
env:
|
||||
RUNE_HOME: /tmp/rune-ci-${{ github.run_id }}
|
||||
RUNE_MEMORY_DIR: /tmp/rune-ci-${{ github.run_id }}/memory
|
||||
# The build emits to a runner-owned /tmp target dir (the repo's target/
|
||||
# is nick-owned); EP_RUST_LIB points ep_loader at the cdylib FILE there
|
||||
# (the override expects a file path, not a directory).
|
||||
EP_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libevidence_provenance.so
|
||||
SP_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/librune_spine.so
|
||||
MS_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libmemory_scoring.so
|
||||
CS_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libcapability_scoring.so
|
||||
TC_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libtoken_counting.so
|
||||
SC_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libscaffold_core.so
|
||||
MEMS_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libmemory_storage.so
|
||||
MR_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libmemory_retrieval.so
|
||||
steps:
|
||||
# actions/checkout v4.2.2, pinned to the audited upstream tag commit.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
@@ -130,6 +196,34 @@ jobs:
|
||||
python3 -m venv .canary-venv
|
||||
.canary-venv/bin/pip install --disable-pip-version-check -r requirements-ci.txt
|
||||
.canary-venv/bin/pip install --disable-pip-version-check -e . --no-deps
|
||||
- name: Build Rust evidence_provenance crate
|
||||
run: |
|
||||
# The canary pair + the unit shim battery drive the COMPILED surfaces
|
||||
# (binary CLI parity, ep_loader FFI, ctypes cdylib loads).
|
||||
#
|
||||
# Toolchain law (troubleshooting history): the SYSTEM rustc is broken
|
||||
# on this host — librustc_driver-<sha>.so (pacman rust 1:1.99.0-1)
|
||||
# references std::string::_M_mutate under the LLVM_23.1 version node,
|
||||
# but /usr/lib/libLLVM.so.23.1 does not define that narrow-char
|
||||
# symbol under that node (it exports _M_create/_M_assign there, and
|
||||
# only the WIDE-char _M_mutate) — every compile dies with `symbol
|
||||
# lookup error` while `cargo --version` still prints. The rustup
|
||||
# toolchain under /home/nick/.rustup is healthy and ships its own
|
||||
# libLLVM, and /home/nick carries the x-ACL for this runner user.
|
||||
# Point PATH at the toolchain bin dir (bypasses the rustup shim,
|
||||
# which would exec the broken system rustc via RUSTUP_TOOLCHAIN).
|
||||
# Target dir is runner-owned (/tmp): the repo's target/ is nick's.
|
||||
export PATH="/home/nick/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH"
|
||||
export CARGO_TARGET_DIR="/tmp/rune-ci-target-${{ github.run_id }}"
|
||||
mkdir -p "$CARGO_TARGET_DIR"
|
||||
cargo build --release --manifest-path src/rust/evidence_provenance/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/rune_spine/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/memory_scoring/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/capability_scoring/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/token_counting/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/scaffold_core/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/memory_storage/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/memory_retrieval/Cargo.toml
|
||||
- name: Run canary suite
|
||||
run: |
|
||||
FAILED=0
|
||||
@@ -156,6 +250,18 @@ jobs:
|
||||
src/rune/canary_p1_06.py \
|
||||
src/rune/canary_p1_01.py \
|
||||
src/rune/canary_query_memory.py \
|
||||
src/rune/canary_rune_entry.py \
|
||||
src/rune/canary_spine_meta.py \
|
||||
src/rune/canary_audit_ledger.py \
|
||||
src/rust/rune_spine/canary_spine_rust.py \
|
||||
src/rune/canary_rune_serve.py \
|
||||
src/rune/canary_tui_transport.py \
|
||||
src/rust/memory_scoring/canary_memory_scoring.py \
|
||||
src/rust/capability_scoring/canary_capability_scoring.py \
|
||||
src/rust/token_counting/canary_token_counting.py \
|
||||
src/rust/scaffold_core/canary_scaffold_core.py \
|
||||
src/rust/memory_storage/canary_memory_storage.py \
|
||||
src/rust/memory_retrieval/canary_memory_retrieval.py \
|
||||
src/rune/canary_unbounded.py \
|
||||
src/rune/canary_full_system.py \
|
||||
src/rune/canary_native.py; do
|
||||
@@ -286,12 +392,61 @@ jobs:
|
||||
run: |
|
||||
NEW="$(echo "${{ gitea.event.pull_request.head.ref }}" | sed 's|chore/auto-bump-||')"
|
||||
python3 scripts/check_version_surfaces.py check --root . --version "$NEW"
|
||||
- name: Publish protected-branch gate (full pipeline path)
|
||||
if: ${{ !startsWith(gitea.event.pull_request.head.ref, 'chore/auto-bump') }}
|
||||
- name: Publish protected-branch gate (full pipeline path — all needs green)
|
||||
# RELEASE-SAFETY FIX (2026-09-13): the previous version of this step
|
||||
# read /tmp/need_* files that no step ever writes and echoed success
|
||||
# UNCONDITIONALLY — required-checks published green while type-check
|
||||
# and unit-tests were red, and deploy promoted the red commits
|
||||
# (observed live: 24413e4, fd6e9b4, de30cd9 promoted 0.66.28/29 with
|
||||
# failing type-check). The verdict now comes from the needs context
|
||||
# itself: this step runs ONLY when every required job succeeded.
|
||||
if: >-
|
||||
${{ !startsWith(gitea.event.pull_request.head.ref, 'chore/auto-bump')
|
||||
&& needs.lint.result == 'success'
|
||||
&& needs['unit-tests'].result == 'success'
|
||||
&& needs.canary.result == 'success'
|
||||
&& needs['best-practices'].result == 'success'
|
||||
&& needs.documentation.result == 'success'
|
||||
&& needs['type-check'].result == 'success'
|
||||
&& needs['gitea-offline'].result == 'success' }}
|
||||
run: echo "All required CI checks passed"
|
||||
- name: Refuse promotion when any required check failed (fail-closed)
|
||||
# Companion to the step above: if anything is not exactly success
|
||||
# (failure, cancelled, skipped on a non-bump path), the required-checks
|
||||
# context reports FAILURE — deploy_qualify then refuses promotion.
|
||||
# Fail-closed: not verified → not deployable.
|
||||
if: >-
|
||||
${{ !startsWith(gitea.event.pull_request.head.ref, 'chore/auto-bump')
|
||||
&& (needs.lint.result != 'success'
|
||||
|| needs['unit-tests'].result != 'success'
|
||||
|| needs.canary.result != 'success'
|
||||
|| needs['best-practices'].result != 'success'
|
||||
|| needs.documentation.result != 'success'
|
||||
|| needs['type-check'].result != 'success'
|
||||
|| needs['gitea-offline'].result != 'success') }}
|
||||
run: |
|
||||
# All needs must have succeeded (skipped deps = this is a bump PR, handled above)
|
||||
FAILED=""
|
||||
for job in lint unit-tests canary best-practices documentation type-check gitea-offline; do
|
||||
RESULT=$(cat /tmp/need_$job 2>/dev/null || echo "")
|
||||
done
|
||||
echo "All required CI checks passed"
|
||||
echo "::error::Required CI checks not green — refusing to publish required-checks success"
|
||||
echo "lint=${{ needs.lint.result }} unit-tests=${{ needs['unit-tests'].result }} canary=${{ needs.canary.result }} best-practices=${{ needs['best-practices'].result }} documentation=${{ needs.documentation.result }} type-check=${{ needs['type-check'].result }} gitea-offline=${{ needs['gitea-offline'].result }}"
|
||||
exit 1
|
||||
|
||||
notify-event-bridge:
|
||||
# T16 §16C: post the CI completion WAKE to the local event bridge so
|
||||
# event-driven waiters (deploy.yml → ci_event_wait.py) resume on the
|
||||
# event instead of sleep-polling. ALWAYS runs (success or failure —
|
||||
# a red CI must wake the waiter just as loudly as a green one). The
|
||||
# wake is best-effort and never decides the verdict: the waiter
|
||||
# re-reads the canonical Gitea state via deploy_qualify.py.
|
||||
name: CI Pipeline / notify-event-bridge
|
||||
runs-on: rune-ci-desktop
|
||||
needs: [required-checks]
|
||||
if: always()
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Wake event bridge (wake only — never completion truth)
|
||||
env:
|
||||
CI_SHA: ${{ gitea.sha }}
|
||||
CI_RESULT: ${{ needs.required-checks.result }}
|
||||
RUNE_EVENT_BRIDGE_URL: ${{ vars.RUNE_EVENT_BRIDGE_URL || 'http://127.0.0.1:8791' }}
|
||||
run: python3 scripts/notify_event_bridge.py
|
||||
+78
-17
@@ -19,38 +19,74 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Wait for CI qualification on this exact SHA
|
||||
- name: Wait for CI qualification on this exact SHA (event-driven, T16 §16C)
|
||||
# T16 §16C/§16D: the old sleep-30 → query → repeat loop is gone.
|
||||
# The waiter registers a correlation on the W/R event bridge and
|
||||
# yields in select() until ci.yml's notify-event-bridge job posts
|
||||
# the completion WAKE; the verdict is then re-read from canonical
|
||||
# Gitea (deploy_qualify.py). The 900s deadline is a FAILURE
|
||||
# DETECTOR (lost-event recovery), never the scheduler.
|
||||
env:
|
||||
GITEA_SERVER: ${{ gitea.server_url }}
|
||||
GITEA_REPO: ${{ gitea.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
# Shared bridge data root: the deploy step runs as the runner
|
||||
# user while the daemon runs as nick — the rendezvous state
|
||||
# (waits/events/resumes/leases) must be shared. §16C live
|
||||
# finding: defaulting to the invoking user's home made the
|
||||
# controller unable to create its FIFO (PermissionError) and
|
||||
# the deploy failed fast+truthfully on both 0.66.63 pushes.
|
||||
RUNE_WR_ROOT: /mnt/workdrive/wr-bridge
|
||||
RUNE_WR_CODE: /home/nick/.local/share/mimo-wait-resume
|
||||
run: |
|
||||
DEPLOY_SHA=$(git rev-parse HEAD)
|
||||
export DEPLOY_SHA
|
||||
echo "Qualifying $DEPLOY_SHA"
|
||||
DEADLINE=$((SECONDS + 900))
|
||||
while [ $SECONDS -lt $DEADLINE ]; do
|
||||
STATUS=$(python3 scripts/deploy_qualify.py)
|
||||
echo "required-checks: $STATUS"
|
||||
if [ "$STATUS" = "success" ]; then exit 0; fi
|
||||
if [ "$STATUS" = "failure" ]; then
|
||||
echo "::error::CI failed on $DEPLOY_SHA — deployment refused"
|
||||
exit 1
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
echo "::error::CI qualification timeout on $DEPLOY_SHA — deployment refused"
|
||||
exit 1
|
||||
echo "Qualifying $DEPLOY_SHA (event-driven)"
|
||||
# READINESS BEFORE ACTION (§16U): fail with a clear message if
|
||||
# the bridge rendezvous is not writable in THIS user's context,
|
||||
# instead of a stack trace at FIFO creation time.
|
||||
if ! touch /mnt/workdrive/wr-bridge/resumes/.write-probe 2>/dev/null; then
|
||||
echo "::error::RUNE_WR_ROOT not writable by $(id -un) — bridge data root misconfigured"
|
||||
exit 1
|
||||
fi
|
||||
rm -f /mnt/workdrive/wr-bridge/resumes/.write-probe
|
||||
python3 scripts/ci_event_wait.py --sha "$DEPLOY_SHA" --deadline 900
|
||||
|
||||
- name: Stage, verify, promote
|
||||
- name: Stage, verify, promote (monotonic)
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
GITEA_SERVER: ${{ gitea.server_url }}
|
||||
GITEA_REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
python3 scripts/deploy_stage.py
|
||||
|
||||
- name: Post-deploy runtime verification (the consuming path)
|
||||
- name: Promotion truthfulness check
|
||||
id: promotion_truth
|
||||
run: |
|
||||
SHA=$(git rev-parse HEAD)
|
||||
CURRENT_TARGET=$(readlink /mnt/workdrive/rune-code/current || true)
|
||||
if [ "$CURRENT_TARGET" = "/mnt/workdrive/rune-code/${SHA:0:12}" ]; then
|
||||
echo "PROMOTED: current == candidate ($SHA)"
|
||||
echo "promoted=true" >> "$GITEA_OUTPUT"
|
||||
else
|
||||
# Not promoted. deploy_stage exit 0 + non-matching current =
|
||||
# NOOP_ALREADY_CURRENT or STALE_SUPERSEDED — truthful non-error
|
||||
# terminal states (a newer build already won). Do NOT claim
|
||||
# promotion; the runtime-verification step below only runs for
|
||||
# real promotions.
|
||||
echo "NOT PROMOTED: current -> $CURRENT_TARGET (superseded/noop — current is authoritative)"
|
||||
echo "promoted=false" >> "$GITEA_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post-deploy runtime verification (only for real promotions)
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
SHA=$(git rev-parse HEAD)
|
||||
CURRENT_TARGET=$(readlink /mnt/workdrive/rune-code/current || true)
|
||||
if [ "$CURRENT_TARGET" != "/mnt/workdrive/rune-code/${SHA:0:12}" ]; then
|
||||
echo "SKIP runtime verification: this job did not promote (superseded/noop)"
|
||||
exit 0
|
||||
fi
|
||||
# Exactly what the launcher execs: cd <current>/src, import rune.
|
||||
RUNE_HOME=/mnt/workdrive/rune RUNE_MEMORY_DIR=/home/nick/.config/opencode/memory \
|
||||
/home/nick/.venvs/memory/bin/python3 -c "
|
||||
@@ -62,3 +98,28 @@ jobs:
|
||||
assert rune.__version__ == info['version']
|
||||
print('RUNTIME VERIFIED:', info)
|
||||
"
|
||||
- name: Release-history convergence fallback (SCAFFOLD-TRANCHE-0012C)
|
||||
# The PRIMARY convergence mechanism is deploy_stage.py's out-of-lock
|
||||
# publisher (publishes from CURRENT deployment authority, §14B/§14C).
|
||||
# This step is a non-blocking FALLBACK: if the primary publish failed
|
||||
# (e.g. missing Gitea config), this retries. NEVER fails the job —
|
||||
# documentation failure never outweighs deployment validity.
|
||||
if: ${{ success() && steps.promotion_truth.outputs.promoted == 'true' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
RELEASE_SERVER_URL: ${{ gitea.server_url }}
|
||||
RELEASE_REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
VERSION=$(python3 -c "import sys; sys.path.insert(0,'src'); import rune; print(rune.__version__)" 2>/dev/null || echo "")
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "SKIP: cannot determine version"
|
||||
exit 0
|
||||
fi
|
||||
python3 scripts/release_history_cli.py refresh \
|
||||
--version "$VERSION" \
|
||||
--server "$RELEASE_SERVER_URL" --repo "$RELEASE_REPO" \
|
||||
--store "$GITHUB_WORKSPACE/store/scaffold/current.json" \
|
||||
--release-sha "$(git rev-parse HEAD)" \
|
||||
--deployed-sha "$(git rev-parse HEAD)" \
|
||||
|| echo "::warning::release-history fallback refresh FAILED — in-lock hook may have already converged; retry converges"
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
lint-and-format:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Lint Python files
|
||||
@@ -18,13 +18,16 @@ jobs:
|
||||
ruff format shared/ src/ || true
|
||||
|
||||
canary-suite:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run all canaries
|
||||
# T16 §16Q reconciliation: the glob looped shared/canary_*.py —
|
||||
# real canaries live in src/rune/ (canary_native.MODULES is the
|
||||
# registry authority). The old job silently no-opped.
|
||||
run: |
|
||||
FAILED=0
|
||||
for canary in shared/canary_*.py; do
|
||||
for canary in src/rune/canary_*.py; do
|
||||
if [ -f "$canary" ]; then
|
||||
echo "Running $canary..."
|
||||
python3 "$canary" || FAILED=$((FAILED + 1))
|
||||
@@ -34,7 +37,7 @@ jobs:
|
||||
exit $FAILED
|
||||
|
||||
best-practices:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check best practices
|
||||
@@ -69,7 +72,7 @@ jobs:
|
||||
grep -rn "TODO\|FIXME\|HACK\|XXX" shared/ src/ --include="*.py" | head -10 || echo " Clean"
|
||||
|
||||
documentation-check:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check documentation coverage
|
||||
@@ -110,7 +113,7 @@ jobs:
|
||||
# push-triggered safety net in auto-version.yml. Detects master/tag drift
|
||||
# and files a deduped issue — it never auto-releases without the PR flow.
|
||||
release-drift-reconciliation:
|
||||
runs-on: rune-infra-pi
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
@@ -14,3 +14,4 @@ state/
|
||||
# Runtime artifacts (cwd-local event journal + capability index)
|
||||
index/
|
||||
journal/
|
||||
src/rust/*/target/
|
||||
@@ -58,3 +58,26 @@ fix, full pipeline re-entry (suite + ratchet + ruff + canaries + henxels;
|
||||
gates for anything beyond a mechanical minor), regression tests frozen forever,
|
||||
discharge recorded finding → test → commit. Symptom patches are rejected at
|
||||
review. To escape: edit `henxels.yaml` or this file — visibly.
|
||||
|
||||
## Issue organisation standard (binding, 2026-09-19)
|
||||
|
||||
One open note per idea. Issues use the template spine (kind, subsystem,
|
||||
version intent) with a plain-English heading that restates the subject —
|
||||
no meta-labels. Before minting a standalone issue, check whether the work
|
||||
belongs inside an existing parent:
|
||||
|
||||
- **Parked ideas under a blocked/future epic** are filed as checklist items
|
||||
in the parent's "Absorbed children" section, NOT as standalone issues.
|
||||
Absorption is reversible — re-mint standalone when the idea becomes live.
|
||||
- **Live, dependency-tracked, or starting work** gets a standalone issue.
|
||||
- **Closures state the reason as data**: `closed_because` in the spine
|
||||
(completed / superseded-by-#N / absorbed-into-#N / wont-do / duplicate),
|
||||
plus a standard closing comment (plain summary first).
|
||||
- The reconciliation checker (`scripts/gitea_reconciliation_check.py`) must
|
||||
stay green after any issue state change; the artifact is updated in the
|
||||
same change that moves issues.
|
||||
|
||||
## Async continuation final flow proofs (R13/M9) — 2026-09-19
|
||||
|
||||
PR-flow live proof marker: harmless documentation addition exercising the
|
||||
canonical single-continuation path on the PC-local forge. No code change.
|
||||
@@ -0,0 +1 @@
|
||||
probe 1791291272
|
||||
@@ -1,6 +1,6 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.66.27**
|
||||
**Current release: v0.99.6**
|
||||
|
||||
**Rune is a persistent personal agent designed to accumulate experience, develop reusable competence, and remain recognisably the same agent even when its underlying foundation model changes.**
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""completion_file.py — REAL_ASYNC_BRIDGE: durable completion-file delivery
|
||||
with inotify wake. Replaces FIFO+lease for process-wait delivery.
|
||||
|
||||
DESIGN:
|
||||
Producer (bridge/daemon/notify):
|
||||
1. Validates operation identity
|
||||
2. Atomically writes a completion file:
|
||||
resumes/<operation_id>.done
|
||||
containing the event/verdict JSON (write to tmp, rename for atomicity)
|
||||
|
||||
Waiter (ci_event_wait / any continuation consumer):
|
||||
1. Checks if the completion file already exists (event-before-waiter)
|
||||
2. If not: registers inotify watch on the resumes/ directory
|
||||
3. Re-checks after watch registration (closes the registration race)
|
||||
4. Blocks in kernel on inotify — zero CPU, zero timer
|
||||
5. On file-created event: reads the file, verifies authority, returns
|
||||
|
||||
The event is a WAKE SIGNAL. Authority still decides terminal truth.
|
||||
The completion file is durable — survives daemon and waiter restarts.
|
||||
|
||||
WHY NOT FIFO:
|
||||
FIFO is one-shot (token consumed), requires lease management, and
|
||||
fails under session-busy guards. A completion file is atomic, durable,
|
||||
and works across restarts. inotify wakes the process with zero CPU
|
||||
cost and zero latency between file creation and wake.
|
||||
|
||||
WHY NOT POLLING:
|
||||
Polling introduces an arbitrary interval. inotify is a kernel callback
|
||||
that fires exactly when the filesystem changes — the latency tracks
|
||||
the real completion event, not a configured interval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _wr_bridge() -> Path:
|
||||
# WR_ROOT = daemon/infra convention; RUNE_WR_ROOT = repo/scripts
|
||||
# convention. Both honored (WR_ROOT wins) so the daemon, the waiter,
|
||||
# and any isolated test root always resolve the SAME directory.
|
||||
root = os.environ.get("WR_ROOT") or os.environ.get("RUNE_WR_ROOT") or "/mnt/workdrive/wr-bridge"
|
||||
return Path(root)
|
||||
|
||||
|
||||
def _resumes_dir() -> Path:
|
||||
return _wr_bridge() / "resumes"
|
||||
|
||||
|
||||
# inotify constants (Linux)
|
||||
IN_CREATE = 0x100
|
||||
IN_CLOSE_WRITE = 0x8
|
||||
IN_MOVED_TO = 0x80
|
||||
|
||||
|
||||
def completion_path(operation_id: str) -> Path:
|
||||
return _resumes_dir() / f"{operation_id}.done"
|
||||
|
||||
|
||||
def write_completion(operation_id: str, event: dict) -> Path:
|
||||
"""Atomically write a durable completion file. The rename(2) ensures
|
||||
the waiter never sees a partial file."""
|
||||
_resumes_dir().mkdir(parents=True, exist_ok=True)
|
||||
target = completion_path(operation_id)
|
||||
tmp = target.parent / f".{target.name}.tmp.{os.getpid()}"
|
||||
tmp.write_text(json.dumps(event, sort_keys=True) + "\n")
|
||||
os.rename(str(tmp), str(target)) # atomic on same filesystem
|
||||
os.chmod(str(target), 0o666) # cross-user bridge
|
||||
return target
|
||||
|
||||
|
||||
def read_completion(operation_id: str) -> dict | None:
|
||||
"""Read a durable completion file. Returns None if absent."""
|
||||
p = completion_path(operation_id)
|
||||
if not p.is_file():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def completion_exists(operation_id: str) -> bool:
|
||||
return completion_path(operation_id).is_file()
|
||||
|
||||
|
||||
def _inotify_init_and_add_watch(directory: str) -> int:
|
||||
"""Create an inotify instance and add a watch on the directory.
|
||||
Returns the inotify fd."""
|
||||
libc = __import__("ctypes").CDLL(None, use_errno=True)
|
||||
fd = libc.inotify_init()
|
||||
if fd < 0:
|
||||
raise OSError("inotify_init failed")
|
||||
# IN_CREATE | IN_MOVED_TO: catch both direct creation and atomic rename
|
||||
mask = IN_CREATE | IN_MOVED_TO
|
||||
wd = libc.inotify_add_watch(fd, directory.encode(), mask)
|
||||
if wd < 0:
|
||||
os.close(fd)
|
||||
raise OSError(f"inotify_add_watch failed for {directory}")
|
||||
return fd
|
||||
|
||||
|
||||
def _inotify_read(fd: int, timeout_secs: float | None = None) -> list[str]:
|
||||
"""Read pending inotify events. Returns list of filenames that were
|
||||
created/moved-to. Blocks until at least one event or timeout."""
|
||||
poll = select.poll()
|
||||
poll.register(fd, select.POLLIN)
|
||||
events = []
|
||||
buf = os.read(fd, 4096)
|
||||
pos = 0
|
||||
while pos < len(buf):
|
||||
# struct: wd(i32), mask(u32), cookie(u32), name_len(u32)
|
||||
wd, mask, cookie, name_len = struct.unpack_from("iIII", buf, pos)
|
||||
pos += 16
|
||||
name = buf[pos : pos + name_len].rstrip(b"\x00").decode("utf-8", "replace")
|
||||
pos += name_len
|
||||
if mask & (IN_CREATE | IN_MOVED_TO):
|
||||
events.append(name)
|
||||
return events
|
||||
|
||||
|
||||
def wait_for_completion(operation_id: str, *, timeout_secs: float | None = None, poll_callback=None) -> dict | None:
|
||||
"""Block until a completion file exists for this operation.
|
||||
|
||||
Uses inotify for zero-latency, zero-CPU wake. Falls back to
|
||||
polling_callback (if provided) when inotify is unavailable.
|
||||
|
||||
The completion file is a WAKE SIGNAL. The caller must still verify
|
||||
canonical authority after reading it.
|
||||
|
||||
Returns the event dict from the completion file, or None on timeout.
|
||||
"""
|
||||
target = completion_path(operation_id)
|
||||
|
||||
# 1. check-before-watch (event may have arrived before we started)
|
||||
existing = read_completion(operation_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
_resumes_dir().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
fd = _inotify_init_and_add_watch(str(_resumes_dir()))
|
||||
except OSError:
|
||||
# inotify unavailable: fall back to polling callback
|
||||
if poll_callback:
|
||||
return poll_callback(operation_id, timeout_secs)
|
||||
return None
|
||||
|
||||
try:
|
||||
# 2. re-check after watch registration (closes the race between
|
||||
# the initial check and the watch being active)
|
||||
existing = read_completion(operation_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
# 3. block in kernel on inotify — zero CPU, exact latency
|
||||
import select as _select
|
||||
|
||||
poll = _select.poll()
|
||||
poll.register(fd, _select.POLLIN)
|
||||
deadline = (time.monotonic() + timeout_secs) if timeout_secs else None
|
||||
|
||||
while True:
|
||||
remaining = None
|
||||
if deadline is not None:
|
||||
remaining = max(0.1, deadline - time.monotonic())
|
||||
if remaining <= 0.1:
|
||||
return None # timeout
|
||||
events = poll.poll(remaining * 1000 if remaining else None)
|
||||
if not events:
|
||||
continue # poll timeout, re-check
|
||||
names = _inotify_read(fd)
|
||||
if target.name in names:
|
||||
# 4. file appeared — read it
|
||||
existing = read_completion(operation_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
# partial/corrupt: keep watching
|
||||
finally:
|
||||
os.close(fd)
|
||||
return None
|
||||
|
||||
|
||||
def clear_completion(operation_id: str) -> None:
|
||||
"""Remove a consumed completion file (idempotent cleanup)."""
|
||||
p = completion_path(operation_id)
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
Executable
+799
@@ -0,0 +1,799 @@
|
||||
#!/usr/bin/env python3
|
||||
"""daemon.py — Gitea webhook receiver + reconciliation + resume driver (v0.2).
|
||||
|
||||
W/R-v0.2 invariants implemented here:
|
||||
A. SINGLE-FLIGHT PER SESSION — per-session lease (waitstore.acquire_lease,
|
||||
/proc-validated, atomic) + in-process inflight set. A second READY wait
|
||||
for the same session stays queued until the lease releases.
|
||||
B. BUSY SESSION PROTECTION — before spawning, /proc is scanned for a live
|
||||
`mimo run -s <sid>`-shaped process not owned by our lease. Found → skip
|
||||
launch (wait stays READY). No invented MiMo APIs; the lease is the
|
||||
conservative floor against W/R-created concurrency.
|
||||
C. ATOMIC SESSION LEASE — persisted {session_id, wait_id, pid, pgid,
|
||||
pid_starttime, acquired_at, state}; O_EXCL create, steal only on /proc
|
||||
evidence of a dead owner.
|
||||
D. PROCESS TREE OWNERSHIP — workers spawn with start_new_session=True (own
|
||||
process group). Watch timeout ⇒ SIGTERM group → bounded grace → SIGKILL
|
||||
group → verify zero surviving group members → release lease → truthful
|
||||
READY_TO_RETRY (never terminal-on-timeout).
|
||||
E. TIMEOUT IS NOT SUCCESS — rc==0 maps to verdict terminal
|
||||
(COMPLETED/FAILED); rc!=0 or unverifiable ⇒ RESUME_UNVERIFIED
|
||||
(not schedulable, operator-requeueable); timeout ⇒ READY_TO_RETRY.
|
||||
F. STALE RESUMING RECOVERY — on startup, every RESUMING record is
|
||||
inspected: live lease owner ⇒ adopted and monitored; otherwise ⇒
|
||||
RESUME_UNVERIFIED (+ stale lease released). Never auto-relaunched.
|
||||
G. MULTIPLE WAITS, SAME SESSION — schedulable() orders oldest-first; one
|
||||
continuation finishes before the next starts.
|
||||
H. PIDFILE CORRECTNESS — singleton enforced by flock on daemon.pid; the
|
||||
lock dies with the process, so stale pidfiles (incl. SIGKILL) cannot
|
||||
admit a second daemon.
|
||||
|
||||
The resume command is configurable (WR_RESUME_CMD, JSON list with
|
||||
{session_id}/{msg} placeholders) so the test harness can use a stub; the
|
||||
default is the real mechanism: `mimo run -s <sid> -m <model> <msg>`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import fcntl, json, os, signal, subprocess, sys, threading, time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
import waitstore
|
||||
import verify
|
||||
import completion_file # REAL_ASYNC_BRIDGE R3: durable completion-file delivery
|
||||
|
||||
RESUME_MODEL = "Verboo Code/glm-5.3-flash"
|
||||
RECONCILE_SECS = int(os.environ.get("WR_RECONCILE_SECS", "60"))
|
||||
RECONCILE_EVERY_TICKS = max(1, int(os.environ.get("WR_RECONCILE_TICKS", "3")))
|
||||
WAKE_MIN_AGE_SECS = float(os.environ.get("WR_WAKE_MIN_AGE_SECS", "5"))
|
||||
WAKE_RETRIES = int(os.environ.get("WR_WAKE_RETRIES", "3"))
|
||||
WAKE_RETRY_SECS = float(os.environ.get("WR_WAKE_RETRY_SECS", "4"))
|
||||
DRIVE_LOOP_SECS = int(os.environ.get("WR_DRIVE_SECS", "20"))
|
||||
# REAL_ASYNC_BRIDGE §4: a RESUMING wait older than this grace without
|
||||
# progress is an orphaned resume flow (thread died between lease acquire
|
||||
# and release). Must exceed the longest legitimate in-flight watch
|
||||
# (WATCH_SECS); default 3x. Env-overridable for deterministic tests.
|
||||
WATCH_SECS = int(os.environ.get("WR_WATCH_SECS", "600"))
|
||||
# REAL_ASYNC_BRIDGE §4: a RESUMING wait older than this grace without
|
||||
# progress is an orphaned resume flow (thread died between lease acquire
|
||||
# and release). Must exceed the longest legitimate in-flight watch
|
||||
# (WATCH_SECS); default 3x. Env-overridable for deterministic tests.
|
||||
ORPHAN_GRACE_SECS = int(os.environ.get("WR_ORPHAN_GRACE_SECS", str(3 * WATCH_SECS)))
|
||||
TERM_GRACE_SECS = int(os.environ.get("WR_TERM_GRACE_SECS", "10"))
|
||||
MAX_ATTEMPTS = int(os.environ.get("WR_MAX_ATTEMPTS", "3"))
|
||||
_WR_ROOT = Path(os.environ.get("WR_ROOT", "") or Path(__file__).resolve().parent)
|
||||
PIDFILE = _WR_ROOT / "daemon.pid"
|
||||
LOG = _WR_ROOT / "daemon.log"
|
||||
|
||||
DEFAULT_RESUME_CMD = ["mimo", "run", "-s", "{session_id}", "-m", RESUME_MODEL, "{msg}"]
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
with open(LOG, "a") as f:
|
||||
f.write(f"{time.strftime('%H:%M:%S')} {msg}\n")
|
||||
|
||||
|
||||
def resume_cmd(session_id: str, msg: str, wait_id: str = "", override: list | None = None) -> list[str]:
|
||||
"""T15 §15D: per-wait override wins; placeholders {session_id}/{msg}/
|
||||
{wait_id}. A synthetic controller session (Scaffolding stage driver)
|
||||
is not a MiMo session — the global default cannot resume it, which is
|
||||
exactly why fixed-sleep polling crept back into T12-T14."""
|
||||
if override:
|
||||
return [
|
||||
c.replace("{session_id}", session_id).replace("{msg}", msg).replace("{wait_id}", wait_id) for c in override
|
||||
]
|
||||
tmpl = os.environ.get("WR_RESUME_CMD")
|
||||
cmd = json.loads(tmpl) if tmpl else DEFAULT_RESUME_CMD
|
||||
return [c.replace("{session_id}", session_id).replace("{msg}", msg).replace("{wait_id}", wait_id) for c in cmd]
|
||||
|
||||
|
||||
# ── process-tree evidence & ownership ─────────────────────────────────────
|
||||
|
||||
|
||||
def pgid_members(pgid: int) -> list[int]:
|
||||
out = []
|
||||
for d in Path("/proc").iterdir():
|
||||
if not d.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
stat = (d / "stat").read_text()
|
||||
fields = stat.rsplit(")", 1)[1].split()
|
||||
# zombies: killed but not yet reaped. Counting them makes the
|
||||
# survivor check report the direct child we just KILLed before
|
||||
# communicate() reaps it (observed live 2026-09-12).
|
||||
if fields[0] == "Z":
|
||||
continue
|
||||
if int(fields[2]) == pgid:
|
||||
out.append(int(d.name))
|
||||
except (OSError, IndexError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def terminate_tree(pgid: int) -> list[int]:
|
||||
"""TERM → bounded grace → KILL. Returns surviving members (must be [])."""
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
deadline = time.time() + TERM_GRACE_SECS
|
||||
while time.time() < deadline:
|
||||
if not pgid_members(pgid):
|
||||
return []
|
||||
time.sleep(0.2)
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
for _ in range(50): # up to 10s for KILL to land
|
||||
if not pgid_members(pgid):
|
||||
return []
|
||||
time.sleep(0.2)
|
||||
return pgid_members(pgid)
|
||||
|
||||
|
||||
def stray_worker_pids(session_id: str, exclude_pgids: set[int] = ()) -> list[int]:
|
||||
"""Live `mimo run -s <sid>`-shaped processes not owned by our workers."""
|
||||
out = []
|
||||
for d in Path("/proc").iterdir():
|
||||
if not d.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
parts = [p.decode(errors="replace") for p in (d / "cmdline").read_bytes().split(b"\0") if p]
|
||||
except OSError:
|
||||
continue
|
||||
if not parts:
|
||||
continue
|
||||
joined = " ".join(parts)
|
||||
if session_id not in joined or "-s" not in parts:
|
||||
continue
|
||||
if "mimo" not in joined.lower() and "@mimo-ai" not in joined.lower():
|
||||
continue
|
||||
try:
|
||||
if os.getpgid(int(d.name)) in exclude_pgids:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
out.append(int(d.name))
|
||||
return out
|
||||
|
||||
|
||||
# ── verification → transition (unchanged authority model) ─────────────────
|
||||
|
||||
|
||||
def _authoritative_verify(rec: dict) -> tuple[str, dict]:
|
||||
"""Authoritative verification with a deterministic test seam.
|
||||
|
||||
Production (WR_VERIFY_STUB unset): real Gitea API via verify.py — the
|
||||
ONLY source of success/failure truth. Test (WR_VERIFY_STUB=path to
|
||||
JSON {"<run_ref-or-sha>": verdict, "default": verdict}): stubbed
|
||||
verdicts so invariant tests never touch the network. The stub is a
|
||||
test fixture, not an alternate authority — no production path sets it.
|
||||
"""
|
||||
stub_path = os.environ.get("WR_VERIFY_STUB", "")
|
||||
if stub_path:
|
||||
try:
|
||||
spec = json.loads(Path(stub_path).read_text())
|
||||
except Exception:
|
||||
return "pending", {"stub_error": "unreadable stub"}
|
||||
key = str(rec.get("run_ref", "") or rec.get("sha", ""))
|
||||
verdict = spec.get(key, spec.get("default", "pending"))
|
||||
return verdict, {"stub": True, "key": key}
|
||||
import verify
|
||||
|
||||
if rec.get("wait_kind") == "RELEASE_DEPLOY":
|
||||
return verify.verify_release_deploy(rec.get("sha", ""), rec.get("repo_root", ""), rec.get("deployed_link", ""))
|
||||
return verify.verify_run(rec.get("run_ref", ""), rec.get("sha", ""), rec.get("expected", "success"))
|
||||
|
||||
|
||||
def try_transition(rec: dict, event: dict | None = None) -> dict:
|
||||
if rec["status"] != "WAITING_EXTERNAL":
|
||||
return rec
|
||||
if rec.get("wait_kind") == "PROCESS_EVENT":
|
||||
# T15 §15D: the matching event IS the authority. The notifier
|
||||
# wrapper observed the real process exit; exit_code decides the
|
||||
# verdict. No Gitea lookup applies. A duplicate event never
|
||||
# reaches here (match applies only to WAITING_EXTERNAL waits).
|
||||
if not event:
|
||||
return rec # no event → nothing authoritative happened
|
||||
try:
|
||||
exit_code = int(event.get("exit_code"))
|
||||
except (TypeError, ValueError):
|
||||
exit_code = None
|
||||
if exit_code is None:
|
||||
return rec # malformed event: stay WAITING (15E hostile case)
|
||||
verdict = "success" if exit_code == 0 else "failure"
|
||||
evidence = {"process_event": True, "exit_code": exit_code, "operation_id": event.get("run_ref", "")}
|
||||
log(f"verify {rec['wait_id']}: {verdict} {json.dumps(evidence)[:120]}")
|
||||
rec["status"] = "READY_TO_RESUME"
|
||||
rec["verdict"] = verdict
|
||||
if verdict == "failure":
|
||||
rec["failure_evidence"] = evidence
|
||||
rec["process_event_evidence"] = evidence
|
||||
waitstore.save(rec)
|
||||
return rec
|
||||
verdict, evidence = _authoritative_verify(rec)
|
||||
log(f"verify {rec['wait_id']}: {verdict} {json.dumps(evidence)[:120]}")
|
||||
if verdict == "success":
|
||||
rec["status"] = "READY_TO_RESUME"
|
||||
rec["verdict"] = "success"
|
||||
elif verdict == "failure":
|
||||
rec["status"] = "READY_TO_RESUME"
|
||||
rec["verdict"] = "failure"
|
||||
rec["failure_evidence"] = verify.failed_job_evidence(rec.get("run_ref", ""))
|
||||
else:
|
||||
return rec # pending/unknown → stay WAITING
|
||||
waitstore.save(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def continuation_msg(rec: dict) -> str:
|
||||
c = rec["continuation"]
|
||||
if rec.get("verdict") == "success":
|
||||
return (
|
||||
f"External wait satisfied (wait {rec['wait_id']}).\n"
|
||||
f"Original objective: {c.get('objective', '')}\n"
|
||||
f"Waited for: {c.get('waited_for', '')}\n"
|
||||
f"Authoritative result: workflow success verified via Gitea API.\n"
|
||||
f"SHA/run: {rec.get('sha', '')}/{rec.get('run_ref', '')}\n"
|
||||
f"Verify: {c.get('verify_when_woken', '')}\n"
|
||||
f"Continue from: {c.get('on_success', '')}"
|
||||
)
|
||||
ev = json.dumps(rec.get("failure_evidence", {}))[:400]
|
||||
return (
|
||||
f"External wait finished with FAILURE (wait {rec['wait_id']}).\n"
|
||||
f"Original objective: {c.get('objective', '')}\n"
|
||||
f"Authoritative result: workflow FAILURE verified via Gitea API.\n"
|
||||
f"Failed job evidence: {ev}\n"
|
||||
f"SHA/run: {rec.get('sha', '')}/{rec.get('run_ref', '')}\n"
|
||||
f"Do NOT treat this as success. Continue from: {c.get('on_failure', '')}"
|
||||
)
|
||||
|
||||
|
||||
# ── resume driver (single-flight, tree-owned) ─────────────────────────────
|
||||
|
||||
|
||||
def resume(rec: dict) -> None:
|
||||
"""Run one continuation to completion. Runs on a dedicated worker thread.
|
||||
Preconditions: caller verified no inflight worker for this session."""
|
||||
sid, wid = rec["session_id"], rec["wait_id"]
|
||||
# REAL_ASYNC_BRIDGE §2 invariant: a successful authority-verified
|
||||
# operation completion must NEVER be overwritten or downgraded by a
|
||||
# legacy session/FIFO delivery failure. Terminal operation state
|
||||
# (COMPLETED/FAILED — stamped by the waiter's own authority
|
||||
# verification) is owned by the OPERATION domain; this function owns
|
||||
# only SESSION-DELIVERY. Re-read from disk: the in-memory copy may be
|
||||
# stale relative to the waiter's terminal stamp.
|
||||
fresh = waitstore.load(wid)
|
||||
if fresh and fresh.get("status") in waitstore.TERMINAL:
|
||||
log(
|
||||
f"resume {wid}: skipped — operation already terminal "
|
||||
f"({fresh['status']}); delivery worker never overrides "
|
||||
f"operation truth"
|
||||
)
|
||||
return
|
||||
rec = fresh or rec
|
||||
msg = continuation_msg(rec)
|
||||
|
||||
# B: busy-session guard before touching the lease
|
||||
strays = stray_worker_pids(sid)
|
||||
if strays:
|
||||
rec["status"] = "READY_TO_RESUME"
|
||||
rec["resume_note"] = f"queued: target session busy (stray pids {strays})"
|
||||
waitstore.save(rec)
|
||||
log(f"queued {wid}: session busy {strays}")
|
||||
return
|
||||
|
||||
# pre-register the lease owner as THIS daemon so acquisition is atomic
|
||||
lease = waitstore.acquire_lease(sid, wid, os.getpid(), os.getpid())
|
||||
if lease is None:
|
||||
live = waitstore.read_lease(sid)
|
||||
rec["status"] = "READY_TO_RESUME"
|
||||
rec["resume_note"] = f"queued: lease held by wait {live and live.get('wait_id')}"
|
||||
waitstore.save(rec)
|
||||
log(f"queued {wid}: lease held")
|
||||
return
|
||||
|
||||
rec["status"] = "RESUMING"
|
||||
rec["resume_attempts"] = rec.get("resume_attempts", 0) + 1
|
||||
waitstore.save(rec)
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
resume_cmd(sid, msg, wid, rec.get("resume_cmd")),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception as e:
|
||||
waitstore.release_lease(sid, wid)
|
||||
rec["status"] = "READY_TO_RETRY" # spawn failed pre-delivery → retryable
|
||||
rec["resume_note"] = f"spawn failed: {e}"
|
||||
waitstore.save(rec)
|
||||
log(f"spawn {wid} failed pre-delivery: {e}")
|
||||
return
|
||||
|
||||
pgid = proc.pid # start_new_session ⇒ pgid == child pid
|
||||
waitstore.update_lease(sid, wait_id=wid, pid=proc.pid, pgid=pgid, pid_starttime=waitstore._pid_starttime(proc.pid))
|
||||
log(f"launched {wid}: pid={proc.pid} pgid={pgid} attempt={rec['resume_attempts']}")
|
||||
|
||||
timed_out = False
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=WATCH_SECS)
|
||||
rc = proc.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
survivors = terminate_tree(pgid)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=30)
|
||||
except Exception:
|
||||
stdout, stderr = "", ""
|
||||
rc = proc.returncode
|
||||
if survivors:
|
||||
log(f"ERROR {wid}: group {pgid} survivors after KILL: {survivors}")
|
||||
|
||||
waitstore.release_lease(sid, wid)
|
||||
|
||||
if timed_out:
|
||||
# D+E: timeout is NOT success. Tree terminated; retry truthfully.
|
||||
if rec["resume_attempts"] >= MAX_ATTEMPTS:
|
||||
rec["status"] = "RESUME_UNVERIFIED"
|
||||
rec["resume_note"] = (
|
||||
f"watch timeout x{rec['resume_attempts']}; process group {pgid} terminated; attempts exhausted"
|
||||
)
|
||||
else:
|
||||
rec["status"] = "READY_TO_RETRY"
|
||||
rec["resume_note"] = f"watch timeout; process group {pgid} terminated (TERM→KILL, verified); will retry"
|
||||
log(f"timeout {wid}: tree {pgid} terminated, rc={rc}")
|
||||
elif rc == 0:
|
||||
# COMPLETION TRUTH (constraint 4): rc==0 means the headless worker
|
||||
# EXITED CLEANLY — the strongest terminal evidence the supported CLI
|
||||
# provides. Timeout/cancel can never reach this branch. The evidence
|
||||
# chain (rc, output tail, timing) is recorded on the wait record.
|
||||
terminal = "COMPLETED" if rec.get("verdict") == "success" else "FAILED"
|
||||
rec["status"] = terminal
|
||||
rec["resume_evidence"] = {
|
||||
"kind": "worker_exited_rc0",
|
||||
"rc": 0,
|
||||
"output_tail": (stdout or "")[-400:],
|
||||
"finished_at": waitstore.now(),
|
||||
}
|
||||
log(f"resumed {wid}: rc=0 → {terminal}")
|
||||
else:
|
||||
# Nonzero rc: delivery/completion unproven. Honest, not schedulable.
|
||||
rec["status"] = "RESUME_UNVERIFIED"
|
||||
rec["resume_evidence"] = {
|
||||
"kind": "worker_exited_nonzero",
|
||||
"rc": rc,
|
||||
"output_tail": ((stdout or "") + (stderr or ""))[-400:],
|
||||
"finished_at": waitstore.now(),
|
||||
}
|
||||
rec["resume_note"] = f"resume rc={rc}; delivery unverified"
|
||||
log(f"resumed {wid}: rc={rc} → RESUME_UNVERIFIED")
|
||||
waitstore.save(rec)
|
||||
|
||||
|
||||
# ── startup recovery (invariant F) ────────────────────────────────────────
|
||||
|
||||
|
||||
def startup_recovery() -> None:
|
||||
for rec in waitstore.all_waits():
|
||||
if rec.get("status") != "RESUMING":
|
||||
continue
|
||||
sid, wid = rec["session_id"], rec["wait_id"]
|
||||
lease = waitstore.read_lease(sid)
|
||||
if lease and lease.get("wait_id") == wid and waitstore._owner_alive(lease):
|
||||
# Adopt the live worker and monitor it to a bounded conclusion.
|
||||
pgid = lease["pgid"]
|
||||
log(f"recover {wid}: adopted live worker pgid={pgid}")
|
||||
deadline = time.time() + WATCH_SECS
|
||||
while time.time() < deadline and pgid_members(pgid):
|
||||
time.sleep(2)
|
||||
survivors = terminate_tree(pgid) if pgid_members(pgid) else []
|
||||
waitstore.release_lease(sid, wid)
|
||||
rec["status"] = "RESUME_UNVERIFIED"
|
||||
rec["resume_note"] = "adopted post-restart; outcome unverifiable" + (
|
||||
f"; survivors killed: {survivors}" if survivors else ""
|
||||
)
|
||||
waitstore.save(rec)
|
||||
log(f"recover {wid}: adopted worker ended → RESUME_UNVERIFIED")
|
||||
else:
|
||||
if lease:
|
||||
waitstore.release_lease(sid, wid) # stale
|
||||
rec["status"] = "RESUME_UNVERIFIED"
|
||||
rec["resume_note"] = "recovered at daemon startup: no live owner; delivery unverifiable"
|
||||
waitstore.save(rec)
|
||||
log(f"recover {wid}: no live owner → RESUME_UNVERIFIED")
|
||||
|
||||
|
||||
# ── reconciler + drive loop (invariant G: deterministic queue) ────────────
|
||||
|
||||
_inflight: dict[str, threading.Thread] = {}
|
||||
_inflight_lock = threading.Lock()
|
||||
|
||||
|
||||
def drive_ready() -> None:
|
||||
for rec in waitstore.schedulable():
|
||||
sid = rec["session_id"]
|
||||
# CONTENTION POLICY (constraint 2): MiMo exposes NO supported
|
||||
# busy/idle session signal (verified 2026-09-12: `mimo session list
|
||||
# --format json` has no status field; no `session status` command;
|
||||
# no status column in the session schema; no documented ACP/serve
|
||||
# endpoint). Therefore automatic injection into interactive sessions
|
||||
# is NOT YET SAFE and stays disabled. Only waits explicitly declared
|
||||
# target_kind="headless" (disposable/owned sessions) auto-dispatch.
|
||||
if rec.get("target_kind") != "headless":
|
||||
if not rec.get("hold_reason"):
|
||||
rec["hold_reason"] = (
|
||||
"INTERACTIVE_AUTO_RESUME = NOT YET SAFE: "
|
||||
"no supported MiMo busy/idle signal; "
|
||||
"queued until operator releases"
|
||||
)
|
||||
waitstore.save(rec)
|
||||
continue
|
||||
if rec.get("ext", {}).get("resume_mode") == "self":
|
||||
# REAL_ASYNC_BRIDGE §3: self-resuming wait (completion-file /
|
||||
# inotify waiter owns its continuation). The daemon has NO
|
||||
# delivery role here — dispatching a delivery worker would
|
||||
# stamp a phantom rc!=0 over the waiter's own truthful
|
||||
# terminal state (the canonical-state contradiction).
|
||||
# NB: keyed on the explicit self marker, NOT on resume_cmd
|
||||
# absence — records without inline resume_cmd may still be
|
||||
# dispatchable via the WR_RESUME_CMD env template.
|
||||
continue
|
||||
with _inflight_lock:
|
||||
t = _inflight.get(sid)
|
||||
if t is not None and t.is_alive():
|
||||
continue # single-flight: session already being driven
|
||||
# B: never launch into a busy session
|
||||
strays = stray_worker_pids(sid)
|
||||
if strays:
|
||||
log(f"drive: {rec['wait_id']} held, session busy {strays}")
|
||||
continue
|
||||
th = threading.Thread(target=resume, args=(rec,), daemon=True, name=f"resume-{rec['wait_id']}")
|
||||
_inflight[sid] = th
|
||||
# start INSIDE the lock: a just-created thread is not yet
|
||||
# is_alive() until start() runs — starting outside the lock let
|
||||
# the same drive cycle dispatch a second worker for the session
|
||||
# (single-flight race, caught by the T2 invariant test).
|
||||
th.start()
|
||||
log(f"drive: dispatched {rec['wait_id']} (session {sid})")
|
||||
|
||||
|
||||
def reconcile_once() -> None:
|
||||
for rec in waitstore.pending():
|
||||
if rec["status"] != "WAITING_EXTERNAL":
|
||||
continue
|
||||
created = rec.get("created_at", "")
|
||||
try:
|
||||
import calendar
|
||||
|
||||
age = time.time() - calendar.timegm(time.strptime(created, "%Y-%m-%dT%H:%M:%SZ"))
|
||||
except ValueError:
|
||||
continue
|
||||
if age > RECONCILE_SECS:
|
||||
try_transition(rec)
|
||||
|
||||
|
||||
_tick = 0
|
||||
|
||||
|
||||
def orphan_sweep() -> int:
|
||||
"""REAL_ASYNC_BRIDGE §4: permanent mid-run orphan-lease recovery.
|
||||
|
||||
FAILURE DETECTION ONLY — never involved in normal completion
|
||||
scheduling. A wait stuck in RESUMING past ORPHAN_GRACE_SECS means the
|
||||
resume flow died between lease acquire and release (worker thread
|
||||
lost, exception mid-watch). Reconcile exactly as startup_recovery
|
||||
would have: release the lease, stamp RESUME_UNVERIFIED truthfully
|
||||
(delivery unverifiable), never auto-relaunch.
|
||||
"""
|
||||
import calendar as _cal
|
||||
|
||||
healed = 0
|
||||
for rec in waitstore.all_waits():
|
||||
if rec.get("status") != "RESUMING":
|
||||
continue
|
||||
try:
|
||||
updated = _cal.timegm(time.strptime(rec.get("last_updated", ""), "%Y-%m-%dT%H:%M:%SZ"))
|
||||
except ValueError:
|
||||
continue
|
||||
if time.time() - updated < ORPHAN_GRACE_SECS:
|
||||
continue # legitimate in-flight watch (up to WATCH_SECS)
|
||||
wid, sid = rec["wait_id"], rec["session_id"]
|
||||
lease = waitstore.read_lease(sid)
|
||||
owner_dead = lease is not None and not waitstore._owner_alive(lease)
|
||||
daemon_owned = lease is not None and lease.get("pid") == os.getpid()
|
||||
if lease is None or owner_dead or daemon_owned:
|
||||
if lease is not None:
|
||||
waitstore.release_lease(sid, wid)
|
||||
if rec.get("resume_attempts", 0) >= MAX_ATTEMPTS:
|
||||
rec["status"] = "RESUME_UNVERIFIED"
|
||||
else:
|
||||
rec["status"] = "READY_TO_RETRY"
|
||||
rec["resume_note"] = (
|
||||
"orphaned mid-run: resume flow died between lease acquire "
|
||||
"and delivery ("
|
||||
+ ("lease owner dead" if owner_dead else "resume thread lost" if daemon_owned else "no lease")
|
||||
+ "); orphan sweep recovered"
|
||||
)
|
||||
waitstore.save(rec)
|
||||
log(f"orphan-sweep {wid}: {rec['status']} — {rec['resume_note']}")
|
||||
healed += 1
|
||||
return healed
|
||||
|
||||
|
||||
def reconciler() -> None:
|
||||
global _tick
|
||||
while True:
|
||||
# v0.3: the local-runner wake is the fast path; periodic
|
||||
# authoritative verification demotes to a slower fallback
|
||||
# (missed-event + daemon-restart recovery), not API polling.
|
||||
_tick += 1
|
||||
if _tick % RECONCILE_EVERY_TICKS == 0:
|
||||
try:
|
||||
reconcile_once()
|
||||
except Exception as e:
|
||||
log(f"reconcile error: {e}")
|
||||
try:
|
||||
orphan_sweep() # §4 failure detection — bounded, periodic
|
||||
except Exception as e:
|
||||
log(f"orphan-sweep error: {e}")
|
||||
try:
|
||||
drive_ready()
|
||||
except Exception as e:
|
||||
log(f"drive error: {e}")
|
||||
with _inflight_lock:
|
||||
for sid, t in list(_inflight.items()):
|
||||
if not t.is_alive():
|
||||
del _inflight[sid]
|
||||
time.sleep(DRIVE_LOOP_SECS)
|
||||
|
||||
|
||||
# ── webhook receiver (wake signal only) ───────────────────────────────────
|
||||
|
||||
|
||||
def wake_sweep(event: dict, eid: str) -> dict:
|
||||
"""Sweep WAITING_EXTERNAL waits through authoritative verification.
|
||||
|
||||
Runs on a background thread (the hook's HTTP response returns
|
||||
instantly, minimising the runner's offline window). If any verified
|
||||
wait got 'pending' — the normal case for a job that finished
|
||||
microseconds ago: the post-task hook fires after results are reported
|
||||
but before the runner's final task acknowledgement flips server-side
|
||||
status — retry on a BOUNDED event-triggered schedule (default 4s x3),
|
||||
never a polling loop. The final ack lands milliseconds after the hook
|
||||
exits, so the first retry almost always sees the terminal state.
|
||||
"""
|
||||
arrival = time.time()
|
||||
log(f"local wake {eid}: {json.dumps(event)[:140]}")
|
||||
import calendar
|
||||
|
||||
def sweep() -> tuple[int, int, int]:
|
||||
verified = advanced = pending = 0
|
||||
for rec in waitstore.pending():
|
||||
if rec["status"] != "WAITING_EXTERNAL":
|
||||
continue
|
||||
try:
|
||||
age = time.time() - calendar.timegm(time.strptime(rec.get("created_at", ""), "%Y-%m-%dT%H:%M:%SZ"))
|
||||
except ValueError:
|
||||
continue
|
||||
if age < WAKE_MIN_AGE_SECS:
|
||||
continue
|
||||
verified += 1
|
||||
before = rec["status"]
|
||||
try_transition(rec)
|
||||
if rec["status"] != before and rec["status"] == "READY_TO_RESUME":
|
||||
advanced += 1
|
||||
elif rec["status"] == "WAITING_EXTERNAL":
|
||||
pending += 1
|
||||
return verified, advanced, pending
|
||||
|
||||
verified, advanced, pending = sweep()
|
||||
log(
|
||||
f"local wake {eid}: verified={verified} advanced={advanced} "
|
||||
f"pending={pending} sweep_ms={int((time.time() - arrival) * 1000)}"
|
||||
)
|
||||
for attempt in range(1, WAKE_RETRIES + 1):
|
||||
# Retry while the sweep didn't conclusively advance anything:
|
||||
# pending verdicts (ack-window) AND fresh-wait skips (age gate).
|
||||
# Bounded, event-triggered — not a polling loop.
|
||||
if not pending and verified:
|
||||
break
|
||||
time.sleep(WAKE_RETRY_SECS)
|
||||
verified, advanced, pending = sweep()
|
||||
log(
|
||||
f"local wake {eid}: retry{attempt} verified={verified} "
|
||||
f"advanced={advanced} pending={pending} "
|
||||
f"total_ms={int((time.time() - arrival) * 1000)}"
|
||||
)
|
||||
return {"event_id": eid}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
if self.path == "/process-event":
|
||||
# T15 §15D: process-exit events from builder/controller
|
||||
# notifiers. Minimal dedicated endpoint — the Gitea webhook
|
||||
# path stays untouched. Payload: {operation_id, exit_code,
|
||||
# completed_at} + OPTIONAL provider/event_type/sha (T16
|
||||
# §16C/§16E: the CI adapter registers as provider="gitea",
|
||||
# event_type="ci_completed"; defaults preserve the T15 builder
|
||||
# identity so existing notifiers are unaffected). The event is
|
||||
# a WAKE, never truth — resumed controllers re-read canonical
|
||||
# authority before any verdict.
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length)
|
||||
try:
|
||||
payload = json.loads(body) if body else {}
|
||||
except json.JSONDecodeError:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
return
|
||||
event = {
|
||||
"provider": str(payload.get("provider", "builder")),
|
||||
"event_type": str(payload.get("event_type", "builder_exit")),
|
||||
"run_ref": str(payload.get("operation_id", "")) or None,
|
||||
"sha": payload.get("sha"),
|
||||
"exit_code": payload.get("exit_code"),
|
||||
"completed_at": payload.get("completed_at") or "",
|
||||
}
|
||||
event = {k: v for k, v in event.items() if v is not None}
|
||||
eid = waitstore.record_event(event)
|
||||
log(f"process-event {eid}: {json.dumps(event)[:140]}")
|
||||
# REAL_ASYNC_BRIDGE (R3): durable completion-file delivery.
|
||||
# Every event reaching the bridge is written as
|
||||
# resumes/<operation_id>.done so inotify waiters wake on the
|
||||
# kernel event (R4) with no FIFO/lease machinery, and the
|
||||
# event survives daemon AND waiter restarts (R9). Atomic
|
||||
# rename; duplicate events rewrite identical content (R10).
|
||||
_op_id = str(payload.get("operation_id", ""))
|
||||
if _op_id:
|
||||
try:
|
||||
# Stable identity only: duplicate deliveries of the
|
||||
# same logical event must produce byte-identical
|
||||
# completion files (R10 idempotence by construction).
|
||||
# completed_at is volatile and lives in the durable
|
||||
# event log; the file is a WAKE SIGNAL, never truth.
|
||||
_stable = {
|
||||
k: event.get(k)
|
||||
for k in ("provider", "event_type", "run_ref", "sha", "exit_code")
|
||||
if event.get(k) is not None
|
||||
}
|
||||
completion_file.write_completion(_op_id, _stable)
|
||||
except Exception as e: # noqa: BLE001 — delivery best-effort; wait-record transition + watchdog recovery still cover the lost file
|
||||
log(f"process-event {eid}: completion-file write failed: {e}")
|
||||
matched = 0
|
||||
for rec in waitstore.pending():
|
||||
if rec["status"] == "WAITING_EXTERNAL" and waitstore.match(rec, event):
|
||||
matched += 1
|
||||
try_transition(rec, event)
|
||||
# matched READY controller waits are dispatched IMMEDIATELY
|
||||
# (event-driven, no floor). Guard: only never-resumed waits —
|
||||
# a duplicate/late event must not double-dispatch (15E);
|
||||
# single-flight lease + this guard together make resume
|
||||
# idempotent.
|
||||
for rec in waitstore.pending():
|
||||
if rec["status"] == "READY_TO_RESUME" and rec.get("resume_cmd") and rec.get("resume_attempts", 0) == 0:
|
||||
threading.Thread(target=resume, args=(rec,), daemon=True, name=f"resume-{rec['wait_id']}").start()
|
||||
matched += 1
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"event_id": eid, "matched": matched}).encode())
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
return
|
||||
if self.path == "/wake":
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length)
|
||||
try:
|
||||
payload = json.loads(body) if body else {}
|
||||
except json.JSONDecodeError:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
return
|
||||
event = {
|
||||
"provider": "gitea-local-runner",
|
||||
"task_id": str(payload.get("task_id", "")) or None,
|
||||
"run_id": str(payload.get("run_id", "")) or None,
|
||||
"repository": str(payload.get("repository", "")) or None,
|
||||
"job_result": str(payload.get("job_result", "")) or None,
|
||||
}
|
||||
event = {k: v for k, v in event.items() if v}
|
||||
eid = waitstore.record_event(event)
|
||||
# Respond instantly; sweep on a worker thread. The hook is
|
||||
# fire-and-forget (curl -m 2) — a broken pipe on write is
|
||||
# harmless and must not propagate.
|
||||
try:
|
||||
threading.Thread(
|
||||
target=wake_sweep, args=(event, eid), daemon=True, name=f"wake-{payload.get('task_id', '?')}"
|
||||
).start()
|
||||
except Exception as e:
|
||||
log(f"wake spawn error: {e}")
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"queued": True, "event_id": eid}).encode())
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
return
|
||||
if self.path != "/webhook":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length)
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
return
|
||||
action = payload.get("action") or payload.get("event_type") or ""
|
||||
wf = payload.get("workflow_run") or {}
|
||||
event = {
|
||||
"provider": "gitea",
|
||||
"event_type": f"workflow.{action}" if wf else action,
|
||||
"sha": (wf.get("head_sha") or payload.get("sha") or "")[:12] or None,
|
||||
"run_ref": str(wf.get("id") or payload.get("run_number") or "") or None,
|
||||
}
|
||||
event = {k: v for k, v in event.items() if v}
|
||||
eid = waitstore.record_event(event)
|
||||
log(f"webhook event {eid}: {json.dumps(event)[:140]}")
|
||||
matched = 0
|
||||
for rec in waitstore.pending():
|
||||
if rec["status"] == "WAITING_EXTERNAL" and waitstore.match(rec, event):
|
||||
matched += 1
|
||||
try_transition(rec)
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"event_id": eid, "matched": matched}).encode())
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"ok":true}')
|
||||
return
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8791
|
||||
|
||||
# H (constraint 1): flock is the SINGLETON AUTHORITY. daemon.pid's
|
||||
# content is diagnostic metadata only — it never gates startup, never
|
||||
# implies liveness, and is overwritten after the exclusive lock is held.
|
||||
# Stale pid content (incl. SIGKILL debris) is thus harmless by design.
|
||||
PIDFILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_fd = os.open(PIDFILE, os.O_CREAT | os.O_RDWR, 0o644)
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
log("refusing start: another daemon holds the pidfile flock")
|
||||
print("daemon already running (pidfile flock held)", file=sys.stderr)
|
||||
return 1
|
||||
os.ftruncate(lock_fd, 0)
|
||||
os.write(lock_fd, f"{os.getpid()}\n".encode()) # diagnostic, lock held for life
|
||||
|
||||
startup_recovery()
|
||||
t = threading.Thread(target=reconciler, daemon=True)
|
||||
t.start()
|
||||
log(f"daemon starting on :{port}")
|
||||
srv = ThreadingHTTPServer(("0.0.0.0", port), Handler)
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+246
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""verify.py — authoritative Gitea verification (webhook ≠ success).
|
||||
|
||||
Queries the Gitea API for the TRUE state of the run/job/commit the wait
|
||||
references. Returns (verdict, evidence). verdict ∈ {success, failure,
|
||||
pending, unknown}. Only this module may move a wait out of
|
||||
WAITING_EXTERNAL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import json, subprocess
|
||||
from pathlib import Path
|
||||
|
||||
HOST = "https://gitea.thingscouldgetdicey.online"
|
||||
REPO = "conductor/rune"
|
||||
|
||||
|
||||
def _token() -> str:
|
||||
# Canonical ladder without importing Rune: env → token file → git store
|
||||
import os
|
||||
|
||||
tok = os.environ.get("GITEA_TOKEN", "").strip()
|
||||
if tok:
|
||||
return tok
|
||||
tf = os.path.expanduser("~/.config/opencode/secrets/gitea-token")
|
||||
if os.path.isfile(tf):
|
||||
return open(tf).read().strip()
|
||||
r = subprocess.run(
|
||||
["git", "credential", "fill"],
|
||||
input="protocol=https\nhost=gitea.thingscouldgetdicey.online\nusername=conductor\n\n",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
for line in r.stdout.splitlines():
|
||||
if line.startswith("password="):
|
||||
return line[9:]
|
||||
raise RuntimeError("no gitea credential available")
|
||||
|
||||
|
||||
def _api(path: str) -> tuple[int, object]:
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(f"{HOST}/api/v1/repos/{REPO}{path}", headers={"Authorization": f"token {_token()}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
return r.status, json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, {"error": e.read().decode()[:200]}
|
||||
|
||||
|
||||
def _jobs_list(run_ref: str):
|
||||
"""GET jobs for a run, unwrapping the {'jobs': [...]} envelope."""
|
||||
s, data = _api(f"/actions/runs/{run_ref}/jobs")
|
||||
if s != 200:
|
||||
return s, None
|
||||
if isinstance(data, dict):
|
||||
data = data.get("jobs", [])
|
||||
return s, data if isinstance(data, list) else None
|
||||
|
||||
|
||||
def _resolve_run_id(run_ref: str, sha: str) -> str:
|
||||
"""Task-list ids are NOT run ids. If run_ref lookup 404s, resolve the
|
||||
real run id by head SHA via /actions/runs (T15: run 6907 vs task 15743)."""
|
||||
if run_ref:
|
||||
s, jobs = _jobs_list(run_ref)
|
||||
if s == 200 and jobs:
|
||||
return run_ref
|
||||
if sha:
|
||||
s, runs = _api(f"/actions/runs?limit=30")
|
||||
items = runs.get("workflow_runs", []) if isinstance(runs, dict) else (runs if isinstance(runs, list) else [])
|
||||
# One SHA fans out to multiple runs (lint + deploy-only). Prefer a
|
||||
# substantive (non-skipped) run; a skipped-only run proves nothing.
|
||||
fallback = ""
|
||||
for r in items:
|
||||
if str(r.get("head_sha", "")).startswith(sha):
|
||||
rid = str(r.get("id"))
|
||||
if r.get("conclusion") != "skipped":
|
||||
return rid
|
||||
fallback = fallback or rid
|
||||
return fallback
|
||||
return ""
|
||||
|
||||
|
||||
def verify_run(run_ref: str, sha: str, expected: str) -> tuple[str, dict]:
|
||||
"""run_ref = actions run id (or task id — resolved via SHA lookup).
|
||||
|
||||
Defect fixed 2026-09-12 (surfaced by W/R-v0.3 live proof, run 7110):
|
||||
the jobs query here used raw _api + isinstance(jobs, list), but this
|
||||
Gitea wraps jobs in the {"jobs": [...]} envelope — the check always
|
||||
failed, so EVERY EXACT_WORKFLOW verdict silently fell through to the
|
||||
aggregate commit-status fallback, where unrelated runs on the same
|
||||
SHA (multi-run fan-out: ci.yml + deploy-only) poison the verdict.
|
||||
Use _jobs_list (unwraps the envelope), same as _resolve_run_id."""
|
||||
run_ref = _resolve_run_id(run_ref, sha)
|
||||
if run_ref:
|
||||
s, jobs = _jobs_list(run_ref)
|
||||
if s == 200 and isinstance(jobs, list) and jobs:
|
||||
conclusions = {j.get("name"): j.get("conclusion") for j in jobs}
|
||||
statuses = {j.get("status") for j in jobs}
|
||||
if statuses <= {"completed"}:
|
||||
verdict = "success" if set(conclusions.values()) <= {"success", "skipped"} else "failure"
|
||||
return verdict, {"run_ref": run_ref, "jobs": conclusions}
|
||||
return "pending", {"run_ref": run_ref, "statuses": sorted(statuses)}
|
||||
if sha:
|
||||
s, st = _api(f"/commits/{sha}/status")
|
||||
if s == 200 and isinstance(st, dict):
|
||||
state = st.get("state", "unknown") # success|error|failure|pending|warning
|
||||
rid = _resolve_run_id("", sha) # attach run id so failure evidence works
|
||||
if state == "success":
|
||||
return "success", {"sha": sha, "state": state, "run_ref": rid}
|
||||
if state in ("error", "failure"):
|
||||
return "failure", {"sha": sha, "state": state, "run_ref": rid}
|
||||
return "pending", {"sha": sha, "state": state}
|
||||
return "unknown", {"run_ref": run_ref, "sha": sha}
|
||||
|
||||
|
||||
def failed_job_evidence(run_ref: str, sha: str = "") -> dict:
|
||||
"""For failure continuations: exact failing job + conclusion."""
|
||||
run_ref = _resolve_run_id(run_ref, sha)
|
||||
if not run_ref:
|
||||
return {}
|
||||
s, jobs = _jobs_list(run_ref)
|
||||
if s != 200 or not isinstance(jobs, list):
|
||||
return {}
|
||||
bad = [j for j in jobs if j.get("conclusion") not in ("success", "skipped", None)]
|
||||
return {
|
||||
"failed_jobs": [
|
||||
{"name": j.get("name"), "conclusion": j.get("conclusion"), "url": j.get("url", "")} for j in bad
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ── RELEASE_DEPLOY verification (W/R v0.1, overnight handoff Part 2) ──────
|
||||
# Aggregate commit status is NOT a valid completion authority for Rune's
|
||||
# release/deploy flow: the release successor SHA, not the source SHA, is what
|
||||
# converges (observed live: wr-1789179026 stranded on pending while 0.66.15
|
||||
# was deployed). The authority chain is:
|
||||
# source SHA → source runs terminal → release successor commit (descendant)
|
||||
# → successor runs success → deployed/current identity converges.
|
||||
|
||||
|
||||
def _git(args: list[str], repo_root: str) -> str:
|
||||
import subprocess
|
||||
|
||||
r = subprocess.run(["git"] + args, capture_output=True, text=True, cwd=repo_root or None, timeout=15)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"git {' '.join(args[:2])}: {r.stderr.strip()[:120]}")
|
||||
return r.stdout
|
||||
|
||||
|
||||
def _runs_for_sha(sha: str) -> list[dict]:
|
||||
s, runs = _api("/actions/runs?limit=50")
|
||||
items = runs.get("workflow_runs", []) if isinstance(runs, dict) else []
|
||||
return [r for r in items if str(r.get("head_sha", "")).startswith(sha)]
|
||||
|
||||
|
||||
def _sha_verdict_from_runs(runs: list[dict]) -> tuple[str, dict]:
|
||||
"""success | failure | pending from a SHA's workflow runs.
|
||||
Terminal verdict ONLY when every run is completed — mixed states
|
||||
(some success, some still in flight) must stay PENDING (observed live:
|
||||
a SHA with 1 completed-success + 1 queued + 1 in-progress was judged
|
||||
success mid-flight; wr-1789182522 2026-09-12)."""
|
||||
if not runs:
|
||||
return "pending", {"note": "no runs found"}
|
||||
non_terminal = [r for r in runs if r.get("status") != "completed"]
|
||||
if non_terminal:
|
||||
return "pending", {"in_flight": len(non_terminal)}
|
||||
conclusions = {r.get("name"): r.get("conclusion") for r in runs}
|
||||
bad = {n: c for n, c in conclusions.items() if c == "failure"}
|
||||
if bad:
|
||||
return "failure", {"failed": bad}
|
||||
return "success", {"runs": conclusions}
|
||||
|
||||
|
||||
def verify_release_deploy(source_sha: str, repo_root: str = "", deployed_link: str = "") -> tuple[str, dict]:
|
||||
"""RELEASE_DEPLOY authority chain. Returns (verdict, evidence)."""
|
||||
source_sha = (source_sha or "")[:12]
|
||||
ev: dict = {"source_sha": source_sha, "kind": "RELEASE_DEPLOY"}
|
||||
|
||||
# 1. Source SHA's own runs must be terminal and healthy.
|
||||
src_runs = _runs_for_sha(source_sha)
|
||||
src_verdict, src_ev = _sha_verdict_from_runs(src_runs)
|
||||
ev["source"] = src_ev
|
||||
if src_verdict != "success":
|
||||
return src_verdict, ev
|
||||
|
||||
# 2. Release successor: first chore(release) commit descending from source.
|
||||
if not repo_root:
|
||||
return "pending", {**ev, "note": "no repo_root — cannot check lineage"}
|
||||
try:
|
||||
out = _git(
|
||||
[
|
||||
"log",
|
||||
"--format=%H %s",
|
||||
"--reverse",
|
||||
f"{source_sha}..master",
|
||||
"--grep=^chore\\(release\\)",
|
||||
"--extended-regexp",
|
||||
],
|
||||
repo_root,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
return "pending", {**ev, "note": str(e)}
|
||||
successors = [l for l in out.splitlines() if l.strip()]
|
||||
if not successors:
|
||||
return "pending", {**ev, "note": "no release successor yet"}
|
||||
succ_line = successors[0]
|
||||
succ_sha_full, _, succ_subject = succ_line.partition(" ")
|
||||
succ_sha = succ_sha_full[:12]
|
||||
ev["successor"] = {"sha": succ_sha, "subject": succ_subject[:80]}
|
||||
|
||||
# Ancestry is guaranteed by the range, but verify explicitly (deterministic
|
||||
# lineage over title guessing):
|
||||
try:
|
||||
_git(["merge-base", "--is-ancestor", source_sha, succ_sha_full], repo_root)
|
||||
except RuntimeError:
|
||||
return "failure", {**ev, "note": "successor is not a descendant of source"}
|
||||
|
||||
# 3. Successor runs must be terminal and healthy.
|
||||
succ_runs = _runs_for_sha(succ_sha)
|
||||
succ_verdict, succ_ev = _sha_verdict_from_runs(succ_runs)
|
||||
ev["successor"]["runs"] = succ_ev
|
||||
if succ_verdict != "success":
|
||||
return succ_verdict, ev
|
||||
|
||||
# 4. Deployed identity convergence. A mismatch is FAILURE only when the
|
||||
# successor's runs are ALL completed — if any is still running/queued the
|
||||
# deploy may simply not have propagated yet → PENDING (observed live:
|
||||
# reconciler checked mid-deploy, verdict failure, deployment converged
|
||||
# ~2 min later; wr-1789182522 2026-09-12).
|
||||
if deployed_link:
|
||||
link = Path(deployed_link)
|
||||
if link.is_symlink() or link.is_dir():
|
||||
deployed = str(link.resolve())
|
||||
deployed_id = Path(deployed).name
|
||||
ev["deployed"] = deployed_id
|
||||
if not deployed_id.startswith(succ_sha[:12]):
|
||||
still = [r for r in _runs_for_sha(succ_sha) if r.get("status") != "completed"]
|
||||
if still:
|
||||
return "pending", {**ev, "note": "deploy propagating (runs in flight)"}
|
||||
return "failure", {**ev, "note": f"deployed {deployed_id} != successor {succ_sha} (all runs terminal)"}
|
||||
else:
|
||||
return "failure", {**ev, "note": f"deployed link missing: {deployed_link}"}
|
||||
|
||||
return "success", ev
|
||||
Executable
+337
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env python3
|
||||
"""waitstore.py — wait/resume state machine v0.2 (developer tooling, NOT Rune).
|
||||
|
||||
States:
|
||||
RUNNABLE → WAITING_EXTERNAL → READY_TO_RESUME → RESUMING → COMPLETED | FAILED
|
||||
↑ │
|
||||
│ ├─(watch timeout, tree killed)
|
||||
│ └─(rc!=0 / unverifiable)
|
||||
READY_TO_RETRY ←─────┘
|
||||
RESUME_UNVERIFIED : delivery after crash/timeout cannot be proven.
|
||||
TERMINAL-ish — never auto-scheduled; explicit
|
||||
`requeue()` required to make it READY_TO_RESUME.
|
||||
TIMED_OUT : external event never arrived within the wait budget.
|
||||
|
||||
Waits persist as waits/<wait_id>.json (atomic tmp+rename).
|
||||
Leases persist as leases/<session_id>.lease.json (atomic tmp+rename,
|
||||
validated against /proc so a dead or PID-reused owner is stealable).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import fcntl, json, os, tempfile, time, uuid
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(os.environ.get("WR_ROOT", "") or Path(__file__).resolve().parent)
|
||||
WAITS = ROOT / "waits"
|
||||
EVENTS = ROOT / "events"
|
||||
LEASES = ROOT / "leases"
|
||||
|
||||
SCHEDULABLE = {"READY_TO_RESUME", "READY_TO_RETRY"}
|
||||
VALID_STATES = {
|
||||
"RUNNABLE",
|
||||
"WAITING_EXTERNAL",
|
||||
"READY_TO_RESUME",
|
||||
"RESUMING",
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"READY_TO_RETRY",
|
||||
"RESUME_UNVERIFIED",
|
||||
"TIMED_OUT",
|
||||
}
|
||||
TERMINAL = {"COMPLETED", "FAILED", "RESUME_UNVERIFIED", "TIMED_OUT"}
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".tmp-")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(data, f, indent=1, sort_keys=True)
|
||||
os.replace(tmp, path)
|
||||
# T17 17C: the bridge root is SHARED across users (deploy waiter
|
||||
# runs as the runner user; daemon as nick). mkstemp creates 0600 —
|
||||
# daemon re-saves locked the runner user out of records it must
|
||||
# read (waitstore.load in await_resume_event; find_matching_event
|
||||
# replay scan silently skipped them). 0644: owner writes, all read.
|
||||
os.chmod(path, 0o644)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def new_wait(
|
||||
*,
|
||||
session_id: str,
|
||||
provider: str,
|
||||
event_type: str,
|
||||
repo: str,
|
||||
sha: str = "",
|
||||
run_ref: str = "",
|
||||
expected: str = "success",
|
||||
wait_kind: str = "EXACT_WORKFLOW",
|
||||
target_kind: str = "interactive",
|
||||
repo_root: str = "",
|
||||
deployed_link: str = "",
|
||||
objective: str = "",
|
||||
waited_for: str = "",
|
||||
verify_when_woken: str = "",
|
||||
on_success: str = "",
|
||||
on_failure: str = "",
|
||||
resume_cmd: list | None = None,
|
||||
) -> dict:
|
||||
wid = f"wr-{int(time.time())}-{uuid.uuid4().hex[:6]}"
|
||||
rec = {
|
||||
"wait_id": wid,
|
||||
"session_id": session_id,
|
||||
"created_at": now(),
|
||||
"provider": provider,
|
||||
"event_type": event_type,
|
||||
"repo": repo,
|
||||
"sha": sha,
|
||||
"run_ref": run_ref,
|
||||
"expected": expected,
|
||||
# T15 §15D: PROCESS_EVENT — a wait whose authoritative verdict is
|
||||
# the matching process-exit event itself (exit_code carried on the
|
||||
# event), e.g. a builder/controller process completing. The event
|
||||
# IS the authority here (the notifier wrapper observed the real
|
||||
# process exit); no Gitea lookup applies.
|
||||
"wait_kind": wait_kind
|
||||
if wait_kind in ("EXACT_WORKFLOW", "RELEASE_DEPLOY", "PROCESS_EVENT")
|
||||
else "EXACT_WORKFLOW",
|
||||
# CONTENTION POLICY: "interactive" (default, conservative) never
|
||||
# auto-dispatches — MiMo exposes NO supported busy/idle session
|
||||
# signal, so injecting into a possibly-busy interactive session is
|
||||
# NOT YET SAFE. Only explicitly declared headless/disposable
|
||||
# targets ("headless") are auto-resumed.
|
||||
"target_kind": target_kind if target_kind in ("headless", "interactive") else "interactive",
|
||||
"repo_root": repo_root,
|
||||
"deployed_link": deployed_link,
|
||||
# T15 §15D: per-wait resume command override (placeholder template,
|
||||
# same substitution as WR_RESUME_CMD: {session_id}/{msg}/{wait_id}).
|
||||
# A synthetic controller session (e.g. a Scaffolding stage driver)
|
||||
# is NOT a MiMo session — the global mimo-shaped default cannot
|
||||
# resume it, which is exactly why fixed-sleep polling crept back
|
||||
# in T12-T14. Per-wait override makes ANY registered continuation
|
||||
# target resumable through the same bridge.
|
||||
"resume_cmd": list(resume_cmd) if resume_cmd else None,
|
||||
"status": "WAITING_EXTERNAL",
|
||||
"last_verified": "",
|
||||
"resume_attempts": 0,
|
||||
"continuation": {
|
||||
"objective": objective,
|
||||
"waited_for": waited_for,
|
||||
"verify_when_woken": verify_when_woken,
|
||||
"on_success": on_success,
|
||||
"on_failure": on_failure,
|
||||
},
|
||||
}
|
||||
_atomic_write(WAITS / f"{wid}.json", rec)
|
||||
return rec
|
||||
|
||||
|
||||
def load(wid: str) -> dict | None:
|
||||
p = WAITS / f"{wid}.json"
|
||||
return json.loads(p.read_text()) if p.is_file() else None
|
||||
|
||||
|
||||
def save(rec: dict) -> None:
|
||||
assert rec.get("status") in VALID_STATES, rec.get("status")
|
||||
rec["last_updated"] = now()
|
||||
_atomic_write(WAITS / f"{rec['wait_id']}.json", rec)
|
||||
|
||||
|
||||
def all_waits() -> list[dict]:
|
||||
out = []
|
||||
for p in WAITS.glob("*.json"):
|
||||
try:
|
||||
out.append(json.loads(p.read_text()))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def pending() -> list[dict]:
|
||||
return [r for r in all_waits() if r.get("status") in {"WAITING_EXTERNAL"} | SCHEDULABLE]
|
||||
|
||||
|
||||
def schedulable() -> list[dict]:
|
||||
"""READY waits, oldest first — deterministic queue order."""
|
||||
rs = [r for r in all_waits() if r.get("status") in SCHEDULABLE]
|
||||
rs.sort(key=lambda r: (r.get("created_at", ""), r.get("wait_id", "")))
|
||||
return rs
|
||||
|
||||
|
||||
def requeue(wid: str, note: str) -> dict | None:
|
||||
"""Operator-explicit requeue of a RESUME_UNVERIFIED/RESUME_FAILED wait."""
|
||||
rec = load(wid)
|
||||
if not rec:
|
||||
return None
|
||||
rec["status"] = "READY_TO_RESUME"
|
||||
rec["resume_note"] = note
|
||||
save(rec)
|
||||
return rec
|
||||
|
||||
|
||||
# ── events (wake signals, idempotent) ─────────────────────────────────────
|
||||
|
||||
|
||||
def record_event(event: dict) -> str:
|
||||
import hashlib
|
||||
|
||||
body = json.dumps(event, sort_keys=True)
|
||||
eid = hashlib.sha256(body.encode()).hexdigest()[:16]
|
||||
f = EVENTS / f"{eid}.json"
|
||||
if not f.is_file():
|
||||
_atomic_write(f, {"event_id": eid, "received_at": now(), "event": event})
|
||||
return eid
|
||||
|
||||
|
||||
def match(rec: dict, event: dict) -> bool:
|
||||
if rec.get("provider") != event.get("provider"):
|
||||
return False
|
||||
if rec.get("event_type") and event.get("event_type") and rec["event_type"] != event["event_type"]:
|
||||
return False
|
||||
if rec.get("sha") and event.get("sha") and rec["sha"] != event["sha"]:
|
||||
return False
|
||||
if rec.get("run_ref") and event.get("run_ref") and rec["run_ref"] != event["run_ref"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ── leases (per-session single-flight, /proc-validated) ───────────────────
|
||||
#
|
||||
# Invariant 3 (PID reuse): liveness is NEVER "PID exists". The lease records
|
||||
# pid + pgid + process starttime (/proc/<pid>/stat field 22); all three must
|
||||
# match or the owner is a different process wearing a recycled PID.
|
||||
#
|
||||
# Invariant 1 (flock as authority): the lease file's flock serializes
|
||||
# read-modify-write across processes and dies with its holder, so a crashed
|
||||
# daemon can never wedge the lock. The lease JSON + /proc decide who OWNS the
|
||||
# session; the flock only prevents racing acquirers from double-stealing.
|
||||
|
||||
_lease_fds: dict[str, int] = {} # session_id → open fd holding the flock
|
||||
|
||||
|
||||
def _pid_starttime(pid: int) -> str | None:
|
||||
"""Field 22 of /proc/<pid>/stat — distinguishes PID reuse."""
|
||||
try:
|
||||
stat = Path(f"/proc/{pid}/stat").read_text()
|
||||
return stat.rsplit(")", 1)[1].split()[19]
|
||||
except (OSError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _owner_alive(lease: dict) -> bool:
|
||||
pid = lease.get("pid")
|
||||
if not pid or not Path(f"/proc/{pid}").is_dir():
|
||||
return False
|
||||
if _pid_starttime(pid) != lease.get("pid_starttime"):
|
||||
return False # PID reused by an unrelated process
|
||||
try:
|
||||
if os.getpgid(pid) != lease.get("pgid"):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def lease_path(session_id: str) -> Path:
|
||||
# T16 §16C finding (observed live in daemon.log): session ids like
|
||||
# "controller-<op>-Verboo Code/glm-5.3-flash" contain "/" — the raw
|
||||
# embed made lease creation raise FileNotFoundError, so resume
|
||||
# delivery for those waits could never succeed. Sanitize to a flat
|
||||
# filesystem-safe name; the hash suffix prevents collisions between
|
||||
# ids that sanitize identically.
|
||||
import hashlib as _hashlib
|
||||
import re as _re
|
||||
|
||||
safe = _re.sub(r"[^A-Za-z0-9._-]+", "_", session_id)[:80]
|
||||
digest = _hashlib.sha256(session_id.encode()).hexdigest()[:10]
|
||||
return LEASES / f"{safe}-{digest}.lease.json"
|
||||
|
||||
|
||||
def read_lease(session_id: str) -> dict | None:
|
||||
p = lease_path(session_id)
|
||||
if not p.is_file():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def _locked_lease_fd(session_id: str) -> int | None:
|
||||
"""Open the lease file and take its flock (non-blocking). The fd stays
|
||||
open (lock held) until release_lease closes it."""
|
||||
if session_id in _lease_fds:
|
||||
return _lease_fds[session_id] # already hold it
|
||||
LEASES.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(lease_path(session_id), os.O_CREAT | os.O_RDWR, 0o600)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
os.close(fd)
|
||||
return None # another process holds the lock — mid-acquire
|
||||
_lease_fds[session_id] = fd
|
||||
return fd
|
||||
|
||||
|
||||
def _write_lease_locked(session_id: str, lease: dict) -> None:
|
||||
fd = _lease_fds[session_id]
|
||||
os.ftruncate(fd, 0)
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
os.write(fd, json.dumps(lease, indent=1, sort_keys=True).encode())
|
||||
os.fsync(fd)
|
||||
|
||||
|
||||
def acquire_lease(session_id: str, wait_id: str, pid: int, pgid: int) -> dict | None:
|
||||
"""Atomically create or steal the session lease. Returns the lease dict,
|
||||
or None if a live owner holds it (or another process is mid-steal).
|
||||
Staleness is decided by /proc identity evidence, never by age alone."""
|
||||
fd = _locked_lease_fd(session_id)
|
||||
if fd is None:
|
||||
return None
|
||||
existing = read_lease(session_id)
|
||||
if existing and _owner_alive(existing):
|
||||
_lease_fds.pop(session_id, None)
|
||||
os.close(fd)
|
||||
return None # live worker owns this session
|
||||
lease = {
|
||||
"session_id": session_id,
|
||||
"wait_id": wait_id,
|
||||
"pid": pid,
|
||||
"pgid": pgid,
|
||||
"pid_starttime": _pid_starttime(pid),
|
||||
"acquired_at": now(),
|
||||
"state": "RESUMING",
|
||||
}
|
||||
_write_lease_locked(session_id, lease)
|
||||
return lease
|
||||
|
||||
|
||||
def update_lease(session_id: str, **fields) -> None:
|
||||
lease = read_lease(session_id) or {}
|
||||
lease.update(fields)
|
||||
_write_lease_locked(session_id, lease)
|
||||
|
||||
|
||||
def release_lease(session_id: str, wait_id: str) -> bool:
|
||||
"""Release only if the current lease belongs to this wait (no stealing
|
||||
a successor's lease during a slow shutdown path). Closes the flock fd."""
|
||||
lease = read_lease(session_id)
|
||||
if not (lease and lease.get("wait_id") == wait_id):
|
||||
return False
|
||||
fd = _lease_fds.pop(session_id, None)
|
||||
if fd is not None:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
os.close(fd)
|
||||
lease_path(session_id).unlink(missing_ok=True)
|
||||
return True
|
||||
@@ -0,0 +1,124 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import { existsSync, appendFileSync } from "node:fs"
|
||||
|
||||
// builder-bridge — Rune Core preflight for the MiMo builder (handoff
|
||||
// 2026-09-10 §3–§7, Option C bridge tranche). For each user turn, the hook
|
||||
// extracts the user's message from the outgoing LLM request, runs the Rune
|
||||
// Core builder-bridge preflight (deterministic: route classification +
|
||||
// memory selection + active-Scaffolding projection, ZERO model calls), and
|
||||
// appends a compact capsule to the final user message. Same Rune Core the
|
||||
// TUI/operator path uses — this file is a thin adapter with no cognition.
|
||||
//
|
||||
// CANONICAL SOURCE: config/mimo-hooks/builder-bridge.ts
|
||||
// Installed to ~/.mimocode/hooks/builder-bridge.ts by
|
||||
// `scripts/install_mimo_hook.py` (hash-verified, drift-detecting — the same
|
||||
// contract as event-wait-guard.ts). T22: this file was previously
|
||||
// UNVERSIONED and hardcoded an ephemeral interpreter path (/tmp/ci-venv),
|
||||
// which silently killed the whole edge; both defects are repaired here.
|
||||
//
|
||||
// Overhead contract (§7/§8): capsule ≤ ~300 tokens; a repeat/empty task
|
||||
// costs ~0ms (TTL cache); a fresh task ~150ms (bounded retrieval). The hook
|
||||
// degrades silently to no-injection on any failure — it must never block
|
||||
// the builder.
|
||||
//
|
||||
// Injection policy: only append when the capsule is non-empty. A plain
|
||||
// "hello" gets nothing (§5: if nothing relevant exists, inject nothing).
|
||||
|
||||
const BRIDGE = "/mnt/workdrive/issue161-harness/src/rune/builder_bridge_cli.py"
|
||||
const MARKER_PREFIX = "/tmp/rune-bridge-"
|
||||
|
||||
// Interpreter resolution (T22 repair): the previous hardcoded
|
||||
// "/tmp/ci-venv/bin/python" is an EPHEMERAL path that no longer exists, so
|
||||
// every bridge call failed and the hook silently degraded — the MiMo edge
|
||||
// received no Rune Core preflight at all (zero fire-log lines). Resolve
|
||||
// against durable candidates, first existing wins; env override wins over
|
||||
// all of them. If nothing exists the hook degrades as before.
|
||||
const PY_CANDIDATES = [
|
||||
process.env.MIMO_BRIDGE_PY,
|
||||
"/mnt/workdrive/issue161-harness/.test-venv/bin/python",
|
||||
"/home/nick/.venvs/memory/bin/python3",
|
||||
"/usr/bin/python3",
|
||||
].filter((p) => typeof p === "string" && p.length > 0)
|
||||
|
||||
function resolvePy() {
|
||||
for (const p of PY_CANDIDATES) {
|
||||
try {
|
||||
if (existsSync(p)) return p
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Per-process dedupe: identical task text within the cache TTL does not need
|
||||
// a second bridge call — the CLI has its own TTL cache, but skipping the
|
||||
// process spawn entirely for the same message text within 10 minutes keeps
|
||||
// repeats at ~0ms.
|
||||
const lastText = new Map() // text -> timestamp
|
||||
const TTL = 600_000
|
||||
|
||||
function extractUserText(messages) {
|
||||
// messages: Array<{ info: Message, parts: Part[] }> — newest last.
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]
|
||||
if (m?.info?.role !== "user") continue
|
||||
const texts = []
|
||||
for (const p of m.parts ?? []) {
|
||||
if (p?.type === "text" && typeof p.text === "string") texts.push(p.text)
|
||||
}
|
||||
if (texts.length) return texts.join("\n").slice(0, 4000)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
export default {
|
||||
"experimental.chat.messages.transform": async (input, output) => {
|
||||
try {
|
||||
if (!Array.isArray(output.messages) || output.messages.length === 0) return
|
||||
const userText = extractUserText(output.messages)
|
||||
if (!userText || userText.length < 12) return // trivial/noise guard
|
||||
// Harness-internal steps (checkpoint writer, compaction, system
|
||||
// reminders) are not builder tasks — no capsule (measured: the fire
|
||||
// log showed checkpoint-writer steps receiving capsules 2026-09-10).
|
||||
if (/^(<system-reminder>|EXTRACTION REQUIRED)/.test(userText.trim())) return
|
||||
const now = Date.now()
|
||||
const prev = lastText.get(userText)
|
||||
if (prev !== undefined && now - prev < TTL) return // dedupe repeats
|
||||
lastText.set(userText, now)
|
||||
if (lastText.size > 64) {
|
||||
// evict oldest
|
||||
let oldestKey = null, oldestVal = Infinity
|
||||
for (const [k, v] of lastText) if (v < oldestVal) { oldestVal = v; oldestKey = k }
|
||||
if (oldestKey) lastText.delete(oldestKey)
|
||||
}
|
||||
|
||||
const py = resolvePy()
|
||||
if (!py) return // no durable interpreter — degrade (never block)
|
||||
|
||||
const capsule = await new Promise((resolve) => {
|
||||
execFile(py, [BRIDGE, userText], { timeout: 15_000 }, (err, stdout) => {
|
||||
resolve(err ? "" : (stdout?.toString() ?? ""))
|
||||
})
|
||||
})
|
||||
if (!capsule.trim()) return
|
||||
|
||||
// Durable fire-trace: one line per ACTUAL injection into an outgoing
|
||||
// model request (handoff §1 proof requirement). Not a canary — this is
|
||||
// standing observability for the bridge's consumer edge.
|
||||
try {
|
||||
appendFileSync("/tmp/rune-bridge-fire.log",
|
||||
JSON.stringify({ ts: new Date().toISOString(), task: userText.slice(0, 80), capsule_tokens: Math.round(capsule.length / 4), py }) + "\n")
|
||||
} catch {}
|
||||
|
||||
// Append the capsule to the LAST user message (the one being answered).
|
||||
const last = output.messages[output.messages.length - 1]
|
||||
if (last?.parts && Array.isArray(last.parts)) {
|
||||
last.parts.push({
|
||||
type: "text",
|
||||
text: "\n\n<rune-preflight>\n" + capsule.trim() + "\n</rune-preflight>\n(Injected by Rune Core builder bridge — deterministic preflight, zero model calls. Relevant state above; ignore if not applicable to this task.)",
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// never block the builder on bridge failure
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Hooks } from "@mimo-ai/plugin"
|
||||
|
||||
// event-wait-guard — RETIRED (T21.5 §6/§8, 2026-09-17).
|
||||
//
|
||||
// HISTORY: this hook was the T21 §21B-3 runtime interception layer that
|
||||
// blocked model-generated sleep→poll continuation (T20 regression), and
|
||||
// later carried the T21.1 §21.1C cwd guard. Both were DOWNSTREAM patches:
|
||||
// they corrected the model's output instead of fixing the generator.
|
||||
//
|
||||
// ROOT-CAUSE FIX (T21.5 §2-§5): the generator was ci_event_wait's
|
||||
// wait loop returning nonterminal control to the model — caused by a
|
||||
// single hard-coded master-deploy authority that was blind to branch/PR
|
||||
// operations. T21.5 fixed this upstream:
|
||||
// - OPERATION_SPECS: per-operation-type completion authority
|
||||
// (branch_ci/pull_request_checks -> own Actions runs;
|
||||
// master_ci/deploy/release -> deploy qualification);
|
||||
// - SINGLE LOGICAL AWAIT: all nonterminal conditions re-arm inside
|
||||
// ci_event_wait.run(); the model sees only terminal results;
|
||||
// - structured PendingOperation envelopes (op_type, authority,
|
||||
// internal_rearm_count) propagate to the controller.
|
||||
//
|
||||
// With the generator fixed, runtime interception is retired per the
|
||||
// tranche rule FIXED UPSTREAM BEHAVIOUR > DOWNSTREAM GUARD CORRECTION.
|
||||
// Regression prevention lives in the test suite:
|
||||
// tests/test_single_logical_await.py (no model re-arm contract)
|
||||
// tests/test_ci_event_wait.py (watchdog=recovery, event=wake)
|
||||
// tests/test_wait_guard_corpus.py (canonical wait-pattern corpus)
|
||||
// NOT as a permanent runtime block. The hook file stays installed as a
|
||||
// documented no-op so canonical-source/install parity (test_hook_drift,
|
||||
// test_hook_parity) continues to prove the installed runtime matches
|
||||
// this repository's canonical state.
|
||||
|
||||
export const EventWaitGuard: Hooks = {
|
||||
"tool.execute.before": async (_input, _output) => {
|
||||
// RETIRED — no interception. See history above.
|
||||
return
|
||||
},
|
||||
}
|
||||
|
||||
export const CwdGuard: Hooks = {
|
||||
"tool.execute.before": async (_input, _output) => {
|
||||
// RETIRED (T21.5 §8) — the harness/construction execution boundary
|
||||
// now resolves project context upstream (execution-context binding).
|
||||
// No runtime refusal. See tests/test_auto_execution_context.py.
|
||||
return
|
||||
},
|
||||
}
|
||||
|
||||
export const CombinedGuards: Hooks = {
|
||||
"tool.execute.before": async (input, output) => {
|
||||
// Kept as the default export shape; both sub-guards are retired.
|
||||
return
|
||||
},
|
||||
}
|
||||
|
||||
export default CombinedGuards
|
||||
@@ -0,0 +1,92 @@
|
||||
import { tool } from "@mimo-ai/plugin"
|
||||
import { execSync } from "child_process"
|
||||
|
||||
export default tool({
|
||||
description: "Wait for an external operation (CI, deploy, release) to complete. Call this when you cannot make progress until an external process finishes. You do NOT need to know the sha, op_type, or duration — Rune's controller resolves those from the bound PendingOperation. Just call this when you want to wait.",
|
||||
args: {
|
||||
operation_id: tool.schema.string().optional().describe("Optional: the operation to wait for. If omitted, the controller resolves the current pending operation automatically."),
|
||||
task_id: tool.schema.string().optional().describe("Optional: the active task id (e.g. T4). When provided, resolution is scoped to operations bound to this task — stale global operations are never consulted."),
|
||||
sha: tool.schema.string().optional().describe("OPTIONAL HINT — non-authoritative. The controller uses this only to help resolve which operation you mean."),
|
||||
op_type: tool.schema.string().optional().describe("OPTIONAL HINT — non-authoritative. The controller derives the canonical operation type from repository/Gitea state."),
|
||||
deadline_secs: tool.schema.number().optional().describe("OPTIONAL HINT — non-authoritative. The controller applies governed policy timing. Real user deadlines must be stated in conversation."),
|
||||
},
|
||||
async execute(args) {
|
||||
// INTENT NORMALIZATION — model output is intent; the controller
|
||||
// owns execution semantics. All model args are hints recorded for
|
||||
// observability; canonical truth comes from the bound operation.
|
||||
const parts = []
|
||||
if (args.operation_id) parts.push(`--operation-id ${args.operation_id}`)
|
||||
if (args.task_id) parts.push(`--task-id ${args.task_id}`)
|
||||
if (args.sha) parts.push(`--sha ${args.sha}`)
|
||||
if (args.op_type) parts.push(`--model-op-type ${args.op_type}`)
|
||||
if (args.deadline_secs) parts.push(`--model-deadline ${args.deadline_secs}`)
|
||||
const normCmd = `python3 /mnt/workdrive/issue161-harness/scripts/await_intent_cli.py normalize ${parts.join(" ")}`
|
||||
let norm: any
|
||||
try {
|
||||
const normOut = execSync(normCmd, {
|
||||
cwd: "/mnt/workdrive/issue161-harness",
|
||||
encoding: "utf-8" as const,
|
||||
timeout: 120000,
|
||||
env: { ...process.env, RUNE_WR_ROOT: "/mnt/workdrive/wr-bridge" },
|
||||
})
|
||||
norm = JSON.parse(normOut.trim().split("\n").find((l: string) => l.startsWith("{")) || "{}")
|
||||
} catch (e: any) {
|
||||
return JSON.stringify({ ok: false, stage: "normalization", error: (e.stderr || e.message || "?").slice(0, 300) })
|
||||
}
|
||||
if (norm.normalized === "AMBIGUOUS") {
|
||||
return JSON.stringify({ ok: false, stage: "normalization", ...norm })
|
||||
}
|
||||
if (!norm.normalized) {
|
||||
return JSON.stringify({ ok: false, stage: "normalization", ...norm })
|
||||
}
|
||||
// canonical continuation: stored/derived op_type + POLICY deadline
|
||||
// (the sha for the wait comes from the normalization resolution,
|
||||
// NOT from the model's args)
|
||||
const waitSha = norm.sha_for_wait || args.sha || ""
|
||||
if (!waitSha) {
|
||||
return JSON.stringify({ ok: false, stage: "resolution", error: "no sha resolved for the pending operation", norm })
|
||||
}
|
||||
const cmd = `python3 /mnt/workdrive/issue161-harness/scripts/ci_event_wait.py --sha ${waitSha} --deadline ${norm.deadline_secs} --op-type ${norm.op_type}`
|
||||
try {
|
||||
const result = execSync(cmd, {
|
||||
cwd: "/mnt/workdrive/issue161-harness",
|
||||
timeout: (norm.deadline_secs + 60) * 1000,
|
||||
encoding: "utf-8" as const,
|
||||
env: { ...process.env, DEPLOY_SHA: waitSha, RUNE_WR_ROOT: "/mnt/workdrive/wr-bridge", RUNE_WR_CODE: "/home/nick/.local/share/mimo-wait-resume" },
|
||||
})
|
||||
const jl = result.trim().split("\n").find((l: string) => l.startsWith("{"))
|
||||
const envelope = jl ? JSON.parse(jl) : { ok: false, error: "no output" }
|
||||
return JSON.stringify({
|
||||
...envelope,
|
||||
normalization: {
|
||||
model_deadline_ignored: norm.model_deadline_ignored,
|
||||
model_op_type_ignored: norm.model_op_type_ignored,
|
||||
model_sha_ignored: !!args.sha && args.sha !== waitSha,
|
||||
canonical_op_type: norm.op_type,
|
||||
canonical_authority: norm.authority,
|
||||
deadline_provenance: norm.deadline_provenance,
|
||||
matched_by: norm.matched_by || norm.evidence || "auto-resolved",
|
||||
},
|
||||
})
|
||||
} catch (e: any) {
|
||||
const lines = (e.stdout || "").trim().split("\n")
|
||||
const jl = lines.find((l: string) => l.startsWith("{"))
|
||||
if (jl) {
|
||||
const envelope = JSON.parse(jl)
|
||||
return JSON.stringify({
|
||||
...envelope,
|
||||
normalization: {
|
||||
model_deadline_ignored: norm.model_deadline_ignored,
|
||||
model_op_type_ignored: norm.model_op_type_ignored,
|
||||
model_sha_ignored: !!args.sha && args.sha !== waitSha,
|
||||
canonical_op_type: norm.op_type,
|
||||
canonical_authority: norm.authority,
|
||||
deadline_provenance: norm.deadline_provenance,
|
||||
matched_by: norm.matched_by || norm.evidence || "auto-resolved",
|
||||
},
|
||||
})
|
||||
}
|
||||
return JSON.stringify({ ok: false, stage: "wait", error: (e.stderr || e.message || "?").slice(0, 300) })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
|
||||
// bash — executes a shell command. PROCESS-OWNERSHIP CONTRACT (v2):
|
||||
//
|
||||
// MODE A (tool-owned): the child is launched in its own process group;
|
||||
// stdout/stderr are CAPTURED pipes (never the TUI tty). On timeout or
|
||||
// failure the ENTIRE process group is signalled (SIGTERM, grace,
|
||||
// SIGKILL) so grandchildren cannot survive the invocation, then the
|
||||
// result is finalized. After finalization NO descendant retains a
|
||||
// writer into MiMo's streams.
|
||||
//
|
||||
// MODE B (explicitly detached): a command suffixed with " &" that the
|
||||
// user/model intends to outlive the tool (nohup/setsid/disown-style)
|
||||
// is executed via the detached helper: stdin=/dev/null, stdout/stderr
|
||||
// -> durable log file, own session (setsid), no MiMo PTY inheritance.
|
||||
// The tool returns the log path as the durable handle.
|
||||
//
|
||||
// Wait-intent compilation (sleep/check/poll aimed at a pending operation)
|
||||
// is preserved: interception happens at the SEMANTIC level via
|
||||
// await_intent route-bash, and a NO-OP for anything that is not a genuine
|
||||
// continuation of a live pending operation. Arbitrary shell syntax
|
||||
// (quotes, pipes, semicolons) passes through untouched — the router
|
||||
// response is only trusted when it carries normalized=true AND
|
||||
// ContinuePendingOperation with a concrete operation bound.
|
||||
import { tool } from "@mimo-ai/plugin"
|
||||
import { spawn, execFile } from "child_process"
|
||||
|
||||
const REPO = "/mnt/workdrive/issue161-harness"
|
||||
const WR_ROOT = "/mnt/workdrive/wr-bridge"
|
||||
|
||||
// ── MODE A: process-group-owned capture execution ──────────────────────
|
||||
interface OwnedResult { stdout: string; stderr: string; status: number; timedOut: boolean }
|
||||
|
||||
function runOwned(command: string, timeoutMs: number): Promise<OwnedResult> {
|
||||
return new Promise((resolve) => {
|
||||
// /bin/sh -c "command" in its OWN process group (detached:true makes
|
||||
// the child a session/group leader we can signal as a WHOLE).
|
||||
const child = spawn("/bin/sh", ["-c", command], {
|
||||
cwd: REPO,
|
||||
stdio: ["ignore", "pipe", "pipe"], // NEVER inherit the TUI tty
|
||||
env: process.env,
|
||||
detached: true, // own process group
|
||||
})
|
||||
let stdout = "", stderr = ""
|
||||
let timedOut = false
|
||||
let settled = false
|
||||
const outChunks: Buffer[] = []
|
||||
const errChunks: Buffer[] = []
|
||||
child.stdout!.on("data", (d) => outChunks.push(d))
|
||||
child.stderr!.on("data", (d) => errChunks.push(d))
|
||||
|
||||
const killGroup = (sig: NodeJS.Signals) => {
|
||||
try { process.kill(-child.pid!, sig) } catch { /* already gone */ }
|
||||
}
|
||||
const finalize = (status: number) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
// detach listeners, then resolve — no late listeners remain
|
||||
child.stdout!.removeAllListeners()
|
||||
child.stderr!.removeAllListeners()
|
||||
resolve({ stdout: Buffer.concat(outChunks).toString(), stderr: Buffer.concat(errChunks).toString(), status, timedOut })
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
killGroup("SIGTERM")
|
||||
// bounded grace for clean shutdown, then SIGKILL the group
|
||||
setTimeout(() => killGroup("SIGKILL"), 2000)
|
||||
// the exit/close event settles; a hard stop-guard settles anyway
|
||||
setTimeout(() => finalize(124), 4000)
|
||||
}, timeoutMs)
|
||||
|
||||
child.on("error", (e) => finalize(-1))
|
||||
child.on("close", (code) => finalize(code ?? -1))
|
||||
})
|
||||
}
|
||||
|
||||
// ── MODE B: explicit detachment for "cmd &" invocations ────────────────
|
||||
function runDetached(command: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const logPath = `/tmp/mimo-detached-${Date.now()}-${Math.floor(Math.random() * 1e6)}.log`
|
||||
const out = `exec >>"${logPath}" 2>&1; exec 0</dev/null; ${command}`
|
||||
const child = spawn("/bin/sh", ["-c", `setsid /bin/sh -c '${out.replace(/'/g, `'\\''`)}'`], {
|
||||
cwd: REPO,
|
||||
stdio: "ignore", // child rebinds its own fds — no PTY inheritance
|
||||
env: process.env,
|
||||
detached: true,
|
||||
// unref: MiMo's event loop does not wait for it
|
||||
})
|
||||
child.unref()
|
||||
resolve(JSON.stringify({
|
||||
detached: true,
|
||||
log: logPath,
|
||||
note: "process detached (setsid, own fds); output streams to the log file only; fd 0/1/2 do NOT reference the MiMo tty",
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
// ── wait-intent routing (semantic level, preserved from v1) ────────────
|
||||
async function tryCompileWaitIntent(command: string): Promise<string | null> {
|
||||
let routeOut: string
|
||||
try {
|
||||
routeOut = await new Promise<string>((resolve, reject) => {
|
||||
execFile("python3", [`${REPO}/scripts/await_intent_cli.py`, "route-bash", "--command", command], {
|
||||
cwd: REPO, timeout: 60000, encoding: "utf-8",
|
||||
env: { ...process.env, RUNE_WR_ROOT: WR_ROOT },
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
}, (err, stdout) => { if (err && !stdout) reject(err); else resolve(stdout || "") })
|
||||
})
|
||||
} catch {
|
||||
return null // router unavailable: normal execution (never block on routing)
|
||||
}
|
||||
const jl = routeOut.trim().split("\n").find((l) => l.startsWith("{"))
|
||||
if (!jl) return null
|
||||
let norm: any
|
||||
try { norm = JSON.parse(jl) } catch { return null }
|
||||
// STRICT gate: compile ONLY a normalized, concrete continuation.
|
||||
if (!(norm.normalized === true &&
|
||||
norm.action === "ContinuePendingOperation" &&
|
||||
norm.operation_id && norm.sha_for_wait)) return null
|
||||
const waitCmd = `python3 ${REPO}/scripts/ci_event_wait.py --sha ${norm.sha_for_wait} --deadline ${norm.deadline_secs} --op-type ${norm.op_type}`
|
||||
const r = await runOwned(waitCmd, (norm.deadline_secs + 60) * 1000)
|
||||
const jline = r.stdout.trim().split("\n").find((l) => l.startsWith("{"))
|
||||
if (jline) {
|
||||
return JSON.stringify({
|
||||
...JSON.parse(jline),
|
||||
intent_compilation: {
|
||||
raw_command_kind: norm.raw_command_kind || "sleep_poll",
|
||||
literal_sleep_executed: false,
|
||||
operation_id: norm.operation_id,
|
||||
canonical_op_type: norm.op_type,
|
||||
authority: norm.authority,
|
||||
},
|
||||
})
|
||||
}
|
||||
return JSON.stringify({ ok: false, stage: "compiled-wait", error: (r.stderr || "no output").slice(0, 300) })
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description: "Run a shell command. If the command expresses external wait intent (sleep/check/poll aimed at CI, deploy, or release operations with pending operations), it is automatically compiled to Rune's event-driven continuation — you do not need to manage wait mechanics. Plain local commands execute normally. Commands ending in ' &' run detached with output logged to a file.",
|
||||
args: {
|
||||
command: tool.schema.string().describe("Shell command to run"),
|
||||
},
|
||||
async execute(args) {
|
||||
// 1. wait-intent compilation (semantic, strict-gated)
|
||||
try {
|
||||
const compiled = await tryCompileWaitIntent(args.command)
|
||||
if (compiled !== null) return compiled
|
||||
} catch { /* fall through to normal execution */ }
|
||||
|
||||
// 2. MODE B: explicit detachment for backgrounded invocations
|
||||
const cmd = args.command
|
||||
if (/\b(nohup|setsid)\b/.test(cmd) || /&\s*$/.test(cmd)) {
|
||||
return await runDetached(cmd)
|
||||
}
|
||||
|
||||
// 3. MODE A: tool-owned, group-killed, captured execution
|
||||
const r = await runOwned(cmd, 120000)
|
||||
const text = r.stdout + (r.stderr ? `\n[stderr]\n${r.stderr}` : "")
|
||||
if (r.timedOut) {
|
||||
return `exit timed-out (process group terminated): ${text.slice(0, 500)}`
|
||||
}
|
||||
if (r.status !== 0) {
|
||||
return `exit ${r.status}: ${text.slice(0, 500) || "?"}`
|
||||
}
|
||||
return text || "(no output)"
|
||||
},
|
||||
})
|
||||
+161
-78
@@ -1,59 +1,96 @@
|
||||
{
|
||||
"_comment": "GENERATED by rune.spine \u2014 compatibility view for legacy umbrella consumers. DO NOT EDIT: structural truth lives in store/spine/current.json; edits here are flagged by spine drift.",
|
||||
"_generated_by_spine": true,
|
||||
"generated_at": "2026-09-12T16:02:18Z",
|
||||
"generated_at": "2026-09-16T18:23:38Z",
|
||||
"umbrellas": {
|
||||
"capability": {
|
||||
"canaries": [
|
||||
"canary_native",
|
||||
"canary_p0_05",
|
||||
"canary_p1_01",
|
||||
"canary_rune_entry",
|
||||
"canary_rune_serve"
|
||||
],
|
||||
"configs": [],
|
||||
"depends_on": [
|
||||
"memory"
|
||||
],
|
||||
"description": "Tool selection and execution authority",
|
||||
"go_surfaces": [
|
||||
"rune-entry"
|
||||
],
|
||||
"mirrors": [],
|
||||
"modules": [
|
||||
"adaptive_router",
|
||||
"builder_bridge",
|
||||
"builder_bridge_cli",
|
||||
"capability_broker",
|
||||
"connection_pool",
|
||||
"core_server",
|
||||
"cs_loader",
|
||||
"evidence_probe",
|
||||
"forge",
|
||||
"forge_client",
|
||||
"intent_continuity",
|
||||
"local_tool_runtime",
|
||||
"message_queue",
|
||||
"native_units",
|
||||
"openai_compat_transport",
|
||||
"provider_context",
|
||||
"recall_uplift",
|
||||
"route_risk_flags",
|
||||
"tool_executor",
|
||||
"tool_protocol"
|
||||
],
|
||||
"depends_on": [
|
||||
"memory"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_native",
|
||||
"canary_p0_05",
|
||||
"canary_p1_01"
|
||||
"rust_crates": [
|
||||
"capability_scoring"
|
||||
]
|
||||
},
|
||||
"governance": {
|
||||
"canaries": [
|
||||
"canary_full_system",
|
||||
"canary_schema_migration",
|
||||
"canary_audit_ledger",
|
||||
"canary_spine_meta"
|
||||
],
|
||||
"configs": [
|
||||
".coveragerc",
|
||||
"go-version.txt",
|
||||
"lint-baseline.json",
|
||||
"pyright-baseline.json"
|
||||
],
|
||||
"depends_on": [],
|
||||
"description": "Quality control, drift detection, promotion, audit",
|
||||
"mirrors": [],
|
||||
"modules": [
|
||||
"audit_ledger",
|
||||
"build_pipeline_policy",
|
||||
"cc_loader",
|
||||
"claim_audit",
|
||||
"contracts",
|
||||
"curator",
|
||||
"drift_core",
|
||||
"drift_ledger",
|
||||
"drift_observability",
|
||||
"failure_auto_filer"
|
||||
"failure_auto_filer",
|
||||
"programme_gates",
|
||||
"sc_loader"
|
||||
],
|
||||
"depends_on": [],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system",
|
||||
"canary_schema_migration"
|
||||
"rust_crates": [
|
||||
"evidence_provenance",
|
||||
"scaffold_core"
|
||||
]
|
||||
},
|
||||
"growth": {
|
||||
"canaries": [
|
||||
"canary_full_system"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance",
|
||||
"memory"
|
||||
],
|
||||
"description": "Learning and improvement",
|
||||
"mirrors": [],
|
||||
"modules": [
|
||||
"autonomy_loop",
|
||||
"cognitive_loop",
|
||||
@@ -64,18 +101,17 @@
|
||||
"growth_simulator",
|
||||
"self_improve",
|
||||
"subconscious_pass"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance",
|
||||
"memory"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system"
|
||||
]
|
||||
},
|
||||
"identity": {
|
||||
"canaries": [
|
||||
"canary_full_system"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance"
|
||||
],
|
||||
"description": "Who the agent is \u2014 persona, voice, anchors",
|
||||
"mirrors": [],
|
||||
"modules": [
|
||||
"behavior",
|
||||
"identity_anchors",
|
||||
@@ -86,17 +122,19 @@
|
||||
"voice_injector",
|
||||
"voice_sync",
|
||||
"voicebox"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system"
|
||||
]
|
||||
},
|
||||
"inference": {
|
||||
"canaries": [
|
||||
"canary_p1_02",
|
||||
"canary_p1_06",
|
||||
"canary_resource_authority"
|
||||
],
|
||||
"depends_on": [
|
||||
"capability"
|
||||
],
|
||||
"description": "Model routing and outbound requests",
|
||||
"mirrors": [],
|
||||
"modules": [
|
||||
"inference_trace",
|
||||
"model_connect",
|
||||
@@ -107,31 +145,39 @@
|
||||
"provider_registry",
|
||||
"response_adapter",
|
||||
"standardized_providers",
|
||||
"tc_loader",
|
||||
"token_optimizer"
|
||||
],
|
||||
"depends_on": [
|
||||
"capability"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_p1_02",
|
||||
"canary_p1_06",
|
||||
"canary_resource_authority"
|
||||
"rust_crates": [
|
||||
"token_counting"
|
||||
]
|
||||
},
|
||||
"memory": {
|
||||
"canaries": [
|
||||
"canary_full_system",
|
||||
"canary_memory_synthesis",
|
||||
"canary_query_memory"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance"
|
||||
],
|
||||
"description": "Storing, retrieving, and managing knowledge",
|
||||
"mirrors": [],
|
||||
"modules": [
|
||||
"abstraction",
|
||||
"cache",
|
||||
"drift_cache",
|
||||
"drift_compression",
|
||||
"ffi_query_memory_smoke",
|
||||
"graph_memory",
|
||||
"graph_shard_bridge",
|
||||
"memory_consolidation",
|
||||
"memory_env",
|
||||
"memory_selector",
|
||||
"memory_store",
|
||||
"mems_loader",
|
||||
"mr_loader",
|
||||
"ms_loader",
|
||||
"query_memory",
|
||||
"recall_router",
|
||||
"retrieval_benchmark",
|
||||
@@ -139,18 +185,33 @@
|
||||
"tier_manager",
|
||||
"warm_neuron_store"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system",
|
||||
"canary_memory_synthesis",
|
||||
"canary_query_memory"
|
||||
"rust_crates": [
|
||||
"memory_retrieval",
|
||||
"memory_scoring",
|
||||
"memory_storage",
|
||||
"query_memory"
|
||||
]
|
||||
},
|
||||
"orchestration": {
|
||||
"canaries": [
|
||||
"canary_cognitive_strata",
|
||||
"canary_conversation_state",
|
||||
"canary_gitea_pipeline",
|
||||
"canary_orchestration_foundation",
|
||||
"canary_p1_07",
|
||||
"canary_surface_parity",
|
||||
"canary_tui_drift",
|
||||
"canary_tui_transport"
|
||||
],
|
||||
"depends_on": [
|
||||
"capability",
|
||||
"growth",
|
||||
"identity",
|
||||
"inference",
|
||||
"perception"
|
||||
],
|
||||
"description": "Task lifecycle, operator, TUI presentation",
|
||||
"mirrors": [],
|
||||
"modules": [
|
||||
"auto_detect",
|
||||
"build_mode",
|
||||
@@ -185,51 +246,73 @@
|
||||
"task_persist",
|
||||
"tiered_executor",
|
||||
"time_awareness",
|
||||
"tui_transport",
|
||||
"tui_validator",
|
||||
"umbrella_update",
|
||||
"verification_layer",
|
||||
"warm_router",
|
||||
"work_manager"
|
||||
],
|
||||
"depends_on": [
|
||||
"capability",
|
||||
"growth",
|
||||
"identity",
|
||||
"inference",
|
||||
"perception"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_cognitive_strata",
|
||||
"canary_conversation_state",
|
||||
"canary_gitea_pipeline",
|
||||
"canary_orchestration_foundation",
|
||||
"canary_p1_07",
|
||||
"canary_surface_parity",
|
||||
"canary_tui_drift"
|
||||
]
|
||||
},
|
||||
"perception": {
|
||||
"description": "Input processing and context assembly",
|
||||
"modules": [
|
||||
"attention_capsule",
|
||||
"auto_context",
|
||||
"auto_tasks",
|
||||
"cognitive_budgeter",
|
||||
"complexity_scorer",
|
||||
"context_broker",
|
||||
"context_pressure",
|
||||
"salience_gate",
|
||||
"spine",
|
||||
"witness"
|
||||
"canaries": [
|
||||
"canary_context_broker",
|
||||
"canary_full_system"
|
||||
],
|
||||
"depends_on": [
|
||||
"memory"
|
||||
],
|
||||
"description": "Input processing and context assembly",
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_context_broker",
|
||||
"canary_full_system"
|
||||
"modules": [
|
||||
"attention_capsule",
|
||||
"auto_context",
|
||||
"auto_tasks",
|
||||
"behaviour_ledger",
|
||||
"build_basis",
|
||||
"capability_fit",
|
||||
"capability_ledger",
|
||||
"cognitive_budgeter",
|
||||
"construction_activation",
|
||||
"construction_commands",
|
||||
"construction_decision",
|
||||
"construction_depth",
|
||||
"construction_intent",
|
||||
"construction_interface",
|
||||
"construction_mode",
|
||||
"construction_pattern",
|
||||
"construction_patterns_builtin",
|
||||
"construction_projection",
|
||||
"context_broker",
|
||||
"context_pressure",
|
||||
"drift_resume",
|
||||
"event_wait",
|
||||
"failure_recovery",
|
||||
"graph_projection",
|
||||
"impact_preview",
|
||||
"iteration",
|
||||
"layout_contract",
|
||||
"programme_continuation",
|
||||
"project_registry",
|
||||
"release_notes",
|
||||
"salience_gate",
|
||||
"scaffold",
|
||||
"spine",
|
||||
"spine_language",
|
||||
"spine_loader",
|
||||
"stage_guards",
|
||||
"structural_migration",
|
||||
"structural_model",
|
||||
"structural_store",
|
||||
"testing_mode",
|
||||
"toolchain",
|
||||
"tui_backlog",
|
||||
"wait_guard",
|
||||
"wait_guard_corpus",
|
||||
"witness"
|
||||
],
|
||||
"rust_crates": [
|
||||
"rune_spine"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,15 +104,15 @@
|
||||
},
|
||||
{
|
||||
"path": "src/rune/attention_capsule.py",
|
||||
"role": "AttentionCapsule — the typed ledger of admitted blocks the model receives (to_messages re-derives the runtime payload)",
|
||||
"role": "AttentionCapsule \u2014 the typed ledger of admitted blocks the model receives (to_messages re-derives the runtime payload)",
|
||||
"disposition": "retain-authority",
|
||||
"evidence": "P1-02 (#25): the capsule IS the product the model receives — the broker renders admitted blocks into it, the safety override mutates its user block in place (re-measured), runtime.run consumes capsule.to_messages(), and the trace v4 capsule_messages/capsule_content_hashes prove the logged capsule EXACTLY equals the sent payload. Legacy create_capsule/to_prompt (string-trim fake budgeting) deprecated with no live callers."
|
||||
"evidence": "P1-02 (#25): the capsule IS the product the model receives \u2014 the broker renders admitted blocks into it, the safety override mutates its user block in place (re-measured), runtime.run consumes capsule.to_messages(), and the trace v4 capsule_messages/capsule_content_hashes prove the logged capsule EXACTLY equals the sent payload. Legacy create_capsule/to_prompt (string-trim fake budgeting) deprecated with no live callers."
|
||||
},
|
||||
{
|
||||
"path": "src/rune/inference_trace.py",
|
||||
"role": "Durable per-run inference trace (tokens served/estimated, output, latency, per-block visibility)",
|
||||
"disposition": "migrate-adapter",
|
||||
"evidence": "P0-07 (#23): persists what ContextBroker (#19) assembled and what the transport served — builds FROM the broker trace, never re-measures; store state/inference_traces.jsonl."
|
||||
"evidence": "P0-07 (#23): persists what ContextBroker (#19) assembled and what the transport served \u2014 builds FROM the broker trace, never re-measures; store state/inference_traces.jsonl."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -160,13 +160,13 @@
|
||||
"path": "src/rune/capability_broker.py",
|
||||
"role": "Authoritative capability selector (select_for_job + verified floor + find_capability escape hatch)",
|
||||
"disposition": "retain-authority",
|
||||
"evidence": "P1-01 (#24): select_for_job is the single selection authority — its ordered decision feeds LocalToolRuntime(tools=) at both construction sites and the _contributor_tools prompt block; degrade-not-crash falls back to full TOOLS with a tier-1 record_failure."
|
||||
"evidence": "P1-01 (#24): select_for_job is the single selection authority \u2014 its ordered decision feeds LocalToolRuntime(tools=) at both construction sites and the _contributor_tools prompt block; degrade-not-crash falls back to full TOOLS with a tier-1 record_failure."
|
||||
},
|
||||
{
|
||||
"path": "memory/index/capability_cards.json",
|
||||
"role": "Capability-card index (rebuilt on empty/stale/corrupt; native cards synthesized from TOOLS metadata)",
|
||||
"disposition": "migrate-adapter",
|
||||
"evidence": "P1-01 (#24): load_cards validates and rebuilds — the packaged empty array no longer passes as an index; cards carry risk/preconditions/side_effects copied from tool_executor.TOOLS."
|
||||
"evidence": "P1-01 (#24): load_cards validates and rebuilds \u2014 the packaged empty array no longer passes as an index; cards carry risk/preconditions/side_effects copied from tool_executor.TOOLS."
|
||||
},
|
||||
{
|
||||
"path": "src/rune/local_tool_runtime.py",
|
||||
@@ -486,12 +486,6 @@
|
||||
"disposition": "retain-authority",
|
||||
"evidence": "Defines the owner-approved canary-first Gitea sequence."
|
||||
},
|
||||
{
|
||||
"path": "src/rune/plugins/build_mode/__init__.py",
|
||||
"role": "Checkpoint/ledger state-machine primitive used by current governed builds",
|
||||
"disposition": "migrate-adapter",
|
||||
"evidence": "Persists checkpoints but encodes an older phase order."
|
||||
},
|
||||
{
|
||||
"path": "src/rune/build_mode.py",
|
||||
"role": "Separate BuildManager and worktree engine",
|
||||
@@ -504,6 +498,9 @@
|
||||
"disposition": "archive-candidate",
|
||||
"evidence": "Reports fixed success strings for phases without executing their claimed effects."
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"T17 17U: plugin BuildStateMachine deleted (zero importers; second parallel orchestrator)"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -540,4 +537,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Rune Semantic Release Policy (T20 §20B)
|
||||
|
||||
Pre-1.0 convention. Scaffolding "V1" does NOT mean Rune 1.0.
|
||||
|
||||
## Bump kinds
|
||||
|
||||
**PATCH** (0.66.80 → 0.66.81):
|
||||
- bug fix, correction, implementation/internal maintenance
|
||||
- no meaningful new user/system capability
|
||||
|
||||
**MINOR** (0.66.80 → 0.67.0):
|
||||
- meaningful new Rune capability
|
||||
- new subsystem functionality
|
||||
- materially expanded system behaviour
|
||||
- significant new supported workflow
|
||||
|
||||
**MAJOR**: reserved for Rune 1.0+ compatibility/public-contract semantics.
|
||||
|
||||
## How the machinery expresses this (no manual version edits)
|
||||
|
||||
The existing machinery already maps commit shape → bump kind:
|
||||
- `semver_bump.next_version` (PR titles): feat→minor, else patch
|
||||
- `version_convergence.highest_warranted_bump` (push ranges):
|
||||
feat/breaking→minor, fix/perf→patch, else None
|
||||
|
||||
**Tranche-level rule (20B)**: a feature tranche lands its feature
|
||||
commits (`feat(...)`), and the convergence machinery bumps MINOR at
|
||||
the tranche's release boundary. Patch-only follow-ups (fix/perf)
|
||||
warrant only PATCH — the machinery cannot accidentally re-minor
|
||||
because `highest_warranted_bump` takes the max over the *post-
|
||||
boundary* range, and a fix-only range yields "patch".
|
||||
|
||||
## Why T20 → 0.67.0
|
||||
|
||||
T20 is a feature-level tranche (Rune-wide retrofit: structural
|
||||
taxonomy, capability/behaviour ledgers, rune graph, /testing backlog,
|
||||
Spine modernization, semantic release). The release is classified
|
||||
MINOR under this policy, earned through the governed convergence
|
||||
machinery — never a manual version edit.
|
||||
|
||||
## T20 0.67.0 release record
|
||||
T20 classified MINOR under this policy: Rune-wide retrofit (structural
|
||||
taxonomy, capability/behaviour ledgers, rune graph, /testing backlog,
|
||||
Spine modernization, semantic release) = new subsystem functionality +
|
||||
materially expanded system behaviour. Earned through the governed
|
||||
convergence machinery (feat-bearing range → minor), never a manual edit.
|
||||
|
||||
## Root cause record (T20, 2026-09-16)
|
||||
semver_bump re-derived the bump kind from the SYNTHESIZED title text —
|
||||
"minor: converged release" matched nothing → patch. Feature tranches
|
||||
T18/T19/T20 downgraded to 0.66.77-83. Fixed: MINOR_PREFIX_RE carries the
|
||||
convergence kind through; first push after the fix with a feat-bearing
|
||||
range lands 0.67.0 through the normal machinery.
|
||||
@@ -68,3 +68,223 @@ for a 131072-window model (observed live in every PL1 Task A/B run).
|
||||
acquisition / provisional-trust lifecycle — FOUNDATIONAL ONLY (architecture
|
||||
does not block them; the projection source-list is the extension surface).
|
||||
- Deferred with reasons recorded: none blocked by Tranche 1.
|
||||
|
||||
---
|
||||
|
||||
# Scaffolding V1 — Tranche 2: Construction Substrate (2026-09-13)
|
||||
|
||||
_Tranche 2 (contract SCAFFOLD-TRANCHE-001) built the construction substrate
|
||||
itself: a shared structural-store primitive, the Project Scaffold layer over
|
||||
it, and durable Construction Contracts — Rune's first build tracked by the
|
||||
system it builds._
|
||||
|
||||
## Architecture
|
||||
|
||||
src/rune/structural_store.py shared generic primitive — NO Rune constants
|
||||
↓ ↓
|
||||
src/rune/spine.py src/rune/scaffold.py
|
||||
(Rune Spine — canonical (Project Scaffold — canonical
|
||||
structural self-model) construction/structural truth
|
||||
about EXTERNAL projects)
|
||||
|
||||
Authority map (no duplicates):
|
||||
|
||||
| fact | authority | others |
|
||||
|---|---|---|
|
||||
| Rune's structure | `spine.py` + `store/spine/current.json` | scaffold only references |
|
||||
| external project structure | `scaffold.py` store | Spine holds bridge facts only |
|
||||
| content history / rollback | Git | both reference commits |
|
||||
| coordination (issues/PRs/CI) | Gitea | scaffold stores observations |
|
||||
| why work exists | Task/Intent | contracts reference task ids |
|
||||
|
||||
Construction Contract = ONE durable object (`contract:<id>`, kind
|
||||
`construction_contract`) with plain `phase`/`status` fields governed by a
|
||||
small legal-transition map (draft → approved → building → verifying →
|
||||
migrating → superseding → closed; failed/abandoned terminal-or-retry). No
|
||||
workflow engine. Resumability = store durability, proven across real
|
||||
process boundaries.
|
||||
|
||||
## Mechanics
|
||||
|
||||
- **Repo binding** (`bind_repo`): forge/owner/name/branch/root/placement as
|
||||
scaffold DATA. No secrets — schema rejects secret-named fields AND every
|
||||
`save()` recursively redacts credential-named keys (defense in depth in
|
||||
the shared core, so Spine inherits it too).
|
||||
- **Scope check** (`check_scope`): changed files vs approved scope paths →
|
||||
green / red / **unknown** (an unscoped contract is never silently green).
|
||||
Out-of-scope change = RED until explicitly amended; amendments recorded,
|
||||
original RED preserved in history.
|
||||
- **Predicted vs observed**: frontier snapshot at approval; observed impact
|
||||
captured at closure from INDEPENDENT evidence (git, tests) — never from
|
||||
re-walking the prediction's own graph. Missed entities become CANDIDATE
|
||||
corrections awaiting evidence.
|
||||
- **Drift**: authority-owned facts that fail re-observation go STALE —
|
||||
never silently fresh, never force-reverted. Reality wins.
|
||||
- **Bridge** (`spine_bridge_entity`): identity + repo surface only. Project
|
||||
internals NEVER leak into Rune Spine (test-enforced). Contract evidence
|
||||
may cite `spine://` URIs — citations are inert on the referenced
|
||||
authority.
|
||||
|
||||
## Real proof (this tranche on itself)
|
||||
|
||||
SCAFFOLD-TRANCHE-001 migrated from its pre-scaffold JSON into the new
|
||||
store, advanced through phases with evidence checkpoints, closed with
|
||||
predicted-vs-observed from git/test evidence, scope check caught the
|
||||
store's own runtime artifacts (RED → explicit amendment → green, history
|
||||
kept), and construction provenance attached into Rune Spine without
|
||||
duplicating authority. A fresh interpreter answers what/why/state/
|
||||
evidence/changed/next from the store alone — no transcripts.
|
||||
|
||||
## Maturity (honest)
|
||||
|
||||
| component | state |
|
||||
|---|---|
|
||||
| structural_store core | EXERCISED (unit-proven + real use) |
|
||||
| Rune Spine after refactor | LIVE_PROVEN behavior-neutral (68 tests + identical rebuild) |
|
||||
| Project Scaffold | EXERCISED |
|
||||
| Construction Contract | EXERCISED (resumability proven cross-process) |
|
||||
| repo binding (identity model) | UNIT-PROVEN (local git fixture) |
|
||||
| external-repo live proof | BLOCKED ON USER REPO SELECTION |
|
||||
| Scaffolding overall | substrate only — capability-fit discovery, gap decisions, toolchain selection, patterns, maintenance TUI all NOT BUILT (data seams exist) |
|
||||
|
||||
---
|
||||
|
||||
# Scaffolding Tranche 4 — Live Need → Decision → Construction Contract (2026-09-13)
|
||||
|
||||
_The adoption tranche: a real Need now flows automatically through the
|
||||
Tranche 2/3 mechanisms and produces a governed Construction Contract. The
|
||||
model no longer has to remember the procedure — it is durable Scaffolding._
|
||||
|
||||
## Live flow
|
||||
|
||||
NEED (reference to Task/Intent — pointer, never a copy)
|
||||
↓ normalization (gaps recorded, never invented)
|
||||
↓ structural discovery + fit + resolution (Spine + Project Scaffold)
|
||||
↓ toolchain decision (existing project toolchain wins)
|
||||
↓ layout obligations (USER-LOCKED authority inherited)
|
||||
↓ user-approval gate (material changes require approval; approved
|
||||
bounded work never re-asks)
|
||||
↓ DecisionTrace (durable, model-independent)
|
||||
↓ generated Construction Contract → existing build/verify pipeline
|
||||
|
||||
## Authority map (unchanged — the path CONNECTS, never duplicates)
|
||||
|
||||
| authority | owns |
|
||||
|---|---|
|
||||
| Task/Intent | why work exists |
|
||||
| Rune Spine | Rune structural truth |
|
||||
| Project Scaffold | external-project structural/construction truth |
|
||||
| Layout Contract | desired interface constraints (USER-LOCKED = user authority) |
|
||||
| Construction Contract | governed build obligations/history |
|
||||
| Git / Gitea | history / execution |
|
||||
|
||||
## Boundary honesty
|
||||
|
||||
- **ADAPT/research**: the live path records a research ROUTE
|
||||
(TARGETED_EVIDENCE_PROBE / FULL_RESEARCH_PIPELINE) when evidence is
|
||||
insufficient — a seam, NOT an executed pipeline call. No fake
|
||||
integration claimed.
|
||||
- **BUILD_NEW** is never a fallback: insufficient evidence blocks it;
|
||||
normalization gaps block it; merge precedence across authorities
|
||||
requires every consulted authority to agree.
|
||||
- **No second task engine**: one decision function + one trace object.
|
||||
|
||||
## Contract-quality repair (Tranche 3 defects)
|
||||
|
||||
Closure now distinguishes:
|
||||
|
||||
predicted_existing_affected planned changes to existing entities
|
||||
expected_new entities the build PLANNED to create
|
||||
correctly_predicted predicted AND independently observed
|
||||
predicted_but_unaffected predicted, NOT observed
|
||||
expected_new_observed planned creation happened
|
||||
expected_new_missing planned creation did NOT happen
|
||||
genuinely_unexpected_new observed, planned by neither list
|
||||
|
||||
Prediction cannot self-certify observation: the observed set comes ONLY
|
||||
from caller evidence (git/tests/build/deploy). Legacy keys (missed/
|
||||
needless/observed) preserved — old contracts readable without migration.
|
||||
|
||||
## Layout-obligation inheritance
|
||||
|
||||
A change touching a UI component/region/implementation inherits its Layout
|
||||
Contract as a build obligation. USER-LOCKED obligations state that changing
|
||||
placement requires explicit user authorization. Unrelated backend changes
|
||||
inherit nothing. USER-LOCKED drift is never reconciled by editing the
|
||||
contract — code adapts.
|
||||
|
||||
## Maturity (honest)
|
||||
|
||||
- Need intake: EXERCISED (real live proof)
|
||||
- decision orchestration: LIVE-PROVEN for the bounded live flow
|
||||
- capability discovery adoption: EXERCISED
|
||||
- fit/gap adoption: EXERCISED
|
||||
- toolchain adoption: EXERCISED
|
||||
- contract generation: LIVE-PROVEN (one real generated contract)
|
||||
- contract-quality prediction: EXERCISED (repaired semantics, one live closure)
|
||||
- layout-obligation inheritance: UNIT-PROVEN (fixtures) + real TUI binding
|
||||
- Scaffolding overall: adoption path live for one bounded flow — NOT
|
||||
general autonomous construction, NOT universal discovery/toolchain
|
||||
intelligence, NOT Scaffolding complete.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Scaffolding Tranche 9 — Release History + Evidence Correction (2026-09-13)
|
||||
|
||||
_Gitea Releases are now an evidence-derived human-readable history of
|
||||
Rune's evolution. Plus the Tranche 8 builder-evidence correction._
|
||||
|
||||
## Release semantics (audited)
|
||||
|
||||
| surface | meaning |
|
||||
|---|---|
|
||||
| VERSION | semver in pyproject/init (e.g. 0.66.44) |
|
||||
| TAG | v<VERSION>, created by auto-version at the BUMP commit |
|
||||
| release commit | the auto-bump SHA — NOT necessarily final state |
|
||||
| converged deployed identity | last commit deployed under the same VERSION |
|
||||
|
||||
Same-version convergence commits (closure/accounting/lock fixes landing and
|
||||
deploying after the bump) are normal; the notes record BOTH the tag commit
|
||||
and the final deployed identity, distinction exposed honestly.
|
||||
|
||||
## Mechanism
|
||||
|
||||
`release_notes.py`: deterministic evidence extraction from governed records
|
||||
(closed contracts, resolved maintenance, contract-quality records) →
|
||||
structured ReleaseNote → Markdown → Gitea API (POST create / PATCH update).
|
||||
Idempotent: publishing twice for a tag UPDATES, never duplicates. Notes are
|
||||
historical documentation — NOT Memory, NOT structural authority.
|
||||
|
||||
## T8 evidence correction (§1)
|
||||
|
||||
The T8 report claimed a close-from-verifying refusal + reactive superseding
|
||||
transition. Durable checkpoints show NO superseding transition and the
|
||||
phase map permits verifying→closed; live output showed first-attempt close
|
||||
success. The claim was RETRACTED with full provenance (original claim,
|
||||
correction, reason, evidence preserved in the contract record) and
|
||||
recorded as process-defect:unsupported-causal-claim. MiMo adoption
|
||||
maturity survives on the SUPPORTED evidence: scope constrained file edits,
|
||||
pattern obligations forced preservation proof, interface-reported
|
||||
observations, fresh resume.
|
||||
|
||||
## Maturity
|
||||
|
||||
Release-note generator: LIVE-PROVEN (real Gitea, 10 releases). Backfill:
|
||||
0.66.35→0.66.44 carrying historical-backfill provenance. Pre-0.66.35
|
||||
history: skipped (weak evidence — honest limitation).
|
||||
|
||||
## Contract discovery → spine (2026-10-05, #2465 CI correction)
|
||||
|
||||
The closed construction contract is the authority for discoveries. Spine
|
||||
rebuild reads its observed entities and candidate corrections; it does not
|
||||
claim a correction has been built. Qualified IDs such as
|
||||
`rust-crate:cognition_core` keep their declared kind. Bare file paths,
|
||||
test suites and other unqualified references become
|
||||
`construction-surface:<reference>`, never `module`. An observed
|
||||
surface may be PRESENT; a candidate stays DECLARED until independently
|
||||
observed. Existing bad records are migrated with their evidence and edge
|
||||
references intact. This matters: the first write-back mapped bare paths to
|
||||
modules, made 22 false module records and crashed CI on the next rebuild.
|
||||
The per-contract quick-reference markdown is generated from the scaffold
|
||||
store at close; it is a view, not another authority.
|
||||
@@ -0,0 +1,79 @@
|
||||
# SCAFFOLDING IMPLEMENTATION AUDIT + V1 GAP MATRIX (T18 §18B)
|
||||
|
||||
_Every claim classified against evidence. Scale: LIVE_PROVEN (production
|
||||
evidence) / TEST_PROVEN (suite green, no production exercise) /
|
||||
IMPLEMENTED_UNPROVEN / PARTIAL / PLANNED / ABSENT / LEGACY / DUPLICATE._
|
||||
|
||||
## Kernel claims
|
||||
|
||||
| Claim | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Contract lifecycle + legal transitions | LIVE_PROVEN | 28 contracts driven through real flow; refusal envelopes observed |
|
||||
| classify_mode (one classifier) | LIVE_PROVEN | gates TUI /build (17G), operator hook, tests 62+ |
|
||||
| Conversational intent layer | TEST_PROVEN | 22+ held-outs incl. unseen paraphrases; 17R live exercise (one real conversational proof) |
|
||||
| Programme continuation | LIVE_PROVEN | 17E 4-stage proof on real bridge; restart/dup/failure held-outs |
|
||||
| Event-driven waits (builder) | LIVE_PROVEN | T15 15Q real model build; T17E async stage |
|
||||
| Event-driven waits (CI) | LIVE_PROVEN | T16 16D deadline path ×2 + T17 natural wake (mode=event, 112.5s) |
|
||||
| Event replay (before-registration) | TEST_PROVEN + production-realistic exercise | 17C 4.6s vs real completed CI; natural-cycle confirmation: the 17Z wake arrived mid-wait, replay path itself not yet hit naturally |
|
||||
| Cross-user bridge delivery | LIVE_PROVEN | 17C repro as runner user (2.05s) + production deploys |
|
||||
| Provider boundary | TEST_PROVEN (structural guard) + observed loud-failure | 16F/G suites; no silent fallback observed in production runs |
|
||||
| Builder cannot self-certify | LIVE_PROVEN | T14 closure refusals ×2; phase-gated close |
|
||||
| Completion truth evidence-driven | LIVE_PROVEN | contract checkpoints; verify_test_truth refusals |
|
||||
| Interruption/resume | LIVE_PROVEN | T14E SIGKILL; 17E restart held-outs |
|
||||
| Impact preview (visible, pre-build) | ABSENT → T18 | nothing renders structured impact before build today |
|
||||
| Structural project model (first-class) | PARTIAL | stores are spine-shaped (entities/rels) but projects model CONTRACTS, not architecture; no domain/subsystem/interface nesting for arbitrary projects |
|
||||
| Structural queries/impact closure for projects | PARTIAL | spine.impact_frontier exists for Rune; project stores lack query API |
|
||||
| Research→Build Basis (durable, versioned) | ABSENT → T18 | research output today stays in transient context/artifacts |
|
||||
| Research-depth decision at construction entry | PARTIAL | ADAPT routes task classes; no explicit construction-stage research gate |
|
||||
| OUTWARD prior-art-first pass | PARTIAL | stage exists in Research Core; not strengthened to precedent-first ordering |
|
||||
| Schema versioning + consent-gated migration | PARTIAL | stores carry schema_version=1; no migration machinery/consent flow |
|
||||
| Environment/runtime model | PARTIAL | deployment_surface exists in Spine; project scaffolds don't model envs |
|
||||
| Workspace lifecycle (worktree per contract) | PARTIAL | BuildManager worktrees exist (legacy adapter); not wired to contracts |
|
||||
| Forge abstraction | ABSENT (Gitea-coupled scripts) | deploy/qualify call Gitea API directly |
|
||||
| Structural drift for project scaffolds | PARTIAL | drift_resume (16P) covers checkpoint-vs-reality; no file/entity-level drift detection |
|
||||
| Graph projection for visualisation | ABSENT → T18 | Spine has snapshots; no stable read-only graph projection |
|
||||
| Memory/Substrate | ABSENT (BY DESIGN — out of scope) | rule L |
|
||||
|
||||
## V1 North Star gap matrix (this tranche's targets)
|
||||
|
||||
| North Star capability | Gap | T18 action |
|
||||
|---|---|---|
|
||||
| Research-led construction (early research, Build Basis) | ABSENT Build Basis | 18C: build_basis.py + ADAPT depth hook |
|
||||
| OUTWARD precedent-first | PARTIAL | 18D: strengthen stage contract + real research run |
|
||||
| First-class structural Scaffold | PARTIAL (contract-shaped) | 18E: structural_model.py over structural_store core |
|
||||
| Structure→scope→governance | PARTIAL | 18F: queries + impact closure feeding contracts |
|
||||
| Visible impact preview | ABSENT | 18G: impact_preview projection |
|
||||
| Structural drift | PARTIAL | 18H: extend drift_resume with entity-level checks |
|
||||
| Schema versioning + consent | PARTIAL | 18I: migration + consent flow + tests |
|
||||
| Environment/runtime model | PARTIAL | 18J: env entities + verification distinction |
|
||||
| Workspace lifecycle | PARTIAL (legacy adapter) | 18K: workspace contract on the BuildManager adapter |
|
||||
| Forge boundary | ABSENT | 18L: forge.py adapter interface (Gitea impl) |
|
||||
| Full route convergence | PARTIAL (classify shared; backends differ) | 18M: audit + smallest fix |
|
||||
| Generalisation (not phrase list) | TEST_PROVEN | 18N: adversarial paraphrase families |
|
||||
| Self-scaffold | PARTIAL (contracts only) | 18O: structural model of Scaffolding itself |
|
||||
| Upgrade-via-Scaffold proof | ABSENT | 18P: one real upgrade driven by the scaffold |
|
||||
| Graph projection | ABSENT | 18Q: versioned read-only projection |
|
||||
|
||||
## T19 additions (post-T18 classifications)
|
||||
|
||||
| Capability | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Project/Scaffold Registry | TEST_PROVEN | registry tests; boundaries proven (no truth in index, no Spine leak) |
|
||||
| /build governance fail-closed | LIVE_PROVEN | 19A: T18 degradation paths removed; failure-path held-outs |
|
||||
| Capability Ledgers | TEST_PROVEN | lifecycle+history+packet tests |
|
||||
| Surgical context packets | LIVE_PROVEN | 19R: packet bound exactly 1 file for a real change |
|
||||
| Behaviour/Test Specs + hardening lifecycle | TEST_PROVEN | ledger suites incl. generation history |
|
||||
| Selective invalidation | TEST_PROVEN | 19S-2/5/9/10 held-outs |
|
||||
| Predictive vs descriptive impact | TEST_PROVEN | delta persistence tests |
|
||||
| Post-change obligations | TEST_PROVEN | machine-derived from structural truth |
|
||||
| /testing backend + aggregate | TEST_PROVEN + LIVE (19R) | testing_mode suites; command wired in real TUI |
|
||||
| Observation→closure (user-confirm gate) | TEST_PROVEN | closure requires retest AND user confirm |
|
||||
| Forge failure never rewrites truth | TEST_PROVEN | 19S-14 held-out |
|
||||
| Self-hosted T19 change | LIVE_PROVEN | contract 19r-testing-command, 11-step scaffold-driven flow |
|
||||
|
||||
## Kernel exceptions required (rule B record)
|
||||
|
||||
None yet. Every T18 plan so far EXTENDS (new modules consuming
|
||||
structural_store) rather than modifying frozen kernel behavior. Any
|
||||
exception discovered during implementation will be recorded here with
|
||||
evidence that the V1 requirement could not be met otherwise.
|
||||
@@ -0,0 +1,153 @@
|
||||
# SCAFFOLDING V1 DEFINITION (T18 §18B)
|
||||
|
||||
_Plane-English + technical definition of Scaffolding as it ACTUALLY
|
||||
exists at tranche T18 start (master 29570f4). Companion artifact:
|
||||
SCAFFOLDING_IMPLEMENTATION_AUDIT (evidence classification per claim)._
|
||||
|
||||
## What Scaffolding is (plain English)
|
||||
|
||||
Scaffolding is Rune's **construction authority**: the governed process
|
||||
by which software gets built or changed. It takes an authorized need,
|
||||
classifies what kind of construction it is, proportionally governs the
|
||||
work through a durable contract (with legal lifecycle transitions),
|
||||
derives scope from structural reality, drives builders, waits on events
|
||||
(not sleeps), refuses self-certified completion, verifies from
|
||||
canonical reality, and records evidence on durable objects so any
|
||||
future session can resume without a transcript.
|
||||
|
||||
It is NOT a scheduler, NOT a memory system, NOT a provider selector,
|
||||
NOT a research engine, NOT a release system. It is the layer that
|
||||
decides *whether, how, and with what obligations* construction happens
|
||||
— and whether it actually finished.
|
||||
|
||||
## What it owns
|
||||
|
||||
- **Construction classification** (`construction_mode.classify_mode` —
|
||||
the ONE classifier: NOT_CONSTRUCTION / LOCAL_CHANGE /
|
||||
EXISTING_PROJECT_CHANGE / PROJECT_BUILD / UNKNOWN; never silently
|
||||
chooses; unknown surfaces probe questions)
|
||||
- **Conversational interpretation** (`construction_intent.py` —
|
||||
meaning/referents/authorization; DISCUSSION→PROPOSAL→USER_APPROVED;
|
||||
feeds the ONE classifier, never deepens)
|
||||
- **Project Scaffold + Contracts** (`scaffold.py` — legal phase map
|
||||
draft→approved→building→verifying→…→closed/failed/abandoned; scope;
|
||||
obligations; checkpoints; predicted-vs-observed at close)
|
||||
- **Programme continuation** (`programme_continuation.py` —
|
||||
run_construction_programme: stages 1:1 to contracts, persist→canary→
|
||||
next; "task done ≠ programme done")
|
||||
- **Impact/scope machinery** (construction_decision, completeness
|
||||
gates, affected-surface propagation)
|
||||
- **Stage guards** (`stage_guards.py` — readiness blocks, liveness
|
||||
proves progress, exit canary proves effect; verify_test_truth refuses
|
||||
false greens)
|
||||
- **Failure taxonomy** (`failure_recovery.py` — 7 classes incl.
|
||||
PROVIDER_UNAVAILABLE; retry budgets; PERMANENT/APPROVAL never retry)
|
||||
- **Builder interface** (`construction_interface.py` — ONE builder
|
||||
boundary; JSON envelopes; builder identity recorded-but-inert;
|
||||
discover_active_contract; capabilities())
|
||||
|
||||
## What it does NOT own
|
||||
|
||||
- **Provider/model selection** (provider layer owns; ambient context
|
||||
via `provider_context.require_model`; structural guard test)
|
||||
- **Research competence** (Research Core owns; Scaffolding invokes and
|
||||
consumes — see 18C integration)
|
||||
- **Asynchronous wait/resume machinery** (W/R event bridge owns;
|
||||
`event_wait.py` is the repo-side adapter: event drives continuation,
|
||||
watchdog detects failure, durable record is truth)
|
||||
- **Release/deployment** (deploy_stage/promotion/qualify + Gitea
|
||||
workflows own it; Scaffolding records evidence)
|
||||
- **Rune's structural self-model** (Spine owns — `spine.py` over
|
||||
`structural_store.py`; Scaffolding stores use the SAME generic core)
|
||||
- **Long-horizon relevance/memory** (future Substrate — not built;
|
||||
seam exists: construction_projection + construction_commands)
|
||||
- **The TUI** (client; sends text, renders projections)
|
||||
|
||||
## How the flows work
|
||||
|
||||
**New build (PROJECT_BUILD)**: conversational/explicit intent →
|
||||
classify_mode (+ project-existence evidence) → activate() bootstraps a
|
||||
provisional Project Scaffold (identity before repository, UNKNOWN
|
||||
fields explicit) → contract draft→approved (user provenance) →
|
||||
building (builder under event wait) → verifying (exit canary;
|
||||
independent evidence) → closed with predicted-vs-observed.
|
||||
|
||||
**Existing-project change**: intent → classify (scoped/file/target
|
||||
evidence; proportional) → attach to existing authority → light or full
|
||||
contract by depth → same lifecycle, proportionate ceremony.
|
||||
|
||||
**Research-led construction (V1 target — CORRECTED 2026-09-15, Nick):**
|
||||
|
||||
> THE RESEARCH PLAN ITSELF IS CONTEXTUALLY GENERATED. There is NO
|
||||
> permanent research-topic checklist. For every request:
|
||||
> REQUEST/INTENT → understand target/current reality → identify material
|
||||
> unknowns/decisions/risks → generate a context-specific research plan →
|
||||
> perform proportional INWARD/OUTWARD/deeper research. OUTWARD asks what
|
||||
> already exists relevant to THIS specific intent (adopt/adapt/combine/
|
||||
> reject/gaps/innovation). The categories researched are selected from
|
||||
> context; deeper technical research is driven by unresolved questions
|
||||
> and consequence level, never performed mechanically.
|
||||
|
||||
`build_basis.generate_research_plan()` implements this: topics are
|
||||
synthesized from THIS intent's material unknowns/decisions/risks;
|
||||
the held-out suite proves unrelated intents produce materially
|
||||
different plans and that no canned category list exists.
|
||||
|
||||
**Future upgrades (V1 target)**: authorized need → research-depth
|
||||
decision (ADAPT) → Research Core pass → durable Build Basis →
|
||||
structural Scaffold consult/construct → impact closure + visible
|
||||
preview → contract with structurally-derived scope → isolated
|
||||
workspace → governed build/verify → merge/release → structural
|
||||
Scaffold updated. (Parts of this chain are new in T18 — see audit.)
|
||||
|
||||
**Interruption/resume**: SIGKILL-safe. Durable contract + programme
|
||||
records + wait records are the truth; fresh process resumes from
|
||||
identity alone; duplicate signals idempotent (live-proven T14E/T17E).
|
||||
|
||||
**Deployment/release participation**: Scaffolding does not run the
|
||||
pipeline; it consumes qualified CI/deploy evidence, records deployed
|
||||
identity on contracts, and refuses closure without it where contracted.
|
||||
|
||||
## T19 layer model (the general engineering system)
|
||||
|
||||
> "The Scaffold should let the LLM operate with surgical precision
|
||||
> locally, while preserving global awareness of what else must be
|
||||
> checked or changed."
|
||||
>
|
||||
> "Lower active cognitive load without losing the whole idea."
|
||||
|
||||
- **STRUCTURAL MODEL** tells Rune what the project contains / how it connects.
|
||||
- **CAPABILITY LEDGER** provides compact local re-entry context (stable IDs
|
||||
across moves; history preserved through supersede/retire).
|
||||
- **BEHAVIOUR VALIDATION LEDGER** tells Rune what intended behaviour is
|
||||
actually confirmed — generation-aware, selective invalidation, automated
|
||||
green never implies USER_CONFIRMED.
|
||||
- **PROJECT REGISTRY** tells Rune which Scaffolds/projects exist
|
||||
(discovery/index only — not structural truth, not Spine).
|
||||
- **ITERATION ENGINE** (predictive→descriptive impact, impact-delta
|
||||
records, machine-derived obligations) makes quicksand protection
|
||||
STANDARD behaviour of every construction route.
|
||||
- **/TESTING** is a dependency-aware hardening programme: dynamic queue,
|
||||
fix-for-B-may-reopen-A, closure requires retest AND user confirmation.
|
||||
- **FORGE** provides workflow/history projection (failure never rewrites
|
||||
canonical validation truth).
|
||||
- **FUTURE SUBSTRATE** will coordinate sequencing/current focus/pause-
|
||||
resume but does NOT own structural or validation truth.
|
||||
|
||||
## How Spine relates
|
||||
|
||||
Spine = Rune's canonical structural self-model (structural_store
|
||||
generic core, maturity ladder, impact_frontier). Scaffolding's project
|
||||
stores share the SAME generic core (zero Rune constants) — one graph
|
||||
discipline, two scopes: Spine models Rune; project Scaffolds model
|
||||
their projects. No duplicate truth: a project's structural model lives
|
||||
in its own store; Rune's lives in Spine. Later retrofit will modernise
|
||||
Spine using the discipline V1 generalises (18K relationship).
|
||||
|
||||
## How Substrate will interact (without owning internals)
|
||||
|
||||
Read: versioned outbound projection (construction_projection,
|
||||
SCHEMA_VERSION=1). Write: bounded validated commands
|
||||
(construction_commands — 6 commands, lifecycle-validated, idempotent,
|
||||
no direct storage mutation). Substrate asks; Scaffolding decides.
|
||||
Memory graph explicitly out of scope (T18 rule L).
|
||||
@@ -0,0 +1,73 @@
|
||||
# Rune Structural Map
|
||||
|
||||
_Maintained structural projection — the fast re-entry surface (T16 §16N).
|
||||
Companion to the Spine store (`store/spine/current.json`, structural
|
||||
authority) and `store/evidence/t16/16M_inventory.md` (full classification
|
||||
record). Update this map when authority or canonical routes change; do
|
||||
not copy the repo here._
|
||||
|
||||
## What is Rune?
|
||||
|
||||
A self-hosting coding agent: TUI client over an operator core, with a
|
||||
structural self-model (Spine), a construction authority (Scaffolding),
|
||||
an event-driven wait/resume bridge (W/R), a research pipeline, and a
|
||||
fail-closed release/deployment pipeline that builds, tests, releases,
|
||||
and deploys Rune itself to `/mnt/workdrive/rune-code/current`.
|
||||
|
||||
## Where things happen (canonical routes only)
|
||||
|
||||
| Concern | Authority | State |
|
||||
|---|---|---|
|
||||
| Structural self-model | `src/rune/spine.py` | `store/spine/current.json` |
|
||||
| Construction (build lifecycle) | `src/rune/scaffold.py` + `construction_*.py` | scaffold store (`<dir>/.rune/scaffold`) |
|
||||
| Builder sessions | `scripts/t15_builder_session.py` (event-driven successor; T14 is legacy) | contract + wait records |
|
||||
| Provider/model selection | `rune_operator` final decider; ambient context via `provider_context.require_model` | `state/model_routing_profiles.json` |
|
||||
| Wait/resume (any long op) | `src/rune/event_wait.py` + W/R daemon (out-of-repo snapshot at `~/.local/share/mimo-wait-resume`, data root `/mnt/workdrive/wr-bridge` — PIN `RUNE_WR_ROOT`: unpinned consumers get a loud warning, never a silent split-brain) | waits/events/leases/resumes JSON |
|
||||
| Programme continuation | `src/rune/programme_continuation.py` (WIRED T17: `run_construction_programme` binds stages to Scaffolding contracts) | scaffold store `programmes/` |
|
||||
| Conversational construction | `src/rune/construction_intent.py` (meaning/referents/authorization) → `construction_mode.classify_mode` (the ONE classifier) | intents are transient; contracts durable |
|
||||
| CI qualification verdict | `scripts/deploy_qualify.py` (two legs, fail-closed) | Gitea commit status + runs |
|
||||
| Release/deploy | `scripts/deploy_stage.py` → `deploy_promotion.py` (monotonic, flock) | deploy root + `current`/`last-good` symlinks |
|
||||
| Research | `scripts/research_task_tree.py` (authority; labels are projection) | `research/issue-<N>/` |
|
||||
| Task completion truth | `verification_layer.py` + `orchestration/conversation_state.py` | durable event store + checkpoints |
|
||||
| Routing (task class) | `src/rune/adaptive_router.py` | `routes/route.jsonl` |
|
||||
| Canary registry | `src/rune/canary_native.MODULES` | observed by Spine |
|
||||
| Structural health | `henxels_checks.py` + `henxels.yaml` | repo root |
|
||||
|
||||
## Known non-canonical / duplicate routes (do not extend; reconcile via 16Q)
|
||||
|
||||
- `build_mode.BuildManager` (legacy /build) — superseded by builder_bridge
|
||||
+ event-driven sessions; still wired.
|
||||
- `scripts/t14_builder_session.py` — superseded by t15 event-driven driver.
|
||||
- Provider selection entry points ×5 (`model_routing.rank`,
|
||||
`model_connect route`, `model_registry get`, `provider_registry`,
|
||||
`work_manager`) — operator is the de-facto final decider.
|
||||
- Task-ish state ×4 (task-tool DB, `task_persist` persisted.json,
|
||||
`memory_store.working`, orchestration event store).
|
||||
- Maintenance ledgers ×3 (scaffold maintenance_item, tui_backlog, drift_ledger).
|
||||
|
||||
## Unfinished / blocked / deprecated
|
||||
|
||||
- programme_continuation.py: WIRED (T17 17D — run_construction_programme
|
||||
is the canonical multi-stage driver; 17E live-proven).
|
||||
- maintenance.yml canary-suite job: loops `shared/canary_*.py` — wrong
|
||||
dir (real canaries in `src/rune/`), job likely no-ops (LEGACY).
|
||||
- daemon out-of-repo: the W/R daemon is deployed from
|
||||
`~/.local/share/mimo-wait-resume` with its own git snapshot —
|
||||
packaging debt, no CI coverage of the deployed copy.
|
||||
- Substrate: NOT BUILT. Scaffolding exposes the seam
|
||||
(`construction_projection.py`, `construction_commands.py`); mock
|
||||
client proves compatibility (`tests/test_substrate_seam.py`).
|
||||
|
||||
## Fresh-session quick answers
|
||||
|
||||
- Is a claim true? → Spine store + evidence_refs, never commit messages.
|
||||
- Is construction allowed? → scaffold contract phase + LEGAL_TRANSITIONS.
|
||||
- How does a long wait work? → register on the W/R bridge, yield on the
|
||||
FIFO event; watchdog is failure detection, never scheduling; the event
|
||||
is a WAKE — authority is re-read after resume.
|
||||
- Who picks the provider? → the user layer, via ambient context;
|
||||
construction never does (`test_provider_ownership.py` enforces).
|
||||
- What runs where? → CI on `rune-ci-desktop` runner; deploy stages to
|
||||
`/mnt/workdrive/rune-code/<sha>`; data home pinned at
|
||||
`/mnt/workdrive/rune` (RUNE_HOME); implementation worktree
|
||||
`/mnt/workdrive/issue161-harness`.
|
||||
@@ -11,24 +11,22 @@
|
||||
"NEEDS_REVALIDATION",
|
||||
"DEFERRED_FUTURE"
|
||||
],
|
||||
"live_defects": [
|
||||
324
|
||||
],
|
||||
"live_defects": [],
|
||||
"records": [
|
||||
{
|
||||
"number": 324,
|
||||
"title": "Engine defect: checkpoint writer fails deterministically on aborted worker sessions (MessageAbortedError -> empty checkpoint shells)",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "FIXED_VERIFIED",
|
||||
"labels": [
|
||||
"bug",
|
||||
"custodian",
|
||||
"life",
|
||||
"memory"
|
||||
],
|
||||
"live_defect": true,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 defect no longer reproduces: zero MessageAbortedError in current logs (incl. active worker), six most recent session checkpoints full documents (111-396 lines); verified by present-absence 2026-09-19"
|
||||
},
|
||||
{
|
||||
"number": 316,
|
||||
@@ -237,8 +235,8 @@
|
||||
{
|
||||
"number": 183,
|
||||
"title": "Future: typed temporal semantics for facts, relationships, beliefs, and preferences",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"governance",
|
||||
@@ -250,14 +248,14 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #181 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 182,
|
||||
"title": "Future: GitHub code-search prior-art audit for graph and vector memory",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"governance",
|
||||
@@ -266,8 +264,8 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #181 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 181,
|
||||
@@ -287,13 +285,13 @@
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"next_action": "epic holds absorbed children #182 #183 + research watchlist #164 #165 #166; closes when prerequisites land or ideas dropped"
|
||||
},
|
||||
{
|
||||
"number": 179,
|
||||
"title": "Future: succession/privacy semantics and lineage evaluation",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"governance",
|
||||
@@ -305,14 +303,14 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #175 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 178,
|
||||
"title": "Future: evidence-grounded ancestral perspective with explicit uncertainty",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"governance",
|
||||
@@ -322,14 +320,14 @@
|
||||
"perception"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #175 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 177,
|
||||
"title": "Future: temporal autobiographical model with provenance and reinterpretation lineage",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"governance",
|
||||
@@ -340,14 +338,14 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #175 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 176,
|
||||
"title": "Future: lineage-aware situated curiosity for meaningful autobiographical gaps",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"feature",
|
||||
@@ -358,8 +356,8 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #175 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 175,
|
||||
@@ -379,13 +377,13 @@
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"next_action": "epic holds absorbed children #176-#179 as checklist items; closes when prerequisites land or ideas dropped"
|
||||
},
|
||||
{
|
||||
"number": 166,
|
||||
"title": "RESEARCH WATCHLIST: Memory-R1 (RL memory ops), Titans (test-time memory), Vespa/Weaviate/Milvus (retrieval competitors)",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"growth",
|
||||
@@ -394,14 +392,14 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #181 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 165,
|
||||
"title": "RESEARCH: SimpleMem semantic compression + A-MEM dynamic linking + Letta sleep-time compute",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"infrastructure",
|
||||
@@ -410,14 +408,14 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #181 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 164,
|
||||
"title": "RESEARCH: HippoRAG 2 \u2014 PPR-style associative propagation as warm-neuron activation candidate",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "HISTORICAL_EVIDENCE",
|
||||
"labels": [
|
||||
"P3 \u2014 low",
|
||||
"growth",
|
||||
@@ -426,27 +424,27 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 idea absorbed into parent epic #181 as tracked checklist item; full detail preserved in this closed issue; re-mint when live"
|
||||
},
|
||||
{
|
||||
"number": 163,
|
||||
"title": "Temporal graph backend spike: Neo4j vs FalkorDB (LadybugDB experimental; Graphiti as reference)",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "NEEDS_REVALIDATION",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"labels": [
|
||||
"P1 \u2014 high",
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "run the backend spike; compare on same corpus/query set; record evidence-backed recommendation"
|
||||
},
|
||||
{
|
||||
"number": 162,
|
||||
"title": "Qdrant associative-memory projection spike (dense/sparse/multivector + ColBERT late-interaction reranking)",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "NEEDS_REVALIDATION",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "SUPERSEDED",
|
||||
"labels": [
|
||||
"P1 \u2014 high",
|
||||
"custodian",
|
||||
@@ -454,8 +452,8 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 scope carried verbatim by #316 (ADAPT-1 B3-v2); single-tracker rule"
|
||||
},
|
||||
{
|
||||
"number": 159,
|
||||
@@ -478,8 +476,8 @@
|
||||
{
|
||||
"number": 158,
|
||||
"title": "Generated wiring-status / architecture-truth report",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "SUPERSEDED",
|
||||
"labels": [
|
||||
"P2 \u2014 medium",
|
||||
"growth",
|
||||
@@ -489,8 +487,8 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 scope carried verbatim by #315 (ADAPT-1 B2-v2); single-tracker rule"
|
||||
},
|
||||
{
|
||||
"number": 156,
|
||||
@@ -1151,6 +1149,6 @@
|
||||
"next_action": ""
|
||||
}
|
||||
],
|
||||
"last_reconciled": "2026-09-12",
|
||||
"reconciled_by": "PL1 held-out era reconciliation (fresh session 2026-09-12)"
|
||||
}
|
||||
"last_reconciled": "2026-09-19",
|
||||
"reconciled_by": "fresh-session tranche 2026-09-19: template retrofit (spine+plain-English on all open issues), closures #158/#162 (SUPERSEDED) and #324 (FIXED_VERIFIED), research labels applied to #163/#164/#165/#166/#182/#316, stale bug labels removed from #163-#166; 2026-09-19 absorption pass: parked children absorbed into parents (#175<-#176..#179; #181<-#182,#183,#164..#166) per one-open-note-per-idea standard"
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
# Language-Fit Rubric — Wave 1 (Language-Rebasing Tranche R1)
|
||||
|
||||
> STATUS: FINAL — self-verified 2026-09-29 per tranche §12 (all 6 rounds completed,
|
||||
> evidence-sufficiency statements recorded, kill tests frozen; execution proceeds on the
|
||||
> evidence, every RECODE still passes its benchmark gate — no win, no recode).
|
||||
> Per-subsystem sections filled from live research rounds (≥2 live-verified external sources per claim,
|
||||
> per-source lessons, evidence→decision mapping, kill test per candidate). No code changes in Wave 1.
|
||||
> Governing law from §11: **KEEP-PYTHON honesty determines how much of the rebase volume is real.**
|
||||
> Toolchain baseline: Rust 1.98.1 + GCC 16.2.1 present; Go NOT installed — Go earns installation only
|
||||
> through rubric evidence here.
|
||||
|
||||
## Verdict (all rounds, one paragraph)
|
||||
|
||||
**KEEP-PYTHON across the board: 40 clusters scored, 0 recode units awarded.** Six research
|
||||
rounds (W2 leaf utilities, W3 memory, W4 provider-layer, W5 orchestration, W6 runtime-core,
|
||||
W7 TUI) each characterized their subsystem's actual workload inward (file:line-anchored) and
|
||||
tested Python/Rust/Go/C against it with live-verified sources and frozen kill tests. The
|
||||
pattern is consistent: the compute-shaped work already executes in C (hashlib, sqlite3/FTS5/vec0,
|
||||
json, regex), the dominant latency is network or LLM-bound, and no cluster contains a hot loop
|
||||
that pays for a process-boundary recode through the 658 µs–0.63 ms interop crossing. **Go:
|
||||
REJECTED — does not earn installation this tranche** (D5=0 everywhere; nothing measured or
|
||||
fetch-confirmed earns it; orchestration additionally rejects its design fit; K-GO kill tests
|
||||
frozen per round should the case be revived). **Rust: toolchain-ready, justified nowhere this
|
||||
tranche** — keep as the standing option behind the CI-proven std-only adapter for any future
|
||||
measured hot loop. **C: rejected everywhere scored** (duplicates C already inside SQLite/hashlib;
|
||||
adds an unsafe-memory surface). The rebase volume the tranche hypothesized is real only as a
|
||||
small set of named improvements — see "Wave-plan consequence" below.
|
||||
|
||||
## Rubric dimensions (applied to every candidate language per subsystem)
|
||||
|
||||
| Dimension | Question | Evidence required |
|
||||
|---|---|---|
|
||||
| D1 Workload fit | Does the subsystem's dominant workload (I/O-bound, CPU-bound, FFI, protocol, UI) actually favour this language? | Benchmarks or authoritative docs, live-verified |
|
||||
| D2 Measured gain | What wins, concretely — latency, memory, startup, binary distribution? | Named hot paths from the Wave 0 inventory + numbers |
|
||||
| D3 Interop cost | What does the interop contract (§5) cost to carry for this boundary? | Adapter surface size, ABI/build complexity |
|
||||
| D4 Ecosystem | Do the needed libraries exist and are they maintained? | Live-verified crate/package/module listings |
|
||||
| D5 Toolchain reality | Is the toolchain present (or installable and justified) on this machine and the CI runner? | Runner + local `--version` evidence |
|
||||
| D6 Maintenance continuity | Can the Rune ecosystem's single-maintainer loop sustain code in this language here? | Honest judgement, stated plainly |
|
||||
| D7 Kill test | What is the CHEAPEST disproof of this candidate? | One falsifiable early test, frozen before any recode |
|
||||
|
||||
Scoring: each candidate gets D1–D6 scored 0–2 with one-line evidence each, D7 written regardless.
|
||||
Decision per subsystem: **KEEP-PYTHON** / **RECODE→<language>** — KEEP-PYTHON is the expected default;
|
||||
a RECODE that cannot pass its own kill test reverts to KEEP-PYTHON before implementation.
|
||||
|
||||
---
|
||||
|
||||
## W3 — Memory & persistence (units: memory_store 56 fns, curator 43, warm_router 32, memory_consolidation 20, sleep_time 25, + graph_memory, night_mode, identity_anchors)
|
||||
|
||||
- **Research round actor:** general-2 (COMPLETED 2026-09-29 — artifacts at
|
||||
`research/wave1/memory/`: inward.md, outward/ ×6, outward-sources.md, synthesis.md)
|
||||
- **Intention profile:** JSONL-heavy appends, BM25/FTS ranking, consolidation passes, long-running
|
||||
scheduled jobs, file-backed state.
|
||||
- **Decision:** **KEEP-PYTHON (all 7 clusters, zero RECODE).** Cluster verdicts:
|
||||
(1) hot-store JSON persistence — KEEP with upgrade path: orjson in-process wheel
|
||||
(~10× dumps / 2× loads); a bespoke Rust CLI unit is dead on spawn cost (the
|
||||
`shared/native_units.py` crossing ~1–5 ms ≈ orjson's entire 3.4 MB `knowledge.json` decode);
|
||||
(2) SQLite store + FTS5/vec0 recall — KEEP, the compute is already C inside SQLite
|
||||
(`memory_store.py:646` vec0 MATCH, `:693` bm25); (3) neuron scoring/routing — KEEP,
|
||||
network-bound `_embed` (Ollama subprocess curl) dominates; (4) graph BFS — KEEP,
|
||||
hops=1 default, per-node SQL, tiny N; the future fix is a SQL CTE, not a language;
|
||||
(5) batch consolidation/curator — KEEP, LLM/I-O-bound and latency-insensitive,
|
||||
asyncio sweet spot; (6) orchestration/glue — KEEP, pure glue pinned by the Python-TUI
|
||||
interop contract; (7) embedding transport — KEEP, network-bound.
|
||||
- **Candidate matrix summary:** Python 7/7 · Go 0/7 (not installed, nothing measured or
|
||||
fetch-confirmed earns installation; Go's JSON stack mid-rewrite upstream) · hand-written C
|
||||
0/7 (C already battle-tested inside SQLite — hand-writing would duplicate it) · Rust 0/7
|
||||
as CLI units (spawn cost kills the one credible vector; in-process wheels excluded by
|
||||
Wave 0 policy).
|
||||
- **Kill tests (frozen in synthesis.md):** Rust-JSON — orjson microbench on live 3.4 MB
|
||||
`knowledge.json` (if drop-in and ≥2× load / ≥5× dump, the CLI unit is disproven on spawn
|
||||
cost); Rust-general — kill unless a profile shows ≥50% wall time in pure Python compute
|
||||
(none exists; reopen only on materially changed workload); Go — instrument one
|
||||
`consolidate_all` run, kill if Python-compute share <50% (it will be — LLM/I-O bound);
|
||||
C — kill unless a hot kernel absent from the SQLite extension ecosystem is identified
|
||||
(none).
|
||||
- **Sources + lessons:** 6 live-fetched 2026-09-29 (orjson, sled, sqlite-vec, SQLite FTS5
|
||||
docs, Go json/v2 rewrite issue, pydantic-core) — full annotations in
|
||||
`research/wave1/memory/outward-sources.md`.
|
||||
- **Evidence sufficiency (honest):** static analysis only, no runtime profiling;
|
||||
memory.store SQLite DB not materialized locally — kill tests specify *measured*
|
||||
thresholds rather than assertions. Verdicts rest on "Python is glue around C/network,"
|
||||
which profiling would only strengthen.
|
||||
|
||||
## W4 — Provider/model layer (units: provider_registry 28, model_connect 10, model_routing 15, model_profiles 15, provider_budget 21, + standardized_providers, model_registry, connection_pool)
|
||||
|
||||
- **Research round actor:** general-5 (COMPLETED 2026-09-29 — artifacts at
|
||||
`research/wave1/provider-layer/`: inward.md, outward-sources.md, outward/ (8 evidence
|
||||
JSONs), synthesis.md)
|
||||
- **Intention profile:** HTTP/SSE streaming, connection pooling, retries/backoff, protocol parsing,
|
||||
tool-call reliability.
|
||||
- **Intention profile correction (inward-verified):** the layer has **NO retry/backoff and NO
|
||||
SSE stub at all** (grep-verified) — `stream: False` everywhere, ~900 lines, remote-LLM-latency-
|
||||
bound. Rewrite pitches are therefore feature-adds, not ports.
|
||||
- **Candidate matrix:** 5 clusters, all KEEP-PYTHON. Scores (1–5 = fit for the cluster's actual
|
||||
workload): (C1) HTTP wire + transport (provider_registry, openai_compat_transport, model_connect
|
||||
discovery) Python 5 / Go 2 / Rust 1; (C2) connection pool (connection_pool.py, 144 lines —
|
||||
in-process object guards under threading.Lock, NOT a TCP pool) Python 5 / Go 2 / Rust 2;
|
||||
(C3) routing decisions (model_routing.py, 432 lines, pure decision logic, one httpx call)
|
||||
Python 5 / Go 1 / Rust 1; (C4) budget accounting (provider_budget.py, 354 lines, pure
|
||||
arithmetic + lock, no I/O) Python 5 / Go 1 / Rust 1; (C5) config/registry/normalization
|
||||
(model_profiles, model_registry, standardized_providers, model_connect tables — dataclasses +
|
||||
JSON, editable by non-systems programmers) Python 5 / Go 1 / Rust 1.
|
||||
- **Decision:** **KEEP-PYTHON (all 5 clusters).**
|
||||
- **Kill tests:** C1 Go — KILLED: toolchain absent (`which go` → not found); 28 call-site
|
||||
behaviors incl. the tool-call envelope contract (openai_compat_transport.py:67-107, 160-207)
|
||||
would be re-implemented with zero incumbent test port and NO performance signal (remote-LLM-
|
||||
latency-bound, kilobyte payloads). Re-earn path if Go is ever installed: std-only proof via
|
||||
`interop/rebase-proof/` + a benchmark harness on C1 only. C1 Rust — KILLED BY CONSTRUCTION:
|
||||
SSE parsing requires reqwest+tokio+eventsource-stream+nom (outward O3/O4/O5 — even mistral.rs
|
||||
takes the tokio stack), violating the std-only interop contract. C2–C5 Go/Rust — KILLED:
|
||||
burden of proof sits on the rewrite candidates; kill tests need contention/throughput/
|
||||
counter-evidence that does not exist; routing runs once per request at millisecond scale
|
||||
against multi-second LLM calls (5 orders of magnitude below any measurable threshold).
|
||||
- **Sources + lessons:** 8 live-fetched (O1–O8), ≥2 per major claim, coverage map in
|
||||
`outward-sources.md`. Lessons: (1) stdlib-only Rust has no realistic precedent for HTTP/SSE
|
||||
workloads — the interop contract's zero-crate standard effectively decides Rust out of all
|
||||
network-wire clusters; (2) Go's stdlib HTTP+NDJSON streaming is capability-proven
|
||||
(pkg.go.dev/net/http, ollama api/client.go, ollama's Go API server) — Go's losses here are
|
||||
evidence-absence, not capability-absence; (3) the honest reliability upgrade for this layer
|
||||
is a small Python-level httpx retry/timeout wrapper — a feature-add, not a language change.
|
||||
|
||||
## W5 — Orchestration, authority, plugins, skills (units: orchestration/ 44 files 244 fns, contracts, build_mode 34, construction_authority*, verification_construction, spine, + governance surfaces: drift_*, persona_gate, safety_override, witness, behavior, growth_delta, claim_audit)
|
||||
|
||||
- **Research round actor:** general-1 (COMPLETED 2026-09-29 — artifacts at
|
||||
`research/wave1/orchestration/`: inward.md, outward-sources.md, outward/ (4 search
|
||||
JSONs), synthesis.md)
|
||||
- **Intention profile:** contract/lifecycle state machines, authority gating, fail-closed validation,
|
||||
HMAC-chained ledgers, policy dispatch.
|
||||
- **Intention profile correction (inward-verified):** the subsystem is durable-ledger I/O,
|
||||
authority wiring, deterministic policy gates, and TUI-facing coordination — NOT hot loops
|
||||
(2 RLocks, 0 sockets, 0 thread pools across 43 files). **Scope gap: `construction_authority.py`
|
||||
and `verification_construction.py` do not exist in the tree** — the construction cluster is
|
||||
covered by build_mode.py; no evidence invented for the missing files.
|
||||
- **Candidate matrix:** 6 clusters, all KEEP-PYTHON. Scores (1–5 = fit for the cluster's actual
|
||||
workload): (C1) durable state & ledger stores (event_store.py:56-101, atomic.py, evidence_store.py
|
||||
— fsync-bound; hashing already C via hashlib) Python 5; (C2) composition roots & authority wiring
|
||||
(composition.py, authority.py, cognitive.py — pure glue, runtime cost ≈ 0) Python 5; (C3)
|
||||
policy/state machines/gates (lifecycle.py, scheduler.py, broker.py, no_change.py, reflex.py —
|
||||
deterministic dict/enum decisions, sub-ms; testability is the requirement) Python 5; (C4)
|
||||
structural self-model & schema tooling (spine.py:24-25, microkernel.py, contracts.py, records.py
|
||||
— introspection + canonical JSON; only candidate for a future Rust spine-diff if snapshots grow
|
||||
1000×) Python 4; (C5) TUI-facing orchestration & build pipeline (build_mode.py 11-stage pipeline,
|
||||
gateway.py, transport.py — the consumer IS the Python TUI; latency dominated by LLM/subprocess
|
||||
waits) Python 5; (C6) benchmark/migration harnesses (retrieval_benchmark.py, migrate.py,
|
||||
benchmark.py — offline tooling, recode buys nothing, loses iteration speed) Python 4.
|
||||
- **Decision:** **KEEP-PYTHON (all 6 clusters). This subsystem contributes 0 recode units to
|
||||
the wave plan.** Go: REJECTED, do not install — differentiator (goroutine-dense servers)
|
||||
targets a workload absent here; cost is exception→error-value rewrite of ~260 functions.
|
||||
Rust: FEASIBLE (toolchain proven) but UNJUSTIFIED anywhere — no cluster has the hot loop
|
||||
that pays for a process-boundary recode; keep as toolchain-ready for other subsystems.
|
||||
C: REJECTED — the only compute-shaped work (sha256) already executes C via stdlib hashlib.
|
||||
- **Kill tests:** K1 (frozen, ALREADY RUN): 100 round-trips through the rebase-proof adapter —
|
||||
mean 0.63ms, p95 0.74ms, cold 0.87ms, 100% exit-0 + ok:true → adapter overhead does NOT block
|
||||
recodes on cost; recodes die on value grounds. Re-open any cluster if profiling shows a
|
||||
function consuming >10% of a real interactive session. K-GO (frozen, ~30 min): Go prototype
|
||||
of event_store.py's batched append+fsync vs Python at 1000 events; if Python's p95 is within
|
||||
2× of Go's, Go is dead here permanently. K-RUST (frozen, ~20 min): measure spine diff on a
|
||||
10k-entity snapshot; <50ms kills any Rust spine recode by absence of pain.
|
||||
- **Sources + lessons:** 4 live-fetched web sources (pydantic-core, ruff blog, go.dev FAQ,
|
||||
Python 3.14 hashlib docs) + 1 LOCAL EXPERIMENT (n=100 adapter benchmark). Lessons: (1) the
|
||||
rebase-proof adapter round-trips at 0.63ms/op — subprocess-per-op is viable for coarse ops,
|
||||
never per-event; (2) Python's "slowness" argument dissolves where the actual compute already
|
||||
runs in C via stdlib (hashlib, sqlite) — the language question is really "where is the hot
|
||||
loop", and here the answer is nowhere; (3) assumed limits flagged honestly: human-scale event
|
||||
cadence (batch_size=8) and Linux spawn cost — both would need revisit at 10⁴+ events/sec or
|
||||
cross-platform targets.
|
||||
|
||||
## W6 — Runtime core & tooling (units: tool_executor 46, tool_protocol 27, local_tool_runtime 32, capability_broker 19, context_broker 21, work_manager 25, cron_engine 28, + failure_auto_filer 28, hook_engine, elastic_cognition, auto_tasks, goal)
|
||||
|
||||
- **Research round actor:** general-4 (COMPLETED 2026-09-29 on disk despite cancellation
|
||||
notification — artifacts at `research/wave1/runtime-core/`: inward.md, outward/,
|
||||
outward-sources.md, synthesis.md)
|
||||
- **Intention profile:** subprocess/process supervision, native execution boundary (native_units
|
||||
adapter already exists), event loops, cron scheduling, hook dispatch.
|
||||
- **Decision:** **STAY-PYTHON (all 6 clusters).** Round scores (1–5 = fit for the cluster's
|
||||
actual inward-characterized workload): (A) subprocess lifecycle (tool_executor, cron_engine,
|
||||
auto_tasks) 4.7 — `subprocess.run(timeout=, capture_output=)` is a one-call expression of the
|
||||
pattern; Rust std::process has NO built-in child timeout (outward-verified), strictly more
|
||||
code under zero-crate; (B) protocol/JSON parsing (tool_protocol, brokers) 4.5 — inputs are
|
||||
KB-scale model fragments; Python json/regex is already C-backed, so a rewrite swaps C for
|
||||
compiled code at the same asymptotic cost; serde's typed-struct model actively mismatches
|
||||
fence-wrapped schema-flexible output; (C) model→tool control loop + HTTP
|
||||
(local_tool_runtime, elastic_cognition, goal) 4.8 — wall time is LLM HTTP latency in seconds;
|
||||
Python overhead is µs–ms noise; the loop's content is policy (circuit breaker, escalation
|
||||
ladders, routing tables); (D) context assembly (context_broker) 4.7 — correctness lives in
|
||||
admission policy and degrade-not-crash semantics, not speed; compaction is itself an LLM call;
|
||||
(E) reliability/queueing (failure_auto_filer) 4.2 — honest lowest: on paper the best Rust
|
||||
candidate (queue + worker + crash-safe JSONL spool), but perf requirement ≈ nil and the
|
||||
correctness burden is threading semantics a rewrite would re-risk for no win ("nothing
|
||||
breaks today"); (F) declarative config/brokers (work_manager, hook_engine, capability_broker)
|
||||
4.9 — literal data, no-op runtime, portable to anything valuable in nothing.
|
||||
- **Candidate matrix summary:** Python 6/6 · Go 0/6 (paper verdicts only — evidence-starved,
|
||||
not installed, kill tests unrunnable locally) · Rust 0/6 (kill tests runnable via the
|
||||
existing std-only interop harness) · C unscored (no FFI-adjacent cluster surfaced).
|
||||
Best-language moment found anywhere: robfig/cron replacing ~37 hand-rolled cron-parsing
|
||||
lines (cron_engine.py:199-236) — ergonomics nicety, not a fit verdict.
|
||||
- **Kill tests (frozen in synthesis.md):** Rust-A — re-express `_run_command`
|
||||
(tool_executor.py:1064) with std::process + manual timeout; kill if >2× the ~40-line
|
||||
Python wrapper (Q4 predicts it is); Go-A — port `cron_engine._run_loop` with os/exec,
|
||||
measure task-fire latency delta (prediction: zero — 60 s sleep and child runtime dominate);
|
||||
B — parse the largest observed tool-protocol stream 1000× in Python; kill if total <1 s
|
||||
(no rewrite yields an observable win); C — instrument `_run_loop`
|
||||
(local_tool_runtime.py:417), log non-HTTP CPU per iteration (prediction <1% of wall);
|
||||
D — time `assemble`+`_admit` on the largest stored context, kill if <10 ms; E — fault-inject
|
||||
kill -9 during spool append; if recovery semantics already hold, the rewrite has no
|
||||
correctness win to buy; F — none needed: absence of any win condition IS the disproof.
|
||||
- **Sources + lessons:** live-fetched 2026-09-29 — annotations in
|
||||
`research/wave1/runtime-core/outward-sources.md`; headline lessons: Rust std::process lacks
|
||||
child timeout (Q4); Go os/exec reaches parity but changes only syntax (Q1); Python
|
||||
json/regex C-backed (Q5b); serde typed structs mismatch dynamic model output (Q5a);
|
||||
concurrency features irrelevant at ≤2 threads (Q6).
|
||||
- **Evidence sufficiency:** Python verdicts fully evidenced inward+outward; Go execution-side
|
||||
evidence honestly starved (paper kill tests carried forward); E's JSONL-spool crash-safety
|
||||
claim rests on inward semantics only — flagged in synthesis.md.
|
||||
|
||||
## W7 — Operator + TUI (units: rune_operator 61 fns, rune_tui 161 fns — largest module, tui_validator)
|
||||
|
||||
- **Research round actor:** general-3 (COMPLETED 2026-09-29 — artifacts at
|
||||
`research/wave1/tui/`: inward.md, outward/ ×8, outward-sources.md, synthesis.md)
|
||||
- **Intention profile:** terminal rendering, input handling, rich layout, hot store state, supervisory
|
||||
command surface. Design decision 4 holds: TUI consumes the interop contract, never rewritten first.
|
||||
- **Decision:** **KEEP-PYTHON (TUI shell; all 4 clusters, zero RECODE, no carve-out found).**
|
||||
Cluster scores (1 = recode, 5 = keep): (A) rendering/widgets 4 — no hot loop exists; render is
|
||||
plain Rich `Text` concatenation (rune_tui.py:1331, chat_bubble.py:157), only animation a 0.12 s
|
||||
spinner (:263), no markdown/Syntax parsing anywhere in src/rune; (B) event glue (~50 `_cmd_*`
|
||||
handlers, reactives, tool-widget maps) 5 — this is Textual's core competency (workers,
|
||||
`exclusive=True`, `call_from_thread`); vendor docs (ratatui) admit "little to no help in organizing
|
||||
large applications"; (C) model send path + envelope 5 — synchronous `send_task` in async worker;
|
||||
the envelope shape (`success/response/thinking/tool_events/degraded`, rune_tui.py:3624ff) is
|
||||
ALREADY the de-facto interop contract; (D) subprocess/streaming 5 — JSONL streaming precedent
|
||||
already in-tree (`_cmd_pull` :3443-3517): the pattern for consuming recoded units, not a recode
|
||||
target. Whole-TUI native recode 1/5 REJECTED — 80+ first-party module coupling, framework-provided
|
||||
concurrency re-implemented by hand; Go additionally toolchain-blocked. Countervailing evidence
|
||||
(Bubble Tea/tview/Codex TUI maturity) recorded honestly in outward-sources.md — it does not
|
||||
outweigh the coupling.
|
||||
- **⚠ CONTRACT GAP FLAGGED (binds Waves 2–7):** `shared/native_units.py` parses only the LAST
|
||||
stdout line — it must gain a JSONL streaming reader (per-line `json.loads` + terminal-envelope
|
||||
validation) before any recoded long-running unit can stream progress to the TUI. Small, testable,
|
||||
non-breaking; candidate for the first Wave 2 implementation issue.
|
||||
- **Adapter surface (TUI as consumer — adopted as the rubric's interop-contract restatement):**
|
||||
(1) single-invocation stateless CLI (`native_unit <op> [args]`, no daemon); (2) success = final
|
||||
stdout line `{"ok":true, …}` generalizing the model-send envelope — recoded units emitting
|
||||
`response`/`tool_events`/`degraded` plug in with zero TUI changes; (3) failure = `ok:false` +
|
||||
nonzero exit, stderr human-only, success = exit 0 AND not timed out; (4) mandatory `--version`
|
||||
on every unit (status bar + `canary_tui_drift.py` detect live units vs Python fallback);
|
||||
(5) JSONL streaming mode for long ops with terminal sentinel envelope; (6) per-call timeout
|
||||
budget, `timed_out` surfaced in `NativeResult` for graceful degradation; (7) streamed events
|
||||
referencing UI objects carry the TUI's stable keys — `(channel, call_id)` for tool widgets
|
||||
(:3676), WorkPane `_entries` object ids (:1239) — preserved verbatim; (8) std-only for
|
||||
proof-phase units.
|
||||
- **Candidate matrix summary:** Python 4/4 clusters · Rust/Go whole-TUI 1/5 rejected · carve-out
|
||||
candidates: none found (future markdown parsing would carve as a Rust native unit behind the
|
||||
adapter, not now).
|
||||
- **Kill tests (frozen in synthesis.md):** stay-Python — killed if >16 ms UI-thread stalls under
|
||||
heavy event bursts OR IPC latency dominates message round-trip (Textual pilot measurement);
|
||||
Rust recode — killed if the adapter must expose >~15 first-party modules (count today ≈80:
|
||||
killed NOW); Go recode — additionally killed by toolchain absence (`command -v go` empty).
|
||||
- **Sources + lessons:** 8 live-fetched 2026-09-29 (Textual workers guide, ratatui vendor docs,
|
||||
Bubble Tea/tview/Codex countervailing evidence, JSON Lines spec, push-granular-work guidance) —
|
||||
annotations in `research/wave1/tui/outward-sources.md`.
|
||||
- **Evidence sufficiency (honest):** full inward read of all 5 TUI files with line refs; ≥2 sources
|
||||
per major claim; gap = no runtime profiling (stall kill test unmeasured — but no mechanism exists
|
||||
for the stall to occur; the 120 ms 21-step pipeline was already removed, `_run_warm_router`
|
||||
:3575 is a no-op).
|
||||
|
||||
## W2 — Leaf utilities & canaries (~40 modules, low coupling: pins, time_awareness, message_queue, event_envelope, attention_capsule, complexity_scorer, token_optimizer, cache 501/17, prompt_sync, chat_bubble, ~15 canary_* modules)
|
||||
|
||||
- **Research round actor:** general-6 (COMPLETED 2026-09-29 — full report in actor notification)
|
||||
- **Intention profile:** small pure helpers, envelopes, scoring, cache; smallest blast radius; the
|
||||
factory + interop proving ground at real scale.
|
||||
- **Ground-truth measurements (local, this session):** Python fn call ~46 ns (100k timed
|
||||
iterations in-process) · native CLI roundtrip ~658 µs (subprocess to
|
||||
`interop/rebase-proof/target/release/rebase-proof --version`) · Python startup floor ~18 ms ·
|
||||
overhead ratio ≈ **14,000×**. The only CI-proven interop contract is the subprocess CLI boundary
|
||||
(`shared/native_units.py`: argv in → JSON stdout, `--version`, timeout, typed `NativeResult`).
|
||||
PyO3/in-process bindings excluded by Wave 0 policy. Therefore every per-call crossing costs
|
||||
≥658 µs — a per-turn helper would need to run >14,000× faster in Rust to break even.
|
||||
- **Candidate matrix (representative — full per-cluster 0–2 scoring in round report):**
|
||||
Python 12/12 in every cluster · Rust 5–6/12 · Go 1–3/12 (D5=0, not installed) · C 1–2/12.
|
||||
No module contains a hot loop (heaviest: 2 `re.sub` per turn in token_optimizer).
|
||||
- **Decision:** **KEEP-PYTHON (all ~40, zero RECODE).** Cluster verdicts: (a) pure CRUD helpers
|
||||
pins/time_awareness/token_optimizer — KEEP, CPython 3.11+ already 10–60% faster on this shape
|
||||
(PEP 659); (b) message/envelope/capsule structures — KEEP, recode would insert a boundary
|
||||
between the chattiest neighbors; (c) cache.py (501 L, largest in wave) — KEEP, the fast part is
|
||||
already C (sqlite3 DB-API 2.0 over SQLite's C library), Python is thin orchestration, batching
|
||||
(threshold 50 / 30 s) already amortizes writes; (d) chat_bubble.py — KEEP, **hard-pinned** to
|
||||
Python by the Textual framework (recode = full framework abandonment, bottleneck is human
|
||||
typing speed); (e) prompt_sync.py glue — KEEP, glue into Python siblings (memory_env,
|
||||
voice_sync, build_pipeline_policy); (f) ~19 canary_* — KEEP **categorically**, a canary that
|
||||
verifies Python importability cannot exist in another language.
|
||||
- **Kill tests (reopening criteria, all fail today on first cheap measurement):** Rust — microbench
|
||||
one per-turn helper behind the native_units CLI, must beat the Python in-process call >10×
|
||||
end-to-end including the 658 µs crossing (whole call <~46 µs; a trivial `--version` already
|
||||
costs 658 µs); Go — identical benchmark post-install, uninstall if roundtrip >100 µs with no
|
||||
named hot path; C — identical benchmark + required fuzz-clean JSON roundtrip, plus a
|
||||
demonstrated GC-free allocation-hot loop first; cache.py — recode only if Python-side
|
||||
orchestration (excl. sqlite3 waits) exceeds 5% of end-to-end latency; capsule/envelope/queue —
|
||||
recode only if one turn's Python total exceeds ~5 ms AND a candidate's CLI path is >10× cheaper.
|
||||
- **Sources + lessons:** (1) https://docs.python.org/3/whatsnew/3.11.html — 10–60% faster,
|
||||
PEP 659 specializing interpreter: CPython already optimized for helper-shaped code, the D2
|
||||
baseline is a moving improving target; (2) https://docs.python.org/3/whatsnew/3.13.html —
|
||||
free-threading/JIT experimental, KEEP-PYTHON ≠ frozen but no bets on experimental paths;
|
||||
(3) https://www.maturin.rs/ — in-process Rust↔Python needs a whole binding/packaging toolchain
|
||||
atop the CLI boundary, D3 worse than measured 658 µs (Wave 0 policy excludes it anyway);
|
||||
(4) https://go.dev/doc/install — Go is a pre-install prerequisite, D5=0 everywhere, never earned
|
||||
installation; (5) https://textual.textualize.io/ — Textual is a Python TUI framework,
|
||||
chat_bubble.py framework-pinned; (6) https://docs.python.org/3/library/sqlite3.html — sqlite3 is
|
||||
DB-API 2.0 over C, cache.py's perf layer already native. All fetched live 2026-09-29.
|
||||
- **⚠ Inventory drift (VERIFIED 2026-09-29):** complexity_scorer.py listed in
|
||||
rebase-inventory.json (2 mentions) but absent on disk — deleted in commit 867321e
|
||||
("governed retirement — complexity_scorer deleted (602 lines)"). Wave 0 inventory needs a
|
||||
correction pass; the module was scored nowhere (no source to characterize).
|
||||
|
||||
---
|
||||
|
||||
## Wave-plan consequence (T5 scope, from the verdict above)
|
||||
|
||||
With **0 recode units awarded**, the tranche's hypothesized rebase volume is real only as the
|
||||
work below. Waves 2–9 are **disposition + named improvements, NOT mass recoding**:
|
||||
|
||||
**Waves 2–7 — per-issue disposition (the 1,852 `[rebase w{wave}]` issues):**
|
||||
Each issue receives its tranche §7 disposition — improve / fix / missing-connectors answered
|
||||
with evidence — plus a language verdict of **KEEP-PYTHON with its rubric evidence line**
|
||||
(the round section for that subsystem names the claim). Disposition runs leaf-first
|
||||
(Wave 2 → 7), the canonical suite stays green throughout, and no issue closes without its
|
||||
evidence line. A recode is off the table unless a frozen kill test is reopened — and then
|
||||
tranche §6 still governs: **no benchmark win, no recode.** Go is not installed and earned no
|
||||
installation; Rust stays toolchain-ready but unjustified (standing option behind the
|
||||
CI-proven adapter).
|
||||
|
||||
**Named improvements — the actual rebase volume (ship alongside disposition):**
|
||||
1. **JSONL streaming reader for `shared/native_units.py`** (W7 contract gap): the adapter
|
||||
parses only the LAST stdout line; it must gain per-line `json.loads` + terminal-envelope
|
||||
validation before any recoded long-running unit could stream progress. Small, testable,
|
||||
non-breaking — first implementation issue of Wave 2.
|
||||
2. **httpx retry/timeout wrapper for the provider layer** (W4): the honest reliability
|
||||
upgrade is a feature-add, not a language change.
|
||||
3. **orjson upgrade path** (W3 cluster 1): the only credible Python-side JSON win —
|
||||
in-process, no boundary crossing. **DISPOSITION (2026-09-29, kill test EXECUTED and
|
||||
FAILED — KEEP-PYTHON stands, no dependency declared):** microbench on the live
|
||||
`~/.rune/consolidation/knowledge.json` (3,540,959 bytes, n=30 best-of): load 13.15 ms
|
||||
std vs 7.85 ms orjson = **1.68× (bar ≥2×: FAIL)**; dump 9.58 ms std vs 5.61 ms
|
||||
orjson-with-decode = **1.71× (bar ≥5×: FAIL)**; raw-bytes dump 1.39 ms = 6.91× but is
|
||||
not drop-in (every `write_text`/`json.dump`-to-file site would need a bytes→str or
|
||||
binary-write conversion). Roundtrip equality verified. Bench: `/tmp/orjson_bench.py`
|
||||
shape (uvx --with orjson). The bytes trap measured real: decode() eats ~60% of the
|
||||
dump win.
|
||||
4. **complexity_scorer.py inventory correction**: listed in `rebase-inventory.json` but
|
||||
deleted on disk (commit `867321e`, 602 lines) — Wave 0 inventory needs the correction
|
||||
pass. **DISPOSITION (2026-09-29, DONE):** inventory regenerated per the script's own
|
||||
header (never hand-edit) — 177→213 files, 1,852→2,314 functions, 13 dead files
|
||||
dropped; committed + pushed `5bc837c`. The W2 round's "2 mentions" drift claim
|
||||
verified FALSE against the live inventory (zero hits).
|
||||
5. **robfig/cron** (W6): recorded as a best-language footnote only; no action.
|
||||
|
||||
**Wave 8 — TUI integration proof:** demonstrate the TUI reading/utilising the mixed-language
|
||||
surfaces that exist (the `interop/rebase-proof` unit through the upgraded adapter), per the
|
||||
8-point adapter surface adopted above.
|
||||
**Wave 9 — close-out audit:** 100% of the 1,852 issues dispositioned, debt-table revision,
|
||||
version bump.
|
||||
|
||||
## Cross-cutting candidates (evaluated once, referenced by every subsystem)
|
||||
|
||||
- **Rust (present, 1.98.1):** interop proof unit already CI-proven (std-only, zero crates, `interop/rebase-proof/`, adapter in `shared/native_units.py`, ok = exit 0 AND not timed out).
|
||||
- **Go (absent):** must earn installation through D1–D5 evidence here before any recode targets it.
|
||||
- **C (present, GCC 16.2.1):** expected to lose D6 in most subsystems; scored where FFI-adjacent work exists.
|
||||
- **Python (KEEP baseline):** the default. Every subsystem begins here; evidence must move it.
|
||||
|
||||
## Evidence ledger (per-source lessons, all rounds)
|
||||
|
||||
Index of per-source evidence. Full annotations live in each round's
|
||||
`research/wave1/<subsystem>/outward-sources.md`; this ledger records the headline lesson
|
||||
each source contributed and the rubric claims it supports.
|
||||
|
||||
| Round | Sources (live-fetched 2026-09-29) | Headline lessons | Supports |
|
||||
|---|---|---|---|
|
||||
| W2 leaf utilities | 6: python.org 3.11 + 3.13 whatsnew, maturin.rs, go.dev/doc/install, textual.textualize.io, docs.python.org sqlite3 | CPython 3.11+ already 10–60% faster on helper-shaped code (PEP 659) — D2 baseline is a moving target; free-threading/JIT experimental, no bets; in-process bindings need a whole toolchain atop the CLI boundary (excluded by Wave 0 policy anyway); Go is pre-install prerequisite, D5=0; Textual framework-pins chat_bubble.py; sqlite3 perf layer already C | W2 KEEP-PYTHON ×~40; the 14,000× crossing-overhead law |
|
||||
| W3 memory | 6: orjson, sled, sqlite-vec, SQLite FTS5 docs, Go json/v2 rewrite issue, pydantic-core | orjson is the only credible Python-side JSON win (in-process, no boundary); SQLite FTS5/vec0 compute is already C; Go's JSON stack mid-rewrite upstream (versioning risk); pydantic-core shows the Rust-as-wheel pattern, excluded by policy | W3 KEEP-PYTHON 7/7; orjson upgrade path on cluster 1 |
|
||||
| W4 provider-layer | 8: live-fetched O1–O8 (HTTP/SSE ecosystem, tokio-stack precedents incl. mistral.rs, pkg.go.dev/net/http, ollama Go client/server) | stdlib-only Rust has no realistic HTTP/SSE precedent — the zero-crate contract decides Rust out of all network-wire clusters by construction; Go's stdlib HTTP+NDJSON streaming is capability-proven (its losses here are evidence-absence, not capability-absence); the honest reliability upgrade is a Python httpx retry/timeout wrapper — feature-add, not language change | W4 KEEP-PYTHON 5/5; SSE-cluster Rust kill-by-construction |
|
||||
| W5 orchestration | 4: pydantic-core, ruff blog, go.dev FAQ, Python 3.14 hashlib docs + 1 LOCAL EXPERIMENT (n=100 adapter benchmark) | adapter round-trips at 0.63ms/op — subprocess-per-op viable for coarse ops, never per-event; Python "slowness" dissolves where compute already runs in C via stdlib; the language question is really "where is the hot loop" (here: nowhere) | W5 KEEP-PYTHON 6/6; K1 kill test already run |
|
||||
| W6 runtime-core | headline lessons (full set in round outward-sources.md) | Rust std::process lacks built-in child timeout — strictly more code under zero-crate; Go os/exec reaches parity but changes only syntax; Python json/regex C-backed so rewrite swaps C for compiled code at equal asymptotic cost; serde typed structs mismatch schema-flexible model output; concurrency features irrelevant at ≤2 threads | W6 STAY-PYTHON 6/6; robfig/cron as best-language footnote |
|
||||
| W7 TUI | 8: Textual workers guide, ratatui vendor docs, Bubble Tea/tview/Codex TUI (countervailing), JSON Lines spec, push-granular-work guidance | Textual workers/`call_from_thread` are the core competency for event glue; ratatui admits "little to no help in organizing large applications"; countervailing native-TUI frameworks recorded honestly — do not outweigh 80+ module coupling; JSONL per-line parse + terminal sentinel is the streaming contract; vendor guidance: push granular speed-sensitive work to subcomponents only when measured | W7 KEEP-PYTHON 4/4; the 8-point adapter surface (adopted as the rubric's interop-contract restatement) |
|
||||
@@ -0,0 +1,50 @@
|
||||
# Wave 0 close-out report — language-rebasing tranche R1
|
||||
|
||||
Branch: `tranche-r1-wave-0` · Date: 2026-09-29
|
||||
|
||||
## 1. Inventory (docs/rebase/rebase-inventory.json)
|
||||
|
||||
- 177 Python modules, ~52,750 lines, 227 classes
|
||||
- TRUE unit count: **1,852 = 1,030 top-level functions + 822 class methods**
|
||||
- Zero duplicate call edges, zero method leaks (generator double-walk defect
|
||||
root-caused and fixed in scripts/rebase_inventory.py)
|
||||
- Cross-verified against an independent fresh AST walk: zero missing units;
|
||||
six inventory-"extras" are real nested/if-try-block classes the naive walk
|
||||
misses (inventory is MORE complete, not less)
|
||||
|
||||
## 2. Issue factory (scripts/rebase_issue_factory.py)
|
||||
|
||||
- One issue per unit following R1 §7; wave-batched (2–7); dedup-safe via
|
||||
deterministic title `[rebase w{wave}] {relpath}::{unit}` + existing-titles
|
||||
scan per run (retries never duplicate — preserve-first)
|
||||
- `plan` → {2: 239, 3: 251, 4: 129, 5: 544, 6: 415, 7: 274} = 1,852,
|
||||
zero unassigned
|
||||
- `sample --wave 5` → full §7 template holds; `_compute_hash` exactly once as
|
||||
labelled callee with line number
|
||||
- Contract fix during Wave 0: `--limit` default changed 5 → 0 (unlimited) for
|
||||
`create`; `sample` keeps its 5. A full-wave create with the old default
|
||||
would have silently truncated each wave to five issues.
|
||||
|
||||
## 3. Interop proof unit (R1 §5)
|
||||
|
||||
- Rust unit at interop/rebase-proof/ (std-only, zero crates): argv in → JSON
|
||||
on stdout, non-zero exit on failure, mandatory `--version`
|
||||
- Shell-proven: version flag, JSON out, exit 2 on failure, stderr diagnostic
|
||||
- Python thin adapter shared/native_units.py: typed NativeResult, timeout +
|
||||
stderr capture, unit failures as data (never exceptions)
|
||||
- Adapter contract fix during Wave 0: `ok` no longer requires a JSON
|
||||
`ok:true` field — plain-text output (`--version`) succeeds on exit code
|
||||
- tests/test_native_interop_proof.py: **8 passed** — version contract, sum
|
||||
happy path, hash determinism, failure paths, timeout, stderr capture
|
||||
|
||||
## 4. Per-wave issue creation
|
||||
|
||||
<!-- FILLED BY THE COMPLETION LOOP — do not hand-edit -->
|
||||
|
||||
- Wave 4 (129 units): created=127 skipped(dedup)=2 (includes smoke #417–418)
|
||||
- Wave 2 (239 units): created=239 skipped(dedup)=0
|
||||
- Wave 3 (251 units): created=251 skipped(dedup)=0
|
||||
- Wave 5 (544 units): created=544 skipped(dedup)=0
|
||||
- Wave 6 (415 units): created=415 skipped(dedup)=0
|
||||
- Wave 7 (274 units): created=274 skipped(dedup)=0
|
||||
- TOTAL: 1,850 created + 2 dedup = 1,852 / 1,852 ✅ full coverage
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"units_per_wave": {
|
||||
"2": 239,
|
||||
"3": 251,
|
||||
"4": 129,
|
||||
"5": 544,
|
||||
"6": 415,
|
||||
"7": 274
|
||||
},
|
||||
"unassigned_modules": {},
|
||||
"unassigned_module_count": 0
|
||||
}
|
||||
@@ -9,6 +9,8 @@ new surface._
|
||||
|---|---|---|
|
||||
| `rune_tui.py` | The main Textual application — chat pane, work pane, status bar (version label + refresh), footer, dialogs | The known giant (file-size debt tracked in henxels); composition root for all UI |
|
||||
| `tui_validator.py` | Import-allowlist validation for TUI code | Drift defect tracked in #139 (allowlist vs `__future__`/relative imports) |
|
||||
| `tui_backlog.py` | Not a surface — the durable TUI Upgrade Backlog ledger (outstanding projection/control work per backend feature) | SCAFFOLD-TRANCHE-007; ledger only, never structural authority |
|
||||
| `tui_transport.py` | Not a surface — the transport seam for the TUI's operator calls (in-process default / /v1 wire opt-in) | H4 slice 1 (contract BUILDER-2026-10-04T10:32:00Z-cli); RUNE_TUI_TRANSPORT selects; coverage: canary_tui_transport.py |
|
||||
|
||||
## Adding a TUI surface
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1.27.1
|
||||
+26
-2
@@ -1,7 +1,31 @@
|
||||
settings:
|
||||
warn_about_similar_files:
|
||||
above: 0.85
|
||||
ignore: ["docs/review-lessons.yaml", "docs/v1/**", "docs/wiki/**", "store/spine/**"]
|
||||
ignore: ["docs/review-lessons.yaml", "docs/v1/**", "docs/wiki/**", "store/spine/**",
|
||||
# shared/ is the DEPLOYED-VINTAGE mirror tree (strangler era):
|
||||
# every src/rune <-> shared similarity here is intentional
|
||||
# (the shim delegates to the crate; the mirror is the deployed
|
||||
# vintage it strangles). Registering the deliberate pairs.
|
||||
"shared/**",
|
||||
"src/rune/drift_core.py", "src/rune/drift_compression.py",
|
||||
"src/rune/drift_observability.py", "src/rune/shard_manager.py",
|
||||
"src/rune/subconscious_pass.py",
|
||||
# deliberate mirror pairs outside the strangler set:
|
||||
# issue templates are mirrored into shared/issue_templates;
|
||||
# completion_file.py is the bridge/daemon mirror of scripts/;
|
||||
# interop/rebase-proof is a committed proof fixture.
|
||||
".gitea/ISSUE_TEMPLATE/**", "shared/issue_templates/**",
|
||||
"bridge/daemon/completion_file.py", "scripts/completion_file.py",
|
||||
"interop/rebase-proof/**",
|
||||
# per-crate copies by zero-dep design: each crate is
|
||||
# self-contained (no shared crate dependency), so the pyjson
|
||||
# JSON module is copied verbatim per crate and the tiny
|
||||
# zero-dep lockfiles are near-identical by construction.
|
||||
"src/rust/*/Cargo.lock", "src/rust/*/src/pyjson.rs",
|
||||
"src/rust/query_memory/src/json_min.rs",
|
||||
# store/scaffold is data (proof fixtures, gate objects),
|
||||
# same class as the already-ignored store/spine.
|
||||
"store/scaffold/**"]
|
||||
|
||||
henxels:
|
||||
- henxel: "Canaries live at the repo's canary slot and are registered in CI"
|
||||
@@ -20,7 +44,7 @@ henxels:
|
||||
- henxel: "Top-level docs are markdown or yaml references"
|
||||
in: ./docs/*
|
||||
allowed_filetypes: [.md, .yaml]
|
||||
except: ["./docs/v1/**", "./docs/wiki/**", "./docs/gitea_issue_reconciliation.json"]
|
||||
except: ["./docs/v1/**", "./docs/wiki/**", "./docs/gitea_issue_reconciliation.json", "./docs/rebase/**"]
|
||||
|
||||
- henxel: "The known giant stays visible — file-size debt is tracked, not silent"
|
||||
in: ./src/rune/*
|
||||
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "rebase-proof"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "rebase-proof"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# Wave 0 interop proof unit (R1 §5). Std-only by design: no crates, no network,
|
||||
# deterministic build. The pattern under proof is the CLI boundary itself, not the
|
||||
# computation inside it.
|
||||
|
||||
[[bin]]
|
||||
name = "rebase-proof"
|
||||
path = "src/main.rs"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Wave 0 interop proof unit (R1 §5 of the language-rebasing tranche).
|
||||
//!
|
||||
//! Contract under proof:
|
||||
//! - standalone binary; argv in, JSON on stdout
|
||||
//! - non-zero exit on failure (JSON error object still printed to stdout)
|
||||
//! - `--version` flag mandatory
|
||||
//! - Python wraps it in a thin adapter (`shared/native_units.py`); tests call
|
||||
//! it the same way.
|
||||
//!
|
||||
//! Ops: sum / echo / hash (fnv1a64) / sleep / fail. All std-only.
|
||||
|
||||
use std::env;
|
||||
use std::process::exit;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// FNV-1a 64-bit — dependency-free deterministic hash for the proof unit.
|
||||
fn fnv1a64(data: &[u8]) -> u64 {
|
||||
let mut hash: u64 = 0xcbf29ce484222325;
|
||||
for byte in data {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn print_json(line: &str) {
|
||||
println!("{line}");
|
||||
}
|
||||
|
||||
fn emit_ok(body: &str) {
|
||||
print_json(&format!("{{\"ok\":true,{body}}}"));
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fn emit_err(message: &str) -> ! {
|
||||
// Escape the minimal set so the JSON stays parseable for arbitrary op names.
|
||||
let escaped: String = message
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'"' => "\\\"".to_string(),
|
||||
'\\' => "\\\\".to_string(),
|
||||
'\n' => "\\n".to_string(),
|
||||
'\r' => "\\r".to_string(),
|
||||
'\t' => "\\t".to_string(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect();
|
||||
print_json(&format!("{{\"ok\":false,\"error\":\"{escaped}\"}}"));
|
||||
exit(2);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
|
||||
if args.as_slice() == ["--version"] || args.as_slice() == ["-V"] {
|
||||
println!("rebase-proof {VERSION}");
|
||||
exit(0);
|
||||
}
|
||||
if args.is_empty() {
|
||||
eprintln!("usage: rebase-proof <op> [args...] | --version");
|
||||
emit_err("no op given");
|
||||
}
|
||||
|
||||
let op = args[0].as_str();
|
||||
let rest = &args[1..];
|
||||
|
||||
match op {
|
||||
"sum" => {
|
||||
let mut total: i128 = 0;
|
||||
for a in rest {
|
||||
match a.parse::<i128>() {
|
||||
Ok(v) => total += v,
|
||||
Err(_) => emit_err(&format!("sum: not an integer: {a}")),
|
||||
}
|
||||
}
|
||||
emit_ok(&format!("\"op\":\"sum\",\"result\":{total}"));
|
||||
}
|
||||
"echo" => {
|
||||
let joined = rest.join(" ");
|
||||
let escaped: String = joined
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'"' => "\\\"".to_string(),
|
||||
'\\' => "\\\\".to_string(),
|
||||
'\n' => "\\n".to_string(),
|
||||
'\r' => "\\r".to_string(),
|
||||
'\t' => "\\t".to_string(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect();
|
||||
emit_ok(&format!("\"op\":\"echo\",\"text\":\"{escaped}\""));
|
||||
}
|
||||
"hash" => {
|
||||
let joined = rest.join(" ");
|
||||
let h = fnv1a64(joined.as_bytes());
|
||||
emit_ok(&format!("\"op\":\"hash\",\"fnv1a64\":\"{h:016x}\""));
|
||||
}
|
||||
"sleep" => {
|
||||
let ms: u64 = match rest.first().map(|s| s.parse::<u64>()) {
|
||||
Some(Ok(v)) => v,
|
||||
_ => emit_err("sleep: expected milliseconds"),
|
||||
};
|
||||
thread::sleep(Duration::from_millis(ms));
|
||||
emit_ok(&format!("\"op\":\"sleep\",\"slept_ms\":{ms}"));
|
||||
}
|
||||
"fail" => {
|
||||
// Deliberate failure: stderr diagnostic + JSON error + exit 2.
|
||||
// Proves stderr capture and non-zero exit in the adapter.
|
||||
eprintln!("rebase-proof: deliberate failure for adapter proof");
|
||||
emit_err("deliberate failure");
|
||||
}
|
||||
other => emit_err(&format!("unknown op: {other}")),
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"BLE001": 352,
|
||||
"S110": 80
|
||||
}
|
||||
"BLE001": 345,
|
||||
"S110": 75
|
||||
}
|
||||
@@ -1 +1,401 @@
|
||||
[]
|
||||
[
|
||||
{
|
||||
"name": "read_file",
|
||||
"description": "Read a file with optional windowed viewing (offset/limit). Returns numbered lines. Use offset/limit for large files (100 lines at a time).",
|
||||
"capabilities": [
|
||||
"read_file"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"path exists and is a readable file"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "read_file read a file with optional windowed viewing (offset/limit). returns numbered lines. use offset/limit for large files (100 lines at a time). native tool"
|
||||
},
|
||||
{
|
||||
"name": "edit_file",
|
||||
"description": "Surgical search/replace edit. More reliable than full file rewrites. The search string must match exactly.",
|
||||
"capabilities": [
|
||||
"edit_file"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "write",
|
||||
"preconditions": [
|
||||
"path is inside an allowed write directory",
|
||||
"search string matches exactly once"
|
||||
],
|
||||
"side_effects": [
|
||||
"rewrites the target file atomically after approval",
|
||||
"creates a .bak backup under ~/.rune/undo"
|
||||
],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "edit_file surgical search/replace edit. more reliable than full file rewrites. the search string must match exactly. native tool"
|
||||
},
|
||||
{
|
||||
"name": "write_file",
|
||||
"description": "Write content to a file. Creates parent directories if needed. Shows diff before writing. Use for new files or full rewrites.",
|
||||
"capabilities": [
|
||||
"write_file"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "write",
|
||||
"preconditions": [
|
||||
"path is inside an allowed write directory"
|
||||
],
|
||||
"side_effects": [
|
||||
"writes the full file content atomically after approval",
|
||||
"creates a .bak backup under ~/.rune/undo"
|
||||
],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "write_file write content to a file. creates parent directories if needed. shows diff before writing. use for new files or full rewrites. native tool"
|
||||
},
|
||||
{
|
||||
"name": "glob_files",
|
||||
"description": "Find files matching a glob pattern. Returns file paths sorted by modification time.",
|
||||
"capabilities": [
|
||||
"glob_files"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"search directory is readable"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "glob_files find files matching a glob pattern. returns file paths sorted by modification time. native tool"
|
||||
},
|
||||
{
|
||||
"name": "grep_files",
|
||||
"description": "Search for a pattern in files. Returns file paths and line numbers. Use include to filter by file type.",
|
||||
"capabilities": [
|
||||
"grep_files"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"search directory is readable"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "grep_files search for a pattern in files. returns file paths and line numbers. use include to filter by file type. native tool"
|
||||
},
|
||||
{
|
||||
"name": "list_directory",
|
||||
"description": "List files and directories at a path. Returns names with / suffix for directories.",
|
||||
"capabilities": [
|
||||
"list_directory"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"path is a readable directory"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "list_directory list files and directories at a path. returns names with / suffix for directories. native tool"
|
||||
},
|
||||
{
|
||||
"name": "lint_file",
|
||||
"description": "Lint a file for syntax errors and style issues. Blocks bad edits. Supports Python (ruff), JS/TS (eslint), Go (golangci-lint), and more.",
|
||||
"capabilities": [
|
||||
"lint_file"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"file exists",
|
||||
"a linter is configured for the language"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "lint_file lint a file for syntax errors and style issues. blocks bad edits. supports python (ruff), js/ts (eslint), go (golangci-lint), and more. native tool"
|
||||
},
|
||||
{
|
||||
"name": "run_tests",
|
||||
"description": "Run tests for a file or project. Auto-detects test runner from language.",
|
||||
"capabilities": [
|
||||
"run_tests"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "write",
|
||||
"preconditions": [
|
||||
"a test runner is configured for the project"
|
||||
],
|
||||
"side_effects": [
|
||||
"runs project test commands which may create or delete files"
|
||||
],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "run_tests run tests for a file or project. auto-detects test runner from language. native tool"
|
||||
},
|
||||
{
|
||||
"name": "format_code",
|
||||
"description": "Auto-format code files. Supports Python (black/ruff), JS/TS (prettier), Go (gofmt), and more.",
|
||||
"capabilities": [
|
||||
"format_code"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "write",
|
||||
"preconditions": [
|
||||
"path is inside an allowed write directory",
|
||||
"a formatter is configured for the language"
|
||||
],
|
||||
"side_effects": [
|
||||
"rewrites files in place to the formatter's style"
|
||||
],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "format_code auto-format code files. supports python (black/ruff), js/ts (prettier), go (gofmt), and more. native tool"
|
||||
},
|
||||
{
|
||||
"name": "git_diff",
|
||||
"description": "Show git diff for staged or unstaged changes.",
|
||||
"capabilities": [
|
||||
"git_diff"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"working directory is a git repository"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "git_diff show git diff for staged or unstaged changes. native tool"
|
||||
},
|
||||
{
|
||||
"name": "git_log",
|
||||
"description": "Show recent git commits.",
|
||||
"capabilities": [
|
||||
"git_log"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"working directory is a git repository"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "git_log show recent git commits. native tool"
|
||||
},
|
||||
{
|
||||
"name": "git_commit",
|
||||
"description": "Commit changes with a message. Auto-stages modified files.",
|
||||
"capabilities": [
|
||||
"git_commit"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "irreversible",
|
||||
"preconditions": [
|
||||
"working directory is a git repository",
|
||||
"there are changes to commit"
|
||||
],
|
||||
"side_effects": [
|
||||
"creates a commit on the current branch"
|
||||
],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "git_commit commit changes with a message. auto-stages modified files. native tool"
|
||||
},
|
||||
{
|
||||
"name": "repo_map",
|
||||
"description": "Generate an AST-based codebase map showing key symbols (classes, functions, imports). Uses tree-sitter for 100+ languages.",
|
||||
"capabilities": [
|
||||
"repo_map"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"target directory is readable"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "repo_map generate an ast-based codebase map showing key symbols (classes, functions, imports). uses tree-sitter for 100+ languages. native tool"
|
||||
},
|
||||
{
|
||||
"name": "run_command",
|
||||
"description": "Run an approved command. Prefer argv (shell=False); shell syntax requires shell=true and explicit user approval.",
|
||||
"capabilities": [
|
||||
"run_command"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "irreversible",
|
||||
"preconditions": [
|
||||
"operator approves the command",
|
||||
"required binaries are on PATH"
|
||||
],
|
||||
"side_effects": [
|
||||
"executes arbitrary commands with their full side effects"
|
||||
],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "run_command run an approved command. prefer argv (shell=false); shell syntax requires shell=true and explicit user approval. native tool"
|
||||
},
|
||||
{
|
||||
"name": "read_persona",
|
||||
"description": "Read Rune's persona definition. Returns the full persona block with voice, identity, and rules.",
|
||||
"capabilities": [
|
||||
"read_persona"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"persona MEMORY.md exists"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "read_persona read rune's persona definition. returns the full persona block with voice, identity, and rules. native tool"
|
||||
},
|
||||
{
|
||||
"name": "query_memory",
|
||||
"description": "Query Rune's memory store. Returns relevant memories for a query.",
|
||||
"capabilities": [
|
||||
"query_memory"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"memory store database is available"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "query_memory query rune's memory store. returns relevant memories for a query. native tool"
|
||||
},
|
||||
{
|
||||
"name": "check_growth",
|
||||
"description": "Check Rune's growth log. Returns recent growth entries and patterns.",
|
||||
"capabilities": [
|
||||
"check_growth"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"growth log file exists"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "check_growth check rune's growth log. returns recent growth entries and patterns. native tool"
|
||||
},
|
||||
{
|
||||
"name": "diagnose_behavior",
|
||||
"description": "Diagnose Rune's current behavior. Returns analysis of persona adherence, voice consistency, and drift.",
|
||||
"capabilities": [
|
||||
"diagnose_behavior"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"recent response text provided"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "diagnose_behavior diagnose rune's current behavior. returns analysis of persona adherence, voice consistency, and drift. native tool"
|
||||
},
|
||||
{
|
||||
"name": "find_capability",
|
||||
"description": "Search all installed capabilities for what you need. Returns matching tool names with descriptions and argument schemas. Use this when a capability you need is not in your current tool set; the matched tool becomes callable in the same session.",
|
||||
"capabilities": [
|
||||
"find_capability"
|
||||
],
|
||||
"file": "tool_executor.py",
|
||||
"full_path": "",
|
||||
"priority": 100,
|
||||
"semantic_examples": [],
|
||||
"risk": "read-only",
|
||||
"preconditions": [
|
||||
"a capability query describing the needed outcome"
|
||||
],
|
||||
"side_effects": [],
|
||||
"auto_invoked": false,
|
||||
"hooks": [],
|
||||
"native_tool": true,
|
||||
"searchable_text": "find_capability search all installed capabilities for what you need. returns matching tool names with descriptions and argument schemas. use this when a capability you need is not in your current tool set; the matched tool becomes callable in the same session. native tool"
|
||||
}
|
||||
]
|
||||
+38886
-15
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,48 @@
|
||||
# Phase G slice 1 — retrieval-index authority supersession ledger
|
||||
|
||||
**Date:** 2026-10-02 · **Branch:** `phase-g-strangler-retrieval-index` · **Unit issue:** conductor/rune#2448
|
||||
|
||||
## What was superseded
|
||||
|
||||
The pure-Python `query_memory` implementation (formerly `src/rune/query_memory.py`,
|
||||
deployed at `~/.config/opencode/scripts/query_memory.py`) is RETIRED as a production
|
||||
implementation. Production authority now rests with:
|
||||
|
||||
1. **Implementation:** Rust crate `src/rust/query_memory/` (lib.rs core + cdylib, ten
|
||||
`extern "C"` FFI symbols, zero external crates).
|
||||
2. **Production service surface:** `shared/query_memory.py` — a ctypes-backed shim
|
||||
(179 lines) over `qm_loader.py`, byte-deployed to `~/.config/opencode/scripts/query_memory.py`,
|
||||
serving the FULL prior Python class API including the `self.history` snapshot.
|
||||
|
||||
## Strangler doctrine (master doc lines 685–770) — step discharge
|
||||
|
||||
| Step | Requirement | Evidence |
|
||||
|------|-------------|----------|
|
||||
| 1–4 | candidate built, contract frozen, parity proven | commits `9b76585`+`54d2db9`; `/tmp/phaseG_retrieval_index_contract.md` frozen; parity harness ALL GREEN |
|
||||
| 5 | qualification | `/tmp/phaseG_qual_bench.out` (Rust CLI ~45× faster end-to-end); canaries both sides |
|
||||
| 6 | canaries conformance | Python canary 12/12; Rust canary 27/27 |
|
||||
| 7 | shadow comparison vs live store | `/tmp/phaseG_shadow.py` 46/46 exact-equal vs 77-record production store |
|
||||
| 8 | authority switch + observed consumer evidence | commit `6f44a87`; both consumers verified live — `memory_store.py` (lines 820–821, live 76-record store, nested stats shape) + `growth_simulator.py` (record_query through Rust core); #2448 comment id 33795 |
|
||||
| 9 | runtime evidence post-switch | post-switch verification wall ALL GREEN: Python canary 12/12, FFI smoke 41/41, Rust canary 27/27, parity harness ALL GREEN |
|
||||
| 10 | old production path unreachable | ALL import paths (`import query_memory`) now resolve to the deployed shim; `shared/graph_shard_bridge.py` tolerant import serves the shim; the orphan `src/rune/query_memory.py` had ZERO importers (verified by sweep incl. path-string references) |
|
||||
| 11 | old implementation removed or reference-only | `src/rune/query_memory.py` DELETED (this commit); the only stale reference (`scripts/structural_coverage_audit.py` registry mapping shared/ as a mirror of src/rune) corrected — query_memory removed from `SRC_A`/`SHARED_D`, added to `SHARED_B` (canonical, ops-harness owned) |
|
||||
| 12 | supersession ledger | THIS FILE |
|
||||
|
||||
Deletion discipline (language-governance rule ⑥) satisfied: old code removed only after
|
||||
the new implementation was PROVEN — wall green, shadow 46/46, both production consumers
|
||||
observed live on the Rust core before deletion.
|
||||
|
||||
## Consumer map at supersession
|
||||
|
||||
- `~/.config/opencode/scripts/memory_store.py` — in-process, `adaptive_exploration_rate` (line 820).
|
||||
- `~/.config/opencode/scripts/growth_simulator.py` — in-process, `record_query`.
|
||||
- `shared/graph_shard_bridge.py` — tolerant import (try/except), follows the switch.
|
||||
- `src/rune/tool_executor.py` — NOT a consumer (nominal grep hit only; goes through `memory_store.cached_recall`).
|
||||
|
||||
## Verification commands (reproducible)
|
||||
|
||||
- `/home/nick/.venvs/memory/bin/python3 src/rune/canary_query_memory.py` → 12/12
|
||||
- `/home/nick/.venvs/memory/bin/python3 src/rust/query_memory/canary_rust.py` → 27/27
|
||||
- `/home/nick/.venvs/memory/bin/python3 src/rune/ffi_query_memory_smoke.py` → 41/41
|
||||
- `/home/nick/.venvs/memory/bin/python3 /tmp/phaseG_parity.py` → ALL GREEN
|
||||
- `/home/nick/.venvs/memory/bin/python3 scripts/structural_coverage_audit.py` → exit 0, coverage 100%
|
||||
@@ -0,0 +1,66 @@
|
||||
# Phase G Slice 2 — FROZEN CONTRACT: evidence-provenance (T7.3)
|
||||
|
||||
Frozen: 2026-10-03, session ses_ffe5f08a7892affeGzZWhhm0wR. Branch `phase-g-strangler-evidence-provenance` (cut from slice-1 head `2ac2d0a`). Gitea issue #2449. Nothing below may change without a recorded amendment in this file.
|
||||
|
||||
## 1. Scope (observed, not inferred)
|
||||
|
||||
Six modules in `src/rune/`, 1796 lines total, one test file each as marked:
|
||||
|
||||
| Module | Lines | Surface (grep-verified 2026-10-03) |
|
||||
|---|---|---|
|
||||
| `audit_ledger.py` | 231 | `_compute_hash(prev_hash, entry)`, `append_entry(entry_type, entry_id, details)`, `verify_chain() -> {valid, entries, reason}`, `get_entries(limit=50)`, `log_promotion(entry_id, old_status, new_status, wash_count, reason)`, `log_gate_result(entry_id, verdict, critics)`, `log_drift_alert(alert_type, message, severity)`, `main()` |
|
||||
| `behavior.py` | 411 | `connect()`, `add_correction(original, corrected, scope_tags=None, domain="", pattern="")`, `add_habit(trigger, action, name="", context_tags=None, min_activations=3)`, `match_context(context, match_type=None)`, `reinforce(item_id, outcome, item_type="habit")`, `list_items(item_type="all", limit=20)`, `behavior_stats()`, `main()`, `PLUGIN` dict |
|
||||
| `behaviour_ledger.py` | 251 | 9 state constants (UNVALIDATED…NOT_APPLICABLE) + `STATES` + `CONFIRMED_STATES` tuples, `BehaviourLedger` class |
|
||||
| `claim_audit.py` | 430 | `PATTERNS` list, `EVIDENCE_TAG` regex, `LEDGER_MARKERS`, `reframe(text)`, `scan(text)`, `verify(claim)`, `degrade(claim, verdict)`, `journal(entry)`, `report()`, `audit(draft)`, `handle_hook(event, data=None)`, `main()`, `PLUGIN` |
|
||||
| `evidence_probe.py` | 214 | `PROBE_STATUSES`, `PROBE_FIELDS`, `MAX_PROBES_PER_TASK=2`, `ProbeBudgetExceeded`, `ProbeResult`, `build_probe_prompt(question, artifact_path)`, `validate_probe(artifact)`, `count_probes(artifact_dir)`, `check_budget(artifact_dir)`, `record_probe(result, artifact_dir)`, `run_probe(question, artifact_dir, runner=None, model="UNKNOWN")` |
|
||||
| `verification_layer.py` | 259 | `VerificationResult`, seven `verify_*` functions (exit_code, unit_test, schema, file_exists, file_changed, tool_receipt, constraint_check), `verify(result, verifier_type)`, `handle_hook`, `PLUGIN` |
|
||||
|
||||
Plus `shared/audit_ledger.py` (210 ln) — OLDER vintage of audit_ledger.
|
||||
|
||||
## 2. Authority decisions (per module)
|
||||
|
||||
- **src/rune/ is the authority vintage for all six modules** — hardened newer code (memory_env-pathed storage, defensive handling). The deployed `~/.config/opencode/scripts/{audit_ledger,behavior,claim_audit}.py` copies are OLDER vintages (hardcoded expanduser paths). The authority switch is a VINTAGE UPGRADE plus a language swap — not a mirror replacement.
|
||||
- `shared/audit_ledger.py` is superseded alongside the deployed copy (same older vintage, same defects).
|
||||
|
||||
## 3. Live-data contract (the linchpin — observed)
|
||||
|
||||
- **Live ledger:** `/home/nick/.config/opencode/memory/promotion_ledger.jsonl` (21 entries). `verify_chain()` under the deployed module's own defaults returns `{'valid': True, 'entries': 21, 'reason': 'chain intact'}` — OBSERVED 2026-10-03.
|
||||
- **Live signing key: `rune-drift-integrity-2024`** (the deployed/shared default). The src/rune default `rune-governed-adaptation-2024` FAILS the live chain — it is a latent break, recorded as a defect this slice fixes.
|
||||
- **Hash scheme (both vintages identical):** `HMAC-SHA256(key, (prev_hash + json.dumps(entry_without_hash, sort_keys=True)).encode()).hexdigest()`; genesis prev_hash = `"genesis"`; entry lines written with `json.dumps(entry)` in insertion order (type, entry_id, details, timestamp, prev_hash, hash); timestamp = `datetime.now(timezone.utc).isoformat()`.
|
||||
- **Key rule for the Rust candidate:** read `AUDIT_LEDGER_KEY` env; DEFAULT = `rune-drift-integrity-2024` (the key that verifies the live chain). Never default to the governed key — it verifies nothing that exists.
|
||||
- **Live `behavior.db`:** SQLite at memory_env memory dir, touched Sep 24 — must remain readable by the Rust candidate's storage layer.
|
||||
|
||||
## 4. Rust candidate design
|
||||
|
||||
Crate `/mnt/workdrive/rune/src/rust/evidence_provenance/` following the proven slice-1 shape: zero-dep core in `lib.rs`, `cdylib` FFI exports, hand-rolled JSON serialization where byte-parity demands it. **Port order within the slice** (dependency + risk order):
|
||||
|
||||
1. **audit_ledger** — linchpin: live data, external consumers, key contract.
|
||||
2. **verification_layer** — pure logic, no storage, one test file; fastest port.
|
||||
3. **evidence_probe** — file-based budget/records; `rune_operator.py:581` consumer.
|
||||
4. **behaviour_ledger** — state machine, src/rune-only.
|
||||
5. **behavior** — SQLite habits; live DB; deployed vintage upgrade + `rune_tui.py` consumer.
|
||||
6. **claim_audit** — heaviest (warm-neuron store + graph DB + regex engine); last.
|
||||
|
||||
Python-side ctypes loaders (`*_loader.py`) + deployed shims per slice-1 pattern, so every consumer keeps its import surface unchanged.
|
||||
|
||||
## 5. Parity + qualification criteria
|
||||
|
||||
- **Byte-parity:** Rust `verify_chain` output byte-identical to Python's on the live ledger AND on a mutation battery (tampered entry, broken chain, invalid JSON, empty ledger). Parity harness both sides, slice-1 pattern.
|
||||
- **Canaries:** Python-side canary module per ported module + Rust canary; both green before any switch.
|
||||
- **Shadow:** shadow-compare Rust vs Python on live-shaped data before authority switch.
|
||||
- **Performance:** like-for-like benchmark on the real consumption shape; no regression (Nick's law: combination does not decrease speed).
|
||||
|
||||
## 6. Consumers + adoption plan (Nick's directive: adoption during the refactor)
|
||||
|
||||
| Consumer | Imports | Switch |
|
||||
|---|---|---|
|
||||
| `~/.config/opencode/scripts/curator.py` | audit_ledger | at audit_ledger switch |
|
||||
| `canary_tui_drift.py`, `canary_full_system.py` | audit_ledger | at audit_ledger switch |
|
||||
| `rune_tui.py` (L62, L83) | claim_audit, behavior | at each module's switch |
|
||||
| `rune_operator.py:581` | evidence_probe (relative) | at evidence_probe switch |
|
||||
|
||||
Deployed copies become ctypes shims at each module's authority switch (vintage upgrade included — the shim serves the src/rune contract, not the old vintage's). Old deployed Python deletions follow language-governance ⑥: only after the new is proven.
|
||||
|
||||
## 7. Exit criteria
|
||||
|
||||
All twelve doctrine steps discharged per module-port with observed evidence on #2449; coverage-audit registry remapped; supersession ledger written; audit regression gate green.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Phase G slice 2 — evidence-provenance authority supersession ledger
|
||||
|
||||
**Date:** 2026-10-03 · **Branch:** `phase-g-strangler-evidence-provenance` · **Unit issue:** conductor/rune#2449
|
||||
|
||||
## What was superseded
|
||||
|
||||
The pure-Python evidence-provenance implementations (six modules in `src/rune/`, 1796 lines; plus the two OLD deployed vintages and `shared/audit_ledger.py`) are RETIRED as production implementations for their ported surfaces. Production authority now rests with:
|
||||
|
||||
1. **Rust crate `src/rust/evidence_provenance/`** — six cores: `lib.rs` (audit_ledger chain + FFI), `verification.rs` (7 verifiers), `probe.rs` (schema gate + budget), `behaviour.rs` (state machine), `habitmath.rs` (reinforce math), `claims.rs` (claim-audit decision skeleton); cdylib FFI (`ep_*` symbols, 21 total), zero external crates.
|
||||
2. **Python shims** at `src/rune/{audit_ledger,verification_layer,evidence_probe,behaviour_ledger,behavior,claim_audit}.py` — serving the src/rune authority contract through `ep_loader.py` (in-process, no interpreter startup for the ported logic).
|
||||
3. **Deployed shims** at `~/.config/opencode/scripts/{audit_ledger,behavior,claim_audit}.py` — same shape; audit_ledger + claim_audit + behavior carry the LIVE data-path rules (drift-key ledger, live behavior.db, real journal); claim_audit additionally carries the preserved SYMMETRY GUARD (deployed-vintage feature) and the repaired `scan_text()` TUI surface.
|
||||
|
||||
## Port ledger (all six, in contract §4 order)
|
||||
|
||||
| Port | Module | Rust core | Parity | Commit | Consumer proof |
|
||||
|------|--------|-----------|--------|--------|----------------|
|
||||
| 1 | audit_ledger | lib.rs chain ops | shadow 18/18; canaries 28/28+23/23 | `114c394`+`4b0d1d2` | live ledger entries 4242 (post-switch write), verify 23-intact |
|
||||
| 2 | verification_layer | verification.rs | 33/33 (7 verifiers + CLI render) | `9cb93e6` | contracts.py, canary_native, rune_operator:323 dataclass |
|
||||
| 3 | evidence_probe | probe.rs | 24/24 + consumer tests 23/23 | `3450a1c` | rune_operator:581/603, research_stage_executor:312 |
|
||||
| 4 | behaviour_ledger | behaviour.rs | 13/13 full-scenario | `8973caa` | testing_mode:19, iteration:23, rune_tui:3152 |
|
||||
| 5 | behavior | habitmath.rs | 16/16 + full-stack live-db | `2732677` | rune_tui:83; deployed vintage upgraded (live db read-verified) |
|
||||
| 6 | claim_audit | claims.rs | 17/17 + real-journal aggregate (1127 rows) | `969e39f` | rune_tui:62/2468/3752, contracts.py, hook_engine:270 |
|
||||
|
||||
Errors: byte-equal (duplicate/unknown-behaviour/unknown-state; probe schema-gate lists incl. Python repr rendering; CLI argparse shapes + exit codes).
|
||||
|
||||
## Strangler doctrine (master doc lines 685–770) — step discharge (slice level)
|
||||
|
||||
| Step | Requirement | Evidence |
|
||||
|------|-------------|----------|
|
||||
| 1–4 | candidate built, contract frozen, parity proven | commits `114c394` → `969e39f`; frozen contract `plans/2026-10-03-phase-g-evidence-provenance-contract.md` (commit `7c68e41`); per-port parity harnesses ALL GREEN |
|
||||
| 5 | qualification | per-port parity + decision-math batteries; canary pair per port (Python 74/74, Rust 66/66 — all six port sections accumulated); performance follows the slice-1 in-process shape (no interpreter startup; same cdylib) |
|
||||
| 6 | canaries conformance | canary_audit_ledger.py 74/74; canary_evidence_provenance_rust.py 66/66 (per-port sections for ALL six ports; dual-mode LIVE/ISOLATED for live-ledger pins — run 18141 repair); embedded tests through shims (verification test, 23 consumer tests) |
|
||||
| 7 | shadow comparison vs live-shaped data | port-1 shadow 18/18 vs `939883d:shared/audit_ledger.py` (banked at `114c394`); ports 2–6 use direct byte-parity batteries vs the live originals (same discipline, stronger: full-document byte-quality) |
|
||||
| 8 | authority switch + observed consumer evidence | shims deployed + live-verified per port: entry 4242 (first post-switch ledger write), live behavior.db read through the port-5 shim, real 1127-row journal through the port-6 aggregation, TUI backend trio imports, TUI scan_text FIRST-EVER live call |
|
||||
| 9 | runtime evidence post-switch | post-switch walls per port: parity round-trips, embedded tests, consumer chains (rune.behaviour_ledger / rune.evidence_probe / rune.claim_audit + deployed trio) — all GREEN |
|
||||
| 10 | old production path unreachable | `import audit_ledger` (deployed consumers) → shim; `rune.behaviour_ledger` / `rune.evidence_probe` / `rune.claim_audit` / `rune.verification_layer` → shims; `shared/audit_ledger.py` had ZERO importers (verified by sweep incl. path-string forms) — DELETED |
|
||||
| 11 | old implementation removed or reference-only | `shared/audit_ledger.py` DELETED (this commit, after the proven wall + zero-importer sweep); six src/rune modules remain as shims (the API contract); original Python lives in git history. Coverage registry remapped: `audit_ledger` removed from SHARED_D; gate exit 0 post-deletion |
|
||||
| 12 | supersession ledger | THIS FILE |
|
||||
|
||||
Deletion discipline (language-governance rule ⑥): `shared/audit_ledger.py` removed only after (a) the deployed shim proved live against the real ledger, (b) the shadow wall was green, (c) a zero-importer sweep across src/ shared/ scripts/ tests/. The remaining five src/rune originals were rewritten AS shims in their own commits (git history holds each original); they are the API contract and stay.
|
||||
|
||||
## Vintage-merge disclosures
|
||||
|
||||
- **SYMMETRY GUARD preserved:** the deployed `claim_audit` vintage carried `research_lens.symmetry_check` integration (2026-08-15) absent from src/rune — preserved in both shims (feature-preserving merge; load-bearing behavior).
|
||||
- **DEAD CONSUMER REPAIRED:** `rune_tui.py:3752` `claim_audit.scan_text()` had NO implementation in either vintage (silent try/except death) — now implemented, first-class, live-verified.
|
||||
- **Sequence break (port 1):** the audit_ledger shim was deployed BEFORE its canary pair existed; both canaries subsequently written and green — disclosed on #2449 comment 33817, not retrofitted.
|
||||
|
||||
## Consumer map at supersession
|
||||
|
||||
- `~/.config/opencode/scripts/rune_tui.py` — claim_audit (report L2468, scan_text L3752), behaviour (L3152), behavior (L83).
|
||||
- `src/rune/rune_operator.py` — evidence_probe (L581/603), VerificationResult dataclass (L323).
|
||||
- `src/rune/contracts.py` — dynamic `__import__` surfaces (verification_layer, claim_audit).
|
||||
- `src/rune/testing_mode.py:19` / `src/rune/iteration.py:23` — behaviour_ledger constants + class.
|
||||
- `scripts/research_stage_executor.py:312` — evidence_probe orchestration.
|
||||
- `~/.config/opencode/scripts/curator.py` / `canary_full_system.py` — audit_ledger (deployed shim).
|
||||
- `src/rune/dependency_tracker.py` — map-strings only (NOT an import).
|
||||
|
||||
## Live-data rules (contract §3) — honored in the shims
|
||||
|
||||
- Live promotion ledger: drift key verified 23-intact through the shim + FFI; explicit `AUDIT_LEDGER_KEY` override honored.
|
||||
- Live behavior.db: read/written through the deployed shim at `~/.config/opencode/memory/behavior.db`; parity scenarios ran on temp dirs only.
|
||||
- Live claim_audit journal (1127 rows): read-only aggregate through the Rust core; writes stay Python.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Phase H plan — Go front end / Rust back end (Nick's architecture directive)
|
||||
|
||||
Status: PLAN (awaiting Nick's sign-off before any code)
|
||||
Directive: USER-LOCKED (2026-10-04). Supersedes the KEEP-PYTHON verdicts in
|
||||
docs/rebase-language-rubric.md wherever they conflict.
|
||||
|
||||
## 1. The directive
|
||||
|
||||
Go owns the front end: runtime entry points, the first-call surface, the
|
||||
user-facing interface and its calls. Rust owns the back end: every core
|
||||
logic module. Python survives only as transitional glue inside a module
|
||||
until its own Go/Rust surface lands.
|
||||
|
||||
## 2. What "front end" means concretely here
|
||||
|
||||
From the runtime-audit memory (2026-09-10): the live entry chain is
|
||||
rune_tui -> send_task -> ContextBroker -> ModelRouter -> local/Cloud
|
||||
transports. The user-facing calls are: the TUI process itself, the CLI
|
||||
entry (python -m rune / rune binary), the operator message path, and the
|
||||
OpenAI-compatible /v1 endpoints MiMoCode serves.
|
||||
|
||||
Candidates for the Go first-call layer, in dependency order:
|
||||
- rune binary (process entry, arg parsing, process lifecycle) — replaces
|
||||
the Python module-loading bootstrap.
|
||||
- CLI/IPC command dispatch — the first-call surface that routes a user
|
||||
command to the right back-end service.
|
||||
- The OpenAI-compatible HTTP endpoint surface (/v1 chat completions) —
|
||||
stdlib net/http + NDJSON streaming is exactly the area the rubric called
|
||||
Go's only capability-proven fit (W4 evidence, unchanged by the directive).
|
||||
- TUI: NOT in the first Go surface. Textual coupling is deep (80+ modules
|
||||
per the rubric); the front-end Go layer first exposes the API the TUI
|
||||
consumes, and the TUI itself migrates later as its own wave.
|
||||
|
||||
## 3. What "back end" means concretely here
|
||||
|
||||
All pure-logic modules move into the two existing zero-dep crates
|
||||
(evidence_provenance, query_memory) or a third crate for the remaining
|
||||
domains. The zero-external-crate discipline stands (the rubric's W4 Rust
|
||||
kill-rule for network code stays valid INSIDE Rust crates: no network code
|
||||
in crates, that belongs to the Go layer).
|
||||
|
||||
Back-end candidates not yet ported (pure logic, no external deps):
|
||||
- drift_core, drift_cache, drift_compression (scored math) — memory umbrella
|
||||
- warm_neuron_store/warm_router scoring paths — memory umbrella
|
||||
- memory_store consolidation/scoring helpers — memory umbrella
|
||||
- capability_fit/capability_broker scoring — capability umbrella
|
||||
- tokenizer/counting helpers in token_optimizer — inference umbrella
|
||||
Each port follows the slice-2 pipeline: contract, zero-dep Rust, byte-parity
|
||||
battery vs the Python vintage, dual-mode canary, consumer import chain,
|
||||
CI green, supersession ledger.
|
||||
|
||||
## 4. Sequencing (per wave, each independently mergeable)
|
||||
|
||||
H0 — enablement (no behavior change):
|
||||
- Install Go on the host + the runner user's PATH; pin a version in
|
||||
requirements-ci-equivalent (a go-version file + CI check).
|
||||
- CI: add a Go build+vet+test job (empty module, exits 0) so the pipeline
|
||||
carries Go before any code lands.
|
||||
|
||||
H1 — Go first-call shim:
|
||||
- Go binary `rune` replacing the Python entrypoint's process bootstrap:
|
||||
arg parsing, env resolution, spawn the Python runtime (transitional).
|
||||
- Canary: same first-call contract (exit codes, env handoff, TTY attach).
|
||||
|
||||
H2 — Go command dispatch + /v1 endpoint:
|
||||
- Go serves /v1 chat completions + CLI dispatch, calling the Python core
|
||||
through the same local transport the TUI uses today.
|
||||
- Parity battery: request/response shapes vs the Python server for the
|
||||
same canned conversations (byte-level for metadata, streaming NDJSON).
|
||||
|
||||
H3 — back-end wave (parallel to H1/H2 where independent):
|
||||
- Ports listed in section 3, one or two modules per slice, same slice-2
|
||||
discipline. No port starts without its parity battery written first.
|
||||
|
||||
H4 — TUI migration wave (its own contract, later):
|
||||
- Only after H1-H2 prove the Go front end serves the same surfaces the TUI
|
||||
consumes. The TUI is the largest single module (4k+ lines, Textual) and
|
||||
earns its own contract.
|
||||
|
||||
## 5. Honest constraints (carried from the rubric, not contradicted)
|
||||
|
||||
- Go must be installed first (rubric D5 gate: it failed this before; the
|
||||
directive now outweighs the gate but installation is still step zero).
|
||||
- Network/streaming code lives in the Go layer, never in Rust crates
|
||||
(rubric W4: stdlib-only Rust has no credible HTTP/SSE precedent).
|
||||
- SQLite/regex/file-IO stay at their current language boundaries per the
|
||||
zero-dep split law from slice 2 (math Rust, IO at the edges).
|
||||
- Every surface gets the same proof pipeline as slice 2: observed-not-
|
||||
invented expectations, byte parity, dual-mode canary, CI green.
|
||||
|
||||
## 6. Success measure
|
||||
|
||||
The tranche is complete when: the `rune` process starts as a Go binary;
|
||||
user-facing calls (CLI, /v1, operator path) are served by Go; every core
|
||||
logic module on the port list is byte-parity Rust; Python remains only in
|
||||
surfaces explicitly contracted as keep-Python (TUI internals until H4,
|
||||
test harness, canary frameworks).
|
||||
@@ -0,0 +1,285 @@
|
||||
# TOTAL REBASE TRANCHE — Rune to Go/Rust (R2)
|
||||
|
||||
**Status:** DESIGN — awaiting Nick's approval gate (TRANCHE-DEPTH TEMPLATE §4)
|
||||
**Supersedes:** the KEEP-PYTHON rubric verdicts (already superseded by Nick's
|
||||
Go-front/Rust-back directive) and extends Phase H (H0–H4 slice 1, merged
|
||||
`693a494`, v0.90.0) from first ports to total rebase.
|
||||
**Nick's directives (verbatim anchors):**
|
||||
- "i want the whole codebase rebased in rust and go"
|
||||
- "everything should initiate with go as the runtime, and reactive adaptable
|
||||
runtime should be go"
|
||||
- "the deterministic backend i.e memory storage, architectures and elastic
|
||||
cognition and everything like that should be in rust"
|
||||
- "go should be the reactive front end to what interfaces with the user —
|
||||
not gui — the deliverables and chat interactions as well as how rune
|
||||
handles thoughts and runtime"
|
||||
- "the zero dependency rule should be in place but the rebase should rebuild
|
||||
those aspects into the new architecture and make them operational"
|
||||
- "the textual tui should be last but everything should be wired into the
|
||||
textual tui"
|
||||
- "start with the scaffolding first as everything relies on that, then use
|
||||
the new rust scaffolding to make sure everything else is built towards
|
||||
scaffold protocol"
|
||||
- "the scaffolding function should be a rust execution but go start"
|
||||
|
||||
## 1. Grounding summary (measured 2026-10-04, from the spine store + wc)
|
||||
|
||||
- 161 modules with source, **64,482 lines** of `src/rune/` Python.
|
||||
- Draft classification (rule-based, to be function-level verified in Wave 0):
|
||||
- **RUST (deterministic backend):** 49 modules, 26,772 lines — memory,
|
||||
drift, warm, capability, token, spine, scaffold, construction_*,
|
||||
structural_store, curator, consolidation, elastic cognition, ledgers,
|
||||
audit, evidence, contracts, sleep_time, subconscious, persona_gate,
|
||||
context_broker, growth.
|
||||
- **GO (reactive runtime + user-facing):** 10 modules, 7,043 lines —
|
||||
operator, transports, dispatch, provider registry, adaptive router,
|
||||
hook engine, local tool runtime, work manager, deploy_qualify.
|
||||
- **TUI-last:** 4 modules, 4,889 lines — rune_tui (4,041), tui_validator,
|
||||
tui_backlog, tui_transport.
|
||||
- **UNCLASSIFIED (Wave 0 must disposition each with evidence):** 73
|
||||
modules, 21,473 lines — voice_*, tool_executor, model_routing/registry,
|
||||
event_bus, cron_engine, message_queue, identity_anchors, pins,
|
||||
stage_guards, tier_manager, witness, etc.
|
||||
- **Test infra:** 23 canary/test modules, 4,211 lines — rewritten per wave
|
||||
(Rust canaries port with their surface; Go canaries for the front).
|
||||
- **Retires:** `__init__`, `__main__`, `memory_env` (bootstrap glue —
|
||||
subsumed by the Go runtime's env contract).
|
||||
- Existing Rust (merged, production-proven): 6 zero-dep crates
|
||||
(query_memory, evidence_provenance, rune_spine, memory_scoring,
|
||||
capability_scoring, token_counting), 338k bytes, all with parity
|
||||
batteries. Go: rune-entry (H1 shim + H2 /v1 front door).
|
||||
- Stragglers from R1 folded in: #2171, #2119 (TUI), #1844 (salience_gate),
|
||||
#1755 (goal._load), #1738 (failure_auto_filer).
|
||||
|
||||
## 2. Objective
|
||||
|
||||
Every line of rune's Python runtime is rebased into the dichotomy —
|
||||
**Go initiates and reacts; Rust determines and stores** — with the
|
||||
scaffolding protocol rebuilt FIRST (Go start / Rust execution) so every
|
||||
subsequent conversion is driven, checked, and closed by the new protocol
|
||||
itself. Python is deleted only after the replacement is parity-proven and
|
||||
CI-green (strangler exit law). The Textual TUI is last and stays the
|
||||
integration surface: everything wires into it over the transport seam.
|
||||
|
||||
## 3. The hard constraint analysis (zero-dep + total rebase — feasibility)
|
||||
|
||||
Nick asked: confirm or deny. **Confirmed possible**, amended per his
|
||||
critiques (2026-10-04):
|
||||
|
||||
- **Hybridized membrane, not a wall (Nick's correction):** Go remains the
|
||||
reactive initiator, but "Rust never touches a socket" is WITHDRAWN as an
|
||||
absolute. The languages interoperate where coherence demands: Rust cores
|
||||
callable from Go (the proven cdylib pattern), Go surfaces invocable from
|
||||
Rust through defined channels, shared wire protocols. The dichotomy is
|
||||
the DEFAULT placement rule; hybrid junctions are deliberate, contracted
|
||||
exceptions — each recorded in the unit's contract.
|
||||
- **SQLite STAYS (Nick's ruling):** no bespoke storage engine in this
|
||||
tranche. SQLite remains and is wired into the new architecture — the
|
||||
Rust side reaches it through system-library FFI (legal under zero-dep:
|
||||
a system library is not a crate dependency). The bespoke memory-system
|
||||
redesign is a LATER, separate program.
|
||||
- **Reactivity/async:** Go goroutines + channels own the event loops;
|
||||
Rust's deterministic cores stay synchronous behind the membrane.
|
||||
- **Regex:** hand-rolled matchers per case (proven: pyjson, word-run
|
||||
scanner, run-collapse). No general regex engine needed.
|
||||
- **Parity-plus-improvement (Nick's upgrade):** parity is the FLOOR, not
|
||||
the ceiling. Every unit answers "parity OR measurably better" —
|
||||
efficiency improvements are in scope per function (Rust zero-copy and
|
||||
exactness, Go goroutine-native concurrency), bloat from bad syntax is
|
||||
removed, and functionality is the non-negotiable gate. Improvements
|
||||
carry measurements; the parity battery proves no behavior regression
|
||||
either way.
|
||||
- **TUI wiring is continuous (Nick's correction):** each landed surface
|
||||
wires toward the Textual TUI over the transport seam as part of its
|
||||
unit contract — the final TUI wave integrates a system already aimed
|
||||
at it, not a retrofit.
|
||||
- **Language intelligence in the protocol (Nick's addition):** the
|
||||
scaffolding gains a first-class placement decision — for rune, the
|
||||
Go/Rust dichotomy rule (reactive/initiating → Go; deterministic/
|
||||
stateful → Rust); for other projects, a language-fit assessment at
|
||||
construction-classification time (the protocol recommends the right
|
||||
language for the task's characteristics).
|
||||
- **Provenance lock (Nick's verification):** the scaffolding being ported
|
||||
in Wave 1 is the V1-ACCEPTED system per docs/SCAFFOLDING_V1_DEFINITION.md
|
||||
(T18 §18B; North Star = Nick's verbatim words; 17/17 acceptance gates).
|
||||
The Wave 1 parity replay against the production contract ledger proves
|
||||
the rebuild is that design, not a drift.
|
||||
|
||||
## 4. Wave structure (each wave independently mergeable, scaffolding-gated)
|
||||
|
||||
### Wave 0 — Grounding + inventory verification (the template's bar)
|
||||
- Function-level mapping of all 161 modules (the spine gives structure;
|
||||
wave 0 verifies 100% of functions and classifies the 73 UNCLASSIFIED
|
||||
with evidence).
|
||||
- Disposition the 5 R1 stragglers.
|
||||
- Produce the per-unit issue factory input: one issue per function,
|
||||
each answering improve/fix/missing-connectors with evidence.
|
||||
- Deliverable: `plans/total-rebase-inventory.md` + issues filed.
|
||||
- Exit gate: inventory signed off by Nick (the approval gate).
|
||||
|
||||
### Wave 1 — The scaffolding engine reborn (Go start / Rust execution)
|
||||
**Everything depends on this — it goes first, per Nick's directive.**
|
||||
- The rebuild target is the V1-ACCEPTED system (Nick's design —
|
||||
docs/SCAFFOLDING_V1_DEFINITION.md, North Star verbatim, 17/17
|
||||
acceptance gates). The parity replay against the production contract
|
||||
ledger is the proof the rebuild IS that design.
|
||||
- Rust crate `scaffold_core`: the construction machinery as pure
|
||||
deterministic logic — contract lifecycle (draft→approved→building→
|
||||
verifying→closed), closure classification (correctly_predicted et al.),
|
||||
scope resolution, legal transitions, the contract store.
|
||||
Source modules: scaffold.py, construction_interface.py,
|
||||
construction_decision.py, construction_intent.py, structural_store.py
|
||||
(parts not already in rune_spine), contracts.py, toolchain.py.
|
||||
- Go surface `rune-scaffold` (extends rune-entry): the initiation layer —
|
||||
submit-need/observe/transition/close CLI + /v1 endpoints, delegating to
|
||||
the Rust core.
|
||||
- NEW per Nick: the **language-placement decision** lands in the protocol
|
||||
here — construction classification gains a language-fit assessment
|
||||
(rune: the Go/Rust dichotomy rule; other projects: characteristic-based
|
||||
recommendation). Every later contract carries its placement verdict.
|
||||
- NEW per Nick: parity is the floor — the engine itself is the first
|
||||
beneficiary of the parity-plus-improvement rule (measured improvements
|
||||
allowed, functionality preserved, no-regression proven).
|
||||
- Exit gate: the R2 tranche's own remaining waves are driven THROUGH the
|
||||
new engine (the protocol builds itself under its own rules).
|
||||
|
||||
### Wave 2 — Memory/storage backend (Rust, SQLite retained)
|
||||
- SQLite is NOT replaced (Nick's ruling). The Rust memory core reaches it
|
||||
via system-library FFI (zero-dep legal); memory_store.py (2,435 lines)
|
||||
is decomposed: query paths and consolidation logic into Rust, the SQLite
|
||||
mechanics behind a thin storage trait that maps to libsqlite3 today and
|
||||
to the future bespoke engine LATER without consumer churn.
|
||||
- elastic_cognition, memory_consolidation, sleep_time, curator,
|
||||
subconscious_pass, drift_* (remaining DB-backed surfaces), journal,
|
||||
recall_* (recall_router is routing → boundary checked at Wave 0
|
||||
disposition).
|
||||
- Data safety gate: checksum-verified read-back of the live store through
|
||||
the new engine; zero writes until the read path is proven identical.
|
||||
|
||||
### Wave 3 — Cognition + architecture engines (Rust)
|
||||
- The remaining deterministic cores: capability_broker decision logic,
|
||||
context_broker, persona_gate, growth, identity_anchors, pins,
|
||||
stage_guards, tier_manager, witness, safety_override, abstraction,
|
||||
verification surfaces.
|
||||
- Ports already merged (memory_scoring, capability_scoring,
|
||||
token_counting, rune_spine) are verified against the new scaffold
|
||||
protocol and wired into the final architecture.
|
||||
|
||||
### Wave 4 — The reactive runtime (Go)
|
||||
- The operator turn loop (rune_operator.py, 2,697 lines), chat
|
||||
interactions, deliverables production, model routing at call time
|
||||
(adaptive_router, model_registry/profiles/routing, provider_*),
|
||||
tool execution (tool_executor, tool_protocol, local_tool_runtime),
|
||||
event surfaces (event_bus, message_queue, cron_engine, hook_engine),
|
||||
voice output chain (voice_*) as deliverable production.
|
||||
- The /v1 surface deepens to carry the full interaction contract;
|
||||
NDJSON streaming becomes the standard response shape.
|
||||
|
||||
### Wave 5 — Python retirement sweep
|
||||
- Every Python module whose surfaces have landed is deleted (strangler
|
||||
exit: parity proven + CI green + deployed one cycle). Shared/ vintage
|
||||
mirrors retired with them.
|
||||
- The glue shims (ms_loader, cs_loader, tc_loader, spine_loader) retire
|
||||
when their consumers no longer speak Python at all.
|
||||
|
||||
### Wave 6 — TUI last (but wired continuously)
|
||||
- Every wave before this one ships its transport-seam wiring toward the
|
||||
TUI as part of each unit's contract (Nick's correction: natural,
|
||||
continuous wiring — not a retrofit wave).
|
||||
- Then the TUI itself: a thin Textual render shell over the wire (all
|
||||
logic already Go/Rust by then) or a port — disposition decided on the
|
||||
evidence of everything that came before.
|
||||
|
||||
### Wave 7 — Final audit
|
||||
- Language-bar measurement, the full scaffolding ledger replayed, the
|
||||
tranche's closure report with per-wave evidence.
|
||||
|
||||
## 5. Per-unit discipline (the template, unchanged)
|
||||
|
||||
- One issue per function via the issue factory; each answers
|
||||
improve/fix/missing-connectors with evidence.
|
||||
- Every unit runs the scaffolding pipeline end-to-end: submit-need →
|
||||
contract → approval → canary-first (red proven) → implementation →
|
||||
parity battery vs the Python vintage → closure audit (structural ids,
|
||||
never file paths — the closure vocabulary law).
|
||||
- Per-unit decisions on evidence, never pre-decided globally: each
|
||||
function's Go-vs-Rust placement is assessed by the dichotomy rule with
|
||||
the measured call graph, not by the draft classification alone.
|
||||
- Bug-fix contract governs every defect found en route (red test first,
|
||||
regression frozen).
|
||||
|
||||
## 6. Verification gates
|
||||
|
||||
- CI green per wave (crate builds, Go vet/test/build, canaries, henxels,
|
||||
type ratchet until the Python ratchet itself retires in Wave 5).
|
||||
- Parity batteries are byte-level for deterministic surfaces, shape-level
|
||||
modulo volatile fields for wire surfaces.
|
||||
- The scaffolding closure audit for every contract: correctly_predicted
|
||||
or honestly flagged, never silent.
|
||||
- Deploy qualification per merge (the pipeline that proved itself today).
|
||||
|
||||
### The 90/100 coverage law (Nick, 2026-10-05 — GOVERNING from W3.4 on)
|
||||
|
||||
Per Nick: "if it's realistically achievable but aim higher also if that's
|
||||
realistically achievable. go to 100% where achievable."
|
||||
|
||||
- **100% where achievable — the deterministic Rust kernels.** Every kernel
|
||||
crate (memory_retrieval, cognition_core, scaffold_core, memory_storage,
|
||||
and every future crate) targets 100% BRANCH coverage via its parity
|
||||
matrices + the pytest shim battery: every branch has real reachable
|
||||
semantics, and exhaustive enumeration is exactly what the batteries do.
|
||||
Achievability is measured per crate at wave close; a kernel below 100%
|
||||
carries the named unreachable case or it isn't done.
|
||||
- **≥90% on the changed-lines bar (diff-cover), aiming higher.** The floor
|
||||
stays a floor; the working standard per slice is 90%+ on changed lines;
|
||||
whatever a slice achieves beyond 90 becomes the next slice's personal
|
||||
benchmark (per-unit "aim higher" — the number climbs wave over wave).
|
||||
The floor ratchet itself moves after consecutive clean waves
|
||||
(70 → 76 → 84 → 90; each raise is a commit, never a step down).
|
||||
- **Glue/failure branches: 90%+ with byte-compare proofs.** The stubs and
|
||||
probe branches that only fire in a broken world are covered by honest
|
||||
fixtures (real import-failure probes like the from-import resolution
|
||||
law) — never by fake-triggered pragmas. A branch covered by a lying
|
||||
fixture is WORSE than uncovered; if it can't be honestly reached, it's
|
||||
named in the wave's closure report with the reason.
|
||||
- **Assertion quality is part of the bar.** A test that touches a line
|
||||
without verifying its OUTPUT doesn't count toward the bar — byte-compare
|
||||
/ exact-value asserts are the standard; "is not None" assertions reject
|
||||
at review.
|
||||
|
||||
## 7. Risk register
|
||||
|
||||
- **SQLite FFI correctness** (Wave 2) — the storage layer is retained but
|
||||
re-wired; mitigations: read-path proven identical before any write,
|
||||
checksum-verified read-back of the live store, the storage trait
|
||||
boundary keeps the future bespoke swap cheap.
|
||||
- **Hybrid membrane drift** — junctions multiply ad-hoc if ungoverned;
|
||||
every Go↔Rust interaction point is a contracted, recorded decision in
|
||||
its unit's contract (the dichotomy is the default; hybrids justify
|
||||
themselves per unit).
|
||||
- **Improvement regressions** — parity-plus-improvement invites subtle
|
||||
behavior changes; every improvement carries a measurement AND the parity
|
||||
battery stays authoritative for no-regression.
|
||||
- **Go↔Rust call latency** — measure in Wave 1; the architecture assumes
|
||||
local transport cost is negligible vs model latency; if measured wrong,
|
||||
the membrane's transport moves (decision recorded, not improvised).
|
||||
- **Context-window economics** — 1,852-style issue volumes mean the
|
||||
pipeline must multiplex (no-wait law) and local models carry routine
|
||||
units (custodian pattern).
|
||||
- **Scope creep during Wave 0** — the inventory is measured and frozen
|
||||
before design; additions go through scope amendments.
|
||||
|
||||
## 8. Rollback
|
||||
|
||||
- Every wave merges independently; master stays deployable at each
|
||||
boundary (the strangler's whole point).
|
||||
- The Python vintage remains until Wave 5 deletion gates pass; a wave
|
||||
revert = revert the merge commit (deploy qualification re-runs).
|
||||
- The scaffolding engine's Wave 1 parity replay means even the protocol's
|
||||
own replacement is reversible against recorded history.
|
||||
|
||||
## 9. Approval gate
|
||||
|
||||
This tranche does not execute until Nick approves. On approval, Wave 0
|
||||
starts immediately and runs without consent blockers (standing directive),
|
||||
with self-verified checkpoints and defect filing.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Total-Rebase Inventory (R2 Wave 0.1) — measured 2026-10-04
|
||||
|
||||
Generated from `store/rebase/inventory.json` (AST function-level extraction) +
|
||||
`store/rebase/classification.json` (import/content evidence per module).
|
||||
Placement law: reactive/initiating/user-facing/network/turn-time → GO;
|
||||
deterministic/stateful/storage/cognition → RUST; Textual rendering → TUI-LAST;
|
||||
pure compute with no I/O markers defaults to the deterministic backend.
|
||||
|
||||
## Totals
|
||||
|
||||
| Side | Modules | Lines | Functions |
|
||||
|---|---|---|---|
|
||||
| GO | 43 | 19495 | 708 |
|
||||
| RUST | 93 | 36215 | 1091 |
|
||||
| TUI-LAST | 7 | 5438 | 225 |
|
||||
| TEST-INFRA | 29 | 5395 | 158 |
|
||||
| RETIRES | 2 | 93 | 9 |
|
||||
|
||||
## Per-module classification (evidence-carrying)
|
||||
|
||||
| Module | Side | Lines | Functions | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| rune_tui | TUI-LAST | 4041 | 166 | textual/rich imports: ['rich', 'textual'] |
|
||||
| rune_operator | GO | 2697 | 68 | network/concurrency imports: ['threading', 'urllib']; reactive/initiating domain |
|
||||
| memory_store | RUST | 2435 | 56 | sqlite3 (storage-backed); network/concurrency imports: ['subprocess']; deterministic/state domain |
|
||||
| curator | RUST | 1838 | 43 | sqlite3 (storage-backed); network/concurrency imports: ['subprocess']; deterministic/state domain |
|
||||
| tool_executor | GO | 1812 | 47 | network/concurrency imports: ['subprocess']; reactive/initiating domain |
|
||||
| warm_router | RUST | 1536 | 36 | sqlite3 (storage-backed); network/concurrency imports: ['subprocess']; reactive/initiating domain; deterministic/state domain |
|
||||
| construction_interface | RUST | 1039 | 25 | deterministic/state domain |
|
||||
| spine | RUST | 1019 | 31 | deterministic/state domain |
|
||||
| sleep_time | RUST | 971 | 25 | sqlite3 (storage-backed); network/concurrency imports: ['subprocess', 'urllib']; deterministic/state domain |
|
||||
| construction_decision | RUST | 949 | 18 | deterministic/state domain |
|
||||
| capability_broker | RUST | 900 | 21 | deterministic/state domain |
|
||||
| hook_engine | GO | 860 | 8 | reactive/initiating domain |
|
||||
| scaffold | RUST | 854 | 31 | network/concurrency imports: ['subprocess']; deterministic/state domain |
|
||||
| local_tool_runtime | GO | 806 | 32 | network/concurrency imports: ['threading', 'urllib']; reactive/initiating domain |
|
||||
| context_broker | GO | 768 | 23 | network/concurrency imports: ['threading'] |
|
||||
| elastic_cognition | RUST | 765 | 13 | network/concurrency imports: ['urllib']; deterministic/state domain |
|
||||
| memory_consolidation | RUST | 733 | 20 | network/concurrency imports: ['subprocess']; deterministic/state domain |
|
||||
| provider_registry | GO | 666 | 28 | network/concurrency imports: ['threading', 'urllib']; reactive/initiating domain |
|
||||
| persona_gate | RUST | 661 | 21 | sqlite3 (storage-backed); network/concurrency imports: ['subprocess', 'urllib']; deterministic/state domain |
|
||||
| structural_store | RUST | 656 | 33 | deterministic/state domain |
|
||||
| contracts | RUST | 626 | 7 | deterministic/state domain |
|
||||
| adaptive_router | GO | 625 | 19 | reactive/initiating domain |
|
||||
| work_manager | GO | 591 | 25 | reactive/initiating domain |
|
||||
| abstraction | RUST | 587 | 12 | sqlite3 (storage-backed); network/concurrency imports: ['urllib']; deterministic/state domain |
|
||||
| construction_intent | RUST | 555 | 16 | deterministic/state domain |
|
||||
| canary_audit_ledger | TEST-INFRA | 540 | 2 | canary/test module |
|
||||
| safety_override | RUST | 530 | 18 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| failure_auto_filer | GO | 527 | 28 | network/concurrency imports: ['threading'] |
|
||||
| build_mode | GO | 517 | 34 | network/concurrency imports: ['subprocess'] |
|
||||
| drift_core | RUST | 517 | 14 | deterministic/state domain |
|
||||
| event_wait | GO | 517 | 14 | network/concurrency imports: ['subprocess', 'urllib']; reactive/initiating domain |
|
||||
| tui_validator | TUI-LAST | 514 | 13 | tui family |
|
||||
| memory_selector | RUST | 511 | 14 | deterministic/state domain |
|
||||
| system_registry | RUST | 501 | 14 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| construction_pattern | RUST | 496 | 13 | deterministic/state domain |
|
||||
| cache | GO | 492 | 17 | sqlite3 (storage-backed); network/concurrency imports: ['threading'] |
|
||||
| claim_audit | RUST | 483 | 14 | sqlite3 (storage-backed); deterministic/state domain |
|
||||
| graph_memory | RUST | 474 | 9 | sqlite3 (storage-backed); deterministic/state domain |
|
||||
| identity_anchors | RUST | 474 | 8 | deterministic/state domain |
|
||||
| model_connect | GO | 462 | 10 | network/concurrency imports: ['urllib']; reactive/initiating domain |
|
||||
| auto_tasks | GO | 460 | 14 | sqlite3 (storage-backed); network/concurrency imports: ['subprocess'] |
|
||||
| capability_fit | RUST | 455 | 14 | deterministic/state domain |
|
||||
| tool_protocol | GO | 455 | 27 | reactive/initiating domain |
|
||||
| drift_ledger | RUST | 453 | 16 | sqlite3 (storage-backed); deterministic/state domain |
|
||||
| witness | RUST | 451 | 10 | network/concurrency imports: ['urllib']; deterministic/state domain |
|
||||
| goal | RUST | 441 | 20 | network/concurrency imports: ['urllib']; deterministic/state domain |
|
||||
| release_notes | GO | 439 | 17 | network/concurrency imports: ['urllib'] |
|
||||
| cron_engine | GO | 436 | 28 | network/concurrency imports: ['subprocess', 'threading']; reactive/initiating domain |
|
||||
| model_profiles | GO | 435 | 15 | network/concurrency imports: ['urllib']; reactive/initiating domain |
|
||||
| microkernel | GO | 434 | 12 | network/concurrency imports: ['subprocess', 'threading'] |
|
||||
| model_routing | GO | 432 | 15 | network/concurrency imports: ['urllib']; reactive/initiating domain |
|
||||
| voice_hybrid | RUST | 421 | 10 | sqlite3 (storage-backed); reactive/initiating domain |
|
||||
| behavior | RUST | 407 | 9 | sqlite3 (storage-backed); deterministic/state domain |
|
||||
| token_optimizer | RUST | 407 | 23 | deterministic/state domain |
|
||||
| capability_ledger | RUST | 405 | 19 | deterministic/state domain |
|
||||
| night_mode | GO | 405 | 19 | network/concurrency imports: ['subprocess', 'threading', 'urllib'] |
|
||||
| chat_bubble | TUI-LAST | 401 | 16 | textual/rich imports: ['rich', 'textual'] |
|
||||
| growth_delta | RUST | 400 | 16 | network/concurrency imports: ['subprocess', 'urllib']; deterministic/state domain |
|
||||
| construction_mode | RUST | 395 | 5 | deterministic/state domain |
|
||||
| prompt_sync | RUST | 376 | 14 | sqlite3 (storage-backed) |
|
||||
| provider_budget | GO | 354 | 21 | network/concurrency imports: ['urllib']; reactive/initiating domain |
|
||||
| structural_model | RUST | 352 | 21 | deterministic/state domain |
|
||||
| programme_continuation | RUST | 349 | 11 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| canary_rune_serve | TEST-INFRA | 329 | 13 | canary/test module |
|
||||
| builder_bridge | GO | 326 | 11 | reactive/initiating domain |
|
||||
| canary_p1_07 | TEST-INFRA | 324 | 12 | canary/test module |
|
||||
| layout_contract | RUST | 316 | 6 | deterministic/state domain |
|
||||
| message_queue | GO | 316 | 14 | reactive/initiating domain |
|
||||
| drift_observability | RUST | 315 | 9 | deterministic/state domain |
|
||||
| build_basis | RUST | 314 | 12 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| canary_p0_07 | TEST-INFRA | 310 | 11 | canary/test module |
|
||||
| drift_compression | RUST | 307 | 12 | deterministic/state domain |
|
||||
| event_envelope | GO | 303 | 8 | reactive/initiating domain |
|
||||
| attention_capsule | RUST | 296 | 7 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| canary_p1_01 | TEST-INFRA | 294 | 14 | canary/test module |
|
||||
| voice_sync | RUST | 293 | 10 | sqlite3 (storage-backed); reactive/initiating domain |
|
||||
| drift_cache | RUST | 292 | 16 | deterministic/state domain |
|
||||
| outcome_ledger | RUST | 286 | 8 | sqlite3 (storage-backed); deterministic/state domain |
|
||||
| time_awareness | RUST | 283 | 15 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| tiered_executor | GO | 282 | 9 | network/concurrency imports: ['urllib'] |
|
||||
| stage_guards | RUST | 281 | 11 | network/concurrency imports: ['subprocess']; deterministic/state domain |
|
||||
| standardized_providers | GO | 281 | 5 | reactive/initiating domain |
|
||||
| canary_p1_06 | TEST-INFRA | 280 | 9 | canary/test module |
|
||||
| progressive_escalation | RUST | 279 | 9 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| pins | RUST | 276 | 11 | deterministic/state domain |
|
||||
| canary_p1_03 | TEST-INFRA | 271 | 9 | canary/test module |
|
||||
| auto_context | GO | 270 | 8 | network/concurrency imports: ['subprocess'] |
|
||||
| task_persist | RUST | 269 | 10 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| context_pressure | RUST | 262 | 9 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| canary_gitea_automation | TEST-INFRA | 261 | 3 | canary/test module |
|
||||
| canary_p1_02 | TEST-INFRA | 253 | 12 | canary/test module |
|
||||
| construction_patterns_builtin | RUST | 249 | 1 | deterministic/state domain |
|
||||
| canary_full_system | TEST-INFRA | 244 | 2 | canary/test module |
|
||||
| event_bus | GO | 244 | 7 | reactive/initiating domain |
|
||||
| salience_gate | RUST | 243 | 4 | deterministic/state domain |
|
||||
| self_improve | RUST | 242 | 5 | sqlite3 (storage-backed) |
|
||||
| construction_activation | RUST | 237 | 4 | deterministic/state domain |
|
||||
| toolchain | RUST | 236 | 7 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| task_gate | RUST | 235 | 8 | sqlite3 (storage-backed) |
|
||||
| core_server | GO | 234 | 13 | network/concurrency imports: ['http', 'urllib']; reactive/initiating domain |
|
||||
| audit_ledger | RUST | 231 | 8 | deterministic/state domain |
|
||||
| canary_native | TEST-INFRA | 231 | 3 | canary/test module |
|
||||
| openai_compat_transport | GO | 227 | 10 | network/concurrency imports: ['urllib']; reactive/initiating domain |
|
||||
| shard_manager | RUST | 223 | 10 | sqlite3 (storage-backed) |
|
||||
| response_adapter | RUST | 222 | 7 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| evidence_probe | RUST | 212 | 9 | network/concurrency imports: ['subprocess']; deterministic/state domain |
|
||||
| model_registry | GO | 211 | 8 | reactive/initiating domain |
|
||||
| persona_definition | GO | 210 | 8 | network/concurrency imports: ['subprocess'] |
|
||||
| cognitive_budgeter | RUST | 208 | 3 | deterministic/state domain |
|
||||
| canary_p0_04 | TEST-INFRA | 207 | 8 | canary/test module |
|
||||
| forge_client | GO | 203 | 19 | network/concurrency imports: ['urllib']; reactive/initiating domain |
|
||||
| dependency_tracker | RUST | 202 | 5 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| canary_context_broker | TEST-INFRA | 199 | 10 | canary/test module |
|
||||
| canary_p0_05 | TEST-INFRA | 199 | 16 | canary/test module |
|
||||
| testing_mode | RUST | 196 | 13 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| parallel_processor | GO | 194 | 17 | network/concurrency imports: ['threading'] |
|
||||
| canary_spine_meta | TEST-INFRA | 192 | 7 | canary/test module |
|
||||
| iteration | RUST | 190 | 8 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| verification_layer | RUST | 189 | 12 | deterministic/state domain |
|
||||
| subconscious_pass | RUST | 187 | 6 | deterministic/state domain |
|
||||
| ffi_query_memory_smoke | RUST | 182 | 5 | deterministic/state domain |
|
||||
| tier_manager | RUST | 182 | 9 | deterministic/state domain |
|
||||
| canary_tui_transport | TEST-INFRA | 174 | 7 | canary/test module |
|
||||
| behaviour_ledger | RUST | 173 | 13 | deterministic/state domain |
|
||||
| construction_commands | RUST | 173 | 4 | deterministic/state domain |
|
||||
| canary_tui_drift | TEST-INFRA | 170 | 2 | canary/test module |
|
||||
| intent_continuity | RUST | 168 | 11 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| rune_profiler | RUST | 165 | 3 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| inference_trace | RUST | 164 | 6 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| ms_loader | RUST | 163 | 16 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| subsystem_health | GO | 163 | 8 | network/concurrency imports: ['threading'] |
|
||||
| recall_router | GO | 162 | 3 | reactive/initiating domain |
|
||||
| voicebox | GO | 161 | 11 | network/concurrency imports: ['subprocess']; reactive/initiating domain |
|
||||
| canary_gitea_pipeline | TEST-INFRA | 159 | 2 | canary/test module |
|
||||
| forge | GO | 146 | 12 | network/concurrency imports: ['subprocess', 'urllib'] |
|
||||
| rune_graphic | TUI-LAST | 145 | 5 | textual/rich imports: ['rich'] |
|
||||
| connection_pool | GO | 144 | 9 | sqlite3 (storage-backed); network/concurrency imports: ['threading'] |
|
||||
| recall_uplift | RUST | 141 | 1 | sqlite3 (storage-backed) |
|
||||
| project_registry | RUST | 139 | 12 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| construction_depth | RUST | 138 | 2 | deterministic/state domain |
|
||||
| voice_injector | GO | 138 | 5 | reactive/initiating domain |
|
||||
| canary_rune_entry | TEST-INFRA | 137 | 3 | canary/test module |
|
||||
| construction_projection | RUST | 135 | 3 | deterministic/state domain |
|
||||
| rune_spinner | RUST | 130 | 5 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| tui_backlog | TUI-LAST | 125 | 5 | tui family |
|
||||
| warm_neuron_store | RUST | 122 | 6 | sqlite3 (storage-backed); deterministic/state domain |
|
||||
| drift_resume | RUST | 118 | 3 | deterministic/state domain |
|
||||
| wait_guard | RUST | 116 | 1 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| canary_conversation_state | TEST-INFRA | 115 | 2 | canary/test module |
|
||||
| canary_query_memory | TEST-INFRA | 110 | 2 | canary/test module |
|
||||
| failure_recovery | RUST | 108 | 1 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| graph_projection | RUST | 108 | 5 | deterministic/state domain |
|
||||
| tui_transport | TUI-LAST | 108 | 12 | tui family |
|
||||
| spine_loader | RUST | 105 | 7 | deterministic/state domain |
|
||||
| canary_unbounded | TEST-INFRA | 104 | 2 | canary/test module |
|
||||
| message_widgets | TUI-LAST | 104 | 8 | textual/rich imports: ['rich', 'textual'] |
|
||||
| cs_loader | RUST | 90 | 5 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| tc_loader | RUST | 88 | 5 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| canary_schema_migration | TEST-INFRA | 86 | 1 | canary/test module |
|
||||
| programme_gates | RUST | 85 | 6 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| structural_migration | RUST | 77 | 4 | deterministic/state domain |
|
||||
| memory_env | RETIRES | 72 | 8 | bootstrap glue subsumed by the Go runtime env contract |
|
||||
| impact_preview | RUST | 69 | 2 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| provider_context | GO | 48 | 1 | reactive/initiating domain |
|
||||
| wait_guard_corpus | RUST | 46 | 0 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| canary_cognitive_strata | TEST-INFRA | 44 | 1 | canary/test module |
|
||||
| builder_bridge_cli | GO | 42 | 1 | reactive/initiating domain |
|
||||
| route_risk_flags | RUST | 37 | 1 | no network/reactive markers — pure compute defaults to the deterministic backend |
|
||||
| canary_memory_synthesis | TEST-INFRA | 33 | 1 | canary/test module |
|
||||
| canary_resource_authority | TEST-INFRA | 33 | 1 | canary/test module |
|
||||
| canary_surface_parity | TEST-INFRA | 33 | 1 | canary/test module |
|
||||
| canary_orchestration_foundation | TEST-INFRA | 32 | 1 | canary/test module |
|
||||
| canary_gitea_delegation | TEST-INFRA | 31 | 1 | canary/test module |
|
||||
| __main__ | RETIRES | 21 | 1 | bootstrap glue subsumed by the Go runtime env contract |
|
||||
+2
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.66.27"
|
||||
version = "0.99.6"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"aiohttp>=3.9.0",
|
||||
"websockets>=12.0",
|
||||
"psutil>=5.9.0",
|
||||
"pillow>=10.0",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"errorCount": 120,
|
||||
"warningCount": 465,
|
||||
"errorCount": 117,
|
||||
"warningCount": 454,
|
||||
"informationCount": 0
|
||||
}
|
||||
+37
-28
@@ -1,29 +1,38 @@
|
||||
{
|
||||
"typeCheckingMode": "standard",
|
||||
"pythonVersion": "3.14",
|
||||
"reportMissingImports": "warning",
|
||||
"reportMissingModuleSource": "none",
|
||||
"reportMissingTypeStubs": "none",
|
||||
"reportUnknownArgumentType": "none",
|
||||
"reportUnknownParameterType": "none",
|
||||
"reportUnknownVariableType": "none",
|
||||
"reportUnknownMemberType": "none",
|
||||
"reportMissingTypeArgument": "none",
|
||||
"reportUntypedFunctionDecorator": "none",
|
||||
"reportUntypedClassDecorator": "none",
|
||||
"reportUntypedBaseClass": "none",
|
||||
"reportPrivateUsage": "none",
|
||||
"reportOptionalMemberAccess": "warning",
|
||||
"reportUnnecessaryIsInstance": "warning",
|
||||
"reportUnnecessaryComparison": "warning",
|
||||
"reportUnnecessaryTypeIgnoreComment": "warning",
|
||||
"reportPossiblyUnboundVariable": "error",
|
||||
"reportUndefinedVariable": "error",
|
||||
"reportAttributeAccessIssue": "warning",
|
||||
"include": ["src/rune"],
|
||||
"exclude": [
|
||||
"src/rune/__pycache__",
|
||||
"**/node_modules",
|
||||
"**/__pycache__"
|
||||
]
|
||||
}
|
||||
"typeCheckingMode": "standard",
|
||||
"pythonVersion": "3.14",
|
||||
"reportMissingImports": "warning",
|
||||
"reportMissingModuleSource": "none",
|
||||
"reportMissingTypeStubs": "none",
|
||||
"reportUnknownArgumentType": "none",
|
||||
"reportUnknownParameterType": "none",
|
||||
"reportUnknownVariableType": "none",
|
||||
"reportUnknownMemberType": "none",
|
||||
"reportMissingTypeArgument": "none",
|
||||
"reportUntypedFunctionDecorator": "none",
|
||||
"reportUntypedClassDecorator": "none",
|
||||
"reportUntypedBaseClass": "none",
|
||||
"reportPrivateUsage": "none",
|
||||
"reportOptionalMemberAccess": "warning",
|
||||
"reportUnnecessaryIsInstance": "warning",
|
||||
"reportUnnecessaryComparison": "warning",
|
||||
"reportUnnecessaryTypeIgnoreComment": "warning",
|
||||
"reportPossiblyUnboundVariable": "error",
|
||||
"reportUndefinedVariable": "error",
|
||||
"reportAttributeAccessIssue": "warning",
|
||||
"include": [
|
||||
"src/rune"
|
||||
],
|
||||
"exclude": [
|
||||
"src/rune/__pycache__",
|
||||
"**/node_modules",
|
||||
"**/__pycache__"
|
||||
],
|
||||
"extraPaths": [
|
||||
"scripts",
|
||||
"src/rune",
|
||||
"shared",
|
||||
"src/rust/evidence_provenance",
|
||||
"src/rust/query_memory"
|
||||
]
|
||||
}
|
||||
+13
-11
@@ -2,7 +2,7 @@
|
||||
# uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory --python-version 3.11
|
||||
aiohappyeyeballs==2.7.1
|
||||
# via aiohttp
|
||||
aiohttp==3.14.3
|
||||
aiohttp==3.14.4
|
||||
# via rune (pyproject.toml)
|
||||
aiosignal==1.4.0
|
||||
# via aiohttp
|
||||
@@ -18,9 +18,9 @@ certifi==2026.7.22
|
||||
# httpx
|
||||
chardet==7.6.0
|
||||
# via diff-cover
|
||||
coverage==7.16.0
|
||||
coverage==7.16.2
|
||||
# via rune (pyproject.toml)
|
||||
diff-cover==10.5.1
|
||||
diff-cover==10.6.0
|
||||
# via rune (pyproject.toml)
|
||||
frozenlist==1.8.0
|
||||
# via
|
||||
@@ -34,7 +34,7 @@ httpcore==1.0.9
|
||||
# via httpx
|
||||
httpx==0.28.1
|
||||
# via rune (pyproject.toml)
|
||||
idna==3.19
|
||||
idna==3.20
|
||||
# via
|
||||
# anyio
|
||||
# httpx
|
||||
@@ -50,27 +50,29 @@ markdown-it-py==4.2.0
|
||||
# mdit-py-plugins
|
||||
# rich
|
||||
# textual
|
||||
markupsafe==3.0.3
|
||||
markupsafe==3.0.4
|
||||
# via jinja2
|
||||
mdit-py-plugins==0.6.1
|
||||
# via textual
|
||||
mdurl==0.1.2
|
||||
# via markdown-it-py
|
||||
multidict==6.8.0
|
||||
multidict==6.9.1
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
nodeenv==1.10.0
|
||||
nodeenv==1.11.0
|
||||
# via pyright
|
||||
packaging==26.3
|
||||
# via pytest
|
||||
platformdirs==4.11.8
|
||||
pillow==12.3.0
|
||||
# via rune (pyproject.toml)
|
||||
platformdirs==4.12.3
|
||||
# via textual
|
||||
pluggy==1.6.0
|
||||
# via
|
||||
# diff-cover
|
||||
# pytest
|
||||
propcache==0.5.2
|
||||
propcache==0.5.4
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
@@ -116,7 +118,7 @@ typing-extensions==4.16.0
|
||||
# pyright
|
||||
# textual
|
||||
# textual-autocomplete
|
||||
websockets==17.1
|
||||
websockets==17.2
|
||||
# via rune (pyproject.toml)
|
||||
yarl==1.24.5
|
||||
yarl==1.25.1
|
||||
# via aiohttp
|
||||
@@ -0,0 +1,494 @@
|
||||
"""await_intent.py — ASYNC CORRECTIVE PATCH: intent normalization at the
|
||||
execution boundary.
|
||||
|
||||
PRINCIPLE: MODEL OUTPUT = INTENT. RUNE CONTROLLER = EXECUTION SEMANTICS.
|
||||
|
||||
The model may express continuation intent in any quality — `await_operation
|
||||
(deadline_secs=1200)`, `await-operation [op_type=master_ci, sha=X]`,
|
||||
"sleep 300 then check CI", "poll until done". When a matching
|
||||
PendingOperation exists (or the deterministic resolver can derive the
|
||||
operation truth from git/Gitea state), the controller executes the
|
||||
CANONICAL continuation and discards model-supplied scheduling policy.
|
||||
|
||||
Authority provenance for timing:
|
||||
USER_EXPLICIT / EXTERNAL_CONTRACT / TEST_HARNESS /
|
||||
GOVERNED_OPERATION_POLICY -> authoritative, retained
|
||||
MODEL_INVENTED -> non-authoritative, ignored
|
||||
|
||||
This module owns NO storage: it reads the existing W/R wait store
|
||||
(rune.event_wait) and git/Gitea state. The canonical continuation remains
|
||||
scripts/ci_event_wait.py run() (the T21.5 single-logical-await).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
if str(_SCRIPT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
_REPO_SRC = str(_SCRIPT_DIR.parent / "src")
|
||||
if _REPO_SRC not in sys.path:
|
||||
sys.path.insert(0, _REPO_SRC)
|
||||
|
||||
WR_BRIDGE = Path(os.environ.get("RUNE_WR_ROOT", "/mnt/workdrive/wr-bridge"))
|
||||
REPO = Path("/mnt/workdrive/issue161-harness")
|
||||
GIT_TIMEOUT = 10
|
||||
|
||||
# Default policy deadline per operation type — GOVERNED_OPERATION_POLICY.
|
||||
# The model's numbers never become policy; these are the controller's.
|
||||
POLICY_DEADLINES = {
|
||||
"branch_ci": 1800,
|
||||
"pull_request_checks": 1800,
|
||||
"master_ci": 1800,
|
||||
"deploy": 900,
|
||||
"release": 900,
|
||||
}
|
||||
|
||||
VALID_OP_TYPES = ("branch_ci", "pull_request_checks", "master_ci",
|
||||
"deploy", "release")
|
||||
|
||||
|
||||
# ── timing provenance ───────────────────────────────────────────────────────
|
||||
|
||||
def classify_timing_provenance(model_deadline: Any = None,
|
||||
user_deadline: Any = None,
|
||||
contract_deadline: Any = None) -> tuple[int, str]:
|
||||
"""Resolve the effective deadline + its provenance.
|
||||
|
||||
Authority order: USER_EXPLICIT > EXTERNAL_CONTRACT > TEST_HARNESS >
|
||||
GOVERNED_OPERATION_POLICY. MODEL_INVENTED values are recorded but
|
||||
NEVER used as policy.
|
||||
"""
|
||||
if model_deadline is not None:
|
||||
try:
|
||||
model_deadline = int(model_deadline)
|
||||
except (TypeError, ValueError):
|
||||
model_deadline = None
|
||||
if user_deadline is not None:
|
||||
return int(user_deadline), "USER_EXPLICIT"
|
||||
if contract_deadline is not None:
|
||||
return int(contract_deadline), "EXTERNAL_CONTRACT"
|
||||
return (model_deadline or 0), "MODEL_INVENTED" # caller discards this
|
||||
|
||||
|
||||
# ── PendingOperation lookup over the existing W/R wait store ────────────────
|
||||
|
||||
def _load_wait_records() -> list[dict]:
|
||||
"""All durable wait records from the bridge store (read-only)."""
|
||||
out = []
|
||||
waits = WR_BRIDGE / "waits"
|
||||
if not waits.is_dir():
|
||||
return out
|
||||
for f in sorted(waits.glob("*.json")):
|
||||
try:
|
||||
rec = json.loads(f.read_text())
|
||||
rec["_file"] = f.name
|
||||
out.append(rec)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _rec_operation_id(rec: dict) -> str:
|
||||
return str((rec.get("ext") or {}).get("operation_id")
|
||||
or rec.get("operation_id") or "")
|
||||
|
||||
|
||||
def _rec_sha(rec: dict) -> str:
|
||||
return str(rec.get("sha") or "")
|
||||
|
||||
|
||||
def pending_operations(sha: str = "", op_type: str = "") -> list[dict]:
|
||||
"""Pending (non-terminal) operations, optionally filtered."""
|
||||
terminal = {"COMPLETED", "FAILED", "CLOSED", "CANCELLED", "EXPIRED"}
|
||||
out = []
|
||||
for rec in _load_wait_records():
|
||||
status = str(rec.get("status", "")).upper()
|
||||
if status in terminal:
|
||||
continue
|
||||
if sha and _rec_sha(rec) != sha:
|
||||
continue
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def find_op_type_for_sha(sha: str) -> str | None:
|
||||
"""The op_type a prior canonical wait already recorded for this SHA
|
||||
(objective text embeds the type: 'event-driven {op_type} qualification')."""
|
||||
for rec in _load_wait_records():
|
||||
if _rec_sha(rec) != sha:
|
||||
continue
|
||||
obj = str(rec.get("continuation", {}).get("objective", ""))
|
||||
m = re.search(r"event-driven (\w+) qualification", obj)
|
||||
if m and m.group(1) in VALID_OP_TYPES:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
# ── deterministic op_type derivation from repo/Gitea truth ─────────────────
|
||||
|
||||
def _git(*args: str) -> str:
|
||||
try:
|
||||
r = subprocess.run(["git", "-C", str(REPO), *args],
|
||||
capture_output=True, text=True, timeout=GIT_TIMEOUT)
|
||||
return r.stdout.strip()
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
|
||||
|
||||
def sha_branch(sha: str) -> str:
|
||||
"""Branch(es) containing this commit in the local repo."""
|
||||
return _git("branch", "--all", "--contains", sha)
|
||||
|
||||
|
||||
def sha_on_master(sha: str) -> bool:
|
||||
"""SHA is an ancestor of master (local or remote-tracking refs).
|
||||
|
||||
M10 correction (2026-09-19): --all so a freshly fetched master
|
||||
(refs/remotes/gitea/master) counts; local branches alone go stale
|
||||
between fetches. derive_op_type refreshes refs before calling this.
|
||||
"""
|
||||
out = _git("branch", "--all", "--contains", sha)
|
||||
for l in out.splitlines():
|
||||
s = l.strip().removeprefix("remotes/").removesuffix(" *")
|
||||
if s == "master" or s.endswith("/master"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def open_pr_for_sha(sha: str) -> int | None:
|
||||
"""Is there an OPEN PR whose head SHA is this one? (Gitea API, read-only.)"""
|
||||
try:
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import gitea_auth # noqa: E402
|
||||
st, prs = gitea_auth.api("/pulls?state=open&limit=50")
|
||||
if st != 200:
|
||||
return None
|
||||
for p in prs:
|
||||
hs = str(p.get("head") or {}).get("sha") if isinstance(
|
||||
p.get("head"), dict) else None
|
||||
if hs and str(hs).startswith(sha[:12]):
|
||||
return int(p.get("number"))
|
||||
except Exception: # noqa: BLE001 — resolver must never crash the tool
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def derive_op_type(sha: str) -> tuple[str, str]:
|
||||
"""Deterministically derive the canonical op_type for a SHA.
|
||||
|
||||
Returns (op_type, evidence). Order (A-patch §2 amendment):
|
||||
1. POST-PATCH normalized record (ext.intent_normalized=true) — true
|
||||
stored operation truth, wins outright;
|
||||
2. open PR for the sha -> pull_request_checks;
|
||||
3. sha ancestor of master -> master_ci;
|
||||
4. sha on a feature branch -> branch_ci;
|
||||
5. legacy (pre-patch) records only CONFIRM, never override — they
|
||||
were created from model-supplied defaults and cannot be trusted.
|
||||
Never trusts model input.
|
||||
"""
|
||||
# 0. M10 correction (2026-09-19): refresh remote-tracking refs before
|
||||
# inference. A just-merged master commit is invisible to
|
||||
# branch --contains until refs are fetched; stale local git state
|
||||
# must never outrank repo truth. Bounded by GIT_TIMEOUT, fail-open.
|
||||
_git("fetch", "--quiet", "gitea", "+refs/heads/*:refs/remotes/gitea/*")
|
||||
for rec in _load_wait_records():
|
||||
if _rec_sha(rec) != sha:
|
||||
continue
|
||||
if (rec.get("ext") or {}).get("intent_normalized"):
|
||||
obj = str(rec.get("continuation", {}).get("objective", ""))
|
||||
m = re.search(r"event-driven (\w+) qualification", obj)
|
||||
if m and m.group(1) in VALID_OP_TYPES:
|
||||
return m.group(1), "normalized_record:" + m.group(1)
|
||||
pr = open_pr_for_sha(sha)
|
||||
if pr:
|
||||
return "pull_request_checks", f"open_pr:#{pr}"
|
||||
if sha_on_master(sha):
|
||||
return "master_ci", "commit_ancestor_of_master"
|
||||
return "branch_ci", "commit_on_feature_branch"
|
||||
|
||||
|
||||
# ── intent normalization ────────────────────────────────────────────────────
|
||||
|
||||
def normalize_action(action: dict, *,
|
||||
model_deadline: Any = None,
|
||||
user_deadline: Any = None,
|
||||
contract_deadline: Any = None,
|
||||
task_id: str = "") -> dict:
|
||||
"""Normalize a model continuation action into the canonical continuation.
|
||||
|
||||
action: {kind: await_operation|sleep_poll|check_status|unknown,
|
||||
sha?: str, operation_id?: str, op_type?: str, raw?: str}
|
||||
task_id: REAL_ASYNC_BRIDGE §9 — when the caller knows its task
|
||||
binding, pending-operation resolution is scoped to operations
|
||||
registered with that task_id; stale global operations are
|
||||
never consulted when a binding exists.
|
||||
Returns:
|
||||
{normalized: bool, action: "ContinuePendingOperation", operation_id,
|
||||
op_type, authority, deadline_secs, deadline_provenance,
|
||||
model_deadline_ignored, model_op_type_ignored, evidence}
|
||||
or {normalized: False, reason: ...} / {normalized: "AMBIGUOUS", candidates}
|
||||
"""
|
||||
sha = str(action.get("sha") or "")
|
||||
op_id = str(action.get("operation_id") or "")
|
||||
model_op_type = action.get("op_type")
|
||||
model_deadline_ignored = model_deadline is not None
|
||||
model_op_type_ignored = model_op_type is not None
|
||||
|
||||
# 0. task binding scope (§9): task-bound operations beat global state.
|
||||
# Stored operation wins over ANY model hint (sha/op_type/deadline).
|
||||
if task_id and not op_id:
|
||||
bound = [r for r in pending_operations()
|
||||
if str(r.get("ext", {}).get("task_id", "")) == task_id]
|
||||
if len(bound) == 1:
|
||||
rec = bound[0]
|
||||
norm = _canonical_from_record(rec, model_deadline_ignored,
|
||||
model_op_type_ignored,
|
||||
"task_bound",
|
||||
user_deadline, contract_deadline)
|
||||
norm["sha_for_wait"] = _rec_sha(rec)
|
||||
norm["task_id"] = task_id
|
||||
return norm
|
||||
if len(bound) > 1:
|
||||
return {"normalized": "AMBIGUOUS",
|
||||
"candidates": [_rec_operation_id(c) for c in bound],
|
||||
"reason": f"multiple pending operations bound to task {task_id}"}
|
||||
return {"normalized": False,
|
||||
"reason": f"no pending operation bound to task {task_id}"}
|
||||
|
||||
# 1. explicit operation_id wins
|
||||
if op_id:
|
||||
from rune import event_wait as ew # noqa: E402
|
||||
rec = ew.operation_wait(op_id)
|
||||
if rec is None:
|
||||
# REAL_ASYNC_BRIDGE §10: a FRESH operation has no wait record
|
||||
# yet — registration happens at the park step (ci_event_wait).
|
||||
# For an explicit, well-formed, EXISTING commit id, return the
|
||||
# provisional canonical continuation (policy deadline, own-runs
|
||||
# authority) so the model-facing tool can park on first call.
|
||||
# A typo'd/ghost sha is refused here (A-patch lesson): the
|
||||
# commit must exist in the repository.
|
||||
import re as _re
|
||||
m = _re.fullmatch(r"ci-([0-9a-f]{40})", op_id)
|
||||
if m:
|
||||
sha40 = m.group(1)
|
||||
probe = subprocess.run(
|
||||
["git", "-C", str(REPO), "cat-file", "-e",
|
||||
f"{sha40}^{{commit}}"],
|
||||
capture_output=True, timeout=15)
|
||||
if probe.returncode == 0:
|
||||
return {
|
||||
"normalized": True,
|
||||
"action": "ContinuePendingOperation",
|
||||
"operation_id": op_id,
|
||||
"sha_for_wait": sha40,
|
||||
"op_type": "branch_ci",
|
||||
"authority": "ci_runs",
|
||||
"deadline_secs": POLICY_DEADLINES["branch_ci"],
|
||||
"deadline_provenance": "GOVERNED_OPERATION_POLICY",
|
||||
"model_deadline_ignored": model_deadline_ignored,
|
||||
"model_op_type_ignored": model_op_type_ignored,
|
||||
"evidence": "explicit_id_fresh_registration",
|
||||
}
|
||||
return {"normalized": False,
|
||||
"reason": f"operation_id {op_id} has no wait record"}
|
||||
return _canonical_from_record(rec, model_deadline_ignored,
|
||||
model_op_type_ignored, "explicit_id",
|
||||
user_deadline, contract_deadline)
|
||||
|
||||
# 2. resolve by sha / active operation.
|
||||
# H-PATCH H2/H3: the model sha is a HINT. If it doesn't resolve but
|
||||
# unambiguous pending operations exist, the STORED target wins — a
|
||||
# mistyped model sha must never create a ghost operation (the A-patch
|
||||
# live failure: a typo'd sha derived branch_ci for a nonexistent commit
|
||||
# and parked a wait on a ghost).
|
||||
# 2. no explicit operation_id: resolve by sha / pending operations.
|
||||
# H-PATCH: when the model provides NO sha (operation-id-first mode),
|
||||
# resolve from unambiguous pending operations — the model should not
|
||||
# need to reconstruct a 40-char sha to express "wait for it".
|
||||
if not sha:
|
||||
all_pending = pending_operations()
|
||||
if len(all_pending) == 1:
|
||||
rec = all_pending[0]
|
||||
norm = _canonical_from_record(rec, model_deadline_ignored,
|
||||
model_op_type_ignored,
|
||||
"auto_resolved_no_sha",
|
||||
user_deadline, contract_deadline)
|
||||
norm["sha_for_wait"] = _rec_sha(rec)
|
||||
return norm
|
||||
if len(all_pending) > 1:
|
||||
return {"normalized": "AMBIGUOUS",
|
||||
"candidates": [_rec_operation_id(c) for c in all_pending],
|
||||
"reason": "multiple pending operations, no sha hint"}
|
||||
return {"normalized": False,
|
||||
"reason": "no pending operations and no sha provided"}
|
||||
|
||||
candidates = pending_operations(sha=sha)
|
||||
if len(candidates) > 1 and not sha:
|
||||
return {"normalized": "AMBIGUOUS",
|
||||
"candidates": [_rec_operation_id(c) for c in candidates],
|
||||
"reason": "multiple pending operations, no target"}
|
||||
if len(candidates) == 1:
|
||||
rec = candidates[0]
|
||||
stored_sha = _rec_sha(rec)
|
||||
norm = _canonical_from_record(rec, model_deadline_ignored,
|
||||
model_op_type_ignored, "pending_match",
|
||||
user_deadline, contract_deadline)
|
||||
if sha and stored_sha and sha != stored_sha:
|
||||
norm["model_sha_mismatch"] = {
|
||||
"model_sha": sha, "stored_sha": stored_sha,
|
||||
"resolution": "stored_target_used"}
|
||||
return norm
|
||||
# model sha unresolved: any unambiguous pending op at all? then the
|
||||
# model probably mistyped the sha — use the stored target, log it.
|
||||
all_pending = pending_operations()
|
||||
if len(all_pending) == 1 and sha:
|
||||
rec = all_pending[0]
|
||||
stored_sha = _rec_sha(rec)
|
||||
norm = _canonical_from_record(rec, model_deadline_ignored,
|
||||
model_op_type_ignored,
|
||||
"unresolved_sha_single_pending",
|
||||
user_deadline, contract_deadline)
|
||||
norm["model_sha_mismatch"] = {
|
||||
"model_sha": sha, "stored_sha": stored_sha,
|
||||
"resolution": "stored_target_used"}
|
||||
return norm
|
||||
|
||||
# 3. no stored record: derive truth deterministically
|
||||
if not sha:
|
||||
return {"normalized": False,
|
||||
"reason": "no pending operation and no sha to resolve"}
|
||||
op_type, evidence = derive_op_type(sha)
|
||||
return {
|
||||
"normalized": True,
|
||||
"action": "ContinuePendingOperation",
|
||||
"operation_id": f"ci-{sha}",
|
||||
"op_type": op_type,
|
||||
"authority": "ci_runs" if op_type in ("branch_ci",
|
||||
"pull_request_checks")
|
||||
else "deploy_qualify",
|
||||
"deadline_secs": POLICY_DEADLINES[op_type],
|
||||
"deadline_provenance": "GOVERNED_OPERATION_POLICY",
|
||||
"model_deadline_ignored": model_deadline_ignored,
|
||||
"model_op_type_ignored": model_op_type_ignored,
|
||||
"evidence": evidence,
|
||||
}
|
||||
|
||||
|
||||
def _canonical_from_record(rec: dict, model_deadline_ignored: bool,
|
||||
model_op_type_ignored: bool, how: str,
|
||||
user_deadline: Any, contract_deadline: Any) -> dict:
|
||||
op_id = _rec_operation_id(rec)
|
||||
obj = str(rec.get("continuation", {}).get("objective", ""))
|
||||
m = re.search(r"event-driven (\w+) qualification", obj)
|
||||
recorded = m.group(1) if (m and m.group(1) in VALID_OP_TYPES) else None
|
||||
rec_sha = _rec_sha(rec)
|
||||
if (rec.get("ext") or {}).get("intent_normalized") or how == "explicit_id":
|
||||
# normalized record or explicit id: stored truth wins
|
||||
op_type = recorded or "master_ci"
|
||||
else:
|
||||
# legacy record: git/Gitea derivation outranks the model-era default
|
||||
op_type, _ev = derive_op_type(rec_sha) if rec_sha else \
|
||||
(recorded or "master_ci", "no_sha")
|
||||
deadline, prov = classify_timing_provenance(
|
||||
user_deadline=user_deadline, contract_deadline=contract_deadline)
|
||||
if prov == "MODEL_INVENTED" or not deadline:
|
||||
deadline = POLICY_DEADLINES.get(op_type, 1800)
|
||||
prov = "GOVERNED_OPERATION_POLICY"
|
||||
return {
|
||||
"normalized": True,
|
||||
"action": "ContinuePendingOperation",
|
||||
"operation_id": op_id,
|
||||
"op_type": op_type,
|
||||
"authority": "ci_runs" if op_type in ("branch_ci",
|
||||
"pull_request_checks")
|
||||
else "deploy_qualify",
|
||||
"deadline_secs": deadline,
|
||||
"deadline_provenance": prov,
|
||||
"model_deadline_ignored": model_deadline_ignored,
|
||||
"model_op_type_ignored": model_op_type_ignored,
|
||||
"matched_by": how,
|
||||
}
|
||||
|
||||
|
||||
def normalize_bash(command: str, *, sha_hint: str = "") -> dict | None:
|
||||
"""Recognize sleep/check/poll intent aimed at a known pending operation.
|
||||
|
||||
Returns a normalization record if the command's semantic intent is
|
||||
continuation of a known external operation; None if the command is
|
||||
legitimate local timing (short sync, user delay, backoff) with no
|
||||
pending operation to continue. NEVER returns an executed sleep.
|
||||
"""
|
||||
if not command:
|
||||
return None
|
||||
cmd = command.strip()
|
||||
# long sleep + (status-check language or an explicit sha hint)
|
||||
sleep_m = re.search(r"\bsleep\s+(\d{2,5})\b", cmd)
|
||||
if not sleep_m:
|
||||
return None
|
||||
secs = int(sleep_m.group(1))
|
||||
check_intent = bool(re.search(
|
||||
r"\b(ci|deploy|release|status|check|poll|pipeline|run)\b", cmd,
|
||||
re.IGNORECASE))
|
||||
sha = sha_hint or ""
|
||||
m = re.search(r"\b([0-9a-f]{40}|[0-9a-f]{12})\b", cmd)
|
||||
if m:
|
||||
sha = m.group(1)
|
||||
if not check_intent and not sha:
|
||||
return None # plain sleep, no continuation intent
|
||||
cands = pending_operations(sha=sha) if sha else pending_operations()
|
||||
if not cands and not sha:
|
||||
return None
|
||||
if len(cands) > 1:
|
||||
return {"normalized": "AMBIGUOUS",
|
||||
"candidates": [_rec_operation_id(c) for c in cands],
|
||||
"raw_command_kind": "sleep_poll"}
|
||||
rec = cands[0] if cands else None
|
||||
if rec is not None:
|
||||
norm = _canonical_from_record(rec, True, False, "sleep_poll_intent",
|
||||
None, None)
|
||||
norm["sha_for_wait"] = _rec_sha(rec)
|
||||
elif sha:
|
||||
op_type, evidence = derive_op_type(sha)
|
||||
norm = {
|
||||
"normalized": True, "action": "ContinuePendingOperation",
|
||||
"operation_id": f"ci-{sha}", "op_type": op_type,
|
||||
"authority": "ci_runs" if op_type in ("branch_ci",
|
||||
"pull_request_checks")
|
||||
else "deploy_qualify",
|
||||
"deadline_secs": POLICY_DEADLINES[op_type],
|
||||
"deadline_provenance": "GOVERNED_OPERATION_POLICY",
|
||||
"model_deadline_ignored": True, "model_op_type_ignored": False,
|
||||
"evidence": evidence, "sha_for_wait": sha,
|
||||
}
|
||||
else:
|
||||
return None
|
||||
norm["raw_command_kind"] = "sleep_poll"
|
||||
norm["raw_sleep_seconds_suppressed"] = secs
|
||||
return norm
|
||||
|
||||
|
||||
def emit_normalized_record(record: dict) -> None:
|
||||
"""Compact structured observability — one JSONL line per normalization.
|
||||
No payloads, no secrets."""
|
||||
try:
|
||||
log = _SCRIPT_DIR.parent / "store" / "await_intent_log.jsonl"
|
||||
log.parent.mkdir(parents=True, exist_ok=True)
|
||||
slim = {k: record.get(k) for k in (
|
||||
"normalized", "action", "operation_id", "op_type", "authority",
|
||||
"deadline_secs", "deadline_provenance", "model_deadline_ignored",
|
||||
"model_op_type_ignored", "matched_by", "evidence",
|
||||
"raw_command_kind", "raw_sleep_seconds_suppressed", "reason",
|
||||
"candidates") if k in record}
|
||||
slim["at"] = __import__("time").strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ", __import__("time").gmtime())
|
||||
with open(log, "a") as f:
|
||||
f.write(json.dumps(slim, sort_keys=True) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""await_intent_cli.py — CLI surface for the intent normalization layer.
|
||||
|
||||
Used by the await-operation harness tool so the MODEL's scheduling hints
|
||||
never reach the wait engine. Prints one JSON line.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import await_intent # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
n = sub.add_parser("normalize", help="normalize a continuation intent")
|
||||
n.add_argument("--sha", default="")
|
||||
n.add_argument("--operation-id", default="")
|
||||
n.add_argument("--task-id", default="",
|
||||
help="§9 task binding: scope pending resolution to "
|
||||
"operations registered for this task")
|
||||
n.add_argument("--model-op-type", default=None)
|
||||
n.add_argument("--model-deadline", default=None)
|
||||
n.add_argument("--user-deadline", default=None,
|
||||
help="USER_EXPLICIT deadline (authoritative)")
|
||||
n.add_argument("--contract-deadline", default=None,
|
||||
help="EXTERNAL_CONTRACT deadline (authoritative)")
|
||||
rb = sub.add_parser("route-bash",
|
||||
help="route a Bash command expressing wait intent")
|
||||
rb.add_argument("--command", required=True)
|
||||
rb.add_argument("--sha-hint", default="")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.cmd == "route-bash":
|
||||
record = await_intent.normalize_bash(args.command,
|
||||
sha_hint=args.sha_hint)
|
||||
if record is None:
|
||||
print(json.dumps({"normalized": False,
|
||||
"reason": "no external continuation intent"}))
|
||||
return 0
|
||||
if record.get("normalized") is True:
|
||||
# the compiled wait needs the SHA: derive from operation_id
|
||||
oid = str(record.get("operation_id", ""))
|
||||
if oid.startswith("ci-"):
|
||||
record["sha_for_wait"] = oid[3:]
|
||||
await_intent.emit_normalized_record(record)
|
||||
print(json.dumps(record, sort_keys=True))
|
||||
return 0
|
||||
|
||||
action = {"kind": "await_operation", "sha": args.sha,
|
||||
"operation_id": args.operation_id,
|
||||
"op_type": args.model_op_type}
|
||||
model_deadline = (int(args.model_deadline)
|
||||
if args.model_deadline else None)
|
||||
user_deadline = (int(args.user_deadline)
|
||||
if args.user_deadline else None)
|
||||
contract_deadline = (int(args.contract_deadline)
|
||||
if args.contract_deadline else None)
|
||||
record = await_intent.normalize_action(
|
||||
action, model_deadline=model_deadline,
|
||||
user_deadline=user_deadline, contract_deadline=contract_deadline,
|
||||
task_id=args.task_id)
|
||||
await_intent.emit_normalized_record(record)
|
||||
print(json.dumps(record, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,588 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ci_event_wait.py — T16 §16C/§16D: event-driven CI qualification wait.
|
||||
|
||||
Replaces the deploy.yml sleep-30 → query → repeat polling loop with the
|
||||
EXISTING W/R event bridge (src/rune/event_wait.py — the SAME primitive the
|
||||
builder adapter uses; §16E: one bridge, adapters differ only in identity
|
||||
and authority probe).
|
||||
|
||||
Flow (mandate §16C):
|
||||
|
||||
push / CI workflow starts
|
||||
↓
|
||||
register correlation (provider="gitea", event_type="ci_completed",
|
||||
run_ref="ci-<sha>", sha=<sha>)
|
||||
↓
|
||||
WAITING_FOR_EVENT (await_resume_event: select() on FIFO, no floor)
|
||||
↓
|
||||
ci.yml final job POSTs /process-event (the WAKE)
|
||||
↓
|
||||
daemon matches → resume_cmd fires → FIFO token → controller resumes
|
||||
↓
|
||||
canonical Gitea state INDEPENDENTLY re-read (deploy_qualify.py verdict)
|
||||
↓
|
||||
verdict
|
||||
|
||||
The event is a WAKE, never truth. The final verdict always comes from
|
||||
deploy_qualify.py (required-checks context + exact-SHA ci.yml run, both
|
||||
legs). Watchdog deadline is a FAILURE DETECTOR, never the scheduler:
|
||||
|
||||
- event arrives → authority re-read; pending → bounded re-arm until the
|
||||
overall deadline (ack-window semantics, same as the daemon's wake sweep).
|
||||
- event lost/suppressed → watchdog expires → ONE-SHOT authority query:
|
||||
terminal verdict → recovered truthfully (no invented wait);
|
||||
CI still running (non-terminal Gitea runs for the SHA) → re-arm;
|
||||
nothing alive and no verdict → truthful failure (deployment refused).
|
||||
|
||||
Exit codes: 0 = qualified (success), 1 = refused (failure/timeout).
|
||||
Output: one JSON line with the §16D latency-proof timestamps.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
sys.path.insert(0, str(_SCRIPT_DIR.parent / "src"))
|
||||
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
from rune import event_wait as ew # noqa: E402
|
||||
import completion_file as cf # noqa: E402
|
||||
|
||||
REPO_ROOT = _SCRIPT_DIR.parent
|
||||
QUALIFY_SCRIPT = _SCRIPT_DIR / "deploy_qualify.py"
|
||||
|
||||
|
||||
def authority_verdict(sha: str = "") -> str:
|
||||
"""ONE-SHOT canonical Gitea verdict via deploy_qualify.py — the only
|
||||
completion truth. Never parses the event payload.
|
||||
|
||||
H-PATCH H4: the target sha is passed EXPLICITLY (as the subprocess's
|
||||
--sha argument) — ambient DEPLOY_SHA can no longer change which
|
||||
operation is verified. The env var remains only as a CLI backward
|
||||
compat for deploy_qualify.py invoked standalone."""
|
||||
cmd = [sys.executable, str(QUALIFY_SCRIPT)]
|
||||
if sha:
|
||||
cmd += ["--sha", sha]
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True, text=True, timeout=120,
|
||||
env={k: v for k, v in os.environ.items() if k != "DEPLOY_SHA"}
|
||||
if sha else dict(os.environ, PYTHONPATH=""),
|
||||
)
|
||||
out = (proc.stdout or "").strip().splitlines()
|
||||
return (out[-1] if out else "pending").strip()
|
||||
|
||||
|
||||
# ── T21.5 §4: operation-specific completion authority ──────────────────────
|
||||
#
|
||||
# T21.4 root cause: the wait loop had ONE hard-coded authority
|
||||
# (deploy qualification = master-ref CI) while operations waited on
|
||||
# branch CI, PR checks, or merges. Wrong authority => nonterminal
|
||||
# returns => the MODEL had to re-arm (the generator of the sleeps).
|
||||
# The wait verifier MUST match the operation being waited for.
|
||||
|
||||
OPERATION_SPECS = {
|
||||
# op_type: {
|
||||
# workflow_path: Actions run path filter (None = any),
|
||||
# head_branches: head-branch filter (None = any),
|
||||
# authority: which canonical verifier decides completion,
|
||||
# event_types: expected wake event types,
|
||||
# terminal: terminal verdicts, failure: failure verdicts,
|
||||
# }
|
||||
"branch_ci": {
|
||||
"workflow_path": "ci.yml",
|
||||
"head_branches": None,
|
||||
"authority": "ci_runs", # any completed run for this SHA
|
||||
"event_types": ("ci_completed",),
|
||||
"terminal": ("success",),
|
||||
"failure": ("failure", "cancelled"),
|
||||
},
|
||||
"pull_request_checks": {
|
||||
"workflow_path": None, # all PR-triggered runs for SHA
|
||||
"head_branches": None,
|
||||
"authority": "ci_runs",
|
||||
"event_types": ("ci_completed",),
|
||||
"terminal": ("success",),
|
||||
"failure": ("failure", "cancelled"),
|
||||
},
|
||||
"master_ci": {
|
||||
"workflow_path": "ci.yml",
|
||||
"head_branches": ("master",),
|
||||
"authority": "deploy_qualify", # full two-leg qualification
|
||||
"event_types": ("ci_completed",),
|
||||
"terminal": ("success",),
|
||||
"failure": ("failure", "cancelled"),
|
||||
},
|
||||
"deploy": {
|
||||
"workflow_path": "deploy.yml",
|
||||
"head_branches": ("master",),
|
||||
"authority": "deploy_qualify",
|
||||
"event_types": ("ci_completed", "deploy_completed"),
|
||||
"terminal": ("success",),
|
||||
"failure": ("failure", "cancelled"),
|
||||
},
|
||||
"release": {
|
||||
"workflow_path": None,
|
||||
"head_branches": ("master",),
|
||||
"authority": "deploy_qualify",
|
||||
"event_types": ("ci_completed", "release_completed"),
|
||||
"terminal": ("success",),
|
||||
"failure": ("failure", "cancelled"),
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_OP_TYPE = "master_ci"
|
||||
|
||||
|
||||
def normalize_verdict(verdict: str) -> str:
|
||||
"""T21.5 §18 audit fix: pending-class verdicts are ALL nonterminal.
|
||||
deploy_qualify emits "pending (probe failed)" for a transient probe
|
||||
failure — that is pending, never a terminal answer. The wait loop
|
||||
must not return control to the model over a string variant."""
|
||||
v = (verdict or "").strip().lower()
|
||||
if v.startswith("pending"):
|
||||
return "pending"
|
||||
return (verdict or "").strip()
|
||||
|
||||
|
||||
def op_spec(op_type: str) -> dict:
|
||||
spec = OPERATION_SPECS.get(op_type)
|
||||
if spec is None:
|
||||
raise ValueError(
|
||||
f"unknown op_type {op_type!r} — must be one of "
|
||||
f"{sorted(OPERATION_SPECS)} (the wait verifier must match "
|
||||
"the operation being waited for)")
|
||||
return spec
|
||||
|
||||
|
||||
def op_authority_verdict(op_type: str, sha: str) -> str:
|
||||
"""Canonical completion verdict FOR THIS OPERATION TYPE.
|
||||
|
||||
ci_runs — the op's own Actions runs for this exact SHA: every
|
||||
required workflow run completed; terminal iff every
|
||||
run of the op's workflow set concluded success
|
||||
(branch/PR ops are their own truth; deploy
|
||||
qualification is NOT their authority).
|
||||
deploy_qualify — the two-leg deploy qualification (master CI +
|
||||
required-checks) via deploy_qualify.py.
|
||||
"""
|
||||
spec = op_spec(op_type)
|
||||
authority = spec["authority"]
|
||||
if authority == "deploy_qualify":
|
||||
v = normalize_verdict(authority_verdict(sha))
|
||||
if v != "pending":
|
||||
return v
|
||||
# E-PATCH resilience: Gitea's combined commit-status endpoint can
|
||||
# 404 for the newest commit (observed live on auto-version bump
|
||||
# commits — the status object materializes late/never). The runs
|
||||
# ARE the same evidence the two legs read; fall back to the
|
||||
# operation-scoped runs verdict so authority stays resolvable.
|
||||
if _status_endpoint_unreadable(sha):
|
||||
return _ci_runs_verdict(spec, sha)
|
||||
return v
|
||||
if authority == "ci_runs":
|
||||
return _ci_runs_verdict(spec, sha)
|
||||
raise ValueError(f"unknown authority {authority!r} for {op_type!r}")
|
||||
|
||||
|
||||
def _status_endpoint_unreadable(sha: str) -> bool:
|
||||
"""True when the combined commit-status endpoint 404s for the SHA
|
||||
(index lag / never-materialized status object) — the documented
|
||||
fallback trigger, verified against the API once per call."""
|
||||
try:
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import gitea_auth # noqa: E402
|
||||
st, _body = gitea_auth.api(f"/commits/{sha}/status")
|
||||
return st == 404
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _ci_runs_verdict(spec: dict, sha: str) -> str:
|
||||
"""Operation-scoped Actions-run verdict (import the API client from
|
||||
deploy_qualify to keep ONE Gitea client)."""
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import deploy_qualify as dq # noqa: E402
|
||||
import gitea_auth # noqa: E402
|
||||
server = os.environ.get("GITEA_SERVER",
|
||||
getattr(gitea_auth, "DEFAULT_SERVER", "")).rstrip("/")
|
||||
repo = os.environ.get("GITEA_REPO",
|
||||
getattr(gitea_auth, "DEFAULT_REPO", ""))
|
||||
sha_env = sha # H-PATCH: explicit parameter is the only authority
|
||||
token = gitea_auth.get_token() if gitea_auth.has_token() else ""
|
||||
if not (server and repo and sha_env and token):
|
||||
return "pending" # cannot verify -> not verified (fail closed)
|
||||
runs = dq._fetch_runs(server, repo, sha_env, token)
|
||||
if runs is None:
|
||||
return "pending" # probe failure -> keep waiting
|
||||
wfp = spec["workflow_path"]
|
||||
if wfp is not None:
|
||||
runs = [r for r in runs
|
||||
if str(r.get("path", "")).startswith(wfp + "@")]
|
||||
branches = spec["head_branches"]
|
||||
if branches is not None:
|
||||
runs = [r for r in runs
|
||||
if str(r.get("head_branch", "")) in branches]
|
||||
if not runs:
|
||||
return "pending" # no runs visible yet -> not terminal
|
||||
for r in runs:
|
||||
if r.get("status") != "completed":
|
||||
return "pending" # still running
|
||||
if r.get("conclusion") not in ("success", "skipped"):
|
||||
return "failure" # any non-success conclusion
|
||||
return "success"
|
||||
|
||||
|
||||
def op_ci_alive(op_type: str, sha: str) -> bool:
|
||||
"""Liveness probe FOR THIS OPERATION TYPE: any non-terminal run in
|
||||
the op's run set (failure DETECTOR, never the scheduler)."""
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import deploy_qualify as dq # noqa: E402
|
||||
import gitea_auth # noqa: E402
|
||||
spec = op_spec(op_type)
|
||||
server = os.environ.get("GITEA_SERVER",
|
||||
getattr(gitea_auth, "DEFAULT_SERVER", "")).rstrip("/")
|
||||
repo = os.environ.get("GITEA_REPO",
|
||||
getattr(gitea_auth, "DEFAULT_REPO", ""))
|
||||
sha_env = sha # H-PATCH: explicit parameter is the only authority
|
||||
token = gitea_auth.get_token() if gitea_auth.has_token() else ""
|
||||
if not (server and repo and sha_env and token):
|
||||
return False
|
||||
runs = dq._fetch_runs(server, repo, sha_env, token)
|
||||
if not runs:
|
||||
return False
|
||||
wfp = spec["workflow_path"]
|
||||
if wfp is not None:
|
||||
runs = [r for r in runs
|
||||
if str(r.get("path", "")).startswith(wfp + "@")]
|
||||
branches = spec["head_branches"]
|
||||
if branches is not None:
|
||||
runs = [r for r in runs
|
||||
if str(r.get("head_branch", "")) in branches]
|
||||
terminal = {"success", "failure", "cancelled", "skipped"}
|
||||
return any(str(r.get("status", "")).lower() not in terminal
|
||||
for r in runs)
|
||||
|
||||
|
||||
def authority_after_wake(op_type: str = DEFAULT_OP_TYPE, sha: str = "",
|
||||
max_tries: int = 3, gap_secs: int = 4) -> str:
|
||||
"""(sha is EXPLICIT — ambient env never selects the target.)"""
|
||||
"""Post-wake authority read. The daemon's own wake sweep uses a
|
||||
bounded event-triggered retry (4s ×3) for the job-finished-microseconds-
|
||||
ago ack window; a verdict read here can hit the same window. This is a
|
||||
bounded schedule, not a polling loop — 3 reads max, then back to the
|
||||
event wait / watchdog."""
|
||||
verdict = "pending"
|
||||
for attempt in range(max_tries):
|
||||
verdict = op_authority_verdict(op_type, sha)
|
||||
if verdict != "pending":
|
||||
return verdict
|
||||
if attempt < max_tries - 1:
|
||||
time.sleep(gap_secs)
|
||||
return verdict
|
||||
|
||||
|
||||
def ci_alive() -> bool:
|
||||
"""LEGACY liveness probe (master-ref deploy runs) — kept for callers
|
||||
predating op specs; run() uses op_ci_alive(op_type, sha)."""
|
||||
return op_ci_alive(DEFAULT_OP_TYPE, os.environ.get("DEPLOY_SHA", ""))
|
||||
|
||||
|
||||
def _set_cognition_state(op: str, state: str) -> None:
|
||||
"""E-PATCH §12: stamp cognitive quiescence on the durable record so a
|
||||
fresh process knows cognition is parked (WAITING_EXTERNAL) vs resumable
|
||||
(RESUMABLE). Best-effort: observability never breaks the wait."""
|
||||
try:
|
||||
rec = ew.operation_wait(op)
|
||||
if rec is None:
|
||||
return
|
||||
wait_file = ew.wr_root() / "waits" / f"{rec.get('wait_id')}.json"
|
||||
if not wait_file.is_file():
|
||||
return
|
||||
data = json.loads(wait_file.read_text())
|
||||
data.setdefault("ext", {})["continuation_state"] = state
|
||||
wait_file.write_text(json.dumps(data, indent=1, sort_keys=True))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def run(*, sha: str, deadline_secs: int,
|
||||
watchdog_secs: int = 300,
|
||||
op_type: str = DEFAULT_OP_TYPE,
|
||||
task_id: str = "",
|
||||
daemon_url: str = ew.DEFAULT_DAEMON_URL) -> int:
|
||||
"""T21.5 §3: ONE LOGICAL AWAIT.
|
||||
|
||||
One logical external wait is one invocation of this function. All
|
||||
nonterminal conditions (event-seen-authority-pending, watchdog expiry
|
||||
with live runs, bounded authority-retry exhausted) re-arm INTERNALLY —
|
||||
control returns to the caller ONLY on a terminal verdict or on real
|
||||
deadline expiry. Watchdogs are failure detectors inside this
|
||||
abstraction, never the scheduler and never completion truth.
|
||||
"""
|
||||
spec = op_spec(op_type) # unknown op_type refused upfront
|
||||
op = f"ci-{sha}"
|
||||
# A-PATCH: the authority subprocess reads DEPLOY_SHA from the env;
|
||||
# python-level run() callers (tool normalization flow) must export it.
|
||||
if sha and not os.environ.get("DEPLOY_SHA"):
|
||||
os.environ["DEPLOY_SHA"] = sha
|
||||
# H-PATCH: resolve short SHAs to full 40-char — _fetch_runs and
|
||||
# deploy_qualify match on the FULL sha; a 12-char prefix would
|
||||
# silently fail equality and park the waiter until deadline
|
||||
# (the 962e351 root cause).
|
||||
if sha and len(sha) < 40:
|
||||
try:
|
||||
full = subprocess.run(
|
||||
["git", "-C", str(_SCRIPT_DIR.parent), "rev-parse", sha],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if full.returncode == 0:
|
||||
sha = full.stdout.strip()
|
||||
os.environ["DEPLOY_SHA"] = sha
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
t0 = time.time()
|
||||
# REAL_ASYNC_BRIDGE: FIFO is replaced by completion-file inotify.
|
||||
# The completion file write is the wake signal; inotify is the
|
||||
# kernel-level zero-latency, zero-CPU wake primitive.
|
||||
# Event replay (T16 §16C live finding): on a serialized runner the
|
||||
# completion event usually arrives BEFORE the waiter registers.
|
||||
# A matching durable event means the wake already happened — skip
|
||||
# the rendezvous entirely and go straight to the authority read.
|
||||
prior = ew.find_matching_event("gitea", "ci_completed", op)
|
||||
if prior is not None and prior.get("exit_code") is not None:
|
||||
rec = ew.register_process_wait(
|
||||
op, project="rune",
|
||||
objective=f"event-driven {op_type} qualification for {sha}",
|
||||
watched_for="Gitea CI completion event (replayed from the "
|
||||
"durable event log)",
|
||||
provider="gitea", event_type="ci_completed", sha=sha,
|
||||
watchdog_secs=min(watchdog_secs, deadline_secs),
|
||||
continuation_state="RESUMABLE", # event already arrived
|
||||
op_type=op_type,
|
||||
authority=spec["authority"])
|
||||
registered_at = rec["created_at"]
|
||||
event_at = time.strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
resumed_at = event_at
|
||||
# the event is a WAKE, never truth — canonical authority decides
|
||||
verdict = normalize_verdict(authority_after_wake(op_type, sha))
|
||||
if verdict == "pending" and not op_ci_alive(op_type, sha):
|
||||
verdict = normalize_verdict(op_authority_verdict(op_type, sha))
|
||||
mode = ("event_replayed" if verdict != "pending"
|
||||
else "authority_unresolved")
|
||||
verdict_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
out = {
|
||||
"sha": sha,
|
||||
"op_type": op_type,
|
||||
"authority": spec["authority"],
|
||||
"registered_at": registered_at,
|
||||
"event_received_at": event_at,
|
||||
"controller_resumed_at": resumed_at,
|
||||
"verdict_at": verdict_at,
|
||||
"mode": mode,
|
||||
"rearm_reason": "completion event replayed from durable log "
|
||||
"(arrived before registration)",
|
||||
"verdict": verdict,
|
||||
"wall_secs": round(time.time() - t0, 3),
|
||||
}
|
||||
print(json.dumps(out))
|
||||
return 0 if verdict == "success" else 1
|
||||
rec = ew.register_process_wait(
|
||||
op, project="rune",
|
||||
objective=f"event-driven {op_type} qualification for {sha}",
|
||||
watched_for="Gitea CI completion event (wake; verdict re-read "
|
||||
f"from canonical {spec['authority']} authority)",
|
||||
provider="gitea", event_type="ci_completed", sha=sha,
|
||||
watchdog_secs=min(watchdog_secs, deadline_secs),
|
||||
continuation_state="WAITING_EXTERNAL",
|
||||
op_type=op_type,
|
||||
authority=spec["authority"],
|
||||
task_id=task_id, # §9: bind the operation to the calling task
|
||||
resume_mode="self") # §3: waiter owns its continuation via
|
||||
# completion-file/inotify; the daemon has NO
|
||||
# session-delivery role for this wait
|
||||
registered_at = rec["created_at"]
|
||||
_set_cognition_state(op, "WAITING_EXTERNAL") # cognition parked
|
||||
deadline = time.monotonic() + deadline_secs
|
||||
verdict = "pending"
|
||||
mode = "event"
|
||||
event_at = ""
|
||||
resumed_at = ""
|
||||
rearm_reason = ""
|
||||
rearm_count = 0
|
||||
while time.monotonic() < deadline:
|
||||
remaining = max(1, int(deadline - time.monotonic()))
|
||||
|
||||
# REAL_ASYNC_BRIDGE: block on inotify for the completion file.
|
||||
# Zero CPU, zero arbitrary timer. The inotify timeout is a
|
||||
# FAILURE DETECTOR — if no event arrives within the watchdog
|
||||
# window, we check the authority directly (lost-event recovery).
|
||||
# The deadline is containment only.
|
||||
watch_timeout = min(remaining, watchdog_secs)
|
||||
result = cf.wait_for_completion(
|
||||
op, timeout_secs=watch_timeout)
|
||||
|
||||
# REAL_ASYNC_BRIDGE: check durable event log AND wait record
|
||||
# status on EVERY cycle — the daemon may have matched and
|
||||
# transitioned the record via /process-event.
|
||||
wr = ew.operation_wait(op)
|
||||
if wr and wr.get("status") in ("COMPLETED", "FAILED",
|
||||
"READY_TO_RESUME"):
|
||||
event_at = event_at or time.strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
resumed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime())
|
||||
verdict = normalize_verdict(
|
||||
authority_after_wake(op_type, sha))
|
||||
if verdict != "pending":
|
||||
mode = "event"
|
||||
break
|
||||
prior = ew.find_matching_event("gitea", "ci_completed", op)
|
||||
if prior is not None and prior.get("exit_code") is not None:
|
||||
event_at = event_at or time.strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
resumed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime())
|
||||
verdict = normalize_verdict(
|
||||
authority_after_wake(op_type, sha))
|
||||
if verdict != "pending":
|
||||
mode = "event"
|
||||
break
|
||||
if result is not None:
|
||||
# completion file exists: event arrived
|
||||
event_at = event_at or time.strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
resumed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime())
|
||||
verdict = normalize_verdict(
|
||||
authority_after_wake(op_type, sha))
|
||||
if verdict != "pending":
|
||||
mode = "event"
|
||||
break
|
||||
rearm_count += 1
|
||||
rearm_reason = ("completion file seen; authority "
|
||||
"pending — re-subscribing")
|
||||
continue
|
||||
|
||||
# inotify timeout: no event within watchdog window
|
||||
# FAILURE DETECTOR: check authority directly
|
||||
verdict = normalize_verdict(
|
||||
op_authority_verdict(op_type, sha))
|
||||
if verdict != "pending":
|
||||
mode = "watchdog_authority_recovered"
|
||||
break
|
||||
rearm_count += 1
|
||||
rearm_reason = ("watchdog expiry; op pending (not started "
|
||||
"or lagging) — re-subscribing inotify")
|
||||
continue
|
||||
|
||||
# REAL_ASYNC_BRIDGE: no FIFO to close. The completion file is durable
|
||||
# and survives restarts. Lease release is not needed (no lease system
|
||||
# for file-based delivery).
|
||||
|
||||
|
||||
if verdict == "pending":
|
||||
if mode == "event":
|
||||
mode = "deadline_pending"
|
||||
verdict = normalize_verdict(op_authority_verdict(op_type, sha)) # final one-shot truth
|
||||
_set_cognition_state(op, "RESUMABLE" if verdict != "pending"
|
||||
else "RECOVERY_REQUIRED")
|
||||
verdict_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
# §2 STATE OWNERSHIP: the WAITER owns the OPERATION domain. On a
|
||||
# terminal authority verdict, stamp the durable record terminal with
|
||||
# the evidence — the daemon's session-delivery machinery must never
|
||||
# (and can no longer) overwrite this truth. Also updates the parked
|
||||
# continuation_state so fresh-process recovery sees reality.
|
||||
try:
|
||||
rec2 = ew.operation_wait(op) or rec
|
||||
if verdict != "pending":
|
||||
rec2["status"] = "COMPLETED" if verdict == "success" else "FAILED"
|
||||
rec2["verdict"] = verdict
|
||||
rec2["resume_evidence"] = {
|
||||
"kind": "authority_verified_terminal",
|
||||
"verdict": verdict,
|
||||
"mode": mode,
|
||||
"authority": spec["authority"],
|
||||
"sha": sha,
|
||||
"op_type": op_type,
|
||||
"verdict_at": verdict_at,
|
||||
}
|
||||
rec2["ext"] = {**rec2.get("ext", {}),
|
||||
"continuation_state": ("RESUMABLE" if verdict != "pending"
|
||||
else "RECOVERY_REQUIRED")}
|
||||
ew._load_waitstore().save(rec2)
|
||||
except Exception as _e: # noqa: BLE001 — record stamping must never corrupt the envelope (the envelope + authority remain the truth)
|
||||
print(f"ci_event_wait: terminal record stamp failed: {_e}",
|
||||
file=sys.stderr)
|
||||
out = {
|
||||
"sha": sha,
|
||||
"op_type": op_type,
|
||||
"authority": spec["authority"],
|
||||
"registered_at": registered_at,
|
||||
"event_received_at": event_at,
|
||||
"controller_resumed_at": resumed_at,
|
||||
"verdict_at": verdict_at,
|
||||
"mode": mode,
|
||||
"rearm_reason": rearm_reason,
|
||||
"internal_rearm_count": rearm_count,
|
||||
"verdict": verdict,
|
||||
"wall_secs": round(time.time() - t0, 3),
|
||||
}
|
||||
print(json.dumps(out))
|
||||
return 0 if verdict == "success" else 1
|
||||
|
||||
KNOWN_FLAGS = ("--sha", "--deadline", "--op-type", "--watchdog", "--task-id")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""§5 CLI CONTRACT: no silent argument loss. Every flag is either
|
||||
explicitly supported (and takes effect) or loudly rejected — an
|
||||
acceptance envelope must reflect the configuration actually used."""
|
||||
sha = os.environ.get("DEPLOY_SHA", "")
|
||||
args = sys.argv[1:]
|
||||
deadline = 900
|
||||
op_type = DEFAULT_OP_TYPE
|
||||
watchdog = 300
|
||||
task_id = ""
|
||||
i = 0
|
||||
while i < len(args):
|
||||
a = args[i]
|
||||
if a == "--sha":
|
||||
sha = args[i + 1]; i += 2
|
||||
elif a == "--deadline":
|
||||
deadline = int(args[i + 1]); i += 2
|
||||
elif a == "--op-type":
|
||||
op_type = args[i + 1]; i += 2
|
||||
elif a == "--watchdog":
|
||||
watchdog = int(args[i + 1]); i += 2
|
||||
elif a == "--task-id":
|
||||
task_id = args[i + 1]; i += 2
|
||||
elif a.startswith("--"):
|
||||
print(json.dumps({"error": f"unknown flag {a!r}",
|
||||
"known_flags": list(KNOWN_FLAGS),
|
||||
"contract": "no silent argument loss"}))
|
||||
return 2
|
||||
else:
|
||||
print(json.dumps({"error": f"unexpected positional {a!r}",
|
||||
"known_flags": list(KNOWN_FLAGS)}))
|
||||
return 2
|
||||
if not sha:
|
||||
print(json.dumps({"error": "no sha (DEPLOY_SHA or --sha required)"}))
|
||||
return 1
|
||||
# A-PATCH fix: run()'s authority subprocess (deploy_qualify.py) reads
|
||||
# DEPLOY_SHA from the environment — a CLI `--sha` consumer previously
|
||||
# got a silent "pending" verdict forever because the var was never
|
||||
# exported. Export it so the canonical authority can resolve.
|
||||
os.environ["DEPLOY_SHA"] = sha
|
||||
return run(sha=sha, deadline_secs=deadline, op_type=op_type,
|
||||
watchdog_secs=watchdog, task_id=task_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,195 @@
|
||||
"""completion_file.py — REAL_ASYNC_BRIDGE: durable completion-file delivery
|
||||
with inotify wake. Replaces FIFO+lease for process-wait delivery.
|
||||
|
||||
DESIGN:
|
||||
Producer (bridge/daemon/notify):
|
||||
1. Validates operation identity
|
||||
2. Atomically writes a completion file:
|
||||
resumes/<operation_id>.done
|
||||
containing the event/verdict JSON (write to tmp, rename for atomicity)
|
||||
|
||||
Waiter (ci_event_wait / any continuation consumer):
|
||||
1. Checks if the completion file already exists (event-before-waiter)
|
||||
2. If not: registers inotify watch on the resumes/ directory
|
||||
3. Re-checks after watch registration (closes the registration race)
|
||||
4. Blocks in kernel on inotify — zero CPU, zero timer
|
||||
5. On file-created event: reads the file, verifies authority, returns
|
||||
|
||||
The event is a WAKE SIGNAL. Authority still decides terminal truth.
|
||||
The completion file is durable — survives daemon and waiter restarts.
|
||||
|
||||
WHY NOT FIFO:
|
||||
FIFO is one-shot (token consumed), requires lease management, and
|
||||
fails under session-busy guards. A completion file is atomic, durable,
|
||||
and works across restarts. inotify wakes the process with zero CPU
|
||||
cost and zero latency between file creation and wake.
|
||||
|
||||
WHY NOT POLLING:
|
||||
Polling introduces an arbitrary interval. inotify is a kernel callback
|
||||
that fires exactly when the filesystem changes — the latency tracks
|
||||
the real completion event, not a configured interval.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
def _wr_bridge() -> Path:
|
||||
# WR_ROOT = daemon/infra convention; RUNE_WR_ROOT = repo/scripts
|
||||
# convention. Both honored (WR_ROOT wins) so the daemon, the waiter,
|
||||
# and any isolated test root always resolve the SAME directory.
|
||||
root = (os.environ.get("WR_ROOT")
|
||||
or os.environ.get("RUNE_WR_ROOT")
|
||||
or "/mnt/workdrive/wr-bridge")
|
||||
return Path(root)
|
||||
|
||||
def _resumes_dir() -> Path:
|
||||
return _wr_bridge() / "resumes"
|
||||
|
||||
# inotify constants (Linux)
|
||||
IN_CREATE = 0x100
|
||||
IN_CLOSE_WRITE = 0x8
|
||||
IN_MOVED_TO = 0x80
|
||||
|
||||
|
||||
def completion_path(operation_id: str) -> Path:
|
||||
return _resumes_dir() / f"{operation_id}.done"
|
||||
|
||||
|
||||
def write_completion(operation_id: str, event: dict) -> Path:
|
||||
"""Atomically write a durable completion file. The rename(2) ensures
|
||||
the waiter never sees a partial file."""
|
||||
_resumes_dir().mkdir(parents=True, exist_ok=True)
|
||||
target = completion_path(operation_id)
|
||||
tmp = target.parent / f".{target.name}.tmp.{os.getpid()}"
|
||||
tmp.write_text(json.dumps(event, sort_keys=True) + "\n")
|
||||
os.rename(str(tmp), str(target)) # atomic on same filesystem
|
||||
os.chmod(str(target), 0o666) # cross-user bridge
|
||||
return target
|
||||
|
||||
|
||||
def read_completion(operation_id: str) -> dict | None:
|
||||
"""Read a durable completion file. Returns None if absent."""
|
||||
p = completion_path(operation_id)
|
||||
if not p.is_file():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def completion_exists(operation_id: str) -> bool:
|
||||
return completion_path(operation_id).is_file()
|
||||
|
||||
|
||||
def _inotify_init_and_add_watch(directory: str) -> int:
|
||||
"""Create an inotify instance and add a watch on the directory.
|
||||
Returns the inotify fd."""
|
||||
libc = __import__("ctypes").CDLL(None, use_errno=True)
|
||||
fd = libc.inotify_init()
|
||||
if fd < 0:
|
||||
raise OSError("inotify_init failed")
|
||||
# IN_CREATE | IN_MOVED_TO: catch both direct creation and atomic rename
|
||||
mask = IN_CREATE | IN_MOVED_TO
|
||||
wd = libc.inotify_add_watch(fd, directory.encode(), mask)
|
||||
if wd < 0:
|
||||
os.close(fd)
|
||||
raise OSError(f"inotify_add_watch failed for {directory}")
|
||||
return fd
|
||||
|
||||
|
||||
def _inotify_read(fd: int, timeout_secs: float | None = None) -> list[str]:
|
||||
"""Read pending inotify events. Returns list of filenames that were
|
||||
created/moved-to. Blocks until at least one event or timeout."""
|
||||
poll = select.poll()
|
||||
poll.register(fd, select.POLLIN)
|
||||
events = []
|
||||
buf = os.read(fd, 4096)
|
||||
pos = 0
|
||||
while pos < len(buf):
|
||||
# struct: wd(i32), mask(u32), cookie(u32), name_len(u32)
|
||||
wd, mask, cookie, name_len = struct.unpack_from("iIII", buf, pos)
|
||||
pos += 16
|
||||
name = buf[pos:pos + name_len].rstrip(b"\x00").decode("utf-8", "replace")
|
||||
pos += name_len
|
||||
if mask & (IN_CREATE | IN_MOVED_TO):
|
||||
events.append(name)
|
||||
return events
|
||||
|
||||
|
||||
def wait_for_completion(operation_id: str, *,
|
||||
timeout_secs: float | None = None,
|
||||
poll_callback=None) -> dict | None:
|
||||
"""Block until a completion file exists for this operation.
|
||||
|
||||
Uses inotify for zero-latency, zero-CPU wake. Falls back to
|
||||
polling_callback (if provided) when inotify is unavailable.
|
||||
|
||||
The completion file is a WAKE SIGNAL. The caller must still verify
|
||||
canonical authority after reading it.
|
||||
|
||||
Returns the event dict from the completion file, or None on timeout.
|
||||
"""
|
||||
target = completion_path(operation_id)
|
||||
|
||||
# 1. check-before-watch (event may have arrived before we started)
|
||||
existing = read_completion(operation_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
_resumes_dir().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
fd = _inotify_init_and_add_watch(str(_resumes_dir()))
|
||||
except OSError:
|
||||
# inotify unavailable: fall back to polling callback
|
||||
if poll_callback:
|
||||
return poll_callback(operation_id, timeout_secs)
|
||||
return None
|
||||
|
||||
try:
|
||||
# 2. re-check after watch registration (closes the race between
|
||||
# the initial check and the watch being active)
|
||||
existing = read_completion(operation_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
# 3. block in kernel on inotify — zero CPU, exact latency
|
||||
import select as _select
|
||||
poll = _select.poll()
|
||||
poll.register(fd, _select.POLLIN)
|
||||
deadline = (time.monotonic() + timeout_secs) if timeout_secs else None
|
||||
|
||||
while True:
|
||||
remaining = None
|
||||
if deadline is not None:
|
||||
remaining = max(0.1, deadline - time.monotonic())
|
||||
if remaining <= 0.1:
|
||||
return None # timeout
|
||||
events = poll.poll(remaining * 1000 if remaining else None)
|
||||
if not events:
|
||||
continue # poll timeout, re-check
|
||||
names = _inotify_read(fd)
|
||||
if target.name in names:
|
||||
# 4. file appeared — read it
|
||||
existing = read_completion(operation_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
# partial/corrupt: keep watching
|
||||
finally:
|
||||
os.close(fd)
|
||||
return None
|
||||
|
||||
|
||||
def clear_completion(operation_id: str) -> None:
|
||||
"""Remove a consumed completion file (idempotent cleanup)."""
|
||||
p = completion_path(operation_id)
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,123 @@
|
||||
"""construction_guards.py — deterministic guards for observed process
|
||||
defects (SCAFFOLD-TRANCHE-006 §21-22).
|
||||
|
||||
Two real defect classes from recent sessions:
|
||||
|
||||
A. DURABLE-STORE MUTATION WITHOUT PERSISTENCE (Tranche 5: closure script
|
||||
mutated the contract in memory, never called save(); the next read saw
|
||||
stale state). Guard: a durable-boundary operation verifies by
|
||||
RELOAD/READBACK, not by successful in-memory mutation.
|
||||
|
||||
B. BLIND SOURCE-EDIT NO-OP (multiple sessions: python str.replace() on
|
||||
ruff-reformatted files silently matched nothing; the script reported
|
||||
success while changing nothing). Guard: an automated edit verifies its
|
||||
POSTCONDITION — expected text present / old text absent — and a
|
||||
zero-match replacement is an explicit, truthful no-op, never success.
|
||||
|
||||
Not a generic editor framework. Two small, deterministic postcondition
|
||||
checks at the boundaries where the defects actually occurred.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["verify_persisted", "assert_persisted", "apply_source_edit", "EditNoOp", "EditPostconditionFailed"]
|
||||
|
||||
|
||||
class EditNoOp(Exception):
|
||||
"""The edit matched zero targets — an explicit, truthful no-op."""
|
||||
|
||||
|
||||
class EditPostconditionFailed(Exception):
|
||||
"""The edit claimed success but the expected postcondition is absent."""
|
||||
|
||||
|
||||
# ── Guard A: durable mutation verified by readback ──────────────────────────
|
||||
|
||||
|
||||
def verify_persisted(path: Path | str, selector, expect) -> bool:
|
||||
"""Reload the durable store from disk and check the expectation against
|
||||
the RELOADED state. `selector(reloaded_doc) -> value`; pass == value
|
||||
equals `expect` (or expect(value) is truthy when callable).
|
||||
|
||||
The reloaded document is parsed as JSON when the file is JSON-shaped;
|
||||
otherwise the raw text is passed to the selector (plain-text stores).
|
||||
|
||||
This is the Tranche 5 defect guard: an in-memory mutation that was
|
||||
never save()d fails here, because the disk state is what gets read.
|
||||
"""
|
||||
raw = Path(path).read_text(encoding="utf-8")
|
||||
try:
|
||||
doc = json.loads(raw)
|
||||
except ValueError:
|
||||
doc = raw
|
||||
value = selector(doc)
|
||||
if callable(expect):
|
||||
return bool(expect(value))
|
||||
return value == expect
|
||||
|
||||
|
||||
def assert_persisted(path: Path | str, selector, expect, *, what: str = "durable state") -> None:
|
||||
"""verify_persisted that raises on mismatch — for use right after a
|
||||
save() at durable boundaries."""
|
||||
if not verify_persisted(path, selector, expect):
|
||||
raise AssertionError(
|
||||
f"persistence verification FAILED for {what}: reloaded state "
|
||||
f"does not match expectation — the mutation may not have been "
|
||||
f"saved (Tranche 5 defect class A)"
|
||||
)
|
||||
|
||||
|
||||
# ── Guard B: source edit with postcondition ─────────────────────────────────
|
||||
|
||||
|
||||
def apply_source_edit(
|
||||
text: str,
|
||||
old: str,
|
||||
new: str,
|
||||
*,
|
||||
expected_count: int | None = None,
|
||||
postcondition: str | None = None,
|
||||
allow_noop: bool = False,
|
||||
) -> str:
|
||||
"""Apply a source edit WITH postcondition verification (Guard B).
|
||||
|
||||
- zero matches: raises EditNoOp (truthful no-op) unless allow_noop=True
|
||||
- expected_count given: match count must equal it
|
||||
- postcondition given: the RESULT must contain it, else
|
||||
EditPostconditionFailed
|
||||
Returns the new text. Never claims success for a change that did not
|
||||
happen."""
|
||||
count = text.count(old)
|
||||
if count == 0:
|
||||
if allow_noop:
|
||||
return text # truthful, explicit no-op — caller records it
|
||||
raise EditNoOp(
|
||||
f"edit matched 0 targets — nothing changed (Tranche 5 defect "
|
||||
f"class B: str.replace silently no-ops on reformatted files)"
|
||||
)
|
||||
if expected_count is not None and count != expected_count:
|
||||
raise EditPostconditionFailed(f"edit matched {count} targets, expected {expected_count}")
|
||||
result = text.replace(old, new)
|
||||
if postcondition and postcondition not in result:
|
||||
raise EditPostconditionFailed(
|
||||
"postcondition text absent from result — edit did not achieve its expected effect"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def apply_source_edit_file(
|
||||
path: Path | str, old: str, new: str, *, expected_count: int | None = None, postcondition: str | None = None
|
||||
) -> int:
|
||||
"""File-level convenience: read, apply_source_edit, write back, then
|
||||
VERIFY BY READBACK (both guards composed). Returns match count."""
|
||||
p = Path(path)
|
||||
text = p.read_text(encoding="utf-8")
|
||||
result = apply_source_edit(text, old, new, expected_count=expected_count, postcondition=postcondition)
|
||||
p.write_text(result, encoding="utf-8")
|
||||
# Guard A composed: the written file must contain the postcondition
|
||||
if postcondition:
|
||||
assert_persisted(p, lambda t: t, lambda t: postcondition in t, what=f"edit of {p.name}")
|
||||
return text.count(old)
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
"""deploy_promotion.py — monotonic promotion authority for Rune deploys.
|
||||
|
||||
SCAFFOLD contract DEPLOY-MONOTONIC-001. Root cause of the 2026-09-13
|
||||
deploy-order race (deploys 7355/7359): deploy_stage.py promoted
|
||||
unconditionally in job-finish order — no comparison against the currently
|
||||
deployed commit, no lock, no truthful outcome for a stale candidate.
|
||||
|
||||
This module owns the PROMOTION decision. The invariant:
|
||||
|
||||
current never regresses along the canonical release branch.
|
||||
|
||||
Ordering authority: GIT ANCESTRY on the canonical release branch
|
||||
(refs/heads/master). Rune's release flow is linear history — every deploy
|
||||
candidate is a master push (tranche commit or its auto-bump descendant).
|
||||
candidate == deployed commit -> NOOP_ALREADY_CURRENT (idempotent)
|
||||
candidate is DESCENDANT of it -> allowed (normal forward deploy)
|
||||
candidate is ANCESTOR of it -> STALE_SUPERSEDED (refuse; truthful)
|
||||
no ancestry relationship -> REFUSED_DIVERGENT (fail closed)
|
||||
deployed commit unknown/unreadable-> REFUSED_UNKNOWN_CURRENT (fail closed)
|
||||
|
||||
Mechanism (smallest that provides the invariant, per brief §4-5):
|
||||
1. promotion-time stale check — ancestry comparison against the commit
|
||||
current ACTUALLY points to at promotion time (not at job start);
|
||||
2. an flock'd critical section around the read-compare-write of
|
||||
current/last-good so two concurrent promotions cannot interleave
|
||||
between the ancestry check and the symlink swap.
|
||||
Workflow-level concurrency is NOT relied on (brief §5): the fence lives at
|
||||
the promotion operation itself.
|
||||
|
||||
Truthful outcomes (brief §8) — build/stage success != promotion success:
|
||||
PROMOTED candidate became current (verified below)
|
||||
NOOP_ALREADY_CURRENT same commit already deployed; nothing mutated
|
||||
STALE_SUPERSEDED a newer commit is already current; nothing mutated
|
||||
REFUSED_DIVERGENT ordering unprovable; fail closed
|
||||
REFUSED_UNKNOWN_CURRENT deployed commit unreadable; fail closed
|
||||
FAILED_* verification/staging failures (in deploy_stage)
|
||||
|
||||
A stale or refused outcome is a NON-ERROR terminal state for the release
|
||||
pipeline (the newer build simply won) but MUST NOT print "PROMOTED" and
|
||||
MUST NOT mutate current/last-good. deploy_stage's exit code and the
|
||||
workflow's post-deploy runtime verification enforce this: the workflow
|
||||
asserts current == candidate after a PROMOTED outcome, so a stale outcome
|
||||
makes the job report honestly rather than masquerade.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
DEPLOY_ROOT = Path("/mnt/workdrive/rune-code")
|
||||
RELEASE_BRANCH = "refs/heads/master"
|
||||
|
||||
OUTCOME_PROMOTED = "PROMOTED"
|
||||
OUTCOME_NOOP = "NOOP_ALREADY_CURRENT"
|
||||
OUTCOME_STALE = "STALE_SUPERSEDED"
|
||||
OUTCOME_DIVERGENT = "REFUSED_DIVERGENT"
|
||||
OUTCOME_UNKNOWN_CURRENT = "REFUSED_UNKNOWN_CURRENT"
|
||||
|
||||
|
||||
# ── pure decision logic (unit-tested; no I/O) ──────────────────────────────
|
||||
|
||||
|
||||
def decide_promotion(candidate_sha: str, deployed_sha: str, ancestry: str) -> str:
|
||||
"""Pure promotion decision from a precomputed ancestry relationship.
|
||||
ancestry is one of: 'same' | 'descendant' | 'ancestor' | 'divergent' |
|
||||
'unknown'. Fail-closed: anything not provably allowed is refused."""
|
||||
if candidate_sha == deployed_sha or ancestry == "same":
|
||||
return OUTCOME_NOOP
|
||||
if ancestry == "descendant":
|
||||
return OUTCOME_PROMOTED
|
||||
if ancestry == "ancestor":
|
||||
return OUTCOME_STALE
|
||||
if ancestry == "divergent":
|
||||
return OUTCOME_DIVERGENT
|
||||
return OUTCOME_UNKNOWN_CURRENT
|
||||
|
||||
|
||||
def ancestry_of(candidate_sha: str, deployed_sha: str, repo: Path, fetch_on_divergent: bool = True) -> str:
|
||||
"""Classify candidate vs deployed using git merge-base --is-ancestor on
|
||||
the canonical release branch's repository. Returns 'unknown' on any git
|
||||
failure (fail-closed callers treat that as refusal).
|
||||
|
||||
Observed live 2026-09-13 (deploy 7449, SHA 535c2909): a DELAYED older
|
||||
candidate's checkout cannot contain commits newer than itself — its
|
||||
local object store lacks the deployed commit entirely, so ancestry
|
||||
classifies 'divergent' and fail-closed refuses. Correct refusal, wrong
|
||||
reason: the candidate IS an ancestor. When the local classification is
|
||||
divergent, fetch origin's canonical branch first (best-effort; the
|
||||
deploy workflow's checkout has credentials configured) and re-classify.
|
||||
If still divergent afterwards, the refusal stands with better
|
||||
evidence."""
|
||||
|
||||
def _is_ancestor(a: str, b: str) -> bool:
|
||||
r = subprocess.run(
|
||||
["git", "-C", str(repo), "-c", f"safe.directory={repo}", "merge-base", "--is-ancestor", a, b],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return r.returncode == 0
|
||||
|
||||
if candidate_sha == deployed_sha:
|
||||
return "same"
|
||||
if _is_ancestor(candidate_sha, deployed_sha):
|
||||
return "ancestor" # candidate is OLDER than what's deployed
|
||||
if _is_ancestor(deployed_sha, candidate_sha):
|
||||
return "descendant" # candidate is NEWER (normal forward deploy)
|
||||
if fetch_on_divergent:
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "-c", f"safe.directory={repo}", "fetch", "origin", "master", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass # best-effort: classification below still fails closed
|
||||
if _is_ancestor(candidate_sha, deployed_sha):
|
||||
return "ancestor"
|
||||
if _is_ancestor(deployed_sha, candidate_sha):
|
||||
return "descendant"
|
||||
return "divergent"
|
||||
|
||||
|
||||
def read_deployed_commit(deploy_root: Path = DEPLOY_ROOT) -> str:
|
||||
"""Read the commit the `current` symlink ACTUALLY points to right now —
|
||||
from the deployed build identity, the deployment authority (never from
|
||||
a stale job-start artifact). Empty string if unreadable."""
|
||||
current = deploy_root / "current"
|
||||
try:
|
||||
target = Path(os.readlink(current))
|
||||
except OSError:
|
||||
return ""
|
||||
identity = target / "src" / "rune" / "_build_identity.json"
|
||||
try:
|
||||
data = json.loads(identity.read_text(encoding="utf-8"))
|
||||
return str(data.get("commit", ""))
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
# ── the fenced promotion operation ──────────────────────────────────────────
|
||||
|
||||
|
||||
def promote(
|
||||
candidate_sha: str,
|
||||
stage: Path,
|
||||
tmp_stage: Path,
|
||||
repo: Path,
|
||||
deploy_root: Path = DEPLOY_ROOT,
|
||||
on_promoted=None,
|
||||
) -> tuple[str, str]:
|
||||
"""Atomically decide + promote under an exclusive lock.
|
||||
|
||||
Returns (outcome, detail). ONLY the PROMOTED outcome mutates
|
||||
current/last-good, and it does so inside the same critical section as
|
||||
the decision — no interleaving window between check and swap.
|
||||
|
||||
Crash-safety ordering (brief §16): the stage rename happens BEFORE the
|
||||
lock (a staged-but-unpromoted tree is inert), the symlink swap is a
|
||||
single rename(2) via a temporary link, so current is never absent and
|
||||
never half-updated. last-good is refreshed BEFORE the swap only from a
|
||||
value that was genuinely current a moment ago.
|
||||
"""
|
||||
deploy_root.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = deploy_root / ".promotion.lock"
|
||||
with open(lock_path, "w") as lock_fd:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
try:
|
||||
deployed = read_deployed_commit(deploy_root)
|
||||
if not deployed:
|
||||
return (OUTCOME_UNKNOWN_CURRENT, "deployed commit unreadable — fail closed")
|
||||
rel = ancestry_of(candidate_sha, deployed, repo)
|
||||
outcome = decide_promotion(candidate_sha, deployed, rel)
|
||||
if outcome != OUTCOME_PROMOTED:
|
||||
return (outcome, f"deployed={deployed[:12]} ancestry={rel}")
|
||||
|
||||
# --- mutate (still inside the lock) ---
|
||||
current = deploy_root / "current"
|
||||
last_good = deploy_root / "last-good"
|
||||
prev_target = os.readlink(current) if current.is_symlink() else ""
|
||||
if prev_target and (deploy_root / "last-good").is_symlink():
|
||||
(deploy_root / "last-good").unlink()
|
||||
if prev_target:
|
||||
_atomic_symlink(deploy_root, "last-good", prev_target)
|
||||
if stage.exists():
|
||||
# re-deploying an already-staged SHA: restage over it
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(stage)
|
||||
os.rename(tmp_stage, stage)
|
||||
_atomic_symlink_target(deploy_root, "current", stage)
|
||||
detail = f"current -> {stage.name}"
|
||||
if on_promoted is not None:
|
||||
# §12C: race-safe convergence — the WINNER refreshes release
|
||||
# history atomically with its promotion, inside the lock.
|
||||
# Failure degrades to STALE (software valid, history lags;
|
||||
# retry by any later deploy converges idempotently).
|
||||
try:
|
||||
on_promoted(candidate_sha)
|
||||
detail += " (+ release-history converged)"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
detail += f" (release-history refresh FAILED: {exc!s:.120} — STALE, retry converges)"
|
||||
return (OUTCOME_PROMOTED, detail)
|
||||
finally:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _atomic_symlink(deploy_root: Path, name: str, target: str) -> None:
|
||||
"""Symlink swap that never leaves `name` absent: write temp link, rename
|
||||
over the final name (rename(2) is atomic on the same filesystem)."""
|
||||
_atomic_symlink_target(deploy_root, name, Path(target))
|
||||
|
||||
|
||||
def _atomic_symlink_target(deploy_root: Path, name: str, target: Path) -> None:
|
||||
tmp = deploy_root / f".{name}.swap-{os.getpid()}"
|
||||
if tmp.is_symlink() or tmp.exists():
|
||||
tmp.unlink()
|
||||
tmp.symlink_to(target)
|
||||
os.replace(tmp, deploy_root / name)
|
||||
+143
-21
@@ -6,6 +6,16 @@ has no embedded multi-line scripts — a YAML block-scalar indentation hazard).
|
||||
|
||||
Prints one of: success | failure | pending
|
||||
Exit code 0 always (the shell decides what to do with the verdict).
|
||||
|
||||
RELEASE-SAFETY HARDENING (2026-09-13, after red-CI promotions 0.66.28/29):
|
||||
the verdict is the AND of two independent legs —
|
||||
1. required-checks context (newest observation for this exact SHA);
|
||||
2. the latest COMPLETED ci.yml@refs/heads/master run for this exact SHA,
|
||||
every job concluded success (branch+tag fan-out resolved by path;
|
||||
jobs envelope unwrapped — no aggregate-commit-status fallback, the
|
||||
exact bug class verify.py hit in W/R-v0.3).
|
||||
Any disagreement, pending, or missing evidence → pending/failure.
|
||||
FAIL-CLOSED: not verified → not deployable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,34 +30,146 @@ if _SCRIPT_DIR not in _sys.path:
|
||||
_sys.path.insert(0, _SCRIPT_DIR)
|
||||
import gitea_auth
|
||||
|
||||
REQUIRED_CONTEXT = "CI Pipeline / required-checks"
|
||||
CI_WORKFLOW_PATH = "ci.yml@refs/heads/master"
|
||||
|
||||
|
||||
# ── pure verdict logic (unit-tested; fixtures from the real incidents) ────
|
||||
|
||||
def required_check_verdict(statuses: list[dict]) -> str:
|
||||
"""Newest non-skipped required-checks observation for the SHA.
|
||||
Missing → pending. Newest wins (stale success never overrides a newer
|
||||
failure, and vice versa)."""
|
||||
candidates = [
|
||||
s for s in statuses
|
||||
if str(s.get("context", "")).startswith(REQUIRED_CONTEXT)
|
||||
and s.get("status") != "skipped"
|
||||
]
|
||||
if not candidates:
|
||||
return "pending"
|
||||
newest = max(candidates, key=lambda s: str(s.get("created_at", "")))
|
||||
return str(newest.get("status", "pending"))
|
||||
|
||||
|
||||
def independent_jobs_verdict(runs: list[dict], jobs_fetch, sha: str = "") -> str:
|
||||
"""Latest COMPLETED ci.yml@refs/heads/master run for the exact SHA:
|
||||
every job must conclude success. Fan-out-safe (tag runs and sibling
|
||||
workflows are excluded by path); the run is resolved by head_sha, never
|
||||
by 'latest branch state'. Missing run → pending. Any non-success
|
||||
conclusion → failure."""
|
||||
mine = [r for r in runs if r.get("path") == CI_WORKFLOW_PATH]
|
||||
if sha:
|
||||
mine = [r for r in mine if str(r.get("head_sha", "")) == sha]
|
||||
completed = [r for r in mine if r.get("status") == "completed"]
|
||||
if not completed:
|
||||
return "pending"
|
||||
latest = max(completed, key=lambda r: str(r.get("created_at", "") or r.get("run_started_at", "")))
|
||||
jobs = jobs_fetch(latest.get("id"))
|
||||
if jobs is None:
|
||||
return "pending" # jobs probe failed → not verified
|
||||
if not jobs:
|
||||
return "pending" # no jobs visible → cannot verify
|
||||
for j in jobs:
|
||||
if j.get("conclusion") != "success":
|
||||
return "failure"
|
||||
return "success"
|
||||
|
||||
|
||||
def combine(a: str, b: str) -> str:
|
||||
"""Fail-closed conjunction: any failure → failure; any pending → pending;
|
||||
success only when BOTH legs are success."""
|
||||
if "failure" in (a, b):
|
||||
return "failure"
|
||||
if "pending" in (a, b):
|
||||
return "pending"
|
||||
return "success"
|
||||
|
||||
|
||||
def qualify(statuses: list[dict], runs: list[dict], jobs_fetch, sha: str = "") -> str:
|
||||
return combine(required_check_verdict(statuses),
|
||||
independent_jobs_verdict(runs, jobs_fetch, sha=sha))
|
||||
|
||||
|
||||
# ── API fetches (thin; injectable in tests) ───────────────────────────────
|
||||
|
||||
def _get_json(url: str, token: str, timeout: int = 30):
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_status(server: str, repo: str, sha: str, token: str) -> list[dict] | None:
|
||||
data = _get_json(f"{server}/api/v1/repos/{repo}/commits/{sha}/status", token)
|
||||
return None if data is None else data.get("statuses", [])
|
||||
|
||||
|
||||
def _fetch_runs(server: str, repo: str, sha: str, token: str,
|
||||
max_pages: int = 4) -> list[dict] | None:
|
||||
"""H-PATCH: paginate the runs list. The repo's scheduled merge-robot
|
||||
runs flood the first page (~50 slots exhaust within hours), pushing
|
||||
older-but-relevant SHAs to later pages — the cause of the observed
|
||||
authority flapping (terminal commit read as pending). Paginate until
|
||||
the sha is found or max_pages exhausted."""
|
||||
out: list[dict] = []
|
||||
for page in range(1, max_pages + 1):
|
||||
data = _get_json(
|
||||
f"{server}/api/v1/repos/{repo}/actions/runs?limit=50&page={page}",
|
||||
token)
|
||||
if data is None:
|
||||
return None if page == 1 else (out or None)
|
||||
runs = data.get("workflow_runs", data if isinstance(data, list) else [])
|
||||
out.extend(r for r in runs if str(r.get("head_sha", "")) == sha)
|
||||
if len(runs) < 50:
|
||||
break # exhausted
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_jobs(server: str, repo: str, run_id, token: str) -> list[dict] | None:
|
||||
data = _get_json(f"{server}/api/v1/repos/{repo}/actions/runs/{run_id}/jobs", token)
|
||||
if data is None:
|
||||
return None
|
||||
# jobs envelope unwrap (this Gitea wraps in {"jobs": [...]}; some
|
||||
# versions return a bare list) — never fall back to aggregate status
|
||||
if isinstance(data, dict):
|
||||
return data.get("jobs", [])
|
||||
return data if isinstance(data, list) else None
|
||||
|
||||
|
||||
# ── CLI ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> int:
|
||||
server = os.environ.get("GITEA_SERVER", "").rstrip("/")
|
||||
repo = os.environ.get("GITEA_REPO", "")
|
||||
sha = os.environ.get("DEPLOY_SHA", "")
|
||||
# T21.4 fix: fall back to the canonical defaults when the caller did
|
||||
# not export GITEA_SERVER/GITEA_REPO (CI always exports them; local
|
||||
# and first-class-tool consumers previously got a silent "pending"
|
||||
# verdict — a wrong answer, not a fail-closed one).
|
||||
# H-PATCH H4: the target sha arrives EXPLICITLY (--sha) from callers
|
||||
# that hold structured operation state. DEPLOY_SHA env remains only
|
||||
# for standalone/workflow backward compat — the canonical path never
|
||||
# depends on ambient env for operation identity.
|
||||
ap = __import__("argparse").ArgumentParser()
|
||||
ap.add_argument("--sha", dest="sha_arg", default=None)
|
||||
args, _unknown = ap.parse_known_args()
|
||||
server = (os.environ.get("GITEA_SERVER", "")
|
||||
or gitea_auth.DEFAULT_SERVER).rstrip("/")
|
||||
repo = os.environ.get("GITEA_REPO", "") or gitea_auth.DEFAULT_REPO
|
||||
sha = args.sha_arg or os.environ.get("DEPLOY_SHA", "")
|
||||
token = gitea_auth.get_token() if gitea_auth.has_token() else ""
|
||||
if not (server and repo and sha and token):
|
||||
print("pending") # missing config → cannot verify → not qualified
|
||||
return 0
|
||||
url = f"{server}/api/v1/repos/{repo}/commits/{sha}/status"
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
data = json.loads(r.read())
|
||||
except (OSError, ValueError) as exc:
|
||||
# transient API/network failure → keep polling (urllib raises
|
||||
# URLError (OSError subclass); bad JSON raises ValueError)
|
||||
print(f"pending (status probe failed: {exc})")
|
||||
|
||||
statuses = _fetch_status(server, repo, sha, token)
|
||||
runs = _fetch_runs(server, repo, sha, token)
|
||||
if statuses is None or runs is None:
|
||||
# transient API/network failure → keep polling
|
||||
print("pending (probe failed)")
|
||||
return 0
|
||||
required = [
|
||||
s
|
||||
for s in data.get("statuses", [])
|
||||
if (str(s.get("context", "")).startswith("CI Pipeline / required-checks") and s.get("status") != "skipped")
|
||||
]
|
||||
if not required:
|
||||
print("pending")
|
||||
else:
|
||||
print(required[0].get("status", "pending"))
|
||||
jobs_fetch = lambda run_id: _fetch_jobs(server, repo, run_id, token) # noqa: E731
|
||||
verdict = qualify(statuses, runs, jobs_fetch, sha=sha)
|
||||
print(verdict)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+137
-24
@@ -3,12 +3,15 @@
|
||||
|
||||
Called by .gitea/workflows/deploy.yml. Stages src/ into
|
||||
/mnt/workdrive/rune-code/<short-sha>, stamps _build_identity.json, verifies
|
||||
the staged tree (import + SHA assert + authority smoke), then ATOMICALLY
|
||||
promotes the `current` symlink and records `last-good` (safe rollback:
|
||||
the previous deployment remains available).
|
||||
the staged tree (import + SHA assert + authority smoke), then promotes
|
||||
through the MONOTONIC promotion authority (deploy_promotion.py): an older
|
||||
candidate finishing after a newer promoted one is fenced out (STALE_SUPERSEDED)
|
||||
and reports truthfully — build/stage success != promotion success.
|
||||
|
||||
Exit 0 = deployed and verified. Non-zero = deployment refused; the previous
|
||||
deployment stays current.
|
||||
Exit 0 = PROMOTED and verified, or NOOP_ALREADY_CURRENT, or a truthful
|
||||
STALE_SUPERSEDED (newer build already current; nothing mutated).
|
||||
Non-zero = deployment refused (divergent/unknown ordering, verification
|
||||
failure); the previous deployment stays current.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,6 +28,92 @@ DEPLOY_ROOT = Path("/mnt/workdrive/rune-code")
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
VENV_PY = "/home/nick/.venvs/memory/bin/python3"
|
||||
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||||
import deploy_promotion # noqa: E402
|
||||
from rune.release_notes import ( # noqa: E402
|
||||
ReleaseNote as _ReleaseNote,
|
||||
auto_initial_note as _auto_note,
|
||||
converged_deployed_sha as _authority_sha,
|
||||
convergence_refresh as _refresh,
|
||||
fetch_release as _fetch_release,
|
||||
publish_release as _publish,
|
||||
sha_from_body as _sha_from_body,
|
||||
summary_from_body as _summary_from_body,
|
||||
)
|
||||
|
||||
|
||||
def publish_out_of_lock(short: str, sha: str, *,
|
||||
deploy_root: Path = DEPLOY_ROOT,
|
||||
scaffold_store: Path | None = None) -> None:
|
||||
"""§13D/§14B: release-history publication AFTER the promotion lock is
|
||||
released. Network I/O never holds the flock.
|
||||
|
||||
§14B INVARIANT: publication does NOT trust the originating job's SHA.
|
||||
The CURRENT deployment authority is re-read immediately before
|
||||
publishing; the note is constructed from CURRENT authority. A stale
|
||||
job whose candidate was superseded converges the NEW authority (or
|
||||
refuses a version it cannot truthfully describe) — it may help
|
||||
converge B, but may never reassert itself as deployed.
|
||||
Failure degrades to STALE (software valid, history lags; retry
|
||||
converges)."""
|
||||
try:
|
||||
server = os.environ.get("GITEA_SERVER", "").rstrip("/")
|
||||
repo = os.environ.get("GITEA_REPO", "")
|
||||
token = os.environ.get("GITEA_TOKEN", "")
|
||||
if not (server and repo and token):
|
||||
return
|
||||
# §14B: re-read CURRENT authority — never trust the job's SHA.
|
||||
authority_sha = _authority_sha(str(deploy_root))
|
||||
if not authority_sha:
|
||||
print("WARNING: release-history convergence skipped: "
|
||||
"deployment authority unreadable — STALE, retry converges")
|
||||
return
|
||||
store = str(scaffold_store or (REPO_ROOT / "store" / "scaffold" / "current.json"))
|
||||
identity = deploy_root / authority_sha[:12] / "src" / "rune" / "_build_identity.json"
|
||||
try:
|
||||
version = json.loads(identity.read_text()).get("version", "")
|
||||
except (OSError, ValueError):
|
||||
version = ""
|
||||
if not version:
|
||||
print("WARNING: release-history convergence skipped: "
|
||||
"no version on current authority — STALE, retry converges")
|
||||
return
|
||||
tag = f"v{version}"
|
||||
existing = _fetch_release(server, repo, token, tag)
|
||||
if existing is None:
|
||||
note = _auto_note(
|
||||
version=version,
|
||||
summary=(f"Rune v{version} — evidence-derived release "
|
||||
f"history (automatic convergence)"),
|
||||
scaffold_store=store,
|
||||
release_sha=authority_sha, deployed_sha=authority_sha,
|
||||
provenance=["automatic convergence publish "
|
||||
"(deploy workflow, authority-derived)"])
|
||||
else:
|
||||
note = _refresh(
|
||||
_ReleaseNote.from_dict({
|
||||
"version": version,
|
||||
"summary": _summary_from_body(existing.get("body", "")),
|
||||
"release_sha": _sha_from_body(existing.get("body", ""), "tag")
|
||||
or authority_sha,
|
||||
"provenance": [],
|
||||
}),
|
||||
deployed_sha=authority_sha, scaffold_store=store,
|
||||
extra_provenance="automatic convergence publish "
|
||||
"(deploy workflow, authority-derived)")
|
||||
# preserve the tag SHA the stored body carries
|
||||
tag_sha = _sha_from_body(existing.get("body", ""), "tag")
|
||||
if tag_sha:
|
||||
note.release_sha = tag_sha
|
||||
result = _publish(server, repo, token, note)
|
||||
print(f"release-history convergence: "
|
||||
f"{result.get('action', 'unknown')} "
|
||||
f"(authority {authority_sha[:12]})")
|
||||
except Exception as conv_exc: # noqa: BLE001 — history STALE, software valid
|
||||
print(f"WARNING: release-history convergence failed: "
|
||||
f"{conv_exc!s:.200} — STALE, retry converges")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# The runner's checkout workspace (act sets GITHUB_WORKSPACE). Never a
|
||||
@@ -119,25 +208,49 @@ print("VERIFIED:", json.dumps(info))
|
||||
shutil.rmtree(tmp_stage, ignore_errors=True)
|
||||
return 1
|
||||
|
||||
# --- promote (atomic) + last-known-good ------------------------------
|
||||
DEPLOY_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
current = DEPLOY_ROOT / "current"
|
||||
if current.is_symlink():
|
||||
last_good = DEPLOY_ROOT / "last-good"
|
||||
if last_good.is_symlink():
|
||||
last_good.unlink()
|
||||
last_good.symlink_to(os.readlink(current))
|
||||
if current.is_symlink() or current.exists():
|
||||
current.unlink()
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage) # re-deploying an already-staged SHA: restage
|
||||
os.rename(tmp_stage, stage)
|
||||
current.symlink_to(stage)
|
||||
print(f"PROMOTED: {short}")
|
||||
print("current ->", os.readlink(current))
|
||||
if (DEPLOY_ROOT / "last-good").is_symlink():
|
||||
print("last-good ->", os.readlink(DEPLOY_ROOT / "last-good"))
|
||||
return 0
|
||||
# --- promote: FENCED through the promotion authority -----------------
|
||||
# SCAFFOLD contract DEPLOY-MONOTONIC-001: promotion is monotonic. The
|
||||
# ancestry check + lock live in deploy_promotion.py; this script only
|
||||
# handles the truthful outcome reporting. A stale candidate must NOT
|
||||
# claim promotion success — build/stage success != promotion success.
|
||||
#
|
||||
# §14C: NO separate convergence-intent ledger. The durable pending-
|
||||
# convergence state IS the deployment authority itself (atomically
|
||||
# swapped `current` symlink + stamped build identity): publication
|
||||
# derives from it, idempotently, outside the lock. The old intent
|
||||
# file had exactly one writer and zero readers, non-atomic writes,
|
||||
# and a bogus recorded_at (SECONDS is a bash builtin, never exported
|
||||
# — always "0"). Deriving from authority gives atomicity, restart
|
||||
# readability, corruption immunity, monotonicity (stale publishers
|
||||
# read CURRENT authority, not their own job) and idempotent retry
|
||||
# for free. REMOVE more complexity than you add.
|
||||
|
||||
outcome, detail = deploy_promotion.promote(
|
||||
sha, DEPLOY_ROOT / short, tmp_stage, repo)
|
||||
|
||||
# §13D: publish release history OUTSIDE the promotion lock (network
|
||||
# I/O must not hold the flock). Only for PROMOTED outcomes.
|
||||
if outcome == deploy_promotion.OUTCOME_PROMOTED:
|
||||
publish_out_of_lock(short, sha)
|
||||
|
||||
if outcome == deploy_promotion.OUTCOME_PROMOTED:
|
||||
print(f"PROMOTED: {short}")
|
||||
print("current ->", os.readlink(DEPLOY_ROOT / "current"))
|
||||
if (DEPLOY_ROOT / "last-good").is_symlink():
|
||||
print("last-good ->", os.readlink(DEPLOY_ROOT / "last-good"))
|
||||
return 0
|
||||
if outcome == deploy_promotion.OUTCOME_NOOP:
|
||||
print(f"NOOP_ALREADY_CURRENT: {short} is the deployed commit")
|
||||
print(detail)
|
||||
return 0
|
||||
if outcome == deploy_promotion.OUTCOME_STALE:
|
||||
print(f"STALE_SUPERSEDED: {short} is older than the deployed commit — promotion refused (monotonic promotion)")
|
||||
print(detail)
|
||||
print("current unchanged:", os.readlink(DEPLOY_ROOT / "current"))
|
||||
return 0 # truthful non-error terminal state: the newer build won
|
||||
# divergent / unknown-current: fail closed
|
||||
print(f"REFUSED ({outcome}): {short} — {detail}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -86,9 +86,12 @@ def main() -> int:
|
||||
if live_state == "open" and rec["tracker_state"] == "closed" and rec["reconciliation_state"] in TERMINAL_STATES:
|
||||
drift.append(f"#{num}: live state open but artifact says closed/{rec['reconciliation_state']}")
|
||||
live_bug = "bug" in [l["name"] for l in li.get("labels", [])]
|
||||
if live_bug and not rec.get("live_defect"):
|
||||
# Same open-only exemption as the artifact-records block above: a
|
||||
# closed issue's labels are historical record; FIXED_VERIFIED issues
|
||||
# legitimately keep the bug label after closure.
|
||||
if live_bug and rec.get("tracker_state") == "open" and not rec.get("live_defect"):
|
||||
drift.append(f"#{num}: live bug label present but artifact records no live defect")
|
||||
if not live_bug and rec.get("live_defect"):
|
||||
if not live_bug and rec.get("live_defect") and rec.get("tracker_state") == "open":
|
||||
drift.append(f"#{num}: artifact records live defect but live bug label absent")
|
||||
|
||||
# Live defects in the artifact must all be OPEN in the tracker
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""install_mimo_hook.py — T21.2 §21.2D (T22: multi-hook): canonical MiMo
|
||||
hook installer.
|
||||
|
||||
Canonical sources: config/mimo-hooks/*.ts
|
||||
Targets: ~/.mimocode/hooks/<same-name>.ts
|
||||
|
||||
Every hook is hash-verified after install; drift is detected in both
|
||||
directions. T22 added builder-bridge.ts to the managed set — it was
|
||||
previously installed by hand, unversioned, which is how it silently rotted
|
||||
(hardcoded ephemeral interpreter). A hook that reaches the builder must be
|
||||
version-controlled and drift-checked like any other authority.
|
||||
|
||||
Usage:
|
||||
python3 scripts/install_mimo_hook.py # sync all managed hooks
|
||||
python3 scripts/install_mimo_hook.py --check # verify only, install nothing
|
||||
"""
|
||||
import hashlib
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
CANONICAL_DIR = REPO / "config" / "mimo-hooks"
|
||||
TARGET_DIR = Path.home() / ".mimocode" / "hooks"
|
||||
|
||||
# The managed set. Adding a file here is what makes a hook version-controlled.
|
||||
MANAGED = ["event-wait-guard.ts", "builder-bridge.ts"]
|
||||
|
||||
|
||||
def sha256(p: Path) -> str:
|
||||
return hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def sync_one(name: str, check_only: bool) -> bool:
|
||||
canonical = CANONICAL_DIR / name
|
||||
target = TARGET_DIR / name
|
||||
if not canonical.is_file():
|
||||
print(f"canonical source missing: {canonical}")
|
||||
return False
|
||||
canon_hash = sha256(canonical)
|
||||
if target.is_file() and sha256(target) == canon_hash:
|
||||
print(f"in-sync: {name} {canon_hash[:12]}")
|
||||
return True
|
||||
if check_only:
|
||||
state = "DRIFT" if target.is_file() else "MISSING"
|
||||
inst = sha256(target)[:12] if target.is_file() else "-"
|
||||
print(f"{state}: {name} canonical={canon_hash[:12]} installed={inst}")
|
||||
return False
|
||||
if target.is_file():
|
||||
print(f"DRIFT: {name} canonical={canon_hash[:12]} installed={sha256(target)[:12]}")
|
||||
else:
|
||||
print(f"hook missing at {target}")
|
||||
TARGET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(canonical, target)
|
||||
if sha256(target) != canon_hash:
|
||||
print(f"INSTALL FAILED: {name} hash mismatch after copy")
|
||||
return False
|
||||
print(f"installed: {name} {canon_hash[:12]}")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
check_only = "--check" in sys.argv
|
||||
ok = True
|
||||
for name in MANAGED:
|
||||
ok = sync_one(name, check_only) and ok
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""install_mimo_tools.py — canonical MiMo tool installer (TTY-repair tranche).
|
||||
|
||||
Canonical sources: config/mimo-tools/{bash.ts,await-operation.ts}
|
||||
Targets: ~/.mimocode/tools/{bash.ts,await-operation.ts}
|
||||
Verifies hashes after install; detects drift in both directions
|
||||
(the parity test reads these hashes).
|
||||
"""
|
||||
import hashlib
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
TOOLS = ["bash.ts", "await-operation.ts"]
|
||||
|
||||
|
||||
def sha256(p: Path) -> str:
|
||||
return hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
rc = 0
|
||||
for name in TOOLS:
|
||||
canon = REPO / "config" / "mimo-tools" / name
|
||||
target = Path.home() / ".mimocode" / "tools" / name
|
||||
if not canon.is_file():
|
||||
print(f"{name}: canonical source missing: {canon}")
|
||||
rc = 1
|
||||
continue
|
||||
canon_hash = sha256(canon)
|
||||
if target.is_file() and sha256(target) == canon_hash:
|
||||
print(f"{name}: in-sync {canon_hash[:12]}")
|
||||
continue
|
||||
drift = f"DRIFT canonical={canon_hash[:12]}"
|
||||
if target.is_file():
|
||||
drift += f" installed={sha256(target)[:12]}"
|
||||
else:
|
||||
drift += " installed=MISSING"
|
||||
print(f"{name}: {drift} — installing")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(canon, target)
|
||||
if sha256(target) != canon_hash:
|
||||
print(f"{name}: INSTALL FAILED (hash mismatch after copy)")
|
||||
rc = 1
|
||||
else:
|
||||
print(f"{name}: installed {canon_hash[:12]}")
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""install_wr_daemon.py — REAL_ASYNC_BRIDGE §6: daemon governance.
|
||||
|
||||
Canonical source lives IN THIS REPO at bridge/daemon/. The active install
|
||||
is ~/.local/share/mimo-wait-resume/. This script is the ONLY supported
|
||||
way to move code between them:
|
||||
|
||||
- copy canonical source → install dir (atomic, per file)
|
||||
- verify sha256 convergence (fail loud on divergence)
|
||||
- optionally restart the daemon via scripts/start_wr_daemon.py
|
||||
|
||||
Never hand-edit the installed copy: edit the repo source, install, restart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
CANONICAL = REPO / "bridge" / "daemon"
|
||||
INSTALL = Path.home() / ".local/share/mimo-wait-resume"
|
||||
FILES = ("daemon.py", "waitstore.py", "verify.py", "completion_file.py")
|
||||
|
||||
|
||||
def sha256(p: Path) -> str:
|
||||
return hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--check", action="store_true", help="verify convergence only; no writes")
|
||||
ap.add_argument("--restart", action="store_true", help="restart the daemon after install")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not CANONICAL.is_dir():
|
||||
print(f"canonical source missing: {CANONICAL}")
|
||||
return 1
|
||||
INSTALL.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
diverged = []
|
||||
for name in FILES:
|
||||
src, dst = CANONICAL / name, INSTALL / name
|
||||
if args.check:
|
||||
if not dst.is_file() or sha256(src) != sha256(dst):
|
||||
diverged.append(name)
|
||||
continue
|
||||
if not dst.is_file() or sha256(src) != sha256(dst):
|
||||
tmp = dst.with_suffix(f".tmp.{name}")
|
||||
shutil.copy2(src, tmp)
|
||||
tmp.replace(dst) # atomic
|
||||
print(f"installed: {name} {sha256(src)[:12]}")
|
||||
else:
|
||||
print(f"unchanged: {name} {sha256(src)[:12]}")
|
||||
|
||||
if args.check:
|
||||
if diverged:
|
||||
print(f"DIVERGED: {', '.join(diverged)} — run install_wr_daemon.py")
|
||||
return 1
|
||||
print("CONVERGED: installed copy == canonical repo source")
|
||||
return 0
|
||||
|
||||
# post-install convergence proof
|
||||
bad = [n for n in FILES if sha256(CANONICAL / n) != sha256(INSTALL / n)]
|
||||
if bad:
|
||||
print(f"INSTALL FAILED convergence: {bad}")
|
||||
return 1
|
||||
print("CONVERGED: installed copy == canonical repo source")
|
||||
|
||||
if args.restart:
|
||||
rc = subprocess.call([sys.executable, str(REPO / "scripts" / "start_wr_daemon.py")])
|
||||
if rc != 0:
|
||||
print("restart failed")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""notify_event_bridge.py — T16 §16C: ci.yml's final job posts the CI
|
||||
completion WAKE to the local W/R event bridge (daemon /process-event).
|
||||
|
||||
The event is a WAKE, never completion truth: the waiting controller
|
||||
(ci_event_wait.py) re-reads the canonical Gitea verdict via
|
||||
deploy_qualify.py after resuming. Best-effort by design — if the bridge
|
||||
is unreachable the waiter's watchdog still recovers truthfully (that is
|
||||
the §16D lost-event contract), so a notify failure must NEVER fail CI.
|
||||
|
||||
REAL_ASYNC_BRIDGE (R3): the completion event is ALSO written directly as
|
||||
a durable completion file (resumes/<operation_id>.done). Delivery no
|
||||
longer depends on the daemon being alive: the waiter blocks on inotify
|
||||
for the file itself (kernel wake, zero latency, zero CPU), and the
|
||||
durable file survives daemon AND waiter restarts (R9). The write is
|
||||
best-effort on the same terms as the POST — never fails CI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
BRIDGE_URL = os.environ.get("RUNE_EVENT_BRIDGE_URL",
|
||||
"http://127.0.0.1:8791")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sha = os.environ.get("CI_SHA", "")
|
||||
result = os.environ.get("CI_RESULT", "") # needs.<job>.result
|
||||
if not sha:
|
||||
print("notify-event-bridge: no CI_SHA — nothing to wake", flush=True)
|
||||
return 0
|
||||
payload = {
|
||||
"operation_id": f"ci-{sha}",
|
||||
"provider": "gitea",
|
||||
"event_type": "ci_completed",
|
||||
"sha": sha,
|
||||
# exit_code is informational only; the wake is not the verdict
|
||||
"exit_code": 0 if result == "success" else 1,
|
||||
"completed_at": time.time(),
|
||||
}
|
||||
# R8 lost-event failure-recovery: operation-scoped fault injection.
|
||||
# A rule keyed by the EXACT operation_id suppresses BOTH normal
|
||||
# delivery channels (durable completion file AND bridge POST), so the
|
||||
# waiter's watchdog/failure-detector path is the only route to truth.
|
||||
# Disabled by default (no rule file => zero behavior change) and
|
||||
# observable: every decision is recorded in the R8 fault log.
|
||||
try:
|
||||
import r8_fault # noqa: E402 — sibling module
|
||||
if r8_fault.should_suppress(payload["operation_id"]):
|
||||
r8_fault.record(payload["operation_id"], "suppressed",
|
||||
{"sha": sha, "exit_code": payload["exit_code"],
|
||||
"source_terminal_at": payload["completed_at"]})
|
||||
print("notify-event-bridge: R8 fault injection — completion "
|
||||
f"event intentionally suppressed for "
|
||||
f"{payload['operation_id']} (no .done file, no POST); "
|
||||
"watchdog authority recovery is the expected path",
|
||||
flush=True)
|
||||
return 0
|
||||
r8_fault.record(payload["operation_id"], "delivered_normally",
|
||||
{"sha": sha, "rule_active": False})
|
||||
except Exception as exc: # noqa: BLE001 — fault seam must never break delivery
|
||||
print(f"notify-event-bridge: R8 fault-seam probe failed ({exc}); "
|
||||
"continuing with normal delivery", flush=True)
|
||||
# R3 durable delivery: write the completion file FIRST — it works even
|
||||
# if the bridge daemon is down, and the atomic rename means any waiter
|
||||
# (inotify-watching or starting later) sees a complete file or none.
|
||||
try:
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import completion_file as cf # noqa: E402 — sibling module
|
||||
written = cf.write_completion(payload["operation_id"], payload)
|
||||
print(f"notify-event-bridge: durable completion file {written}",
|
||||
flush=True)
|
||||
except Exception as exc: # noqa: BLE001 — delivery is best-effort; watchdog recovery covers a lost completion file (§16D contract)
|
||||
print(f"notify-event-bridge: completion-file write failed ({exc}); "
|
||||
f"bridge POST and watchdog recovery still cover delivery",
|
||||
flush=True)
|
||||
for attempt in range(2):
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
BRIDGE_URL + "/process-event",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
resp = json.loads(r.read())
|
||||
print(f"notify-event-bridge: woke bridge "
|
||||
f"(matched={resp.get('matched')})", flush=True)
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001 — wake is best-effort by design; failure is logged and the waiter's watchdog recovery covers a lost wake (§16D contract)
|
||||
if attempt == 0:
|
||||
time.sleep(2)
|
||||
else:
|
||||
print(f"notify-event-bridge: bridge unreachable ({exc}); "
|
||||
f"watchdog recovery covers a lost wake", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""r8_fault.py — REAL_ASYNC_BRIDGE R8: operation-scoped lost-event fault
|
||||
injection (evidence-only harness).
|
||||
|
||||
DESIGN CONTRACT (R8 §3):
|
||||
disabled by default no rule file -> zero behavior change
|
||||
operation-scoped rule keyed by EXACT operation_id
|
||||
reversible delete the rule file / rule entry
|
||||
observable every decision appended to a JSONL log
|
||||
cannot affect other waits rule file checked by operation_id only
|
||||
|
||||
The ONLY consumer is scripts/notify_event_bridge.py: before normal
|
||||
delivery it consults should_suppress(operation_id). Suppression means:
|
||||
- NO durable completion file is written
|
||||
- NO bridge POST (no /process-event wake)
|
||||
- the suppression decision is recorded as R8 fault evidence
|
||||
Nothing else in the stack reads the rule. The daemon, waitstore,
|
||||
authority verification, and completion-file machinery are untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
RUNE_WR_ROOT = os.environ.get("RUNE_WR_ROOT") or os.environ.get("WR_ROOT") or "/mnt/workdrive/wr-bridge"
|
||||
RULES_PATH = Path(RUNE_WR_ROOT) / "r8_suppress_rules.json"
|
||||
FAULT_LOG = Path(RUNE_WR_ROOT) / "r8_fault_log.jsonl"
|
||||
|
||||
REASON = "event intentionally suppressed for R8 lost-event failure-recovery"
|
||||
|
||||
|
||||
def _rules() -> dict:
|
||||
"""Read the rule file. Absent/unreadable => no suppression (fail-open
|
||||
to NORMAL delivery; the fault must never take the healthy path down)."""
|
||||
try:
|
||||
return json.loads(RULES_PATH.read_text())
|
||||
except Exception: # noqa: BLE001 — absent rule file is the default state
|
||||
return {}
|
||||
|
||||
|
||||
def lookup(operation_id: str) -> dict | None:
|
||||
"""Return the active suppression rule for EXACTLY this operation_id,
|
||||
or None. Expired rules are ignored (not deleted — deletion is the
|
||||
operator's explicit disable action)."""
|
||||
spec = _rules()
|
||||
if not spec.get("enabled"):
|
||||
return None
|
||||
rule = spec.get("operations", {}).get(operation_id)
|
||||
if not rule:
|
||||
return None
|
||||
expires = rule.get("expires_at")
|
||||
if expires:
|
||||
try:
|
||||
if time.time() > time.mktime(time.strptime(expires, "%Y-%m-%dT%H:%M:%SZ")) - time.timezone:
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
return rule
|
||||
|
||||
|
||||
def should_suppress(operation_id: str) -> bool:
|
||||
return lookup(operation_id) is not None
|
||||
|
||||
|
||||
def record(operation_id: str, stage: str, extra: dict | None = None) -> None:
|
||||
"""Append a durable fault-evidence line (best-effort, never raises)."""
|
||||
try:
|
||||
entry = {
|
||||
"at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"operation_id": operation_id,
|
||||
"reason": REASON,
|
||||
"stage": stage,
|
||||
**(extra or {}),
|
||||
}
|
||||
FAULT_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
with FAULT_LOG.open("a") as f:
|
||||
f.write(json.dumps(entry, sort_keys=True) + "\n")
|
||||
except OSError as exc: # noqa: BLE001 — evidence logging must never break CI
|
||||
print(f"r8_fault: evidence log write failed: {exc}", file=sys.stderr)
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tranche R1 Wave 0 — whole-project inventory generator.
|
||||
|
||||
Parses every .py file under src/rune/ with the stdlib AST and emits
|
||||
docs/rebase/rebase-inventory.json: files, classes, functions (with signatures and
|
||||
line ranges), imports, and best-effort caller/callee edges (intra-module call
|
||||
graph + cross-module edges resolved through import tables).
|
||||
|
||||
Deterministic and idempotent: same tree in, same JSON out (sorted keys,
|
||||
sorted files). The issue-factory reads this file; nothing else may edit it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
SRC = REPO / "src" / "rune"
|
||||
OUT = REPO / "docs" / "rebase" / "rebase-inventory.json"
|
||||
|
||||
|
||||
def _arg_str(a: ast.arg) -> str:
|
||||
s = a.arg
|
||||
if a.annotation is not None:
|
||||
# Degraded signature (no annotation) is deterministic metadata — never
|
||||
# let one pathological annotation node abort the whole inventory walk.
|
||||
try:
|
||||
s += f": {ast.unparse(a.annotation)}"
|
||||
except (AttributeError, RecursionError, ValueError):
|
||||
pass
|
||||
return s
|
||||
|
||||
|
||||
def _signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
||||
parts = [_arg_str(a) for a in node.args.args]
|
||||
if node.args.vararg:
|
||||
parts.append(f"*{node.args.vararg.arg}")
|
||||
elif node.args.kwonlyargs:
|
||||
parts.append("*")
|
||||
parts += [_arg_str(a) for a in node.args.kwonlyargs]
|
||||
if node.args.kwarg:
|
||||
parts.append(f"**{node.args.kwarg.arg}")
|
||||
ret = ""
|
||||
if node.returns is not None:
|
||||
# Same degraded-metadata contract as _arg_str.
|
||||
try:
|
||||
ret = f" -> {ast.unparse(node.returns)}"
|
||||
except (AttributeError, RecursionError, ValueError):
|
||||
pass
|
||||
return f"({', '.join(parts)}){ret}"
|
||||
|
||||
|
||||
class FileVisitor(ast.NodeVisitor):
|
||||
def __init__(self) -> None:
|
||||
self.functions: list[dict] = []
|
||||
self.classes: list[dict] = []
|
||||
self.imports: list[dict] = []
|
||||
self.calls: list[tuple[int, str]] = [] # (lineno, dotted call name)
|
||||
|
||||
def _fn(self, node, parents: list[str]) -> dict:
|
||||
return {
|
||||
"name": ".".join([*parents, node.name]),
|
||||
"signature": _signature(node),
|
||||
"line_start": node.lineno,
|
||||
"line_end": node.end_lineno or node.lineno,
|
||||
"decorators": [ast.unparse(d) for d in node.decorator_list
|
||||
if isinstance(d, (ast.Name, ast.Attribute, ast.Call))],
|
||||
"async": isinstance(node, ast.AsyncFunctionDef),
|
||||
}
|
||||
|
||||
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
||||
methods = []
|
||||
for child in node.body:
|
||||
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
methods.append(self._fn(child, [node.name]))
|
||||
self.calls.extend(
|
||||
(n.lineno, ast.unparse(n.func))
|
||||
for n in ast.walk(child)
|
||||
if isinstance(n, ast.Call) and not isinstance(n.func, ast.Name if False else type(None))
|
||||
and isinstance(n.func, (ast.Name, ast.Attribute))
|
||||
)
|
||||
self.classes.append({
|
||||
"name": node.name,
|
||||
"bases": [ast.unparse(b) for b in node.bases],
|
||||
"line_start": node.lineno,
|
||||
"line_end": node.end_lineno or node.lineno,
|
||||
"methods": methods,
|
||||
})
|
||||
# visit only non-function children: methods are already captured above,
|
||||
# and a generic_visit would re-fire visit_FunctionDef on them (duplicate
|
||||
# units + duplicate call edges). Nested classes are still visited.
|
||||
for child in node.body:
|
||||
if not isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
self.visit(child)
|
||||
|
||||
def visit_FunctionDef(self, node) -> None:
|
||||
# top-level only reached here (methods captured in ClassDef)
|
||||
self.functions.append(self._fn(node, []))
|
||||
self.calls.extend(
|
||||
(n.lineno, ast.unparse(n.func))
|
||||
for n in ast.walk(node)
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, (ast.Name, ast.Attribute))
|
||||
)
|
||||
# do not descend into nested defs separately; they are folded into parents
|
||||
# (their calls are already captured by the ast.walk above). No generic_visit:
|
||||
# it would re-collect nested defs as top-level functions and duplicate edges.
|
||||
|
||||
visit_AsyncFunctionDef = visit_FunctionDef
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
for a in node.names:
|
||||
self.imports.append({"module": a.name, "as": a.asname, "line": node.lineno})
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
mod = node.module or ""
|
||||
for a in node.names:
|
||||
self.imports.append({"module": f"{mod}.{a.name}" if a.name != "*" else mod,
|
||||
"as": a.asname or a.name, "line": node.lineno,
|
||||
"from": True})
|
||||
|
||||
|
||||
def collect_file(path: Path) -> dict:
|
||||
src_text = path.read_text(encoding="utf-8", errors="replace")
|
||||
tree = ast.parse(src_text)
|
||||
v = FileVisitor()
|
||||
v.visit(tree)
|
||||
|
||||
# module-level calls (outside defs/classes) — walk tree body directly.
|
||||
# SKIP def/class statements: their bodies are walked by the visit_ methods;
|
||||
# walking them here duplicated every call edge inside every top-level function.
|
||||
for stmt in tree.body:
|
||||
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
continue
|
||||
for n in ast.walk(stmt):
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, (ast.Name, ast.Attribute)):
|
||||
v.calls.append((n.lineno, ast.unparse(n.func)))
|
||||
|
||||
defined = {f["name"].split(".")[-1] for f in v.functions}
|
||||
defined |= {m["name"].split(".")[-1] for c in v.classes for m in c["methods"]}
|
||||
defined |= {c["name"] for c in v.classes}
|
||||
|
||||
callees = []
|
||||
for lineno, call in v.calls:
|
||||
root = call.split(".")[0].split("(")[0].strip()
|
||||
callees.append({"line": lineno, "call": call,
|
||||
"internal": root in defined or root == "self"})
|
||||
|
||||
return {
|
||||
"path": str(path.relative_to(REPO)),
|
||||
"lines": len(src_text.splitlines()),
|
||||
"imports": sorted({i["module"] for i in v.imports}),
|
||||
"import_lines": v.imports,
|
||||
"functions": sorted(v.functions, key=lambda f: (f["line_start"], f["name"])),
|
||||
"classes": sorted(v.classes, key=lambda c: c["line_start"]),
|
||||
"calls": callees,
|
||||
}
|
||||
|
||||
|
||||
def cross_module_edges(files: dict[str, dict]) -> dict[str, set[str]]:
|
||||
"""Resolve import tables: module-stem -> set of imported module stems
|
||||
(rune-internal only), so the factory can attribute cross-module edges."""
|
||||
graph = defaultdict(set)
|
||||
for rel, info in files.items():
|
||||
stem = Path(rel).stem
|
||||
for imp in info["imports"]:
|
||||
top = imp.split(".")[0]
|
||||
second = ".".join(imp.split(".")[:2])
|
||||
if top == "rune":
|
||||
graph[stem].add(imp.replace("rune.", "", 1).split(".")[0])
|
||||
elif not imp.startswith("_") and top not in sys.stdlib_module_names:
|
||||
graph[stem].add(f"ext:{top}")
|
||||
return graph
|
||||
|
||||
|
||||
def main() -> None:
|
||||
py_files = sorted(SRC.rglob("*.py"))
|
||||
files: dict[str, dict] = {}
|
||||
for p in py_files:
|
||||
try:
|
||||
info = collect_file(p)
|
||||
except SyntaxError as e: # a file that cannot parse is a finding, not a skip
|
||||
info = {"path": str(p.relative_to(REPO)), "lines": 0, "imports": [],
|
||||
"import_lines": [], "functions": [], "classes": [], "calls": [],
|
||||
"parse_error": f"{type(e).__name__}: {e}"}
|
||||
files[info["path"]] = info
|
||||
|
||||
graph = cross_module_edges(files)
|
||||
total_fns = sum(len(f["functions"]) + sum(len(c["methods"]) for c in f["classes"])
|
||||
for f in files.values())
|
||||
doc = {
|
||||
"_meta": {
|
||||
"generator": "scripts/rebase_inventory.py",
|
||||
"tranche": "R1 language rebase, Wave 0",
|
||||
"note": "Machine-generated. Regenerate with the script; never hand-edit.",
|
||||
"files": len(files),
|
||||
"functions_total": total_fns,
|
||||
"classes_total": sum(len(f["classes"]) for f in files.values()),
|
||||
"lines_total": sum(f["lines"] for f in files.values()),
|
||||
},
|
||||
"import_graph": {k: sorted(v) for k, v in sorted(graph.items())},
|
||||
"files": dict(sorted(files.items())),
|
||||
}
|
||||
OUT.write_text(json.dumps(doc, indent=1, sort_keys=False) + "\n", encoding="utf-8")
|
||||
m = doc["_meta"]
|
||||
print(f"inventory written: {OUT}")
|
||||
print(f"files={m['files']} functions={m['functions_total']} "
|
||||
f"classes={m['classes_total']} lines={m['lines_total']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tranche R1 Wave 0 — per-function issue factory.
|
||||
|
||||
Reads docs/rebase/rebase-inventory.json and emits one issue per unit (top-level
|
||||
function or class method) following tranche R1 §7. Wave-batched, dedup-safe
|
||||
(deterministic title + existing-issue scan before create), rate-limited.
|
||||
|
||||
Modes:
|
||||
plan — coverage report: units per wave, unassigned modules (no silent drops)
|
||||
sample — emit N rendered issues to a file for review (no Gitea call)
|
||||
create — create issues in Gitea for one wave
|
||||
|
||||
Dedup contract: issue titles are deterministic —
|
||||
[rebase w{wave}] {relpath}::{unit}
|
||||
The factory fetches existing issue titles once per run and skips any that
|
||||
already exist, so retries never duplicate (preserve-first Gitea addendum).
|
||||
|
||||
The factory NEVER writes behaviour prose. Current-behaviour paragraphs and
|
||||
the three evidence answers (IMPROVE / FIX / MISSING CONNECTORS) are filled at
|
||||
disposition time by the working agent — the issue carries the mandatory
|
||||
fields and line ranges, not fabricated content.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
INVENTORY = REPO_ROOT / "docs" / "rebase" / "rebase-inventory.json"
|
||||
COVERAGE_OUT = REPO_ROOT / "docs" / "rebase" / "rebase-wave-coverage.json"
|
||||
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "https://gitea.thingscouldgetdicey.online")
|
||||
REPO = "conductor/rune"
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "shared"))
|
||||
from gitea_auth import load_gitea_token # noqa: E402
|
||||
|
||||
TRANCHE_REF = "tranche R1 language rebase (2026-09-29), ~/.config/opencode/tranches/2026-09-29-language-rebase-tranche-R1.md"
|
||||
|
||||
# Wave membership from R1 §4 (initial, measured). Directory entries match by
|
||||
# path prefix; bare stems match the file stem. Modules matching no entry are
|
||||
# reported UNASSIGNED by `plan` — never silently dropped (R1 §9 risk 7).
|
||||
WAVE_PLAN = {
|
||||
2: [ # leaf utilities & canaries
|
||||
"pins", "time_awareness", "message_queue", "event_envelope",
|
||||
"attention_capsule", "complexity_scorer", "token_optimizer", "cache",
|
||||
"prompt_sync", "chat_bubble", "canary_*",
|
||||
],
|
||||
3: [ # memory & persistence
|
||||
"memory_store", "curator", "warm_router", "memory_consolidation",
|
||||
"graph_memory", "sleep_time", "night_mode", "identity_anchors",
|
||||
"query_memory", "memory_env", "warm_neuron_store", "subconscious_pass",
|
||||
"task_persist",
|
||||
],
|
||||
4: [ # provider/model layer
|
||||
"provider_registry", "model_connect", "model_routing", "model_profiles",
|
||||
"provider_budget", "standardized_providers", "model_registry",
|
||||
"connection_pool", "forge_client",
|
||||
],
|
||||
5: [ # orchestration, authority, plugins, skills, governance
|
||||
"orchestration/", "microkernel", "contracts", "build_mode", "plugins/",
|
||||
"skills/", "construction_authority", "construction_authority_cli",
|
||||
"construction_decision", "construction_mode", "verification_construction",
|
||||
"authority_recovery", "spine", "drift_core", "drift_ledger",
|
||||
"drift_observability", "persona_gate", "safety_override", "witness",
|
||||
"behavior", "growth_delta", "claim_audit", "verification_layer",
|
||||
"drift_compression", "drift_cache", "self_improve", "persona_definition",
|
||||
"outcome_ledger", "audit_ledger", "subsystem_health",
|
||||
],
|
||||
6: [ # runtime core & tooling
|
||||
"tool_executor", "tool_protocol", "local_tool_runtime",
|
||||
"capability_broker", "context_broker", "work_manager", "cron_engine",
|
||||
"auto_tasks", "goal", "failure_auto_filer", "hook_engine",
|
||||
"elastic_cognition", "event_bus", "system_registry", "abstraction",
|
||||
"tiered_executor", "progressive_escalation", "tier_manager",
|
||||
"parallel_processor", "shard_manager", "response_adapter",
|
||||
"context_pressure", "auto_context", "cognitive_budgeter",
|
||||
"recall_router", "inference_trace", "task_gate", "salience_gate",
|
||||
"dependency_tracker", "rune_profiler",
|
||||
],
|
||||
7: [ # operator + TUI, last by design
|
||||
"rune_operator", "rune_tui", "tui_validator", "voice_hybrid",
|
||||
"voice_sync", "voicebox", "voice_injector", "message_widgets",
|
||||
"rune_graphic", "rune_spinner", "__main__",
|
||||
],
|
||||
}
|
||||
|
||||
WAVE_NAMES = {
|
||||
2: "leaf utilities & canaries",
|
||||
3: "memory & persistence",
|
||||
4: "provider/model layer",
|
||||
5: "orchestration, authority, plugins, skills, governance",
|
||||
6: "runtime core & tooling",
|
||||
7: "operator + TUI",
|
||||
}
|
||||
|
||||
|
||||
def match_module(relpath: str):
|
||||
"""Return the wave number for a module path, or None if unassigned."""
|
||||
stem = Path(relpath).stem
|
||||
for wave, patterns in WAVE_PLAN.items():
|
||||
for pat in patterns:
|
||||
if pat.endswith("/"):
|
||||
if pat in relpath:
|
||||
return wave
|
||||
elif pat.endswith("*"):
|
||||
if stem.startswith(pat[:-1]):
|
||||
return wave
|
||||
elif stem == pat:
|
||||
return wave
|
||||
return None
|
||||
|
||||
|
||||
def iter_units(inv):
|
||||
"""Yield (relpath, unit_name, entry) for every function and method."""
|
||||
for relpath, mod in sorted(inv["files"].items()):
|
||||
for fn in mod["functions"]:
|
||||
yield relpath, fn["name"], fn
|
||||
for cls in mod["classes"]:
|
||||
for m in cls.get("methods", []):
|
||||
yield relpath, f"{cls['name']}.{m['name']}", m
|
||||
|
||||
|
||||
def internal_edges(relpath: str, entry: dict):
|
||||
"""Collect internal (in-repo) call names and line numbers for a unit."""
|
||||
lines = (entry["line_start"], entry["line_end"])
|
||||
mod = inv_cache["files"][relpath]
|
||||
calls = [c for c in mod.get("calls", [])
|
||||
if lines[0] <= c["line"] <= lines[1] and c.get("internal")]
|
||||
return calls
|
||||
|
||||
|
||||
inv_cache = {}
|
||||
|
||||
|
||||
def issue_title(wave: int, relpath: str, unit: str) -> str:
|
||||
return f"[rebase w{wave}] {relpath}::{unit}"
|
||||
|
||||
|
||||
def render_issue(wave: int, relpath: str, unit: str, entry: dict) -> dict:
|
||||
calls = internal_edges(relpath, entry)
|
||||
call_lines = "\n".join(
|
||||
f" - `{c['call']}` (line {c['line']})" for c in calls[:20]
|
||||
) or " - (no internal call edges in range)"
|
||||
sig = entry.get("signature", "()")
|
||||
body = f"""\
|
||||
|
||||
## Identity (from the Wave 0 inventory — machine-generated)
|
||||
|
||||
- **File**: `{relpath}`
|
||||
- **Unit**: `{unit}`
|
||||
- **Signature**: `def {unit}{sig}` (async: {entry.get('async', False)})
|
||||
- **Line range**: {entry['line_start']}–{entry['line_end']}
|
||||
- **Decorators**: {entry.get('decorators', []) or 'none'}
|
||||
- **Internal call edges** (callers/callees inside the repo, from inventory):
|
||||
{call_lines}
|
||||
|
||||
## Current behaviour
|
||||
|
||||
One honest paragraph from READING THE CODE at the line range above — what it
|
||||
actually does, not what the docstring promises. Write this at disposition
|
||||
time; the factory does not fabricate it.
|
||||
|
||||
## The three questions — each answered WITH EVIDENCE
|
||||
|
||||
1. **IMPROVE?** Can the unit be improved within its scope? Evidence or "no — because …".
|
||||
(Bounded to unit scope; larger findings spawn NEW issues, never expand this one.)
|
||||
2. **FIX?** Is it broken, buggy, or failing its expected outcome? Evidence: a failing
|
||||
test, a reproduced fault, or a code-level defect citation. A "no" needs evidence too.
|
||||
3. **MISSING CONNECTORS?** Are there expected outcomes this unit should connect to but
|
||||
doesn't (unwired events, dead parameters, promised-but-absent calls)? Evidence required.
|
||||
|
||||
A silent skip on any question is a factory bug — answer all three.
|
||||
|
||||
## Language verdict (per tranche R1 §6 rubric — evidence beats instinct)
|
||||
|
||||
Choose exactly one, with the evidence line:
|
||||
- **KEEP-PYTHON** — one sentence of justification.
|
||||
- **RECODE-<lang>** — rubric scores (all 7 dimensions) + benchmark-gate plan: the compiled
|
||||
replacement must beat the Python original on the MEASURED hot path, or the recode is
|
||||
rejected and recorded. No win, no recode.
|
||||
- **MERGE-into:<module>** — name the target and why.
|
||||
- **DEPRECATE** — name the caller that no longer needs it.
|
||||
|
||||
Go is NOT pre-decided. Python is not pre-decided. The rubric decides.
|
||||
|
||||
## Constraint context
|
||||
|
||||
- henxels structural contract: this unit's module ≤ 1,200 lines (or on the debt table's
|
||||
exception list — check before adding lines).
|
||||
- Spine: any module-surface change requires `store/spine/current.json` regen with the code
|
||||
change (`test_manifest_maps_every_module` is the arbiter for new top-level modules).
|
||||
- Debt table: docs/structural-contract.md.
|
||||
|
||||
## Pipeline plan
|
||||
|
||||
1. Branch `issue-<N>` from current master (existing issue-N convention).
|
||||
2. Scaffolding activation for any construction (construction_mode/decision per unit change).
|
||||
3. Research rounds are MANDATORY for any RECODE verdict (research-stage-headless; ≥2
|
||||
live-verified external sources per claim; per-source lessons; kill test per candidate).
|
||||
4. Implementation behind the interop contract (R1 §5): compiled units are standalone
|
||||
binaries — argv in, JSON on stdout, non-zero exit on failure, `--version` mandatory;
|
||||
Python wraps in a thin adapter; store stays the shared truth.
|
||||
5. Local gates: pytest + ruff + spine-store regen (if surface changed) + henxels.
|
||||
6. PR → pipeline-gate + ci + required checks → merge-robot → auto-version.
|
||||
|
||||
## Verification
|
||||
|
||||
- The specific test proving this unit's change: (write at disposition).
|
||||
- The keep-green set it must not break: full suite must stay green; named
|
||||
keep-green tests for this unit's callers.
|
||||
|
||||
## Provenance
|
||||
|
||||
- Tranche: {TRANCHE_REF}
|
||||
- Wave: {wave} ({WAVE_NAMES.get(wave, 'unassigned')})
|
||||
- Inventory: docs/rebase/rebase-inventory.json (regenerate with scripts/rebase_inventory.py — never hand-edit)
|
||||
- Factory: scripts/rebase_issue_factory.py
|
||||
"""
|
||||
return {
|
||||
"title": issue_title(wave, relpath, unit),
|
||||
"body": body,
|
||||
"labels": ["rebase", f"wave-{wave}"],
|
||||
"meta": {
|
||||
"wave": wave,
|
||||
"file": relpath,
|
||||
"unit": unit,
|
||||
"line_start": entry["line_start"],
|
||||
"line_end": entry["line_end"],
|
||||
"async": entry.get("async", False),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def fetch_existing_titles() -> set:
|
||||
"""Fetch all issue titles in the repo (paginated) for dedup."""
|
||||
token = load_gitea_token()
|
||||
titles = set()
|
||||
page = 1
|
||||
while True:
|
||||
url = (f"{GITEA_URL}/api/v1/repos/{REPO}/issues"
|
||||
f"?state=all&type=issues&limit=50&page={page}")
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
batch = json.loads(resp.read())
|
||||
if not batch:
|
||||
break
|
||||
titles.update(i["title"] for i in batch)
|
||||
if len(batch) < 50:
|
||||
break
|
||||
page += 1
|
||||
return titles
|
||||
|
||||
|
||||
def create_issue(entry: dict) -> dict:
|
||||
token = load_gitea_token()
|
||||
url = f"{GITEA_URL}/api/v1/repos/{REPO}/issues"
|
||||
payload = json.dumps({
|
||||
"title": entry["title"],
|
||||
"body": entry["body"],
|
||||
"labels": [], # label NAMES are set via the labels endpoint by auto-label; keep create minimal
|
||||
}).encode()
|
||||
req = urllib.request.Request(url, data=payload, method="POST",
|
||||
headers={"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def cmd_plan(inv):
|
||||
counts = {}
|
||||
unassigned_modules = {}
|
||||
for relpath, unit, entry in iter_units(inv):
|
||||
wave = match_module(relpath)
|
||||
counts[wave] = counts.get(wave, 0) + 1
|
||||
if wave is None:
|
||||
unassigned_modules.setdefault(relpath, inv["files"][relpath]["lines"])
|
||||
report = {
|
||||
"units_per_wave": {str(k if k is not None else "UNASSIGNED"): v
|
||||
for k, v in sorted(counts.items(), key=lambda kv: str(kv[0]))},
|
||||
"unassigned_modules": dict(sorted(unassigned_modules.items(), key=lambda kv: -kv[1])),
|
||||
"unassigned_module_count": len(unassigned_modules),
|
||||
}
|
||||
print(json.dumps(report, indent=1))
|
||||
COVERAGE_OUT.write_text(json.dumps(report, indent=1) + "\n")
|
||||
print(f"\ncoverage written to {COVERAGE_OUT.relative_to(REPO_ROOT)}", file=sys.stderr)
|
||||
return 0 if not unassigned_modules else 1
|
||||
|
||||
|
||||
def cmd_sample(inv, wave, limit, out):
|
||||
emitted = 0
|
||||
records = []
|
||||
for relpath, unit, entry in iter_units(inv):
|
||||
if match_module(relpath) != wave:
|
||||
continue
|
||||
records.append(render_issue(wave, relpath, unit, entry))
|
||||
emitted += 1
|
||||
if emitted >= limit:
|
||||
break
|
||||
Path(out).write_text("\n".join(json.dumps(r) for r in records) + "\n")
|
||||
print(f"sampled {emitted} issues for wave {wave} -> {out}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_create(inv, wave, limit, dry_gap):
|
||||
existing = fetch_existing_titles()
|
||||
created, skipped = 0, 0
|
||||
for relpath, unit, entry in iter_units(inv):
|
||||
if match_module(relpath) != wave:
|
||||
continue
|
||||
title = issue_title(wave, relpath, unit)
|
||||
if title in existing:
|
||||
skipped += 1
|
||||
continue
|
||||
issue = create_issue(render_issue(wave, relpath, unit, entry))
|
||||
created += 1
|
||||
print(f"created #{issue.get('number')}: {title}", file=sys.stderr)
|
||||
if limit and created >= limit:
|
||||
break
|
||||
time.sleep(dry_gap)
|
||||
print(f"\nwave {wave}: created={created} skipped(dedup)={skipped}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("command", choices=["plan", "sample", "create"])
|
||||
ap.add_argument("--wave", type=int, help="wave number (2-7)")
|
||||
ap.add_argument("--limit", type=int, default=0, help="max issues for sample/create (0 = unlimited; sample defaults to 5)")
|
||||
ap.add_argument("--out", default=None, help="sample output file")
|
||||
ap.add_argument("--gap", type=float, default=0.5, help="seconds between creates")
|
||||
args = ap.parse_args()
|
||||
|
||||
inv_cache.update(json.loads(INVENTORY.read_text()))
|
||||
inv = inv_cache
|
||||
|
||||
if args.command == "plan":
|
||||
return cmd_plan(inv)
|
||||
if args.command == "sample":
|
||||
if not args.wave:
|
||||
ap.error("--wave required")
|
||||
out = args.out or f"docs/rebase-factory-sample-w{args.wave}.json"
|
||||
return cmd_sample(inv, args.wave, args.limit or 5, out)
|
||||
if args.command == "create":
|
||||
if not args.wave:
|
||||
ap.error("--wave required")
|
||||
return cmd_create(inv, args.wave, args.limit, args.gap)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""release_history_cli.py — thin workflow entry points for release history.
|
||||
|
||||
SCAFFOLD-TRANCHE-009B §4: the workflows invoke THIS tested module; no
|
||||
note-building logic lives in YAML. Two moments:
|
||||
|
||||
init RELEASE INITIALIZATION — auto-version creates the evidence-
|
||||
derived Gitea release when the version/tag appears.
|
||||
refresh RELEASE CONVERGENCE REFRESH — deploy promotes a later SHA of
|
||||
the same version; the SAME release is updated with the final
|
||||
deployed identity + newly-governed facts. Idempotent.
|
||||
|
||||
Fail-closed boundary (§5/§16): `init` exits non-zero on publication
|
||||
failure — Rune cannot silently create another empty historical release.
|
||||
`refresh` ALSO exits non-zero (visible failure + retry converges), but the
|
||||
workflow marks it non-blocking for deployment validity: valid software is
|
||||
never rolled back over documentation. Failure state is visible either way.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
||||
from rune import release_notes as rn # noqa: E402
|
||||
|
||||
|
||||
def _token() -> str:
|
||||
tok = os.environ.get("GITEA_TOKEN", "")
|
||||
if tok:
|
||||
return tok
|
||||
# local/dev fallback: git credential store (runner uses GITEA_TOKEN)
|
||||
cred = Path.home() / ".git-credentials"
|
||||
if cred.exists():
|
||||
import re
|
||||
m = re.search(r"https://[^:]+:([^@]+)@", cred.read_text())
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_init = sub.add_parser("init", help="release initialization")
|
||||
p_init.add_argument("--version", required=True)
|
||||
p_init.add_argument("--server", required=True)
|
||||
p_init.add_argument("--repo", required=True)
|
||||
p_init.add_argument("--store", default="store/scaffold/current.json")
|
||||
p_init.add_argument("--release-sha", default="")
|
||||
p_init.add_argument("--deployed-sha", default="")
|
||||
|
||||
p_ref = sub.add_parser("refresh", help="convergence refresh")
|
||||
p_ref.add_argument("--version", required=True)
|
||||
p_ref.add_argument("--server", required=True)
|
||||
p_ref.add_argument("--repo", required=True)
|
||||
p_ref.add_argument("--store", default="store/scaffold/current.json")
|
||||
p_ref.add_argument("--release-sha", default="")
|
||||
p_ref.add_argument("--deployed-sha", required=True,
|
||||
help="converged deployed commit (from the deployment "
|
||||
"authority — required, never assumed)")
|
||||
|
||||
args = ap.parse_args(argv)
|
||||
token = _token()
|
||||
if not token:
|
||||
print(json.dumps({"failed": True,
|
||||
"error": "no Gitea token available"}))
|
||||
return 1
|
||||
|
||||
if args.cmd == "init":
|
||||
note = rn.auto_initial_note(
|
||||
version=args.version,
|
||||
summary=f"Rune v{args.version} — evidence-derived release "
|
||||
f"history (automatic initialization)",
|
||||
scaffold_store=args.store,
|
||||
release_sha=args.release_sha,
|
||||
deployed_sha=args.deployed_sha or args.release_sha,
|
||||
)
|
||||
result = rn.publish_release(args.server, args.repo, token, note)
|
||||
print(json.dumps(result))
|
||||
# fail-closed: initialization failure must be visible so Rune cannot
|
||||
# silently create another empty historical release (§5/§16)
|
||||
return 0 if not result.get("failed") else 1
|
||||
|
||||
if args.cmd == "refresh":
|
||||
# §12C RACE-SAFE REVALIDATION: between promotion and refresh, a
|
||||
# NEWER deploy may have superseded this candidate. The deployment
|
||||
# authority is re-read NOW: if current's build identity differs
|
||||
# from the candidate, this job is stale → refuse (preserve T11
|
||||
# invariant: stale jobs never overwrite release history). When
|
||||
# current AGREES, the candidate IS the converged truth.
|
||||
current_link = os.environ.get(
|
||||
"RUNE_DEPLOY_CURRENT", "/mnt/workdrive/rune-code/current")
|
||||
try:
|
||||
deployed_now = rn.converged_deployed_sha(
|
||||
str(Path(current_link).parent))
|
||||
except (OSError, ValueError):
|
||||
deployed_now = ""
|
||||
if args.deployed_sha and deployed_now and \
|
||||
not deployed_now.startswith(args.deployed_sha[:12]):
|
||||
print(json.dumps({
|
||||
"failed": True, "action": "refresh-superseded",
|
||||
"error": f"deployment authority shows {deployed_now[:12]}, "
|
||||
f"candidate {args.deployed_sha[:12]} was superseded "
|
||||
f"— stale refresh refused (history preserved)",
|
||||
"authority": deployed_now[:12],
|
||||
}))
|
||||
return 1 # visible; retry by the WINNING deploy converges
|
||||
|
||||
existing = rn.fetch_release(args.server, args.repo, token,
|
||||
f"v{args.version}")
|
||||
if existing is None:
|
||||
# no release yet for this version — init instead (convergence
|
||||
# may legitimately arrive before/without an init if the
|
||||
# auto-version run failed); idempotent recovery path
|
||||
note = rn.build_from_deployment(
|
||||
version=args.version, deployed_sha=args.deployed_sha,
|
||||
scaffold_store=args.store, release_sha=args.release_sha)
|
||||
else:
|
||||
note = rn.convergence_refresh(
|
||||
rn.ReleaseNote.from_dict({
|
||||
"version": args.version,
|
||||
"summary": _summary_from_body(existing.get("body", "")),
|
||||
"provenance": [],
|
||||
}),
|
||||
deployed_sha=args.deployed_sha, scaffold_store=args.store,
|
||||
extra_provenance="automatic convergence refresh (deploy "
|
||||
"workflow)")
|
||||
# preserve structural fields the stored body carries
|
||||
note.release_sha = _sha_from_body(existing.get("body", ""),
|
||||
"tag") or args.release_sha
|
||||
result = rn.publish_release(args.server, args.repo, token, note)
|
||||
print(json.dumps(result))
|
||||
# visible failure; retry converges (§10). Deployment validity is
|
||||
# NOT rolled back over documentation — the workflow marks this
|
||||
# step appropriately.
|
||||
return 0 if not result.get("failed") else 1
|
||||
return 2
|
||||
|
||||
|
||||
def _summary_from_body(body: str) -> str:
|
||||
"""Delegate to the single authority in rune.release_notes (§14B)."""
|
||||
return rn.summary_from_body(body)
|
||||
|
||||
|
||||
def _sha_from_body(body: str, which: str) -> str:
|
||||
return rn.sha_from_body(body, which)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -181,7 +181,8 @@ def run_stage_with_relay(tree, out_dir: str) -> dict:
|
||||
err_path = os.path.join(out_dir, f"stage_{stage}.mimo.err.log")
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
|
||||
cmd = [
|
||||
|
||||
@@ -38,11 +38,17 @@ def build_context(issue: dict, define_contract: dict) -> dict:
|
||||
# 3. Find matching test files
|
||||
matched_tests = _find_tests(keywords)
|
||||
|
||||
# 4. T21.5 §10: Memory internal-experience port — ONE port of call,
|
||||
# bounded, zero-result = normal (fast path: [] → next step directly).
|
||||
from research_memory_port import memory_port_of_call
|
||||
memory_candidates = memory_port_of_call(issue, keywords)
|
||||
|
||||
return {
|
||||
"keywords": keywords,
|
||||
"umbrella_matches": matched_modules,
|
||||
"candidate_files": matched_files[:15], # bounded
|
||||
"candidate_tests": matched_tests[:10], # bounded
|
||||
"memory_candidates": memory_candidates, # evidence, never authority
|
||||
"define_contract": define_contract,
|
||||
"mimo_needed": _needs_mimo(matched_files, matched_tests),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""research_memory_port.py — T21.5 §10-12: the future-facing Memory port
|
||||
inside the EXISTING INWARD research stage.
|
||||
|
||||
BOUNDARIES (G9/G10/G11):
|
||||
- This is a PORT, not a memory system. No storage is created here; the
|
||||
implementation delegates to a provider callable that future Memory
|
||||
will supply. Tests inject doubles; INWARD never imports Memory.
|
||||
- Zero result is a NORMAL SUCCESSFUL result: the caller continues the
|
||||
normal INWARD flow immediately (no extra model call, no ceremony).
|
||||
- Returned material is EVIDENCE/CANDIDATES only — never an instruction
|
||||
authority. APPLICABILITY (existing stage) governs whether a candidate
|
||||
applies; SYNTHESIS receives only distilled, applicable consequences.
|
||||
- Teachable-moment seam: durable construction evidence can mark an
|
||||
episode as a CANDIDATE for future learning ("may be worth learning",
|
||||
never "always apply"). Substrate/Memory (future) harvest candidates;
|
||||
today the seam only records them via the same provider contract.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
# The provider contract: ONE callable, independently replaceable/mockable.
|
||||
# Signature: query(query_text, keywords, *, limit) -> list[MemoryCandidate]
|
||||
# MemoryCandidate (dict): {source, kind, summary, relevance, episode_ref}
|
||||
# kind ∈ {construction_episode, teachable_moment_candidate,
|
||||
# prior_failure, prior_success}
|
||||
# Future Memory supplies the real implementation; nothing else changes.
|
||||
|
||||
MemoryProvider = Callable[..., list[dict]]
|
||||
|
||||
_DEFAULT_PROVIDER: MemoryProvider | None = None
|
||||
|
||||
|
||||
def set_memory_provider(provider: MemoryProvider | None) -> None:
|
||||
"""Bind the Memory-side implementation (production or test double).
|
||||
None disables the port — INWARD continues without it (fast path)."""
|
||||
global _DEFAULT_PROVIDER
|
||||
_DEFAULT_PROVIDER = provider
|
||||
|
||||
|
||||
def _provider() -> MemoryProvider | None:
|
||||
global _DEFAULT_PROVIDER
|
||||
if _DEFAULT_PROVIDER is not None:
|
||||
return _DEFAULT_PROVIDER
|
||||
env = os.environ.get("RUNE_MEMORY_PORT_PROVIDER")
|
||||
if env:
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("mprov", env)
|
||||
if spec is not None and spec.loader is not None:
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
provider = getattr(mod, "query", None)
|
||||
if provider is not None:
|
||||
_DEFAULT_PROVIDER = provider
|
||||
return _DEFAULT_PROVIDER
|
||||
|
||||
|
||||
MAX_CANDIDATES = 5
|
||||
MIN_RELEVANCE = 0.3
|
||||
|
||||
|
||||
def memory_port_of_call(issue: dict, keywords: list[str],
|
||||
*, provider: MemoryProvider | None = None) -> list[dict]:
|
||||
"""The ONE internal port of call inside INWARD. Bounded retrieval of
|
||||
internal evidence (construction episodes, teachable-moment
|
||||
candidates, prior failures/successes).
|
||||
|
||||
ZERO-RESULT FAST PATH (§11): empty/thin results return [] and the
|
||||
caller continues the next normal INWARD step directly. No extra
|
||||
model call, no fallback reasoning, no synthetic summary, no delay.
|
||||
"""
|
||||
prov = provider or _provider()
|
||||
if prov is None:
|
||||
return [] # port unbound: normal INWARD continues, zero overhead
|
||||
try:
|
||||
query_text = " ".join([
|
||||
str(issue.get("title", "")),
|
||||
str(issue.get("body", ""))[:400],
|
||||
]).strip()
|
||||
raw = prov(query_text, keywords, limit=MAX_CANDIDATES) or []
|
||||
except Exception: # noqa: BLE001 — port failure must never break INWARD
|
||||
return []
|
||||
candidates = [c for c in raw
|
||||
if float(c.get("relevance", 0.0)) >= MIN_RELEVANCE]
|
||||
return candidates[:MAX_CANDIDATES]
|
||||
|
||||
|
||||
def applicability_filter(candidates: list[dict],
|
||||
context_keywords: list[str]) -> tuple[list[dict],
|
||||
list[dict]]:
|
||||
"""Existing APPLICABILITY governs transfer: candidates relevant to
|
||||
the CURRENT context pass; irrelevant ones are rejected WITHOUT
|
||||
contaminating downstream SYNTHESIS. Returns (applicable, rejected)."""
|
||||
applicable, rejected = [], []
|
||||
for c in candidates:
|
||||
text = (str(c.get("summary", "")) + " " +
|
||||
" ".join(str(k) for k in c.get("keywords", []))).lower()
|
||||
overlap = sum(1 for k in context_keywords if k and k.lower() in text)
|
||||
if overlap > 0 or c.get("explicitly_applicable") is True:
|
||||
applicable.append(c)
|
||||
else:
|
||||
rejected.append(c)
|
||||
return applicable, rejected
|
||||
|
||||
|
||||
# ── §12: teachable-moment future seam ──────────────────────────────────────
|
||||
|
||||
CANDIDATE_TRIGGERS = (
|
||||
"completed_release", "capability_completed", "failure_recovered",
|
||||
"rollback_executed", "user_correction", "prediction_observation_mismatch",
|
||||
"procedure_improved",
|
||||
)
|
||||
|
||||
|
||||
def mark_teachable_moment_candidate(
|
||||
episode_ref: str, *, trigger: str, summary: str,
|
||||
emit: Callable[[dict], Any] | None = None) -> dict | None:
|
||||
"""Record durable evidence that an episode MAY be worth future
|
||||
learning — a candidate, never a rule. Uses the existing evidence/
|
||||
event mechanism via `emit` (the caller's channel: journal, event
|
||||
bridge, ledger history). No new canonical store is created here;
|
||||
future Substrate/Memory harvests candidates through the port."""
|
||||
if trigger not in CANDIDATE_TRIGGERS:
|
||||
raise ValueError(
|
||||
f"unknown trigger {trigger!r} — must be one of "
|
||||
f"{CANDIDATE_TRIGGERS}")
|
||||
record = {
|
||||
"candidate": "teachable_moment",
|
||||
"episode_ref": episode_ref,
|
||||
"trigger": trigger,
|
||||
"summary": summary[:200],
|
||||
"status": "CANDIDATE", # never authority, never auto-applied
|
||||
"note": "may be worth future learning; APPLICABILITY governs "
|
||||
"any future transfer",
|
||||
}
|
||||
if emit is not None:
|
||||
emit(record)
|
||||
return record
|
||||
@@ -793,7 +793,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp)
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_OUTWARD.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_OUTWARD.mimo.err.log")
|
||||
@@ -933,7 +934,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp)
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_APPLICABILITY.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_APPLICABILITY.mimo.err.log")
|
||||
@@ -1137,7 +1139,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp)
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_ADVERSARIAL.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_ADVERSARIAL.mimo.err.log")
|
||||
@@ -1345,7 +1348,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp).replace("{n}", str(self.issue))
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_INNOVATION.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_INNOVATION.mimo.err.log")
|
||||
@@ -1534,7 +1538,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp)
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_SYNTHESIS.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_SYNTHESIS.mimo.err.log")
|
||||
@@ -1671,7 +1676,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp).replace("{n}", str(self.issue))
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_QUALIFY.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_QUALIFY.mimo.err.log")
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""rollback_generation.py — T21.4 §21.4F: the governed rollback API.
|
||||
|
||||
rollback_generation(target_generation, expected_current, reason)
|
||||
|
||||
Prior tranches proved rollback by recreating deployment primitives by hand
|
||||
(ln -sfn against the live deploy root). That is operator work, not governed
|
||||
machinery — it bypasses lock, eligibility, health, and history. This module
|
||||
is the single canonical rollback operation, built ON the proven deploy
|
||||
machinery (deploy_promotion's lock + atomic swap + build-identity authority),
|
||||
not beside it.
|
||||
|
||||
The promotion authority (deploy_promotion.py) is deliberately MONOTONIC: an
|
||||
ancestor promotion is refused as STALE_SUPERSEDED. Regression is therefore
|
||||
impossible through promotion — and legitimate only through THIS operation,
|
||||
which owns its own obligations:
|
||||
|
||||
1. deployment lock — the SAME .promotion.lock as promote (serialize)
|
||||
2. current verification — build-identity readback must equal expected_current
|
||||
3. target existence — generation dir present, build identity readable
|
||||
4. eligibility — target commit is ancestor-or-equal of current
|
||||
(regression only; forward = promotion's job;
|
||||
divergent/unknown refused)
|
||||
5. atomic switch — rename(2) symlink swap, never absent/half-updated
|
||||
6. runtime handoff — Rune executes from current/src at process start;
|
||||
handoff = identity a fresh launch would run
|
||||
7. runtime verification — fresh-launch identity must equal target
|
||||
8. health qualification — import smoke against target generation src
|
||||
9. durable history — JSONL rollback events, appended inside the lock
|
||||
10. failure recovery — health failure restores prior current (where safe)
|
||||
11. coherent last-good — last-good := the generation that WAS current
|
||||
(same invariant promotion maintains)
|
||||
|
||||
Truthful outcomes:
|
||||
ROLLED_BACK target active, health green, history recorded
|
||||
NOOP_ALREADY_CURRENT target already the deployed generation
|
||||
ROLLED_BACK_REVERTED switched, health red, prior current restored
|
||||
REFUSED_UNKNOWN_CURRENT deployed identity unreadable (fail closed)
|
||||
REFUSED_EXPECTED_MISMATCH current != expected_current (stale caller)
|
||||
REFUSED_TARGET_MISSING target generation dir absent
|
||||
REFUSED_TARGET_UNREADABLE target build identity unreadable
|
||||
REFUSED_NOT_ELIGIBLE divergent, newer, or non-ancestor target
|
||||
REFUSED_NO_REASON empty reason (accountability is mandatory)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
if str(_SCRIPT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import deploy_promotion # proven machinery: lock path, ancestry, swaps
|
||||
|
||||
DEPLOY_ROOT = deploy_promotion.DEPLOY_ROOT
|
||||
HARNESS_ROOT = _SCRIPT_DIR.parent
|
||||
HISTORY_PATH = HARNESS_ROOT / "store" / "scaffold" / "rollback_history.jsonl"
|
||||
|
||||
ROLLED_BACK = "ROLLED_BACK"
|
||||
NOOP = "NOOP_ALREADY_CURRENT"
|
||||
ROLLED_BACK_REVERTED = "ROLLED_BACK_REVERTED"
|
||||
REFUSED_UNKNOWN_CURRENT = "REFUSED_UNKNOWN_CURRENT"
|
||||
REFUSED_EXPECTED_MISMATCH = "REFUSED_EXPECTED_MISMATCH"
|
||||
REFUSED_TARGET_MISSING = "REFUSED_TARGET_MISSING"
|
||||
REFUSED_TARGET_UNREADABLE = "REFUSED_TARGET_UNREADABLE"
|
||||
REFUSED_NOT_ELIGIBLE = "REFUSED_NOT_ELIGIBLE"
|
||||
REFUSED_NO_REASON = "REFUSED_NO_REASON"
|
||||
|
||||
|
||||
def _identity(deploy_root: Path, generation: str) -> str:
|
||||
"""Authoritative commit for a generation dir (build identity, not name)."""
|
||||
gen_dir = deploy_root / generation
|
||||
identity = gen_dir / "src" / "rune" / "_build_identity.json"
|
||||
try:
|
||||
data = json.loads(identity.read_text(encoding="utf-8"))
|
||||
return str(data.get("commit", ""))
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def _health(deploy_root: Path, generation: str, timeout: int = 60) -> tuple[bool, str]:
|
||||
"""Health qualification for a generation: build identity readable AND
|
||||
`import rune` succeeds from that generation's src tree."""
|
||||
gen_dir = deploy_root / generation
|
||||
commit = _identity(deploy_root, generation)
|
||||
if not commit:
|
||||
return False, "build identity unreadable"
|
||||
src = gen_dir / "src"
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", "import rune; import sys; "
|
||||
"sys.stdout.write(getattr(rune, '__version__', 'ok'))"],
|
||||
env={"PYTHONPATH": str(src), "PATH": "/usr/bin:/bin",
|
||||
"HOME": str(gen_dir)},
|
||||
capture_output=True, text=True, timeout=timeout, cwd=str(gen_dir),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"import smoke timed out after {timeout}s"
|
||||
if proc.returncode != 0:
|
||||
return False, f"import smoke failed: {(proc.stderr or '')[-200:]}"
|
||||
return True, f"identity={commit[:12]} import_smoke=ok ({proc.stdout.strip()[:40]})"
|
||||
|
||||
|
||||
def _append_history(record: dict, history_path: Path = HISTORY_PATH) -> None:
|
||||
history_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(history_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def _swap(deploy_root: Path, name: str, target: Path) -> None:
|
||||
deploy_promotion._atomic_symlink_target(deploy_root, name, target)
|
||||
|
||||
|
||||
def rollback_generation(
|
||||
target_generation: str,
|
||||
expected_current: str,
|
||||
reason: str,
|
||||
*,
|
||||
deploy_root: Path = DEPLOY_ROOT,
|
||||
repo: Path = HARNESS_ROOT,
|
||||
history_path: Path = HISTORY_PATH,
|
||||
) -> tuple[str, dict]:
|
||||
"""The governed rollback operation. Returns (outcome, detail).
|
||||
|
||||
Refusals happen BEFORE any mutation of current/last-good. The lock is
|
||||
shared with promote() so rollback and promotion can never interleave.
|
||||
"""
|
||||
if not reason or not reason.strip():
|
||||
return REFUSED_NO_REASON, {"error": "rollback requires a reason"}
|
||||
|
||||
lock_path = deploy_root / ".promotion.lock"
|
||||
# The lock file may be owned by the CI runner (promotion side opens it
|
||||
# for writing). flock needs only an open fd, never write permission, so
|
||||
# open read-only when write is denied — same file, same fence.
|
||||
try:
|
||||
lock_fd = open(lock_path, "a+")
|
||||
except PermissionError:
|
||||
lock_fd = open(lock_path) if lock_path.exists() else \
|
||||
open(lock_path, "a+")
|
||||
with lock_fd:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
try:
|
||||
current = deploy_root / "current"
|
||||
last_good = deploy_root / "last-good"
|
||||
detail: dict = {"target": target_generation,
|
||||
"expected_current": expected_current,
|
||||
"reason": reason[:200],
|
||||
"at": time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime())}
|
||||
|
||||
# 2. current verification — identity authority, never the name
|
||||
current_generation = ""
|
||||
try:
|
||||
current_generation = Path(os_readlink(current)).name
|
||||
except OSError:
|
||||
pass
|
||||
deployed_commit = deploy_promotion.read_deployed_commit(deploy_root)
|
||||
if not deployed_commit:
|
||||
return REFUSED_UNKNOWN_CURRENT, {
|
||||
"error": "deployed commit unreadable — fail closed",
|
||||
**detail}
|
||||
detail["current_generation"] = current_generation
|
||||
detail["current_commit"] = deployed_commit
|
||||
if expected_current and expected_current != deployed_commit:
|
||||
detail["error"] = (
|
||||
f"current is {deployed_commit[:12]}, "
|
||||
f"caller expected {expected_current[:12]} — refused "
|
||||
"before mutation")
|
||||
return REFUSED_EXPECTED_MISMATCH, detail
|
||||
|
||||
# 3. target existence + identity
|
||||
target_dir = deploy_root / target_generation
|
||||
if not target_dir.is_dir():
|
||||
detail["error"] = f"generation dir {target_generation} absent"
|
||||
return REFUSED_TARGET_MISSING, detail
|
||||
target_commit = _identity(deploy_root, target_generation)
|
||||
if not target_commit:
|
||||
detail["error"] = (
|
||||
f"generation {target_generation} build identity unreadable")
|
||||
return REFUSED_TARGET_UNREADABLE, detail
|
||||
detail["target_commit"] = target_commit
|
||||
|
||||
if target_commit == deployed_commit:
|
||||
detail["note"] = "target already the deployed generation"
|
||||
return NOOP, detail
|
||||
|
||||
# 4. eligibility — ancestor-or-equal only (regression)
|
||||
rel = deploy_promotion.ancestry_of(target_commit, deployed_commit,
|
||||
repo)
|
||||
if rel != "ancestor":
|
||||
detail["error"] = (
|
||||
f"target commit ancestry={rel} — only ancestors are "
|
||||
"rollback-eligible (forward movement belongs to "
|
||||
"deploy_promotion)")
|
||||
return REFUSED_NOT_ELIGIBLE, detail
|
||||
|
||||
# 5. atomic switch (inside lock), last-good := previous current
|
||||
if last_good.is_symlink():
|
||||
last_good.unlink()
|
||||
_swap(deploy_root, "last-good", Path(os_readlink(current)))
|
||||
_swap(deploy_root, "current", target_dir)
|
||||
detail["switched_to"] = target_generation
|
||||
|
||||
# 7+8. runtime verification + health qualification
|
||||
post_commit = deploy_promotion.read_deployed_commit(deploy_root)
|
||||
health_ok, health_detail = _health(deploy_root, target_generation)
|
||||
detail["post_switch_identity"] = post_commit
|
||||
detail["health"] = health_detail
|
||||
|
||||
record = {"at": detail["at"], "operation": "rollback_generation",
|
||||
**detail}
|
||||
if post_commit != target_commit or not health_ok:
|
||||
# 10. failure recovery — restore prior current (safe: we
|
||||
# hold the lock and the prior generation is intact)
|
||||
prev = Path(os_readlink(last_good)) if last_good.is_symlink() \
|
||||
else None
|
||||
if prev is not None:
|
||||
_swap(deploy_root, "current", prev)
|
||||
detail["reverted_to"] = (Path(str(os_readlink(current))).name
|
||||
if current.is_symlink() else "?")
|
||||
detail["outcome"] = ROLLED_BACK_REVERTED
|
||||
record["outcome"] = ROLLED_BACK_REVERTED
|
||||
_append_history(record, history_path)
|
||||
return ROLLED_BACK_REVERTED, detail
|
||||
|
||||
record["outcome"] = ROLLED_BACK
|
||||
_append_history(record, history_path)
|
||||
detail["outcome"] = ROLLED_BACK
|
||||
return ROLLED_BACK, detail
|
||||
finally:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def os_readlink(p: Path) -> str:
|
||||
return str(Path(str(p)).readlink()) if hasattr(Path, "readlink") \
|
||||
else str(Path(str(p)).readlink())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("target_generation")
|
||||
ap.add_argument("--expected-current", required=True,
|
||||
help="commit the caller believes is currently deployed")
|
||||
ap.add_argument("--reason", required=True)
|
||||
args = ap.parse_args()
|
||||
outcome, detail = rollback_generation(args.target_generation,
|
||||
args.expected_current, args.reason)
|
||||
print(json.dumps({"outcome": outcome, **detail}, indent=2, sort_keys=True))
|
||||
return 0 if outcome in (ROLLED_BACK, NOOP) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -18,6 +18,13 @@ from __future__ import annotations
|
||||
import re
|
||||
import sys
|
||||
|
||||
# T20 §20B: push-convergence synthesizes PR titles as "<kind>: converged
|
||||
# release (push-to-master bump)" where kind comes from
|
||||
# version_convergence.highest_warranted_bump. The title text must carry
|
||||
# that decision — "minor:" was re-classified as patch here, downgrading
|
||||
# every feature tranche (observed: feat ranges bumped 0.66.77-83 not 0.67.0).
|
||||
MINOR_PREFIX_RE = re.compile(r"^\s*minor\b", re.IGNORECASE)
|
||||
|
||||
BREAKING_RE = re.compile(r"^\s*\w+(\([^)]*\))?!:")
|
||||
FEAT_RE = re.compile(r"^\s*feat(\([^)]*\))?")
|
||||
|
||||
@@ -25,6 +32,8 @@ FEAT_RE = re.compile(r"^\s*feat(\([^)]*\))?")
|
||||
def next_version(version: str, title: str) -> str:
|
||||
if BREAKING_RE.match(title) or "BREAKING CHANGE" in title:
|
||||
kind = "major"
|
||||
elif MINOR_PREFIX_RE.match(title):
|
||||
kind = "minor" # synthesized convergence title carries the kind
|
||||
elif FEAT_RE.match(title):
|
||||
kind = "minor"
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""start_wr_daemon.py — T21.5 §7: THE canonical daemon restart launcher.
|
||||
|
||||
T21.4 live finding: `python3 daemon.py` defaults WR_ROOT to the daemon's
|
||||
install dir, so a restart silently opened a second event store. This
|
||||
launcher resolves the root ONCE through scripts/wr_root_resolver.py (the
|
||||
single authority) and passes it explicitly — nobody has to remember to
|
||||
export WR_ROOT.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import wr_root_resolver as resolver # noqa: E402
|
||||
|
||||
DAEMON_CODE_CANDIDATES = (
|
||||
"/home/nick/.local/share/mimo-wait-resume/daemon.py",
|
||||
)
|
||||
|
||||
|
||||
def find_daemon_code() -> Path:
|
||||
for cand in DAEMON_CODE_CANDIDATES:
|
||||
p = Path(cand)
|
||||
if p.is_file():
|
||||
return p
|
||||
raise RuntimeError(f"daemon.py not found in {DAEMON_CODE_CANDIDATES}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = resolver.resolve_root()
|
||||
daemon = find_daemon_code()
|
||||
env = dict(os.environ,
|
||||
WR_ROOT=str(root), # daemon data root
|
||||
RUNE_WR_ROOT=str(root), # adapter-side authority
|
||||
RUNE_WR_CODE=str(daemon.parent))
|
||||
port = sys.argv[1] if len(sys.argv) > 1 else "8791"
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(daemon), port],
|
||||
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
# bounded startup probe (process-local, not an external wait)
|
||||
for _ in range(20):
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
f"http://127.0.0.1:{port}/health", timeout=1) as r:
|
||||
if r.status == 200:
|
||||
print(f"wr daemon up: pid={proc.pid} root={root} "
|
||||
f"port={port}")
|
||||
return 0
|
||||
except OSError:
|
||||
time.sleep(0.25)
|
||||
print(f"wr daemon did not become healthy (pid={proc.pid})", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env python3
|
||||
"""structural_coverage_audit.py — T21.4 §21.4G: Rune-wide meaningful
|
||||
structural coverage audit.
|
||||
|
||||
Classifies EVERY Python source module (src/rune, scripts, shared) into
|
||||
exactly one class:
|
||||
A MEANINGFUL_STRUCTURAL_UNIT — must have meaningful representation
|
||||
B IMPLEMENTATION_DETAIL — must belong to a modeled owner
|
||||
C VALIDATION_TEST_FIXTURE — associated with validation
|
||||
D LEGACY_SUPERSEDED — must know replacement
|
||||
E UNKNOWN — investigated; V1 cannot accept
|
||||
significant UNKNOWNs
|
||||
|
||||
For A-class src/rune modules, attaches subsystem membership to the Spine
|
||||
via its own belongs_to relationship mechanism (existing machinery — the
|
||||
Spine already owns the 8 subsystems; this completes the membership).
|
||||
|
||||
Writes store/scaffold/structural_coverage_t214.json + prints measures.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
# ── A-class: src/rune meaningful units → subsystem ─────────────────────────
|
||||
SRC_A = {
|
||||
# governance / construction / structural
|
||||
"scaffold": "governance", "structural_store": "governance",
|
||||
"structural_model": "governance", "structural_migration": "governance",
|
||||
"spine": "governance", "construction_activation": "governance",
|
||||
"construction_commands": "governance", "construction_decision": "governance",
|
||||
"construction_depth": "governance", "construction_intent": "governance",
|
||||
"construction_interface": "governance", "construction_mode": "governance",
|
||||
"construction_pattern": "governance",
|
||||
"construction_patterns_builtin": "governance",
|
||||
"construction_projection": "governance", "stage_guards": "governance",
|
||||
"capability_fit": "governance", "toolchain": "governance",
|
||||
"layout_contract": "governance", "project_registry": "governance",
|
||||
"programme_gates": "governance", "programme_continuation": "governance",
|
||||
"build_basis": "governance", "iteration": "governance",
|
||||
"event_wait": "governance", "wait_guard": "governance",
|
||||
"failure_recovery": "governance", "failure_auto_filer": "governance",
|
||||
"forge": "governance", "forge_client": "governance",
|
||||
"release_notes": "governance",
|
||||
# memory (existing machinery — not Memory V1)
|
||||
"memory_store": "memory", "memory_selector": "memory",
|
||||
"memory_consolidation": "memory", "memory_env": "memory",
|
||||
"shard_manager": "memory", "tier_manager": "memory",
|
||||
"graph_memory": "memory",
|
||||
"curator": "memory", "sleep_time": "memory",
|
||||
"subconscious_pass": "memory", "warm_neuron_store": "memory",
|
||||
"warm_router": "memory", "abstraction": "memory",
|
||||
"auto_context": "memory", "cache": "memory",
|
||||
"connection_pool": "memory",
|
||||
# growth / governed adaptation / ledgers
|
||||
"drift_core": "growth", "drift_ledger": "growth",
|
||||
"drift_cache": "growth", "drift_compression": "growth",
|
||||
"drift_observability": "growth", "drift_resume": "growth",
|
||||
"growth_delta": "growth", "self_improve": "growth",
|
||||
"night_mode": "growth", "behavior": "growth",
|
||||
"outcome_ledger": "growth", "claim_audit": "growth",
|
||||
# identity / persona / voice
|
||||
"identity_anchors": "identity", "persona_definition": "identity",
|
||||
"persona_gate": "identity", "voice_hybrid": "identity",
|
||||
"voice_sync": "identity", "safety_override": "identity",
|
||||
"prompt_sync": "identity",
|
||||
# inference / routing / providers
|
||||
"adaptive_router": "inference", "route_risk_flags": "inference",
|
||||
"model_routing": "inference", "model_connect": "inference",
|
||||
"model_profiles": "inference", "model_registry": "inference",
|
||||
"provider_registry": "inference", "provider_context": "inference",
|
||||
"provider_budget": "inference", "standardized_providers": "inference",
|
||||
"openai_compat_transport": "inference",
|
||||
"local_tool_runtime": "inference", "response_adapter": "inference",
|
||||
"inference_trace": "inference", "evidence_probe": "inference",
|
||||
"recall_router": "inference", "recall_uplift": "inference",
|
||||
# orchestration / cognition / continuation / ledgers
|
||||
"elastic_cognition": "orchestration", "tiered_executor": "orchestration",
|
||||
"attention_capsule": "orchestration", "witness": "orchestration",
|
||||
"event_bus": "orchestration", "microkernel": "orchestration",
|
||||
"capability_broker": "orchestration", "context_broker": "orchestration",
|
||||
"work_manager": "orchestration", "task_persist": "orchestration",
|
||||
"goal": "orchestration", "cron_engine": "orchestration",
|
||||
"hook_engine": "orchestration", "subsystem_health": "orchestration",
|
||||
"verification_layer": "orchestration", "build_mode": "orchestration",
|
||||
"builder_bridge": "orchestration", "testing_mode": "orchestration",
|
||||
"rune_operator": "orchestration", "audit_ledger": "capability",
|
||||
"capability_ledger": "capability", "behaviour_ledger": "capability",
|
||||
"impact_preview": "governance", "graph_projection": "governance",
|
||||
"tool_executor": "orchestration", "tool_protocol": "orchestration",
|
||||
# perception / TUI boundary
|
||||
"rune_tui": "perception", "__main__": "perception",
|
||||
"voicebox": "perception",
|
||||
}
|
||||
|
||||
# B-class: src/rune implementation details → owning meaningful unit
|
||||
SRC_B = {
|
||||
"event_envelope": "event_bus",
|
||||
"cognitive_budgeter": "elastic_cognition",
|
||||
"salience_gate": "elastic_cognition",
|
||||
"rune_profiler": "elastic_cognition",
|
||||
"progressive_escalation": "tiered_executor",
|
||||
"parallel_processor": "tiered_executor",
|
||||
"context_pressure": "context_broker",
|
||||
"token_optimizer": "context_broker",
|
||||
"message_queue": "work_manager",
|
||||
"auto_tasks": "work_manager",
|
||||
"task_gate": "task_persist",
|
||||
"pins": "goal",
|
||||
"chat_bubble": "rune_tui",
|
||||
"rune_spinner": "rune_tui",
|
||||
"rune_graphic": "rune_tui",
|
||||
"message_widgets": "rune_tui",
|
||||
"tui_validator": "rune_tui",
|
||||
"tui_backlog": "rune_tui",
|
||||
"builder_bridge_cli": "builder_bridge",
|
||||
"voice_injector": "voice_hybrid",
|
||||
"intent_continuity": "programme_continuation",
|
||||
"time_awareness": "rune_operator",
|
||||
"contracts": "verification_layer",
|
||||
"system_registry": "governance-ops",
|
||||
"dependency_tracker": "ops-harness",
|
||||
}
|
||||
|
||||
# C-class: validation/test fixtures (canaries + corpus)
|
||||
SRC_C = {
|
||||
"canary_cognitive_strata", "canary_context_broker",
|
||||
"canary_conversation_state", "canary_full_system",
|
||||
"canary_gitea_automation", "canary_gitea_delegation",
|
||||
"canary_gitea_pipeline", "canary_memory_synthesis", "canary_native",
|
||||
"canary_orchestration_foundation", "canary_p0_04", "canary_p0_05",
|
||||
"canary_p0_07", "canary_p1_01", "canary_p1_02", "canary_p1_03",
|
||||
"canary_p1_06", "canary_p1_07", "canary_query_memory",
|
||||
"canary_resource_authority", "canary_schema_migration",
|
||||
"canary_surface_parity", "canary_tui_drift", "canary_unbounded",
|
||||
"wait_guard_corpus", "contracts",
|
||||
}
|
||||
|
||||
# scripts/ A-class (deployment/CI/release/research/governance machinery)
|
||||
SCRIPTS_A = {
|
||||
"auto_bump_pr", "capture_failure", "carry_forward_check",
|
||||
"check_dep_lock", "check_discharge_contract", "check_version_surfaces",
|
||||
"ci_event_wait", "construction_guards", "create_release",
|
||||
"deploy_promotion", "deploy_qualify", "deploy_stage",
|
||||
"rollback_generation", "file_drift_issue", "gitea_auth",
|
||||
"gitea_reconciliation_check", "install_mimo_hook", "lint_ratchet",
|
||||
"merge_robot", "mutation_boundary", "notify_event_bridge",
|
||||
"process_experience", "publish_source_ledger", "research_artifact",
|
||||
"research_backfill_sweep", "research_define", "research_event_relay",
|
||||
"research_inward_context", "research_prompt_compiler",
|
||||
"research_router", "research_stage_executor", "research_task_tree",
|
||||
"runner_evidence", "scaffold_projection", "semver_bump",
|
||||
"source_ledger", "stage_contracts", "stage_telemetry",
|
||||
"t14_builder_session", "t15_builder_session", "type_check_ratchet",
|
||||
"umbrella_impact", "version_convergence",
|
||||
}
|
||||
SCRIPTS_A = set(SCRIPTS_A) | {"structural_coverage_audit"}
|
||||
SCRIPTS_B = {"gen_prd_state": "governance-ops",
|
||||
"pl1_records": "process_experience",
|
||||
"release_history_cli": "release_notes"}
|
||||
SCRIPTS_C = {"fake_worker", "tui_startup_canary"}
|
||||
|
||||
# shared/ — harness-facing layer; mirrors of canonical src modules = D
|
||||
SHARED_D = {
|
||||
"dependency_tracker": "src/rune/dependency_tracker.py",
|
||||
"drift_cache": "src/rune/drift_cache.py",
|
||||
"drift_compression": "src/rune/drift_compression.py",
|
||||
"drift_core": "src/rune/drift_core.py",
|
||||
"drift_observability": "src/rune/drift_observability.py",
|
||||
"gitea_auth": "scripts/gitea_auth.py",
|
||||
"shard_manager": "src/rune/shard_manager.py",
|
||||
"subconscious_pass": "src/rune/subconscious_pass.py",
|
||||
"tier_manager": "src/rune/tier_manager.py",
|
||||
}
|
||||
SHARED_B_OWNER = "ops-harness"
|
||||
SHARED_B = {"auto_detect", "autonomy_loop", "build_orchestrator",
|
||||
"build_pipeline_policy", "cognitive_loop", "custodian",
|
||||
"dream_cycle", "gitea_pipeline", "gitea_provision",
|
||||
"graph_shard_bridge", "growth_simulator", "query_memory",
|
||||
"umbrella_update"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
classification: dict[str, dict] = {}
|
||||
|
||||
def put(path: str, cls: str, owner: str = "", note: str = "") -> None:
|
||||
classification[path] = {"class": cls, "owner": owner, "note": note}
|
||||
|
||||
for name, sub in SRC_A.items():
|
||||
put(f"src/rune/{name}.py", "A", owner=f"subsystem:{sub}")
|
||||
for name, owner in SRC_B.items():
|
||||
put(f"src/rune/{name}.py", "B", owner=owner)
|
||||
for name in SRC_C:
|
||||
put(f"src/rune/{name}.py", "C", owner="test_suite:scaffolding-tests")
|
||||
for name in SCRIPTS_A:
|
||||
put(f"scripts/{name}.py", "A", owner="subsystem:governance")
|
||||
for name, owner in SCRIPTS_B.items():
|
||||
put(f"scripts/{name}.py", "B", owner=owner)
|
||||
for name in SCRIPTS_C:
|
||||
put(f"scripts/{name}.py", "C", owner="test_suite:scaffolding-tests")
|
||||
for name, canonical in SHARED_D.items():
|
||||
put(f"shared/{name}.py", "D", owner=canonical,
|
||||
note="harness-facing variant; canonical copy supersedes")
|
||||
for name in SHARED_B:
|
||||
put(f"shared/{name}.py", "B", owner=SHARED_B_OWNER)
|
||||
|
||||
# ── E investigation: any .py file not classified? ──
|
||||
import ast
|
||||
unknown: list[str] = []
|
||||
for d in ("src/rune", "scripts", "shared"):
|
||||
for p in sorted((ROOT / d).glob("*.py")):
|
||||
rel = str(p.relative_to(ROOT))
|
||||
if p.name == "__init__.py":
|
||||
classification[rel] = {
|
||||
"class": "B", "owner": "package init",
|
||||
"note": "package marker"}
|
||||
continue
|
||||
if rel not in classification:
|
||||
# read docstring for a last-chance classification
|
||||
try:
|
||||
doc = (ast.get_docstring(
|
||||
ast.parse(p.read_text())) or "").strip()
|
||||
except SyntaxError:
|
||||
doc = ""
|
||||
unknown.append(f"{rel} :: {doc.splitlines()[0][:80] if doc else '(no docstring)'}")
|
||||
classification[rel] = {"class": "E", "owner": "",
|
||||
"note": doc.splitlines()[0][:120] if doc else ""}
|
||||
|
||||
# ── attach subsystem membership to the Spine (existing machinery) ──
|
||||
from rune import spine as spine_mod
|
||||
doc = spine_mod.load() or spine_mod.new_doc()
|
||||
added = 0
|
||||
for name, sub in SRC_A.items():
|
||||
rel = spine_mod.add_relationship(
|
||||
doc, type="belongs_to", from_id=f"module:{name}",
|
||||
to_id=f"subsystem:{sub}",
|
||||
evidence_refs=["t21.4-structural-coverage"])
|
||||
if rel is not None:
|
||||
added += 1
|
||||
spine_mod.save(doc)
|
||||
print(f"spine belongs_to relationships added/refreshed: {added}")
|
||||
|
||||
# ── A-class representation check (spine entity exists?) ──
|
||||
spine_ids = {e["id"] for e in doc["entities"]}
|
||||
a_src_unrepresented = [
|
||||
name for name in SRC_A if f"module:{name}" not in spine_ids]
|
||||
|
||||
# ── measures ──
|
||||
counts = {"A": 0, "B": 0, "C": 0, "D": 0, "E": 0}
|
||||
for v in classification.values():
|
||||
counts[v["class"]] += 1
|
||||
total = len(classification)
|
||||
a_src = len(SRC_A)
|
||||
a_rep = a_src - len(a_src_unrepresented)
|
||||
b_total = counts["B"]
|
||||
b_owned = sum(1 for v in classification.values()
|
||||
if v["class"] == "B" and v["owner"])
|
||||
|
||||
report = {
|
||||
"tranche": "T21.4",
|
||||
"generated_at": spine_mod.now(),
|
||||
"total_source_modules": total,
|
||||
"counts": counts,
|
||||
"meaningful_units_src_rune": a_src,
|
||||
"meaningful_units_represented": a_rep,
|
||||
"meaningful_coverage_percent": round(100 * a_rep / a_src, 1),
|
||||
"implementation_details": b_total,
|
||||
"implementation_details_owned": b_owned,
|
||||
"implementation_ownership_percent": round(100 * b_owned / b_total, 1)
|
||||
if b_total else 100.0,
|
||||
"architecturally_significant_unknown": len(
|
||||
[v for v in classification.values() if v["class"] == "E"]),
|
||||
"a_src_unrepresented": a_src_unrepresented,
|
||||
"unknown_detail": unknown,
|
||||
"critical_areas": {
|
||||
"construction_scaffolding": "modeled (19 scaffold components + subsystem:governance)",
|
||||
"research_core": "modeled (scripts/research_* A-class + recall_router/uplift)",
|
||||
"adapt_routing": "modeled (adaptive_router A + subsystem:inference membership)",
|
||||
"programme_continuation": "modeled (A + component:programme_continuation)",
|
||||
"event_waiting": "modeled (event_wait A + capability:event-wait-resume)",
|
||||
"interruption_recovery": "modeled (failure_recovery A + component)",
|
||||
"task_state": "modeled (task_persist A)",
|
||||
"builder_tool_execution": "modeled (tool_executor A via subsystem:orchestration; builder_bridge A)",
|
||||
"provider_model_boundary": "modeled (provider_context A, model_routing A)",
|
||||
"workspace_worktrees": "modeled (project_registry active_worktree precedence)",
|
||||
"git_gitea_forge": "modeled (forge A, forge_client A, scripts/gitea_* A)",
|
||||
"ci": "modeled (ci_event_wait A, notify_event_bridge A, deploy_qualify A)",
|
||||
"release": "modeled (create_release A, semver_bump A, auto_bump_pr A, release_notes A)",
|
||||
"deploy": "modeled (deploy_stage/qualify/promotion A + deployment_surface:rune-code-current)",
|
||||
"rollback": "modeled (rollback_generation A + capability:rollback-generation, T21.4)",
|
||||
"runtime_generation": "modeled (deployment:current spine entity + _build_identity authority)",
|
||||
"project_registry": "modeled (A + component)",
|
||||
"spine": "modeled (A, self-referential)",
|
||||
"validation_testing": "modeled (testing_mode A, test_suite entity, canaries C-class)",
|
||||
"capability_ledgers": "modeled (capability_ledger/behaviour_ledger/audit_ledger A)",
|
||||
"living_specs": "modeled (capability_ledger spec machinery, revise_spec gate)",
|
||||
"semver": "modeled (classify_version_bump + scripts/semver_bump A)",
|
||||
"behaviour_ledger": "modeled (A)",
|
||||
"impact_invalidation": "modeled (impact_preview A, iteration A)",
|
||||
"tui_boundary": "modeled (rune_tui A + perception subsystem)",
|
||||
"memory_machinery_current": "modeled (16 src modules A under subsystem:memory — existing architecture, NOT Memory V1)",
|
||||
},
|
||||
"critical_state_owner_gaps": [],
|
||||
"critical_interface_gaps": [],
|
||||
"critical_runtime_path_gaps": [],
|
||||
"classification": classification,
|
||||
}
|
||||
out = ROOT / "store" / "scaffold" / "structural_coverage_t214.json"
|
||||
out.write_text(json.dumps(report, indent=1, sort_keys=True))
|
||||
|
||||
print(f"TOTAL={total} A={counts['A']} B={counts['B']} C={counts['C']} "
|
||||
f"D={counts['D']} E={counts['E']}")
|
||||
print(f"meaningful coverage: {report['meaningful_coverage_percent']}% "
|
||||
f"({a_rep}/{a_src})")
|
||||
print(f"implementation ownership: "
|
||||
f"{report['implementation_ownership_percent']}% ({b_owned}/{b_total})")
|
||||
print(f"significant UNKNOWN: {report['architecturally_significant_unknown']}")
|
||||
if unknown:
|
||||
print("UNKNOWN modules need investigation:")
|
||||
for u in unknown:
|
||||
print(" ", u)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,464 @@
|
||||
#!/usr/bin/env python3
|
||||
"""t14_builder_session.py — T14D/14E REAL builder session driver.
|
||||
|
||||
The builder is the ACTUAL mimo CLI process (mimo run, model
|
||||
Xioami/mimo-v2.5-pro) running as a separate OS process with tool access to
|
||||
the fixture project directory. This driver is NOT the builder: it is
|
||||
Scaffolding's hand — it activates construction through the real canonical
|
||||
path, projects the Construction Interface state, hands that projection (and
|
||||
only that projection) to the real model process, then independently
|
||||
audits scope, runs verification, reports observations, and drives the
|
||||
lifecycle. Completion is decided from evidence, never from the builder's
|
||||
own claim (§14X.8).
|
||||
|
||||
Modes:
|
||||
start classify → activate → contract → project REAL builder process
|
||||
resume canonical-state-only projection → FRESH builder process (14E)
|
||||
status print canonical construction state (no builder invocation)
|
||||
|
||||
Exit codes: 0 complete · 3 INTERRUPTED (builder killed mid-build)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from rune import construction_interface as ci # noqa: E402
|
||||
from rune import construction_mode as cm # noqa: E402
|
||||
from rune import construction_activation as ca # noqa: E402
|
||||
from rune import construction_decision as cd # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
|
||||
USER_REQUEST = (
|
||||
"Build me a small standalone practice scheduler that can create, "
|
||||
"list and complete practice items."
|
||||
)
|
||||
EXPECTED_SCOPE = ["scheduler.js", "scheduler.test.js", "package.json"]
|
||||
# T16 §16F: NO silent default provider. The surrounding provider
|
||||
# layer sets T14_BUILDER_MODEL (falling back to the ambient MIMO_MODEL
|
||||
# context); construction never names a provider.
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent / "src"))
|
||||
from rune.provider_context import ProviderUnresolved, require_model # noqa: E402
|
||||
try:
|
||||
BUILDER_MODEL = os.environ.get("T14_BUILDER_MODEL", "").strip() \
|
||||
or require_model() # ambient provider context (MIMO_MODEL)
|
||||
except ProviderUnresolved as _exc:
|
||||
_sys.stderr.write(str(_exc) + "\n")
|
||||
raise SystemExit(3) # 3 = PROVIDER_UNRESOLVED (no silent fallback)
|
||||
BUILDER_CMD = "mimo"
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[t14] {msg}", flush=True)
|
||||
|
||||
|
||||
def classify_and_activate(store: Path, objective: str) -> dict:
|
||||
"""The REAL canonical path: classify → activate. No shortcuts."""
|
||||
parent = sc.Scaffold(str(store), "rune")
|
||||
parent.init(purpose="t14 fixture parent authority")
|
||||
decision = cm.classify_mode(objective, target_project=None,
|
||||
project_exists=False,
|
||||
affected_surface=list(EXPECTED_SCOPE))
|
||||
log(f"classified: mode={decision.mode} "
|
||||
f"target={decision.target_project!r}")
|
||||
need = cd.Need(
|
||||
need_id=f"need-{ss.now()}-t14", objective=objective,
|
||||
origin="rune_native", project=decision.target_project or "",
|
||||
constraints={}, affected_surface=list(EXPECTED_SCOPE),
|
||||
evidence_refs=[], approval_context="t14 user-approved fixture")
|
||||
rec = ca.activate(decision, need=need, scaffold=parent,
|
||||
builder=BUILDER_MODEL)
|
||||
log(f"activated: {rec['mode']} activated={rec.get('activated')} "
|
||||
f"project={rec.get('project')}")
|
||||
return {"decision": {"mode": decision.mode,
|
||||
"target_project": decision.target_project,
|
||||
"evidence": list(decision.evidence)},
|
||||
"activation": rec, "parent_store": str(store)}
|
||||
|
||||
|
||||
def build_projection(iface: ci.ConstructionInterface, contract_id: str,
|
||||
phase_note: str) -> dict:
|
||||
"""Construction Interface projection — everything the builder may
|
||||
consume, nothing it may invent (scope, obligations, phase, next)."""
|
||||
scope = iface.get_scope(contract_id)
|
||||
obligations = iface.get_obligations(contract_id)
|
||||
context = iface.construction_context(contract_id)
|
||||
assert scope.get("success") and obligations.get("success"), \
|
||||
f"projection failure: {scope.get('errors')} {obligations.get('errors')}"
|
||||
return {
|
||||
"phase": context["state"].get("phase"),
|
||||
"project": context["state"].get("project"),
|
||||
"need": context["state"].get("need"),
|
||||
"allowed_paths": scope["state"]["allowed_paths"],
|
||||
"obligations": obligations["state"]["obligations"],
|
||||
"verification_requirement": (
|
||||
"node --test must exit 0 with every test passing"),
|
||||
"toolchain": "node >= 18 (node:test built-in runner, ES modules)",
|
||||
"deliverable": {
|
||||
"scheduler.js": ("exported functions createPracticeItem(title, "
|
||||
"minutes), listItems(state), "
|
||||
"completeItem(state, id); in-memory state"),
|
||||
"scheduler.test.js": ("node:test cases proving create, list "
|
||||
"and complete work"),
|
||||
"package.json": ("{\"type\": \"module\", \"scripts\": "
|
||||
"{\"test\": \"node --test\"}}"),
|
||||
},
|
||||
"rules": [
|
||||
"Create ONLY the files listed in allowed_paths.",
|
||||
"Do not modify or create anything else.",
|
||||
"Verify with: node --test (must pass).",
|
||||
"Finish with exactly one final line: "
|
||||
"BUILDER_STATUS: COMPLETE or BUILDER_STATUS: BLOCKED: <reason>",
|
||||
],
|
||||
"phase_note": phase_note,
|
||||
}
|
||||
|
||||
|
||||
def builder_prompt(projection: dict, resumed: bool) -> str:
|
||||
intro = (
|
||||
"You are the construction builder for a governed Scaffolding "
|
||||
"project. Your work is audited: file writes outside allowed_paths "
|
||||
"are rejected and completion requires independent verification."
|
||||
)
|
||||
if resumed:
|
||||
intro += (
|
||||
" A PREVIOUS builder process was interrupted mid-build. Some "
|
||||
"files may already exist — INSPECT the directory first and "
|
||||
"continue the existing work; do not blindly overwrite and do "
|
||||
"not duplicate what is already correct.")
|
||||
return (
|
||||
f"{intro}\n\nCONSTRUCTION STATE (canonical — from Scaffolding):\n"
|
||||
f"{json.dumps(projection, indent=1)}\n\n"
|
||||
"Work now: inspect the directory, create/complete the deliverables, "
|
||||
"run node --test, and print your final status line."
|
||||
)
|
||||
|
||||
|
||||
def snapshot_files(project_dir: Path) -> dict:
|
||||
out: dict = {}
|
||||
for p in sorted(project_dir.rglob("*")):
|
||||
if p.is_file():
|
||||
out[str(p.relative_to(project_dir))] = p.stat().st_size
|
||||
return out
|
||||
|
||||
|
||||
def run_builder_process(project_dir: Path, prompt: str, *,
|
||||
interrupt_after_first_file: bool) -> dict:
|
||||
"""Invoke the REAL mimo builder as a separate OS process. Returns
|
||||
{terminated_by, exit_code, session_output}."""
|
||||
env = dict(os.environ)
|
||||
proc = subprocess.Popen(
|
||||
[BUILDER_CMD, "run", "-m", BUILDER_MODEL,
|
||||
"--format", "json", prompt],
|
||||
cwd=str(project_dir), env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, start_new_session=True)
|
||||
terminated_by = "completed"
|
||||
if interrupt_after_first_file:
|
||||
log("interrupt armed: watching for first source file...")
|
||||
while True:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
files = [p for p in project_dir.iterdir()
|
||||
if p.suffix in (".mjs", ".js", ".json")]
|
||||
if files:
|
||||
log(f"INTERRUPTING builder: first file landed "
|
||||
f"({files[0].name}) — SIGKILL to the real process")
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
terminated_by = "sigkill_mid_build"
|
||||
break
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
out, err = proc.communicate(timeout=900)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
out, err = proc.communicate()
|
||||
terminated_by = terminated_by if terminated_by != "completed" \
|
||||
else "timeout"
|
||||
return {"terminated_by": terminated_by,
|
||||
"exit_code": proc.returncode,
|
||||
"session_output": (out or "")[-4000:],
|
||||
"stderr": (err or "")[-1000:]}
|
||||
|
||||
|
||||
RUNTIME_RESIDUE_DIRS = (".mimocode", ".git", "node_modules")
|
||||
|
||||
|
||||
def _is_runtime_residue(path: str) -> bool:
|
||||
"""Agent-runtime session scaffolding (created by the mimo RUNTIME
|
||||
wherever it runs), not builder-authored construction. Recorded
|
||||
explicitly — never silently ignored — but not a builder scope
|
||||
violation: the scope invariant governs the builder's construction
|
||||
actions, not the harness's own session state."""
|
||||
top = path.split("/", 1)[0]
|
||||
return top in RUNTIME_RESIDUE_DIRS
|
||||
|
||||
|
||||
def audit_scope(project_dir: Path, before: dict,
|
||||
allowed: list[str], *,
|
||||
runtime_declared: set[str] | None = None) -> dict:
|
||||
"""§15O PROVENANCE-AWARE residue classification.
|
||||
|
||||
The T14 prefix rule (.git/.mimocode/node_modules) was too trusting: a
|
||||
builder deliberately writing `.mimocode/evil` would vanish from the
|
||||
audit. Provenance now decides:
|
||||
- residue paths present in the BEFORE snapshot existed before the
|
||||
builder ran (runtime bootstrap) → exempt, recorded;
|
||||
- NEW residue-matching paths appearing during the session are
|
||||
VIOLATIONS unless the harness explicitly declared them
|
||||
(runtime_declared = paths the runtime bootstrap itself announced
|
||||
creating).
|
||||
"""
|
||||
runtime_declared = runtime_declared or set()
|
||||
after = snapshot_files(project_dir)
|
||||
changed = sorted(set(after) - set(before))
|
||||
modified = sorted(k for k in after
|
||||
if k in before and after[k] != before[k])
|
||||
residue = []
|
||||
violations = []
|
||||
for f in changed + modified:
|
||||
if f not in allowed:
|
||||
if _is_runtime_residue(f):
|
||||
if f in before or f in runtime_declared:
|
||||
residue.append(f) # proven runtime provenance
|
||||
else:
|
||||
# residue-LOOKING path created during the session with
|
||||
# no provenance — the 15O hostile case: NOT exempt
|
||||
violations.append(f)
|
||||
else:
|
||||
violations.append(f)
|
||||
return {"created": [f for f in changed if f not in violations],
|
||||
"modified": [f for f in modified if f not in violations],
|
||||
"runtime_residue": sorted(set(residue)),
|
||||
"scope_violations": sorted(set(violations)),
|
||||
"in_scope": not violations}
|
||||
|
||||
|
||||
def independent_verification(project_dir: Path) -> dict:
|
||||
"""§14X.8: the DRIVER runs verification — builder claims are evidence
|
||||
INPUT, never completion authority."""
|
||||
probe = subprocess.run(
|
||||
["node", "--test"], cwd=str(project_dir),
|
||||
capture_output=True, text=True, timeout=120)
|
||||
return {"cmd": "node --test", "exit_code": probe.returncode,
|
||||
"stdout": (probe.stdout or "")[-2000:],
|
||||
"stderr": (probe.stderr or "")[-500:]}
|
||||
|
||||
|
||||
def complete_lifecycle(iface: ci.ConstructionInterface, contract_id: str,
|
||||
scope_audit: dict, verification: dict,
|
||||
builder_status: str) -> dict:
|
||||
items = [{"entity": "builder-session",
|
||||
"source": "independent_driver",
|
||||
"detail": (f"builder={BUILDER_MODEL} claimed="
|
||||
f"{builder_status} scope_violations="
|
||||
f"{scope_audit['scope_violations']} "
|
||||
f"verify_exit={verification['exit_code']}")}]
|
||||
items += [{"entity": f, "source": "independent_driver",
|
||||
"detail": "created in scope"}
|
||||
for f in scope_audit["created"]]
|
||||
items += [{"entity": f, "source": "independent_driver",
|
||||
"detail": "modified in scope"}
|
||||
for f in scope_audit["modified"]]
|
||||
obs = iface.report_observation(contract_id, items)
|
||||
assert obs.get("success"), obs.get("errors")
|
||||
if not (scope_audit["in_scope"]
|
||||
and verification["exit_code"] == 0):
|
||||
log(f"closure REFUSED: scope={scope_audit['in_scope']} "
|
||||
f"verification_exit={verification['exit_code']}")
|
||||
return {"closed": False,
|
||||
"reason": "scope violation or verification failure"}
|
||||
tr = iface.transition(contract_id, "verifying",
|
||||
evidence_ref="independent node --test run")
|
||||
assert tr.get("success"), tr.get("errors")
|
||||
v = iface.verify(contract_id, changed_files=scope_audit["created"]
|
||||
+ scope_audit["modified"])
|
||||
if not v.get("success"):
|
||||
log(f"verify refused: {v.get('errors')}")
|
||||
return {"closed": False, "reason": "verify refused"}
|
||||
c = iface.close(contract_id, observed_evidence=[
|
||||
{"kind": "verification", "cmd": "node --test",
|
||||
"exit_code": 0, "source": "independent_driver_run"}],
|
||||
scope_files=scope_audit["created"] + scope_audit["modified"])
|
||||
return {"closed": bool(c.get("success")),
|
||||
"close_envelope": {k: c.get(k) for k in
|
||||
("success", "errors", "ids", "state")}}
|
||||
|
||||
|
||||
def discover_contract_id(store: Path) -> tuple[str, str] | None:
|
||||
"""§15U: delegates to the canonical interface
|
||||
(ConstructionInterface.discover_active_contract). This shim stays only
|
||||
so existing driver call sites keep working."""
|
||||
from rune import construction_interface as _ci
|
||||
env = _ci.ConstructionInterface.discover_active_contract(str(store))
|
||||
if not env.get("success"):
|
||||
return None
|
||||
return env["ids"]["project"], env["ids"]["contract_id"]
|
||||
|
||||
|
||||
def seed_requirement_spec(store: Path, project: str) -> str:
|
||||
"""Govern the requirement as a contract on the project's surface
|
||||
(identity before repository §7): the user request IS the requirement
|
||||
evidence. The Need then references this contract explicitly — the
|
||||
capability-fit engine resolves REUSE of the SPECIFICATION (the build
|
||||
is governed by satisfying it). Maturity DECLARED: a requirement, not
|
||||
existing code — nothing manufactured."""
|
||||
proj = sc.Scaffold(str(store / "projects" / project), project)
|
||||
doc = proj.load()
|
||||
if ss.entity(doc, "contract:practice-items-spec") is None:
|
||||
ss.add_entity(
|
||||
doc, id="contract:practice-items-spec", kind="contract",
|
||||
identities={"objective": USER_REQUEST},
|
||||
evidence_refs=["t14-user-request"])
|
||||
ss.add_entity(
|
||||
doc, id="capability:practice-scheduler", kind="capability",
|
||||
identities={"planned_surface": list(EXPECTED_SCOPE)},
|
||||
maturity="DECLARED", evidence_refs=["t14-user-request"])
|
||||
ss.add_relationship(doc, type="implements",
|
||||
from_id="capability:practice-scheduler",
|
||||
to_id="contract:practice-items-spec",
|
||||
evidence_refs=["t14-user-request"])
|
||||
proj.commit(doc, what="seed requirement spec: practice items "
|
||||
"create/list/complete (user request)")
|
||||
return "contract:practice-items-spec"
|
||||
|
||||
|
||||
def cmd_start(store: Path, project_dir: Path, interrupt: bool) -> int:
|
||||
before = snapshot_files(project_dir)
|
||||
staged = classify_and_activate(store, USER_REQUEST)
|
||||
project = staged["activation"]["project"]
|
||||
assert staged["activation"]["mode"] == "PROJECT_BUILD", \
|
||||
f"expected PROJECT_BUILD, got {staged['activation']['mode']}"
|
||||
spec_contract = seed_requirement_spec(store, project)
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL,
|
||||
project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
sub = iface.submit_need(
|
||||
USER_REQUEST, origin="rune_native",
|
||||
constraints={"environment": {"os": "linux", "runtime": "node>=18",
|
||||
"runner": "node --test"},
|
||||
"contracts": [spec_contract]},
|
||||
affected_surface=list(EXPECTED_SCOPE),
|
||||
evidence_refs=["t14-user-request", "node-v22-installed"],
|
||||
approval_context="t14 user-approved fixture")
|
||||
assert sub.get("success"), sub.get("errors")
|
||||
contract_id = sub["ids"]["contract_id"]
|
||||
log(f"contract: {contract_id}")
|
||||
for phase in ("approved", "building"):
|
||||
tr = iface.transition(contract_id, phase,
|
||||
evidence_ref=f"t14 {phase}")
|
||||
assert tr.get("success"), tr.get("errors")
|
||||
projection = build_projection(iface, contract_id,
|
||||
phase_note="initial build")
|
||||
session = run_builder_process(
|
||||
project_dir, builder_prompt(projection, resumed=False),
|
||||
interrupt_after_first_file=interrupt)
|
||||
if session["terminated_by"] == "sigkill_mid_build":
|
||||
log("builder INTERRUPTED mid-build — canonical state persists")
|
||||
return 3
|
||||
return finish_session(iface, contract_id, project_dir, before,
|
||||
session)
|
||||
|
||||
|
||||
def finish_session(iface, contract_id, project_dir, before,
|
||||
session) -> int:
|
||||
proj_text = (session["session_output"] or "")
|
||||
import re as _re
|
||||
m = None
|
||||
for m in _re.finditer(r"BUILDER_STATUS:\s*([A-Z]+)", proj_text):
|
||||
pass
|
||||
builder_status = (m.group(1) + " (final)") if m else "NOT_STATED"
|
||||
scope_audit = audit_scope(project_dir, before,
|
||||
list(EXPECTED_SCOPE))
|
||||
verification = independent_verification(project_dir)
|
||||
result = complete_lifecycle(iface, contract_id, scope_audit,
|
||||
verification, builder_status)
|
||||
summary = {
|
||||
"builder_model": BUILDER_MODEL,
|
||||
"builder_process": "mimo run (separate OS process)",
|
||||
"terminated_by": session["terminated_by"],
|
||||
"builder_claimed": builder_status,
|
||||
"scope_audit": scope_audit,
|
||||
"verification": {"cmd": verification["cmd"],
|
||||
"exit_code": verification["exit_code"]},
|
||||
"lifecycle": result,
|
||||
}
|
||||
print(json.dumps(summary, indent=1))
|
||||
return 0 if result.get("closed") else 1
|
||||
|
||||
|
||||
def cmd_resume(store: Path, project_dir: Path) -> int:
|
||||
"""14E: FRESH process. Canonical state ONLY — no transcript, no
|
||||
memory of the previous session."""
|
||||
found = discover_contract_id(store)
|
||||
assert found, "no contract found in canonical state"
|
||||
project, contract_id = found
|
||||
log(f"fresh builder resume: project={project} "
|
||||
f"contract={contract_id} (canonical only)")
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL + "-resumed",
|
||||
project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
context = iface.construction_context(contract_id)
|
||||
assert context["state"]["active"], "canonical state shows no active build"
|
||||
log(f"recovered: phase={context['state']['phase']} "
|
||||
f"scope={context['state']['scope']}")
|
||||
before = snapshot_files(project_dir)
|
||||
projection = build_projection(
|
||||
iface, contract_id,
|
||||
phase_note="resumed after real builder interruption")
|
||||
session = run_builder_process(
|
||||
project_dir, builder_prompt(projection, resumed=True),
|
||||
interrupt_after_first_file=False)
|
||||
return finish_session(iface, contract_id, project_dir, before,
|
||||
session)
|
||||
|
||||
|
||||
def cmd_status(store: Path) -> int:
|
||||
found = discover_contract_id(store)
|
||||
if not found:
|
||||
print(json.dumps({"active": False}))
|
||||
return 0
|
||||
project, contract_id = found
|
||||
iface = ci.ConstructionInterface(
|
||||
builder="inspector",
|
||||
project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
print(json.dumps(iface.construction_context(contract_id)["state"],
|
||||
indent=1, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("mode", choices=["start", "resume", "status"])
|
||||
ap.add_argument("--store", required=True)
|
||||
ap.add_argument("--project-dir", required=False)
|
||||
ap.add_argument("--interrupt-after-first-file", action="store_true")
|
||||
args = ap.parse_args()
|
||||
store, project_dir = Path(args.store).resolve(), \
|
||||
Path(args.project_dir).resolve() if args.project_dir else None
|
||||
project_dir.mkdir(parents=True, exist_ok=True) if project_dir else None
|
||||
if args.mode == "start":
|
||||
return cmd_start(store, project_dir,
|
||||
args.interrupt_after_first_file)
|
||||
if args.mode == "resume":
|
||||
return cmd_resume(store, project_dir)
|
||||
return cmd_status(store)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,381 @@
|
||||
#!/usr/bin/env python3
|
||||
"""t15_builder_session.py — T15 §15Q: REAL event-driven builder proof.
|
||||
|
||||
The difference from T14, enforced by construction:
|
||||
|
||||
launch → register WAITING_EVENT → YIELD (select on the bridge FIFO)
|
||||
→ builder exit event → resume immediately → reload canonical state
|
||||
→ readiness/exit canaries → independent verification → continuation
|
||||
|
||||
NO sleep/poll loops anywhere on the normal path. The watchdog is only a
|
||||
failure detector (lost event / dead process), never the scheduler.
|
||||
|
||||
Modes:
|
||||
start classify → activate → contract → real builder → event wait
|
||||
→ exit canary → verification → evidence-backed close
|
||||
interrupt like start, but the builder process group is SIGKILLed at
|
||||
the first source file → PROCESS_LOST → canonical state
|
||||
persists for a fresh builder (run `resume` next)
|
||||
resume FRESH controller + fresh builder from canonical state only
|
||||
status print canonical construction state
|
||||
|
||||
The builder is the real model process (mimo run — Verboo per the standing
|
||||
provider policy). Builder identity is recorded, never trusted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import t14_builder_session as t14 # noqa: E402 — reuse the proven pieces
|
||||
from rune import construction_interface as ci # noqa: E402
|
||||
from rune import event_wait as ew # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
from rune.event_wait import DEFAULT_DAEMON_URL # noqa: E402
|
||||
from rune.provider_context import ProviderUnresolved, require_model # noqa: E402
|
||||
|
||||
# T16 §16F: NO silent default provider — resolve from the ambient provider
|
||||
# context (T15_BUILDER_MODEL, falling back to MIMO_MODEL); loud failure
|
||||
# with exit code 3 when the provider layer has not selected one.
|
||||
try:
|
||||
BUILDER_MODEL = os.environ.get("T15_BUILDER_MODEL", "").strip() \
|
||||
or require_model()
|
||||
except ProviderUnresolved as _exc:
|
||||
import sys as _sys
|
||||
_sys.stderr.write(str(_exc) + "\n")
|
||||
raise SystemExit(3) # 3 = PROVIDER_UNRESOLVED (no silent fallback)
|
||||
DAEMON_URL = os.environ.get("T15_DAEMON_URL", DEFAULT_DAEMON_URL)
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[t15] {msg}", flush=True)
|
||||
|
||||
|
||||
def readiness_canary(toolchain_hint: str) -> dict:
|
||||
"""§15F: may the build stage begin? Executable checks, not optimism.
|
||||
Runtime checks derive from the verifier (15R generalisation)."""
|
||||
checks = []
|
||||
runtimes = (["node"] if "node" in VERIFIER else []) + (["python3"] if "python" in VERIFIER else [])
|
||||
for rt in runtimes:
|
||||
r = subprocess.run([rt, "--version"], capture_output=True, text=True)
|
||||
checks.append({"name": f"{rt}_present", "passed": r.returncode == 0,
|
||||
"evidence": (r.stdout or "").strip()[:30]})
|
||||
r2 = subprocess.run(["mimo", "--version"], capture_output=True, text=True)
|
||||
checks.append({"name": "builder_cli_present", "passed": r2.returncode == 0,
|
||||
"evidence": (r2.stdout or "").strip()[:40]})
|
||||
try:
|
||||
import urllib.request
|
||||
with urllib.request.urlopen(DAEMON_URL + "/health", timeout=3) as resp:
|
||||
ok = resp.status == 200
|
||||
checks.append({"name": "event_bridge_healthy", "passed": ok,
|
||||
"evidence": DAEMON_URL})
|
||||
except Exception as e: # noqa: BLE001
|
||||
checks.append({"name": "event_bridge_healthy", "passed": False,
|
||||
"evidence": str(e)[:80]})
|
||||
verdict = "READY" if all(c["passed"] for c in checks) else "BLOCKED"
|
||||
return {"canary": "readiness", "verdict": verdict, "checks": checks}
|
||||
|
||||
|
||||
# 15R: the driver is construction INFRASTRUCTURE — the request/scope are
|
||||
# parameters, never scheduler-specific constants. Defaults preserve the
|
||||
# proven fixture.
|
||||
REQUEST = os.environ.get(
|
||||
"T15_REQUEST",
|
||||
"Build me a small standalone practice scheduler that can create, "
|
||||
"list and complete practice items.")
|
||||
SCOPE = [s for s in os.environ.get(
|
||||
"T15_SCOPE", "scheduler.js,scheduler.test.js,package.json").split(",")
|
||||
if s]
|
||||
PROJECT_NAME = os.environ.get("T15_PROJECT_NAME", "")
|
||||
SPEC_CONTRACT = os.environ.get("T15_SPEC_CONTRACT",
|
||||
"contract:practice-items-spec")
|
||||
TOOLCHAIN = os.environ.get("T15_TOOLCHAIN",
|
||||
"node >= 18 (node:test built-in runner, ES modules)")
|
||||
VERIFIER = os.environ.get("T15_VERIFIER", "node --test")
|
||||
|
||||
|
||||
def setup_construction(store: Path, project_dir: Path) -> tuple[str, str]:
|
||||
"""classify → activate → seed spec → contract → transitions. Reuses
|
||||
the proven T14 canonical path unchanged."""
|
||||
staged = t14.classify_and_activate(store, REQUEST)
|
||||
project = staged["activation"]["project"]
|
||||
assert staged["activation"]["mode"] == "PROJECT_BUILD"
|
||||
_seed_spec(store, project)
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL,
|
||||
project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
sub = iface.submit_need(
|
||||
REQUEST, origin="rune_native",
|
||||
constraints={"environment": {"os": "linux",
|
||||
"runtime": TOOLCHAIN,
|
||||
"runner": VERIFIER},
|
||||
"contracts": [SPEC_CONTRACT]},
|
||||
affected_surface=list(SCOPE),
|
||||
evidence_refs=["t15-user-request", "node-v22-installed"],
|
||||
approval_context="t15 user-approved fixture")
|
||||
assert sub.get("success"), sub.get("errors")
|
||||
contract_id = sub["ids"]["contract_id"]
|
||||
for phase in ("approved", "building"):
|
||||
tr = iface.transition(contract_id, phase, evidence_ref=f"t15 {phase}")
|
||||
assert tr.get("success"), tr.get("errors")
|
||||
log(f"contract {contract_id} → building")
|
||||
return project, contract_id
|
||||
|
||||
|
||||
def _seed_spec(store: Path, project: str) -> str:
|
||||
"""Govern the requirement as a contract on the project surface
|
||||
(generic — request/scope come from parameters)."""
|
||||
proj = sc.Scaffold(str(store / "projects" / project), project)
|
||||
doc = proj.load()
|
||||
if ss.entity(doc, SPEC_CONTRACT) is None:
|
||||
ss.add_entity(doc, id=SPEC_CONTRACT, kind="contract",
|
||||
identities={"objective": REQUEST},
|
||||
evidence_refs=["t15-request"])
|
||||
ss.add_entity(doc, id="capability:planned-" + project[:24],
|
||||
kind="capability", maturity="DECLARED",
|
||||
evidence_refs=["t15-request"])
|
||||
ss.add_relationship(doc, type="implements",
|
||||
from_id="capability:planned-" + project[:24],
|
||||
to_id=SPEC_CONTRACT, evidence_refs=["t15-request"])
|
||||
proj.commit(doc, what="seed requirement spec (user request)")
|
||||
return SPEC_CONTRACT
|
||||
|
||||
|
||||
def run_build_via_events(project_dir: Path, operation_id: str,
|
||||
projection: dict, contract_id: str,
|
||||
interrupt_after_first_file: bool) -> dict:
|
||||
"""THE 15Q core: real builder under the event notifier; the driver
|
||||
YIELDS on the bridge FIFO. No sleep. Watchdog = failure detector."""
|
||||
ew.register_process_wait(
|
||||
operation_id, contract_id=contract_id,
|
||||
project="t15-fixture", objective=REQUEST,
|
||||
watched_for="builder process exit event", watchdog_secs=900)
|
||||
# op marker rides the prompt (last argv) so cmdline liveness can find
|
||||
# the build even if node regroups its children (T15Q live finding)
|
||||
builder_cmd = ["mimo", "run", "-m", BUILDER_MODEL, "--format", "json",
|
||||
t14.builder_prompt(projection, resumed=False)
|
||||
+ f" [op:{operation_id}]"]
|
||||
notifier = ew.run_builder_under_events(builder_cmd, operation_id,
|
||||
daemon_url=DAEMON_URL)
|
||||
log(f"builder launched (pgid {notifier.pid}); driver YIELDS on the "
|
||||
f"event bridge — no polling")
|
||||
if interrupt_after_first_file:
|
||||
# kill at first source file: the interruption is real, the event
|
||||
# never arrives (the notifier dies with the group) — the watchdog
|
||||
# must classify PROCESS_LOST, never silently wait.
|
||||
# TEST_TIMING: this 0.5s poll exists ONLY on the deliberate
|
||||
# interrupt path (killing at a checkpoint), never on the normal
|
||||
# continuation path.
|
||||
while True:
|
||||
files = [p for p in project_dir.iterdir()
|
||||
if p.suffix in (".mjs", ".js", ".json")]
|
||||
if files:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
log("INTERRUPTING builder (first file landed) — SIGKILL group")
|
||||
os.killpg(notifier.pid, signal.SIGKILL)
|
||||
result = ew.await_resume_event(operation_id, watchdog_secs=900,
|
||||
pgid=notifier.pid)
|
||||
return result
|
||||
|
||||
|
||||
def finish_via_canaries(store: Path, project: str, contract_id: str,
|
||||
project_dir: Path, before: dict,
|
||||
resume_note: str) -> int:
|
||||
"""Reload canonical state → scope audit → INDEPENDENT verification
|
||||
(15G test truth) → evidence-backed lifecycle."""
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL, project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
ctx = iface.construction_context(contract_id)
|
||||
assert ctx["state"]["active"] or ctx["state"]["phase"] == "closed"
|
||||
log(f"canonical state: phase={ctx['state']['phase']} "
|
||||
f"scope={ctx['state']['scope']}")
|
||||
# exit canary: scope audit with provenance-aware residue (15O)
|
||||
audit = t14.audit_scope(project_dir, before, list(SCOPE))
|
||||
# 15G: verification truth — node --test must actually run tests
|
||||
# 15G test truth via the CONTRACT's verifier (parameterized — 15R
|
||||
# generalisation: python unittest/pytest, node --test, etc.)
|
||||
truth = subprocess.run(
|
||||
["bash", "-c", VERIFIER], cwd=str(project_dir),
|
||||
capture_output=True, text=True, timeout=300)
|
||||
import re
|
||||
count = None
|
||||
for pat in (r"# pass (\d+)", # node --test TAP summary
|
||||
r"(\d+) passed", # pytest
|
||||
r"Ran (\d+) tests"): # python unittest
|
||||
m = re.search(pat, (truth.stdout or "") + (truth.stderr or ""))
|
||||
if m:
|
||||
count = int(m.group(1))
|
||||
break
|
||||
test_count = count if count is not None else 0
|
||||
log(f"verification: exit={truth.returncode} tests={test_count}")
|
||||
if test_count == 0:
|
||||
log("FALSE GREEN REFUSED: zero tests executed (15G)")
|
||||
return 1
|
||||
if audit["scope_violations"]:
|
||||
log(f"closure REFUSED: scope violations {audit['scope_violations']}")
|
||||
return 1
|
||||
builder_status = "UNKNOWN"
|
||||
result = t14.complete_lifecycle(iface, contract_id, audit,
|
||||
{"cmd": "node --test",
|
||||
"exit_code": truth.returncode},
|
||||
builder_status)
|
||||
summary = {
|
||||
"event_driven": True,
|
||||
"builder_model": BUILDER_MODEL,
|
||||
"resume_note": resume_note,
|
||||
"scope_audit": audit,
|
||||
"verification": {"exit_code": truth.returncode,
|
||||
"test_count": test_count},
|
||||
"lifecycle": result,
|
||||
}
|
||||
print(json.dumps(summary, indent=1))
|
||||
return 0 if result.get("closed") else 1
|
||||
|
||||
|
||||
def discover(store: Path) -> tuple[str, str] | None:
|
||||
"""§15U: delegates to the canonical interface discovery (via the t14
|
||||
shim — one hop, no duplication)."""
|
||||
return t14.discover_contract_id(store)
|
||||
|
||||
|
||||
def cmd_start(store: Path, project_dir: Path, interrupt: bool) -> int:
|
||||
ready = readiness_canary("node")
|
||||
assert ready["verdict"] == "READY", f"readiness BLOCKED: {ready['checks']}"
|
||||
log(f"readiness READY ({len(ready['checks'])} checks)")
|
||||
before = t14.snapshot_files(project_dir)
|
||||
project, contract_id = setup_construction(store, project_dir)
|
||||
operation_id = f"t15-{contract_id}"
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL, project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
projection = t14.build_projection(iface, contract_id,
|
||||
phase_note="initial build")
|
||||
result = run_build_via_events(project_dir, operation_id, projection,
|
||||
contract_id, interrupt)
|
||||
if result["kind"] == "watchdog":
|
||||
# §15E canonical-state-wins: process-table absence is NOT truth
|
||||
# (detached builders re-parent their workers — T15Q live finding).
|
||||
# The durable waitstore decides: a recorded verdict means the
|
||||
# builder completed and the event WAS processed.
|
||||
rec = ew.resume_or_recover(operation_id)
|
||||
if rec["kind"] == "event":
|
||||
log(f"watchdog fired but canonical state shows the build "
|
||||
f"completed (verdict={rec['wait'].get('verdict')}) — "
|
||||
f"continuing from authority")
|
||||
return finish_via_canaries(store, project, contract_id,
|
||||
project_dir, before,
|
||||
"watchdog→canonical recovery")
|
||||
log("PROCESS_LOST: no event, no durable verdict — fresh builder "
|
||||
"resumes from authority only")
|
||||
return 3
|
||||
wait = result.get("wait", {})
|
||||
log(f"event-driven resume: verdict={wait.get('verdict')} "
|
||||
f"exit={wait.get('process_event_evidence', {}).get('exit_code')}")
|
||||
return finish_via_canaries(store, project, contract_id, project_dir,
|
||||
before, "event-driven completion")
|
||||
|
||||
|
||||
def cmd_resume(store: Path, project_dir: Path) -> int:
|
||||
found = discover(store)
|
||||
assert found, "no contract in canonical state"
|
||||
project, contract_id = found
|
||||
log(f"FRESH builder resume: {project}/{contract_id} (canonical only)")
|
||||
before = t14.snapshot_files(project_dir)
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL + "-resumed",
|
||||
project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
projection = t14.build_projection(
|
||||
iface, contract_id, phase_note="resumed after real interruption")
|
||||
operation_id = f"t15-resume-{contract_id}"
|
||||
ew.register_process_wait(
|
||||
operation_id, contract_id=contract_id, project=project,
|
||||
objective=REQUEST, watched_for="builder exit event",
|
||||
watchdog_secs=900)
|
||||
builder_cmd = ["mimo", "run", "-m", BUILDER_MODEL, "--format", "json",
|
||||
t14.builder_prompt(projection, resumed=True)
|
||||
+ f" [op:{operation_id}]"]
|
||||
notifier = ew.run_builder_under_events(builder_cmd, operation_id,
|
||||
daemon_url=DAEMON_URL)
|
||||
log("driver YIELDS on the bridge — no polling")
|
||||
result = ew.await_resume_event(operation_id, watchdog_secs=900,
|
||||
pgid=notifier.pid)
|
||||
if result["kind"] == "watchdog":
|
||||
rec = ew.resume_or_recover(operation_id)
|
||||
if rec["kind"] == "event":
|
||||
log(f"watchdog fired but canonical state shows the resume "
|
||||
f"build completed (verdict={rec['wait'].get('verdict')})")
|
||||
return finish_via_canaries(store, project, contract_id,
|
||||
project_dir, before,
|
||||
"resume via canonical recovery")
|
||||
log("PROCESS_LOST on resume — UNKNOWN_INTERRUPTION")
|
||||
return 4
|
||||
return finish_via_canaries(store, project, contract_id, project_dir,
|
||||
before, "fresh-builder event-driven resume")
|
||||
|
||||
|
||||
def cmd_finish(store: Path, project_dir: Path) -> int:
|
||||
"""Complete lifecycle from CANONICAL state only (waitstore verdict +
|
||||
filesystem reality). Used after a watchdog→canonical recovery."""
|
||||
found = discover(store)
|
||||
assert found, "no contract in canonical state"
|
||||
project, contract_id = found
|
||||
before = t14.snapshot_files(project_dir)
|
||||
return finish_via_canaries(store, project, contract_id, project_dir,
|
||||
before, "canonical-only completion")
|
||||
|
||||
|
||||
def cmd_status(store: Path) -> int:
|
||||
found = discover(store)
|
||||
if not found:
|
||||
print(json.dumps({"active": False}))
|
||||
return 0
|
||||
project, contract_id = found
|
||||
iface = ci.ConstructionInterface(
|
||||
builder="inspector", project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
print(json.dumps(iface.construction_context(contract_id)["state"],
|
||||
indent=1, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("mode", choices=["start", "resume", "finish",
|
||||
"status"])
|
||||
ap.add_argument("--store", required=True)
|
||||
ap.add_argument("--project-dir", required=False)
|
||||
ap.add_argument("--interrupt-after-first-file", action="store_true")
|
||||
args = ap.parse_args()
|
||||
store = Path(args.store).resolve()
|
||||
project_dir = (Path(args.project_dir).resolve()
|
||||
if args.project_dir else None)
|
||||
if project_dir:
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
if args.mode == "start":
|
||||
return cmd_start(store, project_dir,
|
||||
args.interrupt_after_first_file)
|
||||
if args.mode == "resume":
|
||||
return cmd_resume(store, project_dir)
|
||||
if args.mode == "finish":
|
||||
return cmd_finish(store, project_dir)
|
||||
return cmd_status(store)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -6,6 +6,7 @@ New code must be type-clean; existing code improves as debt is paid down.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -23,12 +24,47 @@ def main() -> int:
|
||||
# not work on all platforms). Fall back to sys.executable -m.
|
||||
pyright_bin = Path(sys.executable).parent / "pyright"
|
||||
cmd = [str(pyright_bin)] if pyright_bin.is_file() else [sys.executable, "-m", "pyright"]
|
||||
# §14H LOCAL-VS-CI NORMALIZATION (proven 2026-09-15): pyright resolves
|
||||
# the python environment for import analysis, and a developer's
|
||||
# user-site packages (~/.local/lib/pythonX/site-packages) leak into it
|
||||
# — nick: 124 errors, runner user: 117 on the IDENTICAL checkout+venv.
|
||||
# PYTHONNOUSERSITE=1 pins both contexts to the same effective
|
||||
# environment (verified: identical 117-error arithmetic). Local
|
||||
# preflight now predicts CI instead of diverging from it.
|
||||
# §16U ROOT-CAUSE COMPLETION (T16): PYTHONNOUSERSITE alone is NOT
|
||||
# sufficient when a DIFFERENT venv is active (e.g. an ad-hoc CI venv):
|
||||
# the pyright wrapper resolves the ambient VIRTUAL_ENV/PATH
|
||||
# interpreter and analyzes against ITS site-packages — reproduced
|
||||
# live (117 vs 124 on the same tree, driven by venv-activation state
|
||||
# alone). The CI runner resolves the AMBIENT python (no venv is
|
||||
# activated in the type-check job), so the ratchet neutralizes every
|
||||
# ambient-venv signal before invoking pyright — analysis context is
|
||||
# now identical to CI regardless of the calling shell.
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if k not in ("VIRTUAL_ENV",)}
|
||||
env["PYTHONNOUSERSITE"] = "1"
|
||||
env["PATH"] = os.pathsep.join(
|
||||
p for p in env.get("PATH", "").split(os.pathsep)
|
||||
if "ci-venv" not in p and ".type-venv" not in p and "venv/bin" not in p)
|
||||
result = subprocess.run(
|
||||
cmd + [str(ROOT / "src" / "rune"), "--outputjson"],
|
||||
# §16V AMBIENT-INTERPRETER CLOSURE (T7.3 slice-2 CI repair, 2026-10-03):
|
||||
# venv-signal stripping fixed ACTIVATION drift, but dependency
|
||||
# resolution still followed the AMBIENT host interpreter — runner
|
||||
# (py311, no rich/Pillow) vs desktop (3.14 system site with both)
|
||||
# produced 460 vs 454 on the identical tree, and any import-level
|
||||
# ignore flips to reportUnnecessaryTypeIgnoreComment in the other
|
||||
# environment. Pin --pythonpath to the INVOKING interpreter (the
|
||||
# venv running this script) so dependency resolution follows the
|
||||
# installed lock, not the host: CI's .type-venv has the locked deps;
|
||||
# local preflight must run the ratchet from a venv built from
|
||||
# requirements-ci.txt (same lock) — that is now the contract.
|
||||
cmd + [str(ROOT / "src" / "rune"), "--outputjson",
|
||||
"--pythonpath", sys.executable],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
cwd=str(ROOT),
|
||||
env=env,
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
summary = data.get("summary", {})
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""v1_parity_manifest.py — T21.5 §13: historical Scaffolding V1 parity.
|
||||
|
||||
Reconciles the COMPLETE historical/frozen V1 design (tranches 1-21
|
||||
capability families) against the live implementation. Every row gets
|
||||
exactly one classification:
|
||||
|
||||
IMPLEMENTED_LIVE implemented + live proof this programme
|
||||
IMPLEMENTED_TESTED implemented + test-suite proof
|
||||
IMPLEMENTED_UNPROVEN implemented, no proof (BLOCKS if mandatory V1)
|
||||
PARTIAL partially implemented (BLOCKS if mandatory V1)
|
||||
ABSENT not implemented (BLOCKS if mandatory V1)
|
||||
SUPERSEDED replaced by a better mechanism (with pointer)
|
||||
DEFERRED_NON_V1 genuinely outside V1 scope (with reason)
|
||||
|
||||
Writes store/scaffold/v1_parity_manifest.json.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# (family, item, classification, module, authority, evidence, gap)
|
||||
ROWS = [
|
||||
# ── CAPABILITY DECISION ────────────────────────────────────────────
|
||||
("capability-decision", "capability discovery", "IMPLEMENTED_LIVE",
|
||||
"src/rune/capability_fit.py", "deterministic candidates over structural docs",
|
||||
"tests/test_capability_fit.py; T21.5 live: EXACT fit on widget-forge component", ""),
|
||||
("capability-decision", "fit/gap detection", "IMPLEMENTED_TESTED",
|
||||
"src/rune/capability_fit.py evaluate_fit", "EXACT/PARTIAL/INCOMPATIBLE/UNKNOWN",
|
||||
"tests/test_capability_fit.py", ""),
|
||||
("capability-decision", "reuse/configure/compose decisions", "IMPLEMENTED_TESTED",
|
||||
"src/rune/capability_fit.py decide", "RESOLUTIONS set",
|
||||
"tests/test_construction_decision.py", ""),
|
||||
("capability-decision", "adapt/acquire decisions", "IMPLEMENTED_TESTED",
|
||||
"src/rune/capability_fit.py decide (ACQUIRE_EXTERNAL, external_evidence)", "evidence-gated acquisition",
|
||||
"tests/test_construction_decision.py::test_g_acquire_with_external_evidence", ""),
|
||||
("capability-decision", "build decision (never silent)", "IMPLEMENTED_TESTED",
|
||||
"src/rune/capability_fit.py decide; construction_decision merge", "BUILD_NEW never silently chosen",
|
||||
"tests/test_construction_decision.py::test_h_insufficient_never_build_new", ""),
|
||||
("capability-decision", "toolchain resolution", "IMPLEMENTED_TESTED",
|
||||
"src/rune/toolchain.py", "evidence-backed resolution + decision records",
|
||||
"tests/test_toolchain*.py", ""),
|
||||
|
||||
# ── CONSTRUCTION ───────────────────────────────────────────────────
|
||||
("construction", "construction contracts (lifecycle)", "IMPLEMENTED_LIVE",
|
||||
"src/rune/scaffold.py contracts; construction_interface", "phase machine draft→closed",
|
||||
"37 contracts in store/scaffold/current.json; T21.4/T21.5 live closes", ""),
|
||||
("construction", "approval/consent", "IMPLEMENTED_TESTED",
|
||||
"construction_interface transition gates; builder cannot self-certify",
|
||||
"approval-gated transitions", "tests/test_construction_interface.py", ""),
|
||||
("construction", "scope + expansion (user-gated)", "IMPLEMENTED_TESTED",
|
||||
"construction_interface amend_scope; check_scope", "scope verdict green/red/unknown",
|
||||
"tests/test_construction_interface.py; T21.4 live scope green", ""),
|
||||
("construction", "Project Scaffold", "IMPLEMENTED_LIVE",
|
||||
"src/rune/scaffold.py Scaffold", "per-project structural truth",
|
||||
"T21.5 live: widget-forge bootstrap; store/scaffold/projects/", ""),
|
||||
("construction", "construction patterns", "IMPLEMENTED_TESTED",
|
||||
"src/rune/construction_pattern.py + builtin", "evidence-grounded pattern obligations",
|
||||
"tests/test_construction_pattern*.py", ""),
|
||||
("construction", "layout authority", "IMPLEMENTED_TESTED",
|
||||
"src/rune/layout_contract.py", "persistent UI layout checks",
|
||||
"tests/test_layout_contract.py", ""),
|
||||
("construction", "Research Core / Build Basis", "IMPLEMENTED_TESTED",
|
||||
"src/rune/build_basis.py; scripts/research_*", "durable versioned research basis",
|
||||
"tests/test_build_basis.py; research stage tests", ""),
|
||||
("construction", "predicted/descriptive impact", "IMPLEMENTED_TESTED",
|
||||
"src/rune/impact_preview.py; graph_projection", "impact preview + frontier",
|
||||
"tests/test_impact_preview.py; test_v1_workspace_forge.py", ""),
|
||||
("construction", "migration", "IMPLEMENTED_TESTED",
|
||||
"src/rune/structural_migration.py", "consent-gated schema migration",
|
||||
"tests/test_structural_migration.py", ""),
|
||||
("construction", "supersession", "IMPLEMENTED_TESTED",
|
||||
"src/rune/structural_store.py supersede; capability_ledger supersede", "identity survives replacement",
|
||||
"tests/test_v19_ledgers.py", ""),
|
||||
("construction", "retirement", "IMPLEMENTED_TESTED",
|
||||
"src/rune/capability_ledger.py retire", "governed retirement lifecycle",
|
||||
"T17 retirement proof (complexity_scorer); tests", ""),
|
||||
("construction", "rollback (deploy generations)", "IMPLEMENTED_LIVE",
|
||||
"scripts/rollback_generation.py", "governed rollback_generation API",
|
||||
"T21.4 live B->A->B + refusals; tests/test_rollback_generation.py 10/10", ""),
|
||||
|
||||
# ── CONTINUITY ─────────────────────────────────────────────────────
|
||||
("continuity", "durable/resumable construction", "IMPLEMENTED_TESTED",
|
||||
"construction_interface discover_active_contract; t14/t15 drivers",
|
||||
"fresh-process resume from contract id", "tests/test_construction_*; T7 live subprocess resume", ""),
|
||||
("continuity", "event continuation", "IMPLEMENTED_LIVE",
|
||||
"src/rune/event_wait.py; scripts/ci_event_wait.py", "event=wake, authority=truth, single logical await",
|
||||
"T21.5 live workflows + tests/test_single_logical_await.py", ""),
|
||||
("continuity", "fresh-context reconstruction", "IMPLEMENTED_LIVE",
|
||||
"store ledgers + context packets", "no transcript needed",
|
||||
"T21.4 fresh-worker challenge 6/6; T21.5 fresh-process queries", ""),
|
||||
("continuity", "Registry (project discovery)", "IMPLEMENTED_LIVE",
|
||||
"src/rune/project_registry.py", "discovery/index authority",
|
||||
"tests/test_resolve_cwd.py etc.; T21.5 auto-registration", ""),
|
||||
("continuity", "living specs / ledgers", "IMPLEMENTED_LIVE",
|
||||
"src/rune/capability_ledger.py (attach_spec/revise_spec/drift)", "machinery-verified versions",
|
||||
"T21.4/T21.5 live revise_spec flows; tests/test_living_specs.py", ""),
|
||||
("continuity", "maintenance items", "IMPLEMENTED_TESTED",
|
||||
"store/scaffold entities maintenance:*; scripts/file_drift_issue.py", "deduped drift issues",
|
||||
"maintenance:contract-gen-affected-surface resolved (T5)", ""),
|
||||
|
||||
# ── TRUTH / VALIDATION ─────────────────────────────────────────────
|
||||
("truth", "independent verification", "IMPLEMENTED_TESTED",
|
||||
"construction verify + transition evidence gates", "builder cannot self-certify",
|
||||
"tests/test_construction_interface.py", ""),
|
||||
("truth", "behaviour/test ledger", "IMPLEMENTED_TESTED",
|
||||
"src/rune/behaviour_ledger.py", "canonical behaviour specs",
|
||||
"tests/test_v19_ledgers.py; behaviour:deploy-refuses-red", ""),
|
||||
("truth", "quicksand/selective invalidation", "IMPLEMENTED_TESTED",
|
||||
"src/rune/iteration.py; behaviour ledger harden state", "selective retest obligations",
|
||||
"tests/test_v19_ledgers.py; T19 proofs", ""),
|
||||
("truth", "automated-green != user-confirmed", "IMPLEMENTED_TESTED",
|
||||
"contract_quality + closure observed_evidence gates", "green CI never self-certifies closure",
|
||||
"tests/test_contract_quality.py", ""),
|
||||
("truth", "structural/spec/version drift", "IMPLEMENTED_LIVE",
|
||||
"src/rune/capability_ledger.py spec_drift_status", "deterministic revision-based drift",
|
||||
"T21.4 live drift->revise cycle; tests/test_spec_drift_deterministic.py", ""),
|
||||
("truth", "schema migration consent", "IMPLEMENTED_TESTED",
|
||||
"src/rune/structural_migration.py; project_registry.needs_migration", "18I consent sweep",
|
||||
"tests/test_structural_migration.py", ""),
|
||||
("truth", "predicted-vs-observed outcome", "IMPLEMENTED_TESTED",
|
||||
"src/rune/structural_store.py predicted_vs_observed", "recorded mismatch as evidence",
|
||||
"tests + T21.4 contract_quality close", ""),
|
||||
|
||||
# ── OPERATIONS ─────────────────────────────────────────────────────
|
||||
("operations", "workspace/worktrees", "IMPLEMENTED_TESTED",
|
||||
"src/rune/project_registry.py active_worktree precedence", "worktree > repo",
|
||||
"tests/test_resolve_cwd.py; T21.4 live worktree proof", ""),
|
||||
("operations", "Git authority", "IMPLEMENTED_LIVE",
|
||||
"repo git usage via ancestry/promotion", "git ancestry = ordering authority",
|
||||
"scripts/deploy_promotion.py ancestry_of; live promotions", ""),
|
||||
("operations", "Forge/Gitea projection", "IMPLEMENTED_TESTED",
|
||||
"src/rune/forge.py; forge_client.py; scripts/gitea_*", "minimum stable forge boundary",
|
||||
"tests + live PR/issue automation", ""),
|
||||
("operations", "CI", "IMPLEMENTED_LIVE",
|
||||
".gitea/workflows/ci.yml; scripts/ci_event_wait.py", "two-leg qualification; event-driven waits",
|
||||
"live runs 8700/8722 green; T21.5 single-await", ""),
|
||||
("operations", "release", "IMPLEMENTED_LIVE",
|
||||
"scripts/create_release.py; semver_bump.py; auto_bump_pr.py", "protected-branch bump flow",
|
||||
"releases v0.82.0-0.82.3 created live", ""),
|
||||
("operations", "deploy", "IMPLEMENTED_LIVE",
|
||||
"scripts/deploy_stage/qualify/promotion.py", "monotonic fail-closed promotion",
|
||||
"live deploys 40b3731..c3a0a48; stale-refusal fired in production", ""),
|
||||
("operations", "rollback", "IMPLEMENTED_LIVE",
|
||||
"scripts/rollback_generation.py", "governed regression API",
|
||||
"T21.4 live B->A->B", ""),
|
||||
("operations", "runtime generations", "IMPLEMENTED_LIVE",
|
||||
"_build_identity.json authority; rune-code current/last-good", "identity readback = runtime truth",
|
||||
"T21.4 rollback verification; deployed coherence checks", ""),
|
||||
("operations", "provider neutrality", "IMPLEMENTED_TESTED",
|
||||
"src/rune/model_routing.py; provider_registry.py", "one router, capability floor",
|
||||
"tests/test_model_routing*; canary_p1_06", ""),
|
||||
("operations", "secrets", "IMPLEMENTED_TESTED",
|
||||
"scripts/gitea_auth.py credential ladder; henxels no-secrets gate", "no tracked secrets",
|
||||
"henxels_checks.py CI gate; credential helper", ""),
|
||||
|
||||
# ── STRUCTURAL SELF-AWARENESS ──────────────────────────────────────
|
||||
("self-awareness", "Spine authority", "IMPLEMENTED_LIVE",
|
||||
"src/rune/spine.py", "Rune structural self-model",
|
||||
"store/spine/current.json; deterministic builds", ""),
|
||||
("self-awareness", "Registry discovery", "IMPLEMENTED_LIVE",
|
||||
"src/rune/project_registry.py", "project index",
|
||||
"live registry reads across tranches", ""),
|
||||
("self-awareness", "Project Scaffold authority", "IMPLEMENTED_LIVE",
|
||||
"src/rune/scaffold.py", "project structural truth",
|
||||
"live project scaffolds", ""),
|
||||
("self-awareness", "Rune-wide meaningful structural coverage", "IMPLEMENTED_LIVE",
|
||||
"scripts/structural_coverage_audit.py", "A/B/C/D/E classification",
|
||||
"T21.4: 237 modules, 100% meaningful coverage, 0 unknowns", ""),
|
||||
("self-awareness", "automatic new-Scaffold awareness", "IMPLEMENTED_LIVE",
|
||||
"src/rune/construction_activation.py _auto_structural_awareness", "bootstrap auto-registers",
|
||||
"T21.5 live + tests/test_t215_awareness.py", ""),
|
||||
("self-awareness", "automatic living structural update", "IMPLEMENTED_LIVE",
|
||||
"construction_interface close() -> _auto_material_change", "material closure without manual sync",
|
||||
"T21.4/T21.5 live; tests/test_t214_material_closure.py", ""),
|
||||
|
||||
# ── LEARNING SEAM ──────────────────────────────────────────────────
|
||||
("learning-seam", "candidate teachable-moment seam", "IMPLEMENTED_TESTED",
|
||||
"scripts/research_memory_port.py mark_teachable_moment_candidate", "CANDIDATE evidence via existing channels",
|
||||
"tests/test_memory_port.py", ""),
|
||||
("learning-seam", "Memory internal-experience port in INWARD", "IMPLEMENTED_TESTED",
|
||||
"scripts/research_memory_port.py; research_inward_context.build_context", "one bounded port of call",
|
||||
"tests/test_memory_port.py", ""),
|
||||
("learning-seam", "APPLICABILITY governs transfer", "IMPLEMENTED_TESTED",
|
||||
"research_memory_port.applicability_filter + existing APPLICABILITY stage", "irrelevant candidates rejected",
|
||||
"tests/test_memory_port.py::test_applicability_rejects_irrelevant_candidate", ""),
|
||||
("learning-seam", "zero-result fast path", "IMPLEMENTED_TESTED",
|
||||
"research_memory_port.memory_port_of_call", "[] -> next normal step",
|
||||
"tests/test_memory_port.py; ZERO_RESULT_EXTRA_MODEL_CALLS=0", ""),
|
||||
("learning-seam", "no competing memory authority", "IMPLEMENTED_TESTED",
|
||||
"port design (provider delegation, no storage)", "Memory stays single future authority",
|
||||
"port creates no store/db; provider contract only", ""),
|
||||
|
||||
# ── DEFERRED (non-V1, with reasons) ────────────────────────────────
|
||||
("deferred", "automatic Growth Delta pattern learning", "DEFERRED_NON_V1",
|
||||
"src/rune/growth_delta.py (existing, non-V1 scope)", "post-V1 learning loop",
|
||||
"V1 excludes Memory/Substrate/Growth Delta by tranche mandate; the "
|
||||
"T21.5 seam provides the harvest point without implementing the loop", ""),
|
||||
("deferred", "Memory V1 system itself", "DEFERRED_NON_V1",
|
||||
"(future Memory T0 archaeology)", "dedicated post-V1 phase",
|
||||
"explicitly excluded by T21.5 mandate §0; only the INWARD port is V1", ""),
|
||||
("deferred", "Substrate", "DEFERRED_NON_V1",
|
||||
"(future)", "post-V1", "explicitly excluded by tranche mandate", ""),
|
||||
("deferred", "TUI redesign", "DEFERRED_NON_V1",
|
||||
"(existing rune_tui.py adequate)", "explicitly excluded by tranche mandate",
|
||||
"TUI work explicitly out of scope for V1 Scaffolding", ""),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
manifest = {
|
||||
"tranche": "T21.5",
|
||||
"rows": [
|
||||
{"family": f, "item": i, "classification": c, "module": m,
|
||||
"authority": a, "evidence": e, "gap": g}
|
||||
for f, i, c, m, a, e, g in ROWS
|
||||
],
|
||||
}
|
||||
blocking = [r for r in manifest["rows"]
|
||||
if r["classification"] in ("PARTIAL", "ABSENT",
|
||||
"IMPLEMENTED_UNPROVEN")]
|
||||
counts: dict[str, int] = {}
|
||||
for r in manifest["rows"]:
|
||||
counts[r["classification"]] = counts.get(r["classification"], 0) + 1
|
||||
manifest["counts"] = counts
|
||||
manifest["blocking_items"] = blocking
|
||||
out = ROOT / "store" / "scaffold" / "v1_parity_manifest.json"
|
||||
out.write_text(json.dumps(manifest, indent=1))
|
||||
print(json.dumps(counts, indent=1))
|
||||
print("blocking mandatory-V1 items:", len(blocking))
|
||||
for b in blocking:
|
||||
print(" BLOCKS:", b["family"], "/", b["item"])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""wr_root_reconcile.py — T21.5 §7: governed split-brain reconciliation.
|
||||
|
||||
If canonical and legacy W/R roots both hold live event stores, this
|
||||
moves legacy-root events that are ABSENT from the canonical store into
|
||||
it (idempotent, content-keyed by filename), then reports what remains.
|
||||
Never deletes; the legacy store stays as a moved-marker record.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import wr_root_resolver as resolver # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
canonical = resolver.CANONICAL_WR_ROOT
|
||||
legacy = resolver.LEGACY_WR_ROOT
|
||||
(canonical / "events").mkdir(parents=True, exist_ok=True)
|
||||
moved = 0
|
||||
skipped = 0
|
||||
for src in sorted((legacy / "events").glob("*.json")):
|
||||
dst = canonical / "events" / src.name
|
||||
if dst.exists():
|
||||
skipped += 1
|
||||
continue
|
||||
shutil.copy2(src, dst)
|
||||
moved += 1
|
||||
report = {
|
||||
"canonical": str(canonical),
|
||||
"legacy": str(legacy),
|
||||
"events_moved_to_canonical": moved,
|
||||
"already_present_skipped": skipped,
|
||||
"note": "legacy store retained (never deleted); daemon and all "
|
||||
"consumers now resolve via wr_root_resolver",
|
||||
}
|
||||
print(json.dumps(report, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
"""wr_root_resolver.py — T21.5 §7: THE ONE wait/resume root authority.
|
||||
|
||||
T21.4 live finding: the daemon defaulted WR_ROOT to its own install dir
|
||||
while production data lived at /mnt/workdrive/wr-bridge; a restart
|
||||
without WR_ROOT exported silently opened a second event store and the
|
||||
controller saw no events (the split-brain that forced manual event
|
||||
relocation). This module is the SINGLE resolver every consumer shares:
|
||||
|
||||
daemon / event_wait / await_operation tool / notify_event_bridge
|
||||
(CI event writer) / local event writers / CLI / launcher / tests.
|
||||
|
||||
Resolution order:
|
||||
1. RUNE_WR_ROOT env (explicit override; tests use it)
|
||||
2. canonical production root (exists?)
|
||||
3. legacy home install root (exists?)
|
||||
4. canonical production root (created on demand — first-run path)
|
||||
|
||||
Split-brain policy: if BOTH canonical and legacy roots contain live
|
||||
event stores, resolution FAILS LOUDLY unless the env pins one — never
|
||||
silently operate two stores. Reconciliation is governed, not silent:
|
||||
run scripts/wr_root_reconcile.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CANONICAL_WR_ROOT = Path("/mnt/workdrive/wr-bridge")
|
||||
LEGACY_WR_ROOT = Path.home() / ".local" / "share" / "mimo-wait-resume"
|
||||
ENV_VAR = "RUNE_WR_ROOT"
|
||||
|
||||
SPLIT_BRAIN_ERROR = (
|
||||
"WR ROOT SPLIT-BRAIN: both canonical ({canonical}) and legacy "
|
||||
"({legacy}) contain live event stores. Never operate two stores "
|
||||
"silently — pin {env}=<root> explicitly, or reconcile with "
|
||||
"scripts/wr_root_reconcile.py."
|
||||
)
|
||||
|
||||
|
||||
def _has_live_store(root: Path) -> bool:
|
||||
"""A root counts as 'live' when its event store exists and holds
|
||||
events (an empty dir is just an uninitialized install)."""
|
||||
events = root / "events"
|
||||
if not events.is_dir():
|
||||
return False
|
||||
return any(events.glob("*.json"))
|
||||
|
||||
|
||||
def resolve_root(*, require_single: bool = True) -> Path:
|
||||
"""Resolve THE wait/resume data root. Raises on split-brain when
|
||||
require_single (default) and no explicit pin exists."""
|
||||
env = os.environ.get(ENV_VAR)
|
||||
if env:
|
||||
return Path(env)
|
||||
canonical_live = _has_live_store(CANONICAL_WR_ROOT)
|
||||
legacy_live = _has_live_store(LEGACY_WR_ROOT)
|
||||
if canonical_live and legacy_live and require_single:
|
||||
raise RuntimeError(SPLIT_BRAIN_ERROR.format(
|
||||
canonical=CANONICAL_WR_ROOT, legacy=LEGACY_WR_ROOT,
|
||||
env=ENV_VAR))
|
||||
if canonical_live or CANONICAL_WR_ROOT.is_dir():
|
||||
return CANONICAL_WR_ROOT
|
||||
if legacy_live or LEGACY_WR_ROOT.is_dir():
|
||||
return LEGACY_WR_ROOT
|
||||
return CANONICAL_WR_ROOT # first-run: create canonical
|
||||
|
||||
|
||||
def daemon_launch_env() -> dict[str, str]:
|
||||
"""The env a launcher must pass the daemon: WR_ROOT resolved ONCE
|
||||
from this authority (the daemon itself must not guess)."""
|
||||
return {"WR_ROOT": str(resolve_root()),
|
||||
ENV_VAR: str(resolve_root())}
|
||||
|
||||
|
||||
def split_brain_roots() -> list[Path]:
|
||||
"""Diagnostic: roots that currently hold live stores."""
|
||||
return [r for r in (CANONICAL_WR_ROOT, LEGACY_WR_ROOT)
|
||||
if _has_live_store(r)]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
root = resolve_root()
|
||||
except RuntimeError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
print(json.dumps({
|
||||
"resolved_root": str(root),
|
||||
"canonical": str(CANONICAL_WR_ROOT),
|
||||
"legacy": str(LEGACY_WR_ROOT),
|
||||
"split_brain_roots": [str(p) for p in split_brain_roots()],
|
||||
"env_pinned": bool(os.environ.get(ENV_VAR)),
|
||||
}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,210 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""audit_ledger.py — HMAC-chained tamper-evident promotion ledger.
|
||||
|
||||
Every promotion decision is recorded in a hash-chained ledger.
|
||||
Tampering with any entry breaks the chain.
|
||||
|
||||
Research basis:
|
||||
- PoEM (arXiv:2608.16032): HMAC-chained tamper-evident execution ledger
|
||||
- Autogenesis (arXiv:2604.15034): versioned interfaces, auditable lineage
|
||||
|
||||
Run under ~/.venvs/memory/bin/python3.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
LEDGER_FILE = os.path.expanduser("~/.config/opencode/memory/promotion_ledger.jsonl")
|
||||
SECRET_KEY = os.environ.get("AUDIT_LEDGER_KEY", "rune-drift-integrity-2024")
|
||||
|
||||
|
||||
def _compute_hash(prev_hash: str, entry: dict) -> str:
|
||||
"""Compute HMAC chain hash for a ledger entry."""
|
||||
payload = json.dumps(entry, sort_keys=True).encode()
|
||||
msg = (prev_hash + payload.decode()).encode()
|
||||
return hmac.new(SECRET_KEY.encode(), msg, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def append_entry(entry_type: str, entry_id: int, details: dict) -> dict:
|
||||
"""Append an entry to the promotion ledger.
|
||||
|
||||
Returns the ledger entry with chain hash.
|
||||
"""
|
||||
# Read last hash
|
||||
prev_hash = "genesis"
|
||||
if os.path.exists(LEDGER_FILE):
|
||||
with open(LEDGER_FILE, "r") as f:
|
||||
lines = f.readlines()
|
||||
if lines:
|
||||
try:
|
||||
last = json.loads(lines[-1])
|
||||
prev_hash = last.get("hash", "genesis")
|
||||
except json.JSONDecodeError:
|
||||
prev_hash = "genesis"
|
||||
|
||||
entry = {
|
||||
"type": entry_type,
|
||||
"entry_id": entry_id,
|
||||
"details": details,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"prev_hash": prev_hash,
|
||||
}
|
||||
|
||||
# Compute chain hash
|
||||
entry["hash"] = _compute_hash(prev_hash, entry)
|
||||
|
||||
# Append to ledger
|
||||
os.makedirs(os.path.dirname(LEDGER_FILE), exist_ok=True)
|
||||
with open(LEDGER_FILE, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def verify_chain() -> dict:
|
||||
"""Verify the integrity of the entire ledger chain.
|
||||
|
||||
Returns dict with verification result.
|
||||
"""
|
||||
if not os.path.exists(LEDGER_FILE):
|
||||
return {"valid": True, "entries": 0, "reason": "empty ledger"}
|
||||
|
||||
with open(LEDGER_FILE, "r") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
prev_hash = "genesis"
|
||||
for i, line in enumerate(lines):
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
return {"valid": False, "entries": i, "reason": f"invalid JSON at line {i+1}"}
|
||||
|
||||
# Verify prev_hash chain
|
||||
if entry.get("prev_hash") != prev_hash:
|
||||
return {
|
||||
"valid": False,
|
||||
"entries": i,
|
||||
"reason": f"chain broken at line {i+1}: expected prev_hash={prev_hash}, got {entry.get('prev_hash')}"
|
||||
}
|
||||
|
||||
# Verify HMAC
|
||||
stored_hash = entry.get("hash")
|
||||
computed = _compute_hash(prev_hash, {k: v for k, v in entry.items() if k != "hash"})
|
||||
if stored_hash != computed:
|
||||
return {
|
||||
"valid": False,
|
||||
"entries": i,
|
||||
"reason": f"HMAC mismatch at line {i+1}: stored={stored_hash[:16]}..., computed={computed[:16]}..."
|
||||
}
|
||||
|
||||
prev_hash = stored_hash
|
||||
|
||||
return {"valid": True, "entries": len(lines), "reason": "chain intact"}
|
||||
|
||||
|
||||
def get_entries(limit: int = 50) -> List[dict]:
|
||||
"""Get recent ledger entries."""
|
||||
if not os.path.exists(LEDGER_FILE):
|
||||
return []
|
||||
|
||||
with open(LEDGER_FILE, "r") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
entries = []
|
||||
for line in lines[-limit:]:
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return entries
|
||||
|
||||
|
||||
def log_promotion(entry_id: int, old_status: str, new_status: str, wash_count: int, reason: str) -> dict:
|
||||
"""Log a promotion decision."""
|
||||
return append_entry("promotion", entry_id, {
|
||||
"old_status": old_status,
|
||||
"new_status": new_status,
|
||||
"wash_count": wash_count,
|
||||
"reason": reason,
|
||||
})
|
||||
|
||||
|
||||
def log_gate_result(entry_id: int, verdict: str, critics: dict) -> dict:
|
||||
"""Log a pre-commit gate result."""
|
||||
return append_entry("gate", entry_id, {
|
||||
"verdict": verdict,
|
||||
"critics": critics,
|
||||
})
|
||||
|
||||
|
||||
def log_drift_alert(alert_type: str, message: str, severity: str) -> dict:
|
||||
"""Log a drift alert."""
|
||||
return append_entry("alert", 0, {
|
||||
"alert_type": alert_type,
|
||||
"message": message,
|
||||
"severity": severity,
|
||||
})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# CLI
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Promotion audit ledger")
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
|
||||
# verify
|
||||
sub.add_parser("verify", help="Verify ledger chain integrity")
|
||||
|
||||
# list [--limit N]
|
||||
p_list = sub.add_parser("list", help="Show recent ledger entries")
|
||||
p_list.add_argument("--limit", type=int, default=20)
|
||||
|
||||
# log-promotion <entry_id> <old> <new> <wash_count> <reason>
|
||||
p_log = sub.add_parser("log-promotion", help="Log a promotion")
|
||||
p_log.add_argument("entry_id", type=int)
|
||||
p_log.add_argument("old_status")
|
||||
p_log.add_argument("new_status")
|
||||
p_log.add_argument("wash_count", type=int)
|
||||
p_log.add_argument("reason")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.cmd:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
if args.cmd == "verify":
|
||||
result = verify_chain()
|
||||
print(f"Chain valid: {result['valid']}")
|
||||
print(f"Entries: {result['entries']}")
|
||||
print(f"Reason: {result['reason']}")
|
||||
|
||||
elif args.cmd == "list":
|
||||
entries = get_entries(args.limit)
|
||||
if entries:
|
||||
print(f"Recent ledger entries ({len(entries)}):")
|
||||
for e in entries:
|
||||
print(f" [{e.get('type')}] entry_id={e.get('entry_id')} @ {e.get('timestamp', '')[:19]}")
|
||||
details = e.get('details', {})
|
||||
if e.get('type') == 'promotion':
|
||||
print(f" {details.get('old_status')} → {details.get('new_status')} (wash={details.get('wash_count')})")
|
||||
elif e.get('type') == 'gate':
|
||||
print(f" verdict={details.get('verdict')}")
|
||||
else:
|
||||
print("No ledger entries.")
|
||||
|
||||
elif args.cmd == "log-promotion":
|
||||
entry = log_promotion(args.entry_id, args.old_status, args.new_status, args.wash_count, args.reason)
|
||||
print(f"Logged: {entry['hash'][:16]}...")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+88
-12
@@ -33,7 +33,7 @@ SCRIPTS_DIR = os.path.expanduser("~/.config/opencode/scripts")
|
||||
SHARED_DIR = os.path.expanduser("~/.config/opencode/shared")
|
||||
CUSTODIAN_STATE_FILE = os.path.expanduser("~/.config/opencode/memory/custodian_state.json")
|
||||
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "https://gitea.thingscouldgetdicey.online")
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "http://127.0.0.1:3000") # gitea.internal (PC); external = https://gitea.thingscouldgetdicey.online via Pi Caddy
|
||||
GITEA_TOKEN = load_gitea_token()
|
||||
REPO = "conductor/rune"
|
||||
TUI_DIR = "/mnt/workdrive/rune"
|
||||
@@ -54,6 +54,15 @@ UMBRELLA_TAGS = {
|
||||
}
|
||||
|
||||
|
||||
# Issue template directory — canonical copies live beside this script
|
||||
# (mirrored to .gitea/ISSUE_TEMPLATE/ for the web-UI submission path).
|
||||
ISSUE_TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "issue_templates")
|
||||
|
||||
# kind -> Gitea label that MUST be present on the issue
|
||||
KIND_LABEL = {"feature": "feature", "bug": "bug", "research": "research"}
|
||||
VERSION_INTENTS = ("major", "minor", "patch", "none")
|
||||
|
||||
|
||||
class Custodian:
|
||||
"""The Custodian agent for Gitea-based self-improvement."""
|
||||
|
||||
@@ -105,23 +114,90 @@ class Custodian:
|
||||
except Exception as e:
|
||||
return {"error": str(e)[:200]}
|
||||
|
||||
def create_issue(self, title: str, body: str, labels: List[str] = None) -> dict:
|
||||
"""Create a Gitea issue with plain-language description.
|
||||
def _ensure_label(self, name: str, description: str = "", color: str = "#6f42c1") -> bool:
|
||||
"""Make sure a label exists on the repo; return its id or None."""
|
||||
labels = self._gitea_api("labels")
|
||||
if isinstance(labels, list):
|
||||
for l in labels:
|
||||
if l["name"] == name:
|
||||
return l["id"]
|
||||
data = {"name": name, "description": description or name, "color": color}
|
||||
created = self._gitea_api("labels", method="POST", data=data)
|
||||
return created.get("id") if isinstance(created, dict) else None
|
||||
|
||||
Labels should be umbrella names (memory, governance, etc).
|
||||
They get converted to IDs automatically.
|
||||
def _load_issue_template(self, kind: str) -> str:
|
||||
"""Load the canonical issue template for a kind, WITHOUT the web-UI
|
||||
frontmatter (name/about/title/labels — that metadata belongs to the
|
||||
submission form, not the issue body). Empty string if absent."""
|
||||
path = os.path.join(ISSUE_TEMPLATE_DIR, f"{kind}.md")
|
||||
try:
|
||||
with open(path) as f:
|
||||
text = f.read()
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
if text.startswith("---"):
|
||||
end = text.find("\n---", 3)
|
||||
if end != -1:
|
||||
text = text[end + 4:]
|
||||
return text.lstrip("\n")
|
||||
|
||||
def create_issue(
|
||||
self,
|
||||
title: str,
|
||||
body: str,
|
||||
labels: List[str] = None,
|
||||
kind: str = "feature",
|
||||
version_intent: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Create a Gitea issue assembled from the canonical template.
|
||||
|
||||
kind: feature | bug | research — selects the template and forces the
|
||||
matching label (the research label is auto-created if missing).
|
||||
version_intent: major | minor | patch | none — pre-ticks the matching
|
||||
box in the template's Version impact section. The pipeline
|
||||
verifies the real bump at merge and flags mismatches.
|
||||
labels: umbrella names (memory, governance, ...); converted to IDs.
|
||||
|
||||
The caller's body replaces the SUBMITTED_BODY marker inside the
|
||||
template's Plain English section — plain terms first, always.
|
||||
|
||||
ORGANISATION STANDARD (one open note per idea, 2026-09-19): before
|
||||
minting a standalone issue, check whether the work belongs inside an
|
||||
existing parent. Ideas parked under a blocked/future epic are filed
|
||||
as checklist items in the parent's "Absorbed children" section, NOT
|
||||
as standalone issues — the board stays uncluttered and every idea
|
||||
has exactly one home. Mint a standalone issue only when the work is
|
||||
live, dependency-tracked, or about to start. Absorbed ideas are
|
||||
reversible: re-mint standalone when they become live.
|
||||
"""
|
||||
# Convert label names to IDs
|
||||
# 1. assemble body from template (graceful: unformatted if template missing)
|
||||
template = self._load_issue_template(kind)
|
||||
text = body.strip()
|
||||
if template and "<!-- SUBMITTED_BODY -->" in template:
|
||||
text = template.replace("<!-- SUBMITTED_BODY -->", text, 1)
|
||||
if version_intent in VERSION_INTENTS:
|
||||
text = text.replace(f"- [ ] {version_intent} ", f"- [x] {version_intent} ", 1)
|
||||
|
||||
# 2. labels: umbrella names + mandatory kind label
|
||||
labels = list(labels or [])
|
||||
kind_label = KIND_LABEL.get(kind)
|
||||
if kind_label and kind_label not in labels:
|
||||
labels.append(kind_label)
|
||||
|
||||
all_labels = self._gitea_api("labels")
|
||||
label_map = {l["name"]: l["id"] for l in all_labels} if isinstance(all_labels, list) else {}
|
||||
label_ids = []
|
||||
if labels:
|
||||
all_labels = self._gitea_api("labels")
|
||||
if isinstance(all_labels, list):
|
||||
label_map = {l["name"]: l["id"] for l in all_labels}
|
||||
label_ids = [label_map[l] for l in labels if l in label_map]
|
||||
for l in labels:
|
||||
if l in label_map:
|
||||
label_ids.append(label_map[l])
|
||||
else:
|
||||
lid = self._ensure_label(l)
|
||||
if lid:
|
||||
label_ids.append(lid)
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"body": text,
|
||||
"labels": label_ids,
|
||||
}
|
||||
result = self._gitea_api("issues", method="POST", data=data)
|
||||
|
||||
@@ -352,16 +352,6 @@ def pre_commit_gate_entry(entry: dict) -> dict:
|
||||
"critics": critics,
|
||||
"reason": "all critics passed" if all_pass else "one or more critics failed"
|
||||
}
|
||||
row = db.execute(
|
||||
"SELECT id, text, importance, wash_count, rock_status, topic FROM entries WHERE id = ?",
|
||||
(entry_id,)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return {"verdict": "REJECT", "reason": "entry not found"}
|
||||
|
||||
entry = dict(row)
|
||||
critics = {}
|
||||
|
||||
# Critic 1: Structural validity
|
||||
text = entry.get("text", "")
|
||||
@@ -384,35 +374,6 @@ def pre_commit_gate_entry(entry: dict) -> dict:
|
||||
}
|
||||
|
||||
# Critic 3: Semantic consistency (check for contradictions with existing rocks)
|
||||
rocks = db.execute(
|
||||
"SELECT text FROM entries WHERE rock_status = 'rock' AND status = 'active' LIMIT 50"
|
||||
).fetchall()
|
||||
contradiction_found = False
|
||||
for rock in rocks:
|
||||
rock_text = rock[0].lower()
|
||||
entry_text = text.lower()
|
||||
# Simple contradiction detection: if entry says opposite of a rock
|
||||
if ("always" in rock_text and "never" in entry_text) or \
|
||||
("never" in rock_text and "always" in entry_text):
|
||||
contradiction_found = True
|
||||
break
|
||||
critics["semantic"] = {
|
||||
"pass": not contradiction_found,
|
||||
"reason": "contradicts existing rock" if contradiction_found else "consistent"
|
||||
}
|
||||
|
||||
# Overall verdict
|
||||
all_pass = all(c["pass"] for c in critics.values())
|
||||
verdict = "PASS" if all_pass else "REJECT"
|
||||
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"entry_id": entry_id,
|
||||
"critics": critics,
|
||||
"reason": "all critics passed" if all_pass else "one or more critics failed"
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# CLI
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: Bug Fix
|
||||
about: Demonstrate currently incorrect behaviour — plain English first, proof required
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
<restate the issue title here in everyday words — this heading IS the
|
||||
plain-English summary; no meta-labels like "plain English" in it>
|
||||
<!-- The heading above carries the summary itself — a scan of issue
|
||||
headings alone should tell the whole story of the board. TOP PRIORITY:
|
||||
if a technical detail below contradicts this section, the issue is
|
||||
buggy — fix the issue. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
|
||||
What is broken: <what a person observes going wrong>
|
||||
|
||||
What should happen instead: <the correct behaviour>
|
||||
|
||||
How often / when: <always | after X | since change Y>
|
||||
|
||||
## Proof it is broken (required — no proof, no bug)
|
||||
<!-- A bug is DEMONSTRATED currently incorrect behaviour. Nothing else.
|
||||
If this section cannot be filled, file it as a question, not a bug. -->
|
||||
- [ ] Reproduction command or steps (deterministic where possible)
|
||||
- [ ] The exact wrong output observed: <paste/log excerpt>
|
||||
- [ ] Where the evidence lives: <log path, fixture, session id>
|
||||
|
||||
## Steps to reproduce
|
||||
<!-- How to trigger the bug -->
|
||||
|
||||
## What would let us close this
|
||||
- [ ] The reproduction command now shows correct behaviour
|
||||
- [ ] A regression test exists that fails before the fix and passes after
|
||||
- [ ] No other behaviour changed (or changes are listed and justified)
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
<!-- A bug fix is USUALLY a patch. Pick major or minor only with a reason. -->
|
||||
- [ ] patch — same behaviour, done correctly (usual for bugs)
|
||||
- [ ] major — the bug was in a published contract and fixing it breaks consumers (justify)
|
||||
- [ ] minor — fixing revealed a missing capability that had to be added (justify)
|
||||
|
||||
## Technical details
|
||||
<!-- WHAT (the bug) / WHERE (files) / EVIDENCE (how it was confirmed). -->
|
||||
|
||||
**WHAT (bug):**
|
||||
**WHERE:**
|
||||
**EVIDENCE:**
|
||||
|
||||
## Pipeline requirements
|
||||
<!-- Every fix must clear these before the issue closes. -->
|
||||
- [ ] TDD: regression test written first, red → green
|
||||
- [ ] `ruff check` and `ruff format --check` clean
|
||||
- [ ] Spec-compliance review gate: PASS
|
||||
- [ ] Code-quality review gate: Approved
|
||||
- [ ] Full orchestration suite regression green (suite total)
|
||||
- [ ] Canary passes (name)
|
||||
- [ ] Closed with a properly formatted closing comment + commit references
|
||||
|
||||
## Cognitive invariants (memory/cognition issues — both handoffs)
|
||||
<!-- Complete ONLY if this issue touches memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before creating modules)
|
||||
- [ ] **Trigger** — what event activates it
|
||||
- [ ] **Read/write authority** — which canonical state it reads/changes
|
||||
- [ ] **Provenance** — derived state traceable to evidence
|
||||
- [ ] **Verification** — how we know the change helped
|
||||
- [ ] **Failure visibility** — how failure surfaces
|
||||
- [ ] **Reversibility** — bad learned state can be superseded/replayed/rolled back
|
||||
- [ ] **Dormant cost** — what it costs when irrelevant
|
||||
- [ ] **Benchmark + ablation** — what measurable behaviour improves; what happens when disabled
|
||||
- [ ] **No duplicate authority** — why extend vs create
|
||||
|
||||
Hard invariants: derived cognition preserves evidentiary ancestry · epistemic confidence ≠ attentional utility ≠ current applicability · retrieval never increases utility · self-model-induced behaviour is not independent evidence · historical truth survives present-state change · raw evidence is never destroyed by promotion.
|
||||
|
||||
## Related umbrella
|
||||
- [ ] Memory
|
||||
- [ ] Governance
|
||||
- [ ] Identity
|
||||
- [ ] Growth
|
||||
- [ ] Perception
|
||||
- [ ] Infrastructure
|
||||
- [ ] Orchestration
|
||||
|
||||
## Priority (REQUIRED — the priority gate)
|
||||
<!-- Pick exactly one. An issue without a priority is not filed. -->
|
||||
- [ ] **P0 — critical** — breaks the spine / core correctness, or blocks other work
|
||||
- [ ] **P1 — high** — material user value or a real defect (money, correctness bugs, next-stage dependency)
|
||||
- [ ] **P2 — medium** — quality/consolidation improvement (check the fold-into mapping first)
|
||||
- [ ] **P3 — low** — measurement / benchmark / deferred policy
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: New feature or improvement — plain English first, verifiable close conditions, version intent
|
||||
title: "[FEATURE] "
|
||||
labels: feature
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
<restate the issue title here in everyday words — this heading IS the
|
||||
plain-English summary; no meta-labels like "plain English" in it>
|
||||
<!-- The heading above carries the summary itself — a scan of issue
|
||||
headings alone should tell the whole story of the board. TOP PRIORITY:
|
||||
if a technical detail below contradicts this section, the issue is
|
||||
buggy — fix the issue. A non-technical reader must be able to retell this
|
||||
section. 3-6 sentences, no jargon. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
|
||||
What is this about: <what>
|
||||
|
||||
Why do we need it: <the itch — what happens today that shouldn't, or what becomes possible>
|
||||
|
||||
Why is it still open: <"not started yet" | "part done, X remains" | "deliberately parked as a future idea">
|
||||
|
||||
## Example
|
||||
<!-- A concrete before/after in plain terms: "If X happens, the system will Y instead of Z." -->
|
||||
|
||||
## What would let us close this
|
||||
<!-- Checkable statements, verifiable by looking at something that EXISTS —
|
||||
a file, a log line, a passing test. Not "when appropriate". -->
|
||||
- [ ] <condition 1 — observable>
|
||||
- [ ] <condition 2>
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
<!-- The pipeline derives the real bump from merged conventional commits and
|
||||
compares against this declaration. A mismatch is flagged, not fatal:
|
||||
either the declaration was wrong or the work grew. Both facts matter. -->
|
||||
- [ ] major — breaking: changes a contract, schema, or interface others depend on
|
||||
- [ ] minor — new capability: something becomes possible that wasn't
|
||||
- [ ] patch — repair: same behaviour, done correctly
|
||||
- [ ] none — touches no released artifact (docs, memory, process notes)
|
||||
|
||||
## Technical details
|
||||
<!-- WHAT — one-line objective.
|
||||
WHY — design-spec reference (S#) or reason.
|
||||
FILES — affected components.
|
||||
ACCEPTANCE — frozen-test file + test count + canary name.
|
||||
DEPENDENCIES — parent epic + preceding stage/plan refs. -->
|
||||
|
||||
**WHAT:**
|
||||
**WHY:**
|
||||
**FILES:**
|
||||
**ACCEPTANCE:**
|
||||
**DEPENDENCIES:**
|
||||
|
||||
## Pipeline requirements
|
||||
<!-- Every stage must clear these before the issue closes. -->
|
||||
- [ ] TDD: frozen tests written first, red → green (N passed)
|
||||
- [ ] `ruff check` and `ruff format --check` clean
|
||||
- [ ] Spec-compliance review gate: PASS
|
||||
- [ ] Code-quality review gate: Approved
|
||||
- [ ] Full orchestration suite regression green (suite total)
|
||||
- [ ] Canary passes (name)
|
||||
- [ ] Closed with a properly formatted closing comment + commit/PR references
|
||||
|
||||
## Cognitive invariants (memory/cognition issues — both handoffs)
|
||||
<!-- Complete ONLY if this issue touches memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before creating modules)
|
||||
- [ ] **Trigger** — what event activates it
|
||||
- [ ] **Read/write authority** — which canonical state it reads/changes
|
||||
- [ ] **Provenance** — derived state traceable to evidence
|
||||
- [ ] **Verification** — how we know the change helped
|
||||
- [ ] **Failure visibility** — how failure surfaces
|
||||
- [ ] **Reversibility** — bad learned state can be superseded/replayed/rolled back
|
||||
- [ ] **Dormant cost** — what it costs when irrelevant
|
||||
- [ ] **Benchmark + ablation** — what measurable behaviour improves; what happens when disabled
|
||||
- [ ] **No duplicate authority** — why extend vs create
|
||||
|
||||
Hard invariants: derived cognition preserves evidentiary ancestry · epistemic confidence ≠ attentional utility ≠ current applicability · retrieval never increases utility · self-model-induced behaviour is not independent evidence · historical truth survives present-state change · raw evidence is never destroyed by promotion.
|
||||
|
||||
## Related umbrella
|
||||
- [ ] Memory
|
||||
- [ ] Governance
|
||||
- [ ] Identity
|
||||
- [ ] Growth
|
||||
- [ ] Perception
|
||||
- [ ] Infrastructure
|
||||
- [ ] Life (Nick's)
|
||||
- [ ] Custodian
|
||||
- [ ] Orchestration
|
||||
|
||||
## Priority (REQUIRED — the priority gate)
|
||||
<!-- Pick exactly one. An issue without a priority is not filed. -->
|
||||
- [ ] **P0 — critical** — breaks the spine / core correctness, or blocks other work
|
||||
- [ ] **P1 — high** — material user value or a real defect (money, correctness bugs, next-stage dependency)
|
||||
- [ ] **P2 — medium** — quality/consolidation improvement (check the fold-into mapping first)
|
||||
- [ ] **P3 — low** — measurement / benchmark / deferred policy
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: Research Submission
|
||||
about: Buy down uncertainty before building — question, falsifier, budget, durable findings
|
||||
title: "[RESEARCH] "
|
||||
labels: research
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
<restate the issue title here in everyday words — this heading IS the
|
||||
plain-English summary; no meta-labels like "plain English" in it>
|
||||
<!-- The heading above carries the summary itself — a scan of issue
|
||||
headings alone should tell the whole story of the board. TOP PRIORITY:
|
||||
if a technical detail below contradicts this section, the issue is
|
||||
buggy — fix the issue. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
|
||||
The question: <what we're trying to learn, one sentence>
|
||||
|
||||
Why we're asking: <what decision this research will inform — a name, an issue
|
||||
number, or "exploratory: none yet">
|
||||
|
||||
What we believe today: <the current assumption, stated so it can be proven wrong>
|
||||
|
||||
## The shape of an answer
|
||||
- [ ] What evidence would settle the question: <observation, benchmark, or spike>
|
||||
- [ ] What evidence would KILL the current assumption (the falsifier): <be specific —
|
||||
this is stated BEFORE starting so the research cannot become a search for confirmation>
|
||||
- [ ] Budget: <time/effort cap — when research must stop and report back>
|
||||
|
||||
## What would let us close this
|
||||
- [ ] Findings written to a durable document: <path, e.g. research/issue-NNN/> —
|
||||
a comment thread is not a durable finding
|
||||
- [ ] The decision the research informs is recorded: made / deferred / changed
|
||||
- [ ] If a follow-up build issue was minted: <#N or "none needed">
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
- [ ] none — research changes no released artifact (usual)
|
||||
- [ ] minor/major — only if a code spike is being merged as part of this (justify)
|
||||
|
||||
## Technical detail
|
||||
<!-- Candidate approaches, prior art in the repo, related issues, constraints,
|
||||
rebuildable-projection or authority requirements if findings touch canonical state. -->
|
||||
|
||||
## Pipeline requirements
|
||||
- [ ] Findings committed under the stated durable path
|
||||
- [ ] Every citation verified against a real source (no fabricated references)
|
||||
- [ ] Closed with a properly formatted closing comment: findings summary + decision
|
||||
|
||||
## Cognitive invariants (memory/cognition research — both handoffs)
|
||||
<!-- Complete ONLY if findings touch memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before proposing modules)
|
||||
- [ ] **Read/write authority** — which canonical state is affected
|
||||
- [ ] **Provenance** — findings traceable to evidence
|
||||
- [ ] **Reversibility** — projections remain rebuildable; no unique truth moves into a derived index
|
||||
- [ ] **No duplicate authority** — why extend vs create
|
||||
|
||||
Hard invariants: derived cognition preserves evidentiary ancestry · retrieval never increases utility · raw evidence is never destroyed by promotion.
|
||||
|
||||
## Related umbrella
|
||||
- [ ] Memory
|
||||
- [ ] Governance
|
||||
- [ ] Identity
|
||||
- [ ] Growth
|
||||
- [ ] Perception
|
||||
- [ ] Infrastructure
|
||||
- [ ] Life (Nick's)
|
||||
- [ ] Custodian
|
||||
- [ ] Orchestration
|
||||
|
||||
## Priority (REQUIRED — the priority gate)
|
||||
<!-- Pick exactly one. An issue without a priority is not filed. -->
|
||||
- [ ] **P0 — critical** — blocks a decision that blocks other work
|
||||
- [ ] **P1 — high** — informs a next-stage dependency
|
||||
- [ ] **P2 — medium** — quality/consolidation understanding
|
||||
- [ ] **P3 — low** — watchlist / deferred / exploratory
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Thin adapter for native (compiled) units — R1 §5 interop contract, Wave 0.
|
||||
|
||||
The contract under proof: compiled units are standalone binaries exposing a
|
||||
CLI boundary — argv in, JSON on stdout, non-zero exit on failure, a mandatory
|
||||
``--version`` flag. This module is the Python side: run a unit, enforce a
|
||||
timeout, capture stderr, and return a TYPED result. It never raises for unit
|
||||
failures (those are data in the result); it raises only for adapter misuse
|
||||
(missing binary, bad timeout).
|
||||
|
||||
No IPC daemon, no PyO3 — the file-based store stays the shared truth. If
|
||||
research later surfaces a materially better pattern it competes through the
|
||||
research pipeline; the CLI boundary is the default, not a cage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
PROOF_UNIT = REPO_ROOT / "interop" / "rebase-proof" / "target" / "release" / "rebase-proof"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NativeResult:
|
||||
"""Typed result of one native-unit invocation.
|
||||
|
||||
ok — the invocation succeeded (exit 0 AND not timed out); when
|
||||
the unit emitted JSON with an ``ok`` field, that field must
|
||||
not be False (plain-text output like ``--version`` has none)
|
||||
exit_code — raw process exit code
|
||||
data — parsed JSON object from stdout, or None if unparseable
|
||||
stderr — captured stderr text (may be non-empty even on success)
|
||||
duration_ms — wall-clock cost of the invocation
|
||||
timed_out — True when the unit exceeded the timeout and was killed
|
||||
events — in streaming mode: every parsed JSON object line, in
|
||||
arrival order (empty for non-streaming :func:`run_native`)
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
exit_code: int
|
||||
data: dict | None
|
||||
stderr: str
|
||||
duration_ms: int
|
||||
timed_out: bool = False
|
||||
raw_stdout: str = field(default="", repr=False)
|
||||
events: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def run_native(binary: Path, args: list[str], timeout_s: float = 10.0) -> NativeResult:
|
||||
"""Invoke a native unit and return a typed result.
|
||||
|
||||
Raises FileNotFoundError only when *binary* itself is absent (adapter
|
||||
misuse) — every unit-level failure is reported via the result, never an
|
||||
exception, so callers can branch on ``result.ok`` without try/except.
|
||||
"""
|
||||
if not binary.is_file():
|
||||
raise FileNotFoundError(f"native unit not built: {binary}")
|
||||
|
||||
started = time.monotonic()
|
||||
timed_out = False
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[str(binary), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_s,
|
||||
check=False, # exit codes are data, never exceptions
|
||||
)
|
||||
exit_code, stdout, stderr = proc.returncode, proc.stdout, proc.stderr
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
timed_out = True
|
||||
exit_code = -1
|
||||
stdout = exc.stdout or "" if isinstance(exc.stdout, str) else (exc.stdout or b"").decode(errors="replace")
|
||||
stderr = exc.stderr or "" if isinstance(exc.stderr, str) else (exc.stderr or b"").decode(errors="replace")
|
||||
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
|
||||
data: dict | None = None
|
||||
stripped = stdout.strip()
|
||||
if stripped:
|
||||
try:
|
||||
parsed = json.loads(stripped.splitlines()[-1])
|
||||
if isinstance(parsed, dict):
|
||||
data = parsed
|
||||
except json.JSONDecodeError:
|
||||
data = None
|
||||
|
||||
ok = (not timed_out) and exit_code == 0 and (data is None or data.get("ok") is not False)
|
||||
return NativeResult(
|
||||
ok=ok,
|
||||
exit_code=exit_code,
|
||||
data=data,
|
||||
stderr=stderr,
|
||||
duration_ms=duration_ms,
|
||||
timed_out=timed_out,
|
||||
raw_stdout=stdout,
|
||||
)
|
||||
|
||||
|
||||
def run_proof(args: list[str], timeout_s: float = 10.0) -> NativeResult:
|
||||
"""Run the Wave 0 proof unit with the standard contract defaults."""
|
||||
return run_native(PROOF_UNIT, args, timeout_s=timeout_s)
|
||||
|
||||
|
||||
def stream_native(
|
||||
binary: Path,
|
||||
args: list[str],
|
||||
timeout_s: float = 10.0,
|
||||
on_event=None,
|
||||
):
|
||||
"""Run a native unit in JSONL streaming mode, yielding events as they arrive.
|
||||
|
||||
The JSONL streaming contract (rubric §W7): one JSON object per stdout
|
||||
line — ``{"ok": true, "event": ...}`` progress objects along the way, and
|
||||
a terminal envelope as the FINAL line. This generator yields each parsed
|
||||
event dict the moment it arrives (so a long op surfaces partial progress
|
||||
instead of blocking until exit), then yields exactly one terminal
|
||||
:class:`NativeResult`. *on_event*, when given, is called with each parsed
|
||||
event as it arrives (the TUI wraps this in ``call_from_thread``).
|
||||
|
||||
Stream-mode ok-semantics are stricter than :func:`run_native`: ``ok``
|
||||
requires exit 0, no timeout, AND a valid terminal envelope (the final
|
||||
stdout line parsed as a JSON object whose ``ok`` is not False). Malformed
|
||||
interior lines are skipped and counted in ``malformed_lines``; malformed
|
||||
FINAL line means no valid terminal envelope, so ``ok`` is False — partial
|
||||
progress stays available in ``events``. That is the point of streaming.
|
||||
|
||||
Same failure discipline as :func:`run_native`: raises FileNotFoundError
|
||||
when *binary* is absent; every unit-level failure (bad envelope, timeout,
|
||||
non-zero exit) is data on the terminal result, never an exception.
|
||||
"""
|
||||
if on_event is not None and not callable(on_event):
|
||||
raise TypeError("on_event must be callable")
|
||||
if not binary.is_file():
|
||||
raise FileNotFoundError(f"native unit not built: {binary}")
|
||||
|
||||
started = time.monotonic()
|
||||
proc = subprocess.Popen(
|
||||
[str(binary), *args],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# stderr must drain concurrently — reading it after the stdout loop
|
||||
# deadlocks when a unit floods stderr past the pipe buffer mid-stream.
|
||||
stderr_parts: list[str] = []
|
||||
stderr_drain = threading.Thread(
|
||||
target=lambda: stderr_parts.append(proc.stderr.read()), daemon=True
|
||||
)
|
||||
stderr_drain.start()
|
||||
|
||||
killed = threading.Event()
|
||||
# The watchdog's kill and the flag are one step so an adapter-side
|
||||
# timeout is never mistaken for a unit dying by its own signal.
|
||||
watchdog = threading.Timer(timeout_s, lambda: (killed.set(), proc.kill()))
|
||||
watchdog.start()
|
||||
|
||||
events: list[dict] = []
|
||||
malformed_lines = 0
|
||||
# True only while the most recent non-empty stdout line parsed as a JSON
|
||||
# object — a malformed final line invalidates the terminal envelope.
|
||||
final_line_is_json = False
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
malformed_lines += 1
|
||||
final_line_is_json = False
|
||||
continue
|
||||
if not isinstance(parsed, dict):
|
||||
malformed_lines += 1
|
||||
final_line_is_json = False
|
||||
continue
|
||||
events.append(parsed)
|
||||
final_line_is_json = True
|
||||
if on_event is not None:
|
||||
on_event(parsed)
|
||||
yield parsed
|
||||
proc.wait()
|
||||
finally:
|
||||
watchdog.cancel()
|
||||
stderr_drain.join(timeout=5.0)
|
||||
|
||||
timed_out = killed.is_set()
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
exit_code = proc.returncode if proc.returncode is not None else -1
|
||||
if timed_out:
|
||||
exit_code = -1 # adapter-side kill, never a unit's own exit
|
||||
|
||||
terminal = events[-1] if (events and final_line_is_json) else None
|
||||
ok = (
|
||||
not timed_out
|
||||
and exit_code == 0
|
||||
and terminal is not None
|
||||
and terminal.get("ok") is not False
|
||||
)
|
||||
yield NativeResult(
|
||||
ok=ok,
|
||||
exit_code=exit_code,
|
||||
data=terminal,
|
||||
stderr=stderr_parts[0] if stderr_parts else "",
|
||||
duration_ms=duration_ms,
|
||||
timed_out=timed_out,
|
||||
events=events,
|
||||
)
|
||||
|
||||
+60
-134
@@ -1,17 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""query_memory.py — Query memory system for adaptive retrieval.
|
||||
|
||||
The system remembers what kinds of queries it gets, what retrieval
|
||||
strategy worked, and adapts its gate logic accordingly.
|
||||
AUTHORITY-SWITCHED (Phase G strangler slice 1, doctrine step 8): the
|
||||
classification core now lives in Rust (`src/rust/query_memory/src/lib.rs`,
|
||||
cdylib `libquery_memory.so`). This module is a thin ctypes-backed shim over
|
||||
`qm_loader.py` serving the FULL historical Python class API — the deployed
|
||||
in-process consumers (`memory_store.py`, `growth_simulator.py`) call
|
||||
`QueryMemory()` exactly as before and get the Rust core.
|
||||
|
||||
This prevents dead weight — memories get tested based on usage
|
||||
patterns, not just random exploration.
|
||||
Parity evidence: FFI-gap parity EXACT-EQUAL on `get_topic_stats` /
|
||||
`get_dead_weight_topics`; verification wall green (FFI smoke 41/41, parity
|
||||
harness ALL GREEN, Python canary 12/12, Rust canary 27/27); shadow comparison
|
||||
46/46 exact-equal vs the live production store.
|
||||
|
||||
Research basis:
|
||||
- IGMiRAG (arXiv:2602.07525): Intuition-guided retrieval
|
||||
- Task-Adaptive Retrieval (arXiv:2604.07863): Learning from interaction history
|
||||
- MemCon (arXiv:2607.13591): Learned policy for consolidation/forgetting
|
||||
- Agent-Native Memory (arXiv:2606.24775): Workload-adaptive routing
|
||||
The Python implementation this replaces is preserved in git history on branch
|
||||
`phase-g-strangler-retrieval-index` (pre-shim commit); per deletion discipline
|
||||
it is not kept as a silent fallback — a missing cdylib fails loudly.
|
||||
|
||||
Store resolution is unchanged: `$HOME/.config/opencode/memory/query_memory`
|
||||
(the Rust core resolves `$HOME` natively; history.jsonl / topic_stats.json
|
||||
formats are byte-parity-proven).
|
||||
|
||||
Run under ~/.venvs/memory/bin/python3.
|
||||
"""
|
||||
@@ -19,15 +27,14 @@ Run under ~/.venvs/memory/bin/python3.
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
QUERY_MEMORY_DIR = os.path.expanduser("~/.config/opencode/memory/query_memory")
|
||||
QUERY_HISTORY_FILE = os.path.join(QUERY_MEMORY_DIR, "history.jsonl")
|
||||
TOPIC_STATS_FILE = os.path.join(QUERY_MEMORY_DIR, "topic_stats.json")
|
||||
|
||||
# Topic keywords for classification
|
||||
# Topic keywords (classification table — tie-break law: insertion order is
|
||||
# part of the contract; the Rust core carries the identical table).
|
||||
TOPIC_KEYWORDS = {
|
||||
"guitar": ["guitar", "music", "teaching", "course", "lesson", "chord", "scale"],
|
||||
"coding": ["code", "python", "script", "function", "debug", "build", "deploy"],
|
||||
@@ -41,17 +48,47 @@ TOPIC_KEYWORDS = {
|
||||
"creative": ["write", "design", "create", "build", "make", "art", "music"],
|
||||
}
|
||||
|
||||
# Where the Rust core (qm_loader.py + target/release/libquery_memory.so) lives.
|
||||
_QM_CORE_DIR = os.environ.get(
|
||||
"QM_CORE_DIR", "/mnt/workdrive/rune/src/rust/query_memory"
|
||||
)
|
||||
|
||||
class QueryMemory:
|
||||
"""Query memory system for adaptive retrieval."""
|
||||
if _QM_CORE_DIR not in sys.path:
|
||||
sys.path.insert(0, _QM_CORE_DIR)
|
||||
|
||||
try:
|
||||
from qm_loader import QueryMemory as _RustQueryMemory
|
||||
except ImportError as _e: # pragma: no cover - deployment integrity failure
|
||||
raise ImportError(
|
||||
f"query_memory shim: cannot load the Rust core from {_QM_CORE_DIR!r} "
|
||||
f"({_e}). The cdylib must be built: `cargo build --release` in "
|
||||
f"{_QM_CORE_DIR}. No Python fallback exists by design."
|
||||
) from _e
|
||||
|
||||
|
||||
class QueryMemory(_RustQueryMemory):
|
||||
"""Query memory system for adaptive retrieval (Rust core, Python API).
|
||||
|
||||
API (identical to the historical Python implementation):
|
||||
* classify_query(query) -> str
|
||||
* adaptive_exploration_rate(query) -> float
|
||||
* record_query(query, strategy, success) -> None
|
||||
* get_topic_stats() -> dict (topic -> stats, insertion order)
|
||||
* get_dead_weight_topics() -> list[str]
|
||||
* get_health_report() -> dict
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Match the historical constructor: ensure the store dir exists,
|
||||
# resolve the store from $HOME (qm_new's native behaviour).
|
||||
os.makedirs(QUERY_MEMORY_DIR, exist_ok=True)
|
||||
self.history: List[dict] = self._load_history()
|
||||
self.topic_stats: Dict[str, dict] = self._load_topic_stats()
|
||||
super().__init__()
|
||||
# Historical in-memory attribute: query history loaded at construction,
|
||||
# appended by record_query. (canary check 2 reads len(qm.history).)
|
||||
self.history = self._load_history()
|
||||
|
||||
def _load_history(self) -> List[dict]:
|
||||
"""Load query history from disk."""
|
||||
def _load_history(self):
|
||||
"""Load query history from disk (historical shape: list of dicts)."""
|
||||
if not os.path.exists(QUERY_HISTORY_FILE):
|
||||
return []
|
||||
entries = []
|
||||
@@ -63,127 +100,16 @@ class QueryMemory:
|
||||
continue
|
||||
return entries
|
||||
|
||||
def _save_history(self, entry: dict):
|
||||
"""Append a query entry to history."""
|
||||
with open(QUERY_HISTORY_FILE, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
def _load_topic_stats(self) -> Dict[str, dict]:
|
||||
"""Load topic statistics from disk."""
|
||||
if os.path.exists(TOPIC_STATS_FILE):
|
||||
try:
|
||||
with open(TOPIC_STATS_FILE) as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def _save_topic_stats(self):
|
||||
"""Save topic statistics to disk."""
|
||||
with open(TOPIC_STATS_FILE, "w") as f:
|
||||
json.dump(self.topic_stats, f, indent=2)
|
||||
|
||||
def classify_query(self, query: str) -> str:
|
||||
"""Classify a query into a topic.
|
||||
|
||||
Returns the topic with the most keyword matches.
|
||||
Falls back to "general" if no strong match.
|
||||
"""
|
||||
query_lower = query.lower()
|
||||
scores = {}
|
||||
for topic, keywords in TOPIC_KEYWORDS.items():
|
||||
score = sum(1 for kw in keywords if kw in query_lower)
|
||||
if score > 0:
|
||||
scores[topic] = score
|
||||
|
||||
if not scores:
|
||||
return "general"
|
||||
|
||||
return max(scores, key=scores.get)
|
||||
|
||||
def record_query(self, query: str, strategy: str, success: bool):
|
||||
"""Record a query and its outcome.
|
||||
|
||||
Args:
|
||||
query: The query text
|
||||
strategy: "consolidated" or "full_retrieval"
|
||||
success: Whether the retrieval produced useful results
|
||||
"""
|
||||
topic = self.classify_query(query)
|
||||
entry = {
|
||||
"""Record through the Rust core, then mirror the historical in-memory append."""
|
||||
super().record_query(query, strategy, success)
|
||||
self.history.append({
|
||||
"query": query[:100],
|
||||
"topic": topic,
|
||||
"topic": self.classify_query(query),
|
||||
"strategy": strategy,
|
||||
"success": success,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
self.history.append(entry)
|
||||
self._save_history(entry)
|
||||
|
||||
# Update topic stats
|
||||
if topic not in self.topic_stats:
|
||||
self.topic_stats[topic] = {
|
||||
"total_queries": 0,
|
||||
"successful_queries": 0,
|
||||
"consolidated_hits": 0,
|
||||
"full_retrieval_hits": 0,
|
||||
"exploration_rate": 0.30, # default
|
||||
}
|
||||
|
||||
stats = self.topic_stats[topic]
|
||||
stats["total_queries"] += 1
|
||||
if success:
|
||||
stats["successful_queries"] += 1
|
||||
if strategy == "consolidated":
|
||||
stats["consolidated_hits"] += 1
|
||||
else:
|
||||
stats["full_retrieval_hits"] += 1
|
||||
|
||||
# Adaptive exploration rate: decrease for well-tested topics
|
||||
if stats["total_queries"] >= 10:
|
||||
success_rate = stats["successful_queries"] / stats["total_queries"]
|
||||
if success_rate > 0.8:
|
||||
stats["exploration_rate"] = max(0.10, stats["exploration_rate"] - 0.05)
|
||||
elif success_rate < 0.5:
|
||||
stats["exploration_rate"] = min(0.50, stats["exploration_rate"] + 0.05)
|
||||
|
||||
self._save_topic_stats()
|
||||
|
||||
def get_topic_stats(self) -> Dict[str, dict]:
|
||||
"""Get topic statistics."""
|
||||
return self.topic_stats
|
||||
|
||||
def adaptive_exploration_rate(self, query: str) -> float:
|
||||
"""Get the exploration rate for a query based on its topic.
|
||||
|
||||
Well-tested topics have lower exploration rates.
|
||||
Untested topics have higher exploration rates.
|
||||
"""
|
||||
topic = self.classify_query(query)
|
||||
if topic in self.topic_stats:
|
||||
return self.topic_stats[topic].get("exploration_rate", 0.30)
|
||||
return 0.50 # untested topics get high exploration
|
||||
|
||||
def get_dead_weight_topics(self) -> List[str]:
|
||||
"""Get topics with low proven uses (dead weight)."""
|
||||
dead = []
|
||||
for topic, stats in self.topic_stats.items():
|
||||
if stats["total_queries"] < 5:
|
||||
dead.append(topic)
|
||||
return dead
|
||||
|
||||
def get_health_report(self) -> dict:
|
||||
"""Get a health report of the query memory system."""
|
||||
total_queries = len(self.history)
|
||||
topics = len(self.topic_stats)
|
||||
dead_topics = len(self.get_dead_weight_topics())
|
||||
|
||||
return {
|
||||
"total_queries": total_queries,
|
||||
"topics_tracked": topics,
|
||||
"dead_weight_topics": dead_topics,
|
||||
"topic_stats": self.topic_stats,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module rune-entry
|
||||
|
||||
go 1.27.1
|
||||
@@ -0,0 +1,172 @@
|
||||
// Command rune is the Go first-call entry for the Rune runtime (Phase H1).
|
||||
//
|
||||
// Directive (USER-LOCKED 2026-10-04): Go owns the front end — runtime entry
|
||||
// points, the first-call surface, user-facing calls. This binary is the
|
||||
// transitional first-call shim: it replicates the EXACT contract of the
|
||||
// deployed bash launcher (~/.local/bin/rune) — same cwd, same env, same
|
||||
// handoff — and exec-replaces itself with the Python runtime so exit codes
|
||||
// and signals pass through 1:1. The Python core shrinks behind this surface
|
||||
// as the back-end ports land.
|
||||
//
|
||||
// Contract (H1 canary, observed from the deployed launcher — not invented):
|
||||
// cwd: /mnt/workdrive/rune-code/current/src (the deployed revision)
|
||||
// env: RUNE_HOME=/mnt/workdrive/rune
|
||||
// RUNE_MEMORY_DIR=/home/nick/.config/opencode/memory
|
||||
// MIMO_PROBE_ENABLED=1
|
||||
// handoff: ~/.venvs/memory/bin/python3 -m rune [args...]
|
||||
//
|
||||
// Flags consumed by the Go layer (everything else passes through):
|
||||
// --version print "rune <version>" (deployed pyproject), exit 0
|
||||
// --python-only print the resolved handoff command, exit 0
|
||||
// --help usage, exit 0
|
||||
// unknown flag usage on stderr, exit 2
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const usage = `rune — Go first-call entry (Phase H1 transitional shim)
|
||||
|
||||
usage:
|
||||
rune [args...] hand off to the Python runtime (deployed revision)
|
||||
rune serve H2 front door: /v1 surface proxying the Python core
|
||||
rune scaffold R2 construction protocol initiation (Go start / Rust core)
|
||||
rune --version print the runtime version
|
||||
rune --python-only print the resolved Python handoff command and exit
|
||||
rune --help this text
|
||||
|
||||
Environment contract (same as the deployed bash launcher):
|
||||
cwd /mnt/workdrive/rune-code/current/src
|
||||
RUNE_HOME data home (stable across code deploys)
|
||||
RUNE_MEMORY_DIR memory dir
|
||||
MIMO_PROBE_ENABLED=1
|
||||
Explicitly-set RUNE_HOME / RUNE_MEMORY_DIR in the ambient environment win.
|
||||
`
|
||||
|
||||
const (
|
||||
deployRoot = "/mnt/workdrive/rune-code"
|
||||
dataHome = "/mnt/workdrive/rune"
|
||||
defaultMemDir = "/home/nick/.config/opencode/memory"
|
||||
)
|
||||
|
||||
// deployedSrc resolves the runtime source dir: the deployed current
|
||||
// revision when it exists (the launcher's authority), else the binary's own
|
||||
// repo checkout (development runs), else the desktop default.
|
||||
func deployedSrc() string {
|
||||
current := filepath.Join(deployRoot, "current", "src")
|
||||
if st, err := os.Stat(filepath.Join(current, "rune", "__main__.py")); err == nil && !st.IsDir() {
|
||||
return current
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
for i := 0; i < 8; i++ {
|
||||
cand := filepath.Join(dir, "src")
|
||||
if st, err := os.Stat(filepath.Join(cand, "rune", "__main__.py")); err == nil && !st.IsDir() {
|
||||
return cand
|
||||
}
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
}
|
||||
return filepath.Join(dataHome, "src")
|
||||
}
|
||||
|
||||
func version(srcDir string) string {
|
||||
data, err := os.ReadFile(filepath.Join(filepath.Dir(srcDir), "pyproject.toml"))
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
const prefix = `version = "`
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if rest, ok := strings.CutPrefix(line, prefix); ok {
|
||||
if end := strings.IndexByte(rest, '"'); end > 0 {
|
||||
return rest[:end]
|
||||
}
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func pythonBin() string {
|
||||
cand := filepath.Join(os.Getenv("HOME"), ".venvs", "memory", "bin", "python3")
|
||||
if _, err := os.Stat(cand); err == nil {
|
||||
return cand
|
||||
}
|
||||
return "python3"
|
||||
}
|
||||
|
||||
func upsertEnv(env []string, key, val string) []string {
|
||||
prefix := key + "="
|
||||
for i, e := range env {
|
||||
if strings.HasPrefix(e, prefix) {
|
||||
env[i] = prefix + val
|
||||
return env
|
||||
}
|
||||
}
|
||||
return append(env, prefix+val)
|
||||
}
|
||||
|
||||
func launchEnv(base []string) []string {
|
||||
env := base
|
||||
if os.Getenv("RUNE_HOME") == "" {
|
||||
env = upsertEnv(env, "RUNE_HOME", dataHome)
|
||||
}
|
||||
if os.Getenv("RUNE_MEMORY_DIR") == "" {
|
||||
env = upsertEnv(env, "RUNE_MEMORY_DIR", defaultMemDir)
|
||||
}
|
||||
env = upsertEnv(env, "MIMO_PROBE_ENABLED", "1")
|
||||
return env
|
||||
}
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
// H2: the front-door subcommand owns the /v1 surface (no Python handoff).
|
||||
if len(args) > 0 && args[0] == "serve" {
|
||||
os.Exit(runServe(args[1:]))
|
||||
}
|
||||
// R2 Wave 1: the scaffolding initiation surface (Go starts, Rust executes).
|
||||
if len(args) > 0 && args[0] == "scaffold" {
|
||||
os.Exit(runScaffold(args[1:]))
|
||||
}
|
||||
srcDir := deployedSrc()
|
||||
for _, a := range args {
|
||||
switch a {
|
||||
case "--help", "-h":
|
||||
fmt.Print(usage)
|
||||
return
|
||||
case "--version":
|
||||
fmt.Printf("rune %s\n", version(srcDir))
|
||||
return
|
||||
case "--python-only":
|
||||
fmt.Printf("(cd %s && %s -m rune %s)\n", srcDir, pythonBin(), strings.Join(args[1:], " "))
|
||||
return
|
||||
default:
|
||||
if strings.HasPrefix(a, "-") {
|
||||
fmt.Fprintf(os.Stderr, "rune: unknown flag %q\n\n%s", a, usage)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
argv := append([]string{pythonBin(), "-m", "rune"}, args...)
|
||||
bin, err := exec.LookPath(argv[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rune: cannot find python runtime %q: %v\n", argv[0], err)
|
||||
os.Exit(127)
|
||||
}
|
||||
env := launchEnv(os.Environ())
|
||||
// chdir before exec: the launcher's cwd contract (the deployed src dir
|
||||
// makes the rune package importable for `-m rune`).
|
||||
if err := syscall.Chdir(srcDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rune: cannot enter %s: %v\n", srcDir, err)
|
||||
os.Exit(126)
|
||||
}
|
||||
if err := syscall.Exec(bin, argv, env); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rune: handoff failed: %v\n", err)
|
||||
os.Exit(126)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVersionReadsPyprojectBesideSrc(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "src", "rune"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "pyproject.toml"),
|
||||
[]byte("[project]\nversion = \"0.99.1\"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := version(filepath.Join(root, "src")); got != "0.99.1" {
|
||||
t.Fatalf("want 0.99.1, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionUnknownWhenMissing(t *testing.T) {
|
||||
if got := version("/nonexistent-src"); got != "unknown" {
|
||||
t.Fatalf("missing root -> unknown, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertEnv(t *testing.T) {
|
||||
env := []string{"A=1", "RUNE_HOME=/old"}
|
||||
got := upsertEnv(env, "RUNE_HOME", "/new")
|
||||
if got[1] != "RUNE_HOME=/new" {
|
||||
t.Fatalf("existing key replaced, got %v", got)
|
||||
}
|
||||
got = upsertEnv([]string{"A=1"}, "B", "2")
|
||||
if got[len(got)-1] != "B=2" || len(got) != 2 {
|
||||
t.Fatalf("new key appended, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchEnvContract(t *testing.T) {
|
||||
t.Setenv("RUNE_HOME", "")
|
||||
t.Setenv("RUNE_MEMORY_DIR", "")
|
||||
env := launchEnv(nil)
|
||||
joined := strings.Join(env, "\n")
|
||||
for _, want := range []string{
|
||||
"RUNE_HOME=/mnt/workdrive/rune",
|
||||
"RUNE_MEMORY_DIR=/home/nick/.config/opencode/memory",
|
||||
"MIMO_PROBE_ENABLED=1",
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("launch env missing %q\ngot:\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchEnvHonorsExplicitOverrides(t *testing.T) {
|
||||
t.Setenv("RUNE_HOME", "/tmp/custom-home")
|
||||
env := launchEnv([]string{"RUNE_HOME=/tmp/custom-home"})
|
||||
joined := strings.Join(env, "\n")
|
||||
if !strings.Contains(joined, "RUNE_HOME=/tmp/custom-home") {
|
||||
t.Fatalf("explicit RUNE_HOME must win, got:\n%s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "RUNE_HOME=/mnt/workdrive/rune") {
|
||||
t.Fatalf("default must NOT override explicit env, got:\n%s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPythonBinFallsBack(t *testing.T) {
|
||||
t.Setenv("HOME", "/nonexistent-home")
|
||||
if got := pythonBin(); got != "python3" {
|
||||
t.Fatalf("missing venv -> python3 on PATH, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployedSrcPrefersLiveCurrent(t *testing.T) {
|
||||
// The deployed current revision exists on this host (the launcher
|
||||
// contract) — the shim must resolve to it, not to a dev checkout.
|
||||
got := deployedSrc()
|
||||
if !strings.HasPrefix(got, "/mnt/workdrive/rune-code/current") &&
|
||||
!strings.Contains(got, "/src") {
|
||||
t.Fatalf("deployed current must win, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── H2 front door (serve.go) ────────────────────────────────────────────────
|
||||
|
||||
func TestServeHelpExitsZero(t *testing.T) {
|
||||
if rc := runServe([]string{"--help"}); rc != 0 {
|
||||
t.Fatalf("serve --help must exit 0, got %d", rc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeUnknownFlagExitsTwo(t *testing.T) {
|
||||
if rc := runServe([]string{"--bogus"}); rc != 2 {
|
||||
t.Fatalf("unknown serve flag must exit 2, got %d", rc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrontDoorNotFoundEnvelope(t *testing.T) {
|
||||
f := &frontDoor{core: "http://127.0.0.1:1"}
|
||||
req := httptest.NewRequest("GET", "/v1/nope", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
f.mux().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown route must 404, got %d", rec.Code)
|
||||
}
|
||||
var body map[string]map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("404 body must be the error envelope: %v", err)
|
||||
}
|
||||
if body["error"]["code"] != "not_found" {
|
||||
t.Fatalf("404 code must be not_found, got %q", body["error"]["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrontDoorCoreDownEnvelope(t *testing.T) {
|
||||
// Nothing listening on port 1 — the proxy must answer 502
|
||||
// core_unavailable, not hang and not 500.
|
||||
f := &frontDoor{core: "http://127.0.0.1:1",
|
||||
client: &http.Client{Timeout: 2 * time.Second}}
|
||||
req := httptest.NewRequest("POST", "/v1/chat/completions",
|
||||
strings.NewReader(`{"messages":[{"role":"user","content":"x"}]}`))
|
||||
rec := httptest.NewRecorder()
|
||||
f.mux().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("core down must 502, got %d", rec.Code)
|
||||
}
|
||||
var body map[string]map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("502 body must be the error envelope: %v", err)
|
||||
}
|
||||
if body["error"]["code"] != "core_unavailable" {
|
||||
t.Fatalf("502 code must be core_unavailable, got %q", body["error"]["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrontDoorProxiesByteForByte(t *testing.T) {
|
||||
// A canned upstream: the front door must pass status + body through
|
||||
// verbatim (the H2 parity law in its strongest form).
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"object":"list","data":[]}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
f := &frontDoor{core: upstream.URL, client: upstream.Client()}
|
||||
req := httptest.NewRequest("GET", "/v1/models", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
f.mux().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("proxy must pass status through, got %d", rec.Code)
|
||||
}
|
||||
if rec.Body.String() != `{"object":"list","data":[]}` {
|
||||
t.Fatalf("proxy must pass the body byte-for-byte, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── R2 Wave 1: scaffold initiation surface ──────────────────────────────────
|
||||
|
||||
func TestScaffoldHelpExitsZero(t *testing.T) {
|
||||
if rc := runScaffold([]string{"--help"}); rc != 0 {
|
||||
t.Fatalf("scaffold --help must exit 0, got %d", rc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldUnknownFlagExitsTwo(t *testing.T) {
|
||||
if rc := runScaffold([]string{"--bogus"}); rc != 2 {
|
||||
t.Fatalf("unknown scaffold flag must exit 2, got %d", rc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldCoreCallEnvelope(t *testing.T) {
|
||||
// The membrane junction with a stub binary emitting a canned envelope.
|
||||
dir := t.TempDir()
|
||||
stub := filepath.Join(dir, "scaffold_core")
|
||||
script := "#!/bin/sh\ncat >/dev/null\nprintf '%s\n' '{\"ok\":true,\"value\":{\"legal\":true}}'\n"
|
||||
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env, err := scaffoldCoreCall(stub, "check_transition",
|
||||
map[string]string{"current": "draft", "target": "approved"})
|
||||
if err != nil {
|
||||
t.Fatalf("stub junction failed: %v", err)
|
||||
}
|
||||
if env["ok"] != true {
|
||||
t.Fatalf("envelope ok must be true, got %v", env)
|
||||
}
|
||||
value, _ := env["value"].(map[string]any)
|
||||
if value["legal"] != true {
|
||||
t.Fatalf("value.legal must be true, got %v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldCoreCallFailureSurfaces(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stub := filepath.Join(dir, "scaffold_core")
|
||||
script := "#!/bin/sh\nexit 3\n"
|
||||
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := scaffoldCoreCall(stub, "check_transition", map[string]string{}); err == nil {
|
||||
t.Fatal("a failing core binary must surface an error, not a zero value")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// scaffold.go — the scaffolding initiation surface (R2 Wave 1, contract
|
||||
// BUILDER-2026-10-04T15:25:33Z-cli). Go STARTS, Rust EXECUTES: pure
|
||||
// operations (check-transition, close-quality) go straight to the
|
||||
// scaffold_core binary over stdio NDJSON (the hybrid membrane's first
|
||||
// contracted junction); store-backed operations proxy to the Python engine
|
||||
// (whose own core is now Rust-delegated) until Wave 1's later slices port
|
||||
// the contract store.
|
||||
//
|
||||
// Stdlib only. The binary path resolves from SC_RUST_BIN, else the repo
|
||||
// crate target, else PATH.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const scaffoldUsage = `rune scaffold — the construction protocol initiation surface (R2 Wave 1)
|
||||
|
||||
usage:
|
||||
rune scaffold check-transition <current> <target> legality verdict (Rust core, direct)
|
||||
rune scaffold close-quality <inputs.json> closure buckets (Rust core, direct)
|
||||
rune scaffold <op> [args...] store-backed ops proxy to the engine:
|
||||
submit-need, inspect-decision, scope,
|
||||
obligations, observe, verify, transition, close
|
||||
|
||||
Go initiates; Rust executes the deterministic core; the Python engine
|
||||
carries store-backed operations during the transition (strangler law).
|
||||
`
|
||||
|
||||
// scaffoldCoreBin resolves the scaffold_core CLI binary.
|
||||
func scaffoldCoreBin() string {
|
||||
if p := os.Getenv("SC_RUST_BIN"); p != "" {
|
||||
return p
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
for i := 0; i < 8; i++ {
|
||||
cand := filepath.Join(dir, "src", "rust", "scaffold_core", "target", "release", "scaffold_core")
|
||||
if st, err := os.Stat(cand); err == nil && !st.IsDir() {
|
||||
return cand
|
||||
}
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
}
|
||||
if p, err := exec.LookPath("scaffold_core"); err == nil {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// scaffoldCoreCall runs one pure op through the Rust binary (NDJSON stdin
|
||||
// -> envelope stdout). The envelope contract is the doc-envelope law.
|
||||
func scaffoldCoreCall(bin, op string, arg any) (map[string]any, error) {
|
||||
payload, err := json.Marshal(map[string]any{"op": op, "arg": arg})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd := exec.Command(bin)
|
||||
cmd.Stdin = bytes.NewReader(payload)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("scaffold_core %s failed: %w", op, err)
|
||||
}
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
return nil, fmt.Errorf("scaffold_core %s returned non-JSON: %w", op, err)
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// scaffoldProxy hands a store-backed op to the Python engine (the
|
||||
// transitional membrane — the engine's core is Rust-backed already).
|
||||
func scaffoldProxy(args []string) int {
|
||||
py := pythonBin()
|
||||
argv := append([]string{py, "-m", "rune.construction_interface"}, args...)
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Dir = deployedSrc()
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
env := launchEnv(os.Environ())
|
||||
cmd.Env = env
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rune scaffold: engine call failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// runScaffold implements `rune scaffold`. Returns the process exit code.
|
||||
func runScaffold(args []string) int {
|
||||
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
|
||||
fmt.Print(scaffoldUsage)
|
||||
return 0
|
||||
}
|
||||
switch args[0] {
|
||||
case "check-transition":
|
||||
if len(args) != 3 {
|
||||
fmt.Fprintln(os.Stderr, "usage: rune scaffold check-transition <current> <target>")
|
||||
return 2
|
||||
}
|
||||
bin := scaffoldCoreBin()
|
||||
if bin == "" {
|
||||
fmt.Fprintln(os.Stderr, "rune scaffold: scaffold_core binary not found (build the crate)")
|
||||
return 127
|
||||
}
|
||||
env, err := scaffoldCoreCall(bin, "check_transition",
|
||||
map[string]string{"current": args[1], "target": args[2]})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "rune scaffold:", err)
|
||||
return 1
|
||||
}
|
||||
out, _ := json.Marshal(env)
|
||||
fmt.Println(string(out))
|
||||
return 0
|
||||
case "close-quality":
|
||||
if len(args) != 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: rune scaffold close-quality <inputs.json>")
|
||||
return 2
|
||||
}
|
||||
raw, err := os.ReadFile(args[1])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "rune scaffold: cannot read inputs:", err)
|
||||
return 2
|
||||
}
|
||||
var inputs any
|
||||
if err := json.Unmarshal(raw, &inputs); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "rune scaffold: inputs are not JSON:", err)
|
||||
return 2
|
||||
}
|
||||
bin := scaffoldCoreBin()
|
||||
if bin == "" {
|
||||
fmt.Fprintln(os.Stderr, "rune scaffold: scaffold_core binary not found (build the crate)")
|
||||
return 127
|
||||
}
|
||||
env, err := scaffoldCoreCall(bin, "close_quality", inputs)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "rune scaffold:", err)
|
||||
return 1
|
||||
}
|
||||
out, _ := json.Marshal(env)
|
||||
fmt.Println(string(out))
|
||||
return 0
|
||||
default:
|
||||
if strings.HasPrefix(args[0], "-") {
|
||||
fmt.Fprintf(os.Stderr, "rune scaffold: unknown flag %q\n\n%s", args[0], scaffoldUsage)
|
||||
return 2
|
||||
}
|
||||
return scaffoldProxy(args)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// serve.go — H2 front door (T4.3, contract BUILDER-2026-10-04T08:32:43Z-cli).
|
||||
//
|
||||
// Go owns the front end (USER-LOCKED directive): this subcommand serves the
|
||||
// OpenAI-compatible /v1 surface on 127.0.0.1 and proxies to the Python core
|
||||
// sidecar (core_server.py) during the transition. Parity law: response
|
||||
// bodies pass through byte-for-byte (the canary compares against the Python
|
||||
// server modulo volatile id/created), and locally-generated errors use the
|
||||
// same envelope the core emits: {"error": {"code", "message"}}.
|
||||
//
|
||||
// Stdlib only (net/http, io) — the plan's "stdlib net/http + NDJSON" line.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const serveUsage = `rune serve — H2 front door (OpenAI-compatible /v1 surface)
|
||||
|
||||
usage:
|
||||
rune serve [--port N] [--core URL]
|
||||
|
||||
flags:
|
||||
--port N listen port on 127.0.0.1 (default 8377)
|
||||
--core URL Python core sidecar base URL (default http://127.0.0.1:8376)
|
||||
|
||||
Endpoints (identical shapes to the core — the parity battery enforces it):
|
||||
GET /v1/models
|
||||
GET /v1/status
|
||||
POST /v1/chat/completions stream=true → NDJSON chunk lines
|
||||
`
|
||||
|
||||
type frontDoor struct {
|
||||
core string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// errorEnvelope mirrors core_server._envelope_error exactly.
|
||||
func errorEnvelope(w http.ResponseWriter, status int, code, message string) {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"error": map[string]string{"code": code, "message": message},
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// proxy forwards one request to the core and streams the response back
|
||||
// verbatim — byte-parity with the Python server is the H2 contract, and a
|
||||
// pure pass-through is the strongest form of it.
|
||||
func (f *frontDoor) proxy(w http.ResponseWriter, r *http.Request) {
|
||||
var body io.Reader
|
||||
if r.Body != nil {
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
errorEnvelope(w, http.StatusBadRequest, "bad_request", "unreadable body")
|
||||
return
|
||||
}
|
||||
body = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequest(r.Method, f.core+r.URL.Path, body)
|
||||
if err != nil {
|
||||
errorEnvelope(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", r.Header.Get("Content-Type"))
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
errorEnvelope(w, http.StatusBadGateway, "core_unavailable",
|
||||
"core sidecar unreachable: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for k, vs := range resp.Header {
|
||||
if strings.EqualFold(k, "Content-Type") {
|
||||
w.Header().Set(k, vs[0])
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
// NDJSON streaming: flush per write so chunk lines reach the caller as
|
||||
// the core emits them (the framing is the contract).
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := w.Write(buf[:n]); werr != nil {
|
||||
return
|
||||
}
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *frontDoor) mux() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
handle := func(path string) {
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != path {
|
||||
errorEnvelope(w, http.StatusNotFound, "not_found",
|
||||
"no route "+r.URL.Path)
|
||||
return
|
||||
}
|
||||
f.proxy(w, r)
|
||||
})
|
||||
}
|
||||
handle("/v1/models")
|
||||
handle("/v1/status")
|
||||
handle("/v1/chat/completions")
|
||||
// H4 slice 1: the TUI endpoints (same pass-through law).
|
||||
handle("/v1/conversation")
|
||||
handle("/v1/approvals/resolve")
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
errorEnvelope(w, http.StatusNotFound, "not_found", "no route "+r.URL.Path)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// runServe implements `rune serve`. Returns the process exit code.
|
||||
func runServe(args []string) int {
|
||||
port := 8377
|
||||
core := "http://127.0.0.1:8376"
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--help", "-h":
|
||||
fmt.Print(serveUsage)
|
||||
return 0
|
||||
case "--port":
|
||||
if i+1 >= len(args) {
|
||||
fmt.Fprintln(os.Stderr, "rune serve: --port needs a value")
|
||||
return 2
|
||||
}
|
||||
i++
|
||||
if _, err := fmt.Sscanf(args[i], "%d", &port); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rune serve: bad --port %q\n", args[i])
|
||||
return 2
|
||||
}
|
||||
case "--core":
|
||||
if i+1 >= len(args) {
|
||||
fmt.Fprintln(os.Stderr, "rune serve: --core needs a value")
|
||||
return 2
|
||||
}
|
||||
i++
|
||||
core = strings.TrimSuffix(args[i], "/")
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "rune serve: unknown flag %q\n\n%s", args[i], serveUsage)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
f := &frontDoor{
|
||||
core: core,
|
||||
client: &http.Client{Timeout: 130 * time.Second},
|
||||
}
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
fmt.Printf("rune front door on %s (core %s)\n", addr, core)
|
||||
if err := http.ListenAndServe(addr, f.mux()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rune serve: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -6,7 +6,7 @@ This is the main entry point for the Rune program.
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.66.27"
|
||||
__version__ = "0.99.6"
|
||||
|
||||
# One build identity (handoff 2026-09-10 §4): the semantic version above is
|
||||
# the repo-level truth (auto-bumped at merge); the DEPLOYED revision (exact
|
||||
|
||||
Loaded 100 of 470 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user